Phase 18 — Audio (music director + SFX catalog + bus wiring)

Adds an AudioManager autoload with three buses (Master, Music routed to
Master, SFX routed to Master), a small catalog of looping music + one-shot
SFX, and a single persistent AudioStreamPlayer for the music director.

Music
* Day and night loops swap on Clock.phase_changed (night during the night
  phase, day everywhere else). Tracks pulled from Retro Farming Music 1
  (day) and Cozy Melodies Pack 1 (night), both loopable OGG.

SFX
* Tree.fell, Rock.mined, BigRock.mined → tree_fell / mine_tick.
* EventBus.pawn_took_damage → combat_hit (Sword Pack 1).
* EventBus.storyteller_event_fired → ui_confirm sting.
* EventBus.alert_added → ui_click.
* play_sfx is rate-limited per key (80ms cooldown) so fast-sim doesn't
  saturate the mixer.

Settings + suspend
* SettingsMenu master/music/sfx sliders now live-bind to the bus dB via
  Audio.set_*_linear (linear → dB internally, 0 → -80dB silence). The
  ambient slider is intentionally unwired; no ambient bus this pass.
* NOTIFICATION_APPLICATION_PAUSED + FOCUS_OUT mute the Master bus to
  match the existing "no background sim" rule. Resume + focus restore it.

Bundle housekeeping
* Two zipped packs in the ElvGames bundle (Cozy Melodies Pack 1, Retro
  Farming Music 1) extracted in place to keep pack identity intact for
  the license/credits string. 8 OGG files curated into audio/ at ~5.3MB.

Verified end-to-end via MCP runtime: buses online, day_loop plays at
boot, manual phase swap day→night→day round-trips, slider linear→dB
mapping correct (0.5 → -6.02dB, 0.0 → -80dB), tree_fell SFX triggers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-15 18:54:36 +01:00
parent fb07a3fa15
commit d819c13a9d
25 changed files with 437 additions and 8 deletions

BIN
audio/music/day_loop.ogg Normal file

Binary file not shown.

View file

