4 Commits
32 changed files with 399 additions and 81 deletions
+51 -23
View File
@@ -1,12 +1,25 @@
# SNAKE — Xiaomi Watch S4
Textured Snake on a 16×16 grid: 20px tiles, 320px board, centered on a round
466px screen. Existing PNG board, directional snake pieces, apple and icon are
preserved. Package identity: `com.ilias.snake`.
Textured Snake on a **368px circular arena**, centered on the round 466px
screen. **32px tiles** (60% larger than 0.7) occupy 89 legal cells of an 11×11
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
still need an on-watch check.** Earlier versions rendered and played on the
user's Watch S4 41mm. Node tests and numeric layout checks are not device tests.
**Version 0.9 (versionCode 9): crown control experiment, unit-tested, needs an
on-watch check.** See docs/CROWN.md for the firmware reasoning.
## 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
@@ -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.
- **Tap the board, snake or apple** to pause. Tap **RESUME** (or an exposed part
of the board) to resume. Pausing clears queued turns.
- In the pause or game-over panel, select **CLASSIC** (walls end the run) or
**WRAP** (cross an edge to appear on the opposite edge). Changing mode starts
- In the pause or game-over panel, select **CLASSIC** (the circular boundary ends the run) or
**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
reset the run. The current mode is shown below the board.
- 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,
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
resume; game-over never resumes automatically. The system back action remains
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
@@ -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
read and are serialized/coalesced so slow callbacks cannot overwrite a newer
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
loop. A failed/uncompleted write or immediate app termination may lose the
unsaved record. Storage persistence still needs verification on the watch.
## Lightweight rendering
Directional PNG tiles use only `left`/`top` positioning, no transforms or new
art assets. Wrapped neighbors are normalized before classifying seam textures.
Food placement takes at most 256 cell checks, including a nearly full board.
The HUD, footer, board and overlay fit the circular chord numerically with
at least an 8px total horizontal safety allowance.
Directional PNG tiles use only `left`/`top` positioning, no transforms. Existing
20px sprite art is nearest-neighbor enlarged to native 32px assets (crisp pixel
art, not new high-resolution detail). The board is a newly generated circular
PNG with transparent corners and a subtle legal-cell checker pattern. Wrapped
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
@@ -62,13 +86,15 @@ npm test
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
swipes, lifecycle/timer races, storage failures and async ordering, mode changes,
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.
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`;
do not regenerate or commit those keys. Release uses plain JS (not JSC/protobuf),
matching the previously working toolchain. The dependency lock is unchanged;
@@ -77,15 +103,16 @@ matching the previously working toolchain. The dependency lock is unchanged;
## 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/best.js`: asynchronous storage coordinator.
- `src/common/tex/`: preserved textured artwork.
- `tests/{snake,pieces,page,upgrade,layout,properties}.cjs`: Node regression suites.
- `src/common/tex/`: native 32px sprite artwork and circular board.
- `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
https://gitea.oimcloud.myaddr.tools/codex-bot/vela-snake — publication is a
separate step after review; a local build is not evidence of a published release.
Install the signed RPK through AstroBox. Source lives at
https://gitea.oimcloud.myaddr.tools/Omarchy-Big-PC/vela-snake-m (write access
to the upstream codex-bot repo was denied, so 0.9 lives in the fork).
## Earlier versions
@@ -93,3 +120,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.7: queued swipes, live/persisted records, capped acceleration, pause/resume,
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,
"description": "Textured Snake for the round Xiaomi Watch S4 screen",
"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",
"release": "aiot release",
"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() {
return [
{ x: 6, y: 7 },
@@ -12,13 +26,22 @@ function step(snake, dir, food, N, mode) {
const head = snake[0]
let nx = head.x + dir.x
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
ny = (ny + N) % N
} else if (nx < 0 || ny < 0 || nx >= N || ny >= N) {
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
const body = ate ? snake : snake.slice(0, snake.length - 1)
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 opp = { e: 'w', w: 'e', n: 's', s: 'n' }
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) => {
if (i === 0) return 'head_' + D[dir.x + ',' + dir.y]
const prev = 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
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 === 'n' || from === 's') && (to === 'n' || to === 's')) return 'body_v'
// 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.
function spawnFood(snake, N) {
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
// Select an empty-cell rank uniformly; at most N*N checks, even almost full.
let rank = Math.floor(Math.random() * (N * N - occupied.size))
@@ -89,4 +125,4 @@ function spawnFood(snake, N) {
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",
"name": "SNAKE",
"icon": "/common/icon.png",
"versionName": "0.7",
"versionCode": 7,
"versionName": "0.9",
"versionCode": 9,
"minAPILevel": 1,
"deviceTypeList": [
"watch"
+62 -18
View File
@@ -1,5 +1,6 @@
<template>
<div class="page" onswipe="onSwipe">
<div class="page">
<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="food" src="/common/tex/apple.png" show="{{ hasFood }}" onclick="onTap" style="left: {{ food.left }}; top: {{ food.top }};"></image>
@@ -10,7 +11,7 @@
</div>
<div class="footer">
<text class="modeLabel">{{ modeText }}</text>
<text class="hint">swipe / tap to pause</text>
<text class="hint">swipe / tap pause</text>
</div>
<div class="overlay" show="{{ paused && !over }}">
<text class="overText">PAUSED</text>
@@ -33,6 +34,7 @@
</div>
</div>
</stack>
</scroll>
</div>
</template>
<script>
@@ -40,14 +42,25 @@ import snake from '../../common/snake.js'
import storage from '@system.storage'
import best from '../../common/best.js'
const CELL = 20
const GRID = 16
const OFFSET = 73
const CELL = snake.ARENA.cell
const GRID = snake.ARENA
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 {
private: {
snake: [],
food: { left: '73px', top: '73px' },
food: { left: '57px', top: '57px' },
hasFood: true,
endText: 'GAME OVER',
scoreText: 'SCORE 0',
@@ -68,6 +81,8 @@ export default {
onShow() {
if (this.destroyed) return
this.visible = true
this.crownAnchor = 233
try { this.$element('scroller').scrollTo({ top: 233 }) } catch (err) {}
this.records.sync()
this.startTimer()
},
@@ -157,6 +172,31 @@ export default {
if (snake.turn(last, v) === last) return
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() {
if (this.destroyed || !this.visible || this.over) return
this.paused = !this.paused
@@ -190,46 +230,50 @@ export default {
background-color: #000000;
align-items: 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 {
width: 466px; height: 466px;
}
.board {
position: absolute; left: 73px; top: 73px;
width: 320px; height: 320px;
position: absolute; left: 49px; top: 49px;
width: 368px; height: 368px;
}
.seg {
position: absolute;
width: 20px; height: 20px;
width: 32px; height: 32px;
}
.food {
position: absolute;
width: 20px; height: 20px;
width: 32px; height: 32px;
}
.score {
width: 188px; height: 22px;
width: 152px; height: 20px;
color: #d8edce; font-size: 18px; text-align: center;
}
.best {
width: 188px; height: 22px;
width: 152px; height: 20px;
color: #f3bd60; font-size: 17px; text-align: center;
}
.hud {
position: absolute; left: 139px; top: 24px;
width: 188px; height: 44px;
position: absolute; left: 157px; top: 16px;
width: 152px; height: 40px;
flex-direction: column; align-items: center;
}
.footer {
position: absolute; left: 135px; top: 400px;
width: 196px; height: 40px;
position: absolute; left: 143px; top: 410px;
width: 180px; height: 36px;
flex-direction: column; align-items: center;
}
.modeLabel {
width: 196px; height: 20px;
width: 180px; height: 18px;
color: #cee4bd; font-size: 17px; text-align: center;
}
.hint {
width: 196px; height: 20px;
width: 180px; height: 18px;
color: #adb7c6; font-size: 16px; text-align: center;
}
.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*/)))
}
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 dy = Math.max(Math.abs(y-233), Math.abs(y+h-233))
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 body = ux.match(/<script>([\s\S]*?)<\/script>/)[1]
.replace(/import\s+\w+\s+from\s+'[^']*';?/g, '')
.replace(/^const\s+\w+\s*=[^\n]*\n/gm, '')
.replace('export default', '');
.replace('export default', 'globalThis.spec =');
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);
const spec = vm.runInContext('(' + body + ')', sandbox);
vm.runInContext(body, sandbox);
const spec = sandbox.spec;
function mk() {
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[2].src, /tail_w\.png$/, 'tail texture');
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();
assert.equal(c.model[0].x, 11, 'head moved right 5 steps from x=6');
for (let i = 0; i < 1; i++) c.tick();
assert.equal(c.model[0].x, 7, 'head moved right one step from x=6');
assert.equal(c.over, false);
c.onSwipe({ direction: 'down' });
@@ -47,8 +47,8 @@ c.tick();
assert.equal(c.dir.x, 0, 'reversal keeps x');
assert.equal(c.dir.y, 1, 'reversal blocked');
// run into the wall to trigger game over (GRID=16, last column x=15)
c.model = [{ x: 15, y: 5 }, { x: 14, y: 5 }, { x: 13, y: 5 }];
// Hit the actual circular boundary.
c.model = [{ x: 10, y: 5 }, { x: 9, y: 5 }, { x: 8, y: 5 }];
c.dir = { x: 1, y: 0 };
c.pending = null;
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, /for="\{\{ seg in snake \}\}"/);
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, /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 dirs=[{x:1,y:0},{x:0,y:1},{x:-1,y:0},{x:0,y:-1}]
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]
for(let tick=0;tick<100;tick++) {
dir=snake.turn(dir,dirs[Math.floor(rand()*4)])
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 r=snake.step(cells,dir,food,16,mode)
const food=rand()<0.3?snake.step([h],dir,null,board,'wrap').snake[0]:null
const r=snake.step(cells,dir,food,board,mode)
if(!r.alive)break
if(Math.abs(r.snake[0].x-h.x)>1||Math.abs(r.snake[0].y-h.y)>1)seams++
cells=r.snake
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 name of snake.renderPieces(cells,dir,16))assert.ok(fs.existsSync(require('node:path').join(__dirname,'../src/common/tex',name+'.png')),name)
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,board))assert.ok(fs.existsSync(require('node:path').join(__dirname,'../src/common/tex',name+'.png')),name)
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', () => {
const {p, timers} = page()
// A serpentine Hamilton path, head (14,0), final food (15,0).
const path = []
for (let y=0;y<16;y++) for (let k=0;k<16;k++) path.push({x:y%2 ? k : 15-k,y})
p.model = path.slice(1); p.dir = {x:1,y:0}; p.foodModel = {x:15,y:0}
// Legal occupancy fixture isolates final-cell victory from pathfinding.
p.model = [{x:5,y:0}, ...snake.ARENA.cells.filter(c=>!(c.y===0 && (c.x===5 || c.x===6)))]
p.dir = {x:1,y:0}; p.foodModel = {x:6,y:0}
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.hasFood, false); assert.equal(p.snake.length, 256); assert.equal(timers.size, 0)
assert.equal(p.scoreText, 'SCORE 86'); assert.equal(p.bestText, 'BEST 86')
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.restart(); assert.equal(p.hasFood, true); assert.equal(p.endText, 'GAME OVER'); assert.equal(p.model.length, 3)
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.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.onTap(); p.model = [{x:15,y:7},{x:14,y:7},{x:13,y:7}]; p.tick()
assert.equal(p.model[0].x, 0); assert.match(p.snake[1].src, /body_h/)
p.onTap(); p.model = [{x:9,y:7},{x:8,y:7},{x:7,y:7}]; p.tick()
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.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)
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)
@@ -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'])
})
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()
}
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')
}
})
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', () => {
let get; const writes = []
const {p} = page({get(o) { get=o }, set(o) { writes.push(o) }})
assert.ok(get, 'best score requested'); assert.equal(get.default, '0')
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()
assert.equal(p.bestText, 'BEST 5'); assert.equal(writes.length, 1)
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()
assert.equal([...timers.values()][0].ms, 300)
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()
assert.equal(p.scoreText, 'SCORE ' + score)
assert.equal(timers.size, 1); assert.equal([...timers.values()][0].ms, Math.max(120, 300-score*10))