modules/compiler.sx -> modules/build.sx; stb/stb_truetype/opengl/sdl3 -> modules/ffi/; modules/process.sx -> modules/std/process.sx. Rebuilt for macos + ios-sim; 23/23 logic snapshots pass.
175 lines
7.7 KiB
Plaintext
175 lines
7.7 KiB
Plaintext
// 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 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 —
|
|
// no sx-library change. The AudioToolbox + CoreFoundation frameworks are linked
|
|
// per-target in build.sx. Every call is guarded by `inline if OS == .ios`, so
|
|
// other targets never reference these symbols nor need the frameworks.
|
|
|
|
#import "modules/std.sx";
|
|
#import "modules/ffi/objc.sx";
|
|
#import "modules/build.sx";
|
|
|
|
// AudioToolbox — System Sound Services. SystemSoundID is a UInt32; OSStatus a
|
|
// SInt32 (0 == noErr); the clip's file is passed as a CFURLRef (opaque ptr).
|
|
AudioServicesCreateSystemSoundID :: (url: *void, out_id: *u32) -> s32 #foreign;
|
|
AudioServicesPlaySystemSound :: (sound_id: u32) #foreign;
|
|
|
|
// CoreFoundation — build a file CFURL from an absolute path. `len` is a CFIndex
|
|
// (long); `is_dir` a Boolean (unsigned char); a NULL allocator = default.
|
|
CFURLCreateFromFileSystemRepresentation :: (allocator: *void, buffer: *u8, len: s64, is_dir: s8) -> *void #foreign;
|
|
CFRelease :: (cf: *void) #foreign;
|
|
|
|
// libc — getcwd to absolutize the bundle-relative asset path. The platform
|
|
// chdir's to the bundle's resource dir at boot, so CWD is the .app and the
|
|
// game's other relative `assets/...` loads already resolve against it.
|
|
getcwd :: (buf: *u8, size: usize) -> *u8 #foreign;
|
|
c_strlen :: (s: *u8) -> usize #foreign "strlen";
|
|
|
|
// 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 {
|
|
swap_id: u32;
|
|
match_id: u32;
|
|
combo_ids: [COMBO_CLIPS]u32;
|
|
win_id: u32;
|
|
lose_id: u32;
|
|
loaded: bool;
|
|
|
|
init :: (self: *GameAudio) {
|
|
self.loaded = false;
|
|
inline if OS != .ios { return; }
|
|
|
|
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_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);
|
|
}
|
|
|
|
// 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; }
|
|
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).
|
|
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/<name>` (relative to the bundle).
|
|
// Returns 0 on any failure.
|
|
load_system_sound :: (name: string) -> u32 {
|
|
inline if OS != .ios { return 0; }
|
|
|
|
cwd_buf : [1024]u8 = ---;
|
|
if getcwd(@cwd_buf[0], 1024) == null { return 0; }
|
|
cwd : string = ---;
|
|
cwd.ptr = @cwd_buf[0];
|
|
cwd.len = cast(s64) c_strlen(@cwd_buf[0]);
|
|
|
|
// CFURLCreateFromFileSystemRepresentation takes an explicit byte length, so
|
|
// the formatted path needs no NUL terminator.
|
|
path := format("{}/assets/audio/{}", cwd, name);
|
|
|
|
url := CFURLCreateFromFileSystemRepresentation(null, path.ptr, path.len, 0);
|
|
if url == null { return 0; }
|
|
|
|
sound_id : u32 = 0;
|
|
status := AudioServicesCreateSystemSoundID(url, @sound_id);
|
|
CFRelease(url);
|
|
if status != 0 { return 0; }
|
|
sound_id
|
|
}
|
|
|
|
// The process-wide instance. main() allocates + inits it; board_view triggers
|
|
// 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(); } }
|
|
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(); } }
|