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