Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18a6ef7d72 | ||
|
|
5a7081b933 |
@@ -1,51 +1,103 @@
|
||||
# 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,
|
||||
20px cells). Swipe to steer; the crown is NOT used (confirmed unsupported by the
|
||||
Vela quick-app API on this device). Tap the overlay to restart after a crash.
|
||||
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`.
|
||||
|
||||
**Status: RENDERS AND PLAYS ON WATCH** (user-confirmed since 0.2/0.3).
|
||||
**Version 0.8 (versionCode 8): 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)
|
||||
- **Tap** the game-over overlay to restart
|
||||
- Crown does nothing — no rotary API exists in Vela quick apps (triple-confirmed
|
||||
against docs + the largest open-source S4 app)
|
||||
- **Swipe** to steer. A two-turn queue accepts rapid corners, consumes one turn
|
||||
per tick, and rejects duplicate directions and reversals against the last
|
||||
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** (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** 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.
|
||||
|
||||
## Project layout
|
||||
## Score, speed and persistence
|
||||
|
||||
```text
|
||||
src/
|
||||
├── app.ux # app lifecycle
|
||||
├── manifest.json # com.ilias.snake, watch, designWidth 466
|
||||
├── 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
|
||||
```
|
||||
Each apple is one point. The always-visible header shows score and best. One
|
||||
best score is shared across Classic and Wrap, saved through documented
|
||||
[`@system.storage`](https://iot.mi.com/vela/quickapp/en/features/data/storage.html)
|
||||
under `snake.best`. Mode is session-only; a newly created page defaults to Classic.
|
||||
|
||||
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.
|
||||
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. 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
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run release # signed .rpk → dist/
|
||||
node tests/snake.cjs # logic tests
|
||||
node tests/page.cjs # page wiring tests
|
||||
npm test
|
||||
npm run release
|
||||
```
|
||||
|
||||
## Install
|
||||
`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.
|
||||
New behaviors were exercised through failing-then-passing test cycles.
|
||||
|
||||
Side-load `dist/com.ilias.snake.release.*.rpk` via AstroBox.
|
||||
Releases: https://gitea.oimcloud.myaddr.tools/codex-bot/vela-snake
|
||||
Release output: `dist/com.ilias.snake.release.0.8.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;
|
||||
`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
|
||||
- **0.3** — designWidth 480→466 fixed the undersized bezel
|
||||
- Crown-based input was tried and disproven — swipe is the only reliable input
|
||||
- `src/pages/index/index.ux`: round-screen UI, swipe queue, game/lifecycle state.
|
||||
- `src/common/snake.js`: movement, collision, Wrap piece classification, food.
|
||||
- `src/common/best.js`: asynchronous storage coordinator.
|
||||
- `src/common/tex/`: native 32px sprite artwork and circular board.
|
||||
- `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.
|
||||
|
||||
## 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,8 +2,9 @@
|
||||
"name": "xiaomi-vela-s4-hello-world",
|
||||
"version": "1.0.0",
|
||||
"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": {
|
||||
"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",
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 },
|
||||
@@ -8,14 +22,26 @@ function initialSnake() {
|
||||
}
|
||||
|
||||
// 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 nx = head.x + dir.x
|
||||
const ny = head.y + dir.y
|
||||
if (nx < 0 || ny < 0 || nx >= N || ny >= N) {
|
||||
let nx = head.x + dir.x
|
||||
let ny = head.y + dir.y
|
||||
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++) {
|
||||
@@ -37,16 +63,26 @@ function turn(current, desired) {
|
||||
// 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.
|
||||
// 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 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[(prev.x - s.x) + ',' + (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[(next.x - s.x) + ',' + (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
|
||||
@@ -71,13 +107,22 @@ 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
|
||||
let x, y
|
||||
do {
|
||||
x = Math.floor(Math.random() * N)
|
||||
y = Math.floor(Math.random() * N)
|
||||
} while (occupied.has(x + ',' + y))
|
||||
return { x, y }
|
||||
// Select an empty-cell rank uniformly; at most N*N checks, even almost full.
|
||||
let rank = Math.floor(Math.random() * (N * N - occupied.size))
|
||||
for (let y = 0; y < N; y++) {
|
||||
for (let x = 0; x < N; x++) {
|
||||
if (!occupied.has(x + ',' + y)) {
|
||||
if (rank === 0) return { x, y }
|
||||
rank--
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
module.exports = { initialSnake, step, turn, renderPieces, rotateDir, spawnFood }
|
||||
module.exports = { ARENA, initialSnake, step, turn, renderPieces, rotateDir, spawnFood }
|
||||
|
||||
|
Before Width: | Height: | Size: 902 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 672 B After Width: | Height: | Size: 791 B |
|
Before Width: | Height: | Size: 694 B After Width: | Height: | Size: 839 B |
|
Before Width: | Height: | Size: 727 B After Width: | Height: | Size: 873 B |
|
Before Width: | Height: | Size: 306 B After Width: | Height: | Size: 372 B |
|
Before Width: | Height: | Size: 321 B After Width: | Height: | Size: 383 B |
|
Before Width: | Height: | Size: 237 B After Width: | Height: | Size: 276 B |
|
Before Width: | Height: | Size: 346 B After Width: | Height: | Size: 397 B |
|
Before Width: | Height: | Size: 841 B After Width: | Height: | Size: 955 B |
|
Before Width: | Height: | Size: 918 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 913 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 731 B After Width: | Height: | Size: 878 B |
|
Before Width: | Height: | Size: 644 B After Width: | Height: | Size: 768 B |
|
Before Width: | Height: | Size: 848 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 794 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 673 B After Width: | Height: | Size: 790 B |
@@ -2,8 +2,8 @@
|
||||
"package": "com.ilias.snake",
|
||||
"name": "SNAKE",
|
||||
"icon": "/common/icon.png",
|
||||
"versionName": "0.6",
|
||||
"versionCode": 6,
|
||||
"versionName": "0.8",
|
||||
"versionCode": 8,
|
||||
"minAPILevel": 1,
|
||||
"deviceTypeList": [
|
||||
"watch"
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"features": [
|
||||
{
|
||||
"name": "system.router"
|
||||
"name": "system.storage"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,80 +1,150 @@
|
||||
<template>
|
||||
<div class="page" onswipe="onSwipe">
|
||||
<stack class="dial">
|
||||
<image class="board" src="/common/tex/board.png"></image>
|
||||
<image class="food" src="/common/tex/apple.png" style="left: {{ food.left }}px; top: {{ food.top }}px;"></image>
|
||||
<image class="seg" src="{{ seg.src }}" for="{{ seg in snake }}" style="left: {{ seg.left }}px; top: {{ seg.top }}px;"></image>
|
||||
<div class="overlay" show="{{ over }}" onclick="onTap">
|
||||
<text class="overText">GAME OVER</text>
|
||||
<text class="overSub">{{ scoreText }}</text>
|
||||
<text class="overHint">tap to play again</text>
|
||||
</div>
|
||||
<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="seg" src="{{ seg.src }}" for="{{ seg in snake }}" onclick="onTap" style="left: {{ seg.left }}; top: {{ seg.top }};"></image>
|
||||
<div class="hud">
|
||||
<text class="score">{{ scoreText }}</text>
|
||||
<text class="hint">swipe to move</text>
|
||||
<text class="best">{{ bestText }}</text>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<text class="modeLabel">{{ modeText }}</text>
|
||||
<text class="hint">swipe / tap 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>
|
||||
</stack>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
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
|
||||
|
||||
export default {
|
||||
private: {
|
||||
snake: [],
|
||||
food: { left: '73px', top: '73px' },
|
||||
food: { left: '57px', top: '57px' },
|
||||
hasFood: true,
|
||||
endText: 'GAME OVER',
|
||||
scoreText: 'SCORE 0',
|
||||
bestText: 'BEST 0',
|
||||
mode: 'classic',
|
||||
modeText: 'CLASSIC',
|
||||
paused: false,
|
||||
over: false
|
||||
},
|
||||
onInit() {
|
||||
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.records.sync()
|
||||
},
|
||||
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() {
|
||||
clearInterval(this.timer)
|
||||
this.visible = false
|
||||
this.paused = true
|
||||
this.queue = []
|
||||
this.stopTimer()
|
||||
},
|
||||
onDestroy() {
|
||||
clearInterval(this.timer)
|
||||
this.onHide()
|
||||
this.destroyed = true
|
||||
this.records.subscribe(null)
|
||||
},
|
||||
reset() {
|
||||
this.model = snake.initialSnake()
|
||||
this.foodModel = snake.spawnFood(this.model, GRID)
|
||||
this.dir = { x: 1, y: 0 }
|
||||
this.pending = null
|
||||
this.queue = []
|
||||
this.over = false
|
||||
this.endText = 'GAME OVER'
|
||||
this.paused = false
|
||||
this.rebuild()
|
||||
},
|
||||
tick() {
|
||||
if (this.over) return
|
||||
if (this.pending) {
|
||||
this.dir = snake.turn(this.dir, this.pending)
|
||||
this.pending = null
|
||||
}
|
||||
const r = snake.step(this.model, this.dir, this.foodModel, GRID)
|
||||
if (this.over || this.paused || this.destroyed || this.visible === false) return
|
||||
if (this.queue.length) this.dir = this.queue.shift()
|
||||
const r = snake.step(this.model, this.dir, this.foodModel, GRID, this.mode)
|
||||
if (!r.alive) {
|
||||
this.over = true
|
||||
clearInterval(this.timer)
|
||||
this.stopTimer()
|
||||
return
|
||||
}
|
||||
this.model = r.snake
|
||||
if (r.ate) {
|
||||
this.foodModel = snake.spawnFood(this.model, GRID)
|
||||
if (!this.foodModel) {
|
||||
this.over = true
|
||||
this.endText = 'YOU WIN'
|
||||
this.stopTimer()
|
||||
}
|
||||
}
|
||||
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() {
|
||||
if (this.destroyed || this.visible === false) return
|
||||
this.reset()
|
||||
clearInterval(this.timer)
|
||||
this.timer = setInterval(() => this.tick(), 300)
|
||||
this.startTimer()
|
||||
},
|
||||
onSwipe(e) {
|
||||
if (this.over) return
|
||||
if (this.over || this.paused || this.destroyed || this.visible === false) return
|
||||
const d = e.direction
|
||||
let v
|
||||
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 === 'down') v = { x: 0, y: 1 }
|
||||
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() {
|
||||
if (this.over) this.restart()
|
||||
if (this.destroyed || !this.visible || this.over) return
|
||||
this.paused = !this.paused
|
||||
this.queue = []
|
||||
this.startTimer()
|
||||
},
|
||||
rebuild() {
|
||||
const pieces = snake.renderPieces(this.model, this.dir)
|
||||
const pieces = snake.renderPieces(this.model, this.dir, this.mode === 'wrap' ? GRID : 0)
|
||||
const out = []
|
||||
for (let i = 0; i < this.model.length; i++) {
|
||||
const s = this.model[i]
|
||||
@@ -99,7 +175,8 @@ export default {
|
||||
})
|
||||
}
|
||||
this.snake = out
|
||||
this.food = {
|
||||
this.hasFood = !!this.foodModel
|
||||
if (this.hasFood) this.food = {
|
||||
left: (OFFSET + this.foodModel.x * CELL) + 'px',
|
||||
top: (OFFSET + this.foodModel.y * CELL) + 'px'
|
||||
}
|
||||
@@ -118,53 +195,73 @@ export default {
|
||||
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 {
|
||||
position: absolute; left: 0px; top: 24px;
|
||||
width: 466px;
|
||||
color: #9aa0aa;
|
||||
font-size: 22px;
|
||||
text-align: center;
|
||||
width: 152px; height: 20px;
|
||||
color: #d8edce; font-size: 18px; text-align: center;
|
||||
}
|
||||
.best {
|
||||
width: 152px; height: 20px;
|
||||
color: #f3bd60; font-size: 17px; text-align: center;
|
||||
}
|
||||
.hud {
|
||||
position: absolute; left: 157px; top: 16px;
|
||||
width: 152px; height: 40px;
|
||||
flex-direction: column; align-items: center;
|
||||
}
|
||||
.footer {
|
||||
position: absolute; left: 143px; top: 410px;
|
||||
width: 180px; height: 36px;
|
||||
flex-direction: column; align-items: center;
|
||||
}
|
||||
.modeLabel {
|
||||
width: 180px; height: 18px;
|
||||
color: #cee4bd; font-size: 17px; text-align: center;
|
||||
}
|
||||
.hint {
|
||||
position: absolute; left: 0px; top: 408px;
|
||||
width: 466px;
|
||||
color: #3a4052;
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
width: 180px; height: 18px;
|
||||
color: #adb7c6; font-size: 16px; text-align: center;
|
||||
}
|
||||
.overlay {
|
||||
position: absolute; left: 0px; top: 160px;
|
||||
width: 466px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: absolute; left: 93px; top: 123px;
|
||||
width: 280px; height: 220px;
|
||||
background-color: #101923; border-radius: 18px;
|
||||
flex-direction: column; align-items: center; justify-content: center;
|
||||
}
|
||||
.overText {
|
||||
height: 40px;
|
||||
color: #ffffff;
|
||||
font-size: 32px;
|
||||
text-align: center;
|
||||
width: 260px; height: 40px;
|
||||
color: #ffffff; font-size: 30px; text-align: center;
|
||||
}
|
||||
.overSub {
|
||||
height: 32px;
|
||||
color: #f3a633;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
width: 260px; height: 30px;
|
||||
color: #f3bd60; font-size: 22px; text-align: center;
|
||||
}
|
||||
.retry {
|
||||
width: 216px; height: 56px;
|
||||
background-color: #80bf55; border-radius: 12px;
|
||||
color: #101923; font-size: 23px; text-align: center;
|
||||
}
|
||||
.overHint {
|
||||
height: 30px;
|
||||
color: #9aa0aa;
|
||||
font-size: 20px;
|
||||
text-align: center;
|
||||
width: 260px; height: 30px;
|
||||
color: #adb7c6; font-size: 17px; 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>
|
||||
|
||||
@@ -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')
|
||||
@@ -0,0 +1,41 @@
|
||||
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)) }
|
||||
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)
|
||||
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)')
|
||||
@@ -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.')
|
||||
@@ -6,14 +6,14 @@ 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+snake\s+from\s+'[^']*';?/, '')
|
||||
.replace(/^const\s+\w+\s*=[^\n]*\n/gm, '')
|
||||
.replace('export default', '');
|
||||
.replace(/import\s+\w+\s+from\s+'[^']*';?/g, '')
|
||||
.replace('export default', 'globalThis.spec =');
|
||||
|
||||
const sandbox = { snake, Date, Math, String, Number, console, setInterval: () => 0, clearInterval: () => {},
|
||||
CELL: 20, GRID: 16, OFFSET: 73 };
|
||||
const sandbox = { best: require('../src/common/best.js'), storage: {get() {}, set() {}}, snake, Date, Math, String, Number, console, setInterval: () => 0, clearInterval: () => {},
|
||||
};
|
||||
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();
|
||||
@@ -60,7 +60,7 @@ assert.equal(c.model.length, 3);
|
||||
assert.equal(c.scoreText, 'SCORE 0');
|
||||
|
||||
// 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, /for="\{\{ seg in snake \}\}"/);
|
||||
assert.match(ux, /show="\{\{ over \}\}"/);
|
||||
|
||||
|
After Width: | Height: | Size: 42 KiB |
@@ -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 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?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(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++
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify({randomizedValidMoves:moves,edgeCrossings:seams}))
|
||||
@@ -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'}))
|
||||
@@ -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')
|
||||
@@ -0,0 +1,178 @@
|
||||
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()
|
||||
// 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 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 \}\}"/)
|
||||
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: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: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)
|
||||
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}, ...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', () => {
|
||||
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('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}, ...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)
|
||||
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}, ...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))
|
||||
}
|
||||
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)
|
||||
})
|
||||