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).
329 lines
12 KiB
Plaintext
329 lines
12 KiB
Plaintext
#import "modules/std.sx";
|
|
#import "build.sx";
|
|
#import "modules/compiler.sx";
|
|
#import "modules/opengl.sx";
|
|
#import "modules/sdl3.sx";
|
|
#import "modules/math";
|
|
#import "modules/stb.sx";
|
|
#import "modules/stb_truetype.sx";
|
|
#import "modules/gpu/api.sx";
|
|
#import "modules/gpu/types.sx";
|
|
#import "modules/gpu/metal.sx";
|
|
#import "modules/ui";
|
|
#import "modules/platform/api.sx";
|
|
#import "modules/platform/sdl3.sx";
|
|
#import "modules/platform/uikit.sx";
|
|
#import "board.sx";
|
|
#import "board_view.sx";
|
|
#import "board_anim.sx";
|
|
#import "board_fx.sx";
|
|
#import "gem_anim.sx";
|
|
#import "audio.sx";
|
|
|
|
#run configure_build();
|
|
|
|
// libc is the implicit foreign-library handle the std allocators bind against;
|
|
// reused here to read the deterministic-capture environment variables at startup.
|
|
getenv :: (name: [:0]u8) -> *u8 #foreign libc "getenv";
|
|
strlen :: (s: *u8) -> usize #foreign libc "strlen";
|
|
|
|
// Fixed seed for the rendered board — the same seed tests/board_init.sx locks
|
|
// as a snapshot, so the on-screen layout matches that golden gem-for-gem.
|
|
BOARD_SEED :: 1337;
|
|
|
|
g_plat : Platform = ---;
|
|
g_pipeline : *UIPipeline = ---;
|
|
g_delta_time : f32 = 0.016;
|
|
g_viewport_w : f32 = 800.0;
|
|
g_viewport_h : f32 = 600.0;
|
|
g_safe_insets : EdgeInsets = .{};
|
|
|
|
// iOS-only concrete handles kept alongside the boxed `g_plat` so the frame loop
|
|
// can reach the CAMetalLayer pointer / pixel dims without going through the
|
|
// protocol box.
|
|
g_uikit_plat : *UIKitPlatform = null;
|
|
g_metal_gpu : *MetalGPU = null;
|
|
|
|
// The pure-sx model (board.sx) and its sprites, seeded once in main() and
|
|
// rendered every frame. Heap-allocated so the view holds stable pointers to
|
|
// the mutable state across frames.
|
|
g_board : *Board = null;
|
|
g_assets : *BoardAssets = null;
|
|
|
|
// Current cell selection (P4.4). Heap-allocated so it survives BoardView's
|
|
// per-frame rebuild; a tap hit-tests a cell and toggles this.
|
|
g_sel : *BoardSelection = null;
|
|
|
|
// In-progress touch drag (P5.2). Heap-allocated for the same reason: the press
|
|
// and release that bracket a swipe land on different per-frame BoardView values,
|
|
// so the drag start must persist between them.
|
|
g_drag : *DragInput = null;
|
|
|
|
// In-flight move animation (P6.1). Heap-allocated for the same reason: a swipe
|
|
// begins the swap/clear/fall timeline, which then plays out over many subsequent
|
|
// frames, so the timeline state must persist across BoardView's per-frame rebuild.
|
|
g_anim : *BoardAnim = null;
|
|
|
|
// Transient match FX + score popups (P6.2). Heap-allocated like the animation:
|
|
// a committed move spawns short-lived bursts/popups that play out (and prune
|
|
// themselves) over many later frames. `g_fxassets` holds the per-colour tinted
|
|
// particle textures, loaded once. Purely visual; neither gates input.
|
|
g_fx : *BoardFx = null;
|
|
g_fxassets : *BoardFxAssets = null;
|
|
|
|
// Per-gem idle/select/land animation state (P6.3). Heap-allocated like the rest:
|
|
// `clock` advances by delta_time each frame (or is pinned by capture mode) and
|
|
// drives every per-gem pose. Purely visual; does not gate input.
|
|
g_motion : *GemMotion = null;
|
|
|
|
// Tracks whether the move timeline was active last frame, so the frame loop can
|
|
// fire the landing squash-bounce on the exact frame a move settles.
|
|
g_anim_prev_active : 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 {
|
|
BoardView.{ board = g_board, assets = g_assets, sel = g_sel, drag = g_drag, anim = g_anim, fx = g_fx, fxassets = g_fxassets, motion = g_motion, safe = g_safe_insets, seed = BOARD_SEED }
|
|
}
|
|
|
|
// Deterministic capture (P6.3). The idle loop is always-on, so a live screenshot
|
|
// would be time-dependent; these env hooks pin the visual state so goldens are
|
|
// reproducible. M3TE_ANIM_TIME=<seconds> freezes the animation clock at a chosen
|
|
// phase (t==0 is the resting board, identical to the pre-P6.3 goldens). Optional
|
|
// M3TE_SELECT=<cellIndex 0..63> forces a selection so the select-pop reaction can
|
|
// be captured without injecting a tap. Absent → normal live behaviour.
|
|
read_env :: (name: [:0]u8) -> ?string {
|
|
p := getenv(name);
|
|
addr : s64 = xx p;
|
|
if addr == 0 { return null; }
|
|
n := cast(s64) strlen(p);
|
|
if n == 0 { return ""; }
|
|
buf := cstring(n);
|
|
memcpy(buf.ptr, xx p, n);
|
|
buf
|
|
}
|
|
|
|
// Digit arithmetic runs entirely in s64; the result converts to f32 only once at
|
|
// the end. Doing the digit math in f32 would unify the ASCII literals (45/46/48/
|
|
// 57) to f32 across the comparisons, which mis-types the byte compares.
|
|
parse_f32 :: (s: string) -> f32 {
|
|
i : s64 = 0;
|
|
neg : bool = false;
|
|
if s.len > 0 {
|
|
c0 : s64 = xx s[0];
|
|
if c0 == 45 { neg = true; i = 1; } // '-'
|
|
}
|
|
intval : s64 = 0;
|
|
while i < s.len {
|
|
c : s64 = xx s[i];
|
|
if c < 48 or c > 57 { break; }
|
|
intval = intval * 10 + (c - 48);
|
|
i += 1;
|
|
}
|
|
fracval : s64 = 0;
|
|
fracdiv : s64 = 1;
|
|
if i < s.len {
|
|
d : s64 = xx s[i];
|
|
if d == 46 { // '.'
|
|
i += 1;
|
|
while i < s.len {
|
|
c : s64 = xx s[i];
|
|
if c < 48 or c > 57 { break; }
|
|
fracval = fracval * 10 + (c - 48);
|
|
fracdiv = fracdiv * 10;
|
|
i += 1;
|
|
}
|
|
}
|
|
}
|
|
v : f32 = cast(f32) intval + cast(f32) fracval / cast(f32) fracdiv;
|
|
if neg { v = 0.0 - v; }
|
|
v
|
|
}
|
|
|
|
parse_s64 :: (s: string) -> s64 {
|
|
i : s64 = 0;
|
|
v : s64 = 0;
|
|
while i < s.len {
|
|
c : s64 = xx s[i];
|
|
if c < 48 or c > 57 { break; }
|
|
v = v * 10 + (c - 48);
|
|
i += 1;
|
|
}
|
|
v
|
|
}
|
|
|
|
frame :: () {
|
|
fc := g_plat.begin_frame();
|
|
g_delta_time = fc.delta_time;
|
|
g_viewport_w = fc.viewport_w;
|
|
g_viewport_h = fc.viewport_h;
|
|
g_safe_insets = g_plat.safe_insets();
|
|
|
|
if fc.viewport_w != g_pipeline.screen_width or fc.viewport_h != g_pipeline.screen_height {
|
|
g_pipeline.resize(fc.viewport_w, fc.viewport_h);
|
|
}
|
|
|
|
for g_plat.poll_events(): (*ev) {
|
|
inline if OS != .ios {
|
|
if ev == {
|
|
case .key_up: (e) {
|
|
if e.key == .escape { g_plat.stop(); }
|
|
}
|
|
}
|
|
}
|
|
g_pipeline.dispatch_event(ev);
|
|
}
|
|
|
|
// Advance the in-flight move animation by this frame's delta before rendering,
|
|
// so the board view draws the timeline slice for the current wall-clock time.
|
|
if g_anim != null { g_anim.tick(g_delta_time); }
|
|
if g_fx != null { g_fx.tick(g_delta_time); }
|
|
|
|
// Advance the always-on per-gem animation clock (idle/select/land). Capture
|
|
// mode pins the clock, so it only moves when not pinned. On the exact frame a
|
|
// move timeline settles, stamp the landing bounce on every cell the move
|
|
// changed, so the gems that actually moved squash-bounce on settle.
|
|
if g_motion != null {
|
|
if !g_motion.pinned { g_motion.clock += g_delta_time; }
|
|
if g_anim != null {
|
|
if g_anim_prev_active and !g_anim.active {
|
|
mv := @g_anim.move;
|
|
for 0..BOARD_CELLS: (i) {
|
|
if mv.pre[i] != mv.final[i] { g_motion.stamp_land(i); }
|
|
}
|
|
}
|
|
g_anim_prev_active = g_anim.active;
|
|
}
|
|
}
|
|
|
|
inline if OS == .ios {
|
|
// Lazy-attach Metal once -[SxAppDelegate didFinishLaunching:] has
|
|
// installed the SxMetalView and its bounds have been measured; both can
|
|
// lag the first CADisplayLink tick, and a zero-sized drawable aborts
|
|
// via XPC.
|
|
if g_uikit_plat.gl_layer == null { return; }
|
|
if g_uikit_plat.pixel_w <= 0 or g_uikit_plat.pixel_h <= 0 { return; }
|
|
if g_metal_gpu.layer == null {
|
|
g_metal_gpu.init(g_uikit_plat.gl_layer, g_uikit_plat.pixel_w, g_uikit_plat.pixel_h);
|
|
} else if g_metal_gpu.pixel_w != g_uikit_plat.pixel_w or g_metal_gpu.pixel_h != g_uikit_plat.pixel_h {
|
|
g_metal_gpu.resize(g_uikit_plat.pixel_w, g_uikit_plat.pixel_h);
|
|
}
|
|
clear : ClearColor = .{ r = 0.05, g = 0.06, b = 0.10, a = 1.0 };
|
|
if !g_metal_gpu.begin_frame(clear) { return; }
|
|
g_pipeline.tick();
|
|
g_metal_gpu.end_frame(fc.target_present_time);
|
|
} else {
|
|
glViewport(0, 0, fc.pixel_w, fc.pixel_h);
|
|
glClearColor(0.05, 0.06, 0.10, 1.0);
|
|
glClear(GL_COLOR_BUFFER_BIT);
|
|
g_pipeline.tick();
|
|
}
|
|
g_plat.end_frame();
|
|
}
|
|
|
|
main :: () -> void {
|
|
inline if OS == .ios {
|
|
u : *UIKitPlatform = xx context.allocator.alloc(size_of(UIKitPlatform));
|
|
u.gpu_mode = .metal;
|
|
if !u.init("m3te", 800, 600) { return; }
|
|
g_plat = xx u;
|
|
g_uikit_plat = u;
|
|
|
|
// The CAMetalLayer doesn't exist until didFinishLaunching: runs after we
|
|
// return into UIApplicationMain, so attach lazily on the first frame.
|
|
// init(null, 0, 0) only needs the MTLDevice, which is enough for the
|
|
// texture uploads below.
|
|
g_metal_gpu = xx context.allocator.alloc(size_of(MetalGPU));
|
|
// alloc returns uninitialized memory; struct field defaults are NOT
|
|
// applied, so List caps/lens would be garbage without this memset.
|
|
memset(xx g_metal_gpu, 0, size_of(MetalGPU));
|
|
if !g_metal_gpu.init(null, 0, 0) { return; }
|
|
} else {
|
|
s : *SdlPlatform = xx context.allocator.alloc(size_of(SdlPlatform));
|
|
if !s.init("m3te", 800, 600) { return; }
|
|
g_plat = xx s;
|
|
}
|
|
|
|
fc := g_plat.begin_frame();
|
|
g_viewport_w = fc.viewport_w;
|
|
g_viewport_h = fc.viewport_h;
|
|
g_safe_insets = g_plat.safe_insets();
|
|
|
|
g_pipeline = xx context.allocator.alloc(size_of(UIPipeline));
|
|
// Same alloc caveat as above: zero so the optional `gpu` reads as null on
|
|
// the desktop path (where set_gpu is not called) and the Lists start empty.
|
|
memset(xx g_pipeline, 0, size_of(UIPipeline));
|
|
inline if OS == .ios {
|
|
g_pipeline.set_gpu(xx g_metal_gpu);
|
|
}
|
|
g_pipeline.init(fc.viewport_w, fc.viewport_h);
|
|
g_pipeline.init_font("assets/fonts/default.ttf", 32.0, fc.dpi_scale);
|
|
|
|
g_board = xx context.allocator.alloc(size_of(Board));
|
|
g_board.init(BOARD_SEED);
|
|
|
|
g_assets = xx context.allocator.alloc(size_of(BoardAssets));
|
|
g_assets.init();
|
|
g_assets.load(g_pipeline.gpu);
|
|
|
|
g_sel = xx context.allocator.alloc(size_of(BoardSelection));
|
|
g_sel.init();
|
|
|
|
g_drag = xx context.allocator.alloc(size_of(DragInput));
|
|
g_drag.init();
|
|
|
|
g_anim = xx context.allocator.alloc(size_of(BoardAnim));
|
|
g_anim.init();
|
|
|
|
g_fx = xx context.allocator.alloc(size_of(BoardFx));
|
|
g_fx.init();
|
|
|
|
g_fxassets = xx context.allocator.alloc(size_of(BoardFxAssets));
|
|
g_fxassets.init();
|
|
g_fxassets.load(g_pipeline.gpu);
|
|
|
|
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.
|
|
if t := read_env("M3TE_ANIM_TIME") {
|
|
g_motion.pinned = true;
|
|
g_motion.clock = parse_f32(t);
|
|
}
|
|
if sc := read_env("M3TE_SELECT") {
|
|
idx := parse_s64(sc);
|
|
if idx >= 0 and idx < BOARD_CELLS {
|
|
g_sel.active = true;
|
|
g_sel.cell = Cell.{ col = idx % BOARD_COLS, row = idx / BOARD_COLS };
|
|
g_sel.since = g_motion.clock;
|
|
}
|
|
}
|
|
|
|
// Level-state capture hooks (P7.2): override the goal / move budget so a
|
|
// terminal status can be screenshot without scripting a swipe. M3TE_TARGET=0
|
|
// makes the fresh board read WON immediately (score 0 ≥ goal 0);
|
|
// M3TE_MOVE_LIMIT=0 makes it read LOST (budget spent below the goal). With
|
|
// M3TE_RESTART set non-zero the board is then restart()-ed, capturing the
|
|
// fresh in_progress board the restart button produces.
|
|
if tg := read_env("M3TE_TARGET") { g_board.target_score = parse_s64(tg); }
|
|
if ml := read_env("M3TE_MOVE_LIMIT") { g_board.move_limit = parse_s64(ml); }
|
|
if rs := read_env("M3TE_RESTART") {
|
|
if parse_s64(rs) != 0 { g_board.restart(BOARD_SEED); }
|
|
}
|
|
|
|
g_pipeline.set_body(closure(build_ui));
|
|
|
|
g_plat.run_frame_loop(closure(frame));
|
|
g_plat.shutdown();
|
|
}
|