3 Commits

Author SHA1 Message Date
Paul Bailey
5778d61763 Version 0.2.1 2026-11-06 00:12:53 +00:00
Paul Bailey
787d8d1d65 Old Tam speaks (Kokoro); CC0 birdsong, crickets and owls; no plants through floors; menu and marker fixes 2026-11-06 00:03:59 +00:00
Paul Bailey
0550a4daa0 tools/publish_release.sh: create the Gitea release, upload and verify 2026-11-05 21:06:33 +00:00
72 changed files with 542 additions and 92 deletions

3
.gitignore vendored
View File

@@ -21,3 +21,6 @@ Thumbs.db
# Cloudflare wrangler cache (tools/site deploys)
.wrangler/
# Kokoro TTS environment (tools/voice/make_voice.sh sets it up)
tools/voice/.venv/

View File

@@ -19,9 +19,36 @@ http://creativecommons.org/publicdomain/zero/1.0/". Thank you, Kenney.
Which file is which is listed in `KENNEY` in `tools/audio/make_audio.py`,
which also re-copies them (`--kenney <folder with the unzipped packs>`).
## SamsterBirdies (CC0)
`ambience/birds.ogg` is from "Bird ambience. Coniferous forest dawn chorus."
by SamsterBirdies (https://freesound.org/people/SamsterBirdies/sounds/745273/,
License: Creative Commons 0): its first 90 s (kept in
`tools/audio/sources/birds_samsterbirdies_745273.ogg`), softened a little and
made into a seamless loop by `tools/audio/make_audio.py birds`. Thank you.
## OwlStorm (CC0)
`ambience/crickets.ogg` is "Night Crickets Ambience on Rural Property" by
OwlStorm (https://freesound.org/people/OwlStorm/sounds/320145/, License:
Creative Commons 0; the preview is in `tools/audio/sources/`), with the low
rumble taken out and made into a seamless loop by
`tools/audio/make_audio.py crickets`. Thank you.
## Breviceps (CC0)
`ambience/owl_0.ogg` and `owl_1.ogg` are the two hoots in "Owl Hoot" by
Breviceps (https://freesound.org/people/Breviceps/sounds/465697/, License:
Creative Commons 0; the preview is in `tools/audio/sources/`), cut at 0.12 s
and 2.65 s (leaving out the short sound between them), then levelled. Thank you.
## Old Tam's voice
`voice/` is spoken by **Kokoro** (Kokoro-82M by hexgrad, Apache-2.0,
https://huggingface.co/hexgrad/Kokoro-82M) with its British "George" voice,
made a little older (deeper, slower, warmer) by `tools/voice/make_voice.py`.
The recordings are generated from our own text; rebuild them with
`tools/voice/make_voice.sh` after changing any line.
## Emberwild's own (CC0)
`music/`, `ambience/` and `sfx/synth/` are synthesised from scratch by
`tools/audio/make_audio.py` (no samples): the five music tracks (calm,
night, storm, danger, boss), the birds, crickets and fire crackle, and the
night, storm, danger, boss), the fire crackle, the creature voices, and the
swings, eating, splashes, the fishing reel and bite, the bow, the hurt thump
and death. Released under CC0 like the rest of this folder.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,41 @@
{
"hint_cold": "91c5088e453fb0e6",
"hint_died": "e865f8a63622b3c3",
"hint_diet": "3178b319c44e9fa0",
"hint_freezing": "48b1e505119a0fc9",
"hint_hard_winter": "5513aca1655bc278",
"hint_howl": "05792dda3f3f0b60",
"hint_hungry": "ad76f22cb8e034a0",
"hint_night": "2a07d9c0bfae4bf9",
"hint_rotten": "98b69de03dbee089",
"hint_sheltered": "ba6d6ff8bc4a1c80",
"hint_sick": "ec07963c46638ffc",
"hint_snow": "8f0ac7d093f73840",
"hint_stamina": "82bf5b903db7a29f",
"hint_storm": "f06bb82d15d991c8",
"hint_wet": "6aef86b85d67d7ac",
"hint_wolf_moon": "723b3dbd5439980c",
"tutorial_animals": "e82eb7e35e866812",
"tutorial_axe": "4bb51d6ea8b42840",
"tutorial_bed": "34cf4c842db48c19",
"tutorial_berries": "a7cd3805f11c8898",
"tutorial_campfire": "0a26bd897d1d8a90",
"tutorial_cook": "7164a8ab27a7821d",
"tutorial_diet": "fd47408ab4f2b5de",
"tutorial_finish": "36c07fc5a64c9d65",
"tutorial_fish": "32f2f3228c036136",
"tutorial_flax": "a87ff75ed8864ca6",
"tutorial_hut": "a10bfa289939dfb2",
"tutorial_look": "27acdf33905cc1a3",
"tutorial_mushroom": "79edcfb1afe18c8c",
"tutorial_pickaxe": "177e0474e4d0fda6",
"tutorial_rabbit": "6d429ab2cbdc7b19",
"tutorial_rod": "d610281e722e8fd3",
"tutorial_sleep": "e202cbf133500c2a",
"tutorial_sticks": "e526e30ddf8756a7",
"tutorial_torch": "68808fca608352fc",
"tutorial_tree": "1dfb2ab0491d962f",
"tutorial_weapon": "a17d1fe6d5067b8d",
"tutorial_wolf": "feb6bdda64e28161",
"tutorial_workbench": "1b6108ab18f05de9"
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -41,7 +41,7 @@
"title": "Gather sticks and stones",
"text": "Empty hands are no use here. Look at a stick or a stone on the ground and press E. Three of each, mind.",
"marker": {
"nearest": "pickup:stick"
"nearest": "pickup:stick|stone"
},
"goal": {
"all": [

View File

@@ -29,8 +29,8 @@ application/icon_interpolation=4
application/bundle_identifier="com.pbailey.emberwild"
application/signature=""
application/app_category="Games"
application/short_version="0.2.0"
application/version="0.2.0"
application/short_version="0.2.1"
application/version="0.2.1"
application/copyright=""
display/high_res=true
codesign/codesign=1
@@ -89,8 +89,8 @@ application/modify_resources=false
application/icon=""
application/console_wrapper_icon=""
application/icon_interpolation=4
application/file_version="0.2.0"
application/product_version="0.2.0"
application/file_version="0.2.1"
application/product_version="0.2.1"
application/company_name=""
application/product_name="Emberwild"
application/file_description="Emberwild"

View File

@@ -19,7 +19,7 @@ config/description="Low-poly survival game — Valheim look, Rust depth, self-ho
run/main_scene="res://scenes/title.tscn"
config/features=PackedStringArray("4.7", "Forward Plus")
config/icon="res://icon.svg"
config/version="0.2.0"
config/version="0.2.1"
[autoload]
@@ -37,6 +37,7 @@ CreatureNet="*res://scripts/net/creature_net.gd"
Chat="*res://scripts/net/chat.gd"
Sfx="*res://scripts/core/sfx.gd"
Music="*res://scripts/core/music.gd"
Narrator="*res://scripts/core/narrator.gd"
[display]

View File

@@ -121,6 +121,7 @@ func start(w: Node3D, from_saved: Dictionary = {}) -> void:
func stop() -> void:
_clear_outline()
Narrator.stop()
active = false
world = null
@@ -139,9 +140,11 @@ func chapter_title(step: Dictionary) -> String:
func _enter() -> void:
if index >= steps.size():
finished.emit()
Narrator.say("tutorial_finish", String(finish.get("text", "")))
return
_setup(steps[index].get("setup", {}))
step_started.emit(index, steps[index])
Narrator.say("tutorial_" + String(steps[index].id), String(steps[index].text))
_marker_t = 0.0
_check() # (done early? it ticks straight away)
@@ -346,6 +349,14 @@ func marker_position() -> Vector3:
## Where the current step's marker goes: the nearest thing it asks for, or
## INF (no marker, or you've got what it was for).
## Whether the part of `goal` about `kind`:`id` (e.g. gained: stone) is done.
func _part_met(goal: Dictionary, kind: String, id: String) -> bool:
var parts: Array = goal.get("all", [goal])
for p in parts:
if p is Dictionary and String(p.get(kind, "")) == id:
return _met(p)
return false
func find_marker() -> Vector3:
var s := current()
if s.is_empty() or not s.has("marker") or world == null:
@@ -360,9 +371,14 @@ func find_marker() -> Vector3:
var best_d := INF
var cands: Array = []
if what.begins_with("pickup:"):
var id := what.substr(7)
# "pickup:stick|stone": the nearest of whichever you still need (the
# step's "gained" goal for that item isn't met yet).
var ids: Array = []
for id in what.substr(7).split("|", false):
if not _part_met(s.get("goal", {}), "gained", id):
ids.append(id)
for pk in world.props.pickups.values():
if is_instance_valid(pk) and not pk.taken and pk.item_id == id:
if is_instance_valid(pk) and not pk.taken and pk.item_id in ids:
cands.append(pk)
elif what in ["tree", "rock"]:
for n in world.props.resource_nodes.values():

View File

@@ -103,6 +103,7 @@ func _show(h: Dictionary) -> void:
save_prefs()
_cooldown = GAP
shown.emit(h)
Narrator.say("hint_" + String(h.id), String(h.text))
func _met(when: String) -> bool:
var player: Node = world.player

View File

@@ -42,10 +42,16 @@ var _b: AudioStreamPlayer
var _cur: AudioStreamPlayer
var _birds: AudioStreamPlayer
var _crickets: AudioStreamPlayer
var _owl: AudioStreamPlayer3D
var _owl_left := 20.0
var _owl_calls: Array = [] ## a few different calls, so it doesn't repeat itself
var owls := 0 ## hoots so far (tests)
const OWL_EVERY := [35.0, 110.0]
var _tracks: Dictionary = {}
var _rng := RandomNumberGenerator.new()
var _quiet := false
var _fading := 0 ## crossfades in progress
var duck := false ## Old Tam is talking (narrator.gd): the music dips
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS # (the pause menu stops the game, not the music)
@@ -58,6 +64,15 @@ func _ready() -> void:
_cur = _a
_birds = _player("Ambience", _load(AMB + "birds.ogg", true))
_crickets = _player("Ambience", _load(AMB + "crickets.ogg", true))
_owl = AudioStreamPlayer3D.new()
_owl.bus = "Ambience"
for i in range(2):
var call: Variant = _load(AMB + "owl_%d.ogg" % i, false)
if call is AudioStream:
_owl_calls.append(call)
_owl.unit_size = 30.0 # (far off in the trees, but clearly heard)
_owl.max_distance = 120.0
add_child(_owl)
for p in [_a, _b]:
p.finished.connect(_on_finished.bind(p))
@@ -204,7 +219,7 @@ func _indoors() -> bool:
return sv != null and bool(sv.get("sheltered"))
func _target_db() -> float:
return -6.0 if _indoors() else -2.0
return (-6.0 if _indoors() else -2.0) - (7.0 if duck else 0.0)
func _ambience(dt: float) -> void:
if _quiet or _birds == null:
@@ -218,10 +233,36 @@ func _ambience(dt: float) -> void:
wet = 1.0 if s in ["rain", "storm", "blizzard"] else 0.0
snow = s in ["snow", "blizzard"]
var inside := 0.35 if _indoors() else 1.0
if world == null or not is_instance_valid(world) or world.get("menu_mode"):
inside = 0.0 # (the title screen: music only, no birds or crickets)
var birds := clampf((day - 0.35) / 0.4, 0.0, 1.0) * (1.0 - wet) * inside
var crickets := clampf((0.45 - day) / 0.3, 0.0, 1.0) * (0.0 if snow else 1.0) * (1.0 - wet * 0.8) * inside
_owl_step(dt, crickets > 0.5, inside)
var k := clampf(dt * 0.8, 0.0, 1.0)
_birds.volume_db = lerpf(_birds.volume_db, linear_to_db(maxf(birds * 0.8, 0.0001)), k)
_crickets.volume_db = lerpf(_crickets.volume_db, linear_to_db(maxf(crickets * 0.7, 0.0001)), k)
_birds.volume_db = lerpf(_birds.volume_db, linear_to_db(maxf(birds * 0.28, 0.0001)), k) # (in the background: the recording is full-scale)
_crickets.volume_db = lerpf(_crickets.volume_db, linear_to_db(maxf(crickets * 0.4, 0.0001)), k) # (a recording, full-scale)
if _cur != null and _cur.playing and _fading == 0:
_cur.volume_db = lerpf(_cur.volume_db, _target_db(), k)
## Now and then at night (when the crickets are out: dark, dry, no snow), an
## owl hoots from somewhere 25-45 m off in a random direction. Only you hear
## your owl; online, everyone's night has its own.
func _owl_step(dt: float, night_out: bool, inside: float) -> void:
if _owl == null or _owl_calls.is_empty() or not night_out:
_owl_left = maxf(_owl_left, 8.0)
return
_owl_left -= dt
if _owl_left > 0.0 or _owl.playing:
return
_owl_left = _rng.randf_range(OWL_EVERY[0], OWL_EVERY[1])
var me: Node3D = world.get("player") if world != null else null
if me == null:
return
var a := _rng.randf() * TAU
var r := _rng.randf_range(25.0, 45.0)
_owl.global_position = me.global_position + Vector3(cos(a) * r, _rng.randf_range(6.0, 12.0), sin(a) * r)
_owl.stream = _owl_calls[_rng.randi() % _owl_calls.size()]
_owl.pitch_scale = _rng.randf_range(0.96, 1.04)
_owl.volume_db = -8.0 + linear_to_db(maxf(inside, 0.05))
_owl.play()
owls += 1

66
scripts/core/narrator.gd Normal file
View File

@@ -0,0 +1,66 @@
extends Node
## Old Tam's voice: the recorded line for a tutorial step, his goodbye or one
## of his tips (assets/audio/voice, made by tools/voice/make_voice.sh). A line
## only plays if it was made from exactly the text on screen (lines.json), so
## after an edit it stays quiet rather than saying the old words. A new line
## cuts off the one before; the music dips while he talks. The Voice volume
## (pause menu) turns him down or off. It pauses with the game.
const DIR := "res://assets/audio/voice/"
var lines: Dictionary = {} ## line id -> the key of the text it was made from
var spoken: Array = [] ## ids said (tests; headless nothing is heard)
var speaking := ""
var _p: AudioStreamPlayer
var _quiet := false
func _ready() -> void:
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(DIR + "lines.json")) \
if FileAccess.file_exists(DIR + "lines.json") else null
if d is Dictionary:
lines = d
_quiet = DisplayServer.get_name() == "headless"
if _quiet:
return
_p = AudioStreamPlayer.new()
_p.bus = "Voice"
add_child(_p)
_p.finished.connect(_done)
## The same key make_voice.py writes: the first 16 hex digits of the text's SHA-1.
static func text_key(text: String) -> String:
return text.sha1_text().substr(0, 16)
## Whether there's a recording of `text` for `id`.
func has_line(id: String, text: String) -> bool:
return String(lines.get(id, "")) == text_key(text)
## Says line `id` if it's the recording of `text`. True if it's being said.
func say(id: String, text: String) -> bool:
if not has_line(id, text):
return false
spoken.append(id)
speaking = id
if _quiet:
return true
var st: Variant = load(DIR + id + ".ogg")
if not (st is AudioStream):
speaking = ""
return false
_p.stop()
_p.stream = st
_p.play()
Music.duck = true
return true
func stop() -> void:
if _p != null:
_p.stop()
_done()
func is_speaking() -> bool:
return _p != null and _p.playing
func _done() -> void:
speaking = ""
Music.duck = false

View File

@@ -88,8 +88,8 @@ const SOUNDS := {
"crow_caw": {"set": [C + "crow_caw_%d.ogg", 2], "db": -4.0, "pitch": [0.92, 1.1], "reach": 40.0},
"fox_yip": {"set": [C + "fox_yip_%d.ogg", 2], "db": -4.0, "pitch": [0.92, 1.12], "reach": 30.0},
}
const BUSES := ["Music", "SFX", "Ambience"]
const DEFAULT_VOLUMES := {"Master": 0.85, "Music": 0.6, "SFX": 0.9, "Ambience": 0.7}
const BUSES := ["Music", "SFX", "Ambience", "Voice"]
const DEFAULT_VOLUMES := {"Master": 0.85, "Music": 0.6, "SFX": 0.9, "Ambience": 0.7, "Voice": 1.0}
const POOL_3D := 20
const POOL_2D := 6

View File

@@ -81,7 +81,7 @@ func bind(p: CharacterBody3D) -> void:
_main.add_child(sens_row)
# Volumes (sfx.gd keeps them in user://audio.json).
for row in [["Music", "Music"], ["Sounds", "SFX"], ["Ambience", "Ambience"]]:
for row in [["Music", "Music"], ["Sounds", "SFX"], ["Ambience", "Ambience"], ["Old Tam's voice", "Voice"]]:
var vr := HBoxContainer.new()
var lab := UITheme.label(row[0], 16)
lab.custom_minimum_size = Vector2(150, 0)

View File

@@ -206,10 +206,12 @@ func _main_page() -> Control:
var v := VBoxContainer.new()
v.add_theme_constant_override("separation", 10)
var worlds := Saves.list_worlds()
# Continue is for your own worlds: the tutorial has its own button below.
var own := worlds.filter(func(w): return not bool(w.get("tutorial", false)))
if worlds.is_empty():
v.add_child(UITheme.label("New here? Try the tutorial (about 20 minutes).", 15, UITheme.TEXT_DIM))
else:
var w: Dictionary = worlds[0]
elif not own.is_empty():
var w: Dictionary = own[0]
v.add_child(_button("Continue: %s" % w.name, _play.bind(w.id)))
var tut := tutorial_world()
v.add_child(_button("Tutorial" if tut.is_empty() else "Tutorial (carry on)", _play_tutorial))
@@ -222,7 +224,7 @@ func _main_page() -> Control:
var n: String = Net.get_identity().name
v.add_child(_button("Profile: %s" % n if not n.is_empty() else "Set Up Your Profile",
func(): _after_profile = "main"; _show("profile")))
v.add_child(_button("Quit", get_tree().quit))
v.add_child(_button("Quit", func(): get_tree().quit()))
return v
# --- multiplayer: profile, join, lobby ----------------------------------------
@@ -710,17 +712,20 @@ var version_label: Label
## The version, bottom left; and a card bottom right when a newer one is out.
func _build_update_banner(root: Control) -> void:
# (bottom right: the menu column runs down the left and would cover it)
version_label = UITheme.label("Emberwild %s" % Updates.version, 13, UITheme.TEXT_DIM)
version_label.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_LEFT)
version_label.offset_left = 16
version_label.offset_top = -30
version_label.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_RIGHT)
version_label.grow_horizontal = Control.GROW_DIRECTION_BEGIN
version_label.grow_vertical = Control.GROW_DIRECTION_BEGIN
version_label.offset_right = -16
version_label.offset_bottom = -10
root.add_child(version_label)
update_banner = UITheme.panel()
update_banner.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_RIGHT)
update_banner.grow_horizontal = Control.GROW_DIRECTION_BEGIN
update_banner.grow_vertical = Control.GROW_DIRECTION_BEGIN
update_banner.offset_right = -24
update_banner.offset_bottom = -24
update_banner.offset_bottom = -40 # (above the version)
update_banner.visible = false
root.add_child(update_banner)
var v := VBoxContainer.new()

