1 Commits
Author SHA1 Message Date
codex-bot 18a6ef7d72 [verified] Snake 0.8: circular arena and larger 32px tiles 2026-09-12 13:18:15 +01:00
31 changed files with 272 additions and 70 deletions
+25 -17
View File
@@ -1,10 +1,11 @@
# SNAKE — Xiaomi Watch S4 # SNAKE — Xiaomi Watch S4
Textured Snake on a 16×16 grid: 20px tiles, 320px board, centered on a round Textured Snake on a **368px circular arena**, centered on the round 466px
466px screen. Existing PNG board, directional snake pieces, apple and icon are screen. **32px tiles** (60% larger than 0.7) occupy 89 legal cells of an 11×11
preserved. Package identity: `com.ilias.snake`. 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`.
**Version 0.7 (versionCode 7): locally tested and release-built; the new features **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 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. user's Watch S4 41mm. Node tests and numeric layout checks are not device tests.
@@ -15,14 +16,15 @@ user's Watch S4 41mm. Node tests and numeric layout checks are not device tests.
queued direction. Excess inputs are ignored, not allowed to replace a turn. 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 - **Tap the board, snake or apple** to pause. Tap **RESUME** (or an exposed part
of the board) to resume. Pausing clears queued turns. of the board) to resume. Pausing clears queued turns.
- In the pause or game-over panel, select **CLASSIC** (walls end the run) or - In the pause or game-over panel, select **CLASSIC** (the circular boundary ends the run) or
**WRAP** (cross an edge to appear on the opposite edge). Changing mode starts **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 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. reset the run. The current mode is shown below the board.
- Both modes end on self-collision. Moving into the vacating tail is legal. - 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, - **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 resets score/speed/queued turns, and starts playing. Filling the board displays
**YOU WIN** and hides the apple safely. **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 - 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.
@@ -41,18 +43,24 @@ paused, finished or destroyed. Old timer callbacks are invalidated.
Storage reads merge with the in-memory maximum; writes wait for the initial 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 read and are serialized/coalesced so slow callbacks cannot overwrite a newer
record. The coordinator survives page recreation within the module lifetime. record. The coordinator survives page recreation within the module lifetime.
Invalid saved values are ignored. Errors/exceptions do not interrupt gameplay: 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 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 loop. A failed/uncompleted write or immediate app termination may lose the
unsaved record. Storage persistence still needs verification on the watch. unsaved record. Storage persistence still needs verification on the watch.
## Lightweight rendering ## Lightweight rendering
Directional PNG tiles use only `left`/`top` positioning, no transforms or new Directional PNG tiles use only `left`/`top` positioning, no transforms. Existing
art assets. Wrapped neighbors are normalized before classifying seam textures. 20px sprite art is nearest-neighbor enlarged to native 32px assets (crisp pixel
Food placement takes at most 256 cell checks, including a nearly full board. art, not new high-resolution detail). The board is a newly generated circular
The HUD, footer, board and overlay fit the circular chord numerically with PNG with transparent corners and a subtle legal-cell checker pattern. Wrapped
at least an 8px total horizontal safety allowance. 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 ## Build and test
@@ -62,13 +70,13 @@ npm test
npm run release npm run release
``` ```
`npm test` runs the original core, piece and page tests, 13 incremental upgrade `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 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.
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.7.rpk`. The locked 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`; `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;
@@ -80,8 +88,8 @@ matching the previously working toolchain. The dependency lock is unchanged;
- `src/pages/index/index.ux`: round-screen UI, swipe queue, game/lifecycle state. - `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/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/`: preserved textured artwork. - `src/common/tex/`: native 32px sprite artwork and circular board.
- `tests/{snake,pieces,page,upgrade,layout,properties}.cjs`: Node regression suites. - `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/codex-bot/vela-snake — publication is a
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true, "private": true,
"description": "Textured Snake for the round Xiaomi Watch S4 screen", "description": "Textured Snake for the round Xiaomi Watch S4 screen",
"scripts": { "scripts": {
"test": "node tests/snake.cjs && node tests/pieces.cjs && node tests/page.cjs && node --test tests/upgrade.cjs && node tests/layout.cjs && node tests/properties.cjs", "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", "build": "aiot build",
"release": "aiot release", "release": "aiot release",
"build:source-map": "aiot build --devtool=source-map", "build:source-map": "aiot build --devtool=source-map",
+42 -6
View File
@@ -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() { function initialSnake() {
return [ return [
{ x: 6, y: 7 }, { x: 6, y: 7 },
@@ -12,13 +26,22 @@ function step(snake, dir, food, N, mode) {
const head = snake[0] const head = snake[0]
let nx = head.x + dir.x let nx = head.x + dir.x
let ny = head.y + dir.y let ny = head.y + dir.y
if (mode === 'wrap') { 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 nx = (nx + N) % N
ny = (ny + N) % N ny = (ny + N) % N
} else if (nx < 0 || ny < 0 || nx >= N || ny >= N) { } else if (nx < 0 || ny < 0 || nx >= N || ny >= N) {
return { snake, ate: false, alive: false } return { snake, ate: false, alive: false }
} }
const ate = (nx === food.x && ny === food.y) const ate = !!food && (nx === food.x && ny === food.y)
// tail cell vacates unless we grew, so exclude it from self-collision check // tail cell vacates unless we grew, so exclude it from self-collision check
const body = ate ? snake : snake.slice(0, snake.length - 1) const body = ate ? snake : snake.slice(0, snake.length - 1)
for (let i = 0; i < body.length; i++) { for (let i = 0; i < body.length; i++) {
@@ -44,13 +67,22 @@ function renderPieces(snakeCells, dir, N) {
const D = { '1,0': 'e', '-1,0': 'w', '0,-1': 'n', '0,1': 's' } const D = { '1,0': 'e', '-1,0': 'w', '0,-1': 'n', '0,1': 's' }
const opp = { e: 'w', w: 'e', n: 's', s: 'n' } const opp = { e: 'w', w: 'e', n: 's', s: 'n' }
const delta = d => N && d === N - 1 ? -1 : N && d === 1 - N ? 1 : d 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) => { return snakeCells.map((s, i) => {
if (i === 0) return 'head_' + D[dir.x + ',' + dir.y] if (i === 0) return 'head_' + D[dir.x + ',' + dir.y]
const prev = snakeCells[i - 1] const prev = snakeCells[i - 1]
const next = snakeCells[i + 1] const next = snakeCells[i + 1]
const from = D[delta(prev.x - s.x) + ',' + delta(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 if (!next) return 'tail_' + opp[from] // tip points away from body
const to = D[delta(next.x - s.x) + ',' + delta(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 === 'e' || from === 'w') && (to === 'e' || to === 'w')) return 'body_h'
if ((from === 'n' || from === 's') && (to === 'n' || to === 's')) return 'body_v' if ((from === 'n' || from === 's') && (to === 'n' || to === 's')) return 'body_v'
// corner: name = the two sides it connects; pairs are alphabetically sorted // corner: name = the two sides it connects; pairs are alphabetically sorted
@@ -75,6 +107,10 @@ function rotateDir(dir, clockwise) {
// Random empty cell, avoiding the snake. // Random empty cell, avoiding the snake.
function spawnFood(snake, N) { function spawnFood(snake, N) {
const occupied = new Set(snake.map(s => s.x + ',' + s.y)) const occupied = new Set(snake.map(s => s.x + ',' + s.y))
if (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 if (occupied.size >= N * N) return null
// Select an empty-cell rank uniformly; at most N*N checks, even almost full. // Select an empty-cell rank uniformly; at most N*N checks, even almost full.
let rank = Math.floor(Math.random() * (N * N - occupied.size)) let rank = Math.floor(Math.random() * (N * N - occupied.size))
@@ -89,4 +125,4 @@ function spawnFood(snake, N) {
return null return null
} }
module.exports = { initialSnake, step, turn, renderPieces, rotateDir, spawnFood } module.exports = { ARENA, initialSnake, step, turn, renderPieces, rotateDir, spawnFood }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 902 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 672 B

After

Width:  |  Height:  |  Size: 791 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 694 B

After

Width:  |  Height:  |  Size: 839 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 727 B

After

Width:  |  Height:  |  Size: 873 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 B

After

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 B

After

Width:  |  Height:  |  Size: 383 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 B

After

Width:  |  Height:  |  Size: 276 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 346 B

After

Width:  |  Height:  |  Size: 397 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 841 B

After

Width:  |  Height:  |  Size: 955 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 918 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 913 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 731 B

After

Width:  |  Height:  |  Size: 878 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 644 B

After

Width:  |  Height:  |  Size: 768 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 794 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 673 B

After

Width:  |  Height:  |  Size: 790 B

+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.7", "versionName": "0.8",
"versionCode": 7, "versionCode": 8,
"minAPILevel": 1, "minAPILevel": 1,
"deviceTypeList": [ "deviceTypeList": [
"watch" "watch"
+17 -17
View File
@@ -10,7 +10,7 @@
</div> </div>
<div class="footer"> <div class="footer">
<text class="modeLabel">{{ modeText }}</text> <text class="modeLabel">{{ modeText }}</text>
<text class="hint">swipe / tap to pause</text> <text class="hint">swipe / tap pause</text>
</div> </div>
<div class="overlay" show="{{ paused && !over }}"> <div class="overlay" show="{{ paused && !over }}">
<text class="overText">PAUSED</text> <text class="overText">PAUSED</text>
@@ -40,14 +40,14 @@ import snake from '../../common/snake.js'
import storage from '@system.storage' import storage from '@system.storage'
import best from '../../common/best.js' import best from '../../common/best.js'
const CELL = 20 const CELL = snake.ARENA.cell
const GRID = 16 const GRID = snake.ARENA
const OFFSET = 73 const OFFSET = snake.ARENA.offset
export default { export default {
private: { private: {
snake: [], snake: [],
food: { left: '73px', top: '73px' }, food: { left: '57px', top: '57px' },
hasFood: true, hasFood: true,
endText: 'GAME OVER', endText: 'GAME OVER',
scoreText: 'SCORE 0', scoreText: 'SCORE 0',
@@ -195,41 +195,41 @@ export default {
width: 466px; height: 466px; width: 466px; height: 466px;
} }
.board { .board {
position: absolute; left: 73px; top: 73px; position: absolute; left: 49px; top: 49px;
width: 320px; height: 320px; width: 368px; height: 368px;
} }
.seg { .seg {
position: absolute; position: absolute;
width: 20px; height: 20px; width: 32px; height: 32px;
} }
.food { .food {
position: absolute; position: absolute;
width: 20px; height: 20px; width: 32px; height: 32px;
} }
.score { .score {
width: 188px; height: 22px; width: 152px; height: 20px;
color: #d8edce; font-size: 18px; text-align: center; color: #d8edce; font-size: 18px; text-align: center;
} }
.best { .best {
width: 188px; height: 22px; width: 152px; height: 20px;
color: #f3bd60; font-size: 17px; text-align: center; color: #f3bd60; font-size: 17px; text-align: center;
} }
.hud { .hud {
position: absolute; left: 139px; top: 24px; position: absolute; left: 157px; top: 16px;
width: 188px; height: 44px; width: 152px; height: 40px;
flex-direction: column; align-items: center; flex-direction: column; align-items: center;
} }
.footer { .footer {
position: absolute; left: 135px; top: 400px; position: absolute; left: 143px; top: 410px;
width: 196px; height: 40px; width: 180px; height: 36px;
flex-direction: column; align-items: center; flex-direction: column; align-items: center;
} }
.modeLabel { .modeLabel {
width: 196px; height: 20px; width: 180px; height: 18px;
color: #cee4bd; font-size: 17px; text-align: center; color: #cee4bd; font-size: 17px; text-align: center;
} }
.hint { .hint {
width: 196px; height: 20px; width: 180px; height: 18px;
color: #adb7c6; font-size: 16px; text-align: center; color: #adb7c6; font-size: 16px; text-align: center;
} }
.overlay { .overlay {
+8
View File
@@ -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')
+14 -1
View File
@@ -8,7 +8,20 @@ function rule(name) {
return Object.fromEntries(m[1].split(';').filter(s=>s.includes(':')).map(s=>s.trim().split(/:\s*/))) 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)) } function px(r,k) { assert.match(r[k] || '', /^\d+px$/, k); return Number(r[k].slice(0,-2)) }
for (const name of ['board','hud','footer','overlay']) { const 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 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 dy = Math.max(Math.abs(y-233), Math.abs(y+h-233))
const chord = 2*Math.sqrt(233*233-dy*dy) const chord = 2*Math.sqrt(233*233-dy*dy)
+64
View File
@@ -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.')
+9 -9
View File
@@ -7,13 +7,13 @@ const snake = require('../src/common/snake.js');
const ux = fs.readFileSync('src/pages/index/index.ux', 'utf8'); const ux = fs.readFileSync('src/pages/index/index.ux', 'utf8');
const body = ux.match(/<script>([\s\S]*?)<\/script>/)[1] const body = ux.match(/<script>([\s\S]*?)<\/script>/)[1]
.replace(/import\s+\w+\s+from\s+'[^']*';?/g, '') .replace(/import\s+\w+\s+from\s+'[^']*';?/g, '')
.replace(/^const\s+\w+\s*=[^\n]*\n/gm, '') .replace('export default', 'globalThis.spec =');
.replace('export default', '');
const sandbox = { best: require('../src/common/best.js'), storage: {get() {}, set() {}}, snake, Date, Math, String, Number, console, setInterval: () => 0, clearInterval: () => {}, const sandbox = { best: require('../src/common/best.js'), storage: {get() {}, set() {}}, snake, Date, Math, String, Number, console, setInterval: () => 0, clearInterval: () => {},
CELL: 20, GRID: 16, OFFSET: 73 }; };
vm.createContext(sandbox); vm.createContext(sandbox);
const spec = vm.runInContext('(' + body + ')', sandbox); vm.runInContext(body, sandbox);
const spec = sandbox.spec;
function mk() { function mk() {
const o = Object.assign({}, spec, JSON.parse(JSON.stringify(spec.private))); 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[1].src, /body_h\.png$/, 'body texture');
assert.match(c.snake[2].src, /tail_w\.png$/, 'tail texture'); assert.match(c.snake[2].src, /tail_w\.png$/, 'tail texture');
assert.equal(c.scoreText, 'SCORE 0'); 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(); for (let i = 0; i < 1; i++) c.tick();
assert.equal(c.model[0].x, 11, 'head moved right 5 steps from x=6'); assert.equal(c.model[0].x, 7, 'head moved right one step from x=6');
assert.equal(c.over, false); assert.equal(c.over, false);
c.onSwipe({ direction: 'down' }); c.onSwipe({ direction: 'down' });
@@ -47,8 +47,8 @@ c.tick();
assert.equal(c.dir.x, 0, 'reversal keeps x'); assert.equal(c.dir.x, 0, 'reversal keeps x');
assert.equal(c.dir.y, 1, 'reversal blocked'); assert.equal(c.dir.y, 1, 'reversal blocked');
// run into the wall to trigger game over (GRID=16, last column x=15) // Hit the actual circular boundary.
c.model = [{ x: 15, y: 5 }, { x: 14, y: 5 }, { x: 13, y: 5 }]; c.model = [{ x: 10, y: 5 }, { x: 9, y: 5 }, { x: 8, y: 5 }];
c.dir = { x: 1, y: 0 }; c.dir = { x: 1, y: 0 };
c.pending = null; c.pending = null;
c.tick(); c.tick();
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+5 -5
View File
@@ -5,19 +5,19 @@ let seed=83971
const rand=()=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return seed/4294967296} 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}] const dirs=[{x:1,y:0},{x:0,y:1},{x:-1,y:0},{x:0,y:-1}]
let moves=0, seams=0 let moves=0, seams=0
for(const mode of ['classic','wrap']) for(let game=0;game<1000;game++) { 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] let cells=snake.initialSnake(),dir=dirs[0]
for(let tick=0;tick<100;tick++) { for(let tick=0;tick<100;tick++) {
dir=snake.turn(dir,dirs[Math.floor(rand()*4)]) dir=snake.turn(dir,dirs[Math.floor(rand()*4)])
const h=cells[0] const h=cells[0]
const food=rand()<0.3?{x:(h.x+dir.x+16)%16,y:(h.y+dir.y+16)%16}:{x:0,y:0} const food=rand()<0.3?snake.step([h],dir,null,board,'wrap').snake[0]:null
const r=snake.step(cells,dir,food,16,mode) const r=snake.step(cells,dir,food,board,mode)
if(!r.alive)break if(!r.alive)break
if(Math.abs(r.snake[0].x-h.x)>1||Math.abs(r.snake[0].y-h.y)>1)seams++ if(Math.abs(r.snake[0].x-h.x)>1||Math.abs(r.snake[0].y-h.y)>1)seams++
cells=r.snake cells=r.snake
assert.equal(new Set(cells.map(c=>c.x+','+c.y)).size,cells.length) assert.equal(new Set(cells.map(c=>c.x+','+c.y)).size,cells.length)
for(const c of cells)assert.ok(c.x>=0&&c.x<16&&c.y>=0&&c.y<16) for(const 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,16))assert.ok(fs.existsSync(require('node:path').join(__dirname,'../src/common/tex',name+'.png')),name) 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++ moves++
} }
} }
+30
View File
@@ -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'}))
+38
View File
@@ -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')
+17 -12
View File
@@ -40,13 +40,12 @@ test('retired timer callbacks cannot advance a resumed or restarted run', () =>
}) })
test('filling the board wins without null food crashes and retry clears the result', () => { test('filling the board wins without null food crashes and retry clears the result', () => {
const {p, timers} = page() const {p, timers} = page()
// A serpentine Hamilton path, head (14,0), final food (15,0). // Legal occupancy fixture isolates final-cell victory from pathfinding.
const path = [] p.model = [{x:5,y:0}, ...snake.ARENA.cells.filter(c=>!(c.y===0 && (c.x===5 || c.x===6)))]
for (let y=0;y<16;y++) for (let k=0;k<16;k++) path.push({x:y%2 ? k : 15-k,y}) p.dir = {x:1,y:0}; p.foodModel = {x:6,y:0}
p.model = path.slice(1); p.dir = {x:1,y:0}; p.foodModel = {x:15,y:0}
p.tick(); assert.equal(p.over, true); assert.equal(p.endText, 'YOU WIN') p.tick(); assert.equal(p.over, true); assert.equal(p.endText, 'YOU WIN')
assert.equal(p.scoreText, 'SCORE 253'); assert.equal(p.bestText, 'BEST 253') assert.equal(p.scoreText, 'SCORE 86'); assert.equal(p.bestText, 'BEST 86')
assert.equal(p.hasFood, false); assert.equal(p.snake.length, 256); assert.equal(timers.size, 0) 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.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) 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, /show="\{\{ hasFood \}\}"/)
@@ -58,11 +57,11 @@ test('select modes while paused or over, resetting safely, and retry keeps mode'
p.selectMode('wrap'); assert.equal(p.mode, 'classic', 'no mid-run mode change') p.selectMode('wrap'); assert.equal(p.mode, 'classic', 'no mid-run mode change')
p.onTap(); p.selectMode('garbage'); assert.equal(p.mode, 'classic') 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.selectMode('wrap'); assert.equal(p.mode, 'wrap'); assert.equal(p.paused, true); assert.equal(timers.size, 0)
p.onTap(); p.model = [{x:15,y:7},{x:14,y:7},{x:13,y:7}]; p.tick() p.onTap(); p.model = [{x:9,y:7},{x:8,y:7},{x:7,y:7}]; p.tick()
assert.equal(p.model[0].x, 0); assert.match(p.snake[1].src, /body_h/) 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.restart(); assert.equal(p.mode, 'wrap'); assert.equal(timers.size, 1)
p.onTap(); p.selectMode('classic'); p.onTap() p.onTap(); p.selectMode('classic'); p.onTap()
p.model = [{x:15,y:7},{x:14,y:7},{x:13,y:7}]; p.tick() 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) 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.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) p.restart(); assert.equal(p.over, false); assert.equal(timers.size, 1)
@@ -95,7 +94,7 @@ test('Wrap crosses every edge, still collides with body and renders seam connect
assert.deepEqual(snake.renderPieces([{x:0,y:7},{x:15,y:7}], dirs[0], 16), ['head_e','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) { function eatTo(p, score) {
p.model = [{x:6,y:7}, ...Array.from({length:score+1}, (_,i) => ({x:i%16,y:12+Math.floor(i/16)}))] p.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() 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', () => { test('late best read cannot lower local record; writes are serialized', () => {
@@ -124,12 +123,18 @@ test('storage throws and corrupt values never crash or poison gameplay', () => {
assert.equal(p.bestText, 'BEST 0'); eatTo(p, 1); assert.equal(p.bestText, 'BEST 1') 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', () => { test('best score loads raw storage value and persists only new records', () => {
let get; const writes = [] let get; const writes = []
const {p} = page({get(o) { get=o }, set(o) { writes.push(o) }}) const {p} = page({get(o) { get=o }, set(o) { writes.push(o) }})
assert.ok(get, 'best score requested'); assert.equal(get.default, '0') assert.ok(get, 'best score requested'); assert.equal(get.default, '0')
get.success('4'); assert.equal(p.bestText, 'BEST 4') get.success('4'); assert.equal(p.bestText, 'BEST 4')
p.model = [{x:6,y:7}, ...Array.from({length:6}, (_,i) => ({x:i,y:12}))] p.model = [{x:6,y:7}, ...snake.ARENA.cells.filter(c=>c.y!==7).slice(0,6)]
p.foodModel = {x:7,y:7}; p.tick() p.foodModel = {x:7,y:7}; p.tick()
assert.equal(p.bestText, 'BEST 5'); assert.equal(writes.length, 1) assert.equal(p.bestText, 'BEST 5'); assert.equal(writes.length, 1)
assert.equal(writes[0].value, '5'); assert.equal(writes[0].key, get.key) assert.equal(writes[0].value, '5'); assert.equal(writes[0].key, get.key)
@@ -141,7 +146,7 @@ test('eating updates live score and accelerates to a capped 120ms', () => {
const {p, timers} = page() const {p, timers} = page()
assert.equal([...timers.values()][0].ms, 300) assert.equal([...timers.values()][0].ms, 300)
for (let score = 1; score <= 20; score++) { for (let score = 1; score <= 20; score++) {
p.model = [{x:6,y:7}, ...Array.from({length:score+1}, (_,i) => ({x:i%16,y:12+Math.floor(i/16)}))] p.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() p.dir = {x:1,y:0}; p.foodModel = {x:7,y:7}; p.tick()
assert.equal(p.scoreText, 'SCORE ' + score) assert.equal(p.scoreText, 'SCORE ' + score)
assert.equal(timers.size, 1); assert.equal([...timers.values()][0].ms, Math.max(120, 300-score*10)) assert.equal(timers.size, 1); assert.equal([...timers.values()][0].ms, Math.max(120, 300-score*10))