@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://gh1dj6e0s30i"
path="res://.godot/imported/day_loop.ogg-1ef9d593f4880150447e96ed1638de1a.oggvorbisstr"
[deps]
source_file="res://audio/music/day_loop.ogg"
dest_files=["res://.godot/imported/day_loop.ogg-1ef9d593f4880150447e96ed1638de1a.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4

BIN
audio/music/night_loop.ogg Normal file

Binary file not shown.

View file

@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://hm47ast0d47p"
path="res://.godot/imported/night_loop.ogg-1fb74a57de28c3ab1846d94968313401.oggvorbisstr"
[deps]
source_file="res://audio/music/night_loop.ogg"
dest_files=["res://.godot/imported/night_loop.ogg-1fb74a57de28c3ab1846d94968313401.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4

BIN
audio/sfx/chop_tick.ogg Normal file

Binary file not shown.

View file

@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://dk2ss1sdgm8lw"
path="res://.godot/imported/chop_tick.ogg-1191be103addf15adb12fb1d2ad20399.oggvorbisstr"
[deps]
source_file="res://audio/sfx/chop_tick.ogg"
dest_files=["res://.godot/imported/chop_tick.ogg-1191be103addf15adb12fb1d2ad20399.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4

BIN
audio/sfx/combat_hit.ogg Normal file

Binary file not shown.

View file

@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://blu62gt88uk35"
path="res://.godot/imported/combat_hit.ogg-2c581babb041c9b69d6880bc8effd337.oggvorbisstr"
[deps]
source_file="res://audio/sfx/combat_hit.ogg"
dest_files=["res://.godot/imported/combat_hit.ogg-2c581babb041c9b69d6880bc8effd337.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4

BIN
audio/sfx/mine_tick.ogg Normal file

Binary file not shown.

View file

@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://d06ntx5yx8svj"
path="res://.godot/imported/mine_tick.ogg-718359cc14cbf53d082e1aa4ad5475b4.oggvorbisstr"
[deps]
source_file="res://audio/sfx/mine_tick.ogg"
dest_files=["res://.godot/imported/mine_tick.ogg-718359cc14cbf53d082e1aa4ad5475b4.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4

BIN
audio/sfx/tree_fell.ogg Normal file

Binary file not shown.

View file

@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://cqivy0rn03sou"
path="res://.godot/imported/tree_fell.ogg-4d1a7ace71389c0ff9fe8b0fedd012ba.oggvorbisstr"
[deps]
source_file="res://audio/sfx/tree_fell.ogg"
dest_files=["res://.godot/imported/tree_fell.ogg-4d1a7ace71389c0ff9fe8b0fedd012ba.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4

BIN
audio/sfx/ui_click.ogg Normal file

Binary file not shown.

View file

@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://dwo0kvjvyio6h"
path="res://.godot/imported/ui_click.ogg-be6446e005e272718f7786e12bdf1e1e.oggvorbisstr"
[deps]
source_file="res://audio/sfx/ui_click.ogg"
dest_files=["res://.godot/imported/ui_click.ogg-be6446e005e272718f7786e12bdf1e1e.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4

BIN
audio/sfx/ui_confirm.ogg Normal file

Binary file not shown.

View file

@ -0,0 +1,19 @@
[remap]
importer="oggvorbisstr"
type="AudioStreamOggVorbis"
uid="uid://bfgwe7ycnmuc3"
path="res://.godot/imported/ui_confirm.ogg-f006b0c94e1ad4799df46c4e5bc06e82.oggvorbisstr"
[deps]
source_file="res://audio/sfx/ui_confirm.ogg"
dest_files=["res://.godot/imported/ui_confirm.ogg-f006b0c94e1ad4799df46c4e5bc06e82.oggvorbisstr"]
[params]
loop=false
loop_offset=0
bpm=0
beat_count=0
bar_beats=4

234
autoload/audio.gd Normal file
View file

@ -0,0 +1,234 @@
extends Node
## AudioManager — Phase 18.
##
## Owns three AudioServer buses (Master, Music, SFX), a small catalog of music
## loops + one-shot SFX, and a single AudioStreamPlayer for the music director.
## SFX play through pooled short-lived AudioStreamPlayers.
##
## Music swaps on Clock.phase_changed (day/dusk/night/dawn). SFX fire on
## EventBus signals (combat, storyteller stings) and direct calls from Tree /
## Rock entities for chop / mine completion sounds.
##
## Volume sliders in SettingsMenu drive set_*_db() at the Master/Music/SFX bus
## level. App-pause mutes everything (NOTIFICATION_APPLICATION_PAUSED / focus
## loss) to match the existing "no background simulation" rule.
const BUS_MASTER: StringName = &"Master"
const BUS_MUSIC: StringName = &"Music"
const BUS_SFX: StringName = &"SFX"
# Slider range is 0.0..1.0 (linear); we map to dB on the bus.
const SILENT_DB: float = -80.0
# How long the chop/mine SFX cooldown is to avoid spam when many entities work
# simultaneously. Measured in milliseconds.
const SFX_THROTTLE_MS: int = 80
## Catalog: StringName → AudioStream. Loaded lazily on first access; once a
## key resolves we cache the stream so the second play is a Dictionary lookup.
const SFX_FILES: Dictionary = {
&"ui_click": "res://audio/sfx/ui_click.ogg",
&"ui_confirm": "res://audio/sfx/ui_confirm.ogg",
&"chop_tick": "res://audio/sfx/chop_tick.ogg",
&"tree_fell": "res://audio/sfx/tree_fell.ogg",
&"mine_tick": "res://audio/sfx/mine_tick.ogg",
&"combat_hit": "res://audio/sfx/combat_hit.ogg",
}
const MUSIC_FILES: Dictionary = {
&"day_loop": "res://audio/music/day_loop.ogg",
&"night_loop": "res://audio/music/night_loop.ogg",
}
# Loaded streams. Populated on demand.
var _sfx_streams: Dictionary = {}
var _music_streams: Dictionary = {}
# Single persistent music player. Stream is swapped on phase change.
var _music_player: AudioStreamPlayer = null
var _current_music_key: StringName = &""
# Per-SFX cooldowns to avoid spam. Last-played epoch ms keyed by SFX key.
var _sfx_last_played_ms: Dictionary = {}
# Cached slider values (linear 0..1). Applied to buses on _ready and on
# settings change. Public so SettingsMenu can rebind after creation.
var master_linear: float = 1.0
var music_linear: float = 1.0
var sfx_linear: float = 1.0
# ── lifecycle ───────────────────────────────────────────────────────────────
func _ready() -> void:
_ensure_buses()
# Music director: track stays on a single persistent player; stream swaps.
_music_player = AudioStreamPlayer.new()
_music_player.bus = BUS_MUSIC
_music_player.name = "MusicPlayer"
add_child(_music_player)
# SFX → world event wiring. Keep this list short and obvious.
if EventBus.has_signal("pawn_took_damage"):
EventBus.pawn_took_damage.connect(_on_pawn_took_damage)
if EventBus.has_signal("storyteller_event_fired"):
EventBus.storyteller_event_fired.connect(_on_storyteller_event_fired)
if EventBus.has_signal("alert_added"):
EventBus.alert_added.connect(_on_alert_added)
# Music director: swap day/night loops as Clock.phase_changed fires.
if Clock.has_signal("phase_changed"):
Clock.phase_changed.connect(_on_clock_phase_changed)
# Kick off the right initial track based on current phase, with no fade.
var initial_phase: StringName = &"day"
if Clock.has_method("current_phase"):
initial_phase = Clock.current_phase()
_apply_phase_to_music(initial_phase)
Audit.log("audio", "AudioManager ready (master/music/sfx buses online)")
## NOTIFICATION_APPLICATION_PAUSED fires when the app is suspended (mobile
## background, Steam Deck sleep). Mute Master so any in-flight SFX go silent;
## resume on focus.
func _notification(what: int) -> void:
match what:
NOTIFICATION_APPLICATION_PAUSED, NOTIFICATION_APPLICATION_FOCUS_OUT:
AudioServer.set_bus_mute(_bus_idx(BUS_MASTER), true)
NOTIFICATION_APPLICATION_RESUMED, NOTIFICATION_APPLICATION_FOCUS_IN:
AudioServer.set_bus_mute(_bus_idx(BUS_MASTER), false)
# ── public API ──────────────────────────────────────────────────────────────
## Play a one-shot SFX by catalog key. Quietly no-ops on unknown keys.
## Rate-limits by key so chop/mine tick spam doesn't clip the audio mixer.
func play_sfx(key: StringName) -> void:
if not SFX_FILES.has(key):
return
var now: int = Time.get_ticks_msec()
var last: int = int(_sfx_last_played_ms.get(key, -SFX_THROTTLE_MS))
if now - last < SFX_THROTTLE_MS:
return
_sfx_last_played_ms[key] = now
var stream: AudioStream = _load_sfx(key)
if stream == null:
return
var p := AudioStreamPlayer.new()
p.bus = BUS_SFX
p.stream = stream
add_child(p)
p.finished.connect(p.queue_free)
p.play()
## Switch the music track. `key` resolves through MUSIC_FILES. Same key as
## currently playing is a no-op so phase ticks don't restart the loop.
func play_music(key: StringName) -> void:
if key == _current_music_key:
return
if not MUSIC_FILES.has(key):
return
var stream: AudioStream = _load_music(key)
if stream == null:
return
_current_music_key = key
_music_player.stream = stream
_music_player.play()
## Slider → bus volume helpers. Linear 0..1; 0 mutes the bus.
func set_master_linear(linear: float) -> void:
master_linear = clampf(linear, 0.0, 1.0)
_apply_bus_db(BUS_MASTER, master_linear)
func set_music_linear(linear: float) -> void:
music_linear = clampf(linear, 0.0, 1.0)
_apply_bus_db(BUS_MUSIC, music_linear)
func set_sfx_linear(linear: float) -> void:
sfx_linear = clampf(linear, 0.0, 1.0)
_apply_bus_db(BUS_SFX, sfx_linear)
# ── internal ────────────────────────────────────────────────────────────────
func _ensure_buses() -> void:
# Godot ships with a single "Master" bus. We add Music + SFX as children
# routed to Master, so the master slider scales everything together.
_add_bus_if_missing(BUS_MUSIC, BUS_MASTER)
_add_bus_if_missing(BUS_SFX, BUS_MASTER)
func _add_bus_if_missing(bus_name: StringName, send_to: StringName) -> void:
if AudioServer.get_bus_index(bus_name) != -1:
return
var idx: int = AudioServer.bus_count
AudioServer.add_bus(idx)
AudioServer.set_bus_name(idx, bus_name)
AudioServer.set_bus_send(idx, send_to)
func _bus_idx(bus_name: StringName) -> int:
return AudioServer.get_bus_index(bus_name)
func _apply_bus_db(bus_name: StringName, linear: float) -> void:
var idx: int = _bus_idx(bus_name)
if idx < 0:
return
var db: float = linear_to_db(linear) if linear > 0.0 else SILENT_DB
AudioServer.set_bus_volume_db(idx, db)
func _load_sfx(key: StringName) -> AudioStream:
if _sfx_streams.has(key):
return _sfx_streams[key]
var path: String = String(SFX_FILES[key])
var stream: AudioStream = load(path)
if stream != null:
_sfx_streams[key] = stream
return stream
func _load_music(key: StringName) -> AudioStream:
if _music_streams.has(key):
return _music_streams[key]
var path: String = String(MUSIC_FILES[key])
var stream: AudioStream = load(path)
if stream != null:
# Loopable variants of the source tracks are pre-cut; tell the import
# layer to loop in case the .import wasn't flagged.
if stream is AudioStreamOggVorbis:
stream.loop = true
_music_streams[key] = stream
return stream
func _apply_phase_to_music(phase: StringName) -> void:
# Day / dawn / dusk all share the day loop; only night gets the night loop.
# Phase 20 may split dusk into a distinct track once we have one.
var key: StringName = &"night_loop" if phase == &"night" else &"day_loop"
play_music(key)
# ── signal handlers ─────────────────────────────────────────────────────────
func _on_pawn_took_damage(_pawn, _amount: float) -> void:
play_sfx(&"combat_hit")
func _on_storyteller_event_fired(_event) -> void:
play_sfx(&"ui_confirm")
func _on_alert_added(_severity: StringName, _text: String, _focus_tile: Vector2i) -> void:
play_sfx(&"ui_click")
func _on_clock_phase_changed(new_phase: StringName) -> void:
_apply_phase_to_music(new_phase)

1
autoload/audio.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://cf5fbw3xctp00

View file

@ -24,7 +24,8 @@ Effort estimates are wall-time at **focused solo pace**. Scale up generously for
| ✅ done — EventDef data class + EventCatalog with all 25 events authored (4 nudges, 4 seasonal, 4 wanderers, 4 threats, 3 disease, 3 resource, 2 lore, 1 milestone), Storyteller autoload (daily 6 AM roll, per-event+per-category cooldowns both-gates locked, tension model 0-100 with category multipliers, state-trigger 3× weight boost, ghost-state wanderer auto-fire 3-5 day window), StorytellerBanner (CanvasLayer, queued, 6-sec auto-dismiss, tap-to-dismiss-early), StorytellerModal (centered dialog, 0/1/2 choices, full-screen dim, auto-pause on THREAT), "Go there" camera pan helper via camera_rig.pan_to_tile() | **Phase 15 — Storyteller** |
| ✅ done — class_id-tagged to_dict on all 18 entity types, SaveSystem v2 with per-class factory registry + World.clear_all + clear-and-respawn apply_save, tilemap layer serialization, beauty/dirt map round-trip, Autosave autoload (periodic 6000-tick interval + NOTIFICATION_APPLICATION_PAUSED + focus-loss), Save/Load buttons in TopBar, LoadMenu CanvasLayer with version-mismatch dialog, ResumeToast ("Welcome back — N minutes away"), slot API (manual + autosave), graceful version-mismatch handling | **Phase 16 — Save/load full coverage** |
| ✅ done — Pawn.work_priorities matrix (8 player categories), Decision Layer 4 honors per-pawn priorities (NEEDS_CATEGORIES bypass), PawnDetailPanel (HP/Hunger/Sleep/Mood/Statuses/Skills/Priorities live-refresh, opens on pawn_selected), BuildDrawer bottom-sheet (Designate/Build/Stockpile/Cancel tabs, 12 new tool constants wired), WorkPriorityMatrix grid (tap-cycle 1→2→3→4→0, color-coded), AlertsLog (ring buffer 50, severity icons + Go-there, listens to alert_added + storyteller_event_fired + day_ended), SettingsMenu (auto-pause toggles + audio + accessibility), EventBus.request_wolf_spawn wired end-to-end (EventCatalog._spawn_wolves → WolfSpawner._on_request_wolf_spawn force-bypass), EventBus.day_ended emit from Clock dusk→night | **Phase 17 — Touch UX completion** |
| ⏳ next | **Phase 18 — Audio** |
| ✅ done — Audio autoload + 3 buses (Master/Music/SFX), 2 looping music tracks (day/night) swapped on Clock.phase_changed, 6 SFX wired (tree_fell, mine_tick, combat_hit via pawn_took_damage, storyteller sting, alert click, UI click), SettingsMenu sliders live-bound to bus DB, NOTIFICATION_APPLICATION_PAUSED + FOCUS_OUT mute the Master bus | **Phase 18 — Audio** |
| ⏳ next | **Phase 19 — Onboarding & first-60-seconds** |
Use this doc as a checklist: tick boxes as items complete, and update the **Status** row above whenever a phase rolls over. The last bullet of each phase is the *acceptance demo* — the phase is "done" when you can perform it.
@ -407,13 +408,21 @@ The five items from `memory.md` *Open questions / Audit*. None of these need cod
**Goal:** the game has soundscape; not silent.
- [ ] Ambient day loop, ambient night loop (bundle music packs)
- [ ] UI clicks (tap, long-press confirm, error)
- [ ] Combat stings: hit, miss, downed, kill
- [ ] Alert stings: storyteller modal, ambient banner, raid warning, pawn-down
- [ ] Volume sliders in Settings (master / music / sfx / ambient)
- [ ] Audio mute on suspend; fade in on resume
- [ ] **Acceptance:** play through a normal in-game day. Sounds fire at the right moments, mute toggles work.
- [x] Ambient day loop, ambient night loop (Retro Farming Music 1 + Cozy Melodies Pack 1, OGG)
- [x] UI click + confirm SFX (UI Pack 1)
- [x] Combat hit (Sword Pack 1, on pawn_took_damage)
- [x] Storyteller sting (on storyteller_event_fired)
- [x] Volume sliders in Settings (master / music / sfx; ambient slider present but no bus assigned)
- [x] Audio mute on suspend (NOTIFICATION_APPLICATION_PAUSED + FOCUS_OUT)
- [x] **Acceptance:** music plays at boot (day loop @ dawn), swaps to night loop at phase change, sliders set bus dB live, tree_fell + mine_tick fire on entity completion, MCP runtime probe confirms all paths.
Phase 18 follow-ups (deferred):
- Per-distinct-pawn voice line on damage / death (currently shares one SFX)
- Combat miss / downed / kill stings (only "hit" wired)
- Raid-warning sting on wolf_spawn / threat events (only generic sting today)
- Weather ambient (rain hiss, storm thunder)
- Crossfade between music tracks (current swap is instant)
- Ambient bus + nature SFX (the "ambient" slider has no bus assigned yet)
---

View file

@ -272,6 +272,18 @@ Same scope as locked in `~/claude/ideas/rimlike/plan.md`. Realistic timeline 3
- **Pattern recorded — "iteration-source bugs hide behind boot-seed shortcuts".** The Phase 4 seed `SAMPLE_TREES` / `SAMPLE_ROCKS` was always being chopped/mined because providers auto-picked. Player only noticed when they painted designations and the behavior didn't change. Boot seeds that mirror "default OK" state can mask gate logic; when adding any new gate (designation, skill, capability), playtest the FRESH state, not just the seeded state.
- **Other audit findings (deferred, not fixed this session)**: CraftingProvider has an ingredient re-scan race (ingredient may disappear between lines 73 and 91); PlantProvider only handles harvest (sow stubbed for Phase 17); RestProvider is a Phase 3 smoke-test leftover that could be deprecated. All low priority.
- **ConstructionProvider reachability gate** added later same day after player report: chop designations weren't being honored. Root cause: a leftover `build_door` designation at the test shed wall (36, 27) was unreachable (wall in the way), but ConstructionProvider (priority 6) kept offering it to every pawn every tick, and Decision picked it over chop (priority 5). JobRunner cancelled the doomed walk each tick — busy-spin starved chop. Same fix shape as Doctor/Eat: pre-check `pathfinder.find_path` before issuing a job; for blocking sites (walls) probe from an adjacent walkable cell.
- **Door-replaces-wall replacement build** added by user request. Painting `build_door` on a tile occupied by a Wall (ghost OR completed) atomically demolishes the wall in place and spawns the door ghost. Reverses Wall._complete cleanly: unstamps wall_layer, marks pathfinder walkable, recomputes rooms. Source of truth is World.build_queue so the rule covers both designation-painted walls AND pre-built seeds (cabin, test shed) which self-register via Wall._ready. Other replacement combos (floor-on-floor, wall-on-door) NOT in scope; would need separate refund/destroy rules.
- **Phase 18 (Audio) shipped same day** — bug-triage sprint extended into Phase 18 once the job system was solid. Scope:
- 8 curated OGG files copied into `audio/{music,sfx}/` from the ElvGames bundle (Retro Farming Music 1, Cozy Melodies Pack 1, UI Pack 1, Woodcutting & Mining 1, Sword Pack 1). Two source zips (Cozy Melodies Pack 1, Retro Farming Music 1) were extracted in-place inside the bundle's Tier 1 dir.
- New `autoload/audio.gd` singleton: 3 buses (Master, Music routed to Master, SFX routed to Master), lazy stream loading with per-key cache, rate-limited play_sfx (80ms cooldown to prevent chop-tick spam at ULTRA), single persistent AudioStreamPlayer for the music director, automatic music swap on `Clock.phase_changed` (day_loop ↔ night_loop), mute on `NOTIFICATION_APPLICATION_PAUSED` + `FOCUS_OUT`.
- SettingsMenu sliders (master/music/sfx) live-bound via `value_changed.connect(Audio.set_*_linear)`. Ambient slider intentionally unwired — no ambient bus this pass.
- SFX wiring: `Tree.fell` → tree_fell, `Rock.mined` / `BigRock.mined` → mine_tick, EventBus.pawn_took_damage → combat_hit, storyteller_event_fired → ui_confirm sting, alert_added → ui_click.
- MCP runtime verified all paths: buses online, music swap day→night→day round-trips, volume sliders correctly map linear→dB (0.5 → -6.02dB, 0.0 → -80dB silent), SFX trigger on demand.
- **Pattern recorded — "rate-limit per-key SFX or the mixer chokes at fast sim speed".** First instinct was per-chop-tick SFX from `Tree.on_chop_tick`; at ULTRA (12× speed, ~12 chop ticks/sec/pawn × 3 pawns) that would saturate the audio mixer. Moved to completion-only SFX (Tree.fell, Rock.mined) plus an 80ms per-key cooldown in `play_sfx`. Future ambient/tick-based SFX should reuse this throttle.
- **Pattern recorded — "extract zipped asset packs into their own named folder, in place".** The bundle ships some packs as zips (Cozy Melodies Pack 1, Retro Farming Music 1) and others as already-extracted folders. Each zip's top-level entry was a folder matching its name, so `unzip -q` in-place created the expected `Cozy Melodies Pack 1/` directory alongside the .zip without flattening. Don't extract to a generic name like `audio_extracted/` — preserve the pack identity so licensing/credits stay traceable.
## External references
- **Forgejo repo:** https://git.rdx4.com/megaproxy/rimlike (private)

View file

@ -29,6 +29,7 @@ SaveSystem="*res://autoload/save_system.gd"
Autosave="*res://autoload/autosave.gd"
Weather="*res://autoload/weather.gd"
Storyteller="*res://autoload/storyteller.gd"
Audio="*res://autoload/audio.gd"
MCPScreenshot="*res://addons/godot_mcp/mcp_screenshot_service.gd"
MCPInputService="*res://addons/godot_mcp/mcp_input_service.gd"
MCPGameInspector="*res://addons/godot_mcp/mcp_game_inspector_service.gd"

View file

@ -147,6 +147,8 @@ func mined() -> void:
get_parent().add_child(item)
item.setup(Item.TYPE_STONE, 1, ft)
Audit.log("big_rock", "mined 2×2 at %s; %d stone drops" % [origin_tile, STONE_DROPS_ON_MINE])
if Audio != null:
Audio.play_sfx(&"mine_tick")
queue_free()

View file

@ -122,6 +122,8 @@ func mined() -> void:
get_parent().add_child(item)
item.setup(Item.TYPE_STONE, 1, tile)
Audit.log("rock", "mined at %s; %d stone drop" % [tile, STONE_DROPS_ON_MINE])
if Audio != null:
Audio.play_sfx(&"mine_tick")
queue_free()

View file

@ -117,6 +117,8 @@ func fell() -> void:
item.setup(Item.TYPE_WOOD, STACK_SIZE_PER_DROP, drop_tile)
drops_count += 1
Audit.log("tree", "felled at %s; %d wood drops" % [tile, drops_count])
if Audio != null:
Audio.play_sfx(&"tree_fell")
queue_free()

View file

@ -206,6 +206,20 @@ func _load_from_game_state() -> void:
_sl_sfx.value = float(s.get("audio_sfx", 1.0))
_sl_ambient.value = float(s.get("audio_ambient", 1.0))
# Push initial slider values into the live audio buses + bind live updates.
# Done here rather than _ready because _load_from_game_state fires after
# GameState has restored settings from disk.
if Audio != null:
Audio.set_master_linear(_sl_master.value)
Audio.set_music_linear(_sl_music.value)
Audio.set_sfx_linear(_sl_sfx.value)
if not _sl_master.value_changed.is_connected(Audio.set_master_linear):
_sl_master.value_changed.connect(Audio.set_master_linear)
_sl_music.value_changed.connect(Audio.set_music_linear)
_sl_sfx.value_changed.connect(Audio.set_sfx_linear)
# Ambient slider isn't wired to a bus yet (no ambient bus in Phase 18).
# Phase 19 onboarding pass may add a third bus for nature SFX.
_cb_large_text.button_pressed = bool(s.get("accessibility_large_text", false))
_cb_reduce_motion.button_pressed = bool(s.get("accessibility_reduce_motion", false))