View File

@@ -165,6 +165,30 @@ func _try_regrow(uid: String) -> void:
node.regrow()
regrown.emit(uid)
## Clears the pickups (sticks, stones, flax, grass) under a newly built
## floor, wall or roof: a short ray up from each nearby one finds the piece.
func clear_under(piece: Node3D) -> void:
if not is_instance_valid(piece) or not is_inside_tree():
return
await get_tree().physics_frame # (the piece's collider is in the world from the next step)
if not is_instance_valid(piece):
return
var space := get_world_3d().direct_space_state
var at := piece.global_position
for pk in pickups.values():
if not is_instance_valid(pk) or pk.taken:
continue
var p: Vector3 = pk.global_position
if Vector2(p.x - at.x, p.z - at.z).length() > 4.0:
continue
var q := PhysicsRayQueryParameters3D.create(p + Vector3(0, 0.02, 0), p + Vector3(0, 2.6, 0))
q.collision_mask = 4 # building pieces (build_piece.gd BUILD_LAYER)
q.hit_from_inside = true # (a floor's footing reaches into the ground, round the pickup)
if not space.intersect_ray(q).is_empty():
pk.collected()
if Net.is_server():
WorldNet.taken(WorldNet.pickup_key(pk)) # (gone for everyone playing too)
## Something built (or being built) where a tree or rock would come back.
func _built_over(pos: Vector3, r: float) -> bool:
var sph := SphereShape3D.new()

