From 1d0008d34b46644514d2c9905f78975964f595f8 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Thu, 5 Nov 2026 19:31:33 +0000 Subject: [PATCH] Trailer tool (Movie Maker + ffmpeg) and a static download page built per release --- README.md | 9 ++ tools/site/index.html | 117 +++++++++++++++ tools/site/make_site.sh | 45 ++++++ tools/trailer/make_trailer.sh | 36 +++++ tools/trailer/trailer.gd | 274 ++++++++++++++++++++++++++++++++++ 5 files changed, 481 insertions(+) create mode 100644 tools/site/index.html create mode 100755 tools/site/make_site.sh create mode 100755 tools/trailer/make_trailer.sh create mode 100644 tools/trailer/trailer.gd diff --git a/README.md b/README.md index 9a2040d..ac66156 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,15 @@ Export Templates). The version number is `application/short_version` in `Emberwild.exe --headless -- --server` (Windows). Saves and settings live in the same user folder as a copy run from source. +## Trailer and download page +```bash +tools/trailer/make_trailer.sh # build/Emberwild-trailer.mp4: recorded in-game (Movie Maker), needs ffmpeg +tools/site/make_site.sh # build/site/: index.html + poster.jpg + trailer.mp4 for the current release +``` +The page's download links point at the Gitea release named in +`build/version.json`, so rebuild it after each `tools/export.sh` release. +Gitea doesn't serve HTML pages: copy `build/site/` to any static web host. + ## Layout ``` project.godot Godot project definition diff --git a/tools/site/index.html b/tools/site/index.html new file mode 100644 index 0000000..514c406 --- /dev/null +++ b/tools/site/index.html @@ -0,0 +1,117 @@ + + + + + +Emberwild: a self-hosted co-op survival game + + + + + + + +
+

EMBERWILD

+

Survive the island. Build a home.

+
+ +
+
+ +
+
+

Download version {{VERSION}}

+
+
+

macOS

+

Apple silicon and Intel · {{MAC_SIZE}}

+ Download for Mac +

