Snake 0.9: crown control via scroll-delta turns (firmware-derived)
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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: {
|
||||||
@@ -157,6 +170,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 = 466
|
||||||
|
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 +228,10 @@ export default {
|
|||||||
background-color: #000000;
|
background-color: #000000;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
/* scrollable strip: dial centered in a 1398px-tall content so the crown
|
||||||
|
always has travel to scroll against; page re-centers after each event */
|
||||||
|
padding-top: 466px;
|
||||||
|
padding-bottom: 466px;
|
||||||
}
|
}
|
||||||
.dial {
|
.dial {
|
||||||
width: 466px; height: 466px;
|
width: 466px; height: 466px;
|
||||||
|
|||||||
+20
-2
@@ -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 = 466;
|
||||||
|
c.onCrownScroll({ scrollTop: 466 + 40 }); // one notch "down"
|
||||||
|
c.onCrownScroll({ scrollTop: 466 });
|
||||||
|
assert.equal(c.queue.length, 1, 'one turn queued after one crown notch');
|
||||||
|
assert.equal(c.crownAnchor, 466, '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: 466 - 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: 466 });
|
||||||
|
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)');
|
||||||
|
|||||||
Reference in New Issue
Block a user