View File

@@ -859,6 +859,11 @@ func spawn_piece(def: Dictionary, xf: Transform3D, extra: Dictionary = {}) -> No
build.recompute()
if piece.kind == "floor":
_update_grass_cuts()
# Sticks, stones and plants it's built over are gone (cleared as if picked,
# so a server tells everyone and the save keeps it; regrowth won't put them
# back under a building). Also on load, for huts built before this.
if piece.is_in_group("build") and not WorldNet.is_client():
props.clear_under.call_deferred(piece)
if server_mode and not _applying:
piece_added.emit(piece)
return piece

View File

@@ -88,8 +88,8 @@ func _body() -> void:
var st: Variant = load(M.DIR + m + ".ogg")
lens.append(roundi(st.get_length()) if st is AudioStream else 0)
check(lens.all(func(l): return l >= 40), "five music tracks, 40 s or more each %s" % [lens])
var amb := ["birds", "crickets", "fire"].filter(func(a): return load(M.AMB + a + ".ogg") is AudioStream)
check(amb.size() == 3, "birds, crickets and the fire's crackle")
var amb := ["birds", "crickets", "fire", "owl_0", "owl_1"].filter(func(a): return load(M.AMB + a + ".ogg") is AudioStream)
check(amb.size() == 5, "birds, crickets, the fire's crackle and two owl hoots")
check(AudioServer.get_bus_index("Music") > 0 and AudioServer.get_bus_index("SFX") > 0 and AudioServer.get_bus_index("Ambience") > 0,
"Music, SFX and Ambience buses")
@@ -145,6 +145,18 @@ func _body() -> void:
"collider": world.terrain.get_node("TerrainBody")}, 0.0)
check(r.ok and A.place(player, slot, r.xf, world) and _heard("place_wood", h) == 1, "placing a floor: a plank's knock")
# (not a sound: building over plants, in any world, clears them)
var plant: Node3D = null
for pk in world.props.pickups.values():
if not pk.taken and pk.global_position.distance_to(player.global_position) < 40.0:
plant = pk
break
var cover: Node3D = world.spawn_piece(ItemDB_get("wood_floor"), Transform3D(Basis(), plant.global_position + Vector3(0.3, 0.05, 0.2)))
for _i in range(4):
await physics_frame
check(plant.taken and not plant.visible, "a floor built over a %s clears it (any hut, any world)" % plant.item_id)
cover.queue_free()
inv.add("berries", 3)
player.survival.hunger = 40.0
A.eat(player, _hold("berries"))

