7 Commits
33 changed files with 472 additions and 80 deletions
+75
View File
@@ -0,0 +1,75 @@
# AGENTS.md — vela-snake
Guidance for AI agents working on this repo. Humans can read it too.
## What this is
Textured Snake for the Xiaomi Watch S4 (round 466px screen, Vela quickapp).
Package `com.ilias.snake`. Source of truth for releases:
https://gitea.oimcloud.myaddr.tools/oiwmiodev/vela-snake (Big Intelligence group).
Upstream codex-bot/vela-snake is read-only for this account (no write permission).
## Hard rules
- Never commit `sign/*.pem` (gitignored on purpose). Builds currently use the
toolkit's default debug keys; if real keys are added, keep them out of git.
- Never edit files under `node_modules/`. The toolkit is locked at aiot-toolkit
2.0.5; do not upgrade it casually (release uses plain JS, not JSC/protobuf).
- Tests must pass before any commit: `npm test` (pure Node, no watch needed).
- Bump `versionName`/`versionCode` in `src/manifest.json` for any release.
Hotfixes: `0.9-Hotfix.1` style, versionCode still increments (integer only).
- No em dashes in docs, commits or comments.
## Crown control (the fragile part)
The Vela firmware does NOT expose crown events to quickapps (verified by
firmware string analysis, see docs/CROWN.md). Crown rotation arrives only as
scroll deltas on the focused scroll container. The page is a full-screen
vertical `scroll` with the dial centered; `onscroll` converts accumulated
deltas into queued 90-degree turns (`CROWN_STEP_PX`, 40px per turn) and snaps
the scroller back to center (`scrollTo({top: 233})`).
Known failure mode (issue oiwmiodev/vela-snake#1): the game can still scroll
away, likely because momentum scrolling outlives the snap-back or onShow's
scrollTo runs before layout. Any fix must keep: swipe steering working, the
dial visually pinned, reversals impossible. If scroll-delta extraction proves
unreliable on hardware, the fallback is accepting codex-bot issue #2's
conclusion (swipe-only input) and removing the scroll wrapper.
Crown work happens on the `crown-experimental` branch; `main` tracks the
released state.
## Layout invariants
- Dial/board: 466px screen, board at 49px offset, 368px circle, 32px cells,
11x11 grid, 89 legal cells (circular mask, see src/common/snake.js ARENA).
- Scroll content height = 932px (233px padding each side), center offset 233.
- Overlays fit the watch chord; no legal cell is ever covered by HUD/footer.
- No CSS transforms anywhere (tests assert this); tiles are positioned with
left/top only.
## Testing
`npm test` runs snake logic, piece classification, page wiring (including the
crown scroll path), upgrade regressions, numeric layout, randomized property
tests and art checks. When fixing a bug, add a failing test first, then fix.
Node tests are not device tests: behavior on the actual watch is the only
final proof, and whoever has the watch does that testing.
## Release checklist
1. All tests green.
2. `src/manifest.json` version bumped.
3. `npm run release`, verify dist/com.ilias.snake.release.<ver>.rpk exists.
4. Commit + push to origin (oiwmiodev/vela-snake).
5. Create the release with `tea releases create --repo oiwmiodev/vela-snake
--tag v<ver>` and attach the rpk with `tea releases assets create`.
Download the asset back and byte-compare before claiming success.
6. Release notes: real changelog, never "see commit history".
## Git
Git identity is not globally configured; commits must set the repo persona
(the "big pc republic" identity used across this Gitea, see prior commits)
via `git -c user.name=... -c user.email=...` or local config. Pushes go to
origin (the org repo). Auth uses the `oimcloud` tea login.
+49 -22
View File
@@ -1,12 +1,25 @@
# SNAKE — Xiaomi Watch S4 # SNAKE — Xiaomi Watch S4
Textured Snake on a 16×16 grid: 20px tiles, 320px board, centered on a round Textured Snake on a **368px circular arena**, centered on the round 466px
466px screen. Existing PNG board, directional snake pieces, apple and icon are screen. **32px tiles** (60% larger than 0.7) occupy 89 legal cells of an 11×11
preserved. Package identity: `com.ilias.snake`. bounding grid; every tile corner stays inside the actual circle. The square
board frame is gone. Directional sprite artwork and user icon are preserved. Package identity: `com.ilias.snake`.
**Version 0.7 (versionCode 7): locally tested and release-built; the new features **Version 0.9 (versionCode 9): crown control experiment, unit-tested, needs an
still need an on-watch check.** Earlier versions rendered and played on the on-watch check.** See docs/CROWN.md for the firmware reasoning.
user's Watch S4 41mm. Node tests and numeric layout checks are not device tests.
## Crown control (new in 0.9)
- The watch firmware exposes **no crown event to quickapps**; it routes crown
rotation into the scrollable container as scroll deltas. The page is now a
full-screen vertical scroll with the dial centered; `onscroll` fires for both
crown rotation and finger scroll.
- Scroll deltas accumulate; every **40 px of scroll = one 90° turn**,
clockwise = clockwise turn. Reversals are structurally impossible because
turns are relative 90° steps (`rotateDir`-style), never absolute directions.
- The scroller is snapped back to center after every event, so the board never
visibly moves, even though the scroll travel exists.
- Swipes still work exactly as before; both inputs share the two-slot queue.
## Play ## Play
@@ -15,17 +28,22 @@ user's Watch S4 41mm. Node tests and numeric layout checks are not device tests.
queued direction. Excess inputs are ignored, not allowed to replace a turn. queued direction. Excess inputs are ignored, not allowed to replace a turn.
- **Tap the board, snake or apple** to pause. Tap **RESUME** (or an exposed part - **Tap the board, snake or apple** to pause. Tap **RESUME** (or an exposed part
of the board) to resume. Pausing clears queued turns. of the board) to resume. Pausing clears queued turns.
- In the pause or game-over panel, select **CLASSIC** (walls end the run) or - In the pause or game-over panel, select **CLASSIC** (the circular boundary ends the run) or
**WRAP** (cross an edge to appear on the opposite edge). Changing mode starts **WRAP** (cross the circular boundary to appear at the opposite legal end
of the **same row or column**). Changing mode starts
a fresh, paused game; tap RESUME to begin. Selecting the current mode does not a fresh, paused game; tap RESUME to begin. Selecting the current mode does not
reset the run. The current mode is shown below the board. reset the run. The current mode is shown below the board.
- Both modes end on self-collision. Moving into the vacating tail is legal. - Both modes end on self-collision. Moving into the vacating tail is legal.
- **PLAY AGAIN** is a large 216×56px retry target. Retry keeps the selected mode, - **PLAY AGAIN** is a large 216×56px retry target. Retry keeps the selected mode,
resets score/speed/queued turns, and starts playing. Filling the board displays resets score/speed/queued turns, and starts playing. Filling the board displays
**YOU WIN** and hides the apple safely. **YOU WIN** at 89 occupied cells (86 points) and hides the apple safely.
- Leaving the page pauses it and stops the timer. Returning requires an explicit - Leaving the page pauses it and stops the timer. Returning requires an explicit
resume; game-over never resumes automatically. The system back action remains resume; game-over never resumes automatically. The system back action remains
untouched. No crown, rotary, side-button or back interception APIs are used. untouched. No crown, rotary, side-button or back interception APIs are used.
- **Crown to steer** (0.9): rotate the crown; every 40px of scroll is one
90° turn, clockwise = clockwise. Crown and swipe feed the same queue; the
board stays visually fixed while you turn. If your firmware gives the crown
coarser steps than 40px, adjust `CROWN_STEP_PX` at the top of `index.ux`.
## Score, speed and persistence ## Score, speed and persistence
@@ -41,18 +59,24 @@ paused, finished or destroyed. Old timer callbacks are invalidated.
Storage reads merge with the in-memory maximum; writes wait for the initial Storage reads merge with the in-memory maximum; writes wait for the initial
read and are serialized/coalesced so slow callbacks cannot overwrite a newer read and are serialized/coalesced so slow callbacks cannot overwrite a newer
record. The coordinator survives page recreation within the module lifetime. record. The coordinator survives page recreation within the module lifetime.
Invalid saved values are ignored. Errors/exceptions do not interrupt gameplay: Old 0.7 records through 253 remain valid and are never reduced to the new
86-point maximum. Invalid saved values are ignored. Errors/exceptions do not interrupt gameplay:
failed reads/writes retry on later apples or page-show activity, without a retry failed reads/writes retry on later apples or page-show activity, without a retry
loop. A failed/uncompleted write or immediate app termination may lose the loop. A failed/uncompleted write or immediate app termination may lose the
unsaved record. Storage persistence still needs verification on the watch. unsaved record. Storage persistence still needs verification on the watch.
## Lightweight rendering ## Lightweight rendering
Directional PNG tiles use only `left`/`top` positioning, no transforms or new Directional PNG tiles use only `left`/`top` positioning, no transforms. Existing
art assets. Wrapped neighbors are normalized before classifying seam textures. 20px sprite art is nearest-neighbor enlarged to native 32px assets (crisp pixel
Food placement takes at most 256 cell checks, including a nearly full board. art, not new high-resolution detail). The board is a newly generated circular
The HUD, footer, board and overlay fit the circular chord numerically with PNG with transparent corners and a subtle legal-cell checker pattern. Wrapped
at least an 8px total horizontal safety allowance. texture connections handle each varying row/column length. All rows/columns
have at least three cells, avoiding ambiguous one-/two-cell seams.
Food scans only 89 legal cells, including a nearly full board. HUD/footer
rectangles never overlap legal play tiles; text/overlay fit the watch chord.
`tests/preview-0.8-static.png` is a static composition of actual geometry/art,
with a legal-cell overlay—not a runtime screenshot.
## Build and test ## Build and test
@@ -62,13 +86,15 @@ npm test
npm run release npm run release
``` ```
`npm test` runs the original core, piece and page tests, 13 incremental upgrade `npm test` runs the original core, piece and page tests, 14 upgrade
regressions, the numeric layout suite, and deterministic randomized property tests. Upgrade coverage includes rapid regressions, the numeric layout suite, and deterministic randomized property tests. Upgrade coverage includes rapid
swipes, lifecycle/timer races, storage failures and async ordering, mode changes, swipes, lifecycle/timer races, storage failures and async ordering, mode changes,
all four Wrap edges, seam textures, full-board victory and bounded food spawning. all four Wrap edges, seam textures, full-board victory and bounded food spawning.
The page suite also drives the crown scroll path: one notch = one queued turn,
scroller re-centered, queue capped at two, no reversal, turn consumed on tick.
New behaviors were exercised through failing-then-passing test cycles. New behaviors were exercised through failing-then-passing test cycles.
Release output: `dist/com.ilias.snake.release.0.7.rpk`. The locked Release output: `dist/com.ilias.snake.release.0.9.rpk`. The locked
`aiot-toolkit` 2.0.5 uses the existing `sign/private.pem` and `sign/certificate.pem`; `aiot-toolkit` 2.0.5 uses the existing `sign/private.pem` and `sign/certificate.pem`;
do not regenerate or commit those keys. Release uses plain JS (not JSC/protobuf), do not regenerate or commit those keys. Release uses plain JS (not JSC/protobuf),
matching the previously working toolchain. The dependency lock is unchanged; matching the previously working toolchain. The dependency lock is unchanged;
@@ -77,15 +103,15 @@ matching the previously working toolchain. The dependency lock is unchanged;
## Files ## Files
- `src/pages/index/index.ux`: round-screen UI, swipe queue, game/lifecycle state. - `src/pages/index/index.ux`: round-screen UI, swipe/crown queue, game/lifecycle state.
- `src/common/snake.js`: movement, collision, Wrap piece classification, food. - `src/common/snake.js`: movement, collision, Wrap piece classification, food.
- `src/common/best.js`: asynchronous storage coordinator. - `src/common/best.js`: asynchronous storage coordinator.
- `src/common/tex/`: preserved textured artwork. - `src/common/tex/`: native 32px sprite artwork and circular board.
- `tests/{snake,pieces,page,upgrade,layout,properties}.cjs`: Node regression suites. - `docs/CROWN.md`: firmware-derived rationale for the crown scroll path.
- `tests/{snake,pieces,page,upgrade,layout,properties,round,art,round-history}.cjs`: Node regression suites, including history-based circular seam orientation checks.
Install the signed RPK through AstroBox. Source/releases live at Install the signed RPK through AstroBox. Source/releases live at
https://gitea.oimcloud.myaddr.tools/codex-bot/vela-snake — publication is a https://gitea.oimcloud.myaddr.tools/oiwmiodev/vela-snake (Big Intelligence group).
separate step after review; a local build is not evidence of a published release.
## Earlier versions ## Earlier versions
@@ -93,3 +119,4 @@ separate step after review; a local build is not evidence of a published release
- 0.6: directional textured tiles and preserved user-supplied icon. - 0.6: directional textured tiles and preserved user-supplied icon.
- 0.7: queued swipes, live/persisted records, capped acceleration, pause/resume, - 0.7: queued swipes, live/persisted records, capped acceleration, pause/resume,
lifecycle safety, retry/win panels and selectable Classic/Wrap. lifecycle safety, retry/win panels and selectable Classic/Wrap.
- 0.8: circular arena (no square frame), 32px tiles, 89 legal cells.
+34
View File
@@ -0,0 +1,34 @@
# Crown control experiment (v0.9)
Firmware study of the O63 OTA (`upd_miwear.watch.o63.zip`) found how the crown
reaches quickapps: there is **no crown/rotary JS event** (no `onCrown`,
`onRotary`, wheel keyevents in `vela_quickapp.bin`), but the native layer
(`vela_ap.bin`, `inputmanager/lv_touchpad_wheel_interface.c` + the
`crown_scroll` input tag in `lvx_inputmanager.c`) routes crown rotation into
**scroll deltas of the focused scrollable container**.
So Snake listens to the crown indirectly:
- the page is wrapped in a full-screen vertical `scroll` component with the
dial centered inside it,
- `onscroll` fires for both crown rotation and finger scroll,
- the page converts each scroll delta into a **queued 90° turn**
(`snake.rotateDir`, already in the codebase): positive delta =
clockwise, negative = counterclockwise, `CROWN_STEP_PX` px of scroll per
turn,
- because turns are 90° relative steps, the crown always turns the snake
left/right regardless of where the head is pointing, and reversals are
structurally impossible (rotateDir never emits a 180° flip),
- the scroll position is actively re-centered (`scrollTo({ top: center })`)
after each event so the board never visually moves; only the idle-swipe
fallback path (when no scroll events arrive, e.g. some firmwares coalesce
crown scroll with the swipe handler) uses `onswipe` as before,
- swipes still work exactly as in 0.8; both input paths feed the same
two-slot turn queue.
Open questions that need the physical watch:
- does the Crown S4 emit scroll deltas with sign, and at what px granularity?
- does `onscroll` fire for crown motion at all on this firmware, or only for
touch scroll? If not, the `CROWN_STEP_PX` accumulator still catches the
swipe-path fallback but true crown control would need native access.
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true, "private": true,
"description": "Textured Snake for the round Xiaomi Watch S4 screen", "description": "Textured Snake for the round Xiaomi Watch S4 screen",
"scripts": { "scripts": {
"test": "node tests/snake.cjs && node tests/pieces.cjs && node tests/page.cjs && node --test tests/upgrade.cjs && node tests/layout.cjs && node tests/properties.cjs", "test": "node tests/snake.cjs && node tests/pieces.cjs && node tests/page.cjs && node --test tests/upgrade.cjs && node tests/layout.cjs && node tests/properties.cjs && node tests/round.cjs && node tests/art.cjs && node tests/round-history.cjs",
"build": "aiot build", "build": "aiot build",
"release": "aiot release", "release": "aiot release",
"build:source-map": "aiot build --devtool=source-map", "build:source-map": "aiot build --devtool=source-map",
+42 -6
View File
@@ -1,4 +1,18 @@
// Snake core — pure functions, no Vela APIs. Exports: initialSnake, step, turn, spawnFood. // Shared geometry: 32px tiles wholly inside the 368px circular arena.
const ARENA = { cell: 32, size: 11, offset: 57, radius: 184, cells: [], rows: [], columns: [] }
for (let y = 0; y < ARENA.size; y++) {
ARENA.rows[y] = []
ARENA.columns[y] = []
}
for (let y = 0; y < ARENA.size; y++) for (let x = 0; x < ARENA.size; x++) {
const dx = Math.abs((x + 0.5) * ARENA.cell - 176) + ARENA.cell / 2
const dy = Math.abs((y + 0.5) * ARENA.cell - 176) + ARENA.cell / 2
if (dx * dx + dy * dy <= ARENA.radius * ARENA.radius) {
ARENA.cells.push({ x, y })
ARENA.rows[y].push(x)
ARENA.columns[x].push(y)
}
}
function initialSnake() { function initialSnake() {
return [ return [
{ x: 6, y: 7 }, { x: 6, y: 7 },
@@ -12,13 +26,22 @@ function step(snake, dir, food, N, mode) {
const head = snake[0] const head = snake[0]
let nx = head.x + dir.x let nx = head.x + dir.x
let ny = head.y + dir.y let ny = head.y + dir.y
if (mode === 'wrap') { if (typeof N === 'object') {
const row = N.rows[ny]
if (!row || row.indexOf(nx) === -1) {
if (mode !== 'wrap') return { snake, ate: false, alive: false }
const span = dir.x ? N.rows[head.y] : N.columns[head.x]
const opposite = dir.x + dir.y > 0 ? span[0] : span[span.length - 1]
if (dir.x) nx = opposite
else ny = opposite
}
} else if (mode === 'wrap') {
nx = (nx + N) % N nx = (nx + N) % N
ny = (ny + N) % N ny = (ny + N) % N
} else if (nx < 0 || ny < 0 || nx >= N || ny >= N) { } else if (nx < 0 || ny < 0 || nx >= N || ny >= N) {
return { snake, ate: false, alive: false } return { snake, ate: false, alive: false }
} }
const ate = (nx === food.x && ny === food.y) const ate = !!food && (nx === food.x && ny === food.y)
// tail cell vacates unless we grew, so exclude it from self-collision check // tail cell vacates unless we grew, so exclude it from self-collision check
const body = ate ? snake : snake.slice(0, snake.length - 1) const body = ate ? snake : snake.slice(0, snake.length - 1)
for (let i = 0; i < body.length; i++) { for (let i = 0; i < body.length; i++) {
@@ -44,13 +67,22 @@ function renderPieces(snakeCells, dir, N) {
const D = { '1,0': 'e', '-1,0': 'w', '0,-1': 'n', '0,1': 's' } const D = { '1,0': 'e', '-1,0': 'w', '0,-1': 'n', '0,1': 's' }
const opp = { e: 'w', w: 'e', n: 's', s: 'n' } const opp = { e: 'w', w: 'e', n: 's', s: 'n' }
const delta = d => N && d === N - 1 ? -1 : N && d === 1 - N ? 1 : d const delta = d => N && d === N - 1 ? -1 : N && d === 1 - N ? 1 : d
// Circular seams join the endpoints of this row/column, not the bounding grid.
const side = (s, neighbor) => {
let dx = neighbor.x - s.x, dy = neighbor.y - s.y
if (N && typeof N === 'object') {
if (Math.abs(dx) > 1) dx = dx > 0 ? -1 : 1
if (Math.abs(dy) > 1) dy = dy > 0 ? -1 : 1
} else { dx = delta(dx); dy = delta(dy) }
return D[dx + ',' + dy]
}
return snakeCells.map((s, i) => { return snakeCells.map((s, i) => {
if (i === 0) return 'head_' + D[dir.x + ',' + dir.y] if (i === 0) return 'head_' + D[dir.x + ',' + dir.y]
const prev = snakeCells[i - 1] const prev = snakeCells[i - 1]
const next = snakeCells[i + 1] const next = snakeCells[i + 1]
const from = D[delta(prev.x - s.x) + ',' + delta(prev.y - s.y)] // side toward head const from = side(s, prev) // side toward head
if (!next) return 'tail_' + opp[from] // tip points away from body if (!next) return 'tail_' + opp[from] // tip points away from body
const to = D[delta(next.x - s.x) + ',' + delta(next.y - s.y)] // side toward tail tip const to = side(s, next) // side toward tail tip
if ((from === 'e' || from === 'w') && (to === 'e' || to === 'w')) return 'body_h' if ((from === 'e' || from === 'w') && (to === 'e' || to === 'w')) return 'body_h'
if ((from === 'n' || from === 's') && (to === 'n' || to === 's')) return 'body_v' if ((from === 'n' || from === 's') && (to === 'n' || to === 's')) return 'body_v'
// corner: name = the two sides it connects; pairs are alphabetically sorted // corner: name = the two sides it connects; pairs are alphabetically sorted
@@ -75,6 +107,10 @@ function rotateDir(dir, clockwise) {
// Random empty cell, avoiding the snake. // Random empty cell, avoiding the snake.
function spawnFood(snake, N) { function spawnFood(snake, N) {
const occupied = new Set(snake.map(s => s.x + ',' + s.y)) const occupied = new Set(snake.map(s => s.x + ',' + s.y))
if (typeof N === 'object') {
const empty = N.cells.filter(c => !occupied.has(c.x + ',' + c.y))
return empty.length ? empty[Math.floor(Math.random() * empty.length)] : null
}
if (occupied.size >= N * N) return null if (occupied.size >= N * N) return null
// Select an empty-cell rank uniformly; at most N*N checks, even almost full. // Select an empty-cell rank uniformly; at most N*N checks, even almost full.
let rank = Math.floor(Math.random() * (N * N - occupied.size)) let rank = Math.floor(Math.random() * (N * N - occupied.size))
@@ -89,4 +125,4 @@ function spawnFood(snake, N) {
return null return null
} }
module.exports = { initialSnake, step, turn, renderPieces, rotateDir, spawnFood } module.exports = { ARENA, initialSnake, step, turn, renderPieces, rotateDir, spawnFood }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 902 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 672 B

After

Width:  |  Height:  |  Size: 791 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 694 B

After

Width:  |  Height:  |  Size: 839 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 727 B

After

Width:  |  Height:  |  Size: 873 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 B

After

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 B

After

Width:  |  Height:  |  Size: 383 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 B

After

Width:  |  Height:  |  Size: 276 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 346 B

After

Width:  |  Height:  |  Size: 397 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 841 B

After

Width:  |  Height:  |  Size: 955 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 918 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 913 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 731 B

After

Width:  |  Height:  |  Size: 878 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 644 B

After

Width:  |  Height:  |  Size: 768 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 794 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 673 B

After

Width:  |  Height:  |  Size: 790 B

+2 -2
View File
@@ -2,8 +2,8 @@
"package": "com.ilias.snake", "package": "com.ilias.snake",
"name": "SNAKE", "name": "SNAKE",
"icon": "/common/icon.png", "icon": "/common/icon.png",
"versionName": "0.7", "versionName": "0.9",
"versionCode": 7, "versionCode": 9,
"minAPILevel": 1, "minAPILevel": 1,
"deviceTypeList": [ "deviceTypeList": [
"watch" "watch"
+64 -20
View File
@@ -1,6 +1,7 @@
<template> <template>
<div class="page" onswipe="onSwipe"> <div class="page">
<stack class="dial"> <scroll id="scroller" class="page" scroll-y="true" direction="vertical" onscroll="onCrownScroll">
<stack class="dial">
<image class="board" src="/common/tex/board.png" onclick="onTap"></image> <image class="board" src="/common/tex/board.png" onclick="onTap"></image>
<image class="food" src="/common/tex/apple.png" show="{{ hasFood }}" onclick="onTap" style="left: {{ food.left }}; top: {{ food.top }};"></image> <image class="food" src="/common/tex/apple.png" show="{{ hasFood }}" onclick="onTap" style="left: {{ food.left }}; top: {{ food.top }};"></image>
<image class="seg" src="{{ seg.src }}" for="{{ seg in snake }}" onclick="onTap" style="left: {{ seg.left }}; top: {{ seg.top }};"></image> <image class="seg" src="{{ seg.src }}" for="{{ seg in snake }}" onclick="onTap" style="left: {{ seg.left }}; top: {{ seg.top }};"></image>
@@ -10,7 +11,7 @@
</div> </div>
<div class="footer"> <div class="footer">
<text class="modeLabel">{{ modeText }}</text> <text class="modeLabel">{{ modeText }}</text>
<text class="hint">swipe / tap to pause</text> <text class="hint">swipe / tap pause</text>
</div> </div>
<div class="overlay" show="{{ paused && !over }}"> <div class="overlay" show="{{ paused && !over }}">
<text class="overText">PAUSED</text> <text class="overText">PAUSED</text>
@@ -32,7 +33,8 @@
<text class="modeButton" onclick="selectMode('wrap')">WRAP</text> <text class="modeButton" onclick="selectMode('wrap')">WRAP</text>
</div> </div>
</div> </div>
</stack> </stack>
</scroll>
</div> </div>
</template> </template>
<script> <script>
@@ -40,14 +42,25 @@ import snake from '../../common/snake.js'
import storage from '@system.storage' import storage from '@system.storage'
import best from '../../common/best.js' import best from '../../common/best.js'
const CELL = 20 const CELL = snake.ARENA.cell
const GRID = 16 const GRID = snake.ARENA
const OFFSET = 73 const OFFSET = snake.ARENA.offset
// Crown rotation arrives as scroll deltas on the focused scroll container
// (see docs/CROWN.md). This many px of accumulated scroll = one 90° turn.
const CROWN_STEP_PX = 40
const CW_ORDER = [{ x: 1, y: 0 }, { x: 0, y: 1 }, { x: -1, y: 0 }, { x: 0, y: -1 }]
function rotate90(d, clockwise) {
let i = 0
for (let k = 0; k < 4; k++) {
if (CW_ORDER[k].x === d.x && CW_ORDER[k].y === d.y) { i = k; break }
}
return clockwise ? CW_ORDER[(i + 1) % 4] : CW_ORDER[(i + 3) % 4]
}
export default { export default {
private: { private: {
snake: [], snake: [],
food: { left: '73px', top: '73px' }, food: { left: '57px', top: '57px' },
hasFood: true, hasFood: true,
endText: 'GAME OVER', endText: 'GAME OVER',
scoreText: 'SCORE 0', scoreText: 'SCORE 0',
@@ -68,6 +81,8 @@ export default {
onShow() { onShow() {
if (this.destroyed) return if (this.destroyed) return
this.visible = true this.visible = true
this.crownAnchor = 233
try { this.$element('scroller').scrollTo({ top: 233 }) } catch (err) {}
this.records.sync() this.records.sync()
this.startTimer() this.startTimer()
}, },
@@ -157,6 +172,31 @@ export default {
if (snake.turn(last, v) === last) return if (snake.turn(last, v) === last) return
this.queue.push(v) this.queue.push(v)
}, },
// Crown rotation: firmware routes it into this container's scroll offset
// (docs/CROWN.md). Accumulate deltas, one 90° turn per CROWN_STEP_PX, then
// re-center so the board never visibly moves.
onCrownScroll(e) {
if (this.destroyed) return
const top = e && typeof e.scrollTop === 'number' ? e.scrollTop : 0
const center = 233
const d = top - (this.crownAnchor == null ? center : this.crownAnchor)
this.crownAnchor = top
if (d === 0) return
// keep the dial pinned: snap back to center immediately
try { this.$element('scroller').scrollTo({ top: center }) } catch (err) {}
this.crownAnchor = center
if (this.over || this.paused || !this.visible) return
this.crownAcc = (this.crownAcc || 0) + d
while (Math.abs(this.crownAcc) >= CROWN_STEP_PX) {
const clockwise = this.crownAcc > 0
this.crownAcc += clockwise ? -CROWN_STEP_PX : CROWN_STEP_PX
const base = this.queue.length ? this.queue[this.queue.length - 1] : this.dir
if (this.queue.length >= 2) return
const v = rotate90(base, clockwise)
if (v.x === base.x && v.y === base.y) return
this.queue.push(v)
}
},
onTap() { onTap() {
if (this.destroyed || !this.visible || this.over) return if (this.destroyed || !this.visible || this.over) return
this.paused = !this.paused this.paused = !this.paused
@@ -190,46 +230,50 @@ export default {
background-color: #000000; background-color: #000000;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
/* scrollable travel: dial centered in 932px content, 233px of crown travel
each way; onReady + onscroll snap back to the center offset (233) */
padding-top: 233px;
padding-bottom: 233px;
} }
.dial { .dial {
width: 466px; height: 466px; width: 466px; height: 466px;
} }
.board { .board {
position: absolute; left: 73px; top: 73px; position: absolute; left: 49px; top: 49px;
width: 320px; height: 320px; width: 368px; height: 368px;
} }
.seg { .seg {
position: absolute; position: absolute;
width: 20px; height: 20px; width: 32px; height: 32px;
} }
.food { .food {
position: absolute; position: absolute;
width: 20px; height: 20px; width: 32px; height: 32px;
} }
.score { .score {
width: 188px; height: 22px; width: 152px; height: 20px;
color: #d8edce; font-size: 18px; text-align: center; color: #d8edce; font-size: 18px; text-align: center;
} }
.best { .best {
width: 188px; height: 22px; width: 152px; height: 20px;
color: #f3bd60; font-size: 17px; text-align: center; color: #f3bd60; font-size: 17px; text-align: center;
} }
.hud { .hud {
position: absolute; left: 139px; top: 24px; position: absolute; left: 157px; top: 16px;
width: 188px; height: 44px; width: 152px; height: 40px;
flex-direction: column; align-items: center; flex-direction: column; align-items: center;
} }
.footer { .footer {
position: absolute; left: 135px; top: 400px; position: absolute; left: 143px; top: 410px;
width: 196px; height: 40px; width: 180px; height: 36px;
flex-direction: column; align-items: center; flex-direction: column; align-items: center;
} }
.modeLabel { .modeLabel {
width: 196px; height: 20px; width: 180px; height: 18px;
color: #cee4bd; font-size: 17px; text-align: center; color: #cee4bd; font-size: 17px; text-align: center;
} }
.hint { .hint {
width: 196px; height: 20px; width: 180px; height: 18px;
color: #adb7c6; font-size: 16px; text-align: center; color: #adb7c6; font-size: 16px; text-align: center;
} }
.overlay { .overlay {
+8
View File
@@ -0,0 +1,8 @@
const assert=require('node:assert/strict'), fs=require('node:fs'), zlib=require('node:zlib')
const a=require('../src/common/snake.js').ARENA
for(const name of fs.readdirSync('src/common/tex').filter(n=>n.endsWith('.png'))) {
const b=fs.readFileSync('src/common/tex/'+name)
const size=name==='board.png'?a.radius*2:a.cell
assert.equal(b.readUInt32BE(16),size,name+' width');assert.equal(b.readUInt32BE(20),size,name+' height')
}
console.log('NATIVE ART DIMENSIONS PASS')
+14 -1
View File
@@ -8,7 +8,20 @@ function rule(name) {
return Object.fromEntries(m[1].split(';').filter(s=>s.includes(':')).map(s=>s.trim().split(/:\s*/))) return Object.fromEntries(m[1].split(';').filter(s=>s.includes(':')).map(s=>s.trim().split(/:\s*/)))
} }
function px(r,k) { assert.match(r[k] || '', /^\d+px$/, k); return Number(r[k].slice(0,-2)) } function px(r,k) { assert.match(r[k] || '', /^\d+px$/, k); return Number(r[k].slice(0,-2)) }
for (const name of ['board','hud','footer','overlay']) { const arena = require('../src/common/snake.js').ARENA
assert.equal(px(rule('seg'),'width'),arena.cell)
assert.equal(px(rule('seg'),'height'),arena.cell)
assert.equal(px(rule('food'),'width'),arena.cell)
const board=rule('board')
assert.equal(px(board,'width'),arena.radius*2)
assert.equal(px(board,'height'),arena.radius*2)
assert.equal(px(board,'left')+arena.radius,233)
assert.equal(px(board,'top')+arena.radius,233)
for(const c of arena.cells) for(const name of ['hud','footer']) {
const r=rule(name), x=arena.offset+c.x*arena.cell,y=arena.offset+c.y*arena.cell
assert.ok(x+arena.cell<=px(r,'left') || x>=px(r,'left')+px(r,'width') || y+arena.cell<=px(r,'top') || y>=px(r,'top')+px(r,'height'), 'legal cell never overlaps '+name)
}
for (const name of ['hud','footer','overlay']) {
const r = rule(name), x=px(r,'left'), y=px(r,'top'), w=px(r,'width'), h=px(r,'height') const r = rule(name), x=px(r,'left'), y=px(r,'top'), w=px(r,'width'), h=px(r,'height')
const dy = Math.max(Math.abs(y-233), Math.abs(y+h-233)) const dy = Math.max(Math.abs(y-233), Math.abs(y+h-233))
const chord = 2*Math.sqrt(233*233-dy*dy) const chord = 2*Math.sqrt(233*233-dy*dy)
+64
View File
@@ -0,0 +1,64 @@
"""Create circular board and static preview from the real JS geometry and PNG art.
Preview is a composited illustration, NOT a Vela runtime screenshot.
Existing sprite art is nearest-neighbor enlarged to native 32px, not blurred.
"""
import json
from pathlib import Path
import subprocess
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
a = json.loads(subprocess.check_output(['node', '-p', "JSON.stringify(require('./src/common/snake.js').ARENA)"], cwd=ROOT))
tex = ROOT / 'src/common/tex'
for p in tex.glob('*.png'):
if p.name != 'board.png':
img = Image.open(p).convert('RGBA')
if img.size != (a['cell'], a['cell']):
img.resize((a['cell'], a['cell']), Image.Resampling.NEAREST).save(p)
S=4
size=a['radius']*2
board=Image.new('RGBA',(size*S,size*S),(0,0,0,0))
d=ImageDraw.Draw(board)
d.ellipse((0,0,size*S-1,size*S-1),fill='#14251e',outline='#36513c',width=2*S)
local=a['offset']-(233-a['radius'])
for c in a['cells']:
x=(local+c['x']*a['cell'])*S;y=(local+c['y']*a['cell'])*S
d.rectangle((x,y,x+a['cell']*S-1,y+a['cell']*S-1),fill=('#1d3227' if (c['x']+c['y'])%2 else '#20362a'))
board=board.resize((size,size),Image.Resampling.LANCZOS)
board.save(tex/'board.png')
# Read actual CSS text placement/colors instead of inventing a separate layout.
import re
ux=(ROOT/'src/pages/index/index.ux').read_text()
def rule(name):
body=re.search(r'\.'+name+r'\s*\{([^}]+)\}',ux)[1]
return dict(re.findall(r'([\w-]+):\s*([^;]+)',body))
def px(r,key): return int(r[key].removesuffix('px'))
font=subprocess.check_output(['fc-match','-f','%{file}','sans'],text=True)
canvas=Image.new('RGB',(980,526),'#10131a')
for panel,mask in enumerate([False,True]):
watch=Image.new('RGBA',(466,466),'black');watch.alpha_composite(board,(233-a['radius'],233-a['radius']))
wd=ImageDraw.Draw(watch)
if mask:
for c in a['cells']:
x=a['offset']+c['x']*a['cell'];y=a['offset']+c['y']*a['cell']
wd.rectangle((x,y,x+a['cell']-1,y+a['cell']-1),outline='#71967a')
cells=[{'x':7,'y':4},{'x':6,'y':4},{'x':5,'y':4},{'x':5,'y':5},{'x':4,'y':5},{'x':3,'y':5}]
names=json.loads(subprocess.check_output(['node','-p',f"JSON.stringify(require('./src/common/snake.js').renderPieces({json.dumps(cells)},{{x:1,y:0}}))"],cwd=ROOT))
for c,name in zip(cells,names):
watch.alpha_composite(Image.open(tex/(name+'.png')),(a['offset']+c['x']*a['cell'],a['offset']+c['y']*a['cell']))
watch.alpha_composite(Image.open(tex/'apple.png'),(a['offset']+8*a['cell'],a['offset']+6*a['cell']))
for parent,entries in [('hud',[('score','SCORE 3'),('best','BEST 253')]),('footer',[('modeLabel','CLASSIC'),('hint','swipe / tap pause')])]:
pr=rule(parent); y=px(pr,'top')
for cls,text in entries:
r=rule(cls); f=ImageFont.truetype(font,px(r,'font-size'))
wd.text((233,y+px(r,'height')/2),text,font=f,fill=r['color'],anchor='mm')
y+=px(r,'height')
circle=Image.new('L',(466,466));ImageDraw.Draw(circle).ellipse((0,0,465,465),fill=255)
canvas.paste(watch.convert('RGB'),(12+panel*490,12),circle)
cd=ImageDraw.Draw(canvas)
cd.text((20,490),'STATIC ART / GEOMETRY COMPOSITE — NOT WATCH RUNTIME',fill='white',font=ImageFont.truetype(font,16))
cd.text((510,490),'Legal-cell overlay: 89 cells, 32px tiles',fill='white',font=ImageFont.truetype(font,16))
canvas.save(ROOT/'tests/preview-0.8-static.png')
print(ROOT/'tests/preview-0.8-static.png')
# Alpha corners prove the replacement board is circular, not a square frame.
assert all(board.getpixel(p)[3]==0 for p in [(0,0),(size-1,0),(0,size-1),(size-1,size-1)])
print('Circular PNG alpha corners verified; native 32px sprites generated.')
+29 -11
View File
@@ -7,13 +7,13 @@ const snake = require('../src/common/snake.js');
const ux = fs.readFileSync('src/pages/index/index.ux', 'utf8'); const ux = fs.readFileSync('src/pages/index/index.ux', 'utf8');
const body = ux.match(/<script>([\s\S]*?)<\/script>/)[1] const body = ux.match(/<script>([\s\S]*?)<\/script>/)[1]
.replace(/import\s+\w+\s+from\s+'[^']*';?/g, '') .replace(/import\s+\w+\s+from\s+'[^']*';?/g, '')
.replace(/^const\s+\w+\s*=[^\n]*\n/gm, '') .replace('export default', 'globalThis.spec =');
.replace('export default', '');
const sandbox = { best: require('../src/common/best.js'), storage: {get() {}, set() {}}, snake, Date, Math, String, Number, console, setInterval: () => 0, clearInterval: () => {}, const sandbox = { best: require('../src/common/best.js'), storage: {get() {}, set() {}}, snake, Date, Math, String, Number, console, setInterval: () => 0, clearInterval: () => {},
CELL: 20, GRID: 16, OFFSET: 73 }; };
vm.createContext(sandbox); vm.createContext(sandbox);
const spec = vm.runInContext('(' + body + ')', sandbox); vm.runInContext(body, sandbox);
const spec = sandbox.spec;
function mk() { function mk() {
const o = Object.assign({}, spec, JSON.parse(JSON.stringify(spec.private))); const o = Object.assign({}, spec, JSON.parse(JSON.stringify(spec.private)));
@@ -29,10 +29,10 @@ assert.match(c.snake[0].src, /head_e\.png$/, 'head texture');
assert.match(c.snake[1].src, /body_h\.png$/, 'body texture'); assert.match(c.snake[1].src, /body_h\.png$/, 'body texture');
assert.match(c.snake[2].src, /tail_w\.png$/, 'tail texture'); assert.match(c.snake[2].src, /tail_w\.png$/, 'tail texture');
assert.equal(c.scoreText, 'SCORE 0'); assert.equal(c.scoreText, 'SCORE 0');
assert.equal(c.snake[0].left, (73 + 6 * 20) + 'px', 'head x mapped to px'); assert.equal(c.snake[0].left, (snake.ARENA.offset + 6 * snake.ARENA.cell) + 'px', 'head x mapped to px');
for (let i = 0; i < 5; i++) c.tick(); for (let i = 0; i < 1; i++) c.tick();
assert.equal(c.model[0].x, 11, 'head moved right 5 steps from x=6'); assert.equal(c.model[0].x, 7, 'head moved right one step from x=6');
assert.equal(c.over, false); assert.equal(c.over, false);
c.onSwipe({ direction: 'down' }); c.onSwipe({ direction: 'down' });
@@ -47,8 +47,8 @@ c.tick();
assert.equal(c.dir.x, 0, 'reversal keeps x'); assert.equal(c.dir.x, 0, 'reversal keeps x');
assert.equal(c.dir.y, 1, 'reversal blocked'); assert.equal(c.dir.y, 1, 'reversal blocked');
// run into the wall to trigger game over (GRID=16, last column x=15) // Hit the actual circular boundary.
c.model = [{ x: 15, y: 5 }, { x: 14, y: 5 }, { x: 13, y: 5 }]; c.model = [{ x: 10, y: 5 }, { x: 9, y: 5 }, { x: 8, y: 5 }];
c.dir = { x: 1, y: 0 }; c.dir = { x: 1, y: 0 };
c.pending = null; c.pending = null;
c.tick(); c.tick();
@@ -64,8 +64,26 @@ assert.match(ux, /<image class="board" src="\/common\/tex\/board\.png"[^>]*>/);
assert.match(ux, /<image class="seg" src="\{\{ seg\.src \}\}"/); assert.match(ux, /<image class="seg" src="\{\{ seg\.src \}\}"/);
assert.match(ux, /for="\{\{ seg in snake \}\}"/); assert.match(ux, /for="\{\{ seg in snake \}\}"/);
assert.match(ux, /show="\{\{ over \}\}"/); assert.match(ux, /show="\{\{ over \}\}"/);
assert.match(ux, /onswipe="onSwipe"/); assert.match(ux, /onscroll="onCrownScroll"/, 'scroll container wired for crown deltas');
assert.match(ux, /<scroll id="scroller"/, 'scroller element present for re-centering');
assert.doesNotMatch(ux, /crownrotationchanged/i); assert.doesNotMatch(ux, /crownrotationchanged/i);
assert.doesNotMatch(ux, /transform\s*:/); assert.doesNotMatch(ux, /transform\s*:/);
console.log('PAGE WIRING PASS (textures, movement, turn, reversal, game over, restart, directives)'); // crown path: scroll deltas become queued 90° turns, board stays centered
c = mk();
c.visible = true;
c.crownAnchor = 233;
c.onCrownScroll({ scrollTop: 233 + 40 }); // one notch "down"
c.onCrownScroll({ scrollTop: 233 });
assert.equal(c.queue.length, 1, 'one turn queued after one crown notch');
assert.equal(c.crownAnchor, 233, 'scroller re-centered');
assert.equal(JSON.stringify(c.queue[0]), JSON.stringify({ x: 0, y: 1 }), 'right-facing snake turned clockwise = down');
c.onCrownScroll({ scrollTop: 233 - 120 }); // three notches back
assert.equal(c.queue.length, 2, 'queue capped at 2');
assert.equal(c.dir.x, 1, 'no reversal emitted, x unchanged');
assert.equal(c.dir.y, 0, 'no reversal emitted, y unchanged');
c.onCrownScroll({ scrollTop: 233 });
c.tick();
assert.equal(JSON.stringify(c.dir), JSON.stringify({ x: 0, y: 1 }), 'first queued turn consumed');
console.log('PAGE WIRING PASS (textures, movement, turn, reversal, game over, restart, directives, crown scroll)');
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+5 -5
View File
@@ -5,19 +5,19 @@ let seed=83971
const rand=()=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return seed/4294967296} const rand=()=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return seed/4294967296}
const dirs=[{x:1,y:0},{x:0,y:1},{x:-1,y:0},{x:0,y:-1}] const dirs=[{x:1,y:0},{x:0,y:1},{x:-1,y:0},{x:0,y:-1}]
let moves=0, seams=0 let moves=0, seams=0
for(const mode of ['classic','wrap']) for(let game=0;game<1000;game++) { for(const board of [16,snake.ARENA]) for(const mode of ['classic','wrap']) for(let game=0;game<1000;game++) {
let cells=snake.initialSnake(),dir=dirs[0] let cells=snake.initialSnake(),dir=dirs[0]
for(let tick=0;tick<100;tick++) { for(let tick=0;tick<100;tick++) {
dir=snake.turn(dir,dirs[Math.floor(rand()*4)]) dir=snake.turn(dir,dirs[Math.floor(rand()*4)])
const h=cells[0] const h=cells[0]
const food=rand()<0.3?{x:(h.x+dir.x+16)%16,y:(h.y+dir.y+16)%16}:{x:0,y:0} const food=rand()<0.3?snake.step([h],dir,null,board,'wrap').snake[0]:null
const r=snake.step(cells,dir,food,16,mode) const r=snake.step(cells,dir,food,board,mode)
if(!r.alive)break if(!r.alive)break
if(Math.abs(r.snake[0].x-h.x)>1||Math.abs(r.snake[0].y-h.y)>1)seams++ if(Math.abs(r.snake[0].x-h.x)>1||Math.abs(r.snake[0].y-h.y)>1)seams++
cells=r.snake cells=r.snake
assert.equal(new Set(cells.map(c=>c.x+','+c.y)).size,cells.length) assert.equal(new Set(cells.map(c=>c.x+','+c.y)).size,cells.length)
for(const c of cells)assert.ok(c.x>=0&&c.x<16&&c.y>=0&&c.y<16) for(const c of cells)assert.ok(board===16 ? c.x>=0&&c.x<16&&c.y>=0&&c.y<16 : snake.ARENA.rows[c.y].includes(c.x))
for(const name of snake.renderPieces(cells,dir,16))assert.ok(fs.existsSync(require('node:path').join(__dirname,'../src/common/tex',name+'.png')),name) for(const name of snake.renderPieces(cells,dir,board))assert.ok(fs.existsSync(require('node:path').join(__dirname,'../src/common/tex',name+'.png')),name)
moves++ moves++
} }
} }
+30
View File
@@ -0,0 +1,30 @@
const assert=require('node:assert/strict')
const c=require('../src/common/snake.js'),a=c.ARENA
let seed=408;const random=()=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return seed/4294967296};Math.random=random
const dirs=[{x:1,y:0},{x:0,y:1},{x:-1,y:0},{x:0,y:-1}],names=['e','s','w','n'],opp={e:'w',w:'e',n:'s',s:'n'}
const side=d=>names[dirs.findIndex(v=>v.x===d.x&&v.y===d.y)]
let moves=0,seams=0
for(const mode of ['classic','wrap'])for(let game=0;game<1000;game++){
let model=c.initialSnake(),arrivals=model.map(()=>dirs[0]),dir=dirs[0],food=c.spawnFood(model,a)
for(let tick=0;tick<250;tick++){
dir=c.turn(dir,dirs[Math.floor(random()*4)])
const result=c.step(model,dir,food,a,mode)
if(!result.alive)break
if(Math.abs(result.snake[0].x-model[0].x)+Math.abs(result.snake[0].y-model[0].y)>1)seams++
model=result.snake;arrivals.unshift(dir);if(!result.ate)arrivals.pop()
assert.equal(new Set(model.map(p=>p.x+','+p.y)).size,model.length)
for(const p of model)assert.ok(a.rows[p.y].includes(p.x))
const expected=model.map((p,i)=>{
if(i===0)return 'head_'+side(dir)
const from=side(arrivals[i-1]);if(i===model.length-1)return 'tail_'+opp[from]
const to=opp[side(arrivals[i])]
if((from==='e'||from==='w')&&(to==='e'||to==='w'))return 'body_h'
if((from==='n'||from==='s')&&(to==='n'||to==='s'))return 'body_v'
return 'corner_'+[from,to].sort().join('')
})
assert.deepEqual(c.renderPieces(model,dir,mode==='wrap'?a:0),expected)
moves++
if(result.ate){food=c.spawnFood(model,a);if(!food)break}
}
}
console.log(JSON.stringify({roundMovesChecked:moves,wrapSeamsChecked:seams,historyBasedTextureClassification:'passed'}))
+38
View File
@@ -0,0 +1,38 @@
const assert = require('node:assert/strict')
const snake = require('../src/common/snake.js')
assert.ok(snake.ARENA, 'shared circular arena exists')
const a = snake.ARENA
assert.equal(a.cell,32); assert.equal(a.radius,184); assert.equal(a.offset,57)
assert.equal(a.cells.length,89)
const widths=[]
for(let y=0;y<a.size;y++) {
const row=a.cells.filter(c=>c.y===y); widths.push(row.length)
for(const c of row) {
const dx=Math.abs(a.offset+(c.x+.5)*a.cell-233)+a.cell/2
const dy=Math.abs(a.offset+(c.y+.5)*a.cell-233)+a.cell/2
assert.ok(dx*dx+dy*dy<=a.radius*a.radius,'whole tile inside circle')
}
}
assert.deepEqual(widths,[3,7,9,9,11,11,11,9,9,7,3])
assert.ok(snake.initialSnake().every(c=>a.cells.some(p=>p.x===c.x&&p.y===c.y)))
// Exhaust every varying row/column boundary, including shortest rows.
for(const horizontal of [true,false]) for(let line=0;line<a.size;line++) {
const span=horizontal?a.rows[line]:a.columns[line]
for(const sign of [-1,1]) {
const edge=sign===1?span[span.length-1]:span[0], opposite=sign===1?span[0]:span[span.length-1]
const c=v=>horizontal?{x:v,y:line}:{x:line,y:v}
const dir=horizontal?{x:sign,y:0}:{x:0,y:sign}
const cells=[c(edge),c(edge-sign),c(edge-sign*2)]
assert.equal(snake.step(cells,dir,null,a,'classic').alive,false)
const r=snake.step(cells,dir,null,a,'wrap')
assert.equal(r.alive,true); assert.deepEqual(r.snake[0],c(opposite))
assert.deepEqual(snake.renderPieces(r.snake,dir,a),[horizontal?(sign===1?'head_e':'head_w'):(sign===1?'head_s':'head_n'),horizontal?'body_h':'body_v',horizontal?(sign===1?'tail_w':'tail_e'):(sign===1?'tail_n':'tail_s')])
}
}
const original=Math.random
try {
Math.random=()=>0
for(const empty of a.cells) assert.deepEqual(snake.spawnFood(a.cells.filter(c=>c!==empty),a),empty)
assert.equal(snake.spawnFood(a.cells,a),null)
} finally {Math.random=original}
console.log('ROUND ARENA GEOMETRY / MOVEMENT / FOOD / SEAMS PASS')
+17 -12
View File
@@ -40,13 +40,12 @@ test('retired timer callbacks cannot advance a resumed or restarted run', () =>
}) })
test('filling the board wins without null food crashes and retry clears the result', () => { test('filling the board wins without null food crashes and retry clears the result', () => {
const {p, timers} = page() const {p, timers} = page()
// A serpentine Hamilton path, head (14,0), final food (15,0). // Legal occupancy fixture isolates final-cell victory from pathfinding.
const path = [] p.model = [{x:5,y:0}, ...snake.ARENA.cells.filter(c=>!(c.y===0 && (c.x===5 || c.x===6)))]
for (let y=0;y<16;y++) for (let k=0;k<16;k++) path.push({x:y%2 ? k : 15-k,y}) p.dir = {x:1,y:0}; p.foodModel = {x:6,y:0}
p.model = path.slice(1); p.dir = {x:1,y:0}; p.foodModel = {x:15,y:0}
p.tick(); assert.equal(p.over, true); assert.equal(p.endText, 'YOU WIN') p.tick(); assert.equal(p.over, true); assert.equal(p.endText, 'YOU WIN')
assert.equal(p.scoreText, 'SCORE 253'); assert.equal(p.bestText, 'BEST 253') assert.equal(p.scoreText, 'SCORE 86'); assert.equal(p.bestText, 'BEST 86')
assert.equal(p.hasFood, false); assert.equal(p.snake.length, 256); assert.equal(timers.size, 0) assert.equal(p.hasFood, false); assert.equal(p.snake.length, 89); assert.equal(timers.size, 0)
p.tick(); p.onShow(); assert.equal(timers.size, 0) p.tick(); p.onShow(); assert.equal(timers.size, 0)
p.restart(); assert.equal(p.hasFood, true); assert.equal(p.endText, 'GAME OVER'); assert.equal(p.model.length, 3) p.restart(); assert.equal(p.hasFood, true); assert.equal(p.endText, 'GAME OVER'); assert.equal(p.model.length, 3)
assert.match(ux, /show="\{\{ hasFood \}\}"/) assert.match(ux, /show="\{\{ hasFood \}\}"/)
@@ -58,11 +57,11 @@ test('select modes while paused or over, resetting safely, and retry keeps mode'
p.selectMode('wrap'); assert.equal(p.mode, 'classic', 'no mid-run mode change') p.selectMode('wrap'); assert.equal(p.mode, 'classic', 'no mid-run mode change')
p.onTap(); p.selectMode('garbage'); assert.equal(p.mode, 'classic') p.onTap(); p.selectMode('garbage'); assert.equal(p.mode, 'classic')
p.selectMode('wrap'); assert.equal(p.mode, 'wrap'); assert.equal(p.paused, true); assert.equal(timers.size, 0) p.selectMode('wrap'); assert.equal(p.mode, 'wrap'); assert.equal(p.paused, true); assert.equal(timers.size, 0)
p.onTap(); p.model = [{x:15,y:7},{x:14,y:7},{x:13,y:7}]; p.tick() p.onTap(); p.model = [{x:9,y:7},{x:8,y:7},{x:7,y:7}]; p.tick()
assert.equal(p.model[0].x, 0); assert.match(p.snake[1].src, /body_h/) assert.equal(p.model[0].x, 1); assert.match(p.snake[1].src, /body_h/)
p.restart(); assert.equal(p.mode, 'wrap'); assert.equal(timers.size, 1) p.restart(); assert.equal(p.mode, 'wrap'); assert.equal(timers.size, 1)
p.onTap(); p.selectMode('classic'); p.onTap() p.onTap(); p.selectMode('classic'); p.onTap()
p.model = [{x:15,y:7},{x:14,y:7},{x:13,y:7}]; p.tick() p.model = [{x:9,y:7},{x:8,y:7},{x:7,y:7}]; p.tick()
assert.equal(p.over, true); assert.equal(timers.size, 0) assert.equal(p.over, true); assert.equal(timers.size, 0)
p.onShow(); p.onTap(); assert.equal(p.over, true); assert.equal(timers.size, 0) p.onShow(); p.onTap(); assert.equal(p.over, true); assert.equal(timers.size, 0)
p.restart(); assert.equal(p.over, false); assert.equal(timers.size, 1) p.restart(); assert.equal(p.over, false); assert.equal(timers.size, 1)
@@ -95,7 +94,7 @@ test('Wrap crosses every edge, still collides with body and renders seam connect
assert.deepEqual(snake.renderPieces([{x:0,y:7},{x:15,y:7}], dirs[0], 16), ['head_e','tail_w']) assert.deepEqual(snake.renderPieces([{x:0,y:7},{x:15,y:7}], dirs[0], 16), ['head_e','tail_w'])
}) })
function eatTo(p, score) { function eatTo(p, score) {
p.model = [{x:6,y:7}, ...Array.from({length:score+1}, (_,i) => ({x:i%16,y:12+Math.floor(i/16)}))] p.model = [{x:6,y:7}, ...snake.ARENA.cells.filter(c=>c.y!==7).slice(0,score+1)]
p.dir = {x:1,y:0}; p.foodModel = {x:7,y:7}; p.tick() p.dir = {x:1,y:0}; p.foodModel = {x:7,y:7}; p.tick()
} }
test('late best read cannot lower local record; writes are serialized', () => { test('late best read cannot lower local record; writes are serialized', () => {
@@ -124,12 +123,18 @@ test('storage throws and corrupt values never crash or poison gameplay', () => {
assert.equal(p.bestText, 'BEST 0'); eatTo(p, 1); assert.equal(p.bestText, 'BEST 1') assert.equal(p.bestText, 'BEST 0'); eatTo(p, 1); assert.equal(p.bestText, 'BEST 1')
} }
}) })
test('legacy 0.7 records above the new arena maximum survive', () => {
const writes=[]
const {p}=page({get(o){o.success('253')},set(o){writes.push(o)}})
eatTo(p,20); p.restart()
assert.equal(p.bestText,'BEST 253'); assert.equal(writes.length,0)
})
test('best score loads raw storage value and persists only new records', () => { test('best score loads raw storage value and persists only new records', () => {
let get; const writes = [] let get; const writes = []
const {p} = page({get(o) { get=o }, set(o) { writes.push(o) }}) const {p} = page({get(o) { get=o }, set(o) { writes.push(o) }})
assert.ok(get, 'best score requested'); assert.equal(get.default, '0') assert.ok(get, 'best score requested'); assert.equal(get.default, '0')
get.success('4'); assert.equal(p.bestText, 'BEST 4') get.success('4'); assert.equal(p.bestText, 'BEST 4')
p.model = [{x:6,y:7}, ...Array.from({length:6}, (_,i) => ({x:i,y:12}))] p.model = [{x:6,y:7}, ...snake.ARENA.cells.filter(c=>c.y!==7).slice(0,6)]
p.foodModel = {x:7,y:7}; p.tick() p.foodModel = {x:7,y:7}; p.tick()
assert.equal(p.bestText, 'BEST 5'); assert.equal(writes.length, 1) assert.equal(p.bestText, 'BEST 5'); assert.equal(writes.length, 1)
assert.equal(writes[0].value, '5'); assert.equal(writes[0].key, get.key) assert.equal(writes[0].value, '5'); assert.equal(writes[0].key, get.key)
@@ -141,7 +146,7 @@ test('eating updates live score and accelerates to a capped 120ms', () => {
const {p, timers} = page() const {p, timers} = page()
assert.equal([...timers.values()][0].ms, 300) assert.equal([...timers.values()][0].ms, 300)
for (let score = 1; score <= 20; score++) { for (let score = 1; score <= 20; score++) {
p.model = [{x:6,y:7}, ...Array.from({length:score+1}, (_,i) => ({x:i%16,y:12+Math.floor(i/16)}))] p.model = [{x:6,y:7}, ...snake.ARENA.cells.filter(c=>c.y!==7).slice(0,score+1)]
p.dir = {x:1,y:0}; p.foodModel = {x:7,y:7}; p.tick() p.dir = {x:1,y:0}; p.foodModel = {x:7,y:7}; p.tick()
assert.equal(p.scoreText, 'SCORE ' + score) assert.equal(p.scoreText, 'SCORE ' + score)
assert.equal(timers.size, 1); assert.equal([...timers.values()][0].ms, Math.max(120, 300-score*10)) assert.equal(timers.size, 1); assert.equal([...timers.values()][0].ms, Math.max(120, 300-score*10))