Compare commits

..

10 Commits

Author SHA1 Message Date
swipelab
31d1012806 shed local vendors: stb + kb_text_shape + file_utils now ship with sx
The local vendors/ copies existed because the old modules/ffi/stb*.sx
resolved C paths CWD-relative, forcing every consumer to carry
identically-named copies. sx now ships these as proper library vendors
(#import "vendors/<name>/<name>.sx"), so the copies and the retired
ffi module imports both go. Verified: sx build --target ios-sim
bundles M3te.app; tools/run_tests.sh 23/23.
2026-06-12 18:35:12 +03:00
swipelab
39740a1d36 migrate to sx cstring era: std env() replaces local getenv/strlen, alloc_string rename
sx 1d17b0a reserves 'cstring' as the C-boundary string type and renames
std's cstring(size) allocator to alloc_string; std getenv is now
(cstring) -> ?cstring, so the local conflicting binding (caught by the
new same-symbol diagnostic) and its strlen/copy loop collapse into a
process.env delegation. iOS-sim build + 22/22 snapshots green.
2026-06-12 14:57:59 +03:00
swipelab
bb728d0ab0 migrate restart to opt-in UFCS (sx a47ea14)
Free function restart(board, seed) is dot-called from main.sx and
board_view.sx; the sx opt-in UFCS change gates plain functions out of
dot-dispatch, so declare it ufcs. ios-sim build green, 23/23 logic
tests.
2026-06-12 09:37:35 +03:00
swipelab
6f7d2f4db2 lang migration: rename signed integer types sN -> iN
Mechanical sweep of all .sx sources, plan docs, and tests/expected
snapshots for the sx language rename (s8/s16/s32/s64 -> i8/i16/i32/i64).
Verified: tools/run_tests.sh 23/23.

Note: the ios-sim build has 2 pre-existing 'restart' dot-call errors
from the sx opt-in UFCS change (sx a47ea14) — independent of this
rename (present pre-sweep); migrated in the follow-up commit.
2026-06-12 09:36:51 +03:00
swipelab
1ab74c7d08 migrate allocator calls to alloc_bytes / libc_free 2026-06-12 09:34:13 +03:00
swipelab
38815c7d50 migrate to the restructured sx stdlib paths
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.
2026-06-11 08:46:32 +03:00
swipelab
a7b41ccbca migrate to the new for-loop syntax
Drop the ':' before captures (for xs (x) / for 0..n (i)); the index
capture becomes the trailing open range (for xs, 0.. (x, i)). 136
headers across 26 files, mechanical.

Five headless tests (banner_layout, hit_test, swipe_commit,
swipe_intent, swipe_reshuffle) also gain a direct
#import "modules/ui/types.sx" — they named Point/Frame through a
transitive import, which bare visibility no longer permits.

Gates: sx build --target ios-sim main.sx links; tools/run_tests.sh
23/23.
2026-06-10 20:39:59 +03:00
swipelab
5a0627bb7c Merge branch 'm3te-plan' 2026-06-06 15:18:13 +03:00
swipelab
69e2c1f50d Merge branch 'flow/m3te/fix-final-2' into m3te-plan 2026-06-06 15:00:13 +03:00
swipelab
cd89a5c9c0 FX2: wire no-moves reshuffle into the UI swipe-commit path
The rendered swipe-commit path (`plan_and_commit`) bypassed the turn-loop's
no-moves rule: a deadlocked board (no legal swap) stayed stuck on screen because
only `play_turn` checked `!has_legal_swap` and reshuffled, and the UI never calls
`play_turn`.

Factor the post-settle "no legal swaps -> reshuffle" check into a shared
`reshuffle_if_deadlocked` in board.sx and call it from BOTH `play_turn` and
`plan_and_commit`, so the animated UI commit obeys the identical model rule. The
reshuffle runs after the cascade settles (post-`commit_swap`); the AnimMove's
recorded `final` stays the settled pre-reshuffle board, so the cascade animation,
per-round audio, and input gating are unchanged — the reshuffled layout renders on
the next settled frame. No win/lose/turn-accounting change; a reshuffle spends no
move and no score.

Regression test tests/swipe_reshuffle.sx drives the exact UI path (swipe_intent ->
plan_and_commit) on the deadlocked board from tests/level.sx: before = no legal
swaps / in_progress; after = reshuffled (has_legal_swap true, 9 legal swaps, no
immediate match), score/moves/budget unchanged. It FAILS pre-fix (board stays
stuck, has_legal_swap false) and PASSES post-fix.
2026-06-06 14:55:38 +03:00
52 changed files with 711 additions and 46087 deletions

29
.vscode/ios-sim-debug.sh vendored Executable file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# preLaunchTask for "Debug SxChess (iOS sim)": build SxChess for the iOS
# simulator with debug info, install it to a single simulator, and launch it
# PAUSED for a debugger (--wait-for-debugger). The launch.json `custom` config
# then `target create`s the local binary (so the .dSYM resolves breakpoints)
# and `process attach -n SxChess`es the waiting process.
set -e
SX="/Users/agra/projects/sx/zig-out/bin/sx"
DSYMUTIL="/opt/homebrew/opt/llvm@19/bin/dsymutil"
# Reuse ONE simulator (sx-test-ios18). Swap the UDID for a different device.
SIM="E8DF755C-997D-49D7-9DB0-CFA48F8254CD"
APP="sx-out/ios/M3te.app"
BUNDLE="co.swipelab.m3te"
# Debug build: -O0 + DWARF (--emit-obj keeps the object), then a portable .dSYM.
"$SX" build --target ios-sim --emit-obj main.sx
"$DSYMUTIL" "$APP/M3te"
# Boot the sim (idempotent) and surface the Simulator UI.
xcrun simctl boot "$SIM" 2>/dev/null || true
xcrun simctl bootstatus "$SIM" >/dev/null 2>&1 || true
open -a Simulator
xcrun simctl install "$SIM" "$APP"
xcrun simctl terminate "$SIM" "$BUNDLE" >/dev/null 2>&1 || true
# Launch paused; the app waits at its entry until lldb attaches and continues.
xcrun simctl launch --wait-for-debugger "$SIM" "$BUNDLE"

27
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,27 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Debug M3te (macOS)",
"program": "${workspaceFolder}/sx-out/macos/M3te.app/Contents/MacOS/M3te",
"cwd": "${workspaceFolder}",
"preLaunchTask": "sx build (macos debug)"
},
{
"type": "lldb",
"request": "custom",
"name": "Debug M3te (iOS sim)",
"preLaunchTask": "sx build+launch (ios-sim debug)",
"targetCreateCommands": [
"target create '${workspaceFolder}/sx-out/ios/M3te.app/M3te'"
],
"processCreateCommands": [
"process attach -n M3te",
"process handle SIGSTOP --notify false --stop false --pass false",
"process continue"
]
}
]
}

23
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,23 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "sx build (macos debug)",
"detail": "Build M3te for macOS with DWARF (-O0) and collect a .dSYM for lldb.",
"type": "shell",
"command": "/Users/agra/projects/sx/zig-out/bin/sx build --target macos --emit-obj main.sx && /opt/homebrew/opt/llvm@19/bin/dsymutil sx-out/macos/M3te.app/Contents/MacOS/M3te",
"options": { "cwd": "${workspaceFolder}" },
"group": "build",
"problemMatcher": []
},
{
"label": "sx build+launch (ios-sim debug)",
"detail": "Build for the iOS simulator (-O0 + .dSYM), install, and launch paused for the debugger.",
"type": "shell",
"command": "${workspaceFolder}/.vscode/ios-sim-debug.sh",
"options": { "cwd": "${workspaceFolder}" },
"group": "build",
"problemMatcher": []
}
]
}

View File

