diff --git a/assets/audio/LICENSE.txt b/assets/audio/LICENSE.txt new file mode 100644 index 0000000..783d8a6 --- /dev/null +++ b/assets/audio/LICENSE.txt @@ -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. diff --git a/assets/audio/clear.wav b/assets/audio/clear.wav new file mode 100644 index 0000000..1ddd5e5 Binary files /dev/null and b/assets/audio/clear.wav differ diff --git a/audio.sx b/audio.sx new file mode 100644 index 0000000..9254ef5 --- /dev/null +++ b/audio.sx @@ -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/` (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(); } +} diff --git a/board_view.sx b/board_view.sx index 0c27434..a05e925 100644 --- a/board_view.sx +++ b/board_view.sx @@ -21,6 +21,7 @@ #import "board_fx.sx"; #import "gem_anim.sx"; #import "swipe.sx"; +#import "audio.sx"; // 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. @@ -622,6 +623,10 @@ 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(); } self.sel.clear(); } else { if hit := self.layout.point_to_cell(start) { diff --git a/build.sx b/build.sx index d7ce4fc..b5ccb9d 100644 --- a/build.sx +++ b/build.sx @@ -29,6 +29,10 @@ configure_build :: () { opts.add_framework("OpenGLES"); opts.add_framework("QuartzCore"); 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"); } } diff --git a/main.sx b/main.sx index 953bfea..fcd91ba 100644 --- a/main.sx +++ b/main.sx @@ -18,6 +18,7 @@ #import "board_anim.sx"; #import "board_fx.sx"; #import "gem_anim.sx"; +#import "audio.sx"; #run configure_build(); @@ -284,6 +285,14 @@ 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. + 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 // cell so the always-on idle (and the select reaction) screenshot the same // way every time. No env set → fully live.