[verified] Snake 0.7: queued controls, records, pause and wrap mode

This commit is contained in:
2026-09-12 12:23:09 +01:00
parent eacad97240
commit 5a7081b933
10 changed files with 548 additions and 109 deletions
+79 -35
View File
@@ -1,51 +1,95 @@
# SNAKE — classic snake for Xiaomi Watch S4 # SNAKE — Xiaomi Watch S4
Classic snake on a 16×16 grid rendered on the round 466px screen (320px board, Textured Snake on a 16×16 grid: 20px tiles, 320px board, centered on a round
20px cells). Swipe to steer; the crown is NOT used (confirmed unsupported by the 466px screen. Existing PNG board, directional snake pieces, apple and icon are
Vela quick-app API on this device). Tap the overlay to restart after a crash. preserved. Package identity: `com.ilias.snake`.
**Status: RENDERS AND PLAYS ON WATCH** (user-confirmed since 0.2/0.3). **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.
## Controls ## Play
- **Swipe** in any direction to turn (180° reversals are blocked in the logic) - **Swipe** to steer. A two-turn queue accepts rapid corners, consumes one turn
- **Tap** the game-over overlay to restart per tick, and rejects duplicate directions and reversals against the last
- Crown does nothing — no rotary API exists in Vela quick apps (triple-confirmed queued direction. Excess inputs are ignored, not allowed to replace a turn.
against docs + the largest open-source S4 app) - **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
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.
- 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.
## Project layout ## Score, speed and persistence
```text Each apple is one point. The always-visible header shows score and best. One
src/ best score is shared across Classic and Wrap, saved through documented
├── app.ux # app lifecycle [`@system.storage`](https://iot.mi.com/vela/quickapp/en/features/data/storage.html)
├── manifest.json # com.ilias.snake, watch, designWidth 466 under `snake.best`. Mode is session-only; a newly created page defaults to Classic.
├── common/
│ ├── snake.js # pure game logic: step/turn/spawnFood — unit-tested
│ └── icon.png # 192×192 circular app icon
└── pages/index/index.ux # board rendering (for-directive), swipe input, game loop
tests/
├── snake.cjs # logic: self-collision, food, 180° block, wrap rules
└── page.cjs # wiring: template binds, handlers, state machine
```
The game ticks on a 300ms `setInterval`, cleared on hide/destroy. The movement interval starts at **300ms**, decreases by **10ms per apple**, and
never goes below **120ms**. Only one movement timer runs; none runs while hidden,
paused, finished or destroyed. Old timer callbacks are invalidated.
## Build & test 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:
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.
## Build and test
```bash ```bash
npm ci npm ci
npm run release # signed .rpk → dist/ npm test
node tests/snake.cjs # logic tests npm run release
node tests/page.cjs # page wiring tests
``` ```
## Install `npm test` runs the original core, piece and page tests, 13 incremental 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.
New behaviors were exercised through failing-then-passing test cycles.
Side-load `dist/com.ilias.snake.release.*.rpk` via AstroBox. Release output: `dist/com.ilias.snake.release.0.7.rpk`. The locked
Releases: https://gitea.oimcloud.myaddr.tools/codex-bot/vela-snake `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;
`npm ci` currently reports 14 development-toolchain audit findings (3 low,
11 high). No forced toolkit upgrade was applied.
## Version notes ## Files
- **0.2** — board renders on watch (user confirmed); ring undersized - `src/pages/index/index.ux`: round-screen UI, swipe queue, game/lifecycle state.
- **0.3** — designWidth 480→466 fixed the undersized bezel - `src/common/snake.js`: movement, collision, Wrap piece classification, food.
- Crown-based input was tried and disproven — swipe is the only reliable input - `src/common/best.js`: asynchronous storage coordinator.
- `src/common/tex/`: preserved textured artwork.
- `tests/{snake,pieces,page,upgrade,layout,properties}.cjs`: Node regression suites.
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.
## Earlier versions
- 0.2/0.3: board played on watch; designWidth 480→466 fixed undersizing.
- 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.
+2 -1
View File
@@ -2,8 +2,9 @@
"name": "xiaomi-vela-s4-hello-world", "name": "xiaomi-vela-s4-hello-world",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"description": "Minimal Xiaomi Vela JS Hello World app for Xiaomi Watch S4", "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",
"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",
+63
View File
@@ -0,0 +1,63 @@
// Vela storage callbacks return raw strings, not { data } wrappers.
// https://iot.mi.com/vela/quickapp/en/features/data/storage.html
function createStore(storage) {
let value = 0
let saved = 0
let loaded = false
let reading = false
let writing = false
let listener = null
function notify() { if (listener) listener(value) }
function flush() {
if (!loaded || writing || value <= saved) return
writing = true
const target = value
let settled = false
const fail = () => { if (!settled) { settled = true; writing = false } }
try {
storage.set({ key: 'snake.best', value: String(target), success() {
if (settled) return
settled = true
writing = false
saved = target
flush()
}, fail })
} catch (e) { fail() }
}
function sync() {
if (loaded) { flush(); return }
if (reading) return
reading = true
let settled = false
const fail = () => { if (!settled) { settled = true; reading = false } }
try {
storage.get({ key: 'snake.best', default: '0', success(raw) {
if (settled) return
settled = true
reading = false
const n = Number(raw)
saved = Number.isInteger(n) && n >= 0 && n <= 253 ? n : 0
value = Math.max(value, saved)
loaded = true
notify()
flush()
}, fail })
} catch (e) { fail() }
}
return {
subscribe(fn) { listener = fn; notify() },
record(score) { value = Math.max(value, score); notify(); sync() },
sync
}
}
// One coordinator per native storage module, retained across page recreation.
let sharedAPI
let sharedStore
function getStore(storage) {
if (storage !== sharedAPI) {
sharedAPI = storage
sharedStore = createStore(storage)
}
return sharedStore
}
module.exports = { getStore }
+22 -13
View File
@@ -8,11 +8,14 @@ function initialSnake() {
} }
// Advance the snake one cell in `dir` ({x,y} unit vector). Returns { snake, ate, alive }. // Advance the snake one cell in `dir` ({x,y} unit vector). Returns { snake, ate, alive }.
function step(snake, dir, food, N) { function step(snake, dir, food, N, mode) {
const head = snake[0] const head = snake[0]
const nx = head.x + dir.x let nx = head.x + dir.x
const ny = head.y + dir.y let ny = head.y + dir.y
if (nx < 0 || ny < 0 || nx >= N || ny >= N) { 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 } return { snake, ate: false, alive: false }
} }
const ate = (nx === food.x && ny === food.y) const ate = (nx === food.x && ny === food.y)
@@ -37,16 +40,17 @@ function turn(current, desired) {
// Classify every segment as a texture piece (head/body/corner/tail + compass side), // Classify every segment as a texture piece (head/body/corner/tail + compass side),
// so the page can render each cell with the right PNG. Returns one name per segment. // so the page can render each cell with the right PNG. Returns one name per segment.
// Sides are screen compass (y grows down): e/w/n/s = right/left/up/down. // Sides are screen compass (y grows down): e/w/n/s = right/left/up/down.
function renderPieces(snakeCells, dir) { 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
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[(prev.x - s.x) + ',' + (prev.y - s.y)] // side toward head const from = D[delta(prev.x - s.x) + ',' + delta(prev.y - s.y)] // 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[(next.x - s.x) + ',' + (next.y - s.y)] // side toward tail tip const to = D[delta(next.x - s.x) + ',' + delta(next.y - s.y)] // 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
@@ -72,12 +76,17 @@ function rotateDir(dir, clockwise) {
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 (occupied.size >= N * N) return null if (occupied.size >= N * N) return null
let x, y // Select an empty-cell rank uniformly; at most N*N checks, even almost full.
do { let rank = Math.floor(Math.random() * (N * N - occupied.size))
x = Math.floor(Math.random() * N) for (let y = 0; y < N; y++) {
y = Math.floor(Math.random() * N) for (let x = 0; x < N; x++) {
} while (occupied.has(x + ',' + y)) if (!occupied.has(x + ',' + y)) {
return { x, y } if (rank === 0) return { x, y }
rank--
}
}
}
return null
} }
module.exports = { initialSnake, step, turn, renderPieces, rotateDir, spawnFood } module.exports = { initialSnake, step, turn, renderPieces, rotateDir, spawnFood }
+3 -3
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.6", "versionName": "0.7",
"versionCode": 6, "versionCode": 7,
"minAPILevel": 1, "minAPILevel": 1,
"deviceTypeList": [ "deviceTypeList": [
"watch" "watch"
@@ -26,7 +26,7 @@
}, },
"features": [ "features": [
{ {
"name": "system.router" "name": "system.storage"
} }
] ]
} }
+151 -54
View File
@@ -1,21 +1,44 @@
<template> <template>
<div class="page" onswipe="onSwipe"> <div class="page" onswipe="onSwipe">
<stack class="dial"> <stack class="dial">
<image class="board" src="/common/tex/board.png"></image> <image class="board" src="/common/tex/board.png" onclick="onTap"></image>
<image class="food" src="/common/tex/apple.png" style="left: {{ food.left }}px; top: {{ food.top }}px;"></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 }}" style="left: {{ seg.left }}px; top: {{ seg.top }}px;"></image> <image class="seg" src="{{ seg.src }}" for="{{ seg in snake }}" onclick="onTap" style="left: {{ seg.left }}; top: {{ seg.top }};"></image>
<div class="overlay" show="{{ over }}" onclick="onTap"> <div class="hud">
<text class="overText">GAME OVER</text> <text class="score">{{ scoreText }}</text>
<text class="overSub">{{ scoreText }}</text> <text class="best">{{ bestText }}</text>
<text class="overHint">tap to play again</text> </div>
<div class="footer">
<text class="modeLabel">{{ modeText }}</text>
<text class="hint">swipe / tap to pause</text>
</div>
<div class="overlay" show="{{ paused && !over }}">
<text class="overText">PAUSED</text>
<text class="overSub">{{ scoreText }}</text>
<text class="retry" onclick="onTap">RESUME</text>
<text class="overHint">Change mode: new game</text>
<div class="modes">
<text class="modeButton" onclick="selectMode('classic')">CLASSIC</text>
<text class="modeButton" onclick="selectMode('wrap')">WRAP</text>
</div>
</div>
<div class="overlay" show="{{ over }}">
<text class="overText">{{ endText }}</text>
<text class="overSub">{{ scoreText }}</text>
<text class="retry" onclick="restart">PLAY AGAIN</text>
<text class="overHint">Change mode: new game</text>
<div class="modes">
<text class="modeButton" onclick="selectMode('classic')">CLASSIC</text>
<text class="modeButton" onclick="selectMode('wrap')">WRAP</text>
</div>
</div> </div>
<text class="score">{{ scoreText }}</text>
<text class="hint">swipe to move</text>
</stack> </stack>
</div> </div>
</template> </template>
<script> <script>
import snake from '../../common/snake.js' import snake from '../../common/snake.js'
import storage from '@system.storage'
import best from '../../common/best.js'
const CELL = 20 const CELL = 20
const GRID = 16 const GRID = 16
@@ -25,56 +48,103 @@ export default {
private: { private: {
snake: [], snake: [],
food: { left: '73px', top: '73px' }, food: { left: '73px', top: '73px' },
hasFood: true,
endText: 'GAME OVER',
scoreText: 'SCORE 0', scoreText: 'SCORE 0',
bestText: 'BEST 0',
mode: 'classic',
modeText: 'CLASSIC',
paused: false,
over: false over: false
}, },
onInit() { onInit() {
this.dir = { x: 1, y: 0 } this.dir = { x: 1, y: 0 }
this.pending = null this.queue = []
this.records = best.getStore(storage)
this.records.subscribe(value => { if (!this.destroyed) this.bestText = 'BEST ' + value })
this.reset() this.reset()
this.records.sync()
}, },
onShow() { onShow() {
this.timer = setInterval(() => this.tick(), 300) if (this.destroyed) return
this.visible = true
this.records.sync()
this.startTimer()
},
stopTimer() {
this.timerEpoch = (this.timerEpoch || 0) + 1
clearInterval(this.timer)
this.timer = null
},
startTimer() {
this.stopTimer()
if (this.destroyed || !this.visible || this.paused || this.over) return
const epoch = this.timerEpoch
this.timer = setInterval(() => {
if (epoch === this.timerEpoch) this.tick()
}, Math.max(120, 300 - (this.model.length - 3) * 10))
}, },
onHide() { onHide() {
clearInterval(this.timer) this.visible = false
this.paused = true
this.queue = []
this.stopTimer()
}, },
onDestroy() { onDestroy() {
clearInterval(this.timer) this.onHide()
this.destroyed = true
this.records.subscribe(null)
}, },
reset() { reset() {
this.model = snake.initialSnake() this.model = snake.initialSnake()
this.foodModel = snake.spawnFood(this.model, GRID) this.foodModel = snake.spawnFood(this.model, GRID)
this.dir = { x: 1, y: 0 } this.dir = { x: 1, y: 0 }
this.pending = null this.queue = []
this.over = false this.over = false
this.endText = 'GAME OVER'
this.paused = false
this.rebuild() this.rebuild()
}, },
tick() { tick() {
if (this.over) return if (this.over || this.paused || this.destroyed || this.visible === false) return
if (this.pending) { if (this.queue.length) this.dir = this.queue.shift()
this.dir = snake.turn(this.dir, this.pending) const r = snake.step(this.model, this.dir, this.foodModel, GRID, this.mode)
this.pending = null
}
const r = snake.step(this.model, this.dir, this.foodModel, GRID)
if (!r.alive) { if (!r.alive) {
this.over = true this.over = true
clearInterval(this.timer) this.stopTimer()
return return
} }
this.model = r.snake this.model = r.snake
if (r.ate) { if (r.ate) {
this.foodModel = snake.spawnFood(this.model, GRID) this.foodModel = snake.spawnFood(this.model, GRID)
if (!this.foodModel) {
this.over = true
this.endText = 'YOU WIN'
this.stopTimer()
}
} }
this.rebuild() this.rebuild()
if (r.ate) {
this.records.record(this.model.length - 3)
this.startTimer()
}
},
selectMode(mode) {
if (this.destroyed || !this.visible || (!this.paused && !this.over)) return
if ((mode !== 'classic' && mode !== 'wrap') || mode === this.mode) return
this.mode = mode
this.modeText = mode === 'wrap' ? 'WRAP' : 'CLASSIC'
this.reset()
this.paused = true
this.stopTimer()
}, },
restart() { restart() {
if (this.destroyed || this.visible === false) return
this.reset() this.reset()
clearInterval(this.timer) this.startTimer()
this.timer = setInterval(() => this.tick(), 300)
}, },
onSwipe(e) { onSwipe(e) {
if (this.over) return if (this.over || this.paused || this.destroyed || this.visible === false) return
const d = e.direction const d = e.direction
let v let v
if (d === 'left') v = { x: -1, y: 0 } if (d === 'left') v = { x: -1, y: 0 }
@@ -82,13 +152,19 @@ export default {
else if (d === 'up') v = { x: 0, y: -1 } else if (d === 'up') v = { x: 0, y: -1 }
else if (d === 'down') v = { x: 0, y: 1 } else if (d === 'down') v = { x: 0, y: 1 }
else return else return
this.pending = v const last = this.queue.length ? this.queue[this.queue.length - 1] : this.dir
if (this.queue.length >= 2 || (last.x === v.x && last.y === v.y)) return
if (snake.turn(last, v) === last) return
this.queue.push(v)
}, },
onTap() { onTap() {
if (this.over) this.restart() if (this.destroyed || !this.visible || this.over) return
this.paused = !this.paused
this.queue = []
this.startTimer()
}, },
rebuild() { rebuild() {
const pieces = snake.renderPieces(this.model, this.dir) const pieces = snake.renderPieces(this.model, this.dir, this.mode === 'wrap' ? GRID : 0)
const out = [] const out = []
for (let i = 0; i < this.model.length; i++) { for (let i = 0; i < this.model.length; i++) {
const s = this.model[i] const s = this.model[i]
@@ -99,7 +175,8 @@ export default {
}) })
} }
this.snake = out this.snake = out
this.food = { this.hasFood = !!this.foodModel
if (this.hasFood) this.food = {
left: (OFFSET + this.foodModel.x * CELL) + 'px', left: (OFFSET + this.foodModel.x * CELL) + 'px',
top: (OFFSET + this.foodModel.y * CELL) + 'px' top: (OFFSET + this.foodModel.y * CELL) + 'px'
} }
@@ -130,41 +207,61 @@ export default {
width: 20px; height: 20px; width: 20px; height: 20px;
} }
.score { .score {
position: absolute; left: 0px; top: 24px; width: 188px; height: 22px;
width: 466px; color: #d8edce; font-size: 18px; text-align: center;
color: #9aa0aa; }
font-size: 22px; .best {
text-align: center; width: 188px; height: 22px;
color: #f3bd60; font-size: 17px; text-align: center;
}
.hud {
position: absolute; left: 139px; top: 24px;
width: 188px; height: 44px;
flex-direction: column; align-items: center;
}
.footer {
position: absolute; left: 135px; top: 400px;
width: 196px; height: 40px;
flex-direction: column; align-items: center;
}
.modeLabel {
width: 196px; height: 20px;
color: #cee4bd; font-size: 17px; text-align: center;
} }
.hint { .hint {
position: absolute; left: 0px; top: 408px; width: 196px; height: 20px;
width: 466px; color: #adb7c6; font-size: 16px; text-align: center;
color: #3a4052;
font-size: 18px;
text-align: center;
} }
.overlay { .overlay {
position: absolute; left: 0px; top: 160px; position: absolute; left: 93px; top: 123px;
width: 466px; width: 280px; height: 220px;
flex-direction: column; background-color: #101923; border-radius: 18px;
align-items: center; flex-direction: column; align-items: center; justify-content: center;
} }
.overText { .overText {
height: 40px; width: 260px; height: 40px;
color: #ffffff; color: #ffffff; font-size: 30px; text-align: center;
font-size: 32px;
text-align: center;
} }
.overSub { .overSub {
height: 32px; width: 260px; height: 30px;
color: #f3a633; color: #f3bd60; font-size: 22px; text-align: center;
font-size: 24px; }
text-align: center; .retry {
width: 216px; height: 56px;
background-color: #80bf55; border-radius: 12px;
color: #101923; font-size: 23px; text-align: center;
} }
.overHint { .overHint {
height: 30px; width: 260px; height: 30px;
color: #9aa0aa; color: #adb7c6; font-size: 17px; text-align: center;
font-size: 20px; }
text-align: center; .modes {
width: 240px; height: 44px;
flex-direction: row; justify-content: space-between;
}
.modeButton {
width: 116px; height: 44px;
background-color: #304252; border-radius: 10px;
color: #ffffff; font-size: 18px; text-align: center;
} }
</style> </style>
+28
View File
@@ -0,0 +1,28 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const ux = fs.readFileSync('src/pages/index/index.ux','utf8')
const css = ux.match(/<style>([\s\S]*?)<\/style>/)[1]
function rule(name) {
const m = css.match(new RegExp('\\.'+name+'\\s*\\{([^}]+)\\}'))
assert.ok(m, 'style exists: '+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 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)
const used = 2*Math.max(Math.abs(x-233), Math.abs(x+w-233))
assert.ok(used <= chord-8, `${name}: ${used} > chord ${chord} minus 8px safety`)
console.log(`${name}: y=${y}..${y+h}, width=${w}, chord=${chord.toFixed(2)}, margin=${((chord-used)/2).toFixed(2)}px`)
}
const overlay = rule('overlay'), retry = rule('retry'), modes = rule('modes'), button = rule('modeButton')
assert.ok(px(retry,'width') >= 200 && px(retry,'height') >= 56, 'large retry touch target')
assert.ok(px(button,'height') >= 44 && px(button,'width') >= 100)
assert.ok(2*px(button,'width') <= px(modes,'width'))
const total = ['overText','overSub','retry','overHint','modes'].reduce((n,k)=>n+px(rule(k),'height'),0)
assert.ok(total <= px(overlay,'height'), 'overlay children fit without clipping')
assert.ok(px(retry,'width') <= px(overlay,'width'))
assert.doesNotMatch(ux, /\}\}px/, 'style bindings already contain px: no double px')
assert.equal(JSON.parse(fs.readFileSync('src/manifest.json')).config.designWidth, 466)
console.log('ROUND LAYOUT PASS (numeric geometry, not hardware rendering)')
+3 -3
View File
@@ -6,11 +6,11 @@ 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+snake\s+from\s+'[^']*';?/, '') .replace(/import\s+\w+\s+from\s+'[^']*';?/g, '')
.replace(/^const\s+\w+\s*=[^\n]*\n/gm, '') .replace(/^const\s+\w+\s*=[^\n]*\n/gm, '')
.replace('export default', ''); .replace('export default', '');
const sandbox = { 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 }; CELL: 20, GRID: 16, OFFSET: 73 };
vm.createContext(sandbox); vm.createContext(sandbox);
const spec = vm.runInContext('(' + body + ')', sandbox); const spec = vm.runInContext('(' + body + ')', sandbox);
@@ -60,7 +60,7 @@ assert.equal(c.model.length, 3);
assert.equal(c.scoreText, 'SCORE 0'); assert.equal(c.scoreText, 'SCORE 0');
// template uses image components for textures + documented directives/events // template uses image components for textures + documented directives/events
assert.match(ux, /<image class="board" src="\/common\/tex\/board\.png">/); 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 \}\}"/);
+24
View File
@@ -0,0 +1,24 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const snake = require('../src/common/snake.js')
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++) {
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)
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)
moves++
}
}
console.log(JSON.stringify({randomizedValidMoves:moves,edgeCrossings:seams}))
+173
View File
@@ -0,0 +1,173 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const snake = require('../src/common/snake.js')
const ux = fs.readFileSync('src/pages/index/index.ux', 'utf8')
function page(storage = { get() {}, set() {} }) {
const timers = new Map(); let id = 0
const context = { best: require('../src/common/best.js'), snake, storage, console, setInterval(fn, ms) { timers.set(++id, {fn, ms}); return id }, clearInterval(id) { timers.delete(id) } }
const body = ux.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/import\s+\w+\s+from\s+'[^']*';?/g, '').replace('export default', 'globalThis.spec =')
vm.runInNewContext(body, context)
const p = Object.assign({}, context.spec, JSON.parse(JSON.stringify(context.spec.private)))
p.onInit(); p.onShow()
p.foodModel = {x: 0, y: 0}
return {p, timers}
}
test('food selection has bounded work even when only one empty cell remains', () => {
const cells = []; for(let y=0;y<16;y++) for(let x=0;x<16;x++) if(x!==15 || y!==15) cells.push({x,y})
const original = Math.random; let calls = 0
try {
Math.random = () => { assert.ok(++calls <= 1, 'at most one random draw'); return 0 }
assert.deepEqual(snake.spawnFood(cells,16), {x:15,y:15})
cells.push({x:15,y:15}); assert.equal(snake.spawnFood(cells,16), null)
} finally {Math.random=original}
})
test('page recreation shares pending best writes instead of racing an older page', () => {
const reads=[], writes=[]
const api = {get(o) {reads.push(o)}, set(o) {writes.push(o)}}
const {p:old} = page(api); reads[0].success('0'); eatTo(old, 5); old.onDestroy()
const {p} = page(api); assert.equal(p.bestText, 'BEST 5'); assert.equal(reads.length, 1)
eatTo(p, 7); assert.equal(writes.length, 1); writes[0].success()
assert.equal(writes[1].value, '7'); writes[1].success(); assert.equal(p.bestText, 'BEST 7')
})
test('retired timer callbacks cannot advance a resumed or restarted run', () => {
const {p, timers} = page(); const retired = [...timers.values()][0].fn
p.onTap(); p.onTap(); retired(); assert.equal(p.model[0].x, 6)
const oldRun = [...timers.values()][0].fn
p.restart(); oldRun(); assert.equal(p.model[0].x, 6)
p.onHide(); const model=p.model; p.restart(); assert.equal(p.model, model, 'hidden retry ignored')
})
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}
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)
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 \}\}"/)
assert.match(ux, /\{\{ endText \}\}/)
})
test('select modes while paused or over, resetting safely, and retry keeps mode', () => {
const {p, timers} = page()
assert.equal(p.mode, 'classic')
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.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()
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)
for (const cls of ['board','food','seg']) assert.match(ux, new RegExp('class="'+cls+'"[^>]*onclick="onTap"'))
assert.match(ux, /class="retry" onclick="restart"/)
assert.match(ux, /onclick="selectMode\('classic'\)"/)
assert.match(ux, /onclick="selectMode\('wrap'\)"/)
assert.doesNotMatch(ux, /<(?:div|stack)[^>]*onclick=/, 'actions cannot bubble into a second action')
assert.match(ux, /\{\{ bestText \}\}/)
assert.match(ux, /show="\{\{ paused && !over \}\}"/)
})
test('Wrap crosses every edge, still collides with body and renders seam connections', () => {
const cases = [
[[15,7],[14,7],[13,7]], [[0,7],[1,7],[2,7]],
[[7,15],[7,14],[7,13]], [[7,0],[7,1],[7,2]]
]
const dirs = [{x:1,y:0},{x:-1,y:0},{x:0,y:1},{x:0,y:-1}]
const expected = [{x:0,y:7},{x:15,y:7},{x:7,y:0},{x:7,y:15}]
cases.forEach((coords,i) => {
const cells = coords.map(([x,y])=>({x,y}))
const r = snake.step(cells, dirs[i], {x:8,y:8}, 16, 'wrap')
assert.equal(r.alive, true); assert.deepEqual(r.snake[0], expected[i])
assert.equal(snake.step(cells, dirs[i], {x:8,y:8}, 16, 'classic').alive, false)
})
assert.equal(snake.step([{x:15,y:7},{x:0,y:7},{x:1,y:7}], dirs[0], {x:8,y:8}, 16, 'wrap').alive, false)
assert.equal(snake.step([{x:15,y:7},{x:15,y:8},{x:0,y:8},{x:0,y:7}], dirs[0], {x:8,y:8}, 16, 'wrap').alive, true)
// At (15,7), neighbor (0,7) is east; neighbor (15,8) is south.
assert.deepEqual(snake.renderPieces([{x:0,y:7},{x:15,y:7},{x:15,y:8}], dirs[0], 16), ['head_e','corner_es','tail_s'])
assert.deepEqual(snake.renderPieces([{x:7,y:0},{x:7,y:15},{x:6,y:15}], dirs[2], 16), ['head_s','corner_sw','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) {
p.model = [{x:6,y:7}, ...Array.from({length:score+1}, (_,i) => ({x:i%16,y:12+Math.floor(i/16)}))]
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', () => {
const reads = [], writes = []
const {p} = page({get(o) {reads.push(o)}, set(o) {writes.push(o)}})
eatTo(p, 5); assert.equal(writes.length, 0, 'wait for stored high water before writing')
reads[0].success('3'); assert.equal(p.bestText, 'BEST 5'); assert.equal(writes[0].value, '5')
eatTo(p, 6); eatTo(p, 7); assert.equal(writes.length, 1)
writes[0].success(); assert.equal(writes.length, 2); assert.equal(writes[1].value, '7')
writes[1].success(); p.restart(); assert.equal(p.bestText, 'BEST 7')
})
test('read/write failures retry on later lifecycle activity without erasing a saved record', () => {
const reads = [], writes = []
const {p} = page({get(o) {reads.push(o)}, set(o) {writes.push(o)}})
reads[0].fail('offline', 1); eatTo(p, 5); assert.equal(writes.length, 0)
reads[1].success('8'); assert.equal(p.bestText, 'BEST 8'); assert.equal(writes.length, 0)
eatTo(p, 9); writes[0].fail('full', 1); assert.equal(writes.length, 1)
p.onHide(); p.onShow(); assert.equal(writes.length, 2); assert.equal(writes[1].value, '9')
p.onDestroy(); const text = p.bestText; writes[1].success(); assert.equal(p.bestText, text)
})
test('storage throws and corrupt values never crash or poison gameplay', () => {
const {p} = page({get() {throw Error('offline')}, set() {throw Error('full')}})
eatTo(p, 1); assert.equal(p.bestText, 'BEST 1')
for (const value of ['oops', '-4', '2.5', 'Infinity', '99999', '', '{}']) {
const {p} = page({get(o) {o.success(value)}, set() {throw Error('full')}})
assert.equal(p.bestText, 'BEST 0'); eatTo(p, 1); assert.equal(p.bestText, 'BEST 1')
}
})
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.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)
writes[0].success(); p.restart(); assert.equal(p.bestText, 'BEST 5')
assert.match(ux, /import storage from '@system.storage'/)
assert.ok(JSON.parse(fs.readFileSync('src/manifest.json')).features.some(f => f.name === 'system.storage'))
})
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.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))
}
p.restart(); assert.equal([...timers.values()][0].ms, 300)
})
test('pause stops timers, resume is explicit after hide, destroy is final', () => {
const {p, timers} = page()
assert.equal(timers.size, 1); p.onShow(); assert.equal(timers.size, 1)
const stale = [...timers.values()][0].fn
p.onSwipe({direction:'up'}); p.onTap(); assert.equal(p.paused, true); assert.equal(timers.size, 0)
stale(); p.tick(); assert.equal(p.model[0].x, 6)
p.onSwipe({direction:'down'}); p.onTap(); assert.equal(timers.size, 1)
p.tick(); assert.equal(p.model[0].x, 7)
p.onHide(); assert.equal(timers.size, 0); p.onShow(); assert.equal(p.paused, true); assert.equal(timers.size, 0)
p.onTap(); assert.equal(timers.size, 1)
p.onDestroy(); p.onShow(); p.onTap(); assert.equal(timers.size, 0)
assert.doesNotMatch(ux, /onBackPress|transform\s*:/)
assert.match(ux, /class="board"[^>]*onclick="onTap"/)
})
test('two safe turns are queued and consumed one per tick', () => {
const {p} = page()
for (const direction of ['up', 'down', 'up', 'left', 'down', 'garbage']) p.onSwipe({direction})
p.tick(); assert.equal(p.model[0].y, 6); assert.equal(p.model[0].x, 6)
p.tick(); assert.equal(p.model[0].x, 5); assert.equal(p.model[0].y, 6)
p.tick(); assert.equal(p.model[0].x, 4); assert.equal(p.model[0].y, 6)
p.onSwipe({direction:'down'}); p.restart(); p.tick()
assert.equal(p.model[0].x, 7); assert.equal(p.model[0].y, 7)
})