View File

@@ -122,7 +122,8 @@ func _wildlife() -> void:
close += 1
check(same >= mini(CN.puppets.size(), life.puppets.size()) - 1 and same >= 3,
"Bob sees the same ones (%d in common of %d / %d)" % [same, CN.puppets.size(), life.puppets.size()])
check(close >= same - 1, "in the same places (%d of %d within 3 m)" % [close, same])
# (two may be caught mid-dash between updates on a busy machine: the full suite)
check(close >= same - 2, "in the same places (%d of %d within 3 m)" % [close, same])
## A boar on the server, `dist` metres in front of Alice. Returns her view of it.
func _boar(dist: float) -> Node3D:

View File

@@ -134,7 +134,7 @@ func _body() -> void:
var median: float = speeds[speeds.size() / 2] if not speeds.is_empty() else 0.0
print(" (speeds seen: median %.2f, top %.2f m/s over %d samples)" % [median, top, speeds.size()])
check(top < 6.0, "smoothly: no jumps (top speed seen %.1f m/s for a 4 m/s walk)" % top)
check(absf(median - 4.0) < 0.6, "at his real speed (median %.2f m/s)" % median) # (0.6: a busy machine skews the samples a little)
check(absf(median - 4.0) < 0.75, "at his real speed (median %.2f m/s)" % median) # (0.75: a busy machine (the full suite) skews the samples a little)
check(stalls <= 2, "and no freezing mid-walk (%d stalls in %d samples)" % [stalls, moving.size()])
check(end_anim == 0 and anims.has(1), "the walk animation played, then idle")
var fe: Array = br.get("walk_end", [0, 0, 0])

