Compare commits
10 Commits
2a196943aa
...
31d1012806
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31d1012806 | ||
|
|
39740a1d36 | ||
|
|
bb728d0ab0 | ||
|
|
6f7d2f4db2 | ||
|
|
1ab74c7d08 | ||
|
|
38815c7d50 | ||
|
|
a7b41ccbca | ||
|
|
5a0627bb7c | ||
|
|
69e2c1f50d | ||
|
|
cd89a5c9c0 |
29
.vscode/ios-sim-debug.sh
vendored
Executable file
29
.vscode/ios-sim-debug.sh
vendored
Executable 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
27
.vscode/launch.json
vendored
Normal 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
23
.vscode/tasks.json
vendored
Normal 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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
18
audio.sx
18
audio.sx
@@ -10,17 +10,17 @@
|
||||
// other targets never reference these symbols nor need the frameworks.
|
||||
|
||||
#import "modules/std.sx";
|
||||
#import "modules/std/objc.sx";
|
||||
#import "modules/compiler.sx";
|
||||
#import "modules/ffi/objc.sx";
|
||||
#import "modules/build.sx";
|
||||
|
||||
// AudioToolbox — System Sound Services. SystemSoundID is a UInt32; OSStatus a
|
||||
// SInt32 (0 == noErr); the clip's file is passed as a CFURLRef (opaque ptr).
|
||||
AudioServicesCreateSystemSoundID :: (url: *void, out_id: *u32) -> s32 #foreign;
|
||||
AudioServicesCreateSystemSoundID :: (url: *void, out_id: *u32) -> i32 #foreign;
|
||||
AudioServicesPlaySystemSound :: (sound_id: u32) #foreign;
|
||||
|
||||
// CoreFoundation — build a file CFURL from an absolute path. `len` is a CFIndex
|
||||
// (long); `is_dir` a Boolean (unsigned char); a NULL allocator = default.
|
||||
CFURLCreateFromFileSystemRepresentation :: (allocator: *void, buffer: *u8, len: s64, is_dir: s8) -> *void #foreign;
|
||||
CFURLCreateFromFileSystemRepresentation :: (allocator: *void, buffer: *u8, len: i64, is_dir: i8) -> *void #foreign;
|
||||
CFRelease :: (cf: *void) #foreign;
|
||||
|
||||
// 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
|
||||
// combo1..combo5 range (see `cascade_cue_index`).
|
||||
play_cascade :: (self: *GameAudio, depth: s64) {
|
||||
play_cascade :: (self: *GameAudio, depth: i64) {
|
||||
inline if OS != .ios { return; }
|
||||
if !self.loaded { return; }
|
||||
idx := cascade_cue_index(depth);
|
||||
@@ -107,7 +107,7 @@ GameAudio :: struct {
|
||||
// `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
|
||||
// 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 == 1 { return "[sx] audio: cue combo2"; }
|
||||
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
|
||||
// (combo1..combo5). Clamps: depth <= 1 → 0, depth >= 5 → 4. Pure arithmetic and
|
||||
// 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 >= COMBO_CLIPS { return COMBO_CLIPS - 1; }
|
||||
depth - 1
|
||||
@@ -145,7 +145,7 @@ load_system_sound :: (name: string) -> u32 {
|
||||
if getcwd(@cwd_buf[0], 1024) == null { return 0; }
|
||||
cwd : string = ---;
|
||||
cwd.ptr = @cwd_buf[0];
|
||||
cwd.len = cast(s64) c_strlen(@cwd_buf[0]);
|
||||
cwd.len = cast(i64) c_strlen(@cwd_buf[0]);
|
||||
|
||||
// CFURLCreateFromFileSystemRepresentation takes an explicit byte length, so
|
||||
// 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_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_lose :: () { if g_audio != null { g_audio.play_lose(); } }
|
||||
|
||||
181
board.sx
181
board.sx
@@ -39,36 +39,36 @@ EMPTY_CHAR :: 46; // '.'
|
||||
|
||||
gem_char :: (g: Gem) -> u8 {
|
||||
if g == .empty { return EMPTY_CHAR; }
|
||||
GEM_CHARS[cast(s64) g]
|
||||
GEM_CHARS[cast(i64) g]
|
||||
}
|
||||
|
||||
// ── Deterministic RNG ─────────────────────────────────────────────────────
|
||||
// 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
|
||||
// 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.
|
||||
RNG_MASK32 :: 0xFFFFFFFF;
|
||||
RNG_MUL :: 1664525;
|
||||
RNG_ADD :: 1013904223;
|
||||
|
||||
Rng :: struct {
|
||||
state: s64;
|
||||
state: i64;
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Uniform-ish value in [0, n). Uses the high bits, whose period is far
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
rng_seeded :: (seed: s64) -> Rng {
|
||||
rng_seeded :: (seed: i64) -> Rng {
|
||||
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 scaled by `combo_multiplier` (P3.2). The HUD (P4.4) reads this
|
||||
// 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 —
|
||||
// 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
|
||||
// counters can never drift apart. A hand-built board must set both before
|
||||
// committing swaps.
|
||||
moves_made: s64;
|
||||
move_limit: s64;
|
||||
moves_made: i64;
|
||||
move_limit: i64;
|
||||
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -131,15 +131,15 @@ Board :: struct {
|
||||
// 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
|
||||
// (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
|
||||
}
|
||||
|
||||
at :: (self: *Board, col: s64, row: s64) -> Gem {
|
||||
at :: (self: *Board, col: i64, row: i64) -> Gem {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -149,14 +149,14 @@ Board :: struct {
|
||||
// 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
|
||||
// excluded, so a choice always remains.
|
||||
init :: (self: *Board, seed: s64) {
|
||||
init :: (self: *Board, seed: i64) {
|
||||
self.rng = rng_seeded(seed);
|
||||
self.score = 0;
|
||||
self.moves_made = 0;
|
||||
self.move_limit = DEFAULT_MOVE_LIMIT;
|
||||
self.target_score = DEFAULT_TARGET_SCORE;
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
for 0..BOARD_COLS: (col) {
|
||||
for 0..BOARD_ROWS (row) {
|
||||
for 0..BOARD_COLS (col) {
|
||||
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
|
||||
// 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 = ---;
|
||||
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.
|
||||
if col >= 2 {
|
||||
left := board.at(col - 1, 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.
|
||||
if row >= 2 {
|
||||
up := board.at(col, row - 1);
|
||||
if up == board.at(col, row - 2) {
|
||||
forbidden[cast(s64) up] = true;
|
||||
forbidden[cast(i64) up] = true;
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
k := rng.next_range(allowed);
|
||||
for 0..GEM_COUNT: (t) {
|
||||
for 0..GEM_COUNT (t) {
|
||||
if !forbidden[t] {
|
||||
if k == 0 { return cast(Gem) t; }
|
||||
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.
|
||||
board_dump :: (self: *Board) -> string {
|
||||
line_w := BOARD_COLS + 1; // 8 gem chars + newline
|
||||
buf := cstring(BOARD_ROWS * line_w);
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
buf := alloc_string(BOARD_ROWS * line_w);
|
||||
for 0..BOARD_ROWS (row) {
|
||||
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 + BOARD_COLS] = 10; // '\n'
|
||||
@@ -223,13 +223,13 @@ board_dump :: (self: *Board) -> string {
|
||||
MatchMask :: struct {
|
||||
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)]
|
||||
}
|
||||
|
||||
count :: (self: *MatchMask) -> s64 {
|
||||
n : s64 = 0;
|
||||
for 0..BOARD_CELLS: (i) { if self.cells[i] { n += 1; } }
|
||||
count :: (self: *MatchMask) -> i64 {
|
||||
n : i64 = 0;
|
||||
for 0..BOARD_CELLS (i) { if self.cells[i] { n += 1; } }
|
||||
n
|
||||
}
|
||||
}
|
||||
@@ -237,8 +237,8 @@ MatchMask :: struct {
|
||||
// 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
|
||||
// vertical one) and the span covers `start..end` of the moving coordinate.
|
||||
mark_run :: (m: *MatchMask, vertical: bool, fixed: s64, start: s64, end: s64) {
|
||||
for start..end: (i) {
|
||||
mark_run :: (m: *MatchMask, vertical: bool, fixed: i64, start: i64, end: i64) {
|
||||
for start..end (i) {
|
||||
if vertical {
|
||||
m.cells[Board.idx(fixed, i)] = true;
|
||||
} 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.
|
||||
find_matches :: (b: *Board) -> 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.
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
for 0..BOARD_ROWS (row) {
|
||||
col := 0;
|
||||
while col < BOARD_COLS {
|
||||
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.
|
||||
for 0..BOARD_COLS: (col) {
|
||||
for 0..BOARD_COLS (col) {
|
||||
row := 0;
|
||||
while row < BOARD_ROWS {
|
||||
g := b.at(col, row);
|
||||
@@ -298,10 +298,10 @@ find_matches :: (b: *Board) -> MatchMask {
|
||||
// unambiguously as the empty set. Suitable for snapshotting.
|
||||
dump_matches :: (b: *Board, m: *MatchMask) -> string {
|
||||
line_w := BOARD_COLS + 1; // 8 cells + newline
|
||||
buf := cstring(BOARD_ROWS * line_w);
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
buf := alloc_string(BOARD_ROWS * line_w);
|
||||
for 0..BOARD_ROWS (row) {
|
||||
base := row * line_w;
|
||||
for 0..BOARD_COLS: (col) {
|
||||
for 0..BOARD_COLS (col) {
|
||||
if m.at(col, row) {
|
||||
buf[base + col] = gem_char(b.at(col, row));
|
||||
} 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
|
||||
// and the move enumeration speak in (col, row) like the rest of the model.
|
||||
Cell :: struct {
|
||||
col: s64;
|
||||
row: s64;
|
||||
col: i64;
|
||||
row: i64;
|
||||
}
|
||||
|
||||
// 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.
|
||||
legal_swaps :: (board: *Board) -> List(Swap) {
|
||||
result := List(Swap).{};
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
for 0..BOARD_COLS: (col) {
|
||||
for 0..BOARD_ROWS (row) {
|
||||
for 0..BOARD_COLS (col) {
|
||||
here := Cell.{ col = col, row = row };
|
||||
if col + 1 < BOARD_COLS {
|
||||
right := Cell.{ col = col + 1, row = row };
|
||||
@@ -397,7 +397,7 @@ legal_swaps :: (board: *Board) -> List(Swap) {
|
||||
// as just "0 legal swaps", which reads unambiguously. Suitable for snapshotting.
|
||||
dump_swaps :: (swaps: *List(Swap)) -> string {
|
||||
result := format("{} legal swaps\n", swaps.len);
|
||||
for 0..swaps.len: (i) {
|
||||
for 0..swaps.len (i) {
|
||||
s := swaps.items[i];
|
||||
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
|
||||
// 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.
|
||||
clear_cells :: (board: *Board, mask: *MatchMask) -> s64 {
|
||||
cleared : s64 = 0;
|
||||
for 0..BOARD_CELLS: (i) {
|
||||
clear_cells :: (board: *Board, mask: *MatchMask) -> i64 {
|
||||
cleared : i64 = 0;
|
||||
for 0..BOARD_CELLS (i) {
|
||||
if mask.cells[i] {
|
||||
board.cells[i] = .empty;
|
||||
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
|
||||
// unchanged. The count drives later cascade/scoring (P2.2+): a non-zero result
|
||||
// means the board changed and the resolution loop should continue.
|
||||
clear_matches :: (board: *Board) -> s64 {
|
||||
clear_matches :: (board: *Board) -> i64 {
|
||||
m := find_matches(board);
|
||||
clear_cells(board, @m)
|
||||
}
|
||||
@@ -449,7 +449,7 @@ clear_matches :: (board: *Board) -> s64 {
|
||||
// this to know when gravity has stopped.
|
||||
collapse :: (board: *Board) -> bool {
|
||||
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
|
||||
// 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`
|
||||
@@ -489,11 +489,11 @@ collapse :: (board: *Board) -> bool {
|
||||
// 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
|
||||
// filled (0 on a board that had none).
|
||||
refill :: (board: *Board) -> s64 {
|
||||
refill :: (board: *Board) -> i64 {
|
||||
rng := @board.rng;
|
||||
filled : s64 = 0;
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
for 0..BOARD_COLS: (col) {
|
||||
filled : i64 = 0;
|
||||
for 0..BOARD_ROWS (row) {
|
||||
for 0..BOARD_COLS (col) {
|
||||
if board.at(col, row) == .empty {
|
||||
board.set(col, row, cast(Gem) rng.next_range(GEM_COUNT));
|
||||
filled += 1;
|
||||
@@ -526,11 +526,11 @@ refill :: (board: *Board) -> s64 {
|
||||
// make "did this settle clear a 4 / 5+ run" observable. `had_len4` /
|
||||
// `had_len5_plus` are the boolean view of the same counts.
|
||||
Cascade :: struct {
|
||||
depth: s64;
|
||||
cleared: List(s64);
|
||||
awarded: s64;
|
||||
len4: s64;
|
||||
len5_plus: s64;
|
||||
depth: i64;
|
||||
cleared: List(i64);
|
||||
awarded: i64;
|
||||
len4: i64;
|
||||
len5_plus: i64;
|
||||
|
||||
had_len4 :: (self: *Cascade) -> bool {
|
||||
self.len4 > 0
|
||||
@@ -546,7 +546,7 @@ Cascade :: struct {
|
||||
// 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
|
||||
// returns 0.
|
||||
resolve_step :: (board: *Board) -> s64 {
|
||||
resolve_step :: (board: *Board) -> i64 {
|
||||
cleared := clear_matches(board);
|
||||
if cleared == 0 { return 0; }
|
||||
collapse(board);
|
||||
@@ -560,7 +560,7 @@ resolve_step :: (board: *Board) -> s64 {
|
||||
// Each round adds `score_round * combo_multiplier(round)` (round 1-based) to
|
||||
// `Board.score`; an already-stable board returns depth 0, awards 0, untouched.
|
||||
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 {
|
||||
// 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
|
||||
@@ -604,15 +604,15 @@ SCORE_RUN_5_PLUS :: 100;
|
||||
// vertical one) and the run covers `start..start+len` of the moving coordinate.
|
||||
Run :: struct {
|
||||
vertical: bool;
|
||||
fixed: s64;
|
||||
start: s64;
|
||||
len: s64;
|
||||
fixed: i64;
|
||||
start: i64;
|
||||
len: i64;
|
||||
}
|
||||
|
||||
// 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
|
||||
// the top tier.
|
||||
run_score :: (len: s64) -> s64 {
|
||||
run_score :: (len: i64) -> i64 {
|
||||
if len <= 3 { return SCORE_RUN_3; }
|
||||
if len == 4 { return SCORE_RUN_4; }
|
||||
SCORE_RUN_5_PLUS
|
||||
@@ -628,7 +628,7 @@ run_score :: (len: s64) -> s64 {
|
||||
find_runs :: (b: *Board) -> List(Run) {
|
||||
runs := List(Run).{};
|
||||
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
for 0..BOARD_ROWS (row) {
|
||||
col := 0;
|
||||
while col < BOARD_COLS {
|
||||
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;
|
||||
while row < BOARD_ROWS {
|
||||
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
|
||||
// BEFORE the round's clear, while the runs are still on the board. A board with
|
||||
// no run scores 0.
|
||||
score_round :: (board: *Board) -> s64 {
|
||||
score_round :: (board: *Board) -> i64 {
|
||||
runs := find_runs(board);
|
||||
total : s64 = 0;
|
||||
for 0..runs.len: (i) {
|
||||
total : i64 = 0;
|
||||
for 0..runs.len (i) {
|
||||
total += run_score(runs.items[i].len);
|
||||
}
|
||||
total
|
||||
@@ -679,7 +679,7 @@ score_round :: (board: *Board) -> s64 {
|
||||
// `score` total and return them. The single-round accumulation primitive; the
|
||||
// cascade loop (`resolve`) instead scales each round by `combo_multiplier`
|
||||
// (P3.2). Neither path changes `score_round`.
|
||||
add_round_score :: (board: *Board) -> s64 {
|
||||
add_round_score :: (board: *Board) -> i64 {
|
||||
points := score_round(board);
|
||||
board.score += points;
|
||||
points
|
||||
@@ -694,7 +694,7 @@ add_round_score :: (board: *Board) -> s64 {
|
||||
// multi-round chain strictly beats the same clears scored flat. `resolve`
|
||||
// accumulates `score_round * combo_multiplier(round)` per round into `Board.score`
|
||||
// and reports the sum as `Cascade.awarded`.
|
||||
combo_multiplier :: (round: s64) -> s64 {
|
||||
combo_multiplier :: (round: i64) -> i64 {
|
||||
round
|
||||
}
|
||||
|
||||
@@ -711,8 +711,8 @@ combo_multiplier :: (round: s64) -> s64 {
|
||||
// "did any occur" lives on `Cascade` (`had_len4` / `had_len5_plus`) for the
|
||||
// whole settle; a single round reads these counts directly.
|
||||
SpecialCounts :: struct {
|
||||
len4: s64;
|
||||
len5_plus: s64;
|
||||
len4: i64;
|
||||
len5_plus: i64;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
runs := find_runs(board);
|
||||
counts := SpecialCounts.{ len4 = 0, len5_plus = 0 };
|
||||
for 0..runs.len: (i) {
|
||||
for 0..runs.len (i) {
|
||||
len := runs.items[i].len;
|
||||
if len == 4 {
|
||||
counts.len4 += 1;
|
||||
@@ -741,7 +741,7 @@ count_specials :: (board: *Board) -> SpecialCounts {
|
||||
// "0 runs". Suitable for snapshotting.
|
||||
dump_runs :: (runs: *List(Run)) -> string {
|
||||
result := format("{} runs\n", runs.len);
|
||||
for 0..runs.len: (i) {
|
||||
for 0..runs.len (i) {
|
||||
r := runs.items[i];
|
||||
axis := if r.vertical then "V" else "H";
|
||||
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 {
|
||||
legal: bool;
|
||||
cascade: Cascade;
|
||||
moves_remaining: s64;
|
||||
moves_remaining: i64;
|
||||
}
|
||||
|
||||
// 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.
|
||||
commit_swap :: (board: *Board, a: Cell, b: Cell) -> PlayerMove {
|
||||
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() };
|
||||
}
|
||||
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,
|
||||
// so the board is left unchanged.
|
||||
has_legal_swap :: (board: *Board) -> bool {
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
for 0..BOARD_COLS: (col) {
|
||||
for 0..BOARD_ROWS (row) {
|
||||
for 0..BOARD_COLS (col) {
|
||||
here := Cell.{ col = col, row = row };
|
||||
if col + 1 < BOARD_COLS {
|
||||
right := Cell.{ col = col + 1, row = row };
|
||||
@@ -882,11 +882,25 @@ reshuffle :: (board: *Board) -> bool {
|
||||
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
|
||||
// seed → identical starting layout), zeroes `score` and `moves_made`, and
|
||||
// restores the default move budget and score goal, so `level_status` reads
|
||||
// `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);
|
||||
}
|
||||
|
||||
@@ -916,14 +930,11 @@ TurnResult :: struct {
|
||||
play_turn :: (board: *Board, a: Cell, b: Cell) -> TurnResult {
|
||||
status := level_status(board);
|
||||
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() };
|
||||
return TurnResult.{ accepted = false, move = frozen, status = status, reshuffled = false };
|
||||
}
|
||||
move := commit_swap(board, a, b);
|
||||
reshuffled := false;
|
||||
if level_status(board) == .in_progress and !has_legal_swap(board) {
|
||||
reshuffled = reshuffle(board);
|
||||
}
|
||||
reshuffled := reshuffle_if_deadlocked(board);
|
||||
TurnResult.{ accepted = true, move = move, status = level_status(board), reshuffled = reshuffled }
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// Board motion animation (P6.1) — a PURELY VISUAL timeline the view plays over
|
||||
// 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
|
||||
// replays the SAME operations on a value-copy of the pre-move board to record the
|
||||
// per-step geometry (the swap, each cascade round's matched cells, and each
|
||||
// round's per-column fall provenance). Because the copy starts from the identical
|
||||
// cells AND RNG state and runs the identical primitives, its recorded `final`
|
||||
// board equals the model's settled board gem-for-gem — the animation only ever
|
||||
// ends ON the already-decided result, never changes it.
|
||||
// `plan_and_commit` commits the move on the real board (and, like the headless
|
||||
// turn loop, reshuffles a deadlocked board afterwards), then replays the SAME
|
||||
// commit operations on a value-copy of the pre-move board to record the per-step
|
||||
// geometry (the swap, each cascade round's matched cells, and each round's
|
||||
// per-column fall provenance). Because the copy starts from the identical cells
|
||||
// AND RNG state and runs the identical primitives, its recorded `final` board
|
||||
// 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
|
||||
// (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.
|
||||
// `tests/easing.sx` pins f(0)=0, f(1)=1, monotonicity, and the cascade ordering.
|
||||
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));
|
||||
window := 1.0 - FALL_STAGGER_MAX;
|
||||
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
|
||||
// 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.
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// 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.
|
||||
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
|
||||
+ 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
|
||||
// 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 {
|
||||
lo : s64 = (BOARD_COLS - 1) + (BOARD_ROWS - 1) + 1;
|
||||
hi : s64 = -1;
|
||||
for 0..BOARD_CELLS: (i) {
|
||||
lo : i64 = (BOARD_COLS - 1) + (BOARD_ROWS - 1) + 1;
|
||||
hi : i64 = -1;
|
||||
for 0..BOARD_CELLS (i) {
|
||||
if m.cells[i] {
|
||||
d := (i % BOARD_COLS) + (i / BOARD_COLS);
|
||||
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
|
||||
// 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.
|
||||
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; }
|
||||
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 {
|
||||
before: [BOARD_CELLS]Gem;
|
||||
matched: MatchMask;
|
||||
src: [BOARD_CELLS]s64;
|
||||
src: [BOARD_CELLS]i64;
|
||||
after: [BOARD_CELLS]Gem;
|
||||
}
|
||||
|
||||
@@ -209,7 +210,7 @@ AnimMove :: struct {
|
||||
pre: [BOARD_CELLS]Gem;
|
||||
rounds: List(AnimRound);
|
||||
final: [BOARD_CELLS]Gem;
|
||||
awarded: s64;
|
||||
awarded: i64;
|
||||
}
|
||||
|
||||
// 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
|
||||
// per-round bounce (render_fall/clear) and the final-settle stamp share this so
|
||||
// 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;
|
||||
k := kmax;
|
||||
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
|
||||
// real board is mutated by `commit_swap` exactly as the non-animated path did;
|
||||
// the recording runs on a separate value-copy taken BEFORE the commit, so it
|
||||
// replays the identical cells + RNG stream and its `final` equals `board.cells`.
|
||||
// real board is mutated by `commit_swap`, then — exactly like the headless
|
||||
// `play_turn` — `reshuffle_if_deadlocked` recovers a stranded board so the rendered
|
||||
// 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 {
|
||||
move : AnimMove = ---;
|
||||
move.a = a;
|
||||
@@ -251,6 +256,7 @@ plan_and_commit :: (board: *Board, a: Cell, b: Cell) -> AnimMove {
|
||||
move.awarded = mv.cascade.awarded;
|
||||
if !mv.legal {
|
||||
move.final = board.cells;
|
||||
reshuffle_if_deadlocked(board);
|
||||
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
|
||||
// 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.
|
||||
for 0..BOARD_COLS: (col) {
|
||||
for 0..BOARD_COLS (col) {
|
||||
w := BOARD_ROWS - 1;
|
||||
r := BOARD_ROWS - 1;
|
||||
while r >= 0 {
|
||||
@@ -296,6 +302,7 @@ plan_and_commit :: (board: *Board, a: Cell, b: Cell) -> AnimMove {
|
||||
}
|
||||
|
||||
move.final = scratch.cells;
|
||||
reshuffle_if_deadlocked(board);
|
||||
move
|
||||
}
|
||||
|
||||
@@ -305,7 +312,7 @@ AnimPhaseKind :: enum { swap; clear; fall; done; }
|
||||
|
||||
AnimPhase :: struct {
|
||||
kind: AnimPhaseKind;
|
||||
round: s64;
|
||||
round: i64;
|
||||
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,
|
||||
// when its clear begins, never re-fired every frame. Reset whenever a move
|
||||
// (re)starts; advanced by the frame loop as rounds clear.
|
||||
cascade_fired: s64;
|
||||
cascade_fired: i64;
|
||||
|
||||
init :: (self: *BoardAnim) {
|
||||
self.active = false;
|
||||
@@ -355,7 +362,7 @@ BoardAnim :: struct {
|
||||
return AnimPhase.{ kind = .swap, round = 0, t = 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 {
|
||||
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
|
||||
// against `BoardAnim.cascade_fired` to play one cue per newly-cleared round. Pure +
|
||||
// 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 elapsed < SWAP_ANIM_DUR { return 0; }
|
||||
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; }
|
||||
started
|
||||
}
|
||||
|
||||
56
board_fx.sx
56
board_fx.sx
@@ -12,8 +12,8 @@
|
||||
// shrink to nothing) rather than alpha — the soft texture edges carry the fade.
|
||||
#import "modules/std.sx";
|
||||
#import "modules/math";
|
||||
#import "modules/opengl.sx";
|
||||
#import "modules/stb.sx";
|
||||
#import "modules/ffi/opengl.sx";
|
||||
#import "vendors/stb_image/stb_image.sx";
|
||||
#import "modules/gpu/types.sx";
|
||||
#import "modules/gpu/api.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
|
||||
// pastel — the low channel is trimmed while the dominant/mid channel is lifted —
|
||||
// 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 == 1 { return Color.{ r = 255, g = 164, b = 44, 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
|
||||
// equivalence to `cascade_cue_index` is locked headlessly (tests/fx_combo.sx).
|
||||
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 >= FX_COMBO_MAX_LEVEL + 1 { return FX_COMBO_MAX_LEVEL; }
|
||||
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)
|
||||
// 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.
|
||||
fx_popup_font :: (depth: s64) -> f32 {
|
||||
fx_popup_font :: (depth: i64) -> f32 {
|
||||
if depth <= 1 { return FX_POPUP_FONT; }
|
||||
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
|
||||
// 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; }
|
||||
t := cast(f32) (fx_combo_level(depth) - 1) / cast(f32) (FX_COMBO_MAX_LEVEL - 1);
|
||||
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
|
||||
// board_view.load_texture's upload half but takes an in-memory buffer (the
|
||||
// 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 {
|
||||
return xx gpu.create_texture(w, h, .rgba8, xx pixels);
|
||||
}
|
||||
@@ -123,28 +123,28 @@ BoardFxAssets :: struct {
|
||||
loaded: bool;
|
||||
|
||||
init :: (self: *BoardFxAssets) {
|
||||
for 0..GEM_COUNT: (t) { self.tex[t] = 0; }
|
||||
for 0..GEM_COUNT (t) { self.tex[t] = 0; }
|
||||
self.loaded = false;
|
||||
}
|
||||
|
||||
load :: (self: *BoardFxAssets, gpu: ?GPU) {
|
||||
w : s32 = 0;
|
||||
h : s32 = 0;
|
||||
ch : s32 = 0;
|
||||
w : i32 = 0;
|
||||
h : i32 = 0;
|
||||
ch : i32 = 0;
|
||||
src : [*]u8 = xx stbi_load("assets/fx/particle.png", @w, @h, @ch, 4);
|
||||
if xx src == 0 {
|
||||
out("WARNING: could not load assets/fx/particle.png\n");
|
||||
self.loaded = false;
|
||||
return;
|
||||
}
|
||||
n := cast(s64) w * cast(s64) h;
|
||||
buf : [*]u8 = xx context.allocator.alloc(n * 4);
|
||||
n := cast(i64) w * cast(i64) h;
|
||||
buf : [*]u8 = xx context.allocator.alloc_bytes(n * 4);
|
||||
// Loop locals are hoisted: a block-scoped local declared inside a body
|
||||
// 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.
|
||||
i : s64 = 0;
|
||||
o : s64 = 0;
|
||||
for 0..GEM_COUNT: (t) {
|
||||
i : i64 = 0;
|
||||
o : i64 = 0;
|
||||
for 0..GEM_COUNT (t) {
|
||||
col := fx_tint(t);
|
||||
i = 0;
|
||||
while i < n {
|
||||
@@ -168,7 +168,7 @@ BoardFxAssets :: struct {
|
||||
FxParticle :: struct {
|
||||
col: f32;
|
||||
row: f32;
|
||||
tint: s64;
|
||||
tint: i64;
|
||||
delay: f32;
|
||||
age: f32;
|
||||
life: f32;
|
||||
@@ -184,8 +184,8 @@ FxParticle :: struct {
|
||||
FxPopup :: struct {
|
||||
col: f32;
|
||||
row: f32;
|
||||
points: s64;
|
||||
depth: s64;
|
||||
points: i64;
|
||||
depth: i64;
|
||||
delay: f32;
|
||||
age: f32;
|
||||
life: f32;
|
||||
@@ -219,7 +219,7 @@ BoardFx :: struct {
|
||||
// Whole-move depth boost: a deeper cascade makes every burst bigger from
|
||||
// its first round, escalating in lockstep with the cascade SFX cue.
|
||||
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];
|
||||
t0 := SWAP_ANIM_DUR + cast(f32) k * (CLEAR_ANIM_DUR + FALL_ANIM_DUR);
|
||||
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
|
||||
// one simultaneous flash. The round's audio cue still fires once at t0.
|
||||
span := clear_diag_span(@rd.matched);
|
||||
for 0..BOARD_CELLS: (idx) {
|
||||
for 0..BOARD_CELLS (idx) {
|
||||
if rd.matched.cells[idx] {
|
||||
g := rd.before[idx];
|
||||
if g != .empty {
|
||||
@@ -237,7 +237,7 @@ BoardFx :: struct {
|
||||
self.particles.append(FxParticle.{
|
||||
col = cast(f32) col + 0.5,
|
||||
row = cast(f32) row + 0.5,
|
||||
tint = cast(s64) g,
|
||||
tint = cast(i64) g,
|
||||
delay = t0 + rdelay,
|
||||
age = 0.0,
|
||||
life = FX_BURST_LIFE,
|
||||
@@ -250,10 +250,10 @@ BoardFx :: struct {
|
||||
|
||||
// One popup for the whole move at the first clear's centroid.
|
||||
rd0 := @mv.rounds.items[0];
|
||||
sc : s64 = 0;
|
||||
sr : s64 = 0;
|
||||
cnt : s64 = 0;
|
||||
for 0..BOARD_CELLS: (idx) {
|
||||
sc : i64 = 0;
|
||||
sr : i64 = 0;
|
||||
cnt : i64 = 0;
|
||||
for 0..BOARD_CELLS (idx) {
|
||||
if rd0.matched.cells[idx] {
|
||||
sc += 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
|
||||
// simple: compact each list in place by overwriting dead entries.
|
||||
tick :: (self: *BoardFx, dt: f32) {
|
||||
w : s64 = 0;
|
||||
i : s64 = 0;
|
||||
w : i64 = 0;
|
||||
i : i64 = 0;
|
||||
while i < self.particles.len {
|
||||
p := self.particles.items[i];
|
||||
p.age += dt;
|
||||
|
||||
@@ -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(
|
||||
self.origin.x + xx col * 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;
|
||||
fy := (p.y - self.origin.y) / self.cell_size;
|
||||
if fx < 0.0 or fy < 0.0 { return null; }
|
||||
col : s64 = xx fx;
|
||||
row : s64 = xx fy;
|
||||
col : i64 = xx fx;
|
||||
row : i64 = xx fy;
|
||||
if col >= BOARD_COLS or row >= BOARD_ROWS { return null; }
|
||||
Cell.{ col = col, row = row }
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
// the grid is a centered square inside the safe-area inset.
|
||||
#import "modules/std.sx";
|
||||
#import "modules/math";
|
||||
#import "modules/opengl.sx";
|
||||
#import "modules/stb.sx";
|
||||
#import "modules/ffi/opengl.sx";
|
||||
#import "vendors/stb_image/stb_image.sx";
|
||||
#import "modules/gpu/types.sx";
|
||||
#import "modules/gpu/api.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;
|
||||
}
|
||||
|
||||
gem_uv :: (self: *BoardAssets, index: s64) -> GemUV {
|
||||
gem_uv :: (self: *BoardAssets, index: i64) -> GemUV {
|
||||
u0 : f32 = xx index * self.cell_u;
|
||||
GemUV.{
|
||||
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
|
||||
// desktop GL path falls back to a plain GL_TEXTURE_2D.
|
||||
load_texture :: (path: [:0]u8, gpu: ?GPU) -> u32 {
|
||||
w : s32 = 0;
|
||||
h : s32 = 0;
|
||||
ch : s32 = 0;
|
||||
w : i32 = 0;
|
||||
h : i32 = 0;
|
||||
ch : i32 = 0;
|
||||
pixels := stbi_load(path, @w, @h, @ch, 4);
|
||||
if pixels == null {
|
||||
out("WARNING: could not load texture: ");
|
||||
@@ -237,7 +237,7 @@ BoardView :: struct {
|
||||
safe: EdgeInsets;
|
||||
// Seed for `restart`: the same fixed seed main seeded the board with, so the
|
||||
// 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,
|
||||
// set only by the M3TE_FPS env pin); `fps` is the smoothed reciprocal frame
|
||||
// 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 :: (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);
|
||||
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
|
||||
// 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;
|
||||
cx := self.layout.origin.x + cast(f32) col * 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
|
||||
// in cell units). A resting pose reproduces gem_frame exactly, so the t==0
|
||||
// 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;
|
||||
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;
|
||||
@@ -311,7 +311,7 @@ BoardView :: struct {
|
||||
// — 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
|
||||
// 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;
|
||||
cx := self.layout.origin.x + (cast(f32) col + 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,
|
||||
// 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.
|
||||
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);
|
||||
|
||||
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.
|
||||
// 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.
|
||||
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);
|
||||
if m < 0 { return 0.0; }
|
||||
col := i % BOARD_COLS;
|
||||
@@ -357,13 +357,13 @@ BoardView :: struct {
|
||||
// Settled-board gems: one sprite per non-empty cell, drawn with its live
|
||||
// per-gem animation pose. Used whenever no move is animating.
|
||||
render_gems :: (self: *BoardView, ctx: *RenderContext, dim: f32) {
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
for 0..BOARD_COLS: (col) {
|
||||
for 0..BOARD_ROWS (row) {
|
||||
for 0..BOARD_COLS (col) {
|
||||
g := self.board.at(col, row);
|
||||
if g != .empty {
|
||||
pose := self.gem_pose_at(col, row);
|
||||
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
|
||||
// `active` before this is reached.
|
||||
last := mv.rounds.len - 1;
|
||||
for 0..BOARD_CELLS: (i) {
|
||||
for 0..BOARD_CELLS (i) {
|
||||
g := mv.final[i];
|
||||
if g != .empty {
|
||||
sq := self.rest_squash(i, last, e);
|
||||
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);
|
||||
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; }
|
||||
g := mv.pre[i];
|
||||
if g != .empty {
|
||||
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];
|
||||
if ga != .empty {
|
||||
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];
|
||||
if gb != .empty {
|
||||
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
|
||||
// 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);
|
||||
for 0..BOARD_CELLS: (i) {
|
||||
for 0..BOARD_CELLS (i) {
|
||||
g := rd.before[i];
|
||||
if g == .empty { continue; }
|
||||
col := i % BOARD_COLS;
|
||||
@@ -507,7 +507,7 @@ BoardView :: struct {
|
||||
// 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)));
|
||||
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 {
|
||||
// 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
|
||||
@@ -515,7 +515,7 @@ BoardView :: struct {
|
||||
// (nothing has fallen yet), keeping that frame byte-identical.
|
||||
sq := self.rest_squash(i, k - 1, e);
|
||||
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
|
||||
);
|
||||
ctx.push_clip(grid);
|
||||
for 0..self.fx.particles.len: (i) {
|
||||
for 0..self.fx.particles.len (i) {
|
||||
p := self.fx.particles.items[i];
|
||||
lt := (p.age - p.delay) / p.life;
|
||||
env := fx_pop_env(lt);
|
||||
@@ -556,7 +556,7 @@ BoardView :: struct {
|
||||
render_fx_popups :: (self: *BoardView, ctx: *RenderContext) {
|
||||
if self.fx == null or self.fx.popups.len == 0 { return; }
|
||||
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];
|
||||
lt := (q.age - q.delay) / q.life;
|
||||
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
|
||||
// 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.
|
||||
render_fall :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, k: s64, e: f32, dim: f32, t: f32) {
|
||||
for 0..BOARD_CELLS: (i) {
|
||||
render_fall :: (self: *BoardView, ctx: *RenderContext, rd: *AnimRound, k: i64, e: f32, dim: f32, t: f32) {
|
||||
for 0..BOARD_CELLS (i) {
|
||||
g := rd.after[i];
|
||||
if g == .empty { continue; }
|
||||
col := i % BOARD_COLS;
|
||||
@@ -608,7 +608,7 @@ BoardView :: struct {
|
||||
// has reached its cell flattens wide-and-short, then wobbles out.
|
||||
sq := self.rest_squash(i, k, e);
|
||||
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
|
||||
// the dark text keeps the digits legible over the light background art.
|
||||
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);
|
||||
sz := measure_text(txt, FPS_FONT);
|
||||
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_dim := self.layout.cell_size * GEM_FILL_FRAC;
|
||||
if self.assets.cell_tex != 0 {
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
for 0..BOARD_COLS: (col) {
|
||||
for 0..BOARD_ROWS (row) {
|
||||
for 0..BOARD_COLS (col) {
|
||||
ctx.add_image(self.layout.cell_frame(col, row), self.assets.cell_tex);
|
||||
}
|
||||
}
|
||||
|
||||
2
build.sx
2
build.sx
@@ -1,4 +1,4 @@
|
||||
#import "modules/compiler.sx";
|
||||
#import "modules/build.sx";
|
||||
#import "modules/platform/bundle.sx";
|
||||
|
||||
configure_build :: () {
|
||||
|
||||
@@ -25,9 +25,9 @@ the step asks for an honest accounting.
|
||||
|
||||
## Category: Numerics / parsing
|
||||
|
||||
### 1. String → number parsing (`parse_s64`, `parse_f32`)
|
||||
- **m3te location:** `main.sx:156` (`parse_s64`), `main.sx:122` (`parse_f32`).
|
||||
- **What it does:** decimal ASCII `string` → `s64` / `f32` (sign + integer +
|
||||
### 1. String → number parsing (`parse_i64`, `parse_f32`)
|
||||
- **m3te location:** `main.sx:156` (`parse_i64`), `main.sx:122` (`parse_f32`).
|
||||
- **What it does:** decimal ASCII `string` → `i64` / `f32` (sign + integer +
|
||||
optional fractional part).
|
||||
- **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
|
||||
@@ -46,7 +46,7 @@ the step asks for an honest accounting.
|
||||
- **m3te location:** `board.sx:55` (`Rng` struct: `next_u32` `board.sx:59`,
|
||||
`next_range` `board.sx:66`), `board.sx:71` (`rng_seeded`), constants
|
||||
`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)`),
|
||||
`rng_seeded(seed)`.
|
||||
- **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)
|
||||
|
||||
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.
|
||||
2. **Seedable PRNG** (`Rng` LCG) — textbook stdlib primitive, entirely absent.
|
||||
3. **`from_cstr` C-string bridge** (+ public `strlen`) — the most-repeated FFI
|
||||
|
||||
12
gem_anim.sx
12
gem_anim.sx
@@ -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
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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);
|
||||
w := t / IDLE_PERIOD * TAU + gem_idle_phase(col, row);
|
||||
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
|
||||
// idle clock keeps running, so the always-on idle simply resumes from rest.
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -143,11 +143,11 @@ GemMotion :: struct {
|
||||
// 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
|
||||
// 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;
|
||||
}
|
||||
|
||||
land_local :: (self: *GemMotion, i: s64) -> f32 {
|
||||
land_local :: (self: *GemMotion, i: i64) -> f32 {
|
||||
self.clock - self.land_at[i]
|
||||
}
|
||||
}
|
||||
|
||||
97
main.sx
97
main.sx
@@ -1,11 +1,13 @@
|
||||
#import "modules/std.sx";
|
||||
#import "build.sx";
|
||||
#import "modules/compiler.sx";
|
||||
#import "modules/opengl.sx";
|
||||
#import "modules/sdl3.sx";
|
||||
#import "modules/build.sx";
|
||||
#import "modules/ffi/opengl.sx";
|
||||
#import "modules/ffi/sdl3.sx";
|
||||
#import "modules/math";
|
||||
#import "modules/stb.sx";
|
||||
#import "modules/stb_truetype.sx";
|
||||
#import "vendors/stb_image/stb_image.sx";
|
||||
#import "vendors/stb_truetype/stb_truetype.sx";
|
||||
#import "vendors/kb_text_shape/kb_text_shape.sx";
|
||||
#import "vendors/file_utils/file_utils.sx";
|
||||
#import "modules/gpu/api.sx";
|
||||
#import "modules/gpu/types.sx";
|
||||
#import "modules/gpu/metal.sx";
|
||||
@@ -24,8 +26,6 @@
|
||||
|
||||
// libc is the implicit foreign-library handle the std allocators bind against;
|
||||
// reused here to read the deterministic-capture environment variables at startup.
|
||||
getenv :: (name: [:0]u8) -> *u8 #foreign libc "getenv";
|
||||
strlen :: (s: *u8) -> usize #foreign libc "strlen";
|
||||
|
||||
// Fixed seed for the rendered board — the same seed tests/board_init.sx locks
|
||||
// as a snapshot, so the on-screen layout matches that golden gem-for-gem.
|
||||
@@ -116,41 +116,34 @@ build_ui :: () -> View {
|
||||
// M3TE_SELECT=<cellIndex 0..63> forces a selection so the select-pop reaction can
|
||||
// be captured without injecting a tap. Absent → normal live behaviour.
|
||||
read_env :: (name: [:0]u8) -> ?string {
|
||||
p := getenv(name);
|
||||
addr : s64 = xx p;
|
||||
if addr == 0 { return null; }
|
||||
n := cast(s64) strlen(p);
|
||||
if n == 0 { return ""; }
|
||||
buf := cstring(n);
|
||||
memcpy(buf.ptr, xx p, n);
|
||||
buf
|
||||
process.env(name)
|
||||
}
|
||||
|
||||
// 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/
|
||||
// 57) to f32 across the comparisons, which mis-types the byte compares.
|
||||
parse_f32 :: (s: string) -> f32 {
|
||||
i : s64 = 0;
|
||||
i : i64 = 0;
|
||||
neg : bool = false;
|
||||
if s.len > 0 {
|
||||
c0 : s64 = xx s[0];
|
||||
c0 : i64 = xx s[0];
|
||||
if c0 == 45 { neg = true; i = 1; } // '-'
|
||||
}
|
||||
intval : s64 = 0;
|
||||
intval : i64 = 0;
|
||||
while i < s.len {
|
||||
c : s64 = xx s[i];
|
||||
c : i64 = xx s[i];
|
||||
if c < 48 or c > 57 { break; }
|
||||
intval = intval * 10 + (c - 48);
|
||||
i += 1;
|
||||
}
|
||||
fracval : s64 = 0;
|
||||
fracdiv : s64 = 1;
|
||||
fracval : i64 = 0;
|
||||
fracdiv : i64 = 1;
|
||||
if i < s.len {
|
||||
d : s64 = xx s[i];
|
||||
d : i64 = xx s[i];
|
||||
if d == 46 { // '.'
|
||||
i += 1;
|
||||
while i < s.len {
|
||||
c : s64 = xx s[i];
|
||||
c : i64 = xx s[i];
|
||||
if c < 48 or c > 57 { break; }
|
||||
fracval = fracval * 10 + (c - 48);
|
||||
fracdiv = fracdiv * 10;
|
||||
@@ -163,11 +156,11 @@ parse_f32 :: (s: string) -> f32 {
|
||||
v
|
||||
}
|
||||
|
||||
parse_s64 :: (s: string) -> s64 {
|
||||
i : s64 = 0;
|
||||
v : s64 = 0;
|
||||
parse_i64 :: (s: string) -> i64 {
|
||||
i : i64 = 0;
|
||||
v : i64 = 0;
|
||||
while i < s.len {
|
||||
c : s64 = xx s[i];
|
||||
c : i64 = xx s[i];
|
||||
if c < 48 or c > 57 { break; }
|
||||
v = v * 10 + (c - 48);
|
||||
i += 1;
|
||||
@@ -183,8 +176,8 @@ parse_s64 :: (s: string) -> s64 {
|
||||
// trial swaps inside `swap_legal` are reverted, so the board is left unchanged.
|
||||
illegal_swaps :: (board: *Board) -> List(Swap) {
|
||||
result := List(Swap).{};
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
for 0..BOARD_COLS: (col) {
|
||||
for 0..BOARD_ROWS (row) {
|
||||
for 0..BOARD_COLS (col) {
|
||||
here := Cell.{ col = col, row = row };
|
||||
if col + 1 < BOARD_COLS {
|
||||
right := Cell.{ col = col + 1, row = row };
|
||||
@@ -222,7 +215,7 @@ frame :: () {
|
||||
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 {
|
||||
if ev == {
|
||||
case .key_up: (e) {
|
||||
@@ -277,7 +270,7 @@ frame :: () {
|
||||
mv := @g_anim.move;
|
||||
total := g_anim.total();
|
||||
last := mv.rounds.len - 1;
|
||||
for 0..BOARD_CELLS: (i) {
|
||||
for 0..BOARD_CELLS (i) {
|
||||
m := delivering_round(mv, i, last);
|
||||
if m >= 0 {
|
||||
col := i % BOARD_COLS;
|
||||
@@ -327,7 +320,7 @@ frame :: () {
|
||||
|
||||
main :: () -> void {
|
||||
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;
|
||||
if !u.init("m3te", 800, 600) { return; }
|
||||
g_plat = xx u;
|
||||
@@ -337,13 +330,13 @@ main :: () -> void {
|
||||
// return into UIApplicationMain, so attach lazily on the first frame.
|
||||
// init(null, 0, 0) only needs the MTLDevice, which is enough for the
|
||||
// texture uploads below.
|
||||
g_metal_gpu = xx context.allocator.alloc(size_of(MetalGPU));
|
||||
g_metal_gpu = xx context.allocator.alloc_bytes(size_of(MetalGPU));
|
||||
// alloc returns uninitialized memory; struct field defaults are NOT
|
||||
// applied, so List caps/lens would be garbage without this memset.
|
||||
memset(xx g_metal_gpu, 0, size_of(MetalGPU));
|
||||
if !g_metal_gpu.init(null, 0, 0) { return; }
|
||||
} else {
|
||||
s : *SdlPlatform = xx context.allocator.alloc(size_of(SdlPlatform));
|
||||
s : *SdlPlatform = xx context.allocator.alloc_bytes(size_of(SdlPlatform));
|
||||
if !s.init("m3te", 800, 600) { return; }
|
||||
g_plat = xx s;
|
||||
}
|
||||
@@ -353,7 +346,7 @@ main :: () -> void {
|
||||
g_viewport_h = fc.viewport_h;
|
||||
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
|
||||
// the desktop path (where set_gpu is not called) and the Lists start empty.
|
||||
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_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_assets = xx context.allocator.alloc(size_of(BoardAssets));
|
||||
g_assets = xx context.allocator.alloc_bytes(size_of(BoardAssets));
|
||||
g_assets.init();
|
||||
g_assets.load(g_pipeline.gpu);
|
||||
|
||||
g_sel = xx context.allocator.alloc(size_of(BoardSelection));
|
||||
g_sel = xx context.allocator.alloc_bytes(size_of(BoardSelection));
|
||||
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_anim = xx context.allocator.alloc(size_of(BoardAnim));
|
||||
g_anim = xx context.allocator.alloc_bytes(size_of(BoardAnim));
|
||||
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_fxassets = xx context.allocator.alloc(size_of(BoardFxAssets));
|
||||
g_fxassets = xx context.allocator.alloc_bytes(size_of(BoardFxAssets));
|
||||
g_fxassets.init();
|
||||
g_fxassets.load(g_pipeline.gpu);
|
||||
|
||||
g_motion = xx context.allocator.alloc(size_of(GemMotion));
|
||||
g_motion = xx context.allocator.alloc_bytes(size_of(GemMotion));
|
||||
g_motion.init();
|
||||
|
||||
// SFX (P10.2). Loads the System Sound Services cue bank once; board_view
|
||||
// plays a cue per event. Purely additive — never touches score/board/move
|
||||
// state. On iOS the platform has already chdir'd to the bundle, so each
|
||||
// cue's relative path resolves. No-op off iOS.
|
||||
g_audio = xx context.allocator.alloc(size_of(GameAudio));
|
||||
g_audio = xx context.allocator.alloc_bytes(size_of(GameAudio));
|
||||
memset(xx g_audio, 0, size_of(GameAudio));
|
||||
g_audio.init();
|
||||
|
||||
@@ -405,7 +398,7 @@ main :: () -> void {
|
||||
g_motion.clock = parse_f32(t);
|
||||
}
|
||||
if sc := read_env("M3TE_SELECT") {
|
||||
idx := parse_s64(sc);
|
||||
idx := parse_i64(sc);
|
||||
if idx >= 0 and idx < BOARD_CELLS {
|
||||
g_sel.active = true;
|
||||
g_sel.cell = Cell.{ col = idx % BOARD_COLS, row = idx / BOARD_COLS };
|
||||
@@ -418,7 +411,7 @@ main :: () -> void {
|
||||
// committed golden stay byte-identical. Purely a render overlay — no board /
|
||||
// score / move / animation state changes and it never gates input.
|
||||
if fp := read_env("M3TE_FPS") {
|
||||
if parse_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,
|
||||
@@ -433,7 +426,7 @@ main :: () -> void {
|
||||
if fx := read_env("M3TE_FX") {
|
||||
swaps := legal_swaps(g_board);
|
||||
if swaps.len > 0 {
|
||||
n := parse_s64(fx);
|
||||
n := parse_i64(fx);
|
||||
if n < 1 { n = 1; }
|
||||
if n > swaps.len { n = swaps.len; }
|
||||
sw := swaps.items[n - 1];
|
||||
@@ -457,7 +450,7 @@ main :: () -> void {
|
||||
if bs := read_env("M3TE_BADSWAP") {
|
||||
bad := illegal_swaps(g_board);
|
||||
if bad.len > 0 {
|
||||
n := parse_s64(bs);
|
||||
n := parse_i64(bs);
|
||||
if n < 1 { n = 1; }
|
||||
if n > bad.len { n = bad.len; }
|
||||
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_RESTART set non-zero the board is then restart()-ed, capturing the
|
||||
// fresh in_progress board the restart button produces.
|
||||
if tg := read_env("M3TE_TARGET") { g_board.target_score = parse_s64(tg); }
|
||||
if ml := read_env("M3TE_MOVE_LIMIT") { g_board.move_limit = parse_s64(ml); }
|
||||
if tg := read_env("M3TE_TARGET") { g_board.target_score = parse_i64(tg); }
|
||||
if ml := read_env("M3TE_MOVE_LIMIT") { g_board.move_limit = parse_i64(ml); }
|
||||
if rs := read_env("M3TE_RESTART") {
|
||||
if parse_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));
|
||||
|
||||
@@ -24,14 +24,14 @@
|
||||
SEED :: 1337;
|
||||
|
||||
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; }
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
main :: () -> s32 {
|
||||
fails : s64 = 0;
|
||||
main :: () -> i32 {
|
||||
fails : i64 = 0;
|
||||
|
||||
// ── 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
|
||||
@@ -61,7 +61,7 @@ main :: () -> s32 {
|
||||
|
||||
// move.final equals the model board.
|
||||
final_eq := true;
|
||||
for 0..BOARD_CELLS: (i) {
|
||||
for 0..BOARD_CELLS (i) {
|
||||
if !(move.final[i] == bm.cells[i]) { final_eq = false; }
|
||||
}
|
||||
if !final_eq { fails += 1; }
|
||||
@@ -74,21 +74,21 @@ main :: () -> s32 {
|
||||
ai := Board.idx(a.col, a.row);
|
||||
bi := Board.idx(b.col, b.row);
|
||||
r0 := @move.rounds.items[0];
|
||||
for 0..BOARD_CELLS: (i) {
|
||||
for 0..BOARD_CELLS (i) {
|
||||
expect : Gem = move.pre[i];
|
||||
if i == ai { expect = move.pre[bi]; }
|
||||
else if i == bi { expect = move.pre[ai]; }
|
||||
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];
|
||||
cur := @move.rounds.items[k];
|
||||
for 0..BOARD_CELLS: (i) {
|
||||
for 0..BOARD_CELLS (i) {
|
||||
if !(cur.before[i] == prev.after[i]) { contiguous = false; }
|
||||
}
|
||||
}
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
#import "modules/std.sx";
|
||||
t :: #import "test.sx";
|
||||
|
||||
main :: () -> s32 {
|
||||
main :: () -> i32 {
|
||||
t.expect(2 + 2 == 4, "two plus two is four");
|
||||
t.expect(7 % 3 == 1, "seven mod three is one");
|
||||
t.expect(10 - 4 == 6, "ten minus four is six");
|
||||
|
||||
@@ -13,16 +13,17 @@
|
||||
// shape and rationale as tests/hit_test.sx. Failure is signalled via a non-zero
|
||||
// exit code (the runner checks exit code AND stdout).
|
||||
#import "modules/std.sx";
|
||||
#import "modules/ui/types.sx";
|
||||
#import "board.sx";
|
||||
#import "board_layout.sx";
|
||||
|
||||
irect :: (f: Frame) -> string {
|
||||
format("({},{},{},{})",
|
||||
cast(s64) f.origin.x, cast(s64) f.origin.y,
|
||||
cast(s64) f.size.width, cast(s64) f.size.height)
|
||||
cast(i64) f.origin.x, cast(i64) f.origin.y,
|
||||
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
|
||||
// same layout tests/hit_test.sx pins, so the numbers are checkable by hand.
|
||||
lay : BoardLayout = ---;
|
||||
@@ -35,18 +36,18 @@ main :: () -> s32 {
|
||||
print("title {}\n", irect(bl.title));
|
||||
print("button {}\n", irect(bl.button));
|
||||
|
||||
fails : s64 = 0;
|
||||
fails : i64 = 0;
|
||||
|
||||
// The button is horizontally centered on the grid (centred banner).
|
||||
bcx := bl.button.mid_x();
|
||||
if cast(s64) bcx != cast(s64) grid.mid_x() { fails += 1; }
|
||||
print("button mid_x {} grid mid_x {}\n", cast(s64) bcx, cast(s64) grid.mid_x());
|
||||
if cast(i64) bcx != cast(i64) grid.mid_x() { fails += 1; }
|
||||
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,
|
||||
// so it can never spill outside the drawn card.
|
||||
bx0 := bl.button.origin.x; by0 := bl.button.origin.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 = bx1, y = by0 }) { 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.
|
||||
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() };
|
||||
off_hits : s64 = 0;
|
||||
off_hits : i64 = 0;
|
||||
if bl.button.contains(corner_cell) { off_hits += 1; }
|
||||
if bl.button.contains(outside) { off_hits += 1; }
|
||||
if off_hits != 0 { fails += 1; }
|
||||
|
||||
@@ -10,16 +10,16 @@ SEED :: 1337;
|
||||
// Count every horizontal or vertical window of three consecutive same-type
|
||||
// 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.
|
||||
count_three_runs :: (b: *Board) -> s32 {
|
||||
runs : s32 = 0;
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
for 0..(BOARD_COLS - 2): (col) {
|
||||
count_three_runs :: (b: *Board) -> i32 {
|
||||
runs : i32 = 0;
|
||||
for 0..BOARD_ROWS (row) {
|
||||
for 0..(BOARD_COLS - 2) (col) {
|
||||
g := b.at(col, row);
|
||||
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_COLS: (col) {
|
||||
for 0..(BOARD_ROWS - 2) (row) {
|
||||
for 0..BOARD_COLS (col) {
|
||||
g := b.at(col, row);
|
||||
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
|
||||
}
|
||||
|
||||
main :: () -> s32 {
|
||||
main :: () -> i32 {
|
||||
board : Board = ---;
|
||||
board.init(SEED);
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ EXPECTED_DEPTH :: 2;
|
||||
// board can be written as a human-readable grid. The hole glyph maps to `.empty`.
|
||||
char_to_gem :: (c: u8) -> Gem {
|
||||
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; }
|
||||
}
|
||||
.red
|
||||
@@ -34,9 +34,9 @@ char_to_gem :: (c: u8) -> Gem {
|
||||
// The RNG is left unseeded — callers seed it before resolving.
|
||||
load_board :: (rows: []string) -> Board {
|
||||
b : Board = ---;
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
for 0..BOARD_ROWS (row) {
|
||||
line := rows[row];
|
||||
for 0..BOARD_COLS: (col) {
|
||||
for 0..BOARD_COLS (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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ checker_board :: () -> Board {
|
||||
b
|
||||
}
|
||||
|
||||
main :: () -> s32 {
|
||||
main :: () -> i32 {
|
||||
print("== cascade (resolution loop) ==\n");
|
||||
|
||||
// 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));
|
||||
|
||||
depth := 0;
|
||||
counts := List(s64).{};
|
||||
counts := List(i64).{};
|
||||
while true {
|
||||
n := resolve_step(@b);
|
||||
if n == 0 { break; }
|
||||
@@ -120,7 +120,7 @@ main :: () -> s32 {
|
||||
t.expect(c.depth == depth, "cascade: resolve depth matches manual loop");
|
||||
same_counts := c.cleared.len == counts.len;
|
||||
if same_counts {
|
||||
for 0..counts.len: (i) {
|
||||
for 0..counts.len (i) {
|
||||
if c.cleared.items[i] != counts.items[i] { same_counts = false; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
#import "modules/std.sx";
|
||||
#import "audio.sx";
|
||||
|
||||
main :: () -> s32 {
|
||||
main :: () -> i32 {
|
||||
print("== cascade cue selection (depth -> combo cue) ==\n");
|
||||
|
||||
// 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
|
||||
// the last; 2,3,4 step up one cue at a time.
|
||||
prev : s64 = -1;
|
||||
for 0..10: (depth) {
|
||||
prev : i64 = -1;
|
||||
for 0..10 (depth) {
|
||||
idx := cascade_cue_index(depth);
|
||||
print("depth {} -> idx {} ({})\n", depth, idx, cascade_cue_name(idx));
|
||||
// The mapping must never step down as depth grows.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
#import "board_anim.sx";
|
||||
#import "audio.sx";
|
||||
|
||||
main :: () -> s32 {
|
||||
main :: () -> i32 {
|
||||
print("== per-round cascade cue timing ==\n");
|
||||
|
||||
// `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
|
||||
// each round window so the integer step is unambiguous. Locked for 5 rounds.
|
||||
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.10 -> {}\n", cascade_rounds_started(0.10, 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
|
||||
// locked acceptance ordering.
|
||||
print("-- ascending per-round run --\n");
|
||||
fired : s64 = 0;
|
||||
fired : i64 = 0;
|
||||
elapsed : f32 = 0.0;
|
||||
while fired < 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).
|
||||
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");
|
||||
return 0;
|
||||
|
||||
@@ -16,7 +16,7 @@ t :: #import "test.sx";
|
||||
// clear) for the holes-never-match regression.
|
||||
char_to_gem :: (c: u8) -> Gem {
|
||||
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; }
|
||||
}
|
||||
.red
|
||||
@@ -26,9 +26,9 @@ char_to_gem :: (c: u8) -> Gem {
|
||||
// characters).
|
||||
load_board :: (rows: []string) -> Board {
|
||||
b : Board = ---;
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
for 0..BOARD_ROWS (row) {
|
||||
line := rows[row];
|
||||
for 0..BOARD_COLS: (col) {
|
||||
for 0..BOARD_COLS (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
|
||||
// invariants against the matched-cell set: every flagged cell is now a hole,
|
||||
// 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);
|
||||
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
|
||||
others_intact := true; // every other cell is byte-identical
|
||||
for 0..BOARD_CELLS: (i) {
|
||||
for 0..BOARD_CELLS (i) {
|
||||
if m.cells[i] {
|
||||
if !(b.cells[i] == .empty) { cleared_holes = false; }
|
||||
} else {
|
||||
@@ -65,7 +65,7 @@ scene :: (name: string, rows: []string, want_cleared: s64) {
|
||||
t.expect(cleared == want_cleared, concat(name, ": cleared count exact"));
|
||||
}
|
||||
|
||||
main :: () -> s32 {
|
||||
main :: () -> i32 {
|
||||
print("== clear (detect -> clear) ==\n");
|
||||
|
||||
// Single horizontal 3-run (row 3, cols 2-4) → three holes there only.
|
||||
|
||||
@@ -18,7 +18,7 @@ t :: #import "test.sx";
|
||||
// maps to `.empty`, so a board can be hand-written with holes in any position.
|
||||
char_to_gem :: (c: u8) -> Gem {
|
||||
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; }
|
||||
}
|
||||
.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_board :: (rows: []string) -> Board {
|
||||
b : Board = ---;
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
for 0..BOARD_ROWS (row) {
|
||||
line := rows[row];
|
||||
for 0..BOARD_COLS: (col) {
|
||||
for 0..BOARD_COLS (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-
|
||||
// bottom, order-preservation, and the all-holes / no-holes edge columns at once.
|
||||
check_collapsed :: (orig: *Board, b: *Board) -> bool {
|
||||
for 0..BOARD_COLS: (col) {
|
||||
for 0..BOARD_COLS (col) {
|
||||
gems : [BOARD_ROWS]Gem = ---;
|
||||
n := 0;
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
for 0..BOARD_ROWS (row) {
|
||||
g := orig.at(col, row);
|
||||
if g != .empty { gems[n] = g; n += 1; }
|
||||
}
|
||||
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 b.at(col, row) != .empty { return false; }
|
||||
} else {
|
||||
@@ -79,7 +79,7 @@ scene :: (name: string, rows: []string, want_moved: bool) {
|
||||
t.expect(moved == want_moved, concat(name, ": moved flag exact"));
|
||||
}
|
||||
|
||||
main :: () -> s32 {
|
||||
main :: () -> i32 {
|
||||
print("== collapse (gravity) ==\n");
|
||||
|
||||
// Eight independent columns, one case each (top-to-bottom):
|
||||
|
||||
@@ -23,7 +23,7 @@ t :: #import "test.sx";
|
||||
// be written as a human-readable grid. The hole glyph maps to `.empty`.
|
||||
char_to_gem :: (c: u8) -> Gem {
|
||||
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; }
|
||||
}
|
||||
.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),
|
||||
// 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 = ---;
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
for 0..BOARD_ROWS (row) {
|
||||
line := rows[row];
|
||||
for 0..BOARD_COLS: (col) {
|
||||
for 0..BOARD_COLS (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
|
||||
// `Cascade.awarded` at the same depth. A depth-1 settle must equal the flat sum
|
||||
// (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);
|
||||
b := load_board(rows, seed);
|
||||
out(board_dump(@b));
|
||||
|
||||
flat : s64 = 0;
|
||||
mult : s64 = 0;
|
||||
depth : s64 = 0;
|
||||
flat : i64 = 0;
|
||||
mult : i64 = 0;
|
||||
depth : i64 = 0;
|
||||
while true {
|
||||
base := score_round(@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"));
|
||||
}
|
||||
|
||||
main :: () -> s32 {
|
||||
main :: () -> i32 {
|
||||
print("== combo (cascade multiplier) ==\n");
|
||||
|
||||
// Single-round clear (seed 0): one RRR clears and the refill makes no new
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
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 }
|
||||
|
||||
main :: () -> s32 {
|
||||
fails : s64 = 0;
|
||||
main :: () -> i32 {
|
||||
fails : i64 = 0;
|
||||
|
||||
// 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).
|
||||
@@ -51,7 +51,7 @@ main :: () -> s32 {
|
||||
p_io := ease_in_out_cubic(0.0);
|
||||
p_oc := ease_out_cubic(0.0);
|
||||
p_iq := ease_in_quad(0.0);
|
||||
for 1..21: (i) {
|
||||
for 1..21 (i) {
|
||||
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_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);
|
||||
spr_mx := spring(0.0); spr_mn := spring(0.0);
|
||||
spr_wobble := false;
|
||||
for 1..21: (i) {
|
||||
for 1..21 (i) {
|
||||
t := cast(f32) i / 20.0;
|
||||
b := ease_out_back(t);
|
||||
if b > back_mx { back_mx = b; }
|
||||
@@ -98,7 +98,7 @@ main :: () -> s32 {
|
||||
// squash (positive) and a stretch (negative) lobe, and stays bounded.
|
||||
print("== squash envelope bounded ==\n");
|
||||
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;
|
||||
s := squash_envelope(t);
|
||||
if s > sq_mx { sq_mx = s; }
|
||||
@@ -121,7 +121,7 @@ main :: () -> s32 {
|
||||
print("== illegal-swap bounce ==\n");
|
||||
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;
|
||||
for 0..101: (i) {
|
||||
for 0..101 (i) {
|
||||
t := cast(f32) i / 100.0;
|
||||
v := bad_swap_bounce(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.
|
||||
print("== fall stagger bounded ==\n");
|
||||
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(1.0, c) != 1.0 { stg_t1 = false; }
|
||||
}
|
||||
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; }
|
||||
}
|
||||
stg_mono := true;
|
||||
for 0..BOARD_COLS: (c) {
|
||||
for 0..BOARD_COLS (c) {
|
||||
pp := fall_stagger_t(0.0, c);
|
||||
for 1..21: (i) {
|
||||
for 1..21 (i) {
|
||||
tt := cast(f32) i / 20.0;
|
||||
vv := fall_stagger_t(tt, c);
|
||||
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_mono := 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; }
|
||||
lf := fall_landing_frac(c);
|
||||
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
|
||||
}
|
||||
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; }
|
||||
}
|
||||
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).
|
||||
print("== clear ripple bounded ==\n");
|
||||
rip_t0 := true; rip_t1 := true;
|
||||
for 0..6: (j) {
|
||||
for 0..6 (j) {
|
||||
u := cast(f32) j / 5.0;
|
||||
if clear_ripple_t(0.0, u) != 0.0 { rip_t0 = false; }
|
||||
if clear_ripple_t(1.0, u) != 1.0 { rip_t1 = false; }
|
||||
}
|
||||
rip_ripple := true;
|
||||
for 1..6: (j) {
|
||||
for 1..6 (j) {
|
||||
u := cast(f32) j / 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; }
|
||||
}
|
||||
rip_mono := true;
|
||||
for 0..6: (j) {
|
||||
for 0..6 (j) {
|
||||
u := cast(f32) j / 5.0;
|
||||
pp := clear_ripple_t(0.0, u);
|
||||
for 1..21: (i) {
|
||||
for 1..21 (i) {
|
||||
tt := cast(f32) i / 20.0;
|
||||
vv := clear_ripple_t(tt, u);
|
||||
if vv < pp - 0.000001 { rip_mono = false; }
|
||||
@@ -245,7 +245,7 @@ main :: () -> s32 {
|
||||
}
|
||||
}
|
||||
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, 1)] = true; // diagonal 6
|
||||
mm.cells[Board.idx(5, 2)] = true; // diagonal 7 — last to pop
|
||||
|
||||
1
tests/expected/swipe_reshuffle.exit
Normal file
1
tests/expected/swipe_reshuffle.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
23
tests/expected/swipe_reshuffle.stdout
Normal file
23
tests/expected/swipe_reshuffle.stdout
Normal 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
|
||||
@@ -13,15 +13,15 @@
|
||||
#import "modules/std.sx";
|
||||
#import "board_fx.sx";
|
||||
|
||||
main :: () -> s32 {
|
||||
main :: () -> i32 {
|
||||
print("== combo emphasis selection (depth -> fx level / popup font) ==\n");
|
||||
|
||||
// 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.
|
||||
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;
|
||||
for 0..10: (depth) {
|
||||
prev : i64 = -1;
|
||||
for 0..10 (depth) {
|
||||
lvl := fx_combo_level(depth);
|
||||
font := fx_popup_font(depth);
|
||||
combo := depth > 1;
|
||||
@@ -45,7 +45,7 @@ main :: () -> s32 {
|
||||
// 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; }
|
||||
pf : f32 = 0.0;
|
||||
for 2..10: (depth) {
|
||||
for 2..10 (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 depth > 2 and f < pf { print("FAIL: popup font shrank at depth {}\n", depth); return 1; }
|
||||
|
||||
@@ -18,14 +18,14 @@
|
||||
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 }
|
||||
|
||||
main :: () -> s32 {
|
||||
fails : s64 = 0;
|
||||
main :: () -> i32 {
|
||||
fails : i64 = 0;
|
||||
|
||||
// 1. t==0 idle pose is EXACTLY rest for every cell (the determinism invariant).
|
||||
print("== idle t=0 is rest for all cells ==\n");
|
||||
rest_ok := true;
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
for 0..BOARD_COLS: (col) {
|
||||
for 0..BOARD_ROWS (row) {
|
||||
for 0..BOARD_COLS (col) {
|
||||
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) {
|
||||
rest_ok = false;
|
||||
@@ -39,8 +39,8 @@ main :: () -> s32 {
|
||||
print("== idle mid-phase deforms, bounded ==\n");
|
||||
moved := false;
|
||||
bounded := true;
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
for 0..BOARD_COLS: (col) {
|
||||
for 0..BOARD_ROWS (row) {
|
||||
for 0..BOARD_COLS (col) {
|
||||
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.05 { bounded = false; }
|
||||
@@ -82,7 +82,7 @@ main :: () -> s32 {
|
||||
c_peak := clear_pop_scale(0.30) > 1.1;
|
||||
c_collapse := true;
|
||||
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;
|
||||
vv := clear_pop_scale(tt);
|
||||
if vv > pc + 0.000001 { c_collapse = false; }
|
||||
|
||||
@@ -10,24 +10,25 @@
|
||||
// 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).
|
||||
#import "modules/std.sx";
|
||||
#import "modules/ui/types.sx";
|
||||
#import "board.sx";
|
||||
#import "board_layout.sx";
|
||||
|
||||
main :: () -> s32 {
|
||||
main :: () -> i32 {
|
||||
// 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.
|
||||
lay : BoardLayout = ---;
|
||||
lay.compute(Frame.make(0.0, 0.0, 800.0, 600.0), EdgeInsets.zero());
|
||||
|
||||
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.
|
||||
hits : s64 = 0;
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
for 0..BOARD_COLS: (col) {
|
||||
hits : i64 = 0;
|
||||
for 0..BOARD_ROWS (row) {
|
||||
for 0..BOARD_COLS (col) {
|
||||
cf := lay.cell_frame(col, row);
|
||||
center := Point.{ x = cf.mid_x(), y = cf.mid_y() };
|
||||
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
|
||||
// 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_col : s64 = -1;
|
||||
corner_row : s64 = -1;
|
||||
corner_col : i64 = -1;
|
||||
corner_row : i64 = -1;
|
||||
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; }
|
||||
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_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 };
|
||||
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_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); }
|
||||
|
||||
@@ -25,7 +25,7 @@ RESHUFFLE_SEED :: 1337;
|
||||
// be written as a human-readable grid. The hole glyph maps to `.empty`.
|
||||
char_to_gem :: (c: u8) -> Gem {
|
||||
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; }
|
||||
}
|
||||
.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),
|
||||
// seeded RNG, running score zeroed, the turn counters reset to a fresh game, and
|
||||
// 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 = ---;
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
for 0..BOARD_ROWS (row) {
|
||||
line := rows[row];
|
||||
for 0..BOARD_COLS: (col) {
|
||||
for 0..BOARD_COLS (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 {
|
||||
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
|
||||
}
|
||||
|
||||
main :: () -> s32 {
|
||||
main :: () -> i32 {
|
||||
print("== level (turn / goal state machine) ==\n");
|
||||
|
||||
// ── Start: a fresh seeded board reads in_progress with the default goal ──
|
||||
|
||||
@@ -11,7 +11,7 @@ t :: #import "test.sx";
|
||||
// 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.
|
||||
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; }
|
||||
}
|
||||
.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
|
||||
// BOARD_COLS gem characters), detect matches, print board + matched dump, and
|
||||
// assert the matched-cell count.
|
||||
scene :: (name: string, rows: []string, want_count: s64) {
|
||||
scene :: (name: string, rows: []string, want_count: i64) {
|
||||
b : Board = ---;
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
for 0..BOARD_ROWS (row) {
|
||||
line := rows[row];
|
||||
for 0..BOARD_COLS: (col) {
|
||||
for 0..BOARD_COLS (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);
|
||||
}
|
||||
|
||||
main :: () -> s32 {
|
||||
main :: () -> i32 {
|
||||
// Single horizontal 3-run (row 3, cols 2-4).
|
||||
scene("horizontal-3", .[
|
||||
"OGOGOGOG",
|
||||
|
||||
@@ -22,7 +22,7 @@ SEED :: 1337;
|
||||
// board can be written as a human-readable grid. The hole glyph maps to `.empty`.
|
||||
char_to_gem :: (c: u8) -> Gem {
|
||||
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; }
|
||||
}
|
||||
.red
|
||||
@@ -32,23 +32,23 @@ char_to_gem :: (c: u8) -> Gem {
|
||||
// The RNG is left unseeded — callers seed it before drawing.
|
||||
load_board :: (rows: []string) -> Board {
|
||||
b : Board = ---;
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
for 0..BOARD_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
|
||||
}
|
||||
|
||||
count_empties :: (b: *Board) -> s64 {
|
||||
n : s64 = 0;
|
||||
for 0..BOARD_CELLS: (i) { if b.cells[i] == .empty { n += 1; } }
|
||||
count_empties :: (b: *Board) -> i64 {
|
||||
n : i64 = 0;
|
||||
for 0..BOARD_CELLS (i) { if b.cells[i] == .empty { n += 1; } }
|
||||
n
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ fresh_board :: () -> Board {
|
||||
b
|
||||
}
|
||||
|
||||
main :: () -> s32 {
|
||||
main :: () -> i32 {
|
||||
print("== refill (seeded) ==\n");
|
||||
|
||||
// Pipeline, snapshotting each stage.
|
||||
@@ -101,7 +101,7 @@ main :: () -> s32 {
|
||||
distinct := false;
|
||||
have_first := false;
|
||||
first : Gem = .empty;
|
||||
for 0..BOARD_CELLS: (i) {
|
||||
for 0..BOARD_CELLS (i) {
|
||||
if pre.cells[i] == .empty {
|
||||
want := cast(Gem) v.next_range(GEM_COUNT);
|
||||
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
|
||||
// fill, so the second fill draws new gems — proof it does NOT reseed per call.
|
||||
holes_n := 0;
|
||||
hole_idx : [BOARD_CELLS]s64 = ---;
|
||||
hole_idx : [BOARD_CELLS]i64 = ---;
|
||||
fill1 : [BOARD_CELLS]Gem = ---;
|
||||
for 0..BOARD_CELLS: (i) {
|
||||
for 0..BOARD_CELLS (i) {
|
||||
if pre.cells[i] == .empty {
|
||||
hole_idx[holes_n] = i;
|
||||
fill1[holes_n] = b.cells[i];
|
||||
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);
|
||||
differs := false;
|
||||
for 0..holes_n: (k) {
|
||||
for 0..holes_n (k) {
|
||||
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)");
|
||||
|
||||
@@ -20,7 +20,7 @@ t :: #import "test.sx";
|
||||
// be written as a human-readable grid. The hole glyph maps to `.empty`.
|
||||
char_to_gem :: (c: u8) -> Gem {
|
||||
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; }
|
||||
}
|
||||
.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.
|
||||
load_board :: (rows: []string) -> Board {
|
||||
b : Board = ---;
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
for 0..BOARD_ROWS (row) {
|
||||
line := rows[row];
|
||||
for 0..BOARD_COLS: (col) {
|
||||
for 0..BOARD_COLS (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_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);
|
||||
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"));
|
||||
}
|
||||
|
||||
main :: () -> s32 {
|
||||
main :: () -> i32 {
|
||||
print("== score (base match scoring) ==\n");
|
||||
|
||||
// Single length-3 horizontal run (row 3, cols 2-4) -> SCORE_RUN_3 = 30.
|
||||
|
||||
@@ -16,7 +16,7 @@ SEED :: 1337;
|
||||
// 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.
|
||||
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; }
|
||||
}
|
||||
.red
|
||||
@@ -26,9 +26,9 @@ char_to_gem :: (c: u8) -> Gem {
|
||||
// characters).
|
||||
load_board :: (rows: []string) -> Board {
|
||||
b : Board = ---;
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
for 0..BOARD_ROWS (row) {
|
||||
line := rows[row];
|
||||
for 0..BOARD_COLS: (col) {
|
||||
for 0..BOARD_COLS (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
|
||||
// board untouched.
|
||||
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; }
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
cell :: (col: s64, row: s64) -> Cell {
|
||||
cell :: (col: i64, row: i64) -> Cell {
|
||||
Cell.{ col = col, row = row }
|
||||
}
|
||||
|
||||
main :: () -> s32 {
|
||||
main :: () -> i32 {
|
||||
print("== swap & legality ==\n");
|
||||
|
||||
// Board whose ONLY swap-formable match is the adjacent (2,3)<->(3,3)
|
||||
|
||||
@@ -13,32 +13,33 @@
|
||||
// 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).
|
||||
#import "modules/std.sx";
|
||||
#import "modules/ui/types.sx";
|
||||
#import "board.sx";
|
||||
#import "board_layout.sx";
|
||||
#import "swipe.sx";
|
||||
|
||||
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);
|
||||
Point.{ x = cf.mid_x(), y = cf.mid_y() }
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
main :: () -> s32 {
|
||||
main :: () -> i32 {
|
||||
// 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.
|
||||
lay : BoardLayout = ---;
|
||||
lay.compute(Frame.make(0.0, 0.0, 800.0, 600.0), EdgeInsets.zero());
|
||||
D : f32 = 60.0;
|
||||
|
||||
fails : s64 = 0;
|
||||
fails : i64 = 0;
|
||||
|
||||
// ── ILLEGAL swipe reverts ──────────────────────────────────────────────
|
||||
// (0,0) and (1,0) are both red on the seed board, so swapping them forms no
|
||||
|
||||
@@ -9,18 +9,19 @@
|
||||
// clears it; a 10px drag does not. Failure is signalled via a non-zero exit code
|
||||
// (the runner checks exit code AND stdout).
|
||||
#import "modules/std.sx";
|
||||
#import "modules/ui/types.sx";
|
||||
#import "board.sx";
|
||||
#import "board_layout.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);
|
||||
Point.{ x = cf.mid_x(), y = cf.mid_y() }
|
||||
}
|
||||
|
||||
// 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.
|
||||
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 {
|
||||
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;
|
||||
@@ -38,14 +39,14 @@ expect_none :: (label: string, got: ?Swap) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
main :: () -> s32 {
|
||||
main :: () -> i32 {
|
||||
lay : BoardLayout = ---;
|
||||
lay.compute(Frame.make(0.0, 0.0, 800.0, 600.0), EdgeInsets.zero());
|
||||
print("grid origin ({},{}) cell {} threshold {}\n",
|
||||
cast(s64) lay.origin.x, cast(s64) lay.origin.y, cast(s64) lay.cell_size,
|
||||
cast(s64) (lay.cell_size * SWIPE_THRESHOLD_FRACTION));
|
||||
cast(i64) lay.origin.x, cast(i64) lay.origin.y, cast(i64) lay.cell_size,
|
||||
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.
|
||||
start := cell_center(@lay, 3, 5);
|
||||
|
||||
144
tests/swipe_reshuffle.sx
Normal file
144
tests/swipe_reshuffle.sx
Normal 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;
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
// terminates the process NON-ZERO (exit 1) via process.exit, so a broken
|
||||
// assertion fails `tools/run_tests.sh` and the build gate.
|
||||
#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) {
|
||||
if !cond {
|
||||
|
||||
@@ -28,7 +28,7 @@ LIMIT :: 5;
|
||||
// be written as a human-readable grid. The hole glyph maps to `.empty`.
|
||||
char_to_gem :: (c: u8) -> Gem {
|
||||
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; }
|
||||
}
|
||||
.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),
|
||||
// seeded RNG, running score zeroed, and the turn counters reset to a fresh game
|
||||
// (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 = ---;
|
||||
for 0..BOARD_ROWS: (row) {
|
||||
for 0..BOARD_ROWS (row) {
|
||||
line := rows[row];
|
||||
for 0..BOARD_COLS: (col) {
|
||||
for 0..BOARD_COLS (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 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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);
|
||||
b := load_board(rows, 0, LIMIT);
|
||||
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"));
|
||||
}
|
||||
|
||||
main :: () -> s32 {
|
||||
main :: () -> i32 {
|
||||
print("== turn (accounting + special-match flagging) ==\n");
|
||||
|
||||
// ── Special-match flagging (single round, no RNG) ──────────────────────
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
// never hits this — its loops run over 64 board cells, not millions of pixels.
|
||||
#import "modules/std.sx";
|
||||
#import "modules/math";
|
||||
#import "modules/stb.sx";
|
||||
#import "vendors/stb_image/stb_image.sx";
|
||||
|
||||
SRC_PATH :: "/Users/agra/Downloads/m3te_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
|
||||
|
||||
is_gray :: (r: u8, g: u8, b: u8) -> bool {
|
||||
hi := max(max(cast(s64) r, cast(s64) g), cast(s64) b);
|
||||
lo := min(min(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(i64) r, cast(i64) g), cast(i64) b);
|
||||
hi - lo <= GRAY_TOL
|
||||
}
|
||||
|
||||
// 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).
|
||||
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; }
|
||||
p := i * 4;
|
||||
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 {
|
||||
w : s32 = 0;
|
||||
h : s32 = 0;
|
||||
ch : s32 = 0;
|
||||
main :: () -> i32 {
|
||||
w : i32 = 0;
|
||||
h : i32 = 0;
|
||||
ch : i32 = 0;
|
||||
src : [*]u8 = xx stbi_load(SRC_PATH, @w, @h, @ch, 4);
|
||||
if xx src == 0 {
|
||||
print("FATAL: could not load {}\n", SRC_PATH);
|
||||
return 1;
|
||||
}
|
||||
W := cast(s64) w;
|
||||
H := cast(s64) h;
|
||||
W := cast(i64) w;
|
||||
H := cast(i64) h;
|
||||
N := W * H;
|
||||
print("loaded {}x{} ({} src channels)\n", w, h, ch);
|
||||
|
||||
// Hoisted working locals (see codegen note above).
|
||||
y : s64 = 0;
|
||||
x : s64 = 0;
|
||||
i : s64 = 0;
|
||||
p : s64 = 0;
|
||||
r : s64 = 0;
|
||||
g : s64 = 0;
|
||||
b : s64 = 0;
|
||||
l : s64 = 0;
|
||||
y : i64 = 0;
|
||||
x : i64 = 0;
|
||||
i : i64 = 0;
|
||||
p : i64 = 0;
|
||||
r : i64 = 0;
|
||||
g : i64 = 0;
|
||||
b : i64 = 0;
|
||||
l : i64 = 0;
|
||||
|
||||
// Per-pixel luminance, plus the checker shades read off the border ring
|
||||
// (the border is pure checker — the glow never reaches the corners).
|
||||
lum : [*]s64 = xx context.allocator.alloc(N * size_of(s64));
|
||||
c_lo : s64 = 255;
|
||||
c_hi : s64 = 0;
|
||||
lum : [*]i64 = xx context.allocator.alloc_bytes(N * size_of(i64));
|
||||
c_lo : i64 = 255;
|
||||
c_hi : i64 = 0;
|
||||
y = 0;
|
||||
while y < H {
|
||||
x = 0;
|
||||
@@ -102,10 +102,10 @@ main :: () -> s32 {
|
||||
|
||||
// 8-connected flood fill of the edge-connected checker, seeded from every
|
||||
// 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);
|
||||
stack : [*]s64 = xx context.allocator.alloc(N * size_of(s64));
|
||||
sp : s64 = 0;
|
||||
stack : [*]i64 = xx context.allocator.alloc_bytes(N * size_of(i64));
|
||||
sp : i64 = 0;
|
||||
checker_lim := c_hi + LUM_MARGIN;
|
||||
|
||||
x = 0;
|
||||
@@ -121,12 +121,12 @@ main :: () -> s32 {
|
||||
y += 1;
|
||||
}
|
||||
|
||||
cx : s64 = 0;
|
||||
cy : s64 = 0;
|
||||
dx : s64 = 0;
|
||||
dy : s64 = 0;
|
||||
nx : s64 = 0;
|
||||
ny : s64 = 0;
|
||||
cx : i64 = 0;
|
||||
cy : i64 = 0;
|
||||
dx : i64 = 0;
|
||||
dy : i64 = 0;
|
||||
nx : i64 = 0;
|
||||
ny : i64 = 0;
|
||||
while sp > 0 {
|
||||
sp -= 1;
|
||||
i = stack[sp];
|
||||
@@ -153,9 +153,9 @@ main :: () -> s32 {
|
||||
// checker shade up to pure white, giving the glow its smooth falloff.
|
||||
denom := cast(f32) (255 - c_hi);
|
||||
if denom < 1.0 { denom = 1.0; }
|
||||
alpha : [*]f32 = xx context.allocator.alloc(N * size_of(f32));
|
||||
kept : s64 = 0;
|
||||
n_bg : s64 = 0;
|
||||
alpha : [*]f32 = xx context.allocator.alloc_bytes(N * size_of(f32));
|
||||
kept : i64 = 0;
|
||||
n_bg : i64 = 0;
|
||||
a : f32 = 0.0;
|
||||
i = 0;
|
||||
while i < N {
|
||||
@@ -174,30 +174,30 @@ main :: () -> s32 {
|
||||
|
||||
// Area-averaged downscale to OUT_DIM. RGB stays white; only the averaged
|
||||
// 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;
|
||||
syf := cast(f32) H / cast(f32) OUT_DIM;
|
||||
max_a : f32 = 0.0;
|
||||
ty : s64 = 0;
|
||||
tx : s64 = 0;
|
||||
x0 : s64 = 0;
|
||||
x1 : s64 = 0;
|
||||
y0 : s64 = 0;
|
||||
y1 : s64 = 0;
|
||||
ty : i64 = 0;
|
||||
tx : i64 = 0;
|
||||
x0 : i64 = 0;
|
||||
x1 : i64 = 0;
|
||||
y0 : i64 = 0;
|
||||
y1 : i64 = 0;
|
||||
sum : f32 = 0.0;
|
||||
cnt : s64 = 0;
|
||||
sy : s64 = 0;
|
||||
sx : s64 = 0;
|
||||
cnt : i64 = 0;
|
||||
sy : i64 = 0;
|
||||
sx : i64 = 0;
|
||||
av : f32 = 0.0;
|
||||
o : s64 = 0;
|
||||
o : i64 = 0;
|
||||
ty = 0;
|
||||
while ty < OUT_DIM {
|
||||
tx = 0;
|
||||
while tx < OUT_DIM {
|
||||
x0 = cast(s64) (cast(f32) tx * sxf);
|
||||
x1 = cast(s64) (cast(f32) (tx + 1) * sxf);
|
||||
y0 = cast(s64) (cast(f32) ty * syf);
|
||||
y1 = cast(s64) (cast(f32) (ty + 1) * syf);
|
||||
x0 = cast(i64) (cast(f32) tx * sxf);
|
||||
x1 = cast(i64) (cast(f32) (tx + 1) * sxf);
|
||||
y0 = cast(i64) (cast(f32) ty * syf);
|
||||
y1 = cast(i64) (cast(f32) (ty + 1) * syf);
|
||||
if x1 <= x0 { x1 = x0 + 1; }
|
||||
if y1 <= y0 { y1 = y0 + 1; }
|
||||
sum = 0.0;
|
||||
|
||||
55
vendors/file_utils/file_utils.c
vendored
55
vendors/file_utils/file_utils.c
vendored
@@ -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;
|
||||
}
|
||||
13
vendors/file_utils/file_utils.h
vendored
13
vendors/file_utils/file_utils.h
vendored
@@ -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
|
||||
19
vendors/kb_text_shape/kb/LICENSE
vendored
19
vendors/kb_text_shape/kb/LICENSE
vendored
@@ -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.
|
||||
30737
vendors/kb_text_shape/kb/kb_text_shape.h
vendored
30737
vendors/kb_text_shape/kb/kb_text_shape.h
vendored
File diff suppressed because it is too large
Load Diff
2
vendors/kb_text_shape/kb_text_shape_impl.c
vendored
2
vendors/kb_text_shape/kb_text_shape_impl.c
vendored
@@ -1,2 +0,0 @@
|
||||
#define KB_TEXT_SHAPE_IMPLEMENTATION
|
||||
#include "kb/kb_text_shape.h"
|
||||
15
vendors/kb_text_shape/kbts_api.h
vendored
15
vendors/kb_text_shape/kbts_api.h
vendored
@@ -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);
|
||||
7988
vendors/stb_image/stb_image.h
vendored
7988
vendors/stb_image/stb_image.h
vendored
File diff suppressed because it is too large
Load Diff
2
vendors/stb_image/stb_image_impl.c
vendored
2
vendors/stb_image/stb_image_impl.c
vendored
@@ -1,2 +0,0 @@
|
||||
#define STB_IMAGE_IMPLEMENTATION
|
||||
#include "stb_image.h"
|
||||
1724
vendors/stb_image/stb_image_write.h
vendored
1724
vendors/stb_image/stb_image_write.h
vendored
File diff suppressed because it is too large
Load Diff
2
vendors/stb_image/stb_image_write_impl.c
vendored
2
vendors/stb_image/stb_image_write_impl.c
vendored
@@ -1,2 +0,0 @@
|
||||
#define STB_IMAGE_WRITE_IMPLEMENTATION
|
||||
#include "stb_image_write.h"
|
||||
5079
vendors/stb_truetype/stb_truetype.h
vendored
5079
vendors/stb_truetype/stb_truetype.h
vendored
File diff suppressed because it is too large
Load Diff
2
vendors/stb_truetype/stb_truetype_impl.c
vendored
2
vendors/stb_truetype/stb_truetype_impl.c
vendored
@@ -1,2 +0,0 @@
|
||||
#define STB_TRUETYPE_IMPLEMENTATION
|
||||
#include "stb_truetype.h"
|
||||
Reference in New Issue
Block a user