First launch: right-click the app and choose Open + (it isn't notarised by Apple yet).

+
+
+

Windows

+

64-bit · {{WIN_SIZE}}

+ Download for Windows +

Unzip, then run Emberwild.exe. If SmartScreen + warns, choose More info > Run anyway (it isn't signed yet).

+
+
+

Released {{DATE}}. {{NOTES}} The game tells you on its title + screen when a newer version is out. + All releases and checksums.

+
+ +
+

What it is

+
    +
  • Gather, craft and cook; keep warm, fed and dry.
  • +
  • Hunt deer, boar and rabbits; fish the lake.
  • +
  • Build a home from floors, walls and roofs that snap together.
  • +
  • Wolves move in, and every seventh night the moon turns red.
  • +
  • Weather that matters: storms, blizzards and hard winters.
  • +
  • A bear, a barrow, and the Old Stag at the standing stones.
  • +
  • Play alone, or host a server for friends on your own machine.
  • +
  • A guided tutorial with Old Tam the hermit.
  • +
+
+ +
+

Playing with friends

+

Choose Host Game on the title screen, and friends choose Join Game + with your address (UDP port 24242). Everyone needs the same version. A spare + computer can run a dedicated server from the same download: + Emberwild --headless -- --server.

+
+
+ + + + + + diff --git a/tools/site/make_site.sh b/tools/site/make_site.sh new file mode 100755 index 0000000..a780d27 --- /dev/null +++ b/tools/site/make_site.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Builds the download page into build/site/ for the release in +# build/version.json (made by tools/export.sh): +# +# tools/site/make_site.sh +# +# build/site/index.html the page, with this release's download links +# build/site/poster.jpg the trailer's title frame +# build/site/trailer.mp4 the trailer (tools/trailer/make_trailer.sh) +# +# Copy the folder to any static web host. Gitea itself won't serve HTML, so +# the page lives elsewhere and links to the files on the Gitea release. +# Rebuild it after each release (the links name the version). + +cd "$(dirname "$0")/../.." || exit 1 +REPO=${REPO:-https://git.psbailey.uk/Admin/Emberwild} +[ -f build/version.json ] || { echo "no build/version.json: run tools/export.sh first"; exit 1; } +[ -f build/Emberwild-trailer.mp4 ] || { echo "no trailer: run tools/trailer/make_trailer.sh first"; exit 1; } + +rm -rf build/site && mkdir -p build/site +cp build/Emberwild-trailer.mp4 build/site/trailer.mp4 +cp build/trailer/poster.jpg build/site/poster.jpg +python3 - "$REPO" <<'EOF' +import html, json, sys +repo = sys.argv[1].rstrip("/") +v = json.load(open("build/version.json")) +tag = "v" + v["version"] +def dl(plat): + d = v["downloads"].get(plat) + if not d: + return repo + "/releases", "see all releases" + return "%s/releases/download/%s/%s" % (repo, tag, d["file"]), "%.0f MB" % (d["size"] / 1e6) +mac_url, mac_size = dl("macos") +win_url, win_size = dl("windows") +page = open("tools/site/index.html").read() +for k, val in { + "VERSION": v["version"], "DATE": v.get("date", ""), "NOTES": v.get("notes", ""), + "MAC_URL": mac_url, "MAC_SIZE": mac_size, "WIN_URL": win_url, "WIN_SIZE": win_size, + "RELEASES_URL": repo + "/releases", +}.items(): + page = page.replace("{{%s}}" % k, html.escape(str(val), quote=True)) +assert "{{" not in page, "a placeholder was left unfilled" +open("build/site/index.html", "w").write(page) +print("built build/site/ for %s:\n %s\n %s" % (v["version"], mac_url, win_url)) +EOF diff --git a/tools/trailer/make_trailer.sh b/tools/trailer/make_trailer.sh new file mode 100755 index 0000000..7ce4122 --- /dev/null +++ b/tools/trailer/make_trailer.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# Records the test trailer (tools/trailer/trailer.gd) with Godot's Movie +# Maker and encodes it, with a generated music pad under the game's sound: +# +# tools/trailer/make_trailer.sh # -> build/Emberwild-trailer.mp4 (+ poster.jpg) +# +# Needs ffmpeg. Takes about a minute: the shots are rendered frame by frame +# at 30 fps whatever the machine's speed, so the result never stutters. + +cd "$(dirname "$0")/../.." || exit 1 +. tools/godot_env.sh +command -v ffmpeg >/dev/null || { echo "needs ffmpeg (brew install ffmpeg)"; exit 1; } + +mkdir -p build/trailer +RAW=build/trailer/raw.avi +OUT=build/Emberwild-trailer.mp4 +rm -f "$RAW" +"$GODOT" --path . --resolution 1280x720 --fixed-fps 30 --write-movie "$RAW" \ + --script tools/trailer/trailer.gd -- --no-creatures > build/trailer/render.log 2>&1 +if [ ! -f "$RAW" ] || grep -q "SCRIPT ERROR" build/trailer/render.log; then + echo "recording failed: see build/trailer/render.log"; exit 1 +fi + +D=$(ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 "$RAW") +OUT_AT=$(echo "$D - 2.5" | bc) +# A slow A-minor-ish drone, left and right a little apart, echoed and soft. +PAD="0.06*(sin(2*PI*110*t)+0.7*sin(2*PI*164.8*t)+0.5*sin(2*PI*220*t)+0.35*sin(2*PI*261.6*t)*(0.5+0.5*sin(2*PI*0.08*t)))*(0.75+0.25*sin(2*PI*0.21*t))" +PAD_R="0.06*(sin(2*PI*110.4*t)+0.7*sin(2*PI*165.2*t)+0.5*sin(2*PI*219.6*t)+0.35*sin(2*PI*262*t)*(0.5+0.5*cos(2*PI*0.08*t)))*(0.75+0.25*cos(2*PI*0.21*t))" +ffmpeg -v error -y -i "$RAW" -f lavfi -i "aevalsrc='$PAD|$PAD_R':s=48000:d=$D" \ + -filter_complex "[1:a]lowpass=f=1800,aecho=0.8:0.7:180|340:0.35|0.25,afade=t=in:d=2.5,afade=t=out:st=$OUT_AT:d=2.5[m];[0:a]volume=0.8[g];[g][m]amix=inputs=2:duration=first:normalize=0[a]" \ + -map 0:v -map "[a]" -c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p \ + -c:a aac -b:a 160k -movflags +faststart "$OUT" || exit 1 +# The title, for the download page's poster. +ffmpeg -v error -y -ss 4.5 -i "$OUT" -frames:v 1 -q:v 3 build/trailer/poster.jpg +rm -f "$RAW" +echo "built $OUT ($(du -h "$OUT" | cut -f1 | tr -d ' '), ${D%.*} s) and build/trailer/poster.jpg" diff --git a/tools/trailer/trailer.gd b/tools/trailer/trailer.gd new file mode 100644 index 0000000..172fbbd --- /dev/null +++ b/tools/trailer/trailer.gd @@ -0,0 +1,274 @@ +extends SceneTree +## Temporary: a ~32 s test trailer, recorded with Movie Maker: +## godot --path . --fixed-fps 30 --write-movie out.avi --script tools/trailer/trailer.gd -- --no-creatures +## Six shots: dawn flyover + title, morning meadow, a hut at dusk, the wolf +## moon, a blizzard, and an end card. + +const FPS := 30.0 +const UITheme = preload("res://scripts/ui/theme.gd") + +var world: Node3D +var cam: Camera3D +var dn: Node +var black: ColorRect +var big: Label +var sub: Label +var cap: Label +var hut_c := Vector3.ZERO +var fire_p := Vector3.ZERO +var yaw := 0.0 + +func _initialize() -> void: + _run.call_deferred() + +func _h(x: float, z: float) -> float: + return world.terrain.sample_height(x, z) + +func _g(p: Vector3, up: float = 0.0) -> Vector3: + return Vector3(p.x, _h(p.x, p.z) + up, p.z) + +func _run() -> void: + var H: Node = root.get_node("Hints") + H.path = "user://trailer_hints.json" # (never your real tips) + H.load_prefs() + H.enabled = false + world = load("res://scenes/world.tscn").instantiate() + root.add_child(world) + for _i in range(8): + await process_frame + var p: CharacterBody3D = world.player + p.set_physics_process(false) + p.survival.set_process(false) + if world.hud != null: + world.hud.visible = false + if world.has_node("HUD"): + world.get_node("HUD").visible = false + if world.wolves != null: + world.wolves.enabled = false + dn = world.day_night + dn.frozen = true + world.weather.set_process(false) + world.weather.force("clear") + cam = Camera3D.new() + cam.fov = 62 + world.add_child(cam) + cam.current = true + _overlay() + await _stage() + await _shot_dawn() + await _shot_meadow() + await _shot_hut() + await _shot_wolves() + await _shot_blizzard() + await _shot_end() + DirAccess.remove_absolute(H.path) + quit(0) + +# --- set dressing -------------------------------------------------------------------- + +func _stage() -> void: + var spawn: Vector3 = world.player.global_position + var site: Vector3 = world.build.debug_find_site(spawn + Vector3(10, 0, 6), 3.0) + yaw = 0.6 + var hut: Dictionary = await world.build.debug_build_hut(site, yaw) + hut_c = site + if hut.has("pieces") and not hut.pieces.is_empty(): + hut_c = hut.pieces[0].global_transform * Vector3(1, 0, 1) + var b := Basis(Vector3.UP, yaw) + fire_p = _g(hut_c + b * Vector3(0, 0, 5.5)) + var fire: Node3D = world.spawn_piece(ItemDBRef().get_item("campfire"), Transform3D(Basis(), fire_p)) + fire.fuel = fire.MAX_FUEL + for _i in range(5): + await physics_frame + +func ItemDBRef() -> Node: + return root.get_node("ItemDB") + +func _park_player(at: Vector3) -> void: + world.player.global_position = at # (the weather particles follow the player) + +# --- overlay ------------------------------------------------------------------------- + +func _overlay() -> void: + var ui := CanvasLayer.new() + ui.layer = 50 + root.add_child(ui) + var r := Control.new() + r.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + ui.add_child(r) + big = UITheme.label("", 84) + sub = UITheme.label("", 26, Color(0.95, 0.9, 0.8)) + cap = UITheme.label("", 34) + for l in [big, sub, cap]: + l.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + l.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + l.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + l.add_theme_constant_override("outline_size", 6 if l == big else 3) + l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.55)) + l.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.5)) + l.add_theme_constant_override("shadow_offset_x", 2) + l.add_theme_constant_override("shadow_offset_y", 2) + l.modulate.a = 0.0 + r.add_child(l) + big.offset_top = -70 + # The smaller lines sit on a soft dark box, so they read over snow and sun. + var box := StyleBoxFlat.new() + box.bg_color = Color(0.04, 0.03, 0.02, 0.55) + box.set_corner_radius_all(8) + box.content_margin_left = 22 + box.content_margin_right = 22 + box.content_margin_top = 8 + box.content_margin_bottom = 10 + for l in [sub, cap]: + l.add_theme_stylebox_override("normal", box) + l.grow_horizontal = Control.GROW_DIRECTION_BOTH + l.grow_vertical = Control.GROW_DIRECTION_BOTH + sub.set_anchors_and_offsets_preset(Control.PRESET_CENTER) + sub.offset_top = 46 + sub.offset_bottom = 46 + cap.set_anchors_and_offsets_preset(Control.PRESET_CENTER_BOTTOM) + cap.grow_vertical = Control.GROW_DIRECTION_BEGIN + cap.offset_top = -70 + cap.offset_bottom = -70 + black = ColorRect.new() + black.color = Color.BLACK + black.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + r.add_child(black) + +## 0..1 ramp: in over `fin` seconds from `a`, out over `fout` ending at `b`. +static func _env(t: float, a: float, b: float, fin: float = 0.6, fout: float = 0.6) -> float: + if t < a or t > b: + return 0.0 + return clampf(minf((t - a) / fin, (b - t) / fout), 0.0, 1.0) + +## Runs `secs` of a shot: `f(t, k)` each frame (t seconds, k = 0..1); fades +## from and to black at its ends. +func _shot(secs: float, f: Callable, caption: String = "", fade: float = 0.5) -> void: + var n := int(secs * FPS) + cap.text = caption + for i in range(n): + var t := i / FPS + f.call(t, t / secs) + black.modulate.a = 1.0 - _env(t, 0.0, secs, fade, fade) + cap.modulate.a = _env(t, 0.6, secs - 0.3, 0.5, 0.5) if not caption.is_empty() else 0.0 + await process_frame + +func _clock(hours: float) -> void: + dn.time = hours / 24.0 + dn.apply(true) + +# --- the shots ------------------------------------------------------------------------- + +func _shot_dawn() -> void: + big.text = "EMBERWILD" + sub.text = "Survive the island. Build a home." + var c := Vector3(0, 0, -10) + await _shot(7.5, func(t: float, k: float): + _clock(7.4 + k * 0.8) + var a := 2.2 + k * 0.55 + cam.global_position = c + Vector3(cos(a), 0, sin(a)) * 115.0 + Vector3(0, 58.0 - k * 10.0, 0) + cam.look_at(c + Vector3(0, 6, 0)) + _park_player(cam.global_position) + big.modulate.a = _env(t, 1.4, 6.6, 1.0, 0.8) + sub.modulate.a = _env(t, 2.4, 6.6, 1.0, 0.8), "", 0.9) + +func _shot_meadow() -> void: + var from := _g(hut_c + Vector3(-30, 0, -22), 2.2) + var to := _g(hut_c + Vector3(-12, 0, -8), 1.8) + var cre: Node = world.creatures + var herd: Array = [] + for i in range(3): + var at := _g(hut_c + Vector3(-14 + i * 2.5, 0, -2 + i * 1.5)) + var d = cre.spawn("deer" if i < 2 else "rabbit", at) + if d != null: + herd.append(d) + await _shot(5.0, func(_t: float, k: float): + _clock(9.0) + var pos := from.lerp(to, k) + pos.y = maxf(pos.y, _h(pos.x, pos.z) + 1.8) + cam.global_position = pos + cam.look_at(_g(hut_c + Vector3(-14, 0, 0), 1.0)) + _park_player(pos), "Gather. Hunt. Cook.") + for d in herd: + if is_instance_valid(d): + d.queue_free() + +func _shot_hut() -> void: + var b := Basis(Vector3.UP, yaw) + var from := _g(hut_c + b * Vector3(9, 0, 13), 3.2) + var to := _g(hut_c + b * Vector3(4, 0, 9.5), 2.0) + await _shot(6.0, func(_t: float, k: float): + _clock(18.25 + k * 0.4) + var e := k * k * (3.0 - 2.0 * k) + cam.global_position = from.lerp(to, e) + cam.look_at((hut_c + fire_p) * 0.5 + Vector3(0, 1.3, 0)) + _park_player(cam.global_position), "Build a home.") + +func _shot_wolves() -> void: + dn.day = 7 + # Three wolves, walked round the far side of the fire by hand (in the + # fire's light, in frame) rather than left to their own circling. + var pack: Array = [] + for i in range(3): + var w: Node3D = load("res://scripts/creatures/tutorial_wolf.gd").new() + w.setup("wolf", world, _g(fire_p + Vector3(7, 0, 0)), 900 + i) + world.add_child(w) + w.set_physics_process(false) + w.set_process(false) + pack.append(w) + for _i in range(5): + await physics_frame + # A warm fill from the fire's side, so the circling pack reads at night. + var fill := OmniLight3D.new() + fill.light_color = Color(1.0, 0.62, 0.35) + fill.light_energy = 2.2 + fill.omni_range = 18.0 + fill.shadow_enabled = true + world.add_child(fill) + fill.global_position = fire_p + Vector3(0, 3.5, 0) + await _shot(5.5, func(_t: float, k: float): + _clock(21.6) + var a := yaw + 2.4 + k * 0.35 + cam.global_position = _g(fire_p + Vector3(cos(a), 0, sin(a)) * 3.4, 1.7) + cam.look_at(_g(fire_p - Vector3(cos(a), 0, sin(a)) * 6.0, 0.4)) + _park_player(cam.global_position) + var far := a + PI + for i in range(pack.size()): + var wa := far - 0.75 + i * 0.6 + k * 0.45 + var r := 5.0 + i * 0.7 + var wp := _g(fire_p + Vector3(cos(wa), 0, sin(wa)) * r) + var to := _g(fire_p + Vector3(cos(wa + 0.2), 0, sin(wa + 0.2)) * r) - wp + pack[i].global_position = wp + pack[i].rotation.y = atan2(-to.x, -to.z), "Hold out through the night.") # (creatures face -Z, as creature.gd) + fill.queue_free() + for w in pack: + if is_instance_valid(w): + w.queue_free() + dn.day = 3 + +func _shot_blizzard() -> void: + world.weather.force("blizzard") + var b := Basis(Vector3.UP, yaw) + for _i in range(60): + await process_frame # (let the snow build up) + await _shot(4.5, func(_t: float, k: float): + _clock(13.0) + var a := yaw - 0.4 - k * 0.25 + cam.global_position = _g(hut_c + Vector3(cos(a), 0, sin(a)) * 14.0, 3.0) + cam.look_at(hut_c + Vector3(0, 1.5, 0)) + _park_player(cam.global_position), "Weather the winter.") + world.weather.force("clear") + if world.weather_fx != null: + world.weather_fx.visible = false # (no leftover snow on the end card) + +func _shot_end() -> void: + big.text = "EMBERWILD" + sub.text = "Self-hosted co-op survival · 0.1.0" + var b := Basis(Vector3.UP, yaw) + await _shot(6.0, func(t: float, k: float): + _clock(17.6 + k * 0.4) + cam.global_position = _g(hut_c + b * Vector3(10, 0, 9), 0.0) + Vector3(0, 6.0 + k * 10.0, 0) + b * Vector3(0, 0, k * 10.0) + cam.look_at(hut_c + Vector3(0, 1.0, 0)) + _park_player(cam.global_position) + big.modulate.a = _env(t, 0.8, 5.6, 0.8, 0.6) + sub.modulate.a = _env(t, 1.6, 5.6, 0.8, 0.6), "", 0.7)