diff --git a/audio.sx b/audio.sx index 9254ef5..84b27b2 100644 --- a/audio.sx +++ b/audio.sx @@ -1,7 +1,7 @@ -// iOS sound effect via AudioToolbox System Sound Services (P8.1). +// iOS sound-effect bank via AudioToolbox System Sound Services (P10.2). // // A purely additive layer: it never reads or mutates score / board / move -// state — board_view tells it a match cleared and it plays one short clip. +// state — board_view tells it an event happened and it plays one short clip. // // System Sound Services is plain C, so it is reached with sx's `#foreign` FFI // exactly as uikit.sx reaches UIApplicationMain / dlsym / CACurrentMediaTime — @@ -29,32 +29,97 @@ CFRelease :: (cf: *void) #foreign; getcwd :: (buf: *u8, size: usize) -> *u8 #foreign; c_strlen :: (s: *u8) -> usize #foreign "strlen"; -// Loaded once at startup; `play` is then a single C call per cleared match. +// The cascade clips combo1..combo5, in ascending pitch. `combo_ids` is indexed +// by `cascade_cue_index`. +COMBO_CLIPS :: 5; + +// The whole bank, each cue loaded once at startup into its own SystemSoundID; +// every `play_*` is then a single C call. `loaded` is true only when every cue +// loaded, so a partial/failed bank mutes rather than playing a 0 id. GameAudio :: struct { - clear_id: u32; + swap_id: u32; + match_id: u32; + combo_ids: [COMBO_CLIPS]u32; + win_id: u32; + lose_id: u32; loaded: bool; init :: (self: *GameAudio) { - self.clear_id = 0; self.loaded = false; inline if OS != .ios { return; } - self.clear_id = load_system_sound("clear.wav"); - self.loaded = self.clear_id != 0; - if self.loaded { NSLog(xx "[sx] audio: clear cue loaded"); } - else { NSLog(xx "[sx] audio: load failed"); } + self.swap_id = load_cue("swap.wav", "[sx] audio: loaded swap", "[sx] audio: load failed swap"); + self.match_id = load_cue("match.wav", "[sx] audio: loaded match", "[sx] audio: load failed match"); + self.combo_ids[0] = load_cue("combo1.wav", "[sx] audio: loaded combo1", "[sx] audio: load failed combo1"); + self.combo_ids[1] = load_cue("combo2.wav", "[sx] audio: loaded combo2", "[sx] audio: load failed combo2"); + self.combo_ids[2] = load_cue("combo3.wav", "[sx] audio: loaded combo3", "[sx] audio: load failed combo3"); + self.combo_ids[3] = load_cue("combo4.wav", "[sx] audio: loaded combo4", "[sx] audio: load failed combo4"); + self.combo_ids[4] = load_cue("combo5.wav", "[sx] audio: loaded combo5", "[sx] audio: load failed combo5"); + self.win_id = load_cue("win.wav", "[sx] audio: loaded win", "[sx] audio: load failed win"); + self.lose_id = load_cue("lose.wav", "[sx] audio: loaded lose", "[sx] audio: load failed lose"); + + self.loaded = self.swap_id != 0 and self.match_id != 0 + and self.combo_ids[0] != 0 and self.combo_ids[1] != 0 and self.combo_ids[2] != 0 + and self.combo_ids[3] != 0 and self.combo_ids[4] != 0 + and self.win_id != 0 and self.lose_id != 0; } - play_clear :: (self: *GameAudio) { + play_swap :: (self: *GameAudio) { inline if OS != .ios { return; } if !self.loaded { return; } - AudioServicesPlaySystemSound(self.clear_id); - NSLog(xx "[sx] sfx clear"); + AudioServicesPlaySystemSound(self.swap_id); + } + + play_match :: (self: *GameAudio) { + inline if OS != .ios { return; } + if !self.loaded { return; } + AudioServicesPlaySystemSound(self.match_id); + } + + // Pick the ascending cascade clip by clamping the cascade depth into the + // combo1..combo5 range (see `cascade_cue_index`). + play_cascade :: (self: *GameAudio, depth: s64) { + inline if OS != .ios { return; } + if !self.loaded { return; } + AudioServicesPlaySystemSound(self.combo_ids[cascade_cue_index(depth)]); + } + + play_win :: (self: *GameAudio) { + inline if OS != .ios { return; } + if !self.loaded { return; } + AudioServicesPlaySystemSound(self.win_id); + } + + play_lose :: (self: *GameAudio) { + inline if OS != .ios { return; } + if !self.loaded { return; } + AudioServicesPlaySystemSound(self.lose_id); } } +// Cascade depth (number of cleared rounds) → combo clip index 0..COMBO_CLIPS-1 +// (combo1..combo5). Clamps: depth <= 1 → 0, depth >= 5 → 4. Pure arithmetic and +// OS-agnostic so it can be snapshot-tested headlessly (P10.4). +cascade_cue_index :: (depth: s64) -> s64 { + if depth <= 1 { return 0; } + if depth >= COMBO_CLIPS { return COMBO_CLIPS - 1; } + depth - 1 +} + +// Load one cue into a SystemSoundID and log the outcome. `loaded_msg` / +// `failed_msg` are string literals (NUL-terminated, so safe to bridge to +// NSString) naming the cue. Returns 0 on any failure, which the play methods +// treat as "skip" via the `loaded` gate. +load_cue :: (name: string, loaded_msg: string, failed_msg: string) -> u32 { + inline if OS != .ios { return 0; } + id := load_system_sound(name); + if id != 0 { NSLog(xx loaded_msg); } + else { NSLog(xx failed_msg); } + return id; +} + // Create a SystemSoundID for `assets/audio/` (relative to the bundle). -// Returns 0 on any failure, which `play_clear` treats as "skip". +// Returns 0 on any failure. load_system_sound :: (name: string) -> u32 { inline if OS != .ios { return 0; } @@ -79,10 +144,12 @@ load_system_sound :: (name: string) -> u32 { } // The process-wide instance. main() allocates + inits it; board_view triggers -// the cue through `sfx_clear`. Null until init, so `sfx_clear` is a safe no-op -// before then. +// cues through the `sfx_*` shims. Null until init, so every shim is a safe +// no-op before then. Event→cue wiring beyond cascade lands in P10.3. g_audio : *GameAudio = null; -sfx_clear :: () { - if g_audio != null { g_audio.play_clear(); } -} +sfx_swap :: () { if g_audio != null { g_audio.play_swap(); } } +sfx_match :: () { if g_audio != null { g_audio.play_match(); } } +sfx_cascade :: (depth: s64) { if g_audio != null { g_audio.play_cascade(depth); } } +sfx_win :: () { if g_audio != null { g_audio.play_win(); } } +sfx_lose :: () { if g_audio != null { g_audio.play_lose(); } } diff --git a/board_view.sx b/board_view.sx index 53295ec..cf07434 100644 --- a/board_view.sx +++ b/board_view.sx @@ -642,10 +642,11 @@ impl View for BoardView { mv := plan_and_commit(self.board, intent.a, intent.b); if self.anim != null { self.anim.begin(mv); } if self.fx != null { self.fx.begin(@mv); } - // SFX (P8.1). Additive only — plays a short cue when a swap - // actually clears a match; reads no score/board state and - // writes none. A legal move has >=1 cascade round. - if mv.legal and mv.rounds.len > 0 { sfx_clear(); } + // SFX (P10.2). Additive only — plays the ascending cascade + // cue (combo1..combo5, clamped by depth) when a swap actually + // clears a match; reads no score/board state and writes none. + // A legal move has >=1 cascade round. + if mv.legal and mv.rounds.len > 0 { sfx_cascade(mv.rounds.len); } self.sel.clear(); } else { if hit := self.layout.point_to_cell(start) { diff --git a/main.sx b/main.sx index fcd91ba..7c2d16f 100644 --- a/main.sx +++ b/main.sx @@ -285,10 +285,10 @@ main :: () -> void { g_motion = xx context.allocator.alloc(size_of(GemMotion)); g_motion.init(); - // SFX (P8.1). Loads the one System Sound Services cue once; board_view - // plays it when a swap clears a match. Purely additive — never touches - // score/board/move state. On iOS the platform has already chdir'd to the - // bundle, so the cue's relative path resolves. No-op off iOS. + // SFX (P10.2). Loads the System Sound Services cue bank once; board_view + // plays a cue per event. Purely additive — never touches score/board/move + // state. On iOS the platform has already chdir'd to the bundle, so each + // cue's relative path resolves. No-op off iOS. g_audio = xx context.allocator.alloc(size_of(GameAudio)); memset(xx g_audio, 0, size_of(GameAudio)); g_audio.init();