P8.1: minimal match/clear SFX via iOS System Sound Services (sx FFI)
Feasibility spike outcome: iOS audio from sx is feasible with no sx-library change. System Sound Services is plain C, reached with the same `#foreign` FFI uikit.sx already uses (UIApplicationMain / dlsym / CACurrentMediaTime); AudioToolbox + CoreFoundation are linked per-target in build.sx. Smallest viable SFX: one short CC0 clip (Kenney Interface Sounds, CC0 1.0) played when a swap clears a match. Purely additive — audio.sx reads/writes no score/board/move state; the wiring in board_view only adds a call. - audio.sx: load clear.wav once, AudioServicesPlaySystemSound on clear - board_view.sx: trigger sfx_clear() on a legal swap that clears (>=1 round) - main.sx: allocate + init g_audio at boot - build.sx: link AudioToolbox + CoreFoundation on iOS - assets/audio/clear.wav (+ one-line CC0 credit in LICENSE.txt) Verified: ios-sim build links; 18/18 tests pass; sim boot log shows "[sx] audio: clear cue loaded" (AudioServicesCreateSystemSoundID succeeded, asset shipped in the bundle and decoded).
This commit is contained in:
9
assets/audio/LICENSE.txt
Normal file
9
assets/audio/LICENSE.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
m3te sound effects
|
||||||
|
==================
|
||||||
|
|
||||||
|
clear.wav — "confirmation_001" from Kenney's Interface Sounds pack
|
||||||
|
(www.kenney.nl), licensed CC0 1.0 (public domain):
|
||||||
|
https://creativecommons.org/publicdomain/zero/1.0/
|
||||||
|
|
||||||
|
Converted to mono 44.1 kHz signed-16-bit PCM WAV (afconvert) — the format
|
||||||
|
iOS System Sound Services loads directly via audio.sx.
|
||||||
BIN
assets/audio/clear.wav
Normal file
BIN
assets/audio/clear.wav
Normal file
Binary file not shown.
88
audio.sx
Normal file
88
audio.sx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
// iOS sound effect via AudioToolbox System Sound Services (P8.1).
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 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/std/objc.sx";
|
||||||
|
#import "modules/compiler.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";
|
||||||
|
|
||||||
|
// Loaded once at startup; `play` is then a single C call per cleared match.
|
||||||
|
GameAudio :: struct {
|
||||||
|
clear_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"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
play_clear :: (self: *GameAudio) {
|
||||||
|
inline if OS != .ios { return; }
|
||||||
|
if !self.loaded { return; }
|
||||||
|
AudioServicesPlaySystemSound(self.clear_id);
|
||||||
|
NSLog(xx "[sx] sfx clear");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a SystemSoundID for `assets/audio/<name>` (relative to the bundle).
|
||||||
|
// Returns 0 on any failure, which `play_clear` treats as "skip".
|
||||||
|
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 cue through `sfx_clear`. Null until init, so `sfx_clear` is a safe no-op
|
||||||
|
// before then.
|
||||||
|
g_audio : *GameAudio = null;
|
||||||
|
|
||||||
|
sfx_clear :: () {
|
||||||
|
if g_audio != null { g_audio.play_clear(); }
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
#import "board_fx.sx";
|
#import "board_fx.sx";
|
||||||
#import "gem_anim.sx";
|
#import "gem_anim.sx";
|
||||||
#import "swipe.sx";
|
#import "swipe.sx";
|
||||||
|
#import "audio.sx";
|
||||||
|
|
||||||
// Fraction of a cell each gem occupies; the remainder is margin so a gem sits
|
// Fraction of a cell each gem occupies; the remainder is margin so a gem sits
|
||||||
// inside its cell tile rather than touching the tile's edges.
|
// inside its cell tile rather than touching the tile's edges.
|
||||||
@@ -622,6 +623,10 @@ impl View for BoardView {
|
|||||||
mv := plan_and_commit(self.board, intent.a, intent.b);
|
mv := plan_and_commit(self.board, intent.a, intent.b);
|
||||||
if self.anim != null { self.anim.begin(mv); }
|
if self.anim != null { self.anim.begin(mv); }
|
||||||
if self.fx != null { self.fx.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(); }
|
||||||
self.sel.clear();
|
self.sel.clear();
|
||||||
} else {
|
} else {
|
||||||
if hit := self.layout.point_to_cell(start) {
|
if hit := self.layout.point_to_cell(start) {
|
||||||
|
|||||||
4
build.sx
4
build.sx
@@ -29,6 +29,10 @@ configure_build :: () {
|
|||||||
opts.add_framework("OpenGLES");
|
opts.add_framework("OpenGLES");
|
||||||
opts.add_framework("QuartzCore");
|
opts.add_framework("QuartzCore");
|
||||||
opts.add_framework("Metal");
|
opts.add_framework("Metal");
|
||||||
|
// System Sound Services SFX (audio.sx) — a short clip played when a
|
||||||
|
// swap clears a match. CoreFoundation supplies the file URL.
|
||||||
|
opts.add_framework("AudioToolbox");
|
||||||
|
opts.add_framework("CoreFoundation");
|
||||||
opts.add_asset_dir("assets", "assets");
|
opts.add_asset_dir("assets", "assets");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
main.sx
9
main.sx
@@ -18,6 +18,7 @@
|
|||||||
#import "board_anim.sx";
|
#import "board_anim.sx";
|
||||||
#import "board_fx.sx";
|
#import "board_fx.sx";
|
||||||
#import "gem_anim.sx";
|
#import "gem_anim.sx";
|
||||||
|
#import "audio.sx";
|
||||||
|
|
||||||
#run configure_build();
|
#run configure_build();
|
||||||
|
|
||||||
@@ -284,6 +285,14 @@ main :: () -> void {
|
|||||||
g_motion = xx context.allocator.alloc(size_of(GemMotion));
|
g_motion = xx context.allocator.alloc(size_of(GemMotion));
|
||||||
g_motion.init();
|
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.
|
||||||
|
g_audio = xx context.allocator.alloc(size_of(GameAudio));
|
||||||
|
memset(xx g_audio, 0, size_of(GameAudio));
|
||||||
|
g_audio.init();
|
||||||
|
|
||||||
// Deterministic-capture hooks: pin the animation clock and/or preselect a
|
// Deterministic-capture hooks: pin the animation clock and/or preselect a
|
||||||
// cell so the always-on idle (and the select reaction) screenshot the same
|
// cell so the always-on idle (and the select reaction) screenshot the same
|
||||||
// way every time. No env set → fully live.
|
// way every time. No env set → fully live.
|
||||||
|
|||||||
Reference in New Issue
Block a user