6 changed files with 204 additions and 14 deletions
+75
View File
@@ -0,0 +1,75 @@
# AGENTS.md — vela-snake
Guidance for AI agents working on this repo. Humans can read it too.
## What this is
Textured Snake for the Xiaomi Watch S4 (round 466px screen, Vela quickapp).
Package `com.ilias.snake`. Source of truth for releases:
https://gitea.oimcloud.myaddr.tools/oiwmiodev/vela-snake (Big Intelligence group).
Upstream codex-bot/vela-snake is read-only for this account (no write permission).
## Hard rules
- Never commit `sign/*.pem` (gitignored on purpose). Builds currently use the
toolkit's default debug keys; if real keys are added, keep them out of git.
- Never edit files under `node_modules/`. The toolkit is locked at aiot-toolkit
2.0.5; do not upgrade it casually (release uses plain JS, not JSC/protobuf).
- Tests must pass before any commit: `npm test` (pure Node, no watch needed).
- Bump `versionName`/`versionCode` in `src/manifest.json` for any release.
Hotfixes: `0.9-Hotfix.1` style, versionCode still increments (integer only).
- No em dashes in docs, commits or comments.
## Crown control (the fragile part)
The Vela firmware does NOT expose crown events to quickapps (verified by
firmware string analysis, see docs/CROWN.md). Crown rotation arrives only as
scroll deltas on the focused scroll container. The page is a full-screen
vertical `scroll` with the dial centered; `onscroll` converts accumulated
deltas into queued 90-degree turns (`CROWN_STEP_PX`, 40px per turn) and snaps
the scroller back to center (`scrollTo({top: 233})`).
Known failure mode (issue oiwmiodev/vela-snake#1): the game can still scroll
away, likely because momentum scrolling outlives the snap-back or onShow's
scrollTo runs before layout. Any fix must keep: swipe steering working, the
dial visually pinned, reversals impossible. If scroll-delta extraction proves
unreliable on hardware, the fallback is accepting codex-bot issue #2's
conclusion (swipe-only input) and removing the scroll wrapper.
Crown work happens on the `crown-experimental` branch; `main` tracks the
released state.
## Layout invariants
- Dial/board: 466px screen, board at 49px offset, 368px circle, 32px cells,
11x11 grid, 89 legal cells (circular mask, see src/common/snake.js ARENA).
- Scroll content height = 932px (233px padding each side), center offset 233.
- Overlays fit the watch chord; no legal cell is ever covered by HUD/footer.
- No CSS transforms anywhere (tests assert this); tiles are positioned with
left/top only.
## Testing
`npm test` runs snake logic, piece classification, page wiring (including the
crown scroll path), upgrade regressions, numeric layout, randomized property
tests and art checks. When fixing a bug, add a failing test first, then fix.
Node tests are not device tests: behavior on the actual watch is the only
final proof, and whoever has the watch does that testing.
## Release checklist
1. All tests green.
2. `src/manifest.json` version bumped.
3. `npm run release`, verify dist/com.ilias.snake.release.<ver>.rpk exists.
4. Commit + push to origin (oiwmiodev/vela-snake).
5. Create the release with `tea releases create --repo oiwmiodev/vela-snake
--tag v<ver>` and attach the rpk with `tea releases assets create`.
Download the asset back and byte-compare before claiming success.
6. Release notes: real changelog, never "see commit history".
## Git
Git identity is not globally configured; commits must set the repo persona
(the "big pc republic" identity used across this Gitea, see prior commits)
via `git -c user.name=... -c user.email=...` or local config. Pushes go to
origin (the org repo). Auth uses the `oimcloud` tea login.
+26 -7
View File
@@ -5,9 +5,21 @@ 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 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`. board frame is gone. Directional sprite artwork and user icon are preserved. Package identity: `com.ilias.snake`.
**Version 0.8 (versionCode 8): locally tested and release-built; the new features **Version 0.9 (versionCode 9): crown control experiment, unit-tested, needs an
still need an on-watch check.** Earlier versions rendered and played on the on-watch check.** See docs/CROWN.md for the firmware reasoning.
user's Watch S4 41mm. Node tests and numeric layout checks are not device tests.
## Crown control (new in 0.9)
- The watch firmware exposes **no crown event to quickapps**; it routes crown
rotation into the scrollable container as scroll deltas. The page is now a
full-screen vertical scroll with the dial centered; `onscroll` fires for both
crown rotation and finger scroll.
- Scroll deltas accumulate; every **40 px of scroll = one 90° turn**,
clockwise = clockwise turn. Reversals are structurally impossible because
turns are relative 90° steps (`rotateDir`-style), never absolute directions.
- The scroller is snapped back to center after every event, so the board never
visibly moves, even though the scroll travel exists.
- Swipes still work exactly as before; both inputs share the two-slot queue.
## Play ## Play
@@ -28,6 +40,10 @@ user's Watch S4 41mm. Node tests and numeric layout checks are not device tests.
- Leaving the page pauses it and stops the timer. Returning requires an explicit - Leaving the page pauses it and stops the timer. Returning requires an explicit
resume; game-over never resumes automatically. The system back action remains resume; game-over never resumes automatically. The system back action remains
untouched. No crown, rotary, side-button or back interception APIs are used. untouched. No crown, rotary, side-button or back interception APIs are used.
- **Crown to steer** (0.9): rotate the crown; every 40px of scroll is one
90° turn, clockwise = clockwise. Crown and swipe feed the same queue; the
board stays visually fixed while you turn. If your firmware gives the crown
coarser steps than 40px, adjust `CROWN_STEP_PX` at the top of `index.ux`.
## Score, speed and persistence ## Score, speed and persistence
@@ -74,9 +90,11 @@ npm run release
regressions, the numeric layout suite, and deterministic randomized property tests. Upgrade coverage includes rapid regressions, the numeric layout suite, and deterministic randomized property tests. Upgrade coverage includes rapid
swipes, lifecycle/timer races, storage failures and async ordering, mode changes, swipes, lifecycle/timer races, storage failures and async ordering, mode changes,
all four Wrap edges, seam textures, full-board victory and bounded food spawning. all four Wrap edges, seam textures, full-board victory and bounded food spawning.
The page suite also drives the crown scroll path: one notch = one queued turn,
scroller re-centered, queue capped at two, no reversal, turn consumed on tick.
New behaviors were exercised through failing-then-passing test cycles. New behaviors were exercised through failing-then-passing test cycles.
Release output: `dist/com.ilias.snake.release.0.8.rpk`. The locked Release output: `dist/com.ilias.snake.release.0.9.rpk`. The locked
`aiot-toolkit` 2.0.5 uses the existing `sign/private.pem` and `sign/certificate.pem`; `aiot-toolkit` 2.0.5 uses the existing `sign/private.pem` and `sign/certificate.pem`;
do not regenerate or commit those keys. Release uses plain JS (not JSC/protobuf), do not regenerate or commit those keys. Release uses plain JS (not JSC/protobuf),
matching the previously working toolchain. The dependency lock is unchanged; matching the previously working toolchain. The dependency lock is unchanged;
@@ -85,15 +103,15 @@ matching the previously working toolchain. The dependency lock is unchanged;
## Files ## Files
- `src/pages/index/index.ux`: round-screen UI, swipe queue, game/lifecycle state. - `src/pages/index/index.ux`: round-screen UI, swipe/crown queue, game/lifecycle state.
- `src/common/snake.js`: movement, collision, Wrap piece classification, food. - `src/common/snake.js`: movement, collision, Wrap piece classification, food.
- `src/common/best.js`: asynchronous storage coordinator. - `src/common/best.js`: asynchronous storage coordinator.
- `src/common/tex/`: native 32px sprite artwork and circular board. - `src/common/tex/`: native 32px sprite artwork and circular board.
- `docs/CROWN.md`: firmware-derived rationale for the crown scroll path.
- `tests/{snake,pieces,page,upgrade,layout,properties,round,art,round-history}.cjs`: Node regression suites, including history-based circular seam orientation checks. - `tests/{snake,pieces,page,upgrade,layout,properties,round,art,round-history}.cjs`: Node regression suites, including history-based circular seam orientation checks.
Install the signed RPK through AstroBox. Source/releases live at Install the signed RPK through AstroBox. Source/releases live at
https://gitea.oimcloud.myaddr.tools/codex-bot/vela-snake — publication is a https://gitea.oimcloud.myaddr.tools/oiwmiodev/vela-snake (Big Intelligence group).
separate step after review; a local build is not evidence of a published release.
## Earlier versions ## Earlier versions
@@ -101,3 +119,4 @@ separate step after review; a local build is not evidence of a published release
- 0.6: directional textured tiles and preserved user-supplied icon. - 0.6: directional textured tiles and preserved user-supplied icon.
- 0.7: queued swipes, live/persisted records, capped acceleration, pause/resume, - 0.7: queued swipes, live/persisted records, capped acceleration, pause/resume,
lifecycle safety, retry/win panels and selectable Classic/Wrap. lifecycle safety, retry/win panels and selectable Classic/Wrap.
- 0.8: circular arena (no square frame), 32px tiles, 89 legal cells.
+34
View File
@@ -0,0 +1,34 @@
# Crown control experiment (v0.9)
Firmware study of the O63 OTA (`upd_miwear.watch.o63.zip`) found how the crown
reaches quickapps: there is **no crown/rotary JS event** (no `onCrown`,
`onRotary`, wheel keyevents in `vela_quickapp.bin`), but the native layer
(`vela_ap.bin`, `inputmanager/lv_touchpad_wheel_interface.c` + the
`crown_scroll` input tag in `lvx_inputmanager.c`) routes crown rotation into
**scroll deltas of the focused scrollable container**.
So Snake listens to the crown indirectly:
- the page is wrapped in a full-screen vertical `scroll` component with the
dial centered inside it,
- `onscroll` fires for both crown rotation and finger scroll,
- the page converts each scroll delta into a **queued 90° turn**
(`snake.rotateDir`, already in the codebase): positive delta =
clockwise, negative = counterclockwise, `CROWN_STEP_PX` px of scroll per
turn,
- because turns are 90° relative steps, the crown always turns the snake
left/right regardless of where the head is pointing, and reversals are
structurally impossible (rotateDir never emits a 180° flip),
- the scroll position is actively re-centered (`scrollTo({ top: center })`)
after each event so the board never visually moves; only the idle-swipe
fallback path (when no scroll events arrive, e.g. some firmwares coalesce
crown scroll with the swipe handler) uses `onswipe` as before,
- swipes still work exactly as in 0.8; both input paths feed the same
two-slot turn queue.
Open questions that need the physical watch:
- does the Crown S4 emit scroll deltas with sign, and at what px granularity?
- does `onscroll` fire for crown motion at all on this firmware, or only for
touch scroll? If not, the `CROWN_STEP_PX` accumulator still catches the
swipe-path fallback but true crown control would need native access.
+2 -2
View File
@@ -2,8 +2,8 @@
"package": "com.ilias.snake", "package": "com.ilias.snake",
"name": "SNAKE", "name": "SNAKE",
"icon": "/common/icon.png", "icon": "/common/icon.png",
"versionName": "0.8", "versionName": "0.9",
"versionCode": 8, "versionCode": 9,
"minAPILevel": 1, "minAPILevel": 1,
"deviceTypeList": [ "deviceTypeList": [
"watch" "watch"
+45 -1
View File
@@ -1,5 +1,6 @@
<template> <template>
<div class="page" onswipe="onSwipe"> <div class="page">
<scroll id="scroller" class="page" scroll-y="true" direction="vertical" onscroll="onCrownScroll">
<stack class="dial"> <stack class="dial">
<image class="board" src="/common/tex/board.png" onclick="onTap"></image> <image class="board" src="/common/tex/board.png" onclick="onTap"></image>
<image class="food" src="/common/tex/apple.png" show="{{ hasFood }}" onclick="onTap" style="left: {{ food.left }}; top: {{ food.top }};"></image> <image class="food" src="/common/tex/apple.png" show="{{ hasFood }}" onclick="onTap" style="left: {{ food.left }}; top: {{ food.top }};"></image>
@@ -33,6 +34,7 @@
</div> </div>
</div> </div>
</stack> </stack>
</scroll>
</div> </div>
</template> </template>
<script> <script>
@@ -43,6 +45,17 @@ import best from '../../common/best.js'
const CELL = snake.ARENA.cell const CELL = snake.ARENA.cell
const GRID = snake.ARENA const GRID = snake.ARENA
const OFFSET = snake.ARENA.offset const OFFSET = snake.ARENA.offset
// Crown rotation arrives as scroll deltas on the focused scroll container
// (see docs/CROWN.md). This many px of accumulated scroll = one 90° turn.
const CROWN_STEP_PX = 40
const CW_ORDER = [{ x: 1, y: 0 }, { x: 0, y: 1 }, { x: -1, y: 0 }, { x: 0, y: -1 }]
function rotate90(d, clockwise) {
let i = 0
for (let k = 0; k < 4; k++) {
if (CW_ORDER[k].x === d.x && CW_ORDER[k].y === d.y) { i = k; break }
}
return clockwise ? CW_ORDER[(i + 1) % 4] : CW_ORDER[(i + 3) % 4]
}
export default { export default {
private: { private: {
@@ -68,6 +81,8 @@ export default {
onShow() { onShow() {
if (this.destroyed) return if (this.destroyed) return
this.visible = true this.visible = true
this.crownAnchor = 233
try { this.$element('scroller').scrollTo({ top: 233 }) } catch (err) {}
this.records.sync() this.records.sync()
this.startTimer() this.startTimer()
}, },
@@ -157,6 +172,31 @@ export default {
if (snake.turn(last, v) === last) return if (snake.turn(last, v) === last) return
this.queue.push(v) this.queue.push(v)
}, },
// Crown rotation: firmware routes it into this container's scroll offset
// (docs/CROWN.md). Accumulate deltas, one 90° turn per CROWN_STEP_PX, then
// re-center so the board never visibly moves.
onCrownScroll(e) {
if (this.destroyed) return
const top = e && typeof e.scrollTop === 'number' ? e.scrollTop : 0
const center = 233
const d = top - (this.crownAnchor == null ? center : this.crownAnchor)
this.crownAnchor = top
if (d === 0) return
// keep the dial pinned: snap back to center immediately
try { this.$element('scroller').scrollTo({ top: center }) } catch (err) {}
this.crownAnchor = center
if (this.over || this.paused || !this.visible) return
this.crownAcc = (this.crownAcc || 0) + d
while (Math.abs(this.crownAcc) >= CROWN_STEP_PX) {
const clockwise = this.crownAcc > 0
this.crownAcc += clockwise ? -CROWN_STEP_PX : CROWN_STEP_PX
const base = this.queue.length ? this.queue[this.queue.length - 1] : this.dir
if (this.queue.length >= 2) return
const v = rotate90(base, clockwise)
if (v.x === base.x && v.y === base.y) return
this.queue.push(v)
}
},
onTap() { onTap() {
if (this.destroyed || !this.visible || this.over) return if (this.destroyed || !this.visible || this.over) return
this.paused = !this.paused this.paused = !this.paused
@@ -190,6 +230,10 @@ export default {
background-color: #000000; background-color: #000000;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
/* scrollable travel: dial centered in 932px content, 233px of crown travel
each way; onReady + onscroll snap back to the center offset (233) */
padding-top: 233px;
padding-bottom: 233px;
} }
.dial { .dial {
width: 466px; height: 466px; width: 466px; height: 466px;
+20 -2
View File
@@ -64,8 +64,26 @@ assert.match(ux, /<image class="board" src="\/common\/tex\/board\.png"[^>]*>/);
assert.match(ux, /<image class="seg" src="\{\{ seg\.src \}\}"/); assert.match(ux, /<image class="seg" src="\{\{ seg\.src \}\}"/);
assert.match(ux, /for="\{\{ seg in snake \}\}"/); assert.match(ux, /for="\{\{ seg in snake \}\}"/);
assert.match(ux, /show="\{\{ over \}\}"/); assert.match(ux, /show="\{\{ over \}\}"/);
assert.match(ux, /onswipe="onSwipe"/); assert.match(ux, /onscroll="onCrownScroll"/, 'scroll container wired for crown deltas');
assert.match(ux, /<scroll id="scroller"/, 'scroller element present for re-centering');
assert.doesNotMatch(ux, /crownrotationchanged/i); assert.doesNotMatch(ux, /crownrotationchanged/i);
assert.doesNotMatch(ux, /transform\s*:/); assert.doesNotMatch(ux, /transform\s*:/);
console.log('PAGE WIRING PASS (textures, movement, turn, reversal, game over, restart, directives)'); // crown path: scroll deltas become queued 90° turns, board stays centered
c = mk();
c.visible = true;
c.crownAnchor = 233;
c.onCrownScroll({ scrollTop: 233 + 40 }); // one notch "down"
c.onCrownScroll({ scrollTop: 233 });
assert.equal(c.queue.length, 1, 'one turn queued after one crown notch');
assert.equal(c.crownAnchor, 233, 'scroller re-centered');
assert.equal(JSON.stringify(c.queue[0]), JSON.stringify({ x: 0, y: 1 }), 'right-facing snake turned clockwise = down');
c.onCrownScroll({ scrollTop: 233 - 120 }); // three notches back
assert.equal(c.queue.length, 2, 'queue capped at 2');
assert.equal(c.dir.x, 1, 'no reversal emitted, x unchanged');
assert.equal(c.dir.y, 0, 'no reversal emitted, y unchanged');
c.onCrownScroll({ scrollTop: 233 });
c.tick();
assert.equal(JSON.stringify(c.dir), JSON.stringify({ x: 0, y: 1 }), 'first queued turn consumed');
console.log('PAGE WIRING PASS (textures, movement, turn, reversal, game over, restart, directives, crown scroll)');