#import "modules/std.sx"; #import "build.sx"; #import "modules/build.sx"; #import "modules/ffi/opengl.sx"; #import "modules/ffi/sdl3.sx"; #import "modules/math"; #import "vendors/stb_image/stb_image.sx"; #import "vendors/stb_truetype/stb_truetype.sx"; #import "vendors/kb_text_shape/kb_text_shape.sx"; #import "vendors/file_utils/file_utils.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. // 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; // Cleared-surface tone for both GPU paths (Metal on iOS, GL on desktop). Tuned to // the candy background's mid-gradient lavender so the clear never reads as a dark // seam past the background art; one source keeps the two paths from diverging. CLEAR_R :f32: 0.765; CLEAR_G :f32: 0.733; CLEAR_B :f32: 0.933; 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 = .{}; // FPS dev overlay (P20.1). OFF unless the M3TE_FPS env pin is set, so default // play and every committed golden stay byte-identical. `g_fps_avg_dt` is an // exponential moving average of the per-frame delta, smoothed so the readout // doesn't jitter wildly; the displayed FPS is its reciprocal. Both are only // touched on the gated path, so the unset path is unchanged. FPS_DT_SMOOTH :f32: 0.9; // weight on the running average vs. this frame's delta g_fps_on : bool = false; g_fps_avg_dt : f32 = 0.016; // 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; // 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 { fps : f32 = if g_fps_avg_dt > 0.0 then 1.0 / g_fps_avg_dt else 0.0; 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, fps_on = g_fps_on, fps = fps } } // 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= freezes the animation clock at a chosen // phase (t==0 is the resting board, identical to the pre-P6.3 goldens). Optional // M3TE_SELECT= 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 { process.env(name) } // Digit arithmetic runs entirely in i64; 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 : i64 = 0; neg : bool = false; if s.len > 0 { c0 : i64 = xx s[0]; if c0 == 45 { neg = true; i = 1; } // '-' } intval : i64 = 0; while i < s.len { c : i64 = xx s[i]; if c < 48 or c > 57 { break; } intval = intval * 10 + (c - 48); i += 1; } fracval : i64 = 0; fracdiv : i64 = 1; if i < s.len { d : i64 = xx s[i]; if d == 46 { // '.' i += 1; while i < s.len { c : i64 = 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_i64 :: (s: string) -> i64 { i : i64 = 0; v : i64 = 0; while i < s.len { c : i64 = xx s[i]; if c < 48 or c > 57 { break; } v = v * 10 + (c - 48); i += 1; } v } // The orthogonally-adjacent pairs that are currently ILLEGAL — the exact // COMPLEMENT of `legal_swaps`, enumerated in the SAME stable row-major order (each // cell's right neighbour before its down neighbour, each adjacency visited once). // Drives the M3TE_BADSWAP capture hook, which needs a KNOWN rejected pair on the // fixed seed to screenshot the springy bounce-back. Headless and read-only — the // trial swaps inside `swap_legal` are reverted, so the board is left unchanged. illegal_swaps :: (board: *Board) -> List(Swap) { result := List(Swap).{}; for 0..BOARD_ROWS (row) { for 0..BOARD_COLS (col) { here := Cell.{ col = col, row = row }; if col + 1 < BOARD_COLS { right := Cell.{ col = col + 1, row = row }; if !swap_legal(board, here, right) { result.append(Swap.{ a = here, b = right }); } } if row + 1 < BOARD_ROWS { down := Cell.{ col = col, row = row + 1 }; if !swap_legal(board, here, down) { result.append(Swap.{ a = here, b = down }); } } } } result } 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(); // FPS dev overlay (P20.1): advance the smoothed frame-time average ONLY when // the env pin enabled it, so an unset run never touches this and renders // byte-identically. delta_time is real wall-clock even when M3TE_ANIM_TIME // pins the animation clock, so the readout is live while the scene is frozen. if g_fps_on and g_delta_time > 0.0 { g_fps_avg_dt = g_fps_avg_dt * FPS_DT_SMOOTH + g_delta_time * (1.0 - FPS_DT_SMOOTH); } 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 + its match FX by this frame's delta // before rendering, so the board view draws the timeline slice for the current // wall-clock time. Capture mode pins the animation clock (M3TE_ANIM_TIME); // while pinned the move/FX timelines stay frozen at the phase the startup // hooks set, so the FX-match scene (M3TE_FX) screenshots identically each run. if g_motion == null or !g_motion.pinned { if g_anim != null { g_anim.tick(g_delta_time); } if g_fx != null { g_fx.tick(g_delta_time); } } // Per-round cascade SFX (P10.10): as each cascade round's clear begins on the // move timeline, play the NEXT ascending combo cue (round 1 → combo1, round 2 // → combo2, … clamped at combo5). Edge-triggered off `cascade_fired` so each // round's cue fires exactly once; only a real multi-round chain (rounds >= 2) // gets the run, so a single match stays the lone match pop. Additive — reads // only the recorded timeline, never board/score/move state. if g_anim != null and g_anim.move.rounds.len >= 2 { started := cascade_rounds_started(g_anim.elapsed, g_anim.move.rounds.len); while g_anim.cascade_fired < started { g_anim.cascade_fired += 1; sfx_cascade(g_anim.cascade_fired); } } // 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 { // On the frame the timeline settles, hand the final round's // per-column landing bounce to land_squash so render_gems resumes it // seamlessly. Each gem the LAST round delivered to cell i is // back-dated to when its column actually touched down — (1 - // fall_landing_frac)·FALL_ANIM_DUR ago — so the bounce picks up // exactly where render_fall left it: one bounce, no double-pop at the // render_anim → render_gems seam. A gem that settled in an earlier // round already bounced then (its back-dated age exceeds LAND_DUR, so // land_squash reads rest); a gem that never moved is skipped. mv := @g_anim.move; total := g_anim.total(); last := mv.rounds.len - 1; for 0..BOARD_CELLS (i) { m := delivering_round(mv, i, last); if m >= 0 { col := i % BOARD_COLS; g_motion.stamp_land_at(i, g_motion.clock - (total - round_land_time(m, col))); } } } g_anim_prev_active = g_anim.active; } } // 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 // 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 = CLEAR_R, g = CLEAR_G, b = CLEAR_B, 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(CLEAR_R, CLEAR_G, CLEAR_B, 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_bytes(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_bytes(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_bytes(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_bytes(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_bytes(size_of(Board)); g_board.init(BOARD_SEED); g_assets = xx context.allocator.alloc_bytes(size_of(BoardAssets)); g_assets.init(); g_assets.load(g_pipeline.gpu); g_sel = xx context.allocator.alloc_bytes(size_of(BoardSelection)); g_sel.init(); g_drag = xx context.allocator.alloc_bytes(size_of(DragInput)); g_drag.init(); g_anim = xx context.allocator.alloc_bytes(size_of(BoardAnim)); g_anim.init(); g_fx = xx context.allocator.alloc_bytes(size_of(BoardFx)); g_fx.init(); g_fxassets = xx context.allocator.alloc_bytes(size_of(BoardFxAssets)); g_fxassets.init(); g_fxassets.load(g_pipeline.gpu); g_motion = xx context.allocator.alloc_bytes(size_of(GemMotion)); g_motion.init(); // SFX (P10.2). Loads the System Sound Services cue bank once; board_view // plays a cue per event. Purely additive — never touches score/board/move // state. On iOS the platform has already chdir'd to the bundle, so each // cue's relative path resolves. No-op off iOS. g_audio = xx context.allocator.alloc_bytes(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_i64(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; } } // FPS dev-overlay hook (P20.1): a non-zero M3TE_FPS turns on the corner FPS // readout. Default (unset / =0) leaves it off, so normal play and every // committed golden stay byte-identical. Purely a render overlay — no board / // score / move / animation state changes and it never gates input. if fp := read_env("M3TE_FPS") { if parse_i64(fp) != 0 { g_fps_on = true; } } // Match-FX capture hook (P11.1). The bursts/popups spawn off a committed move, // which the sim can't script (no public touch injection), so M3TE_FX forces a // representative match at startup the same way a swipe would: it commits the // n-th currently-legal swap (1-based, clamped; =1 is the first) via the normal // plan_and_commit path, then begins the move timeline + its FX. M3TE_ANIM_TIME // pins the phase — advancing both to that time, after which the frozen frame // loop holds them there — so the burst + "+points" popup screenshot identically // every run. A larger M3TE_ANIM_TIME lands past the timeline, capturing the // settled board with the FX fully pruned. Startup-only and unset → fully live. if fx := read_env("M3TE_FX") { swaps := legal_swaps(g_board); if swaps.len > 0 { n := parse_i64(fx); if n < 1 { n = 1; } if n > swaps.len { n = swaps.len; } sw := swaps.items[n - 1]; mv := plan_and_commit(g_board, sw.a, sw.b); g_anim.begin(mv); g_fx.begin(@mv); g_anim.tick(g_motion.clock); g_fx.tick(g_motion.clock); } } // Illegal-swap bounce capture hook (P16.2). The springy bounce-back plays only // for a REJECTED swap, which the sim can't script (no public touch injection), // so M3TE_BADSWAP forces one at startup the way a swipe would. It commits the // n-th currently-ILLEGAL orthogonally-adjacent pair — the complement of // legal_swaps, enumerated in the SAME row-major order, 1-based + clamped — via // the normal plan_and_commit (which reverts an illegal swap: zero rounds, board // byte-identical, no score/move spent), then begins the move timeline and ticks // it to M3TE_ANIM_TIME so the swap-segment bounce screenshots identically. No FX // begins — a rejected swap clears nothing. Startup-only and unset → fully live. if bs := read_env("M3TE_BADSWAP") { bad := illegal_swaps(g_board); if bad.len > 0 { n := parse_i64(bs); if n < 1 { n = 1; } if n > bad.len { n = bad.len; } sw := bad.items[n - 1]; mv := plan_and_commit(g_board, sw.a, sw.b); g_anim.begin(mv); g_anim.tick(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_i64(tg); } if ml := read_env("M3TE_MOVE_LIMIT") { g_board.move_limit = parse_i64(ml); } if rs := read_env("M3TE_RESTART") { if parse_i64(rs) != 0 { g_board.restart(BOARD_SEED); } } g_pipeline.set_body(closure(build_ui)); g_plat.run_frame_loop(closure(frame)); g_plat.shutdown(); }