View File

@@ -149,6 +149,22 @@ func _chapter_one() -> void:
check(float(G.counters.get("walk", 0.0)) < 15.0, "(a jump isn't counted as walking)")
player.global_position = start
await process_frame
print(" Old Tam's voice")
var NR: Node = root.get_node("Narrator")
check(NR.spoken.has("tutorial_look"), "he reads the first step aloud")
var TD: Dictionary = JSON.parse_string(FileAccess.get_file_as_string("res://data/tutorial.json"))
var HD: Dictionary = JSON.parse_string(FileAccess.get_file_as_string("res://data/hints.json"))
var stale: Array = []
for s in TD.steps:
if not NR.has_line("tutorial_" + String(s.id), String(s.text)):
stale.append(s.id)
if not NR.has_line("tutorial_finish", String(TD.finish.text)):
stale.append("finish")
for h in HD.hints:
if not NR.has_line("hint_" + String(h.id), String(h.text)):
stale.append("hint " + String(h.id))
check(stale.is_empty(), "every line he says is recorded, from the text as it is now (else run tools/voice/make_voice.sh) %s" % [stale])
check(not NR.say("tutorial_look", "Some other words"), "(changed words: he stays quiet rather than say the old ones)")
print("2. sticks and stones")
check(String(G.current().id) == "sticks", "next: %s" % G.current().get("title", "?"))
await process_frame
@@ -158,13 +174,24 @@ func _chapter_one() -> void:
for pk in world.props.pickups.values():
if pk.item_id == "stick" and not pk.taken and pk.global_position.distance_to(m) < 0.01:
near_stick = true
check(m != Vector3.INF and near_stick, "the marker stands on the nearest stick")
var near_stone := false
for pk in world.props.pickups.values():
if pk.item_id == "stone" and not pk.taken and pk.global_position.distance_to(m) < 0.01:
near_stone = true
check(m != Vector3.INF and (near_stick or near_stone), "the marker stands on the nearest stick or stone")
# Flax first, out of order: step 3 will tick the moment it comes up.
check(_take("fiber"), "(pick some flax early)")
check(_take("stick") and _take("stick"), "two sticks")
check(G.progress_text().contains("Sticks 2/3") and G.progress_text().contains("Stone 0/3"), "the panel counts them: \"%s\"" % G.progress_text())
check(_panel()._progress.text == G.progress_text(), "(shown on the panel)")
check(_take("stick") and _take("stone") and _take("stone"), "a third, and two stones")
check(_take("stick"), "a third stick")
G._marker = G.find_marker()
var on_stone := false
for pk in world.props.pickups.values():
if pk.item_id == "stone" and not pk.taken and pk.global_position.distance_to(G.marker_position()) < 0.01:
on_stone = true
check(on_stone, "enough sticks: now the marker's on a stone")
check(_take("stone") and _take("stone"), "two stones")
check(String(G.current().id) == "sticks", "not done yet (2 of 3 stones)")
check(_take("stone"), "the third stone")
check(ticked == ["look", "sticks", "flax"], "sticks and stones tick, and so does the flax picked earlier (%s)" % [ticked])

View File

