diff --git a/README.md b/README.md index 0575701..bbe5c8c 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # SNAKE — Xiaomi Watch S4 -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`. +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`. -**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 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. - **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 +- 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** 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 resume; game-over never resumes automatically. The system back action remains 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 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: +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 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. +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 @@ -62,13 +70,13 @@ npm test 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 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. -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`; do not regenerate or commit those keys. Release uses plain JS (not JSC/protobuf), 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/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. +- `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 diff --git a/package.json b/package.json index 2badb6b..f598d6d 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "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", + "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", diff --git a/src/common/snake.js b/src/common/snake.js index e1d8144..06476c4 100644 --- a/src/common/snake.js +++ b/src/common/snake.js @@ -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 }, @@ -12,13 +26,22 @@ function step(snake, dir, food, N, mode) { const head = snake[0] let nx = head.x + dir.x 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 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++) { @@ -44,13 +67,22 @@ 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[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 - 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 === 'n' || from === 's') && (to === 'n' || to === 's')) return 'body_v' // 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. 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 // Select an empty-cell rank uniformly; at most N*N checks, even almost full. let rank = Math.floor(Math.random() * (N * N - occupied.size)) @@ -89,4 +125,4 @@ function spawnFood(snake, N) { return null } -module.exports = { initialSnake, step, turn, renderPieces, rotateDir, spawnFood } +module.exports = { ARENA, initialSnake, step, turn, renderPieces, rotateDir, spawnFood } diff --git a/src/common/tex/apple.png b/src/common/tex/apple.png index 556ee0e..3d5413f 100644 Binary files a/src/common/tex/apple.png and b/src/common/tex/apple.png differ diff --git a/src/common/tex/board.png b/src/common/tex/board.png index 3fc917a..6c161fc 100644 Binary files a/src/common/tex/board.png and b/src/common/tex/board.png differ diff --git a/src/common/tex/body.png b/src/common/tex/body.png index 186c627..0729f22 100644 Binary files a/src/common/tex/body.png and b/src/common/tex/body.png differ diff --git a/src/common/tex/body_h.png b/src/common/tex/body_h.png index 2e50ed1..73ece64 100644 Binary files a/src/common/tex/body_h.png and b/src/common/tex/body_h.png differ diff --git a/src/common/tex/body_v.png b/src/common/tex/body_v.png index f43235b..88c9379 100644 Binary files a/src/common/tex/body_v.png and b/src/common/tex/body_v.png differ diff --git a/src/common/tex/corner_en.png b/src/common/tex/corner_en.png index 3bb5503..9a03cd2 100644 Binary files a/src/common/tex/corner_en.png and b/src/common/tex/corner_en.png differ diff --git a/src/common/tex/corner_es.png b/src/common/tex/corner_es.png index bdccc5f..b80b8d4 100644 Binary files a/src/common/tex/corner_es.png and b/src/common/tex/corner_es.png differ diff --git a/src/common/tex/corner_nw.png b/src/common/tex/corner_nw.png index 9382c87..1e0422d 100644 Binary files a/src/common/tex/corner_nw.png and b/src/common/tex/corner_nw.png differ diff --git a/src/common/tex/corner_sw.png b/src/common/tex/corner_sw.png index 06020aa..17ab34c 100644 Binary files a/src/common/tex/corner_sw.png and b/src/common/tex/corner_sw.png differ diff --git a/src/common/tex/head_e.png b/src/common/tex/head_e.png index 29d2ad0..ea09c4a 100644 Binary files a/src/common/tex/head_e.png and b/src/common/tex/head_e.png differ diff --git a/src/common/tex/head_n.png b/src/common/tex/head_n.png index 89772e8..a2f3349 100644 Binary files a/src/common/tex/head_n.png and b/src/common/tex/head_n.png differ diff --git a/src/common/tex/head_s.png b/src/common/tex/head_s.png index 6b97ccc..33ac95e 100644 Binary files a/src/common/tex/head_s.png and b/src/common/tex/head_s.png differ diff --git a/src/common/tex/head_w.png b/src/common/tex/head_w.png index 2878d29..896661f 100644 Binary files a/src/common/tex/head_w.png and b/src/common/tex/head_w.png differ diff --git a/src/common/tex/tail_e.png b/src/common/tex/tail_e.png index 1cbeb52..d9e49d5 100644 Binary files a/src/common/tex/tail_e.png and b/src/common/tex/tail_e.png differ diff --git a/src/common/tex/tail_n.png b/src/common/tex/tail_n.png index 5891509..d5c4551 100644 Binary files a/src/common/tex/tail_n.png and b/src/common/tex/tail_n.png differ diff --git a/src/common/tex/tail_s.png b/src/common/tex/tail_s.png index 9168b17..b34fb24 100644 Binary files a/src/common/tex/tail_s.png and b/src/common/tex/tail_s.png differ diff --git a/src/common/tex/tail_w.png b/src/common/tex/tail_w.png index bee4e41..3a24de0 100644 Binary files a/src/common/tex/tail_w.png and b/src/common/tex/tail_w.png differ diff --git a/src/manifest.json b/src/manifest.json index d8ee02e..5ef6c13 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.7", - "versionCode": 7, + "versionName": "0.8", + "versionCode": 8, "minAPILevel": 1, "deviceTypeList": [ "watch" diff --git a/src/pages/index/index.ux b/src/pages/index/index.ux index 7eb0c5d..57abf79 100644 --- a/src/pages/index/index.ux +++ b/src/pages/index/index.ux @@ -10,7 +10,7 @@
PAUSED @@ -40,14 +40,14 @@ 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', @@ -195,41 +195,41 @@ 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 { - width: 188px; height: 22px; + width: 152px; height: 20px; color: #d8edce; font-size: 18px; text-align: center; } .best { - width: 188px; height: 22px; + width: 152px; height: 20px; color: #f3bd60; font-size: 17px; text-align: center; } .hud { - position: absolute; left: 139px; top: 24px; - width: 188px; height: 44px; + position: absolute; left: 157px; top: 16px; + width: 152px; height: 40px; flex-direction: column; align-items: center; } .footer { - position: absolute; left: 135px; top: 400px; - width: 196px; height: 40px; + position: absolute; left: 143px; top: 410px; + width: 180px; height: 36px; flex-direction: column; align-items: center; } .modeLabel { - width: 196px; height: 20px; + width: 180px; height: 18px; color: #cee4bd; font-size: 17px; text-align: center; } .hint { - width: 196px; height: 20px; + width: 180px; height: 18px; color: #adb7c6; font-size: 16px; text-align: center; } .overlay { diff --git a/tests/art.cjs b/tests/art.cjs new file mode 100644 index 0000000..704a98d --- /dev/null +++ b/tests/art.cjs @@ -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') diff --git a/tests/layout.cjs b/tests/layout.cjs index e3070f9..38427c6 100644 --- a/tests/layout.cjs +++ b/tests/layout.cjs @@ -8,7 +8,20 @@ function rule(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)) } -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 dy = Math.max(Math.abs(y-233), Math.abs(y+h-233)) const chord = 2*Math.sqrt(233*233-dy*dy) diff --git a/tests/make_round_art.py b/tests/make_round_art.py new file mode 100644 index 0000000..9a6752f --- /dev/null +++ b/tests/make_round_art.py @@ -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.') diff --git a/tests/page.cjs b/tests/page.cjs index e04c1c3..d546dc3 100644 --- a/tests/page.cjs +++ b/tests/page.cjs @@ -7,13 +7,13 @@ const snake = require('../src/common/snake.js'); const ux = fs.readFileSync('src/pages/index/index.ux', 'utf8'); const body = ux.match(/