Old Tam speaks (Kokoro); CC0 birdsong, crickets and owls; no plants through floors; menu and marker fixes

This commit is contained in:
Paul Bailey
2026-11-06 00:03:59 +00:00
parent 0550a4daa0
commit 787d8d1d65
70 changed files with 495 additions and 87 deletions

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