Merge branch 'flow/m3te/P10.3' into m3te-plan
This commit is contained in:
25
audio.sx
25
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(); } }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
17
main.sx
17
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
|
||||
|
||||
Reference in New Issue
Block a user