diff --git a/README.md b/README.md index 708f967..0575701 100644 --- a/README.md +++ b/README.md @@ -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, -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 16×16 grid: 20px tiles, 320px board, centered on a round +466px screen. Existing PNG board, directional snake pieces, apple and icon are +preserved. Package identity: `com.ilias.snake`. -**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) -- **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** (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 -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. +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 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, 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. -Releases: https://gitea.oimcloud.myaddr.tools/codex-bot/vela-snake +Release output: `dist/com.ilias.snake.release.0.7.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/`: 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. diff --git a/package.json b/package.json index 12fd35c..2badb6b 100644 --- a/package.json +++ b/package.json @@ -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", "build": "aiot build", "release": "aiot release", "build:source-map": "aiot build --devtool=source-map", diff --git a/src/common/best.js b/src/common/best.js new file mode 100644 index 0000000..5bbdf33 --- /dev/null +++ b/src/common/best.js @@ -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 } diff --git a/src/common/snake.js b/src/common/snake.js index 734e401..e1d8144 100644 --- a/src/common/snake.js +++ b/src/common/snake.js @@ -8,11 +8,14 @@ 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 (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) @@ -37,16 +40,17 @@ 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 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 = 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 - 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 === 'n' || from === 's') && (to === 'n' || to === 's')) return 'body_v' // corner: name = the two sides it connects; pairs are alphabetically sorted @@ -72,12 +76,17 @@ function rotateDir(dir, clockwise) { function spawnFood(snake, N) { const occupied = new Set(snake.map(s => s.x + ',' + s.y)) 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 } diff --git a/src/manifest.json b/src/manifest.json index 7b480cb..d8ee02e 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -2,8 +2,8 @@ "package": "com.ilias.snake", "name": "SNAKE", "icon": "/common/icon.png", - "versionName": "0.6", - "versionCode": 6, + "versionName": "0.7", + "versionCode": 7, "minAPILevel": 1, "deviceTypeList": [ "watch" @@ -26,7 +26,7 @@ }, "features": [ { - "name": "system.router" + "name": "system.storage" } ] } diff --git a/src/pages/index/index.ux b/src/pages/index/index.ux index 0f29379..7eb0c5d 100644 --- a/src/pages/index/index.ux +++ b/src/pages/index/index.ux @@ -1,21 +1,44 @@