From 51fdb75d357bd67a8a51d426c90b6a11001c4a77 Mon Sep 17 00:00:00 2001 From: swipelab Date: Fri, 5 Jun 2026 19:59:45 +0300 Subject: [PATCH] P10.3: wire the SFX bank to game events (sx / iOS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive event-appropriate cues from the existing System Sound Services bank, purely additively — no board/score/move state is read or written and every call stays inline-if-OS==.ios guarded. board_view.sx (move-commit path): a committed gesture now plays the swap slide cue for any swipe intent (legal or the reverted ping-back); a legal move adds the match pop on its first clearing round; a multi-round chain adds the escalating cascade cue keyed to the recorded AnimMove depth (mv.rounds.len), kept distinct from the match pop so a single clear is never doubled. An illegal swap plays only the swap cue. main.sx (frame loop): the win/lose stinger fires EXACTLY ONCE, edge-triggered on the frame the banner comes up — the level has settled won/lost and any in-flight cascade has finished animating. Status is read-only from the model; a restart re-arms the edge for a fresh win/lose. audio.sx: each play_* method logs a per-cue NSLog line at play time so the ordering is observable via `log show`; cascade_cue_name maps the clamped combo index to a stable literal (literals only — the string→NSString bridge needs NUL-terminated bytes). --- audio.sx | 25 ++++++++++++++++++++++--- board_view.sx | 17 ++++++++++++----- main.sx | 17 +++++++++++++++++ 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/audio.sx b/audio.sx index 84b27b2..fced133 100644 --- a/audio.sx +++ b/audio.sx @@ -67,12 +67,14 @@ GameAudio :: struct { play_swap :: (self: *GameAudio) { inline if OS != .ios { return; } if !self.loaded { return; } + NSLog(xx "[sx] audio: cue swap"); AudioServicesPlaySystemSound(self.swap_id); } play_match :: (self: *GameAudio) { inline if OS != .ios { return; } if !self.loaded { return; } + NSLog(xx "[sx] audio: cue match"); AudioServicesPlaySystemSound(self.match_id); } @@ -81,22 +83,38 @@ GameAudio :: struct { play_cascade :: (self: *GameAudio, depth: s64) { inline if OS != .ios { return; } if !self.loaded { return; } - AudioServicesPlaySystemSound(self.combo_ids[cascade_cue_index(depth)]); + idx := cascade_cue_index(depth); + NSLog(xx cascade_cue_name(idx)); + AudioServicesPlaySystemSound(self.combo_ids[idx]); } play_win :: (self: *GameAudio) { inline if OS != .ios { return; } if !self.loaded { return; } + NSLog(xx "[sx] audio: cue win"); AudioServicesPlaySystemSound(self.win_id); } play_lose :: (self: *GameAudio) { inline if OS != .ios { return; } if !self.loaded { return; } + NSLog(xx "[sx] audio: cue lose"); AudioServicesPlaySystemSound(self.lose_id); } } +// The cascade cue's log line, one stable literal per combo clip so the play-time +// `log show` shows the clip stepping up with cascade depth. Literals only — the +// string→NSString bridge needs NUL-terminated bytes (a formatted string may not +// be). `idx` is a clamped `cascade_cue_index`, so it is always 0..COMBO_CLIPS-1. +cascade_cue_name :: (idx: s64) -> string { + if idx <= 0 { return "[sx] audio: cue combo1"; } + if idx == 1 { return "[sx] audio: cue combo2"; } + if idx == 2 { return "[sx] audio: cue combo3"; } + if idx == 3 { return "[sx] audio: cue combo4"; } + "[sx] audio: cue combo5" +} + // 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). @@ -144,8 +162,9 @@ load_system_sound :: (name: string) -> u32 { } // The process-wide instance. main() allocates + inits it; board_view triggers -// 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. +// the swap/match/cascade cues through the `sfx_*` shims on a committed gesture, +// and main's frame loop fires the win/lose stinger edge-triggered. Null until +// init, so every shim is a safe no-op before then. g_audio : *GameAudio = null; sfx_swap :: () { if g_audio != null { g_audio.play_swap(); } } diff --git a/board_view.sx b/board_view.sx index cf07434..906cc48 100644 --- a/board_view.sx +++ b/board_view.sx @@ -642,11 +642,18 @@ 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 (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); } + // SFX (P10.3): additive cues for the committed gesture — + // never reads or writes board/score/move state. The swap + // slide cue plays for any committed gesture (legal or the + // reverted ping-back); a legal move adds the match pop on its + // first clearing round; a multi-round chain adds the escalating + // cascade cue keyed to recorded depth (mv.rounds.len), distinct + // from the match pop so a single clear is never doubled. + sfx_swap(); + if mv.legal { + sfx_match(); + if mv.rounds.len >= 2 { 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 7c2d16f..7143d8a 100644 --- a/main.sx +++ b/main.sx @@ -80,6 +80,12 @@ g_motion : *GemMotion = null; // fire the landing squash-bounce on the exact frame a move settles. g_anim_prev_active : bool = false; +// Tracks whether the win/lose banner was up last frame, so the frame loop fires +// the win/lose stinger (P10.3) EXACTLY ONCE — on the frame the level settles +// terminal and any final cascade has played out — instead of replaying it every +// frame the banner is up. Re-armed when a restart reopens the level. +g_banner_prev_up : bool = false; + // Rebuilt each frame inside the pipeline's arena; carries the current safe-area // insets so the grid stays inside the notch / home-indicator region. build_ui :: () -> View { @@ -196,6 +202,17 @@ frame :: () { } } + // Win/lose stinger (P10.3): edge-trigger on the banner coming up — the level + // has settled won/lost AND any in-flight cascade has finished animating — so + // the stinger plays once as the banner appears, never every frame it is up. + // Status is read-only from the model (mirrors BoardView.banner_up); a restart + // reopens the level, dropping the edge so a fresh win/lose re-fires. + banner_now := level_status(g_board) != .in_progress and (g_anim == null or !g_anim.active); + if banner_now and !g_banner_prev_up { + if level_status(g_board) == .won { sfx_win(); } else { sfx_lose(); } + } + g_banner_prev_up = banner_now; + inline if OS == .ios { // Lazy-attach Metal once -[SxAppDelegate didFinishLaunching:] has // installed the SxMetalView and its bounds have been measured; both can