@@ -10,17 +10,17 @@
// other targets never reference these symbols nor need the frameworks. // other targets never reference these symbols nor need the frameworks.
#import "modules/std.sx"; #import "modules/std.sx";
#import "modules/std/objc.sx"; #import "modules/ffi/objc.sx";
#import "modules/compiler.sx"; #import "modules/build.sx";
// AudioToolbox — System Sound Services. SystemSoundID is a UInt32; OSStatus a // AudioToolbox — System Sound Services. SystemSoundID is a UInt32; OSStatus a
// SInt32 (0 == noErr); the clip's file is passed as a CFURLRef (opaque ptr). // SInt32 (0 == noErr); the clip's file is passed as a CFURLRef (opaque ptr).
AudioServicesCreateSystemSoundID :: (url: *void, out_id: *u32) -> s32 #foreign; AudioServicesCreateSystemSoundID :: (url: *void, out_id: *u32) -> i32 #foreign;
AudioServicesPlaySystemSound :: (sound_id: u32) #foreign; AudioServicesPlaySystemSound :: (sound_id: u32) #foreign;
// CoreFoundation — build a file CFURL from an absolute path. `len` is a CFIndex // CoreFoundation — build a file CFURL from an absolute path. `len` is a CFIndex
// (long); `is_dir` a Boolean (unsigned char); a NULL allocator = default. // (long); `is_dir` a Boolean (unsigned char); a NULL allocator = default.
CFURLCreateFromFileSystemRepresentation :: (allocator: *void, buffer: *u8, len: s64, is_dir: s8) -> *void #foreign; CFURLCreateFromFileSystemRepresentation :: (allocator: *void, buffer: *u8, len: i64, is_dir: i8) -> *void #foreign;
CFRelease :: (cf: *void) #foreign; CFRelease :: (cf: *void) #foreign;
// libc — getcwd to absolutize the bundle-relative asset path. The platform // libc — getcwd to absolutize the bundle-relative asset path. The platform
@@ -80,7 +80,7 @@ GameAudio :: struct {
// Pick the ascending cascade clip by clamping the cascade depth into the // Pick the ascending cascade clip by clamping the cascade depth into the
// combo1..combo5 range (see `cascade_cue_index`). // combo1..combo5 range (see `cascade_cue_index`).
play_cascade :: (self: *GameAudio, depth: s64) { play_cascade :: (self: *GameAudio, depth: i64) {
inline if OS != .ios { return; } inline if OS != .ios { return; }
if !self.loaded { return; } if !self.loaded { return; }
idx := cascade_cue_index(depth); idx := cascade_cue_index(depth);
@@ -107,7 +107,7 @@ GameAudio :: struct {
// `log show` shows the clip stepping up with cascade depth. Literals only — the // `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 // 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. // be). `idx` is a clamped `cascade_cue_index`, so it is always 0..COMBO_CLIPS-1.
cascade_cue_name :: (idx: s64) -> string { cascade_cue_name :: (idx: i64) -> string {
if idx <= 0 { return "[sx] audio: cue combo1"; } if idx <= 0 { return "[sx] audio: cue combo1"; }
if idx == 1 { return "[sx] audio: cue combo2"; } if idx == 1 { return "[sx] audio: cue combo2"; }
if idx == 2 { return "[sx] audio: cue combo3"; } if idx == 2 { return "[sx] audio: cue combo3"; }
@@ -118,7 +118,7 @@ cascade_cue_name :: (idx: s64) -> string {
// Cascade depth (number of cleared rounds) → combo clip index 0..COMBO_CLIPS-1 // 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 // (combo1..combo5). Clamps: depth <= 1 → 0, depth >= 5 → 4. Pure arithmetic and
// OS-agnostic so it can be snapshot-tested headlessly (P10.4). // OS-agnostic so it can be snapshot-tested headlessly (P10.4).
cascade_cue_index :: (depth: s64) -> s64 { cascade_cue_index :: (depth: i64) -> i64 {
if depth <= 1 { return 0; } if depth <= 1 { return 0; }
if depth >= COMBO_CLIPS { return COMBO_CLIPS - 1; } if depth >= COMBO_CLIPS { return COMBO_CLIPS - 1; }
depth - 1 depth - 1
@@ -145,7 +145,7 @@ load_system_sound :: (name: string) -> u32 {
if getcwd(@cwd_buf[0], 1024) == null { return 0; } if getcwd(@cwd_buf[0], 1024) == null { return 0; }
cwd : string = ---; cwd : string = ---;
cwd.ptr = @cwd_buf[0]; cwd.ptr = @cwd_buf[0];
cwd.len = cast(s64) c_strlen(@cwd_buf[0]); cwd.len = cast(i64) c_strlen(@cwd_buf[0]);
// CFURLCreateFromFileSystemRepresentation takes an explicit byte length, so // CFURLCreateFromFileSystemRepresentation takes an explicit byte length, so
// the formatted path needs no NUL terminator. // the formatted path needs no NUL terminator.
@@ -169,6 +169,6 @@ g_audio : *GameAudio = null;
sfx_swap :: () { if g_audio != null { g_audio.play_swap(); } } sfx_swap :: () { if g_audio != null { g_audio.play_swap(); } }
sfx_match :: () { if g_audio != null { g_audio.play_match(); } } sfx_match :: () { if g_audio != null { g_audio.play_match(); } }
sfx_cascade :: (depth: s64) { if g_audio != null { g_audio.play_cascade(depth); } } sfx_cascade :: (depth: i64) { if g_audio != null { g_audio.play_cascade(depth); } }
sfx_win :: () { if g_audio != null { g_audio.play_win(); } } sfx_win :: () { if g_audio != null { g_audio.play_win(); } }
sfx_lose :: () { if g_audio != null { g_audio.play_lose(); } } sfx_lose :: () { if g_audio != null { g_audio.play_lose(); } }

181
board.sx
View File

@@ -39,36 +39,36 @@ EMPTY_CHAR :: 46; // '.'
gem_char :: (g: Gem) -> u8 { gem_char :: (g: Gem) -> u8 {
if g == .empty { return EMPTY_CHAR; } if g == .empty { return EMPTY_CHAR; }
GEM_CHARS[cast(s64) g] GEM_CHARS[cast(i64) g]
} }
// ── Deterministic RNG ───────────────────────────────────────────────────── // ── Deterministic RNG ─────────────────────────────────────────────────────
// A 32-bit linear congruential generator (Numerical Recipes constants), // A 32-bit linear congruential generator (Numerical Recipes constants),
// carried in an s64 and masked back to 32 bits after every step so the // carried in an i64 and masked back to 32 bits after every step so the
// stream is identical regardless of host integer width. The state*MUL+ADD // stream is identical regardless of host integer width. The state*MUL+ADD
// product stays well under s64 range, so no intermediate overflow. Any seed // product stays well under i64 range, so no intermediate overflow. Any seed
// (including 0) yields a valid stream — an LCG has no forbidden state. // (including 0) yields a valid stream — an LCG has no forbidden state.
RNG_MASK32 :: 0xFFFFFFFF; RNG_MASK32 :: 0xFFFFFFFF;
RNG_MUL :: 1664525; RNG_MUL :: 1664525;
RNG_ADD :: 1013904223; RNG_ADD :: 1013904223;
Rng :: struct { Rng :: struct {
state: s64; state: i64;
// Advance and return the next 32-bit value. // Advance and return the next 32-bit value.
next_u32 :: (self: *Rng) -> s64 { next_u32 :: (self: *Rng) -> i64 {
self.state = (self.state * RNG_MUL + RNG_ADD) & RNG_MASK32; self.state = (self.state * RNG_MUL + RNG_ADD) & RNG_MASK32;
self.state self.state
} }
// Uniform-ish value in [0, n). Uses the high bits, whose period is far // Uniform-ish value in [0, n). Uses the high bits, whose period is far
// longer than the low bits of an LCG. // longer than the low bits of an LCG.
next_range :: (self: *Rng, n: s64) -> s64 { next_range :: (self: *Rng, n: i64) -> i64 {
(self.next_u32() >> 16) % n (self.next_u32() >> 16) % n
} }
} }
rng_seeded :: (seed: s64) -> Rng { rng_seeded :: (seed: i64) -> Rng {
Rng.{ state = seed & RNG_MASK32 } Rng.{ state = seed & RNG_MASK32 }
} }
@@ -106,7 +106,7 @@ Board :: struct {
// round's base points (see `score_round`), and `resolve` adds each cascade // round's base points (see `score_round`), and `resolve` adds each cascade
// round's base scaled by `combo_multiplier` (P3.2). The HUD (P4.4) reads this // round's base scaled by `combo_multiplier` (P3.2). The HUD (P4.4) reads this
// field. A hand-built board must zero this before accumulating. // field. A hand-built board must zero this before accumulating.
score: s64; score: i64;
// Turn accounting (P3.3). `moves_made` counts the swaps actually COMMITTED — // Turn accounting (P3.3). `moves_made` counts the swaps actually COMMITTED —
// only a legal swap (one that resolved into >=1 match) via `commit_swap` // only a legal swap (one that resolved into >=1 match) via `commit_swap`
@@ -115,15 +115,15 @@ Board :: struct {
// is derived from the two, so there is a single source of truth and the // is derived from the two, so there is a single source of truth and the
// counters can never drift apart. A hand-built board must set both before // counters can never drift apart. A hand-built board must set both before
// committing swaps. // committing swaps.
moves_made: s64; moves_made: i64;
move_limit: s64; move_limit: i64;
// Per-level score goal (P7.1). `init` sets it to DEFAULT_TARGET_SCORE; // Per-level score goal (P7.1). `init` sets it to DEFAULT_TARGET_SCORE;
// `level_status` reads it to decide a win (`score >= target_score`). A // `level_status` reads it to decide a win (`score >= target_score`). A
// hand-built board must set this before its status is read. // hand-built board must set this before its status is read.
target_score: s64; target_score: i64;
idx :: (col: s64, row: s64) -> s64 { idx :: (col: i64, row: i64) -> i64 {
row * BOARD_COLS + col row * BOARD_COLS + col
} }
@@ -131,15 +131,15 @@ Board :: struct {
// to 0 when the budget is spent (and below it only if a caller keeps // to 0 when the budget is spent (and below it only if a caller keeps
// committing past the budget — see DEFAULT_MOVE_LIMIT). The turn/goal loop // committing past the budget — see DEFAULT_MOVE_LIMIT). The turn/goal loop
// (P7) reads this to decide when the game ends. // (P7) reads this to decide when the game ends.
moves_remaining :: (self: *Board) -> s64 { moves_remaining :: (self: *Board) -> i64 {
self.move_limit - self.moves_made self.move_limit - self.moves_made
} }
at :: (self: *Board, col: s64, row: s64) -> Gem { at :: (self: *Board, col: i64, row: i64) -> Gem {
self.cells[Board.idx(col, row)] self.cells[Board.idx(col, row)]
} }
set :: (self: *Board, col: s64, row: s64, g: Gem) { set :: (self: *Board, col: i64, row: i64, g: Gem) {
self.cells[Board.idx(col, row)] = g; self.cells[Board.idx(col, row)] = g;
} }
@@ -149,14 +149,14 @@ Board :: struct {
// already-placed cells to its left or above is excluded, and the gem is // already-placed cells to its left or above is excluded, and the gem is
// drawn from the remaining allowed types. At most two types are ever // drawn from the remaining allowed types. At most two types are ever
// excluded, so a choice always remains. // excluded, so a choice always remains.
init :: (self: *Board, seed: s64) { init :: (self: *Board, seed: i64) {
self.rng = rng_seeded(seed); self.rng = rng_seeded(seed);
self.score = 0; self.score = 0;
self.moves_made = 0; self.moves_made = 0;
self.move_limit = DEFAULT_MOVE_LIMIT; self.move_limit = DEFAULT_MOVE_LIMIT;
self.target_score = DEFAULT_TARGET_SCORE; self.target_score = DEFAULT_TARGET_SCORE;
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
self.set(col, row, pick_gem(self, @self.rng, col, row)); self.set(col, row, pick_gem(self, @self.rng, col, row));
} }
} }
@@ -165,31 +165,31 @@ Board :: struct {
// Choose a gem for (col, row) that can't extend an existing run leftward or // Choose a gem for (col, row) that can't extend an existing run leftward or
// upward. Pure given the board's already-placed prefix and the RNG state. // upward. Pure given the board's already-placed prefix and the RNG state.
pick_gem :: (board: *Board, rng: *Rng, col: s64, row: s64) -> Gem { pick_gem :: (board: *Board, rng: *Rng, col: i64, row: i64) -> Gem {
forbidden : [GEM_COUNT]bool = ---; forbidden : [GEM_COUNT]bool = ---;
for 0..GEM_COUNT: (t) { forbidden[t] = false; } for 0..GEM_COUNT (t) { forbidden[t] = false; }
// Two same gems immediately to the left → a third of that type matches. // Two same gems immediately to the left → a third of that type matches.
if col >= 2 { if col >= 2 {
left := board.at(col - 1, row); left := board.at(col - 1, row);
if left == board.at(col - 2, row) { if left == board.at(col - 2, row) {
forbidden[cast(s64) left] = true; forbidden[cast(i64) left] = true;
} }
} }
// Two same gems immediately above → a third of that type matches. // Two same gems immediately above → a third of that type matches.
if row >= 2 { if row >= 2 {
up := board.at(col, row - 1); up := board.at(col, row - 1);
if up == board.at(col, row - 2) { if up == board.at(col, row - 2) {
forbidden[cast(s64) up] = true; forbidden[cast(i64) up] = true;
} }
} }
allowed := 0; allowed := 0;
for 0..GEM_COUNT: (t) { if !forbidden[t] { allowed += 1; } } for 0..GEM_COUNT (t) { if !forbidden[t] { allowed += 1; } }
// Pick the k-th still-allowed type; single RNG draw, always terminates. // Pick the k-th still-allowed type; single RNG draw, always terminates.
k := rng.next_range(allowed); k := rng.next_range(allowed);
for 0..GEM_COUNT: (t) { for 0..GEM_COUNT (t) {
if !forbidden[t] { if !forbidden[t] {
if k == 0 { return cast(Gem) t; } if k == 0 { return cast(Gem) t; }
k -= 1; k -= 1;
@@ -202,10 +202,10 @@ pick_gem :: (board: *Board, rng: *Rng, col: s64, row: s64) -> Gem {
// single gem character per cell. Suitable for snapshotting. // single gem character per cell. Suitable for snapshotting.
board_dump :: (self: *Board) -> string { board_dump :: (self: *Board) -> string {
line_w := BOARD_COLS + 1; // 8 gem chars + newline line_w := BOARD_COLS + 1; // 8 gem chars + newline
buf := cstring(BOARD_ROWS * line_w); buf := alloc_string(BOARD_ROWS * line_w);
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
base := row * line_w; base := row * line_w;
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
buf[base + col] = gem_char(self.at(col, row)); buf[base + col] = gem_char(self.at(col, row));
} }
buf[base + BOARD_COLS] = 10; // '\n' buf[base + BOARD_COLS] = 10; // '\n'
@@ -223,13 +223,13 @@ board_dump :: (self: *Board) -> string {
MatchMask :: struct { MatchMask :: struct {
cells: [BOARD_CELLS]bool; cells: [BOARD_CELLS]bool;
at :: (self: *MatchMask, col: s64, row: s64) -> bool { at :: (self: *MatchMask, col: i64, row: i64) -> bool {
self.cells[Board.idx(col, row)] self.cells[Board.idx(col, row)]
} }
count :: (self: *MatchMask) -> s64 { count :: (self: *MatchMask) -> i64 {
n : s64 = 0; n : i64 = 0;
for 0..BOARD_CELLS: (i) { if self.cells[i] { n += 1; } } for 0..BOARD_CELLS (i) { if self.cells[i] { n += 1; } }
n n
} }
} }
@@ -237,8 +237,8 @@ MatchMask :: struct {
// Mark a closed span of cells along one axis. `vertical` picks the axis; `fixed` // Mark a closed span of cells along one axis. `vertical` picks the axis; `fixed`
// is the constant coordinate (the row for a horizontal span, the column for a // is the constant coordinate (the row for a horizontal span, the column for a
// vertical one) and the span covers `start..end` of the moving coordinate. // vertical one) and the span covers `start..end` of the moving coordinate.
mark_run :: (m: *MatchMask, vertical: bool, fixed: s64, start: s64, end: s64) { mark_run :: (m: *MatchMask, vertical: bool, fixed: i64, start: i64, end: i64) {
for start..end: (i) { for start..end (i) {
if vertical { if vertical {
m.cells[Board.idx(fixed, i)] = true; m.cells[Board.idx(fixed, i)] = true;
} else { } else {
@@ -259,10 +259,10 @@ mark_run :: (m: *MatchMask, vertical: bool, fixed: s64, start: s64, end: s64) {
// break runs of real gems, since a hole differs from every gem type. // break runs of real gems, since a hole differs from every gem type.
find_matches :: (b: *Board) -> MatchMask { find_matches :: (b: *Board) -> MatchMask {
m : MatchMask = ---; m : MatchMask = ---;
for 0..BOARD_CELLS: (i) { m.cells[i] = false; } for 0..BOARD_CELLS (i) { m.cells[i] = false; }
// Horizontal: walk each row left-to-right in maximal same-type spans. // Horizontal: walk each row left-to-right in maximal same-type spans.
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
col := 0; col := 0;
while col < BOARD_COLS { while col < BOARD_COLS {
g := b.at(col, row); g := b.at(col, row);
@@ -276,7 +276,7 @@ find_matches :: (b: *Board) -> MatchMask {
} }
// Vertical: walk each column top-to-bottom in maximal same-type spans. // Vertical: walk each column top-to-bottom in maximal same-type spans.
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
row := 0; row := 0;
while row < BOARD_ROWS { while row < BOARD_ROWS {
g := b.at(col, row); g := b.at(col, row);
@@ -298,10 +298,10 @@ find_matches :: (b: *Board) -> MatchMask {
// unambiguously as the empty set. Suitable for snapshotting. // unambiguously as the empty set. Suitable for snapshotting.
dump_matches :: (b: *Board, m: *MatchMask) -> string { dump_matches :: (b: *Board, m: *MatchMask) -> string {
line_w := BOARD_COLS + 1; // 8 cells + newline line_w := BOARD_COLS + 1; // 8 cells + newline
buf := cstring(BOARD_ROWS * line_w); buf := alloc_string(BOARD_ROWS * line_w);
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
base := row * line_w; base := row * line_w;
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
if m.at(col, row) { if m.at(col, row) {
buf[base + col] = gem_char(b.at(col, row)); buf[base + col] = gem_char(b.at(col, row));
} else { } else {
@@ -317,8 +317,8 @@ dump_matches :: (b: *Board, m: *MatchMask) -> string {
// A board cell address. Kept separate from the row-major index so swap callers // A board cell address. Kept separate from the row-major index so swap callers
// and the move enumeration speak in (col, row) like the rest of the model. // and the move enumeration speak in (col, row) like the rest of the model.
Cell :: struct { Cell :: struct {
col: s64; col: i64;
row: s64; row: i64;
} }
// Exchange the gems of two cells, in place. `swap` is its own inverse: calling // Exchange the gems of two cells, in place. `swap` is its own inverse: calling
@@ -371,8 +371,8 @@ Swap :: struct {
// the snapshot can depend on it. // the snapshot can depend on it.
legal_swaps :: (board: *Board) -> List(Swap) { legal_swaps :: (board: *Board) -> List(Swap) {
result := List(Swap).{}; result := List(Swap).{};
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
here := Cell.{ col = col, row = row }; here := Cell.{ col = col, row = row };
if col + 1 < BOARD_COLS { if col + 1 < BOARD_COLS {
right := Cell.{ col = col + 1, row = row }; right := Cell.{ col = col + 1, row = row };
@@ -397,7 +397,7 @@ legal_swaps :: (board: *Board) -> List(Swap) {
// as just "0 legal swaps", which reads unambiguously. Suitable for snapshotting. // as just "0 legal swaps", which reads unambiguously. Suitable for snapshotting.
dump_swaps :: (swaps: *List(Swap)) -> string { dump_swaps :: (swaps: *List(Swap)) -> string {
result := format("{} legal swaps\n", swaps.len); result := format("{} legal swaps\n", swaps.len);
for 0..swaps.len: (i) { for 0..swaps.len (i) {
s := swaps.items[i]; s := swaps.items[i];
result = concat(result, format("({},{})-({},{})\n", s.a.col, s.a.row, s.b.col, s.b.row)); result = concat(result, format("({},{})-({},{})\n", s.a.col, s.a.row, s.b.col, s.b.row));
} }
@@ -413,9 +413,9 @@ dump_swaps :: (swaps: *List(Swap)) -> string {
// unchanged. Returns the number of cells cleared. `mask` is the matched-cell SET // unchanged. Returns the number of cells cleared. `mask` is the matched-cell SET
// from find_matches, so overlapping L/T shapes (already unioned into a single // from find_matches, so overlapping L/T shapes (already unioned into a single
// `true` per shared cell) clear as one set with no double-counting. // `true` per shared cell) clear as one set with no double-counting.
clear_cells :: (board: *Board, mask: *MatchMask) -> s64 { clear_cells :: (board: *Board, mask: *MatchMask) -> i64 {
cleared : s64 = 0; cleared : i64 = 0;
for 0..BOARD_CELLS: (i) { for 0..BOARD_CELLS (i) {
if mask.cells[i] { if mask.cells[i] {
board.cells[i] = .empty; board.cells[i] = .empty;
cleared += 1; cleared += 1;
@@ -428,7 +428,7 @@ clear_cells :: (board: *Board, mask: *MatchMask) -> s64 {
// cells cleared — 0 when there are no matches, in which case the board is left // cells cleared — 0 when there are no matches, in which case the board is left
// unchanged. The count drives later cascade/scoring (P2.2+): a non-zero result // unchanged. The count drives later cascade/scoring (P2.2+): a non-zero result
// means the board changed and the resolution loop should continue. // means the board changed and the resolution loop should continue.
clear_matches :: (board: *Board) -> s64 { clear_matches :: (board: *Board) -> i64 {
m := find_matches(board); m := find_matches(board);
clear_cells(board, @m) clear_cells(board, @m)
} }
@@ -449,7 +449,7 @@ clear_matches :: (board: *Board) -> s64 {
// this to know when gravity has stopped. // this to know when gravity has stopped.
collapse :: (board: *Board) -> bool { collapse :: (board: *Board) -> bool {
moved := false; moved := false;
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
// Pack this column's gems toward the bottom: scan it bottom-to-top and // Pack this column's gems toward the bottom: scan it bottom-to-top and
// write each gem at the falling cursor `w`, which also descends from the // write each gem at the falling cursor `w`, which also descends from the
// bottom. A gem whose source row differs from `w` actually fell. `w` // bottom. A gem whose source row differs from `w` actually fell. `w`
@@ -489,11 +489,11 @@ collapse :: (board: *Board) -> bool {
// only ever yields ordinals 0..GEM_COUNT, so a hole is never refilled with // only ever yields ordinals 0..GEM_COUNT, so a hole is never refilled with
// `.empty`; afterwards the board has no holes left. Returns the number of cells // `.empty`; afterwards the board has no holes left. Returns the number of cells
// filled (0 on a board that had none). // filled (0 on a board that had none).
refill :: (board: *Board) -> s64 { refill :: (board: *Board) -> i64 {
rng := @board.rng; rng := @board.rng;
filled : s64 = 0; filled : i64 = 0;
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
if board.at(col, row) == .empty { if board.at(col, row) == .empty {
board.set(col, row, cast(Gem) rng.next_range(GEM_COUNT)); board.set(col, row, cast(Gem) rng.next_range(GEM_COUNT));
filled += 1; filled += 1;
@@ -526,11 +526,11 @@ refill :: (board: *Board) -> s64 {
// make "did this settle clear a 4 / 5+ run" observable. `had_len4` / // make "did this settle clear a 4 / 5+ run" observable. `had_len4` /
// `had_len5_plus` are the boolean view of the same counts. // `had_len5_plus` are the boolean view of the same counts.
Cascade :: struct { Cascade :: struct {
depth: s64; depth: i64;
cleared: List(s64); cleared: List(i64);
awarded: s64; awarded: i64;
len4: s64; len4: i64;
len5_plus: s64; len5_plus: i64;
had_len4 :: (self: *Cascade) -> bool { had_len4 :: (self: *Cascade) -> bool {
self.len4 > 0 self.len4 > 0
@@ -546,7 +546,7 @@ Cascade :: struct {
// number of cells cleared this round — 0 iff the board was already stable, in // number of cells cleared this round — 0 iff the board was already stable, in
// which case nothing moves and no gem is drawn. `resolve` repeats this until it // which case nothing moves and no gem is drawn. `resolve` repeats this until it
// returns 0. // returns 0.
resolve_step :: (board: *Board) -> s64 { resolve_step :: (board: *Board) -> i64 {
cleared := clear_matches(board); cleared := clear_matches(board);
if cleared == 0 { return 0; } if cleared == 0 { return 0; }
collapse(board); collapse(board);
@@ -560,7 +560,7 @@ resolve_step :: (board: *Board) -> s64 {
// Each round adds `score_round * combo_multiplier(round)` (round 1-based) to // Each round adds `score_round * combo_multiplier(round)` (round 1-based) to
// `Board.score`; an already-stable board returns depth 0, awards 0, untouched. // `Board.score`; an already-stable board returns depth 0, awards 0, untouched.
resolve :: (board: *Board) -> Cascade { resolve :: (board: *Board) -> Cascade {
result := Cascade.{ depth = 0, cleared = List(s64).{}, awarded = 0, len4 = 0, len5_plus = 0 }; result := Cascade.{ depth = 0, cleared = List(i64).{}, awarded = 0, len4 = 0, len5_plus = 0 };
while true { while true {
// Read the round's base points AND its special-match tally while the runs // Read the round's base points AND its special-match tally while the runs
// are still on the board: `resolve_step` clears them, so both have to be // are still on the board: `resolve_step` clears them, so both have to be
@@ -604,15 +604,15 @@ SCORE_RUN_5_PLUS :: 100;
// vertical one) and the run covers `start..start+len` of the moving coordinate. // vertical one) and the run covers `start..start+len` of the moving coordinate.
Run :: struct { Run :: struct {
vertical: bool; vertical: bool;
fixed: s64; fixed: i64;
start: s64; start: i64;
len: s64; len: i64;
} }
// Base points for a single maximal run, by length. Runs are always length >= 3 // Base points for a single maximal run, by length. Runs are always length >= 3
// (shorter spans are not enumerated), so 3 is the floor; 5 and longer all score // (shorter spans are not enumerated), so 3 is the floor; 5 and longer all score
// the top tier. // the top tier.
run_score :: (len: s64) -> s64 { run_score :: (len: i64) -> i64 {
if len <= 3 { return SCORE_RUN_3; } if len <= 3 { return SCORE_RUN_3; }
if len == 4 { return SCORE_RUN_4; } if len == 4 { return SCORE_RUN_4; }
SCORE_RUN_5_PLUS SCORE_RUN_5_PLUS
@@ -628,7 +628,7 @@ run_score :: (len: s64) -> s64 {
find_runs :: (b: *Board) -> List(Run) { find_runs :: (b: *Board) -> List(Run) {
runs := List(Run).{}; runs := List(Run).{};
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
col := 0; col := 0;
while col < BOARD_COLS { while col < BOARD_COLS {
g := b.at(col, row); g := b.at(col, row);
@@ -643,7 +643,7 @@ find_runs :: (b: *Board) -> List(Run) {
} }
} }
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
row := 0; row := 0;
while row < BOARD_ROWS { while row < BOARD_ROWS {
g := b.at(col, row); g := b.at(col, row);
@@ -666,10 +666,10 @@ find_runs :: (b: *Board) -> List(Run) {
// read-only — it inspects the board but changes nothing, so it must be called // read-only — it inspects the board but changes nothing, so it must be called
// BEFORE the round's clear, while the runs are still on the board. A board with // BEFORE the round's clear, while the runs are still on the board. A board with
// no run scores 0. // no run scores 0.
score_round :: (board: *Board) -> s64 { score_round :: (board: *Board) -> i64 {
runs := find_runs(board); runs := find_runs(board);
total : s64 = 0; total : i64 = 0;
for 0..runs.len: (i) { for 0..runs.len (i) {
total += run_score(runs.items[i].len); total += run_score(runs.items[i].len);
} }
total total
@@ -679,7 +679,7 @@ score_round :: (board: *Board) -> s64 {
// `score` total and return them. The single-round accumulation primitive; the // `score` total and return them. The single-round accumulation primitive; the
// cascade loop (`resolve`) instead scales each round by `combo_multiplier` // cascade loop (`resolve`) instead scales each round by `combo_multiplier`
// (P3.2). Neither path changes `score_round`. // (P3.2). Neither path changes `score_round`.
add_round_score :: (board: *Board) -> s64 { add_round_score :: (board: *Board) -> i64 {
points := score_round(board); points := score_round(board);
board.score += points; board.score += points;
points points
@@ -694,7 +694,7 @@ add_round_score :: (board: *Board) -> s64 {
// multi-round chain strictly beats the same clears scored flat. `resolve` // multi-round chain strictly beats the same clears scored flat. `resolve`
// accumulates `score_round * combo_multiplier(round)` per round into `Board.score` // accumulates `score_round * combo_multiplier(round)` per round into `Board.score`
// and reports the sum as `Cascade.awarded`. // and reports the sum as `Cascade.awarded`.
combo_multiplier :: (round: s64) -> s64 { combo_multiplier :: (round: i64) -> i64 {
round round
} }
@@ -711,8 +711,8 @@ combo_multiplier :: (round: s64) -> s64 {
// "did any occur" lives on `Cascade` (`had_len4` / `had_len5_plus`) for the // "did any occur" lives on `Cascade` (`had_len4` / `had_len5_plus`) for the
// whole settle; a single round reads these counts directly. // whole settle; a single round reads these counts directly.
SpecialCounts :: struct { SpecialCounts :: struct {
len4: s64; len4: i64;
len5_plus: s64; len5_plus: i64;
} }
// Count the board's currently-matched runs that hit a special length, by the // Count the board's currently-matched runs that hit a special length, by the
@@ -724,7 +724,7 @@ SpecialCounts :: struct {
count_specials :: (board: *Board) -> SpecialCounts { count_specials :: (board: *Board) -> SpecialCounts {
runs := find_runs(board); runs := find_runs(board);
counts := SpecialCounts.{ len4 = 0, len5_plus = 0 }; counts := SpecialCounts.{ len4 = 0, len5_plus = 0 };
for 0..runs.len: (i) { for 0..runs.len (i) {
len := runs.items[i].len; len := runs.items[i].len;
if len == 4 { if len == 4 {
counts.len4 += 1; counts.len4 += 1;
@@ -741,7 +741,7 @@ count_specials :: (board: *Board) -> SpecialCounts {
// "0 runs". Suitable for snapshotting. // "0 runs". Suitable for snapshotting.
dump_runs :: (runs: *List(Run)) -> string { dump_runs :: (runs: *List(Run)) -> string {
result := format("{} runs\n", runs.len); result := format("{} runs\n", runs.len);
for 0..runs.len: (i) { for 0..runs.len (i) {
r := runs.items[i]; r := runs.items[i];
axis := if r.vertical then "V" else "H"; axis := if r.vertical then "V" else "H";
result = concat(result, format("{} len {} at fixed {} start {}\n", axis, r.len, r.fixed, r.start)); result = concat(result, format("{} len {} at fixed {} start {}\n", axis, r.len, r.fixed, r.start));
@@ -766,7 +766,7 @@ dump_runs :: (runs: *List(Run)) -> string {
PlayerMove :: struct { PlayerMove :: struct {
legal: bool; legal: bool;
cascade: Cascade; cascade: Cascade;
moves_remaining: s64; moves_remaining: i64;
} }
// Attempt the player's intended swap of two adjacent cells. If the swap is legal // Attempt the player's intended swap of two adjacent cells. If the swap is legal
@@ -779,7 +779,7 @@ PlayerMove :: struct {
// spent (that is the P7 turn-loop's call) — see DEFAULT_MOVE_LIMIT. // spent (that is the P7 turn-loop's call) — see DEFAULT_MOVE_LIMIT.
commit_swap :: (board: *Board, a: Cell, b: Cell) -> PlayerMove { commit_swap :: (board: *Board, a: Cell, b: Cell) -> PlayerMove {
if !swap_legal(board, a, b) { if !swap_legal(board, a, b) {
empty := Cascade.{ depth = 0, cleared = List(s64).{}, awarded = 0, len4 = 0, len5_plus = 0 }; empty := Cascade.{ depth = 0, cleared = List(i64).{}, awarded = 0, len4 = 0, len5_plus = 0 };
return PlayerMove.{ legal = false, cascade = empty, moves_remaining = board.moves_remaining() }; return PlayerMove.{ legal = false, cascade = empty, moves_remaining = board.moves_remaining() };
} }
swap(board, a, b); swap(board, a, b);
@@ -829,8 +829,8 @@ level_status :: (board: *Board) -> Status {
// a throwaway list each call. The trial swaps inside `swap_legal` are reverted, // a throwaway list each call. The trial swaps inside `swap_legal` are reverted,
// so the board is left unchanged. // so the board is left unchanged.
has_legal_swap :: (board: *Board) -> bool { has_legal_swap :: (board: *Board) -> bool {
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
here := Cell.{ col = col, row = row }; here := Cell.{ col = col, row = row };
if col + 1 < BOARD_COLS { if col + 1 < BOARD_COLS {
right := Cell.{ col = col + 1, row = row }; right := Cell.{ col = col + 1, row = row };
@@ -882,11 +882,25 @@ reshuffle :: (board: *Board) -> bool {
false false
} }
// After a committed move's cascade has settled, recover a deadlocked board so the
// player is never stranded: if the level is still in progress yet no legal swap
// remains, `reshuffle` the gems in place. A reshuffle is NOT a move and never runs
// on a finished (won/lost) level, so win/lose and turn accounting are untouched.
// Returns whether a reshuffle ran. BOTH the headless turn loop (`play_turn`) and
// the animated UI commit (`plan_and_commit`) call this, so the rendered game obeys
// the identical no-moves rule — neither path can leave the board stuck.
reshuffle_if_deadlocked :: (board: *Board) -> bool {
if level_status(board) == .in_progress and !has_legal_swap(board) {
return reshuffle(board);
}
false
}
// Reset to a fresh, reproducible level: `init(seed)` reseeds the board (same // Reset to a fresh, reproducible level: `init(seed)` reseeds the board (same
// seed → identical starting layout), zeroes `score` and `moves_made`, and // seed → identical starting layout), zeroes `score` and `moves_made`, and
// restores the default move budget and score goal, so `level_status` reads // restores the default move budget and score goal, so `level_status` reads
// `in_progress` again. The entry point P7.2's restart button calls. // `in_progress` again. The entry point P7.2's restart button calls.
restart :: (board: *Board, seed: s64) { restart :: ufcs (board: *Board, seed: i64) {
board.init(seed); board.init(seed);
} }
@@ -916,14 +930,11 @@ TurnResult :: struct {
play_turn :: (board: *Board, a: Cell, b: Cell) -> TurnResult { play_turn :: (board: *Board, a: Cell, b: Cell) -> TurnResult {
status := level_status(board); status := level_status(board);
if status != .in_progress { if status != .in_progress {
empty := Cascade.{ depth = 0, cleared = List(s64).{}, awarded = 0, len4 = 0, len5_plus = 0 }; empty := Cascade.{ depth = 0, cleared = List(i64).{}, awarded = 0, len4 = 0, len5_plus = 0 };
frozen := PlayerMove.{ legal = false, cascade = empty, moves_remaining = board.moves_remaining() }; frozen := PlayerMove.{ legal = false, cascade = empty, moves_remaining = board.moves_remaining() };
return TurnResult.{ accepted = false, move = frozen, status = status, reshuffled = false }; return TurnResult.{ accepted = false, move = frozen, status = status, reshuffled = false };
} }
move := commit_swap(board, a, b); move := commit_swap(board, a, b);
reshuffled := false; reshuffled := reshuffle_if_deadlocked(board);
if level_status(board) == .in_progress and !has_legal_swap(board) {
reshuffled = reshuffle(board);
}
TurnResult.{ accepted = true, move = move, status = level_status(board), reshuffled = reshuffled } TurnResult.{ accepted = true, move = move, status = level_status(board), reshuffled = reshuffled }
} }

View File

@@ -1,12 +1,13 @@
// Board motion animation (P6.1) — a PURELY VISUAL timeline the view plays over // Board motion animation (P6.1) — a PURELY VISUAL timeline the view plays over
// one player move. The logical model (commit_swap / resolve) stays authoritative: // one player move. The logical model (commit_swap / resolve) stays authoritative:
// `plan_and_commit` commits the move on the real board exactly as before, then // `plan_and_commit` commits the move on the real board (and, like the headless
// replays the SAME operations on a value-copy of the pre-move board to record the // turn loop, reshuffles a deadlocked board afterwards), then replays the SAME
// per-step geometry (the swap, each cascade round's matched cells, and each // commit operations on a value-copy of the pre-move board to record the per-step
// round's per-column fall provenance). Because the copy starts from the identical // geometry (the swap, each cascade round's matched cells, and each round's
// cells AND RNG state and runs the identical primitives, its recorded `final` // per-column fall provenance). Because the copy starts from the identical cells
// board equals the model's settled board gem-for-gem — the animation only ever // AND RNG state and runs the identical primitives, its recorded `final` board
// ends ON the already-decided result, never changes it. // equals the move's settled (pre-reshuffle) board gem-for-gem — the animation only
// ever ends ON the already-decided cascade result, never changes it.
// //
// Per-gem idle/select/clear gem animations (P6.3) and score popups / particle FX // Per-gem idle/select/clear gem animations (P6.3) and score popups / particle FX
// (P6.2) are NOT here; this step animates board MOTION only: swap slide, matched // (P6.2) are NOT here; this step animates board MOTION only: swap slide, matched
@@ -108,7 +109,7 @@ bad_swap_bounce :: (t: f32) -> f32 {
// ease_in_cubic so each column still accelerates under gravity within its window. // ease_in_cubic so each column still accelerates under gravity within its window.
// `tests/easing.sx` pins f(0)=0, f(1)=1, monotonicity, and the cascade ordering. // `tests/easing.sx` pins f(0)=0, f(1)=1, monotonicity, and the cascade ordering.
FALL_STAGGER_MAX :f32: 0.30; FALL_STAGGER_MAX :f32: 0.30;
fall_stagger_t :: (t: f32, col: s64) -> f32 { fall_stagger_t :: (t: f32, col: i64) -> f32 {
delay := FALL_STAGGER_MAX * (cast(f32) col / cast(f32) (BOARD_COLS - 1)); delay := FALL_STAGGER_MAX * (cast(f32) col / cast(f32) (BOARD_COLS - 1));
window := 1.0 - FALL_STAGGER_MAX; window := 1.0 - FALL_STAGGER_MAX;
lt := (t - delay) / window; lt := (t - delay) / window;
@@ -122,7 +123,7 @@ fall_stagger_t :: (t: f32, col: s64) -> f32 {
// at `1 - FALL_STAGGER_MAX`; the last column lands exactly at 1.0. The landing // at `1 - FALL_STAGGER_MAX`; the last column lands exactly at 1.0. The landing
// squash-bounce (P17.3) ages from this instant per column, so the squash begins // squash-bounce (P17.3) ages from this instant per column, so the squash begins
// the moment a gem touches its cell rather than at a flat whole-row settle. // the moment a gem touches its cell rather than at a flat whole-row settle.
fall_landing_frac :: (col: s64) -> f32 { fall_landing_frac :: (col: i64) -> f32 {
(1.0 - FALL_STAGGER_MAX) + FALL_STAGGER_MAX * (cast(f32) col / cast(f32) (BOARD_COLS - 1)) (1.0 - FALL_STAGGER_MAX) + FALL_STAGGER_MAX * (cast(f32) col / cast(f32) (BOARD_COLS - 1))
} }
@@ -131,7 +132,7 @@ fall_landing_frac :: (col: s64) -> f32 {
// per-round bounce ages from. Round k's fall starts after the swap, k clear+fall // per-round bounce ages from. Round k's fall starts after the swap, k clear+fall
// pairs, and that round's own clear; column `col` then lands `fall_landing_frac` // pairs, and that round's own clear; column `col` then lands `fall_landing_frac`
// of the fall window into it. Pure + headless, mirrors `phase`'s segment walk. // of the fall window into it. Pure + headless, mirrors `phase`'s segment walk.
round_land_time :: (k: s64, col: s64) -> f32 { round_land_time :: (k: i64, col: i64) -> f32 {
SWAP_ANIM_DUR + cast(f32) k * (CLEAR_ANIM_DUR + FALL_ANIM_DUR) + CLEAR_ANIM_DUR SWAP_ANIM_DUR + cast(f32) k * (CLEAR_ANIM_DUR + FALL_ANIM_DUR) + CLEAR_ANIM_DUR
+ fall_landing_frac(col) * FALL_ANIM_DUR + fall_landing_frac(col) * FALL_ANIM_DUR
} }
@@ -158,11 +159,11 @@ clear_ripple_t :: (t: f32, u: f32) -> f32 {
// The diagonal (col+row) extent of a round's matched cells — the span the ripple // The diagonal (col+row) extent of a round's matched cells — the span the ripple
// ranks each matched gem across. `hi < lo` only if the mask is empty. // ranks each matched gem across. `hi < lo` only if the mask is empty.
ClearDiag :: struct { lo: s64; hi: s64; } ClearDiag :: struct { lo: i64; hi: i64; }
clear_diag_span :: (m: *MatchMask) -> ClearDiag { clear_diag_span :: (m: *MatchMask) -> ClearDiag {
lo : s64 = (BOARD_COLS - 1) + (BOARD_ROWS - 1) + 1; lo : i64 = (BOARD_COLS - 1) + (BOARD_ROWS - 1) + 1;
hi : s64 = -1; hi : i64 = -1;
for 0..BOARD_CELLS: (i) { for 0..BOARD_CELLS (i) {
if m.cells[i] { if m.cells[i] {
d := (i % BOARD_COLS) + (i / BOARD_COLS); d := (i % BOARD_COLS) + (i / BOARD_COLS);
if d < lo { lo = d; } if d < lo { lo = d; }
@@ -177,7 +178,7 @@ clear_diag_span :: (m: *MatchMask) -> ClearDiag {
// PER ROUND (not across the board) lets even a small 3-match ripple across the // PER ROUND (not across the board) lets even a small 3-match ripple across the
// full stagger budget. A degenerate span (every matched cell on one diagonal) // full stagger budget. A degenerate span (every matched cell on one diagonal)
// ranks all gems 0, so they pop together rather than dividing by zero. // ranks all gems 0, so they pop together rather than dividing by zero.
clear_rank :: (span: ClearDiag, col: s64, row: s64) -> f32 { clear_rank :: (span: ClearDiag, col: i64, row: i64) -> f32 {
if span.hi <= span.lo { return 0.0; } if span.hi <= span.lo { return 0.0; }
cast(f32) ((col + row) - span.lo) / cast(f32) (span.hi - span.lo) cast(f32) ((col + row) - span.lo) / cast(f32) (span.hi - span.lo)
} }
@@ -192,7 +193,7 @@ clear_rank :: (span: ClearDiag, col: s64, row: s64) -> f32 {
AnimRound :: struct { AnimRound :: struct {
before: [BOARD_CELLS]Gem; before: [BOARD_CELLS]Gem;
matched: MatchMask; matched: MatchMask;
src: [BOARD_CELLS]s64; src: [BOARD_CELLS]i64;
after: [BOARD_CELLS]Gem; after: [BOARD_CELLS]Gem;
} }
@@ -209,7 +210,7 @@ AnimMove :: struct {
pre: [BOARD_CELLS]Gem; pre: [BOARD_CELLS]Gem;
rounds: List(AnimRound); rounds: List(AnimRound);
final: [BOARD_CELLS]Gem; final: [BOARD_CELLS]Gem;
awarded: s64; awarded: i64;
} }
// The most recent round at or before `kmax` that dropped a MOVED gem onto // The most recent round at or before `kmax` that dropped a MOVED gem onto
@@ -220,7 +221,7 @@ AnimMove :: struct {
// ages from its LATEST arrival, never a stale earlier one. Pure + headless: the // ages from its LATEST arrival, never a stale earlier one. Pure + headless: the
// per-round bounce (render_fall/clear) and the final-settle stamp share this so // per-round bounce (render_fall/clear) and the final-settle stamp share this so
// one envelope plays continuously across every seam. // one envelope plays continuously across every seam.
delivering_round :: (mv: *AnimMove, i: s64, kmax: s64) -> s64 { delivering_round :: (mv: *AnimMove, i: i64, kmax: i64) -> i64 {
row := i / BOARD_COLS; row := i / BOARD_COLS;
k := kmax; k := kmax;
while k >= 0 { while k >= 0 {
@@ -231,9 +232,13 @@ delivering_round :: (mv: *AnimMove, i: s64, kmax: s64) -> s64 {
} }
// Commit the player's swap authoritatively AND record its visual timeline. The // Commit the player's swap authoritatively AND record its visual timeline. The
// real board is mutated by `commit_swap` exactly as the non-animated path did; // real board is mutated by `commit_swap`, then — exactly like the headless
// the recording runs on a separate value-copy taken BEFORE the commit, so it // `play_turn` — `reshuffle_if_deadlocked` recovers a stranded board so the rendered
// replays the identical cells + RNG stream and its `final` equals `board.cells`. // game obeys the same no-moves rule. The recording runs on a value-copy taken
// BEFORE the commit, so it replays the identical cells + RNG stream; the recorded
// `final` is the SETTLED board the animation ends on. It equals the live board
// unless a deadlock reshuffle then re-arranged it: that reshuffle is a model step,
// not part of this move's timeline, so it renders on the next settled frame.
plan_and_commit :: (board: *Board, a: Cell, b: Cell) -> AnimMove { plan_and_commit :: (board: *Board, a: Cell, b: Cell) -> AnimMove {
move : AnimMove = ---; move : AnimMove = ---;
move.a = a; move.a = a;
@@ -251,6 +256,7 @@ plan_and_commit :: (board: *Board, a: Cell, b: Cell) -> AnimMove {
move.awarded = mv.cascade.awarded; move.awarded = mv.cascade.awarded;
if !mv.legal { if !mv.legal {
move.final = board.cells; move.final = board.cells;
reshuffle_if_deadlocked(board);
return move; return move;
} }
@@ -271,7 +277,7 @@ plan_and_commit :: (board: *Board, a: Cell, b: Cell) -> AnimMove {
// came from source row `r`. The rows left above the survivors (0..w) are // came from source row `r`. The rows left above the survivors (0..w) are
// refilled, so they drop in from above: a dest row `j` there starts at // refilled, so they drop in from above: a dest row `j` there starts at
// `j - n_refill`, i.e. stacked just off the top edge. // `j - n_refill`, i.e. stacked just off the top edge.
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
w := BOARD_ROWS - 1; w := BOARD_ROWS - 1;
r := BOARD_ROWS - 1; r := BOARD_ROWS - 1;
while r >= 0 { while r >= 0 {
@@ -296,6 +302,7 @@ plan_and_commit :: (board: *Board, a: Cell, b: Cell) -> AnimMove {
} }
move.final = scratch.cells; move.final = scratch.cells;
reshuffle_if_deadlocked(board);
move move
} }
@@ -305,7 +312,7 @@ AnimPhaseKind :: enum { swap; clear; fall; done; }
AnimPhase :: struct { AnimPhase :: struct {
kind: AnimPhaseKind; kind: AnimPhaseKind;
round: s64; round: i64;
t: f32; t: f32;
} }
@@ -320,7 +327,7 @@ BoardAnim :: struct {
// so the frame loop's per-round SFX is edge-triggered: a round's cue fires once, // so the frame loop's per-round SFX is edge-triggered: a round's cue fires once,
// when its clear begins, never re-fired every frame. Reset whenever a move // when its clear begins, never re-fired every frame. Reset whenever a move
// (re)starts; advanced by the frame loop as rounds clear. // (re)starts; advanced by the frame loop as rounds clear.
cascade_fired: s64; cascade_fired: i64;
init :: (self: *BoardAnim) { init :: (self: *BoardAnim) {
self.active = false; self.active = false;
@@ -355,7 +362,7 @@ BoardAnim :: struct {
return AnimPhase.{ kind = .swap, round = 0, t = e / SWAP_ANIM_DUR }; return AnimPhase.{ kind = .swap, round = 0, t = e / SWAP_ANIM_DUR };
} }
e -= SWAP_ANIM_DUR; e -= SWAP_ANIM_DUR;
for 0..self.move.rounds.len: (k) { for 0..self.move.rounds.len (k) {
if e < CLEAR_ANIM_DUR { if e < CLEAR_ANIM_DUR {
return AnimPhase.{ kind = .clear, round = k, t = e / CLEAR_ANIM_DUR }; return AnimPhase.{ kind = .clear, round = k, t = e / CLEAR_ANIM_DUR };
} }
@@ -376,11 +383,11 @@ BoardAnim :: struct {
// have fired by now (clamped to the move's round count). The frame loop diffs it // have fired by now (clamped to the move's round count). The frame loop diffs it
// against `BoardAnim.cascade_fired` to play one cue per newly-cleared round. Pure + // against `BoardAnim.cascade_fired` to play one cue per newly-cleared round. Pure +
// headless so the per-round playback is snapshot-testable without audio. // headless so the per-round playback is snapshot-testable without audio.
cascade_rounds_started :: (elapsed: f32, num_rounds: s64) -> s64 { cascade_rounds_started :: (elapsed: f32, num_rounds: i64) -> i64 {
if num_rounds <= 0 { return 0; } if num_rounds <= 0 { return 0; }
if elapsed < SWAP_ANIM_DUR { return 0; } if elapsed < SWAP_ANIM_DUR { return 0; }
seg := CLEAR_ANIM_DUR + FALL_ANIM_DUR; seg := CLEAR_ANIM_DUR + FALL_ANIM_DUR;
started := cast(s64) ((elapsed - SWAP_ANIM_DUR) / seg) + 1; started := cast(i64) ((elapsed - SWAP_ANIM_DUR) / seg) + 1;
if started > num_rounds { return num_rounds; } if started > num_rounds { return num_rounds; }
started started
} }

View File

@@ -12,8 +12,8 @@
// shrink to nothing) rather than alpha — the soft texture edges carry the fade. // shrink to nothing) rather than alpha — the soft texture edges carry the fade.
#import "modules/std.sx"; #import "modules/std.sx";
#import "modules/math"; #import "modules/math";
#import "modules/opengl.sx"; #import "modules/ffi/opengl.sx";
#import "modules/stb.sx"; #import "vendors/stb_image/stb_image.sx";
#import "modules/gpu/types.sx"; #import "modules/gpu/types.sx";
#import "modules/gpu/api.sx"; #import "modules/gpu/api.sx";
#import "modules/ui/types.sx"; #import "modules/ui/types.sx";
@@ -48,7 +48,7 @@ FX_COMBO_LABEL_GAP :f32: 0.12; // gap (cell units) between the label and +poin
// order (red, orange, yellow, green, blue, purple). Saturated a touch past the // order (red, orange, yellow, green, blue, purple). Saturated a touch past the
// pastel — the low channel is trimmed while the dominant/mid channel is lifted — // pastel — the low channel is trimmed while the dominant/mid channel is lifted —
// so every burst pops as a punchier colour without losing luminance. // so every burst pops as a punchier colour without losing luminance.
fx_tint :: (i: s64) -> Color { fx_tint :: (i: i64) -> Color {
if i == 0 { return Color.{ r = 255, g = 92, b = 62, a = 255 }; } if i == 0 { return Color.{ r = 255, g = 92, b = 62, a = 255 }; }
if i == 1 { return Color.{ r = 255, g = 164, b = 44, a = 255 }; } if i == 1 { return Color.{ r = 255, g = 164, b = 44, a = 255 }; }
if i == 2 { return Color.{ r = 255, g = 240, b = 72, a = 255 }; } if i == 2 { return Color.{ r = 255, g = 240, b = 72, a = 255 }; }
@@ -67,7 +67,7 @@ FX_POPUP_COMBO_HOT :: Color.{ r = 255, g = 248, b = 214, a = 255 }; // hot n
// lockstep with the cascade SFX cue. Pure arithmetic, OS-agnostic, and the // lockstep with the cascade SFX cue. Pure arithmetic, OS-agnostic, and the
// equivalence to `cascade_cue_index` is locked headlessly (tests/fx_combo.sx). // equivalence to `cascade_cue_index` is locked headlessly (tests/fx_combo.sx).
FX_COMBO_MAX_LEVEL :: 4; // == audio.sx COMBO_CLIPS - 1 FX_COMBO_MAX_LEVEL :: 4; // == audio.sx COMBO_CLIPS - 1
fx_combo_level :: (depth: s64) -> s64 { fx_combo_level :: (depth: i64) -> i64 {
if depth <= 1 { return 0; } if depth <= 1 { return 0; }
if depth >= FX_COMBO_MAX_LEVEL + 1 { return FX_COMBO_MAX_LEVEL; } if depth >= FX_COMBO_MAX_LEVEL + 1 { return FX_COMBO_MAX_LEVEL; }
depth - 1 depth - 1
@@ -76,14 +76,14 @@ fx_combo_level :: (depth: s64) -> s64 {
// Popup font size for a cascade `depth` rounds deep: a single clear (depth <= 1) // Popup font size for a cascade `depth` rounds deep: a single clear (depth <= 1)
// uses the plain size; a combo starts at the base combo size and grows one step // uses the plain size; a combo starts at the base combo size and grows one step
// per combo level past the first, clamped at the deepest level. // per combo level past the first, clamped at the deepest level.
fx_popup_font :: (depth: s64) -> f32 { fx_popup_font :: (depth: i64) -> f32 {
if depth <= 1 { return FX_POPUP_FONT; } if depth <= 1 { return FX_POPUP_FONT; }
FX_POPUP_COMBO_FONT + FX_POPUP_COMBO_STEP * cast(f32) (fx_combo_level(depth) - 1) FX_POPUP_COMBO_FONT + FX_POPUP_COMBO_STEP * cast(f32) (fx_combo_level(depth) - 1)
} }
// Popup colour for a cascade `depth` rounds deep: white for a single clear, else // Popup colour for a cascade `depth` rounds deep: white for a single clear, else
// the gold lerped toward a hot near-white as the cascade deepens. // the gold lerped toward a hot near-white as the cascade deepens.
fx_popup_color :: (depth: s64) -> Color { fx_popup_color :: (depth: i64) -> Color {
if depth <= 1 { return FX_POPUP_COLOR; } if depth <= 1 { return FX_POPUP_COLOR; }
t := cast(f32) (fx_combo_level(depth) - 1) / cast(f32) (FX_COMBO_MAX_LEVEL - 1); t := cast(f32) (fx_combo_level(depth) - 1) / cast(f32) (FX_COMBO_MAX_LEVEL - 1);
Color.{ Color.{
@@ -101,7 +101,7 @@ fx_lerp_u8 :: (lo: u8, hi: u8, t: f32) -> u8 {
// Upload an RGBA buffer as a texture, returning its handle. Mirrors // Upload an RGBA buffer as a texture, returning its handle. Mirrors
// board_view.load_texture's upload half but takes an in-memory buffer (the // board_view.load_texture's upload half but takes an in-memory buffer (the
// per-colour tinted particle) instead of a file path. // per-colour tinted particle) instead of a file path.
upload_rgba :: (pixels: [*]u8, w: s32, h: s32, gpu: ?GPU) -> u32 { upload_rgba :: (pixels: [*]u8, w: i32, h: i32, gpu: ?GPU) -> u32 {
if gpu != null { if gpu != null {
return xx gpu.create_texture(w, h, .rgba8, xx pixels); return xx gpu.create_texture(w, h, .rgba8, xx pixels);
} }
@@ -123,28 +123,28 @@ BoardFxAssets :: struct {
loaded: bool; loaded: bool;
init :: (self: *BoardFxAssets) { init :: (self: *BoardFxAssets) {
for 0..GEM_COUNT: (t) { self.tex[t] = 0; } for 0..GEM_COUNT (t) { self.tex[t] = 0; }
self.loaded = false; self.loaded = false;
} }
load :: (self: *BoardFxAssets, gpu: ?GPU) { load :: (self: *BoardFxAssets, gpu: ?GPU) {
w : s32 = 0; w : i32 = 0;
h : s32 = 0; h : i32 = 0;
ch : s32 = 0; ch : i32 = 0;
src : [*]u8 = xx stbi_load("assets/fx/particle.png", @w, @h, @ch, 4); src : [*]u8 = xx stbi_load("assets/fx/particle.png", @w, @h, @ch, 4);
if xx src == 0 { if xx src == 0 {
out("WARNING: could not load assets/fx/particle.png\n"); out("WARNING: could not load assets/fx/particle.png\n");
self.loaded = false; self.loaded = false;
return; return;
} }
n := cast(s64) w * cast(s64) h; n := cast(i64) w * cast(i64) h;
buf : [*]u8 = xx context.allocator.alloc(n * 4); buf : [*]u8 = xx context.allocator.alloc_bytes(n * 4);
// Loop locals are hoisted: a block-scoped local declared inside a body // Loop locals are hoisted: a block-scoped local declared inside a body
// that runs hundreds of thousands of times grows the stack per iteration // that runs hundreds of thousands of times grows the stack per iteration
// (sx codegen), so the per-pixel tint loop only ASSIGNS pre-declared vars. // (sx codegen), so the per-pixel tint loop only ASSIGNS pre-declared vars.
i : s64 = 0; i : i64 = 0;
o : s64 = 0; o : i64 = 0;
for 0..GEM_COUNT: (t) { for 0..GEM_COUNT (t) {
col := fx_tint(t); col := fx_tint(t);
i = 0; i = 0;
while i < n { while i < n {
@@ -168,7 +168,7 @@ BoardFxAssets :: struct {
FxParticle :: struct { FxParticle :: struct {
col: f32; col: f32;
row: f32; row: f32;
tint: s64; tint: i64;
delay: f32; delay: f32;
age: f32; age: f32;
life: f32; life: f32;
@@ -184,8 +184,8 @@ FxParticle :: struct {
FxPopup :: struct { FxPopup :: struct {
col: f32; col: f32;
row: f32; row: f32;
points: s64; points: i64;
depth: s64; depth: i64;
delay: f32; delay: f32;
age: f32; age: f32;
life: f32; life: f32;
@@ -219,7 +219,7 @@ BoardFx :: struct {
// Whole-move depth boost: a deeper cascade makes every burst bigger from // Whole-move depth boost: a deeper cascade makes every burst bigger from
// its first round, escalating in lockstep with the cascade SFX cue. // its first round, escalating in lockstep with the cascade SFX cue.
depth_boost := FX_BURST_DEPTH * cast(f32) fx_combo_level(mv.rounds.len); depth_boost := FX_BURST_DEPTH * cast(f32) fx_combo_level(mv.rounds.len);
for 0..mv.rounds.len: (k) { for 0..mv.rounds.len (k) {
rd := @mv.rounds.items[k]; rd := @mv.rounds.items[k];
t0 := SWAP_ANIM_DUR + cast(f32) k * (CLEAR_ANIM_DUR + FALL_ANIM_DUR); t0 := SWAP_ANIM_DUR + cast(f32) k * (CLEAR_ANIM_DUR + FALL_ANIM_DUR);
extra := depth_boost + FX_BURST_COMBO * cast(f32) min(k, 2); extra := depth_boost + FX_BURST_COMBO * cast(f32) min(k, 2);
@@ -227,7 +227,7 @@ BoardFx :: struct {
// bursts ripple in lockstep with the staggered pops (P18.2) instead of // bursts ripple in lockstep with the staggered pops (P18.2) instead of
// one simultaneous flash. The round's audio cue still fires once at t0. // one simultaneous flash. The round's audio cue still fires once at t0.
span := clear_diag_span(@rd.matched); span := clear_diag_span(@rd.matched);
for 0..BOARD_CELLS: (idx) { for 0..BOARD_CELLS (idx) {
if rd.matched.cells[idx] { if rd.matched.cells[idx] {
g := rd.before[idx]; g := rd.before[idx];
if g != .empty { if g != .empty {
@@ -237,7 +237,7 @@ BoardFx :: struct {
self.particles.append(FxParticle.{ self.particles.append(FxParticle.{
col = cast(f32) col + 0.5, col = cast(f32) col + 0.5,
row = cast(f32) row + 0.5, row = cast(f32) row + 0.5,
tint = cast(s64) g, tint = cast(i64) g,
delay = t0 + rdelay, delay = t0 + rdelay,
age = 0.0, age = 0.0,
life = FX_BURST_LIFE, life = FX_BURST_LIFE,
@@ -250,10 +250,10 @@ BoardFx :: struct {
// One popup for the whole move at the first clear's centroid. // One popup for the whole move at the first clear's centroid.
rd0 := @mv.rounds.items[0]; rd0 := @mv.rounds.items[0];
sc : s64 = 0; sc : i64 = 0;
sr : s64 = 0; sr : i64 = 0;
cnt : s64 = 0; cnt : i64 = 0;
for 0..BOARD_CELLS: (idx) { for 0..BOARD_CELLS (idx) {
if rd0.matched.cells[idx] { if rd0.matched.cells[idx] {
sc += idx % BOARD_COLS; sc += idx % BOARD_COLS;
sr += idx / BOARD_COLS; sr += idx / BOARD_COLS;
@@ -275,8 +275,8 @@ BoardFx :: struct {
// Advance every live FX by `dt` and drop those past their lifetime. Kept // Advance every live FX by `dt` and drop those past their lifetime. Kept
// simple: compact each list in place by overwriting dead entries. // simple: compact each list in place by overwriting dead entries.
tick :: (self: *BoardFx, dt: f32) { tick :: (self: *BoardFx, dt: f32) {
w : s64 = 0; w : i64 = 0;
i : s64 = 0; i : i64 = 0;
while i < self.particles.len { while i < self.particles.len {
p := self.particles.items[i]; p := self.particles.items[i];
p.age += dt; p.age += dt;

View File

@@ -25,7 +25,7 @@ BoardLayout :: struct {
}; };
} }
cell_frame :: (self: *BoardLayout, col: s64, row: s64) -> Frame { cell_frame :: (self: *BoardLayout, col: i64, row: i64) -> Frame {
Frame.make( Frame.make(
self.origin.x + xx col * self.cell_size, self.origin.x + xx col * self.cell_size,
self.origin.y + xx row * self.cell_size, self.origin.y + xx row * self.cell_size,
@@ -44,8 +44,8 @@ BoardLayout :: struct {
fx := (p.x - self.origin.x) / self.cell_size; fx := (p.x - self.origin.x) / self.cell_size;
fy := (p.y - self.origin.y) / self.cell_size; fy := (p.y - self.origin.y) / self.cell_size;
if fx < 0.0 or fy < 0.0 { return null; } if fx < 0.0 or fy < 0.0 { return null; }
col : s64 = xx fx; col : i64 = xx fx;
row : s64 = xx fy; row : i64 = xx fy;
if col >= BOARD_COLS or row >= BOARD_ROWS { return null; } if col >= BOARD_COLS or row >= BOARD_ROWS { return null; }
Cell.{ col = col, row = row } Cell.{ col = col, row = row }
} }

View File

@@ -6,8 +6,8 @@
// the grid is a centered square inside the safe-area inset. // the grid is a centered square inside the safe-area inset.
#import "modules/std.sx"; #import "modules/std.sx";
#import "modules/math"; #import "modules/math";
#import "modules/opengl.sx"; #import "modules/ffi/opengl.sx";
#import "modules/stb.sx"; #import "vendors/stb_image/stb_image.sx";
#import "modules/gpu/types.sx"; #import "modules/gpu/types.sx";
#import "modules/gpu/api.sx"; #import "modules/gpu/api.sx";
#import "modules/ui/types.sx"; #import "modules/ui/types.sx";
@@ -126,7 +126,7 @@ BoardAssets :: struct {
self.loaded = self.bg_tex != 0 and self.cell_tex != 0 and self.gems_tex != 0; self.loaded = self.bg_tex != 0 and self.cell_tex != 0 and self.gems_tex != 0;
} }
gem_uv :: (self: *BoardAssets, index: s64) -> GemUV { gem_uv :: (self: *BoardAssets, index: i64) -> GemUV {
u0 : f32 = xx index * self.cell_u; u0 : f32 = xx index * self.cell_u;
GemUV.{ GemUV.{
uv_min = Point.{ x = u0, y = 0.0 }, uv_min = Point.{ x = u0, y = 0.0 },
@@ -139,9 +139,9 @@ BoardAssets :: struct {
// failure). When a GPU backend is bound (iOS Metal) it owns the upload; the // failure). When a GPU backend is bound (iOS Metal) it owns the upload; the
// desktop GL path falls back to a plain GL_TEXTURE_2D. // desktop GL path falls back to a plain GL_TEXTURE_2D.
load_texture :: (path: [:0]u8, gpu: ?GPU) -> u32 { load_texture :: (path: [:0]u8, gpu: ?GPU) -> u32 {
w : s32 = 0; w : i32 = 0;
h : s32 = 0; h : i32 = 0;
ch : s32 = 0; ch : i32 = 0;
pixels := stbi_load(path, @w, @h, @ch, 4); pixels := stbi_load(path, @w, @h, @ch, 4);
if pixels == null { if pixels == null {
out("WARNING: could not load texture: "); out("WARNING: could not load texture: ");
@@ -237,7 +237,7 @@ BoardView :: struct {
safe: EdgeInsets; safe: EdgeInsets;
// Seed for `restart`: the same fixed seed main seeded the board with, so the // Seed for `restart`: the same fixed seed main seeded the board with, so the
// restart button reproduces the identical starting level. // restart button reproduces the identical starting level.
seed: s64; seed: i64;
// FPS dev overlay (P20.1). `fps_on` gates the corner readout (off by default, // FPS dev overlay (P20.1). `fps_on` gates the corner readout (off by default,
// set only by the M3TE_FPS env pin); `fps` is the smoothed reciprocal frame // set only by the M3TE_FPS env pin); `fps` is the smoothed reciprocal frame
// rate computed in the frame loop. Purely a render overlay. // rate computed in the frame loop. Purely a render overlay.
@@ -266,7 +266,7 @@ BoardView :: struct {
} }
// Draw gem `gem_index`'s sprite-sheet column into `gf`. // Draw gem `gem_index`'s sprite-sheet column into `gf`.
draw_gem :: (self: *BoardView, ctx: *RenderContext, gf: Frame, gem_index: s64) { draw_gem :: (self: *BoardView, ctx: *RenderContext, gf: Frame, gem_index: i64) {
uv := self.assets.gem_uv(gem_index); uv := self.assets.gem_uv(gem_index);
ctx.add_image_uv(gf, self.assets.gems_tex, uv.uv_min, uv.uv_max); ctx.add_image_uv(gf, self.assets.gems_tex, uv.uv_min, uv.uv_max);
} }
@@ -285,7 +285,7 @@ BoardView :: struct {
// Frame for a gem shrunk by `scale` about its cell centre — the clear // Frame for a gem shrunk by `scale` about its cell centre — the clear
// scale-out. At scale 0 the gem is a zero-size frame (gone). // scale-out. At scale 0 the gem is a zero-size frame (gone).
gem_frame_scaled :: (self: *BoardView, col: s64, row: s64, dim: f32, scale: f32) -> Frame { gem_frame_scaled :: (self: *BoardView, col: i64, row: i64, dim: f32, scale: f32) -> Frame {
cs := self.layout.cell_size; cs := self.layout.cell_size;
cx := self.layout.origin.x + cast(f32) col * cs + cs * 0.5; cx := self.layout.origin.x + cast(f32) col * cs + cs * 0.5;
cy := self.layout.origin.y + cast(f32) row * cs + cs * 0.5; cy := self.layout.origin.y + cast(f32) row * cs + cs * 0.5;
@@ -297,7 +297,7 @@ BoardView :: struct {
// sprite is scaled about its cell centre and nudged by the pose offset (both // sprite is scaled about its cell centre and nudged by the pose offset (both
// in cell units). A resting pose reproduces gem_frame exactly, so the t==0 // in cell units). A resting pose reproduces gem_frame exactly, so the t==0
// idle pose draws identically to the static sprite. // idle pose draws identically to the static sprite.
gem_pose_frame :: (self: *BoardView, col: s64, row: s64, dim: f32, pose: GemPose) -> Frame { gem_pose_frame :: (self: *BoardView, col: i64, row: i64, dim: f32, pose: GemPose) -> Frame {
cs := self.layout.cell_size; cs := self.layout.cell_size;
cx := self.layout.origin.x + (cast(f32) col + 0.5) * cs + pose.dx * cs; cx := self.layout.origin.x + (cast(f32) col + 0.5) * cs + pose.dx * cs;
cy := self.layout.origin.y + (cast(f32) row + 0.5) * cs + pose.dy * cs; cy := self.layout.origin.y + (cast(f32) row + 0.5) * cs + pose.dy * cs;
@@ -311,7 +311,7 @@ BoardView :: struct {
// — the wide-and-short landing impact. sq==0 reproduces gem_frame's centred // — the wide-and-short landing impact. sq==0 reproduces gem_frame's centred
// placement EXACTLY, so a gem still mid-fall (or one that never moved) draws // placement EXACTLY, so a gem still mid-fall (or one that never moved) draws
// byte-identically to the plain fall; only a landed gem flattens. // byte-identically to the plain fall; only a landed gem flattens.
gem_squash_frame :: (self: *BoardView, col: s64, frow: f32, dim: f32, sq: f32) -> Frame { gem_squash_frame :: (self: *BoardView, col: i64, frow: f32, dim: f32, sq: f32) -> Frame {
cs := self.layout.cell_size; cs := self.layout.cell_size;
cx := self.layout.origin.x + (cast(f32) col + 0.5) * cs; cx := self.layout.origin.x + (cast(f32) col + 0.5) * cs;
cy := self.layout.origin.y + (frow + 0.5) * cs; cy := self.layout.origin.y + (frow + 0.5) * cs;
@@ -323,7 +323,7 @@ BoardView :: struct {
// The per-gem animation pose for a settled cell: the always-on idle breath, // The per-gem animation pose for a settled cell: the always-on idle breath,
// plus a squash-bounce if the cell landed recently, plus a pop if it is the // plus a squash-bounce if the cell landed recently, plus a pop if it is the
// selected cell. Purely visual — composed from gem_anim's pure functions. // selected cell. Purely visual — composed from gem_anim's pure functions.
gem_pose_at :: (self: *BoardView, col: s64, row: s64) -> GemPose { gem_pose_at :: (self: *BoardView, col: i64, row: i64) -> GemPose {
pose := idle_pose(self.motion.clock, col, row); pose := idle_pose(self.motion.clock, col, row);
sq := land_squash(self.motion.land_local(Board.idx(col, row))); sq := land_squash(self.motion.land_local(Board.idx(col, row)));
@@ -347,7 +347,7 @@ BoardView :: struct {
// (land_squash → 0, so it draws unsquashed) and one that never moved reads 0. // (land_squash → 0, so it draws unsquashed) and one that never moved reads 0.
// render_fall passes the current round; render_clear the previous (its board is // render_fall passes the current round; render_clear the previous (its board is
// that round's `after`), so the one bounce plays on across the fall→clear seam. // that round's `after`), so the one bounce plays on across the fall→clear seam.
rest_squash :: (self: *BoardView, i: s64, kmax: s64, elapsed: f32) -> f32 { rest_squash :: (self: *BoardView, i: i64, kmax: i64, elapsed: f32) -> f32 {
m := delivering_round(@self.anim.move, i, kmax); m := delivering_round(@self.anim.move, i, kmax);
if m < 0 { return 0.0; } if m < 0 { return 0.0; }
col := i % BOARD_COLS; col := i % BOARD_COLS;
@@ -357,13 +357,13 @@ BoardView :: struct {
// Settled-board gems: one sprite per non-empty cell, drawn with its live // Settled-board gems: one sprite per non-empty cell, drawn with its live
// per-gem animation pose. Used whenever no move is animating. // per-gem animation pose. Used whenever no move is animating.
render_gems :: (self: *BoardView, ctx: *RenderContext, dim: f32) { render_gems :: (self: *BoardView, ctx: *RenderContext, dim: f32) {
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
g := self.board.at(col, row); g := self.board.at(col, row);
if g != .empty { if g != .empty {
pose := self.gem_pose_at(col, row); pose := self.gem_pose_at(col, row);
gf := self.gem_pose_frame(col, row, dim, pose); gf := self.gem_pose_frame(col, row, dim, pose);
self.draw_gem(ctx, gf, cast(s64) g); self.draw_gem(ctx, gf, cast(i64) g);
} }
} }
} }
@@ -431,12 +431,12 @@ BoardView :: struct {
// (which resumes the same back-dated stamp). tick() normally clears // (which resumes the same back-dated stamp). tick() normally clears
// `active` before this is reached. // `active` before this is reached.
last := mv.rounds.len - 1; last := mv.rounds.len - 1;
for 0..BOARD_CELLS: (i) { for 0..BOARD_CELLS (i) {
g := mv.final[i]; g := mv.final[i];
if g != .empty { if g != .empty {
sq := self.rest_squash(i, last, e); sq := self.rest_squash(i, last, e);
gf := self.gem_squash_frame(i % BOARD_COLS, cast(f32) (i / BOARD_COLS), dim, sq); gf := self.gem_squash_frame(i % BOARD_COLS, cast(f32) (i / BOARD_COLS), dim, sq);
self.draw_gem(ctx, gf, cast(s64) g); self.draw_gem(ctx, gf, cast(i64) g);
} }
} }
} }
@@ -452,12 +452,12 @@ BoardView :: struct {
ai := Board.idx(mv.a.col, mv.a.row); ai := Board.idx(mv.a.col, mv.a.row);
bi := Board.idx(mv.b.col, mv.b.row); bi := Board.idx(mv.b.col, mv.b.row);
for 0..BOARD_CELLS: (i) { for 0..BOARD_CELLS (i) {
if i == ai or i == bi { continue; } if i == ai or i == bi { continue; }
g := mv.pre[i]; g := mv.pre[i];
if g != .empty { if g != .empty {
gf := self.gem_frame(cast(f32) (i % BOARD_COLS), cast(f32) (i / BOARD_COLS), inset, dim); gf := self.gem_frame(cast(f32) (i % BOARD_COLS), cast(f32) (i / BOARD_COLS), inset, dim);
self.draw_gem(ctx, gf, cast(s64) g); self.draw_gem(ctx, gf, cast(i64) g);
} }
} }
@@ -482,20 +482,20 @@ BoardView :: struct {
ga := mv.pre[ai]; ga := mv.pre[ai];
if ga != .empty { if ga != .empty {
gf := self.gem_frame(afc + (bfc - afc) * p, afr + (bfr - afr) * p, inset, dim); gf := self.gem_frame(afc + (bfc - afc) * p, afr + (bfr - afr) * p, inset, dim);
self.draw_gem(ctx, gf, cast(s64) ga); self.draw_gem(ctx, gf, cast(i64) ga);
} }
gb := mv.pre[bi]; gb := mv.pre[bi];
if gb != .empty { if gb != .empty {
gf := self.gem_frame(bfc + (afc - bfc) * p, bfr + (afr - bfr) * p, inset, dim); gf := self.gem_frame(bfc + (afc - bfc) * p, bfr + (afr - bfr) * p, inset, dim);
self.draw_gem(ctx, gf, cast(s64) gb); self.draw_gem(ctx, gf, cast(i64) gb);
} }
} }
// Clear segment: matched gems pop outward then collapse to nothing (a // Clear segment: matched gems pop outward then collapse to nothing (a
// satisfying pop, composing with the particle burst); the rest hold position. // satisfying pop, composing with the particle burst); the rest hold position.
render_clear :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, k: s64, e: f32, dim: f32, t: f32) { render_clear :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, k: i64, e: f32, dim: f32, t: f32) {
span := clear_diag_span(@rd.matched); span := clear_diag_span(@rd.matched);
for 0..BOARD_CELLS: (i) { for 0..BOARD_CELLS (i) {
g := rd.before[i]; g := rd.before[i];
if g == .empty { continue; } if g == .empty { continue; }
col := i % BOARD_COLS; col := i % BOARD_COLS;
@@ -507,7 +507,7 @@ BoardView :: struct {
// reaches scale 0 by t==1, keeping the seam to the fall clean. // reaches scale 0 by t==1, keeping the seam to the fall clean.
pop := clear_pop_scale(clear_ripple_t(t, clear_rank(span, col, row))); pop := clear_pop_scale(clear_ripple_t(t, clear_rank(span, col, row)));
gf := self.gem_frame_scaled(col, row, dim, pop); gf := self.gem_frame_scaled(col, row, dim, pop);
self.draw_gem(ctx, gf, cast(s64) g); self.draw_gem(ctx, gf, cast(i64) g);
} else { } else {
// before[k] is round k-1's settled board, so a survivor here still // before[k] is round k-1's settled board, so a survivor here still
// carries the bounce from the round that dropped it in — continue it // carries the bounce from the round that dropped it in — continue it
@@ -515,7 +515,7 @@ BoardView :: struct {
// (nothing has fallen yet), keeping that frame byte-identical. // (nothing has fallen yet), keeping that frame byte-identical.
sq := self.rest_squash(i, k - 1, e); sq := self.rest_squash(i, k - 1, e);
gf := self.gem_squash_frame(col, cast(f32) row, dim, sq); gf := self.gem_squash_frame(col, cast(f32) row, dim, sq);
self.draw_gem(ctx, gf, cast(s64) g); self.draw_gem(ctx, gf, cast(i64) g);
} }
} }
} }
@@ -532,7 +532,7 @@ BoardView :: struct {
cs * cast(f32) BOARD_COLS, cs * cast(f32) BOARD_ROWS cs * cast(f32) BOARD_COLS, cs * cast(f32) BOARD_ROWS
); );
ctx.push_clip(grid); ctx.push_clip(grid);
for 0..self.fx.particles.len: (i) { for 0..self.fx.particles.len (i) {
p := self.fx.particles.items[i]; p := self.fx.particles.items[i];
lt := (p.age - p.delay) / p.life; lt := (p.age - p.delay) / p.life;
env := fx_pop_env(lt); env := fx_pop_env(lt);
@@ -556,7 +556,7 @@ BoardView :: struct {
render_fx_popups :: (self: *BoardView, ctx: *RenderContext) { render_fx_popups :: (self: *BoardView, ctx: *RenderContext) {
if self.fx == null or self.fx.popups.len == 0 { return; } if self.fx == null or self.fx.popups.len == 0 { return; }
cs := self.layout.cell_size; cs := self.layout.cell_size;
for 0..self.fx.popups.len: (i) { for 0..self.fx.popups.len (i) {
q := self.fx.popups.items[i]; q := self.fx.popups.items[i];
lt := (q.age - q.delay) / q.life; lt := (q.age - q.delay) / q.life;
if lt >= 0.0 { if lt >= 0.0 {
@@ -593,8 +593,8 @@ BoardView :: struct {
// lockstep row; ease_in_cubic pins each column's f(1)=1, and fall_stagger_t // lockstep row; ease_in_cubic pins each column's f(1)=1, and fall_stagger_t
// guarantees every column reaches 1 by t==1, so each gem lands exactly on its // guarantees every column reaches 1 by t==1, so each gem lands exactly on its
// cell and the seam to the next round / settled board stays invisible. // cell and the seam to the next round / settled board stays invisible.
render_fall :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, k: s64, e: f32, dim: f32, t: f32) { render_fall :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, k: i64, e: f32, dim: f32, t: f32) {
for 0..BOARD_CELLS: (i) { for 0..BOARD_CELLS (i) {
g := rd.after[i]; g := rd.after[i];
if g == .empty { continue; } if g == .empty { continue; }
col := i % BOARD_COLS; col := i % BOARD_COLS;
@@ -608,7 +608,7 @@ BoardView :: struct {
// has reached its cell flattens wide-and-short, then wobbles out. // has reached its cell flattens wide-and-short, then wobbles out.
sq := self.rest_squash(i, k, e); sq := self.rest_squash(i, k, e);
gf := self.gem_squash_frame(col, cur_row, dim, sq); gf := self.gem_squash_frame(col, cur_row, dim, sq);
self.draw_gem(ctx, gf, cast(s64) g); self.draw_gem(ctx, gf, cast(i64) g);
} }
} }
@@ -668,7 +668,7 @@ BoardView :: struct {
// pin) is set, so the unset render path is byte-identical. A bright halo under // pin) is set, so the unset render path is byte-identical. A bright halo under
// the dark text keeps the digits legible over the light background art. // the dark text keeps the digits legible over the light background art.
render_fps_overlay :: (self: *BoardView, ctx: *RenderContext, frame: Frame) { render_fps_overlay :: (self: *BoardView, ctx: *RenderContext, frame: Frame) {
n := cast(s64) (self.fps + 0.5); n := cast(i64) (self.fps + 0.5);
txt := format("FPS {}", n); txt := format("FPS {}", n);
sz := measure_text(txt, FPS_FONT); sz := measure_text(txt, FPS_FONT);
x := frame.origin.x + self.safe.left + FPS_PAD; x := frame.origin.x + self.safe.left + FPS_PAD;
@@ -731,8 +731,8 @@ impl View for BoardView {
gem_inset := self.layout.cell_size * (1.0 - GEM_FILL_FRAC) * 0.5; gem_inset := self.layout.cell_size * (1.0 - GEM_FILL_FRAC) * 0.5;
gem_dim := self.layout.cell_size * GEM_FILL_FRAC; gem_dim := self.layout.cell_size * GEM_FILL_FRAC;
if self.assets.cell_tex != 0 { if self.assets.cell_tex != 0 {
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
ctx.add_image(self.layout.cell_frame(col, row), self.assets.cell_tex); ctx.add_image(self.layout.cell_frame(col, row), self.assets.cell_tex);
} }
} }

View File

@@ -1,4 +1,4 @@
#import "modules/compiler.sx"; #import "modules/build.sx";
#import "modules/platform/bundle.sx"; #import "modules/platform/bundle.sx";
configure_build :: () { configure_build :: () {

View File

@@ -25,9 +25,9 @@ the step asks for an honest accounting.
## Category: Numerics / parsing ## Category: Numerics / parsing
### 1. String → number parsing (`parse_s64`, `parse_f32`) ### 1. String → number parsing (`parse_i64`, `parse_f32`)
- **m3te location:** `main.sx:156` (`parse_s64`), `main.sx:122` (`parse_f32`). - **m3te location:** `main.sx:156` (`parse_i64`), `main.sx:122` (`parse_f32`).
- **What it does:** decimal ASCII `string``s64` / `f32` (sign + integer + - **What it does:** decimal ASCII `string``i64` / `f32` (sign + integer +
optional fractional part). optional fractional part).
- **Why general-purpose:** the exact inverse of the formatters everyone already - **Why general-purpose:** the exact inverse of the formatters everyone already
uses; needed by any program that reads numbers from argv, env, config, or a uses; needed by any program that reads numbers from argv, env, config, or a
@@ -46,7 +46,7 @@ the step asks for an honest accounting.
- **m3te location:** `board.sx:55` (`Rng` struct: `next_u32` `board.sx:59`, - **m3te location:** `board.sx:55` (`Rng` struct: `next_u32` `board.sx:59`,
`next_range` `board.sx:66`), `board.sx:71` (`rng_seeded`), constants `next_range` `board.sx:66`), `board.sx:71` (`rng_seeded`), constants
`board.sx:51-53`. `board.sx:51-53`.
- **What it does:** a 32-bit linear-congruential generator carried in `s64` - **What it does:** a 32-bit linear-congruential generator carried in `i64`
and masked to 32 bits — `next_u32()`, `next_range(n)` (uniform-ish `[0,n)`), and masked to 32 bits — `next_u32()`, `next_range(n)` (uniform-ish `[0,n)`),
`rng_seeded(seed)`. `rng_seeded(seed)`.
- **Why general-purpose:** a seedable, reproducible RNG is a textbook stdlib - **Why general-purpose:** a seedable, reproducible RNG is a textbook stdlib
@@ -172,7 +172,7 @@ them, so they are explicitly **not** counted above:
## Summary (top gaps, ranked) ## Summary (top gaps, ranked)
1. **String → number parsing** (`parse_s64` / `parse_f32`) — clean, universally 1. **String → number parsing** (`parse_i64` / `parse_f32`) — clean, universally
needed, the missing inverse of the existing formatters. needed, the missing inverse of the existing formatters.
2. **Seedable PRNG** (`Rng` LCG) — textbook stdlib primitive, entirely absent. 2. **Seedable PRNG** (`Rng` LCG) — textbook stdlib primitive, entirely absent.
3. **`from_cstr` C-string bridge** (+ public `strlen`) — the most-repeated FFI 3. **`from_cstr` C-string bridge** (+ public `strlen`) — the most-repeated FFI

View File

@@ -38,11 +38,11 @@ IDLE_BOB_A :f32: 0.024; // vertical bob amplitude (cell units)
IDLE_RAMP :f32: 0.45; // seconds to ease the idle up from full rest IDLE_RAMP :f32: 0.45; // seconds to ease the idle up from full rest
// Smooth per-cell phase: a diagonal gradient wrapped into one breath period. // Smooth per-cell phase: a diagonal gradient wrapped into one breath period.
gem_idle_phase :: (col: s64, row: s64) -> f32 { gem_idle_phase :: (col: i64, row: i64) -> f32 {
cast(f32) ((col * 2 + row * 3) % 8) / 8.0 * TAU cast(f32) ((col * 2 + row * 3) % 8) / 8.0 * TAU
} }
idle_pose :: (t: f32, col: s64, row: s64) -> GemPose { idle_pose :: (t: f32, col: i64, row: i64) -> GemPose {
ramp := clamp(t / IDLE_RAMP, 0.0, 1.0); ramp := clamp(t / IDLE_RAMP, 0.0, 1.0);
w := t / IDLE_PERIOD * TAU + gem_idle_phase(col, row); w := t / IDLE_PERIOD * TAU + gem_idle_phase(col, row);
s := IDLE_SCALE_A * sin(w) * ramp; s := IDLE_SCALE_A * sin(w) * ramp;
@@ -132,10 +132,10 @@ GemMotion :: struct {
// its resting pose instead of replaying the prior move's landing wobble; the // its resting pose instead of replaying the prior move's landing wobble; the
// idle clock keeps running, so the always-on idle simply resumes from rest. // idle clock keeps running, so the always-on idle simply resumes from rest.
reset_landings :: (self: *GemMotion) { reset_landings :: (self: *GemMotion) {
for 0..BOARD_CELLS: (i) { self.land_at[i] = -1000.0; } for 0..BOARD_CELLS (i) { self.land_at[i] = -1000.0; }
} }
stamp_land :: (self: *GemMotion, i: s64) { stamp_land :: (self: *GemMotion, i: i64) {
self.stamp_land_at(i, self.clock); self.stamp_land_at(i, self.clock);
} }
@@ -143,11 +143,11 @@ GemMotion :: struct {
// can BACK-DATE the stamp to when the gem actually touched down mid-fall (each // can BACK-DATE the stamp to when the gem actually touched down mid-fall (each
// column lands at a staggered instant): land_squash then resumes the per-round // column lands at a staggered instant): land_squash then resumes the per-round
// bounce exactly where render_fall left it, with no double-pop at the seam. // bounce exactly where render_fall left it, with no double-pop at the seam.
stamp_land_at :: (self: *GemMotion, i: s64, at: f32) { stamp_land_at :: (self: *GemMotion, i: i64, at: f32) {
self.land_at[i] = at; self.land_at[i] = at;
} }
land_local :: (self: *GemMotion, i: s64) -> f32 { land_local :: (self: *GemMotion, i: i64) -> f32 {
self.clock - self.land_at[i] self.clock - self.land_at[i]
} }
} }

97
main.sx
View File

@@ -1,11 +1,13 @@
#import "modules/std.sx"; #import "modules/std.sx";
#import "build.sx"; #import "build.sx";
#import "modules/compiler.sx"; #import "modules/build.sx";
#import "modules/opengl.sx"; #import "modules/ffi/opengl.sx";
#import "modules/sdl3.sx"; #import "modules/ffi/sdl3.sx";
#import "modules/math"; #import "modules/math";
#import "modules/stb.sx"; #import "vendors/stb_image/stb_image.sx";
#import "modules/stb_truetype.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/api.sx";
#import "modules/gpu/types.sx"; #import "modules/gpu/types.sx";
#import "modules/gpu/metal.sx"; #import "modules/gpu/metal.sx";
@@ -24,8 +26,6 @@
// libc is the implicit foreign-library handle the std allocators bind against; // libc is the implicit foreign-library handle the std allocators bind against;
// reused here to read the deterministic-capture environment variables at startup. // 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 // 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. // as a snapshot, so the on-screen layout matches that golden gem-for-gem.
@@ -116,41 +116,34 @@ build_ui :: () -> View {
// M3TE_SELECT=<cellIndex 0..63> forces a selection so the select-pop reaction can // M3TE_SELECT=<cellIndex 0..63> forces a selection so the select-pop reaction can
// be captured without injecting a tap. Absent → normal live behaviour. // be captured without injecting a tap. Absent → normal live behaviour.
read_env :: (name: [:0]u8) -> ?string { read_env :: (name: [:0]u8) -> ?string {
p := getenv(name); process.env(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 // 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/ // 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. // 57) to f32 across the comparisons, which mis-types the byte compares.
parse_f32 :: (s: string) -> f32 { parse_f32 :: (s: string) -> f32 {
i : s64 = 0; i : i64 = 0;
neg : bool = false; neg : bool = false;
if s.len > 0 { if s.len > 0 {
c0 : s64 = xx s[0]; c0 : i64 = xx s[0];
if c0 == 45 { neg = true; i = 1; } // '-' if c0 == 45 { neg = true; i = 1; } // '-'
} }
intval : s64 = 0; intval : i64 = 0;
while i < s.len { while i < s.len {
c : s64 = xx s[i]; c : i64 = xx s[i];
if c < 48 or c > 57 { break; } if c < 48 or c > 57 { break; }
intval = intval * 10 + (c - 48); intval = intval * 10 + (c - 48);
i += 1; i += 1;
} }
fracval : s64 = 0; fracval : i64 = 0;
fracdiv : s64 = 1; fracdiv : i64 = 1;
if i < s.len { if i < s.len {
d : s64 = xx s[i]; d : i64 = xx s[i];
if d == 46 { // '.' if d == 46 { // '.'
i += 1; i += 1;
while i < s.len { while i < s.len {
c : s64 = xx s[i]; c : i64 = xx s[i];
if c < 48 or c > 57 { break; } if c < 48 or c > 57 { break; }
fracval = fracval * 10 + (c - 48); fracval = fracval * 10 + (c - 48);
fracdiv = fracdiv * 10; fracdiv = fracdiv * 10;
@@ -163,11 +156,11 @@ parse_f32 :: (s: string) -> f32 {
v v
} }
parse_s64 :: (s: string) -> s64 { parse_i64 :: (s: string) -> i64 {
i : s64 = 0; i : i64 = 0;
v : s64 = 0; v : i64 = 0;
while i < s.len { while i < s.len {
c : s64 = xx s[i]; c : i64 = xx s[i];
if c < 48 or c > 57 { break; } if c < 48 or c > 57 { break; }
v = v * 10 + (c - 48); v = v * 10 + (c - 48);
i += 1; i += 1;
@@ -183,8 +176,8 @@ parse_s64 :: (s: string) -> s64 {
// trial swaps inside `swap_legal` are reverted, so the board is left unchanged. // trial swaps inside `swap_legal` are reverted, so the board is left unchanged.
illegal_swaps :: (board: *Board) -> List(Swap) { illegal_swaps :: (board: *Board) -> List(Swap) {
result := List(Swap).{}; result := List(Swap).{};
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
here := Cell.{ col = col, row = row }; here := Cell.{ col = col, row = row };
if col + 1 < BOARD_COLS { if col + 1 < BOARD_COLS {
right := Cell.{ col = col + 1, row = row }; right := Cell.{ col = col + 1, row = row };
@@ -222,7 +215,7 @@ frame :: () {
g_pipeline.resize(fc.viewport_w, fc.viewport_h); g_pipeline.resize(fc.viewport_w, fc.viewport_h);
} }
for g_plat.poll_events(): (*ev) { for g_plat.poll_events() (*ev) {
inline if OS != .ios { inline if OS != .ios {
if ev == { if ev == {
case .key_up: (e) { case .key_up: (e) {
@@ -277,7 +270,7 @@ frame :: () {
mv := @g_anim.move; mv := @g_anim.move;
total := g_anim.total(); total := g_anim.total();
last := mv.rounds.len - 1; last := mv.rounds.len - 1;
for 0..BOARD_CELLS: (i) { for 0..BOARD_CELLS (i) {
m := delivering_round(mv, i, last); m := delivering_round(mv, i, last);
if m >= 0 { if m >= 0 {
col := i % BOARD_COLS; col := i % BOARD_COLS;
@@ -327,7 +320,7 @@ frame :: () {
main :: () -> void { main :: () -> void {
inline if OS == .ios { inline if OS == .ios {
u : *UIKitPlatform = xx context.allocator.alloc(size_of(UIKitPlatform)); u : *UIKitPlatform = xx context.allocator.alloc_bytes(size_of(UIKitPlatform));
u.gpu_mode = .metal; u.gpu_mode = .metal;
if !u.init("m3te", 800, 600) { return; } if !u.init("m3te", 800, 600) { return; }
g_plat = xx u; g_plat = xx u;
@@ -337,13 +330,13 @@ main :: () -> void {
// return into UIApplicationMain, so attach lazily on the first frame. // return into UIApplicationMain, so attach lazily on the first frame.
// init(null, 0, 0) only needs the MTLDevice, which is enough for the // init(null, 0, 0) only needs the MTLDevice, which is enough for the
// texture uploads below. // texture uploads below.
g_metal_gpu = xx context.allocator.alloc(size_of(MetalGPU)); g_metal_gpu = xx context.allocator.alloc_bytes(size_of(MetalGPU));
// alloc returns uninitialized memory; struct field defaults are NOT // alloc returns uninitialized memory; struct field defaults are NOT
// applied, so List caps/lens would be garbage without this memset. // applied, so List caps/lens would be garbage without this memset.
memset(xx g_metal_gpu, 0, size_of(MetalGPU)); memset(xx g_metal_gpu, 0, size_of(MetalGPU));
if !g_metal_gpu.init(null, 0, 0) { return; } if !g_metal_gpu.init(null, 0, 0) { return; }
} else { } else {
s : *SdlPlatform = xx context.allocator.alloc(size_of(SdlPlatform)); s : *SdlPlatform = xx context.allocator.alloc_bytes(size_of(SdlPlatform));
if !s.init("m3te", 800, 600) { return; } if !s.init("m3te", 800, 600) { return; }
g_plat = xx s; g_plat = xx s;
} }
@@ -353,7 +346,7 @@ main :: () -> void {
g_viewport_h = fc.viewport_h; g_viewport_h = fc.viewport_h;
g_safe_insets = g_plat.safe_insets(); g_safe_insets = g_plat.safe_insets();
g_pipeline = xx context.allocator.alloc(size_of(UIPipeline)); g_pipeline = xx context.allocator.alloc_bytes(size_of(UIPipeline));
// Same alloc caveat as above: zero so the optional `gpu` reads as null on // 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. // the desktop path (where set_gpu is not called) and the Lists start empty.
memset(xx g_pipeline, 0, size_of(UIPipeline)); memset(xx g_pipeline, 0, size_of(UIPipeline));
@@ -363,37 +356,37 @@ main :: () -> void {
g_pipeline.init(fc.viewport_w, fc.viewport_h); g_pipeline.init(fc.viewport_w, fc.viewport_h);
g_pipeline.init_font("assets/fonts/default.ttf", 32.0, fc.dpi_scale); g_pipeline.init_font("assets/fonts/default.ttf", 32.0, fc.dpi_scale);
g_board = xx context.allocator.alloc(size_of(Board)); g_board = xx context.allocator.alloc_bytes(size_of(Board));
g_board.init(BOARD_SEED); g_board.init(BOARD_SEED);
g_assets = xx context.allocator.alloc(size_of(BoardAssets)); g_assets = xx context.allocator.alloc_bytes(size_of(BoardAssets));
g_assets.init(); g_assets.init();
g_assets.load(g_pipeline.gpu); g_assets.load(g_pipeline.gpu);
g_sel = xx context.allocator.alloc(size_of(BoardSelection)); g_sel = xx context.allocator.alloc_bytes(size_of(BoardSelection));
g_sel.init(); g_sel.init();
g_drag = xx context.allocator.alloc(size_of(DragInput)); g_drag = xx context.allocator.alloc_bytes(size_of(DragInput));
g_drag.init(); g_drag.init();
g_anim = xx context.allocator.alloc(size_of(BoardAnim)); g_anim = xx context.allocator.alloc_bytes(size_of(BoardAnim));
g_anim.init(); g_anim.init();
g_fx = xx context.allocator.alloc(size_of(BoardFx)); g_fx = xx context.allocator.alloc_bytes(size_of(BoardFx));
g_fx.init(); g_fx.init();
g_fxassets = xx context.allocator.alloc(size_of(BoardFxAssets)); g_fxassets = xx context.allocator.alloc_bytes(size_of(BoardFxAssets));
g_fxassets.init(); g_fxassets.init();
g_fxassets.load(g_pipeline.gpu); g_fxassets.load(g_pipeline.gpu);
g_motion = xx context.allocator.alloc(size_of(GemMotion)); g_motion = xx context.allocator.alloc_bytes(size_of(GemMotion));
g_motion.init(); g_motion.init();
// SFX (P10.2). Loads the System Sound Services cue bank once; board_view // 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 // 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 // state. On iOS the platform has already chdir'd to the bundle, so each
// cue's relative path resolves. No-op off iOS. // cue's relative path resolves. No-op off iOS.
g_audio = xx context.allocator.alloc(size_of(GameAudio)); g_audio = xx context.allocator.alloc_bytes(size_of(GameAudio));
memset(xx g_audio, 0, size_of(GameAudio)); memset(xx g_audio, 0, size_of(GameAudio));
g_audio.init(); g_audio.init();
@@ -405,7 +398,7 @@ main :: () -> void {
g_motion.clock = parse_f32(t); g_motion.clock = parse_f32(t);
} }
if sc := read_env("M3TE_SELECT") { if sc := read_env("M3TE_SELECT") {
idx := parse_s64(sc); idx := parse_i64(sc);
if idx >= 0 and idx < BOARD_CELLS { if idx >= 0 and idx < BOARD_CELLS {
g_sel.active = true; g_sel.active = true;
g_sel.cell = Cell.{ col = idx % BOARD_COLS, row = idx / BOARD_COLS }; g_sel.cell = Cell.{ col = idx % BOARD_COLS, row = idx / BOARD_COLS };
@@ -418,7 +411,7 @@ main :: () -> void {
// committed golden stay byte-identical. Purely a render overlay — no board / // committed golden stay byte-identical. Purely a render overlay — no board /
// score / move / animation state changes and it never gates input. // score / move / animation state changes and it never gates input.
if fp := read_env("M3TE_FPS") { if fp := read_env("M3TE_FPS") {
if parse_s64(fp) != 0 { g_fps_on = true; } if parse_i64(fp) != 0 { g_fps_on = true; }
} }
// Match-FX capture hook (P11.1). The bursts/popups spawn off a committed move, // Match-FX capture hook (P11.1). The bursts/popups spawn off a committed move,
@@ -433,7 +426,7 @@ main :: () -> void {
if fx := read_env("M3TE_FX") { if fx := read_env("M3TE_FX") {
swaps := legal_swaps(g_board); swaps := legal_swaps(g_board);
if swaps.len > 0 { if swaps.len > 0 {
n := parse_s64(fx); n := parse_i64(fx);
if n < 1 { n = 1; } if n < 1 { n = 1; }
if n > swaps.len { n = swaps.len; } if n > swaps.len { n = swaps.len; }
sw := swaps.items[n - 1]; sw := swaps.items[n - 1];
@@ -457,7 +450,7 @@ main :: () -> void {
if bs := read_env("M3TE_BADSWAP") { if bs := read_env("M3TE_BADSWAP") {
bad := illegal_swaps(g_board); bad := illegal_swaps(g_board);
if bad.len > 0 { if bad.len > 0 {
n := parse_s64(bs); n := parse_i64(bs);
if n < 1 { n = 1; } if n < 1 { n = 1; }
if n > bad.len { n = bad.len; } if n > bad.len { n = bad.len; }
sw := bad.items[n - 1]; sw := bad.items[n - 1];
@@ -473,10 +466,10 @@ main :: () -> void {
// M3TE_MOVE_LIMIT=0 makes it read LOST (budget spent below the goal). With // 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 // M3TE_RESTART set non-zero the board is then restart()-ed, capturing the
// fresh in_progress board the restart button produces. // fresh in_progress board the restart button produces.
if tg := read_env("M3TE_TARGET") { g_board.target_score = parse_s64(tg); } 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_s64(ml); } if ml := read_env("M3TE_MOVE_LIMIT") { g_board.move_limit = parse_i64(ml); }
if rs := read_env("M3TE_RESTART") { if rs := read_env("M3TE_RESTART") {
if parse_s64(rs) != 0 { g_board.restart(BOARD_SEED); } if parse_i64(rs) != 0 { g_board.restart(BOARD_SEED); }
} }
g_pipeline.set_body(closure(build_ui)); g_pipeline.set_body(closure(build_ui));

View File

@@ -24,14 +24,14 @@
SEED :: 1337; SEED :: 1337;
boards_equal :: (x: *Board, y: *Board) -> bool { boards_equal :: (x: *Board, y: *Board) -> bool {
for 0..BOARD_CELLS: (i) { for 0..BOARD_CELLS (i) {
if !(x.cells[i] == y.cells[i]) { return false; } if !(x.cells[i] == y.cells[i]) { return false; }
} }
true true
} }
main :: () -> s32 { main :: () -> i32 {
fails : s64 = 0; fails : i64 = 0;
// ── Legal swap: plan == model, timeline ends on the model ─────────────── // ── Legal swap: plan == model, timeline ends on the model ───────────────
// (5,4)->(6,4): brings R into (5,4), completing R,R,R across cols 3-5 of row // (5,4)->(6,4): brings R into (5,4), completing R,R,R across cols 3-5 of row
@@ -61,7 +61,7 @@ main :: () -> s32 {
// move.final equals the model board. // move.final equals the model board.
final_eq := true; final_eq := true;
for 0..BOARD_CELLS: (i) { for 0..BOARD_CELLS (i) {
if !(move.final[i] == bm.cells[i]) { final_eq = false; } if !(move.final[i] == bm.cells[i]) { final_eq = false; }
} }
if !final_eq { fails += 1; } if !final_eq { fails += 1; }
@@ -74,21 +74,21 @@ main :: () -> s32 {
ai := Board.idx(a.col, a.row); ai := Board.idx(a.col, a.row);
bi := Board.idx(b.col, b.row); bi := Board.idx(b.col, b.row);
r0 := @move.rounds.items[0]; r0 := @move.rounds.items[0];
for 0..BOARD_CELLS: (i) { for 0..BOARD_CELLS (i) {
expect : Gem = move.pre[i]; expect : Gem = move.pre[i];
if i == ai { expect = move.pre[bi]; } if i == ai { expect = move.pre[bi]; }
else if i == bi { expect = move.pre[ai]; } else if i == bi { expect = move.pre[ai]; }
if !(r0.before[i] == expect) { contiguous = false; } if !(r0.before[i] == expect) { contiguous = false; }
} }
for 1..move.rounds.len: (k) { for 1..move.rounds.len (k) {
prev := @move.rounds.items[k - 1]; prev := @move.rounds.items[k - 1];
cur := @move.rounds.items[k]; cur := @move.rounds.items[k];
for 0..BOARD_CELLS: (i) { for 0..BOARD_CELLS (i) {
if !(cur.before[i] == prev.after[i]) { contiguous = false; } if !(cur.before[i] == prev.after[i]) { contiguous = false; }
} }
} }
last := @move.rounds.items[move.rounds.len - 1]; last := @move.rounds.items[move.rounds.len - 1];
for 0..BOARD_CELLS: (i) { for 0..BOARD_CELLS (i) {
if !(last.after[i] == move.final[i]) { contiguous = false; } if !(last.after[i] == move.final[i]) { contiguous = false; }
} }
} }

View File

@@ -4,7 +4,7 @@
#import "modules/std.sx"; #import "modules/std.sx";
t :: #import "test.sx"; t :: #import "test.sx";
main :: () -> s32 { main :: () -> i32 {
t.expect(2 + 2 == 4, "two plus two is four"); t.expect(2 + 2 == 4, "two plus two is four");
t.expect(7 % 3 == 1, "seven mod three is one"); t.expect(7 % 3 == 1, "seven mod three is one");
t.expect(10 - 4 == 6, "ten minus four is six"); t.expect(10 - 4 == 6, "ten minus four is six");

View File

@@ -13,16 +13,17 @@
// shape and rationale as tests/hit_test.sx. Failure is signalled via a non-zero // shape and rationale as tests/hit_test.sx. Failure is signalled via a non-zero
// exit code (the runner checks exit code AND stdout). // exit code (the runner checks exit code AND stdout).
#import "modules/std.sx"; #import "modules/std.sx";
#import "modules/ui/types.sx";
#import "board.sx"; #import "board.sx";
#import "board_layout.sx"; #import "board_layout.sx";
irect :: (f: Frame) -> string { irect :: (f: Frame) -> string {
format("({},{},{},{})", format("({},{},{},{})",
cast(s64) f.origin.x, cast(s64) f.origin.y, cast(i64) f.origin.x, cast(i64) f.origin.y,
cast(s64) f.size.width, cast(s64) f.size.height) cast(i64) f.size.width, cast(i64) f.size.height)
} }
main :: () -> s32 { main :: () -> i32 {
// 800×600, no safe inset → 600px square grid, cell 75, origin (100,0): the // 800×600, no safe inset → 600px square grid, cell 75, origin (100,0): the
// same layout tests/hit_test.sx pins, so the numbers are checkable by hand. // same layout tests/hit_test.sx pins, so the numbers are checkable by hand.
lay : BoardLayout = ---; lay : BoardLayout = ---;
@@ -35,18 +36,18 @@ main :: () -> s32 {
print("title {}\n", irect(bl.title)); print("title {}\n", irect(bl.title));
print("button {}\n", irect(bl.button)); print("button {}\n", irect(bl.button));
fails : s64 = 0; fails : i64 = 0;
// The button is horizontally centered on the grid (centred banner). // The button is horizontally centered on the grid (centred banner).
bcx := bl.button.mid_x(); bcx := bl.button.mid_x();
if cast(s64) bcx != cast(s64) grid.mid_x() { fails += 1; } if cast(i64) bcx != cast(i64) grid.mid_x() { fails += 1; }
print("button mid_x {} grid mid_x {}\n", cast(s64) bcx, cast(s64) grid.mid_x()); print("button mid_x {} grid mid_x {}\n", cast(i64) bcx, cast(i64) grid.mid_x());
// The whole button sits inside the panel — its four corners are contained, // The whole button sits inside the panel — its four corners are contained,
// so it can never spill outside the drawn card. // so it can never spill outside the drawn card.
bx0 := bl.button.origin.x; by0 := bl.button.origin.y; bx0 := bl.button.origin.x; by0 := bl.button.origin.y;
bx1 := bl.button.max_x(); by1 := bl.button.max_y(); bx1 := bl.button.max_x(); by1 := bl.button.max_y();
corners_in : s64 = 0; corners_in : i64 = 0;
if bl.panel.contains(Point.{ x = bx0, y = by0 }) { corners_in += 1; } if bl.panel.contains(Point.{ x = bx0, y = by0 }) { corners_in += 1; }
if bl.panel.contains(Point.{ x = bx1, y = by0 }) { corners_in += 1; } if bl.panel.contains(Point.{ x = bx1, y = by0 }) { corners_in += 1; }
if bl.panel.contains(Point.{ x = bx0, y = by1 }) { corners_in += 1; } if bl.panel.contains(Point.{ x = bx0, y = by1 }) { corners_in += 1; }
@@ -66,7 +67,7 @@ main :: () -> s32 {
// in the button, so each leaves the level frozen. // in the button, so each leaves the level frozen.
corner_cell := Point.{ x = grid.origin.x + lay.cell_size * 0.5, y = grid.origin.y + lay.cell_size * 0.5 }; corner_cell := Point.{ x = grid.origin.x + lay.cell_size * 0.5, y = grid.origin.y + lay.cell_size * 0.5 };
outside := Point.{ x = bl.panel.origin.x - 5.0, y = bl.panel.mid_y() }; outside := Point.{ x = bl.panel.origin.x - 5.0, y = bl.panel.mid_y() };
off_hits : s64 = 0; off_hits : i64 = 0;
if bl.button.contains(corner_cell) { off_hits += 1; } if bl.button.contains(corner_cell) { off_hits += 1; }
if bl.button.contains(outside) { off_hits += 1; } if bl.button.contains(outside) { off_hits += 1; }
if off_hits != 0 { fails += 1; } if off_hits != 0 { fails += 1; }

View File

@@ -10,16 +10,16 @@ SEED :: 1337;
// Count every horizontal or vertical window of three consecutive same-type // Count every horizontal or vertical window of three consecutive same-type
// gems. A correctly initialized board has zero. This walks the finished board // gems. A correctly initialized board has zero. This walks the finished board
// independently of the placement logic, so it's a real check, not a tautology. // independently of the placement logic, so it's a real check, not a tautology.
count_three_runs :: (b: *Board) -> s32 { count_three_runs :: (b: *Board) -> i32 {
runs : s32 = 0; runs : i32 = 0;
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
for 0..(BOARD_COLS - 2): (col) { for 0..(BOARD_COLS - 2) (col) {
g := b.at(col, row); g := b.at(col, row);
if g == b.at(col + 1, row) and g == b.at(col + 2, row) { runs += 1; } if g == b.at(col + 1, row) and g == b.at(col + 2, row) { runs += 1; }
} }
} }
for 0..(BOARD_ROWS - 2): (row) { for 0..(BOARD_ROWS - 2) (row) {
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
g := b.at(col, row); g := b.at(col, row);
if g == b.at(col, row + 1) and g == b.at(col, row + 2) { runs += 1; } if g == b.at(col, row + 1) and g == b.at(col, row + 2) { runs += 1; }
} }
@@ -27,7 +27,7 @@ count_three_runs :: (b: *Board) -> s32 {
runs runs
} }
main :: () -> s32 { main :: () -> i32 {
board : Board = ---; board : Board = ---;
board.init(SEED); board.init(SEED);

View File

@@ -24,7 +24,7 @@ EXPECTED_DEPTH :: 2;
// board can be written as a human-readable grid. The hole glyph maps to `.empty`. // board can be written as a human-readable grid. The hole glyph maps to `.empty`.
char_to_gem :: (c: u8) -> Gem { char_to_gem :: (c: u8) -> Gem {
if c == EMPTY_CHAR { return .empty; } if c == EMPTY_CHAR { return .empty; }
for 0..GEM_COUNT: (i) { for 0..GEM_COUNT (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; } if GEM_CHARS[i] == c { return cast(Gem) i; }
} }
.red .red
@@ -34,9 +34,9 @@ char_to_gem :: (c: u8) -> Gem {
// The RNG is left unseeded — callers seed it before resolving. // The RNG is left unseeded — callers seed it before resolving.
load_board :: (rows: []string) -> Board { load_board :: (rows: []string) -> Board {
b : Board = ---; b : Board = ---;
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
line := rows[row]; line := rows[row];
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
b.set(col, row, char_to_gem(line[col])); b.set(col, row, char_to_gem(line[col]));
} }
} }
@@ -44,7 +44,7 @@ load_board :: (rows: []string) -> Board {
} }
boards_equal :: (a: *Board, b: *Board) -> bool { boards_equal :: (a: *Board, b: *Board) -> bool {
for 0..BOARD_CELLS: (i) { if a.cells[i] != b.cells[i] { return false; } } for 0..BOARD_CELLS (i) { if a.cells[i] != b.cells[i] { return false; } }
true true
} }
@@ -84,7 +84,7 @@ checker_board :: () -> Board {
b b
} }
main :: () -> s32 { main :: () -> i32 {
print("== cascade (resolution loop) ==\n"); print("== cascade (resolution loop) ==\n");
// Drive the loop one round at a time so each post-round board is visible in // Drive the loop one round at a time so each post-round board is visible in
@@ -94,7 +94,7 @@ main :: () -> s32 {
out(board_dump(@b)); out(board_dump(@b));
depth := 0; depth := 0;
counts := List(s64).{}; counts := List(i64).{};
while true { while true {
n := resolve_step(@b); n := resolve_step(@b);
if n == 0 { break; } if n == 0 { break; }
@@ -120,7 +120,7 @@ main :: () -> s32 {
t.expect(c.depth == depth, "cascade: resolve depth matches manual loop"); t.expect(c.depth == depth, "cascade: resolve depth matches manual loop");
same_counts := c.cleared.len == counts.len; same_counts := c.cleared.len == counts.len;
if same_counts { if same_counts {
for 0..counts.len: (i) { for 0..counts.len (i) {
if c.cleared.items[i] != counts.items[i] { same_counts = false; } if c.cleared.items[i] != counts.items[i] { same_counts = false; }
} }
} }

View File

@@ -8,14 +8,14 @@
#import "modules/std.sx"; #import "modules/std.sx";
#import "audio.sx"; #import "audio.sx";
main :: () -> s32 { main :: () -> i32 {
print("== cascade cue selection (depth -> combo cue) ==\n"); print("== cascade cue selection (depth -> combo cue) ==\n");
// Walk a representative depth range (0..9) so both clamps and the monotonic // Walk a representative depth range (0..9) so both clamps and the monotonic
// middle are visible: depths 0,1 pin to the first cue; depths >= 5 pin to // middle are visible: depths 0,1 pin to the first cue; depths >= 5 pin to
// the last; 2,3,4 step up one cue at a time. // the last; 2,3,4 step up one cue at a time.
prev : s64 = -1; prev : i64 = -1;
for 0..10: (depth) { for 0..10 (depth) {
idx := cascade_cue_index(depth); idx := cascade_cue_index(depth);
print("depth {} -> idx {} ({})\n", depth, idx, cascade_cue_name(idx)); print("depth {} -> idx {} ({})\n", depth, idx, cascade_cue_name(idx));
// The mapping must never step down as depth grows. // The mapping must never step down as depth grows.

View File

@@ -13,7 +13,7 @@
#import "board_anim.sx"; #import "board_anim.sx";
#import "audio.sx"; #import "audio.sx";
main :: () -> s32 { main :: () -> i32 {
print("== per-round cascade cue timing ==\n"); print("== per-round cascade cue timing ==\n");
// `cascade_rounds_started` = how many cascade rounds have BEGUN clearing by // `cascade_rounds_started` = how many cascade rounds have BEGUN clearing by
@@ -21,7 +21,7 @@ main :: () -> s32 {
// Round k (0-based) starts clearing at 0.16 + k*0.36; sampled safely INSIDE // Round k (0-based) starts clearing at 0.16 + k*0.36; sampled safely INSIDE
// each round window so the integer step is unambiguous. Locked for 5 rounds. // each round window so the integer step is unambiguous. Locked for 5 rounds.
print("-- started-count across a 5-round chain --\n"); print("-- started-count across a 5-round chain --\n");
rounds : s64 = 5; rounds : i64 = 5;
print("e=0.00 -> {}\n", cascade_rounds_started(0.00, rounds)); print("e=0.00 -> {}\n", cascade_rounds_started(0.00, rounds));
print("e=0.10 -> {}\n", cascade_rounds_started(0.10, rounds)); print("e=0.10 -> {}\n", cascade_rounds_started(0.10, rounds));
print("e=0.20 -> {}\n", cascade_rounds_started(0.20, rounds)); print("e=0.20 -> {}\n", cascade_rounds_started(0.20, rounds));
@@ -37,7 +37,7 @@ main :: () -> s32 {
// ascending. This IS the loop main's frame loop runs; the emitted run is the // ascending. This IS the loop main's frame loop runs; the emitted run is the
// locked acceptance ordering. // locked acceptance ordering.
print("-- ascending per-round run --\n"); print("-- ascending per-round run --\n");
fired : s64 = 0; fired : i64 = 0;
elapsed : f32 = 0.0; elapsed : f32 = 0.0;
while fired < rounds { while fired < rounds {
started := cascade_rounds_started(elapsed, rounds); started := cascade_rounds_started(elapsed, rounds);
@@ -57,7 +57,7 @@ main :: () -> s32 {
// Deep chain: the cue tail clamps at combo5 for round >= 5 (cascade_cue_index). // Deep chain: the cue tail clamps at combo5 for round >= 5 (cascade_cue_index).
print("-- deep-chain cue clamp --\n"); print("-- deep-chain cue clamp --\n");
for 1..8: (r) { print("round {} -> {}\n", r, cascade_cue_name(cascade_cue_index(r))); } for 1..8 (r) { print("round {} -> {}\n", r, cascade_cue_name(cascade_cue_index(r))); }
print("ok: one ascending combo cue per cascade round, clamped at combo5\n"); print("ok: one ascending combo cue per cascade round, clamped at combo5\n");
return 0; return 0;

View File

@@ -16,7 +16,7 @@ t :: #import "test.sx";
// clear) for the holes-never-match regression. // clear) for the holes-never-match regression.
char_to_gem :: (c: u8) -> Gem { char_to_gem :: (c: u8) -> Gem {
if c == EMPTY_CHAR { return .empty; } if c == EMPTY_CHAR { return .empty; }
for 0..GEM_COUNT: (i) { for 0..GEM_COUNT (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; } if GEM_CHARS[i] == c { return cast(Gem) i; }
} }
.red .red
@@ -26,9 +26,9 @@ char_to_gem :: (c: u8) -> Gem {
// characters). // characters).
load_board :: (rows: []string) -> Board { load_board :: (rows: []string) -> Board {
b : Board = ---; b : Board = ---;
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
line := rows[row]; line := rows[row];
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
b.set(col, row, char_to_gem(line[col])); b.set(col, row, char_to_gem(line[col]));
} }
} }
@@ -38,7 +38,7 @@ load_board :: (rows: []string) -> Board {
// Detect→clear one scene, snapshot before/after, and assert the three clear // Detect→clear one scene, snapshot before/after, and assert the three clear
// invariants against the matched-cell set: every flagged cell is now a hole, // invariants against the matched-cell set: every flagged cell is now a hole,
// every unflagged cell is unchanged, and the returned count is exact. // every unflagged cell is unchanged, and the returned count is exact.
scene :: (name: string, rows: []string, want_cleared: s64) { scene :: (name: string, rows: []string, want_cleared: i64) {
b := load_board(rows); b := load_board(rows);
orig := load_board(rows); // pristine copy for the unchanged check orig := load_board(rows); // pristine copy for the unchanged check
@@ -53,7 +53,7 @@ scene :: (name: string, rows: []string, want_cleared: s64) {
cleared_holes := true; // every matched cell is now a hole cleared_holes := true; // every matched cell is now a hole
others_intact := true; // every other cell is byte-identical others_intact := true; // every other cell is byte-identical
for 0..BOARD_CELLS: (i) { for 0..BOARD_CELLS (i) {
if m.cells[i] { if m.cells[i] {
if !(b.cells[i] == .empty) { cleared_holes = false; } if !(b.cells[i] == .empty) { cleared_holes = false; }
} else { } else {
@@ -65,7 +65,7 @@ scene :: (name: string, rows: []string, want_cleared: s64) {
t.expect(cleared == want_cleared, concat(name, ": cleared count exact")); t.expect(cleared == want_cleared, concat(name, ": cleared count exact"));
} }
main :: () -> s32 { main :: () -> i32 {
print("== clear (detect -> clear) ==\n"); print("== clear (detect -> clear) ==\n");
// Single horizontal 3-run (row 3, cols 2-4) → three holes there only. // Single horizontal 3-run (row 3, cols 2-4) → three holes there only.

View File

@@ -18,7 +18,7 @@ t :: #import "test.sx";
// maps to `.empty`, so a board can be hand-written with holes in any position. // maps to `.empty`, so a board can be hand-written with holes in any position.
char_to_gem :: (c: u8) -> Gem { char_to_gem :: (c: u8) -> Gem {
if c == EMPTY_CHAR { return .empty; } if c == EMPTY_CHAR { return .empty; }
for 0..GEM_COUNT: (i) { for 0..GEM_COUNT (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; } if GEM_CHARS[i] == c { return cast(Gem) i; }
} }
.red .red
@@ -27,9 +27,9 @@ char_to_gem :: (c: u8) -> Gem {
// Load an 8x8 board from `rows` (top row first, each exactly BOARD_COLS chars). // Load an 8x8 board from `rows` (top row first, each exactly BOARD_COLS chars).
load_board :: (rows: []string) -> Board { load_board :: (rows: []string) -> Board {
b : Board = ---; b : Board = ---;
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
line := rows[row]; line := rows[row];
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
b.set(col, row, char_to_gem(line[col])); b.set(col, row, char_to_gem(line[col]));
} }
} }
@@ -42,15 +42,15 @@ load_board :: (rows: []string) -> Board {
// them a hole. This single check covers holes-bubble-to-top, gems-settle-to- // them a hole. This single check covers holes-bubble-to-top, gems-settle-to-
// bottom, order-preservation, and the all-holes / no-holes edge columns at once. // bottom, order-preservation, and the all-holes / no-holes edge columns at once.
check_collapsed :: (orig: *Board, b: *Board) -> bool { check_collapsed :: (orig: *Board, b: *Board) -> bool {
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
gems : [BOARD_ROWS]Gem = ---; gems : [BOARD_ROWS]Gem = ---;
n := 0; n := 0;
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
g := orig.at(col, row); g := orig.at(col, row);
if g != .empty { gems[n] = g; n += 1; } if g != .empty { gems[n] = g; n += 1; }
} }
boundary := BOARD_ROWS - n; // first row that must hold a gem boundary := BOARD_ROWS - n; // first row that must hold a gem
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
if row < boundary { if row < boundary {
if b.at(col, row) != .empty { return false; } if b.at(col, row) != .empty { return false; }
} else { } else {
@@ -79,7 +79,7 @@ scene :: (name: string, rows: []string, want_moved: bool) {
t.expect(moved == want_moved, concat(name, ": moved flag exact")); t.expect(moved == want_moved, concat(name, ": moved flag exact"));
} }
main :: () -> s32 { main :: () -> i32 {
print("== collapse (gravity) ==\n"); print("== collapse (gravity) ==\n");
// Eight independent columns, one case each (top-to-bottom): // Eight independent columns, one case each (top-to-bottom):

View File

@@ -23,7 +23,7 @@ t :: #import "test.sx";
// be written as a human-readable grid. The hole glyph maps to `.empty`. // be written as a human-readable grid. The hole glyph maps to `.empty`.
char_to_gem :: (c: u8) -> Gem { char_to_gem :: (c: u8) -> Gem {
if c == EMPTY_CHAR { return .empty; } if c == EMPTY_CHAR { return .empty; }
for 0..GEM_COUNT: (i) { for 0..GEM_COUNT (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; } if GEM_CHARS[i] == c { return cast(Gem) i; }
} }
.red .red
@@ -31,11 +31,11 @@ char_to_gem :: (c: u8) -> Gem {
// Load an 8x8 board from `rows` (top row first, each exactly BOARD_COLS chars), // Load an 8x8 board from `rows` (top row first, each exactly BOARD_COLS chars),
// seeded RNG, running score zeroed so `board.score` ends equal to the payout. // seeded RNG, running score zeroed so `board.score` ends equal to the payout.
load_board :: (rows: []string, seed: s64) -> Board { load_board :: (rows: []string, seed: i64) -> Board {
b : Board = ---; b : Board = ---;
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
line := rows[row]; line := rows[row];
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
b.set(col, row, char_to_gem(line[col])); b.set(col, row, char_to_gem(line[col]));
} }
} }
@@ -51,14 +51,14 @@ load_board :: (rows: []string, seed: s64) -> Board {
// identical board awards `want_mult` into `Board.score` and reports it as // identical board awards `want_mult` into `Board.score` and reports it as
// `Cascade.awarded` at the same depth. A depth-1 settle must equal the flat sum // `Cascade.awarded` at the same depth. A depth-1 settle must equal the flat sum
// (no bonus); a deeper chain must strictly exceed it. // (no bonus); a deeper chain must strictly exceed it.
scene :: (name: string, rows: []string, seed: s64, want_flat: s64, want_mult: s64) { scene :: (name: string, rows: []string, seed: i64, want_flat: i64, want_mult: i64) {
print("== {} ==\n", name); print("== {} ==\n", name);
b := load_board(rows, seed); b := load_board(rows, seed);
out(board_dump(@b)); out(board_dump(@b));
flat : s64 = 0; flat : i64 = 0;
mult : s64 = 0; mult : i64 = 0;
depth : s64 = 0; depth : i64 = 0;
while true { while true {
base := score_round(@b); base := score_round(@b);
n := resolve_step(@b); n := resolve_step(@b);
@@ -90,7 +90,7 @@ scene :: (name: string, rows: []string, seed: s64, want_flat: s64, want_mult: s6
t.expect(b2.score == want_mult, concat(name, ": resolve accumulates into board.score")); t.expect(b2.score == want_mult, concat(name, ": resolve accumulates into board.score"));
} }
main :: () -> s32 { main :: () -> i32 {
print("== combo (cascade multiplier) ==\n"); print("== combo (cascade multiplier) ==\n");
// Single-round clear (seed 0): one RRR clears and the refill makes no new // Single-round clear (seed 0): one RRR clears and the refill makes no new

View File

@@ -19,8 +19,8 @@
fabs :: (x: f32) -> f32 { if x < 0.0 then 0.0 - x else x } fabs :: (x: f32) -> f32 { if x < 0.0 then 0.0 - x else x }
approx :: (a: f32, b: f32) -> bool { fabs(a - b) < 0.0001 } approx :: (a: f32, b: f32) -> bool { fabs(a - b) < 0.0001 }
main :: () -> s32 { main :: () -> i32 {
fails : s64 = 0; fails : i64 = 0;
// 1. Endpoints are locked: every curve starts/ends exactly on its rest value // 1. Endpoints are locked: every curve starts/ends exactly on its rest value
// (the in/out curves at 1, the spring at 1, the squash envelope at 0). // (the in/out curves at 1, the spring at 1, the squash envelope at 0).
@@ -51,7 +51,7 @@ main :: () -> s32 {
p_io := ease_in_out_cubic(0.0); p_io := ease_in_out_cubic(0.0);
p_oc := ease_out_cubic(0.0); p_oc := ease_out_cubic(0.0);
p_iq := ease_in_quad(0.0); p_iq := ease_in_quad(0.0);
for 1..21: (i) { for 1..21 (i) {
t := cast(f32) i / 20.0; t := cast(f32) i / 20.0;
v_in := ease_in_cubic(t); if v_in < p_in - 0.000001 { mono_in = false; } p_in = v_in; v_in := ease_in_cubic(t); if v_in < p_in - 0.000001 { mono_in = false; } p_in = v_in;
v_io := ease_in_out_cubic(t); if v_io < p_io - 0.000001 { mono_io = false; } p_io = v_io; v_io := ease_in_out_cubic(t); if v_io < p_io - 0.000001 { mono_io = false; } p_io = v_io;
@@ -72,7 +72,7 @@ main :: () -> s32 {
back_mx := ease_out_back(0.0); back_mn := ease_out_back(0.0); back_mx := ease_out_back(0.0); back_mn := ease_out_back(0.0);
spr_mx := spring(0.0); spr_mn := spring(0.0); spr_mx := spring(0.0); spr_mn := spring(0.0);
spr_wobble := false; spr_wobble := false;
for 1..21: (i) { for 1..21 (i) {
t := cast(f32) i / 20.0; t := cast(f32) i / 20.0;
b := ease_out_back(t); b := ease_out_back(t);
if b > back_mx { back_mx = b; } if b > back_mx { back_mx = b; }
@@ -98,7 +98,7 @@ main :: () -> s32 {
// squash (positive) and a stretch (negative) lobe, and stays bounded. // squash (positive) and a stretch (negative) lobe, and stays bounded.
print("== squash envelope bounded ==\n"); print("== squash envelope bounded ==\n");
sq_mx : f32 = 0.0; sq_mn : f32 = 0.0; sq_moves := false; sq_mx : f32 = 0.0; sq_mn : f32 = 0.0; sq_moves := false;
for 0..21: (i) { for 0..21 (i) {
t := cast(f32) i / 20.0; t := cast(f32) i / 20.0;
s := squash_envelope(t); s := squash_envelope(t);
if s > sq_mx { sq_mx = s; } if s > sq_mx { sq_mx = s; }
@@ -121,7 +121,7 @@ main :: () -> s32 {
print("== illegal-swap bounce ==\n"); print("== illegal-swap bounce ==\n");
bb_ends := bad_swap_bounce(0.0) == 0.0 and bad_swap_bounce(1.0) == 0.0; bb_ends := bad_swap_bounce(0.0) == 0.0 and bad_swap_bounce(1.0) == 0.0;
bb_mx : f32 = 0.0; bb_mx_t : f32 = 0.0; bb_mn : f32 = 0.0; bb_mx : f32 = 0.0; bb_mx_t : f32 = 0.0; bb_mn : f32 = 0.0;
for 0..101: (i) { for 0..101 (i) {
t := cast(f32) i / 100.0; t := cast(f32) i / 100.0;
v := bad_swap_bounce(t); v := bad_swap_bounce(t);
if v > bb_mx { bb_mx = v; bb_mx_t = t; } if v > bb_mx { bb_mx = v; bb_mx_t = t; }
@@ -151,18 +151,18 @@ main :: () -> s32 {
// starts later), the opposite of a flat lockstep row sharing one progress. // starts later), the opposite of a flat lockstep row sharing one progress.
print("== fall stagger bounded ==\n"); print("== fall stagger bounded ==\n");
stg_t0 := true; stg_t1 := true; stg_t0 := true; stg_t1 := true;
for 0..BOARD_COLS: (c) { for 0..BOARD_COLS (c) {
if fall_stagger_t(0.0, c) != 0.0 { stg_t0 = false; } if fall_stagger_t(0.0, c) != 0.0 { stg_t0 = false; }
if fall_stagger_t(1.0, c) != 1.0 { stg_t1 = false; } if fall_stagger_t(1.0, c) != 1.0 { stg_t1 = false; }
} }
stg_cascade := true; stg_cascade := true;
for 1..BOARD_COLS: (c) { for 1..BOARD_COLS (c) {
if !(fall_stagger_t(0.5, c) < fall_stagger_t(0.5, c - 1)) { stg_cascade = false; } if !(fall_stagger_t(0.5, c) < fall_stagger_t(0.5, c - 1)) { stg_cascade = false; }
} }
stg_mono := true; stg_mono := true;
for 0..BOARD_COLS: (c) { for 0..BOARD_COLS (c) {
pp := fall_stagger_t(0.0, c); pp := fall_stagger_t(0.0, c);
for 1..21: (i) { for 1..21 (i) {
tt := cast(f32) i / 20.0; tt := cast(f32) i / 20.0;
vv := fall_stagger_t(tt, c); vv := fall_stagger_t(tt, c);
if vv < pp - 0.000001 { stg_mono = false; } if vv < pp - 0.000001 { stg_mono = false; }
@@ -190,14 +190,14 @@ main :: () -> s32 {
lf_last := approx(fall_landing_frac(BOARD_COLS - 1), 1.0); lf_last := approx(fall_landing_frac(BOARD_COLS - 1), 1.0);
lf_mono := true; lf_mono := true;
lf_seam := true; lf_seam := true;
for 0..BOARD_COLS: (c) { for 0..BOARD_COLS (c) {
if c >= 1 and !(fall_landing_frac(c) > fall_landing_frac(c - 1)) { lf_mono = false; } if c >= 1 and !(fall_landing_frac(c) > fall_landing_frac(c - 1)) { lf_mono = false; }
lf := fall_landing_frac(c); lf := fall_landing_frac(c);
if !approx(fall_stagger_t(lf, c), 1.0) { lf_seam = false; } // landed at lf if !approx(fall_stagger_t(lf, c), 1.0) { lf_seam = false; } // landed at lf
if fall_stagger_t(lf - 0.05, c) >= 1.0 { lf_seam = false; } // still in air just before if fall_stagger_t(lf - 0.05, c) >= 1.0 { lf_seam = false; } // still in air just before
} }
rlt_col_mono := true; rlt_col_mono := true;
for 1..BOARD_COLS: (c) { for 1..BOARD_COLS (c) {
if !(round_land_time(0, c) > round_land_time(0, c - 1)) { rlt_col_mono = false; } if !(round_land_time(0, c) > round_land_time(0, c - 1)) { rlt_col_mono = false; }
} }
rlt_round_after := round_land_time(1, 0) > round_land_time(0, BOARD_COLS - 1); rlt_round_after := round_land_time(1, 0) > round_land_time(0, BOARD_COLS - 1);
@@ -222,22 +222,22 @@ main :: () -> s32 {
// gem 0..1 by diagonal across the round (lowest-diagonal = 0, the first to pop). // gem 0..1 by diagonal across the round (lowest-diagonal = 0, the first to pop).
print("== clear ripple bounded ==\n"); print("== clear ripple bounded ==\n");
rip_t0 := true; rip_t1 := true; rip_t0 := true; rip_t1 := true;
for 0..6: (j) { for 0..6 (j) {
u := cast(f32) j / 5.0; u := cast(f32) j / 5.0;
if clear_ripple_t(0.0, u) != 0.0 { rip_t0 = false; } if clear_ripple_t(0.0, u) != 0.0 { rip_t0 = false; }
if clear_ripple_t(1.0, u) != 1.0 { rip_t1 = false; } if clear_ripple_t(1.0, u) != 1.0 { rip_t1 = false; }
} }
rip_ripple := true; rip_ripple := true;
for 1..6: (j) { for 1..6 (j) {
u := cast(f32) j / 5.0; u := cast(f32) j / 5.0;
up := cast(f32) (j - 1) / 5.0; up := cast(f32) (j - 1) / 5.0;
if !(clear_ripple_t(0.5, u) < clear_ripple_t(0.5, up)) { rip_ripple = false; } if !(clear_ripple_t(0.5, u) < clear_ripple_t(0.5, up)) { rip_ripple = false; }
} }
rip_mono := true; rip_mono := true;
for 0..6: (j) { for 0..6 (j) {
u := cast(f32) j / 5.0; u := cast(f32) j / 5.0;
pp := clear_ripple_t(0.0, u); pp := clear_ripple_t(0.0, u);
for 1..21: (i) { for 1..21 (i) {
tt := cast(f32) i / 20.0; tt := cast(f32) i / 20.0;
vv := clear_ripple_t(tt, u); vv := clear_ripple_t(tt, u);
if vv < pp - 0.000001 { rip_mono = false; } if vv < pp - 0.000001 { rip_mono = false; }
@@ -245,7 +245,7 @@ main :: () -> s32 {
} }
} }
mm : MatchMask = ---; mm : MatchMask = ---;
for 0..BOARD_CELLS: (i) { mm.cells[i] = false; } for 0..BOARD_CELLS (i) { mm.cells[i] = false; }
mm.cells[Board.idx(5, 0)] = true; // diagonal 5 — first to pop mm.cells[Board.idx(5, 0)] = true; // diagonal 5 — first to pop
mm.cells[Board.idx(5, 1)] = true; // diagonal 6 mm.cells[Board.idx(5, 1)] = true; // diagonal 6
mm.cells[Board.idx(5, 2)] = true; // diagonal 7 — last to pop mm.cells[Board.idx(5, 2)] = true; // diagonal 7 — last to pop

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,23 @@
== deadlocked board: UI swipe-commit path must reshuffle ==
ROYGBPRO
PROYGBPR
BPROYGBP
GBPROYGB
YGBPROYG
OYGBPROY
ROYGBPRO
PROYGBPR
before: matches 0 legal_swaps 0 has_legal_swap false status in_progress
intent (0,0)->(1,0)
commit: legal false rounds 0 awarded 0
after: matches 0 legal_swaps 9 has_legal_swap true status in_progress
after: score 0 moves_made 0 moves_remaining 10
BGGYORYR
RRYGOPBY
YRYBPRGB
OOBGBPRG
RPRPYRPO
OBBPOOPG
OBGGOPGY
YPRYBORP
ok: UI swipe-commit path reshuffles a deadlocked board

View File

@@ -13,15 +13,15 @@
#import "modules/std.sx"; #import "modules/std.sx";
#import "board_fx.sx"; #import "board_fx.sx";
main :: () -> s32 { main :: () -> i32 {
print("== combo emphasis selection (depth -> fx level / popup font) ==\n"); print("== combo emphasis selection (depth -> fx level / popup font) ==\n");
// The cascade-cue index per depth 0..9, copied from cascade_cue.stdout. The // The cascade-cue index per depth 0..9, copied from cascade_cue.stdout. The
// FX level must equal this entry for entry — the audio/visual lockstep. // FX level must equal this entry for entry — the audio/visual lockstep.
expect_level : [10]s64 = .{ 0, 0, 1, 2, 3, 4, 4, 4, 4, 4 }; expect_level : [10]i64 = .{ 0, 0, 1, 2, 3, 4, 4, 4, 4, 4 };
prev : s64 = -1; prev : i64 = -1;
for 0..10: (depth) { for 0..10 (depth) {
lvl := fx_combo_level(depth); lvl := fx_combo_level(depth);
font := fx_popup_font(depth); font := fx_popup_font(depth);
combo := depth > 1; combo := depth > 1;
@@ -45,7 +45,7 @@ main :: () -> s32 {
// larger and the font never shrinks as the cascade deepens. // larger and the font never shrinks as the cascade deepens.
if fx_popup_font(1) != FX_POPUP_FONT { print("FAIL: single-clear popup not plain font\n"); return 1; } if fx_popup_font(1) != FX_POPUP_FONT { print("FAIL: single-clear popup not plain font\n"); return 1; }
pf : f32 = 0.0; pf : f32 = 0.0;
for 2..10: (depth) { for 2..10 (depth) {
f := fx_popup_font(depth); f := fx_popup_font(depth);
if f <= FX_POPUP_FONT { print("FAIL: combo popup not larger than plain at depth {}\n", depth); return 1; } if f <= FX_POPUP_FONT { print("FAIL: combo popup not larger than plain at depth {}\n", depth); return 1; }
if depth > 2 and f < pf { print("FAIL: popup font shrank at depth {}\n", depth); return 1; } if depth > 2 and f < pf { print("FAIL: popup font shrank at depth {}\n", depth); return 1; }

View File

@@ -18,14 +18,14 @@
fabs :: (x: f32) -> f32 { if x < 0.0 then 0.0 - x else x } fabs :: (x: f32) -> f32 { if x < 0.0 then 0.0 - x else x }
approx :: (a: f32, b: f32) -> bool { fabs(a - b) < 0.0001 } approx :: (a: f32, b: f32) -> bool { fabs(a - b) < 0.0001 }
main :: () -> s32 { main :: () -> i32 {
fails : s64 = 0; fails : i64 = 0;
// 1. t==0 idle pose is EXACTLY rest for every cell (the determinism invariant). // 1. t==0 idle pose is EXACTLY rest for every cell (the determinism invariant).
print("== idle t=0 is rest for all cells ==\n"); print("== idle t=0 is rest for all cells ==\n");
rest_ok := true; rest_ok := true;
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
p := idle_pose(0.0, col, row); p := idle_pose(0.0, col, row);
if !(p.scale_x == 1.0 and p.scale_y == 1.0 and p.dx == 0.0 and p.dy == 0.0) { if !(p.scale_x == 1.0 and p.scale_y == 1.0 and p.dx == 0.0 and p.dy == 0.0) {
rest_ok = false; rest_ok = false;
@@ -39,8 +39,8 @@ main :: () -> s32 {
print("== idle mid-phase deforms, bounded ==\n"); print("== idle mid-phase deforms, bounded ==\n");
moved := false; moved := false;
bounded := true; bounded := true;
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
p := idle_pose(0.6, col, row); p := idle_pose(0.6, col, row);
if fabs(p.scale_x - 1.0) > 0.0005 { moved = true; } if fabs(p.scale_x - 1.0) > 0.0005 { moved = true; }
if fabs(p.scale_x - 1.0) > 0.05 { bounded = false; } if fabs(p.scale_x - 1.0) > 0.05 { bounded = false; }
@@ -82,7 +82,7 @@ main :: () -> s32 {
c_peak := clear_pop_scale(0.30) > 1.1; c_peak := clear_pop_scale(0.30) > 1.1;
c_collapse := true; c_collapse := true;
pc := clear_pop_scale(CLEAR_POP_RISE); pc := clear_pop_scale(CLEAR_POP_RISE);
for 1..21: (i) { for 1..21 (i) {
tt := CLEAR_POP_RISE + (1.0 - CLEAR_POP_RISE) * cast(f32) i / 20.0; tt := CLEAR_POP_RISE + (1.0 - CLEAR_POP_RISE) * cast(f32) i / 20.0;
vv := clear_pop_scale(tt); vv := clear_pop_scale(tt);
if vv > pc + 0.000001 { c_collapse = false; } if vv > pc + 0.000001 { c_collapse = false; }

View File

@@ -10,24 +10,25 @@
// second `Frame` struct that collides with the UI `Frame`. Failure is signalled // second `Frame` struct that collides with the UI `Frame`. Failure is signalled
// via a non-zero exit code (the runner checks exit code AND stdout). // via a non-zero exit code (the runner checks exit code AND stdout).
#import "modules/std.sx"; #import "modules/std.sx";
#import "modules/ui/types.sx";
#import "board.sx"; #import "board.sx";
#import "board_layout.sx"; #import "board_layout.sx";
main :: () -> s32 { main :: () -> i32 {
// 800×600 with no safe inset → a 600px square grid, cell 75, centered: the // 800×600 with no safe inset → a 600px square grid, cell 75, centered: the
// grid origin lands at (100, 0). Integer math keeps the dump deterministic. // grid origin lands at (100, 0). Integer math keeps the dump deterministic.
lay : BoardLayout = ---; lay : BoardLayout = ---;
lay.compute(Frame.make(0.0, 0.0, 800.0, 600.0), EdgeInsets.zero()); lay.compute(Frame.make(0.0, 0.0, 800.0, 600.0), EdgeInsets.zero());
print("grid origin ({},{}) cell {}\n", print("grid origin ({},{}) cell {}\n",
cast(s64) lay.origin.x, cast(s64) lay.origin.y, cast(s64) lay.cell_size); cast(i64) lay.origin.x, cast(i64) lay.origin.y, cast(i64) lay.cell_size);
fails : s64 = 0; fails : i64 = 0;
// Every cell center must map back to its own cell. // Every cell center must map back to its own cell.
hits : s64 = 0; hits : i64 = 0;
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
cf := lay.cell_frame(col, row); cf := lay.cell_frame(col, row);
center := Point.{ x = cf.mid_x(), y = cf.mid_y() }; center := Point.{ x = cf.mid_x(), y = cf.mid_y() };
if h := lay.point_to_cell(center) { if h := lay.point_to_cell(center) {
@@ -41,8 +42,8 @@ main :: () -> s32 {
// A cell's top-left corner belongs to that cell (the leading edge is // A cell's top-left corner belongs to that cell (the leading edge is
// inclusive), so corner-of-(3,5) resolves to (3,5). // inclusive), so corner-of-(3,5) resolves to (3,5).
corner := Point.{ x = lay.origin.x + 3.0 * lay.cell_size, y = lay.origin.y + 5.0 * lay.cell_size }; corner := Point.{ x = lay.origin.x + 3.0 * lay.cell_size, y = lay.origin.y + 5.0 * lay.cell_size };
corner_col : s64 = -1; corner_col : i64 = -1;
corner_row : s64 = -1; corner_row : i64 = -1;
if h := lay.point_to_cell(corner) { corner_col = h.col; corner_row = h.row; } if h := lay.point_to_cell(corner) { corner_col = h.col; corner_row = h.row; }
if corner_col != 3 or corner_row != 5 { fails += 1; } if corner_col != 3 or corner_row != 5 { fails += 1; }
print("corner maps to ({},{})\n", corner_col, corner_row); print("corner maps to ({},{})\n", corner_col, corner_row);
@@ -52,7 +53,7 @@ main :: () -> s32 {
off_left := Point.{ x = lay.origin.x - 5.0, y = lay.origin.y + 10.0 }; off_left := Point.{ x = lay.origin.x - 5.0, y = lay.origin.y + 10.0 };
off_above := Point.{ x = lay.origin.x + 10.0, y = lay.origin.y - 5.0 }; off_above := Point.{ x = lay.origin.x + 10.0, y = lay.origin.y - 5.0 };
off_right := Point.{ x = lay.origin.x + 8.0 * lay.cell_size + 1.0, y = lay.origin.y + 10.0 }; off_right := Point.{ x = lay.origin.x + 8.0 * lay.cell_size + 1.0, y = lay.origin.y + 10.0 };
on_board : s64 = 0; on_board : i64 = 0;
if h := lay.point_to_cell(off_left) { on_board += 1; print("off_left hit ({},{})\n", h.col, h.row); } if h := lay.point_to_cell(off_left) { on_board += 1; print("off_left hit ({},{})\n", h.col, h.row); }
if h := lay.point_to_cell(off_above) { on_board += 1; print("off_above hit ({},{})\n", h.col, h.row); } if h := lay.point_to_cell(off_above) { on_board += 1; print("off_above hit ({},{})\n", h.col, h.row); }
if h := lay.point_to_cell(off_right) { on_board += 1; print("off_right hit ({},{})\n", h.col, h.row); } if h := lay.point_to_cell(off_right) { on_board += 1; print("off_right hit ({},{})\n", h.col, h.row); }

View File

@@ -25,7 +25,7 @@ RESHUFFLE_SEED :: 1337;
// be written as a human-readable grid. The hole glyph maps to `.empty`. // be written as a human-readable grid. The hole glyph maps to `.empty`.
char_to_gem :: (c: u8) -> Gem { char_to_gem :: (c: u8) -> Gem {
if c == EMPTY_CHAR { return .empty; } if c == EMPTY_CHAR { return .empty; }
for 0..GEM_COUNT: (i) { for 0..GEM_COUNT (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; } if GEM_CHARS[i] == c { return cast(Gem) i; }
} }
.red .red
@@ -34,11 +34,11 @@ char_to_gem :: (c: u8) -> Gem {
// Load an 8x8 board from `rows` (top row first, each exactly BOARD_COLS chars), // Load an 8x8 board from `rows` (top row first, each exactly BOARD_COLS chars),
// seeded RNG, running score zeroed, the turn counters reset to a fresh game, and // seeded RNG, running score zeroed, the turn counters reset to a fresh game, and
// the per-level goal set. // the per-level goal set.
load_board :: (rows: []string, seed: s64, move_limit: s64, target_score: s64) -> Board { load_board :: (rows: []string, seed: i64, move_limit: i64, target_score: i64) -> Board {
b : Board = ---; b : Board = ---;
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
line := rows[row]; line := rows[row];
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
b.set(col, row, char_to_gem(line[col])); b.set(col, row, char_to_gem(line[col]));
} }
} }
@@ -51,11 +51,11 @@ load_board :: (rows: []string, seed: s64, move_limit: s64, target_score: s64) ->
} }
boards_equal :: (a: *Board, b: *Board) -> bool { boards_equal :: (a: *Board, b: *Board) -> bool {
for 0..BOARD_CELLS: (i) { if a.cells[i] != b.cells[i] { return false; } } for 0..BOARD_CELLS (i) { if a.cells[i] != b.cells[i] { return false; } }
true true
} }
main :: () -> s32 { main :: () -> i32 {
print("== level (turn / goal state machine) ==\n"); print("== level (turn / goal state machine) ==\n");
// ── Start: a fresh seeded board reads in_progress with the default goal ── // ── Start: a fresh seeded board reads in_progress with the default goal ──

View File

@@ -11,7 +11,7 @@ t :: #import "test.sx";
// Inverse of `gem_char`: map a gem character back to its Gem so each board can // Inverse of `gem_char`: map a gem character back to its Gem so each board can
// be written as a human-readable grid of GEM_CHARS. // be written as a human-readable grid of GEM_CHARS.
char_to_gem :: (c: u8) -> Gem { char_to_gem :: (c: u8) -> Gem {
for 0..GEM_COUNT: (i) { for 0..GEM_COUNT (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; } if GEM_CHARS[i] == c { return cast(Gem) i; }
} }
.red .red
@@ -20,11 +20,11 @@ char_to_gem :: (c: u8) -> Gem {
// Build a scene: load an 8x8 board from `rows` (top row first, each exactly // Build a scene: load an 8x8 board from `rows` (top row first, each exactly
// BOARD_COLS gem characters), detect matches, print board + matched dump, and // BOARD_COLS gem characters), detect matches, print board + matched dump, and
// assert the matched-cell count. // assert the matched-cell count.
scene :: (name: string, rows: []string, want_count: s64) { scene :: (name: string, rows: []string, want_count: i64) {
b : Board = ---; b : Board = ---;
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
line := rows[row]; line := rows[row];
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
b.set(col, row, char_to_gem(line[col])); b.set(col, row, char_to_gem(line[col]));
} }
} }
@@ -38,7 +38,7 @@ scene :: (name: string, rows: []string, want_count: s64) {
t.expect(m.count() == want_count, name); t.expect(m.count() == want_count, name);
} }
main :: () -> s32 { main :: () -> i32 {
// Single horizontal 3-run (row 3, cols 2-4). // Single horizontal 3-run (row 3, cols 2-4).
scene("horizontal-3", .[ scene("horizontal-3", .[
"OGOGOGOG", "OGOGOGOG",

View File

@@ -22,7 +22,7 @@ SEED :: 1337;
// board can be written as a human-readable grid. The hole glyph maps to `.empty`. // board can be written as a human-readable grid. The hole glyph maps to `.empty`.
char_to_gem :: (c: u8) -> Gem { char_to_gem :: (c: u8) -> Gem {
if c == EMPTY_CHAR { return .empty; } if c == EMPTY_CHAR { return .empty; }
for 0..GEM_COUNT: (i) { for 0..GEM_COUNT (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; } if GEM_CHARS[i] == c { return cast(Gem) i; }
} }
.red .red
@@ -32,23 +32,23 @@ char_to_gem :: (c: u8) -> Gem {
// The RNG is left unseeded — callers seed it before drawing. // The RNG is left unseeded — callers seed it before drawing.
load_board :: (rows: []string) -> Board { load_board :: (rows: []string) -> Board {
b : Board = ---; b : Board = ---;
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
line := rows[row]; line := rows[row];
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
b.set(col, row, char_to_gem(line[col])); b.set(col, row, char_to_gem(line[col]));
} }
} }
b b
} }
count_empties :: (b: *Board) -> s64 { count_empties :: (b: *Board) -> i64 {
n : s64 = 0; n : i64 = 0;
for 0..BOARD_CELLS: (i) { if b.cells[i] == .empty { n += 1; } } for 0..BOARD_CELLS (i) { if b.cells[i] == .empty { n += 1; } }
n n
} }
boards_equal :: (a: *Board, b: *Board) -> bool { boards_equal :: (a: *Board, b: *Board) -> bool {
for 0..BOARD_CELLS: (i) { if a.cells[i] != b.cells[i] { return false; } } for 0..BOARD_CELLS (i) { if a.cells[i] != b.cells[i] { return false; } }
true true
} }
@@ -72,7 +72,7 @@ fresh_board :: () -> Board {
b b
} }
main :: () -> s32 { main :: () -> i32 {
print("== refill (seeded) ==\n"); print("== refill (seeded) ==\n");
// Pipeline, snapshotting each stage. // Pipeline, snapshotting each stage.
@@ -101,7 +101,7 @@ main :: () -> s32 {
distinct := false; distinct := false;
have_first := false; have_first := false;
first : Gem = .empty; first : Gem = .empty;
for 0..BOARD_CELLS: (i) { for 0..BOARD_CELLS (i) {
if pre.cells[i] == .empty { if pre.cells[i] == .empty {
want := cast(Gem) v.next_range(GEM_COUNT); want := cast(Gem) v.next_range(GEM_COUNT);
if b.cells[i] != want { stream_ok = false; } if b.cells[i] != want { stream_ok = false; }
@@ -123,19 +123,19 @@ main :: () -> s32 {
// filled, then refill again. The board's RNG has advanced past the first // filled, then refill again. The board's RNG has advanced past the first
// fill, so the second fill draws new gems — proof it does NOT reseed per call. // fill, so the second fill draws new gems — proof it does NOT reseed per call.
holes_n := 0; holes_n := 0;
hole_idx : [BOARD_CELLS]s64 = ---; hole_idx : [BOARD_CELLS]i64 = ---;
fill1 : [BOARD_CELLS]Gem = ---; fill1 : [BOARD_CELLS]Gem = ---;
for 0..BOARD_CELLS: (i) { for 0..BOARD_CELLS (i) {
if pre.cells[i] == .empty { if pre.cells[i] == .empty {
hole_idx[holes_n] = i; hole_idx[holes_n] = i;
fill1[holes_n] = b.cells[i]; fill1[holes_n] = b.cells[i];
holes_n += 1; holes_n += 1;
} }
} }
for 0..holes_n: (k) { b.cells[hole_idx[k]] = .empty; } for 0..holes_n (k) { b.cells[hole_idx[k]] = .empty; }
refill(@b); refill(@b);
differs := false; differs := false;
for 0..holes_n: (k) { for 0..holes_n (k) {
if b.cells[hole_idx[k]] != fill1[k] { differs = true; } if b.cells[hole_idx[k]] != fill1[k] { differs = true; }
} }
t.expect(differs, "refill: a second refill of the same holes draws new gems (RNG threads, no reseed)"); t.expect(differs, "refill: a second refill of the same holes draws new gems (RNG threads, no reseed)");

View File

@@ -20,7 +20,7 @@ t :: #import "test.sx";
// be written as a human-readable grid. The hole glyph maps to `.empty`. // be written as a human-readable grid. The hole glyph maps to `.empty`.
char_to_gem :: (c: u8) -> Gem { char_to_gem :: (c: u8) -> Gem {
if c == EMPTY_CHAR { return .empty; } if c == EMPTY_CHAR { return .empty; }
for 0..GEM_COUNT: (i) { for 0..GEM_COUNT (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; } if GEM_CHARS[i] == c { return cast(Gem) i; }
} }
.red .red
@@ -30,9 +30,9 @@ char_to_gem :: (c: u8) -> Gem {
// with the running score zeroed so the accumulation check starts from a known base. // with the running score zeroed so the accumulation check starts from a known base.
load_board :: (rows: []string) -> Board { load_board :: (rows: []string) -> Board {
b : Board = ---; b : Board = ---;
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
line := rows[row]; line := rows[row];
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
b.set(col, row, char_to_gem(line[col])); b.set(col, row, char_to_gem(line[col]));
} }
} }
@@ -42,7 +42,7 @@ load_board :: (rows: []string) -> Board {
// Score one scene: snapshot board + enumerated runs + points, then assert // Score one scene: snapshot board + enumerated runs + points, then assert
// `score_round` is exact and `add_round_score` accumulates it into `board.score`. // `score_round` is exact and `add_round_score` accumulates it into `board.score`.
scene :: (name: string, rows: []string, want_points: s64) { scene :: (name: string, rows: []string, want_points: i64) {
b := load_board(rows); b := load_board(rows);
runs := find_runs(@b); runs := find_runs(@b);
@@ -58,7 +58,7 @@ scene :: (name: string, rows: []string, want_points: s64) {
concat(name, ": add_round_score accumulates into board.score")); concat(name, ": add_round_score accumulates into board.score"));
} }
main :: () -> s32 { main :: () -> i32 {
print("== score (base match scoring) ==\n"); print("== score (base match scoring) ==\n");
// Single length-3 horizontal run (row 3, cols 2-4) -> SCORE_RUN_3 = 30. // Single length-3 horizontal run (row 3, cols 2-4) -> SCORE_RUN_3 = 30.

View File

@@ -16,7 +16,7 @@ SEED :: 1337;
// Inverse of `gem_char`: map a gem character back to its Gem so each board can // Inverse of `gem_char`: map a gem character back to its Gem so each board can
// be written as a human-readable grid of GEM_CHARS. // be written as a human-readable grid of GEM_CHARS.
char_to_gem :: (c: u8) -> Gem { char_to_gem :: (c: u8) -> Gem {
for 0..GEM_COUNT: (i) { for 0..GEM_COUNT (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; } if GEM_CHARS[i] == c { return cast(Gem) i; }
} }
.red .red
@@ -26,9 +26,9 @@ char_to_gem :: (c: u8) -> Gem {
// characters). // characters).
load_board :: (rows: []string) -> Board { load_board :: (rows: []string) -> Board {
b : Board = ---; b : Board = ---;
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
line := rows[row]; line := rows[row];
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
b.set(col, row, char_to_gem(line[col])); b.set(col, row, char_to_gem(line[col]));
} }
} }
@@ -38,17 +38,17 @@ load_board :: (rows: []string) -> Board {
// Whole-board equality, cell by cell — used to prove a trial swap leaves the // Whole-board equality, cell by cell — used to prove a trial swap leaves the
// board untouched. // board untouched.
boards_equal :: (x: *Board, y: *Board) -> bool { boards_equal :: (x: *Board, y: *Board) -> bool {
for 0..BOARD_CELLS: (i) { for 0..BOARD_CELLS (i) {
if !(x.cells[i] == y.cells[i]) { return false; } if !(x.cells[i] == y.cells[i]) { return false; }
} }
true true
} }
cell :: (col: s64, row: s64) -> Cell { cell :: (col: i64, row: i64) -> Cell {
Cell.{ col = col, row = row } Cell.{ col = col, row = row }
} }
main :: () -> s32 { main :: () -> i32 {
print("== swap & legality ==\n"); print("== swap & legality ==\n");
// Board whose ONLY swap-formable match is the adjacent (2,3)<->(3,3) // Board whose ONLY swap-formable match is the adjacent (2,3)<->(3,3)

View File

@@ -13,32 +13,33 @@
// its trace.sx pulls in a second `Frame` that collides with the UI one. Failure // its trace.sx pulls in a second `Frame` that collides with the UI one. Failure
// is signalled via a non-zero exit code (the runner checks exit code AND stdout). // is signalled via a non-zero exit code (the runner checks exit code AND stdout).
#import "modules/std.sx"; #import "modules/std.sx";
#import "modules/ui/types.sx";
#import "board.sx"; #import "board.sx";
#import "board_layout.sx"; #import "board_layout.sx";
#import "swipe.sx"; #import "swipe.sx";
SEED :: 1337; SEED :: 1337;
cell_center :: (lay: *BoardLayout, col: s64, row: s64) -> Point { cell_center :: (lay: *BoardLayout, col: i64, row: i64) -> Point {
cf := lay.cell_frame(col, row); cf := lay.cell_frame(col, row);
Point.{ x = cf.mid_x(), y = cf.mid_y() } Point.{ x = cf.mid_x(), y = cf.mid_y() }
} }
boards_equal :: (x: *Board, y: *Board) -> bool { boards_equal :: (x: *Board, y: *Board) -> bool {
for 0..BOARD_CELLS: (i) { for 0..BOARD_CELLS (i) {
if !(x.cells[i] == y.cells[i]) { return false; } if !(x.cells[i] == y.cells[i]) { return false; }
} }
true true
} }
main :: () -> s32 { main :: () -> i32 {
// 800×600, no safe inset → 600px square grid, cell 75, origin (100, 0). A // 800×600, no safe inset → 600px square grid, cell 75, origin (100, 0). A
// 60px drag clears the cell*0.5 = 37.5px swipe threshold on the dominant axis. // 60px drag clears the cell*0.5 = 37.5px swipe threshold on the dominant axis.
lay : BoardLayout = ---; lay : BoardLayout = ---;
lay.compute(Frame.make(0.0, 0.0, 800.0, 600.0), EdgeInsets.zero()); lay.compute(Frame.make(0.0, 0.0, 800.0, 600.0), EdgeInsets.zero());
D : f32 = 60.0; D : f32 = 60.0;
fails : s64 = 0; fails : i64 = 0;
// ── ILLEGAL swipe reverts ────────────────────────────────────────────── // ── ILLEGAL swipe reverts ──────────────────────────────────────────────
// (0,0) and (1,0) are both red on the seed board, so swapping them forms no // (0,0) and (1,0) are both red on the seed board, so swapping them forms no

View File

@@ -9,18 +9,19 @@
// clears it; a 10px drag does not. Failure is signalled via a non-zero exit code // clears it; a 10px drag does not. Failure is signalled via a non-zero exit code
// (the runner checks exit code AND stdout). // (the runner checks exit code AND stdout).
#import "modules/std.sx"; #import "modules/std.sx";
#import "modules/ui/types.sx";
#import "board.sx"; #import "board.sx";
#import "board_layout.sx"; #import "board_layout.sx";
#import "swipe.sx"; #import "swipe.sx";
cell_center :: (lay: *BoardLayout, col: s64, row: s64) -> Point { cell_center :: (lay: *BoardLayout, col: i64, row: i64) -> Point {
cf := lay.cell_frame(col, row); cf := lay.cell_frame(col, row);
Point.{ x = cf.mid_x(), y = cf.mid_y() } Point.{ x = cf.mid_x(), y = cf.mid_y() }
} }
// Print the resolved intent (locked in the golden) and report whether it matches // Print the resolved intent (locked in the golden) and report whether it matches
// the expected adjacent pair (A, B). Drives the exit code alongside the dump. // the expected adjacent pair (A, B). Drives the exit code alongside the dump.
expect_swap :: (label: string, got: ?Swap, ac: s64, ar: s64, bc: s64, br: s64) -> bool { expect_swap :: (label: string, got: ?Swap, ac: i64, ar: i64, bc: i64, br: i64) -> bool {
if s := got { if s := got {
print("{}: ({},{})->({},{})\n", label, s.a.col, s.a.row, s.b.col, s.b.row); print("{}: ({},{})->({},{})\n", label, s.a.col, s.a.row, s.b.col, s.b.row);
return s.a.col == ac and s.a.row == ar and s.b.col == bc and s.b.row == br; return s.a.col == ac and s.a.row == ar and s.b.col == bc and s.b.row == br;
@@ -38,14 +39,14 @@ expect_none :: (label: string, got: ?Swap) -> bool {
true true
} }
main :: () -> s32 { main :: () -> i32 {
lay : BoardLayout = ---; lay : BoardLayout = ---;
lay.compute(Frame.make(0.0, 0.0, 800.0, 600.0), EdgeInsets.zero()); lay.compute(Frame.make(0.0, 0.0, 800.0, 600.0), EdgeInsets.zero());
print("grid origin ({},{}) cell {} threshold {}\n", print("grid origin ({},{}) cell {} threshold {}\n",
cast(s64) lay.origin.x, cast(s64) lay.origin.y, cast(s64) lay.cell_size, cast(i64) lay.origin.x, cast(i64) lay.origin.y, cast(i64) lay.cell_size,
cast(s64) (lay.cell_size * SWIPE_THRESHOLD_FRACTION)); cast(i64) (lay.cell_size * SWIPE_THRESHOLD_FRACTION));
fails : s64 = 0; fails : i64 = 0;
// A known interior cell; every cardinal swipe from it stays on the board. // A known interior cell; every cardinal swipe from it stays on the board.
start := cell_center(@lay, 3, 5); start := cell_center(@lay, 3, 5);

144
tests/swipe_reshuffle.sx Normal file
View File

@@ -0,0 +1,144 @@
// UI deadlock-recovery golden (final-review F1): prove the RENDERED swipe-commit
// path reshuffles a deadlocked board, just like the headless turn loop's no-moves
// rule (tests/level.sx). The iOS/macOS view commits a swipe through
// `plan_and_commit` (NOT `play_turn`), so the reshuffle must live on THAT shared
// path or a stuck board stays stuck on screen. This test drives exactly what
// BoardView.handle_event does — resolve a drag with `swipe_intent`, feed the intent
// into `plan_and_commit` — on the provably deadlocked diagonal-Latin-square board
// from tests/level.sx, asserting:
// - BEFORE: no immediate match, zero legal swaps, status in_progress (stuck);
// - AFTER the UI commit path resolves: the board RESHUFFLED — `has_legal_swap`
// is true and >=1 legal swap exists, still with no immediate match — and the
// reshuffle itself spent no move and no score (turn accounting unchanged).
// Pre-fix `plan_and_commit` skipped the reshuffle, so `has_legal_swap` stayed false
// after and this FAILS; with the shared `reshuffle_if_deadlocked` wired in it PASSES.
// No rendering, no model reach-around. Links headless like tests/anim_plan.sx;
// avoids tests/test.sx (its trace.sx pulls in a second `Frame` that collides with
// the UI one). Failure is a non-zero exit code (the runner checks exit + stdout).
#import "modules/std.sx";
#import "modules/ui/types.sx";
#import "board.sx";
#import "board_anim.sx";
#import "board_layout.sx";
#import "swipe.sx";
SEED :: 1337;
// Inverse of `gem_char`: map a board character back to its Gem so the deadlocked
// board can be written as a human-readable grid (mirrors tests/level.sx).
char_to_gem :: (c: u8) -> Gem {
if c == EMPTY_CHAR { return .empty; }
for 0..GEM_COUNT (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; }
}
.red
}
// Load an 8x8 board from `rows` (top row first), seeded RNG, score zeroed, turn
// counters reset to a fresh game, and the per-level goal set.
load_board :: (rows: []string, seed: i64, move_limit: i64, target_score: i64) -> Board {
b : Board = ---;
for 0..BOARD_ROWS (row) {
line := rows[row];
for 0..BOARD_COLS (col) {
b.set(col, row, char_to_gem(line[col]));
}
}
b.rng = rng_seeded(seed);
b.score = 0;
b.moves_made = 0;
b.move_limit = move_limit;
b.target_score = target_score;
b
}
cell_center :: (lay: *BoardLayout, col: i64, row: i64) -> Point {
cf := lay.cell_frame(col, row);
Point.{ x = cf.mid_x(), y = cf.mid_y() }
}
main :: () -> i32 {
fails : i64 = 0;
// 800×600, no safe inset → 600px square grid, cell 75, origin (100, 0). A 60px
// drag clears the cell*0.5 = 37.5px swipe threshold on the dominant axis — the
// SAME layout/threshold tests/swipe_commit.sx drives the UI path through.
lay : BoardLayout = ---;
lay.compute(Frame.make(0.0, 0.0, 800.0, 600.0), EdgeInsets.zero());
D : f32 = 60.0;
// The provably deadlocked board from tests/level.sx: gem(col,row)=(col-row) mod
// 6, a diagonal Latin square. Equal gems lie only on slope-1 diagonals, so no
// row/column holds two of a kind within reach — no immediate match and no
// orthogonal swap can line up three.
print("== deadlocked board: UI swipe-commit path must reshuffle ==\n");
b := load_board(.[
"ROYGBPRO",
"PROYGBPR",
"BPROYGBP",
"GBPROYGB",
"YGBPROYG",
"OYGBPROY",
"ROYGBPRO",
"PROYGBPR",
], SEED, 10, 1500);
out(board_dump(@b));
pre_m := find_matches(@b);
pre_sw := legal_swaps(@b);
print("before: matches {} legal_swaps {} has_legal_swap {} status {}\n",
pre_m.count(), pre_sw.len, has_legal_swap(@b), status_name(level_status(@b)));
if pre_m.count() != 0 { fails += 1; }
if pre_sw.len != 0 { fails += 1; }
if has_legal_swap(@b) { fails += 1; }
if level_status(@b) != .in_progress { fails += 1; }
score_before := b.score;
made_before := b.moves_made;
remain_before := b.moves_remaining();
// Drive the EXACT UI path: a rightward drag on (0,0) resolves to the (0,0)->(1,0)
// swap intent, which BoardView.handle_event feeds straight into plan_and_commit.
// On this deadlocked board every swap is illegal (no match), so the swipe itself
// commits nothing — the recovery must come from the post-settle reshuffle.
a0 := cell_center(@lay, 0, 0);
if s := swipe_intent(@lay, a0, Point.{ x = a0.x + D, y = a0.y }) {
print("intent ({},{})->({},{})\n", s.a.col, s.a.row, s.b.col, s.b.row);
if !(s.a.col == 0 and s.a.row == 0 and s.b.col == 1 and s.b.row == 0) { fails += 1; }
mv := plan_and_commit(@b, s.a, s.b);
print("commit: legal {} rounds {} awarded {}\n", mv.legal, mv.rounds.len, mv.awarded);
// The illegal swipe spends nothing; its timeline is the bare ping-back.
if mv.legal { fails += 1; }
if mv.rounds.len != 0 { fails += 1; }
} else {
print("intent none\n");
fails += 1;
}
post_m := find_matches(@b);
post_sw := legal_swaps(@b);
print("after: matches {} legal_swaps {} has_legal_swap {} status {}\n",
post_m.count(), post_sw.len, has_legal_swap(@b), status_name(level_status(@b)));
print("after: score {} moves_made {} moves_remaining {}\n",
b.score, b.moves_made, b.moves_remaining());
out(board_dump(@b));
// The fix: the UI commit path reshuffled the deadlock away. has_legal_swap flips
// false -> true; >=1 legal swap exists; still no immediate match.
if !has_legal_swap(@b) { fails += 1; }
if post_sw.len <= 0 { fails += 1; }
if post_m.count() != 0 { fails += 1; }
// The reshuffle is NOT a move: score, moves spent, and budget are untouched.
if b.score != score_before { fails += 1; }
if b.moves_made != made_before { fails += 1; }
if b.moves_remaining() != remain_before { fails += 1; }
if level_status(@b) != .in_progress { fails += 1; }
if fails == 0 {
print("ok: UI swipe-commit path reshuffles a deadlocked board\n");
return 0;
}
print("FAIL: {} UI-reshuffle checks failed\n", fails);
return 1;
}

View File

@@ -5,7 +5,7 @@
// terminates the process NON-ZERO (exit 1) via process.exit, so a broken // terminates the process NON-ZERO (exit 1) via process.exit, so a broken
// assertion fails `tools/run_tests.sh` and the build gate. // assertion fails `tools/run_tests.sh` and the build gate.
#import "modules/std.sx"; #import "modules/std.sx";
proc :: #import "modules/process.sx"; proc :: #import "modules/std/process.sx";
expect :: (cond: bool, msg: string, loc: Source_Location = #caller_location) { expect :: (cond: bool, msg: string, loc: Source_Location = #caller_location) {
if !cond { if !cond {

View File

@@ -28,7 +28,7 @@ LIMIT :: 5;
// be written as a human-readable grid. The hole glyph maps to `.empty`. // be written as a human-readable grid. The hole glyph maps to `.empty`.
char_to_gem :: (c: u8) -> Gem { char_to_gem :: (c: u8) -> Gem {
if c == EMPTY_CHAR { return .empty; } if c == EMPTY_CHAR { return .empty; }
for 0..GEM_COUNT: (i) { for 0..GEM_COUNT (i) {
if GEM_CHARS[i] == c { return cast(Gem) i; } if GEM_CHARS[i] == c { return cast(Gem) i; }
} }
.red .red
@@ -37,11 +37,11 @@ char_to_gem :: (c: u8) -> Gem {
// Load an 8x8 board from `rows` (top row first, each exactly BOARD_COLS chars), // Load an 8x8 board from `rows` (top row first, each exactly BOARD_COLS chars),
// seeded RNG, running score zeroed, and the turn counters reset to a fresh game // seeded RNG, running score zeroed, and the turn counters reset to a fresh game
// (no moves made, the given move budget). // (no moves made, the given move budget).
load_board :: (rows: []string, seed: s64, move_limit: s64) -> Board { load_board :: (rows: []string, seed: i64, move_limit: i64) -> Board {
b : Board = ---; b : Board = ---;
for 0..BOARD_ROWS: (row) { for 0..BOARD_ROWS (row) {
line := rows[row]; line := rows[row];
for 0..BOARD_COLS: (col) { for 0..BOARD_COLS (col) {
b.set(col, row, char_to_gem(line[col])); b.set(col, row, char_to_gem(line[col]));
} }
} }
@@ -53,14 +53,14 @@ load_board :: (rows: []string, seed: s64, move_limit: s64) -> Board {
} }
boards_equal :: (a: *Board, b: *Board) -> bool { boards_equal :: (a: *Board, b: *Board) -> bool {
for 0..BOARD_CELLS: (i) { if a.cells[i] != b.cells[i] { return false; } } for 0..BOARD_CELLS (i) { if a.cells[i] != b.cells[i] { return false; } }
true true
} }
// One flag scene: snapshot the board, then count its single round's special // One flag scene: snapshot the board, then count its single round's special
// runs and assert the tallies (and the boolean flags derived from them) are // runs and assert the tallies (and the boolean flags derived from them) are
// exactly the documented values. No RNG, no clear — pure detection. // exactly the documented values. No RNG, no clear — pure detection.
flag_scene :: (name: string, rows: []string, want_len4: s64, want_len5_plus: s64) { flag_scene :: (name: string, rows: []string, want_len4: i64, want_len5_plus: i64) {
print("== {} ==\n", name); print("== {} ==\n", name);
b := load_board(rows, 0, LIMIT); b := load_board(rows, 0, LIMIT);
out(board_dump(@b)); out(board_dump(@b));
@@ -71,7 +71,7 @@ flag_scene :: (name: string, rows: []string, want_len4: s64, want_len5_plus: s64
t.expect(sp.len5_plus == want_len5_plus, concat(name, ": len5_plus count exact")); t.expect(sp.len5_plus == want_len5_plus, concat(name, ": len5_plus count exact"));
} }
main :: () -> s32 { main :: () -> i32 {
print("== turn (accounting + special-match flagging) ==\n"); print("== turn (accounting + special-match flagging) ==\n");
// ── Special-match flagging (single round, no RNG) ────────────────────── // ── Special-match flagging (single round, no RNG) ──────────────────────

View File

@@ -20,7 +20,7 @@
// never hits this — its loops run over 64 board cells, not millions of pixels. // never hits this — its loops run over 64 board cells, not millions of pixels.
#import "modules/std.sx"; #import "modules/std.sx";
#import "modules/math"; #import "modules/math";
#import "modules/stb.sx"; #import "vendors/stb_image/stb_image.sx";
SRC_PATH :: "/Users/agra/Downloads/m3te_particle.png"; SRC_PATH :: "/Users/agra/Downloads/m3te_particle.png";
OUT_PATH :: "assets/fx/particle.png"; OUT_PATH :: "assets/fx/particle.png";
@@ -33,14 +33,14 @@ GRAY_TOL :: 24; // max channel spread still considered neutral gray
LUM_MARGIN :: 4; // lum headroom above the light checker shade LUM_MARGIN :: 4; // lum headroom above the light checker shade
is_gray :: (r: u8, g: u8, b: u8) -> bool { is_gray :: (r: u8, g: u8, b: u8) -> bool {
hi := max(max(cast(s64) r, cast(s64) g), cast(s64) b); hi := max(max(cast(i64) r, cast(i64) g), cast(i64) b);
lo := min(min(cast(s64) r, cast(s64) g), cast(s64) b); lo := min(min(cast(i64) r, cast(i64) g), cast(i64) b);
hi - lo <= GRAY_TOL hi - lo <= GRAY_TOL
} }
// Mark pixel `i` as removed background and queue it, if it is unvisited checker // Mark pixel `i` as removed background and queue it, if it is unvisited checker
// (near-neutral gray no brighter than the light checker shade + margin). // (near-neutral gray no brighter than the light checker shade + margin).
fd_seed :: (i: s64, bg: [*]u8, lum: [*]s64, src: [*]u8, stack: [*]s64, sp: *s64, lim: s64) { fd_seed :: (i: i64, bg: [*]u8, lum: [*]i64, src: [*]u8, stack: [*]i64, sp: *i64, lim: i64) {
if bg[i] != 0 { return; } if bg[i] != 0 { return; }
p := i * 4; p := i * 4;
if lum[i] <= lim and is_gray(src[p], src[p+1], src[p+2]) { if lum[i] <= lim and is_gray(src[p], src[p+1], src[p+2]) {
@@ -50,35 +50,35 @@ fd_seed :: (i: s64, bg: [*]u8, lum: [*]s64, src: [*]u8, stack: [*]s64, sp: *s64,
} }
} }
main :: () -> s32 { main :: () -> i32 {
w : s32 = 0; w : i32 = 0;
h : s32 = 0; h : i32 = 0;
ch : s32 = 0; ch : i32 = 0;
src : [*]u8 = xx stbi_load(SRC_PATH, @w, @h, @ch, 4); src : [*]u8 = xx stbi_load(SRC_PATH, @w, @h, @ch, 4);
if xx src == 0 { if xx src == 0 {
print("FATAL: could not load {}\n", SRC_PATH); print("FATAL: could not load {}\n", SRC_PATH);
return 1; return 1;
} }
W := cast(s64) w; W := cast(i64) w;
H := cast(s64) h; H := cast(i64) h;
N := W * H; N := W * H;
print("loaded {}x{} ({} src channels)\n", w, h, ch); print("loaded {}x{} ({} src channels)\n", w, h, ch);
// Hoisted working locals (see codegen note above). // Hoisted working locals (see codegen note above).
y : s64 = 0; y : i64 = 0;
x : s64 = 0; x : i64 = 0;
i : s64 = 0; i : i64 = 0;
p : s64 = 0; p : i64 = 0;
r : s64 = 0; r : i64 = 0;
g : s64 = 0; g : i64 = 0;
b : s64 = 0; b : i64 = 0;
l : s64 = 0; l : i64 = 0;
// Per-pixel luminance, plus the checker shades read off the border ring // Per-pixel luminance, plus the checker shades read off the border ring
// (the border is pure checker — the glow never reaches the corners). // (the border is pure checker — the glow never reaches the corners).
lum : [*]s64 = xx context.allocator.alloc(N * size_of(s64)); lum : [*]i64 = xx context.allocator.alloc_bytes(N * size_of(i64));
c_lo : s64 = 255; c_lo : i64 = 255;
c_hi : s64 = 0; c_hi : i64 = 0;
y = 0; y = 0;
while y < H { while y < H {
x = 0; x = 0;
@@ -102,10 +102,10 @@ main :: () -> s32 {
// 8-connected flood fill of the edge-connected checker, seeded from every // 8-connected flood fill of the edge-connected checker, seeded from every
// border pixel. `bg[i]==1` marks a removed (transparent) background pixel. // border pixel. `bg[i]==1` marks a removed (transparent) background pixel.
bg : [*]u8 = xx context.allocator.alloc(N); bg : [*]u8 = xx context.allocator.alloc_bytes(N);
memset(xx bg, 0, N); memset(xx bg, 0, N);
stack : [*]s64 = xx context.allocator.alloc(N * size_of(s64)); stack : [*]i64 = xx context.allocator.alloc_bytes(N * size_of(i64));
sp : s64 = 0; sp : i64 = 0;
checker_lim := c_hi + LUM_MARGIN; checker_lim := c_hi + LUM_MARGIN;
x = 0; x = 0;
@@ -121,12 +121,12 @@ main :: () -> s32 {
y += 1; y += 1;
} }
cx : s64 = 0; cx : i64 = 0;
cy : s64 = 0; cy : i64 = 0;
dx : s64 = 0; dx : i64 = 0;
dy : s64 = 0; dy : i64 = 0;
nx : s64 = 0; nx : i64 = 0;
ny : s64 = 0; ny : i64 = 0;
while sp > 0 { while sp > 0 {
sp -= 1; sp -= 1;
i = stack[sp]; i = stack[sp];
@@ -153,9 +153,9 @@ main :: () -> s32 {
// checker shade up to pure white, giving the glow its smooth falloff. // checker shade up to pure white, giving the glow its smooth falloff.
denom := cast(f32) (255 - c_hi); denom := cast(f32) (255 - c_hi);
if denom < 1.0 { denom = 1.0; } if denom < 1.0 { denom = 1.0; }
alpha : [*]f32 = xx context.allocator.alloc(N * size_of(f32)); alpha : [*]f32 = xx context.allocator.alloc_bytes(N * size_of(f32));
kept : s64 = 0; kept : i64 = 0;
n_bg : s64 = 0; n_bg : i64 = 0;
a : f32 = 0.0; a : f32 = 0.0;
i = 0; i = 0;
while i < N { while i < N {
@@ -174,30 +174,30 @@ main :: () -> s32 {
// Area-averaged downscale to OUT_DIM. RGB stays white; only the averaged // Area-averaged downscale to OUT_DIM. RGB stays white; only the averaged
// alpha carries the sprite, so no premultiply is needed (white*cov == white). // alpha carries the sprite, so no premultiply is needed (white*cov == white).
out_px : [*]u8 = xx context.allocator.alloc(OUT_DIM * OUT_DIM * 4); out_px : [*]u8 = xx context.allocator.alloc_bytes(OUT_DIM * OUT_DIM * 4);
sxf := cast(f32) W / cast(f32) OUT_DIM; sxf := cast(f32) W / cast(f32) OUT_DIM;
syf := cast(f32) H / cast(f32) OUT_DIM; syf := cast(f32) H / cast(f32) OUT_DIM;
max_a : f32 = 0.0; max_a : f32 = 0.0;
ty : s64 = 0; ty : i64 = 0;
tx : s64 = 0; tx : i64 = 0;
x0 : s64 = 0; x0 : i64 = 0;
x1 : s64 = 0; x1 : i64 = 0;
y0 : s64 = 0; y0 : i64 = 0;
y1 : s64 = 0; y1 : i64 = 0;
sum : f32 = 0.0; sum : f32 = 0.0;
cnt : s64 = 0; cnt : i64 = 0;
sy : s64 = 0; sy : i64 = 0;
sx : s64 = 0; sx : i64 = 0;
av : f32 = 0.0; av : f32 = 0.0;
o : s64 = 0; o : i64 = 0;
ty = 0; ty = 0;
while ty < OUT_DIM { while ty < OUT_DIM {
tx = 0; tx = 0;
while tx < OUT_DIM { while tx < OUT_DIM {
x0 = cast(s64) (cast(f32) tx * sxf); x0 = cast(i64) (cast(f32) tx * sxf);
x1 = cast(s64) (cast(f32) (tx + 1) * sxf); x1 = cast(i64) (cast(f32) (tx + 1) * sxf);
y0 = cast(s64) (cast(f32) ty * syf); y0 = cast(i64) (cast(f32) ty * syf);
y1 = cast(s64) (cast(f32) (ty + 1) * syf); y1 = cast(i64) (cast(f32) (ty + 1) * syf);
if x1 <= x0 { x1 = x0 + 1; } if x1 <= x0 { x1 = x0 + 1; }
if y1 <= y0 { y1 = y0 + 1; } if y1 <= y0 { y1 = y0 + 1; }
sum = 0.0; sum = 0.0;

View File

@@ -1,55 +0,0 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef __ANDROID__
#include <android/asset_manager.h>
// Caller-installed AAssetManager pointer. Chess's android_main extracts
// it from `app->activity->assetManager` (via sx-side platform module's
// `g_android_asset_manager` global) and feeds it here once at startup.
// Until the setter has been called, Android falls through to fopen —
// gives a predictable "file not found" rather than a NULL-deref.
static AAssetManager* g_aam = NULL;
void sx_android_set_asset_manager(void* m) {
g_aam = (AAssetManager*)m;
}
#endif
unsigned char* read_file_bytes(const char* path, int* out_size) {
#ifdef __ANDROID__
if (g_aam != NULL) {
// AAssetManager paths are relative to the APK's `assets/`
// directory. Strip a leading "assets/" so callers can use the
// same paths across iOS/macOS/Android (those platforms read
// assets via `assets/...` rooted in the bundle or CWD).
const char* lookup = path;
if (strncmp(path, "assets/", 7) == 0) {
lookup = path + 7;
}
AAsset* a = AAssetManager_open(g_aam, lookup, AASSET_MODE_BUFFER);
if (a != NULL) {
off_t n = AAsset_getLength(a);
*out_size = (int)n;
unsigned char* buf = (unsigned char*)malloc((size_t)n);
if (buf != NULL) {
memcpy(buf, AAsset_getBuffer(a), (size_t)n);
}
AAsset_close(a);
return buf;
}
// Falls through to fopen — useful when assets land in the data
// dir via extraction or app updates.
}
#endif
FILE* f = fopen(path, "rb");
if (!f) return 0;
fseek(f, 0, SEEK_END);
*out_size = (int)ftell(f);
fseek(f, 0, SEEK_SET);
unsigned char* buf = (unsigned char*)malloc(*out_size);
fread(buf, 1, *out_size, f);
fclose(f);
return buf;
}

View File

@@ -1,13 +0,0 @@
#ifndef FILE_UTILS_H
#define FILE_UTILS_H
unsigned char* read_file_bytes(const char* path, int* out_size);
#ifdef __ANDROID__
// Install the AAssetManager that `read_file_bytes` consults for paths
// rooted inside the APK. Caller is responsible for passing the manager
// from `ANativeActivity->assetManager` before any read_file_bytes call.
void sx_android_set_asset_manager(void* m);
#endif
#endif

View File

@@ -1,19 +0,0 @@
zlib License
(C) Copyright 2024-2025 Jimmy Lefevre
This software is provided 'as-is', without any express or implied
warranty. In no event will the authors be held liable for any damages
arising from the use of this software.
Permission is granted to anyone to use this software for any purpose,
including commercial applications, and to alter it and redistribute it
freely, subject to the following restrictions:
1. The origin of this software must not be misrepresented; you must not
claim that you wrote the original software. If you use this software
in a product, an acknowledgment in the product documentation would be
appreciated but is not required.
2. Altered source versions must be plainly marked as such, and must not be
misrepresented as being the original software.
3. This notice may not be removed or altered from any source distribution.

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
#define KB_TEXT_SHAPE_IMPLEMENTATION
#include "kb/kb_text_shape.h"

View File

@@ -1,15 +0,0 @@
// Minimal API declarations for SX import.
// Only the functions/types we actually use — avoids parsing the full 30k-line header.
typedef struct kbts_shape_context kbts_shape_context;
typedef struct kbts_font kbts_font;
kbts_shape_context *kbts_CreateShapeContext(void *Allocator, void *AllocatorData);
void kbts_DestroyShapeContext(kbts_shape_context *Context);
kbts_font *kbts_ShapePushFontFromMemory(kbts_shape_context *Context, void *Memory, int Size, int FontIndex);
void kbts_GetFontInfo2(kbts_font *Font, void *Info);
void kbts_ShapeBegin(kbts_shape_context *Context, unsigned int ParagraphDirection, unsigned int Language);
void kbts_ShapeUtf8(kbts_shape_context *Context, const char *Utf8, int Length, unsigned int UserIdGenerationMode);
void kbts_ShapeEnd(kbts_shape_context *Context);
int kbts_ShapeRun(kbts_shape_context *Context, void *Run);
int kbts_GlyphIteratorNext(void *It, void **Glyph);

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include "stb_image_write.h"

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
#define STB_TRUETYPE_IMPLEMENTATION
#include "stb_truetype.h"