@@ -44,6 +44,13 @@ func _run() -> void:
check(TS.tutorial_world().is_empty(), "no tutorial world yet")
S.current_id = S.create_world("Tutorial", TS.TUTORIAL_SEED, {"tutorial": true})
check(not TS.tutorial_world().is_empty(), "the title screen finds it once it's made (Tutorial, carry on)")
var ts: Node = TS.new()
var page: Control = ts._main_page()
var labels: Array = page.get_children().filter(func(c): return c is Button).map(func(b): return String(b.text))
check(labels.has("Tutorial (carry on)") and not labels.any(func(t): return t.begins_with("Continue")),
"and lists it once: no \"Continue: Tutorial\" as well %s" % [labels.slice(0, 3)])
page.free()
ts.free()
world = load("res://scenes/world.tscn").instantiate()
root.add_child(world)
for _i in range(5):
@@ -303,6 +310,19 @@ func _night() -> void:
var b := Basis(Vector3.UP, yaw)
var center: Vector3 = hut.pieces[0].global_transform * Vector3(1, 0, 1)
check(center.distance_to(ol.center() if is_instance_valid(ol) else center) < 0.05, "(built right on it)")
for _i in range(3):
await physics_frame
var under := 0
var space: PhysicsDirectSpaceState3D = world.get_world_3d().direct_space_state
for pk in world.props.pickups.values():
if pk.taken or Vector2(pk.global_position.x - center.x, pk.global_position.z - center.z).length() > 5.0:
continue
var rq := PhysicsRayQueryParameters3D.create(pk.global_position + Vector3(0, 0.02, 0), pk.global_position + Vector3(0, 2.6, 0))
rq.collision_mask = 4
rq.hit_from_inside = true
if not space.intersect_ray(rq).is_empty():
under += 1
check(under == 0, "no flax, sticks or stones poking up through the floor (%d left)" % under)
player.global_position = center + b * Vector3(0.6, 0.05, -0.6)
sv._shelter_left = 0.0
sv.step(0.2)

View File

@@ -6,6 +6,7 @@ third-party audio), so they're ours to release under CC0 too.
python3 tools/audio/make_audio.py # everything
python3 tools/audio/make_audio.py music # or: music | ambience | sfx | voices
python3 tools/audio/make_audio.py --kenney ~/Downloads/kenney # also re-copy the Kenney files
python3 tools/audio/make_audio.py birds # or crickets: one recorded loop, from tools/audio/sources
Writes .ogg files under assets/audio/ (via ffmpeg). Deterministic: the same
seeds give the same files. Pure Python (no numpy), so it takes a minute or two.
@@ -341,69 +342,6 @@ def track_boss():
# --- ambience ---------------------------------------------------------------------
def amb_birds():
sr, secs = 22050, 32
L = sr * secs
rng = random.Random(61)
l, r = [0.0] * L, [0.0] * L
add_noise(l, sr, 0, secs, 0.06, lp=0.03)
add_noise(r, sr, 0, secs, 0.06, lp=0.03, rng=random.Random(5))
t = 0.4
while t < secs - 1.0:
pan = rng.random()
base = rng.uniform(2400, 4200)
kind = rng.randrange(3)
n = rng.randint(2, 6)
tt = t
for k in range(n):
d = rng.uniform(0.05, 0.11)
f0 = base * rng.uniform(0.9, 1.15)
f1 = f0 * (rng.uniform(0.7, 0.85) if kind == 0 else rng.uniform(1.15, 1.35) if kind == 1 else 1.0)
nn = int(d * sr)
ph = 0.0
a = rng.uniform(0.15, 0.3)
for j in range(nn):
q = j / nn
ph += TAU * (f0 + (f1 - f0) * q) / sr
e = sin(math.pi * q) ** 2
s = sin(ph) * e * a
i = (int(tt * sr) + j) % L
l[i] += s * (1 - pan)
r[i] += s * pan
tt += d + rng.uniform(0.02, 0.09)
t += rng.uniform(0.6, 2.6)
return sr, (l, r)
def amb_crickets():
sr, secs = 22050, 30
L = sr * secs
rng = random.Random(71)
l, r = [0.0] * L, [0.0] * L
add_noise(l, sr, 0, secs, 0.04, lp=0.02)
add_noise(r, sr, 0, secs, 0.04, lp=0.02, rng=random.Random(9))
for c in range(4):
pan = rng.random()
f = rng.uniform(3800, 4700)
period = rng.uniform(0.5, 0.9)
pulses = rng.randint(3, 5)
amp = rng.uniform(0.06, 0.14)
t = rng.random() * period
while t < secs:
for p in range(pulses):
nn = int(0.018 * sr)
i0 = int((t + p * 0.032) * sr)
for j in range(nn):
e = sin(math.pi * j / nn)
s = sin(TAU * f * j / sr) * e * amp
l[(i0 + j) % L] += s * (1 - pan)
r[(i0 + j) % L] += s * pan
t += period * rng.uniform(0.95, 1.05)
if rng.random() < 0.08:
t += rng.uniform(1.0, 3.0) # (they pause now and then)
return sr, (l, r)
def amb_fire():
sr, secs = 22050, 6
L = sr * secs
@@ -700,9 +638,80 @@ def make_sfx():
def make_ambience():
d = os.path.join(OUT, "ambience")
print("ambience ->", os.path.relpath(d, ROOT))
for name, fn in (("birds", amb_birds), ("crickets", amb_crickets), ("fire", amb_fire)):
for name, fn in (("fire", amb_fire),):
sr, ch = fn()
_encode(os.path.join(d, name + ".ogg"), list(ch), sr, 3)
for name in RECORDED:
make_recorded(name)
SOURCES = os.path.join(ROOT, "tools", "audio", "sources")
def sources(prefix):
"""The CC0 recordings a loop is made from (credited in assets/audio/CREDITS.md)."""
if not os.path.isdir(SOURCES):
return []
return sorted(os.path.join(SOURCES, f) for f in os.listdir(SOURCES) if f.startswith(prefix + "_"))
## The field-recorded loops: name -> (tone filter, which stretch of each source).
## Birds: the shrillest top notes pulled back a little (a dawn chorus is bright).
## Crickets: the rumble under them (wind, a distant road) taken out.
RECORDED = {
"birds": ("highshelf=f=3800:g=-5,lowpass=f=8500", None),
"crickets": ("highpass=f=300,highshelf=f=6000:g=-3", None),
}
def _decode(src, sr, af=None):
cmd = ["ffmpeg", "-v", "error", "-i", src] + (["-af", af] if af else []) + \
["-f", "s16le", "-ac", "2", "-ar", str(sr), "-"]
raw = subprocess.run(cmd, capture_output=True, check=True).stdout
n = len(raw) // 4
left = [struct.unpack_from("<h", raw, i * 4)[0] / 32768.0 for i in range(n)]
right = [struct.unpack_from("<h", raw, i * 4 + 2)[0] / 32768.0 for i in range(n)]
# Each to the same loudness (RMS), so the loop doesn't swell and fade.
rms = math.sqrt(sum(x * x for x in left + right) / (2 * n)) or 1.0
return [x * 0.06 / rms for x in left], [x * 0.06 / rms for x in right]
def make_recorded(name, srcs=None):
"""A field-recorded ambience loop (assets/audio/ambience/<name>.ogg) from
CC0 recordings (tools/audio/sources/<name>_*, or the files given): each
evened out to the same loudness, chained one into the next and the last
back into the first with 3 s equal-power crossfades, into one seamless
loop. (Synthesised birdsong sounded like beeping, and crickets like a
smoke alarm.)"""
tone, _ = RECORDED[name]
srcs = srcs or sources(name)
if not srcs:
sys.exit("no recordings for %s in %s" % (name, os.path.relpath(SOURCES, ROOT)))
sr, xf = 44100, 3.0
k = int(xf * sr)
clips = [_decode(s, sr, tone) for s in srcs]
out_l, out_r = [], []
for c, (left, right) in enumerate(clips):
if c == 0:
out_l, out_r = list(left), list(right)
continue
# The end of what we have fades out over the start of the next clip.
base = len(out_l) - k
for i in range(k):
a = math.sin(0.5 * math.pi * i / k)
b = math.cos(0.5 * math.pi * i / k)
out_l[base + i] = out_l[base + i] * b + left[i] * a
out_r[base + i] = out_r[base + i] * b + right[i] * a
out_l += left[k:]
out_r += right[k:]
# And the end back into the beginning, so it loops.
L = len(out_l) - k
for i in range(k):
a = math.sin(0.5 * math.pi * i / k)
b = math.cos(0.5 * math.pi * i / k)
out_l[i] = out_l[i] * a + out_l[L + i] * b
out_r[i] = out_r[i] * a + out_r[L + i] * b
_encode(os.path.join(OUT, "ambience", name + ".ogg"), [out_l[:L], out_r[:L]], sr, 4)
def make_music():
@@ -773,6 +782,9 @@ if __name__ == "__main__":
copy_kenney(os.path.expanduser(args[i + 1]))
del args[i:i + 2]
what = args[0] if args else "all"
if what in RECORDED: # one recorded loop: birds | crickets
make_recorded(what)
sys.exit(0)
if what in ("all", "sfx"):
make_sfx()
if what in ("all", "sfx", "voices"):

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

42
tools/publish_release.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/bin/sh
# Publishes the release in build/ (tools/export.sh) on the Gitea repo:
# creates release v<version> on its tag (push the tag first), uploads the two
# zips and version.json, then checks each download matches its checksum.
#
# read -s GITEA_TOKEN && export GITEA_TOKEN
# tools/publish_release.sh
#
# The token needs write access to the repository (Gitea: Settings >
# Applications). It's read from the environment, never from the command line.
cd "$(dirname "$0")/.." || exit 1
API=${API:-https://git.psbailey.uk/api/v1/repos/Admin/Emberwild}
[ -n "$GITEA_TOKEN" ] || { echo "set GITEA_TOKEN first: read -s GITEA_TOKEN && export GITEA_TOKEN"; exit 1; }
[ -f build/version.json ] || { echo "no build/version.json: run tools/export.sh first"; exit 1; }
VERSION=$(python3 -c "import json; print(json.load(open('build/version.json'))['version'])")
NOTES=$(python3 -c "import json; print(json.load(open('build/version.json')).get('notes', ''))")
TAG="v$VERSION"
AUTH="Authorization: token $GITEA_TOKEN"
git ls-remote --exit-code origin "refs/tags/$TAG" >/dev/null || { echo "push the tag first: git push origin $TAG"; exit 1; }
BODY=$(python3 -c "import json,sys; print(json.dumps({'tag_name': sys.argv[1], 'name': 'Emberwild ' + sys.argv[2], 'body': sys.argv[3], 'draft': False, 'prerelease': False}))" "$TAG" "$VERSION" "$NOTES")
ID=$(curl -s -H "$AUTH" -H "Content-Type: application/json" -d "$BODY" "$API/releases" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id') or ''); sys.stderr.write(d.get('message','') + '\n') if not d.get('id') else None")
[ -n "$ID" ] || { echo "couldn't create the release (above)"; exit 1; }
echo "release $TAG created (id $ID)"
for f in version.json "Emberwild-$VERSION-macos.zip" "Emberwild-$VERSION-windows.zip"; do
[ -f "build/$f" ] || { echo "missing build/$f"; exit 1; }
R=$(curl -s -H "$AUTH" -F "attachment=@build/$f" "$API/releases/$ID/assets?name=$f")
echo "$R" | grep -q '"browser_download_url"' && echo " uploaded $f" || { echo " upload of $f failed: $R"; exit 1; }
done
# Check what anyone downloads is what was built.
REPO=${API%/api/v1/repos/*}/$(echo "$API" | sed 's#.*/repos/##')
for p in macos windows; do
f="Emberwild-$VERSION-$p.zip"
want=$(python3 -c "import json,sys; print(json.load(open('build/version.json'))['downloads'][sys.argv[1]]['sha256'])" $p)
got=$(curl -s -L "$REPO/releases/download/$TAG/$f" | shasum -a 256 | cut -d' ' -f1)
[ "$want" = "$got" ] && echo " $f: checksum ok" || { echo " $f: CHECKSUM MISMATCH"; exit 1; }
done
echo "done: $REPO/releases/tag/$TAG"

0
tools/voice/.gdignore Normal file
View File

85
tools/voice/make_voice.py Normal file
View File

@@ -0,0 +1,85 @@
"""Old Tam's voice: every line he says (the tutorial's steps and goodbye in
data/tutorial.json, his tips in data/hints.json), spoken by Kokoro (Kokoro-82M,
Apache-2.0, hexgrad) with its British "George" voice, then made a little older:
deeper, slower and warmer. Output: assets/audio/voice/<line id>.ogg plus
lines.json, the text each file was made from (the game only plays a file
whose text still matches, so an edited line goes quiet rather than saying
the old words; tests/test_tutorial.gd checks they're all up to date).
Run it with tools/voice/make_voice.sh (sets up the Kokoro environment once).
Only lines whose text changed are re-made; --all re-makes everything.
"""
import hashlib, json, os, re, subprocess, sys, tempfile
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
OUT = os.path.join(ROOT, "assets", "audio", "voice")
VOICE = "bm_george"
SPEED = 0.9
# Older: down about 1.4 semitones (and so a touch slower), then a little
# warmth and the top taken off.
AGE = "asetrate=24000*0.92,aresample=24000,atempo=1.04,lowpass=f=6500,bass=g=2:f=180"
def lines():
"""[(id, text)] for everything Old Tam says."""
out = []
t = json.load(open(os.path.join(ROOT, "data", "tutorial.json")))
for s in t["steps"]:
out.append(("tutorial_" + s["id"], s["text"]))
if t.get("finish", {}).get("text"):
out.append(("tutorial_finish", t["finish"]["text"]))
h = json.load(open(os.path.join(ROOT, "data", "hints.json")))
for x in h["hints"]:
out.append(("hint_" + x["id"], x["text"]))
return out
def key(text):
return hashlib.sha1(text.encode("utf-8")).hexdigest()[:16]
def spoken(text):
"""What to say for what's written: keys read as words, no brackets."""
s = text.replace("(E)", "with E").replace("[E]", "E")
s = re.sub(r"\(([^)]*)\)", r", \1,", s) # asides become pauses
s = s.replace(" · ", ", ").replace("&", "and")
s = re.sub(r"\bLMB\b", "left click", s)
s = re.sub(r"\bRMB\b", "right click", s)
s = re.sub(r"\s+,", ",", s)
return s
def main():
redo_all = "--all" in sys.argv
os.makedirs(OUT, exist_ok=True)
manifest_path = os.path.join(OUT, "lines.json")
old = json.load(open(manifest_path)) if os.path.exists(manifest_path) else {}
todo = [(i, t) for i, t in lines()
if redo_all or old.get(i) != key(t) or not os.path.exists(os.path.join(OUT, i + ".ogg"))]
keep = {i for i, _ in lines()}
for f in os.listdir(OUT): # lines that no longer exist
if f.endswith(".ogg") and f[:-4] not in keep:
os.remove(os.path.join(OUT, f))
if todo:
import numpy as np
import soundfile as sf
from kokoro import KPipeline
pipe = KPipeline(lang_code="b", repo_id="hexgrad/Kokoro-82M")
for i, text in todo:
audio = np.concatenate([a for _, _, a in pipe(spoken(text), voice=VOICE, speed=SPEED)])
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
sf.write(tmp, audio, 24000)
path = os.path.join(OUT, i + ".ogg")
# (ffmpeg's own Vorbis encoder only writes stereo)
subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", tmp, "-af", AGE + ",apad=pad_dur=0.25", "-ac", "2",
"-c:a", "vorbis", "-strict", "-2", "-q:a", "4", path], check=True)
os.remove(tmp)
old[i] = key(text)
print(" %s (%.1f s)" % (i, len(audio) / 24000 / 0.92 / 1.04))
manifest = {i: key(t) for i, t in lines()}
json.dump(manifest, open(manifest_path, "w"), indent="\t", sort_keys=True)
print("%d lines, %d made now -> %s" % (len(manifest), len(todo), os.path.relpath(OUT, ROOT)))
if __name__ == "__main__":
main()

21
tools/voice/make_voice.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/bin/sh
# Old Tam's voice (tools/voice/make_voice.py). Run after changing any line in
# data/tutorial.json or data/hints.json:
#
# tools/voice/make_voice.sh # just the lines that changed
# tools/voice/make_voice.sh --all # every line again
#
# The first run sets up a Python 3.12 environment for Kokoro in
# tools/voice/.venv (about 1 GB, with PyTorch; needs uv) and downloads the
# Kokoro model (about 330 MB) from Hugging Face.
cd "$(dirname "$0")/../.." || exit 1
V=tools/voice/.venv
command -v ffmpeg >/dev/null || { echo "needs ffmpeg (brew install ffmpeg)"; exit 1; }
if [ ! -x "$V/bin/python" ]; then
command -v uv >/dev/null || { echo "needs uv (https://docs.astral.sh/uv/)"; exit 1; }
uv venv -q --python 3.12 "$V" || exit 1
uv pip install -q --python "$V/bin/python" "kokoro==0.9.4" "misaki[en]==0.9.4" "soundfile==0.14.0" \
"en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl" || exit 1
fi
exec "$V/bin/python" tools/voice/make_voice.py "$@"