[verified] Snake 0.8: circular arena and larger 32px tiles

This commit is contained in:
2026-09-12 13:18:15 +01:00
parent 5a7081b933
commit 18a6ef7d72
31 changed files with 272 additions and 70 deletions
+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*/)))
}
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)
+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 body = ux.match(/<script>([\s\S]*?)<\/script>/)[1]
.replace(/import\s+\w+\s+from\s+'[^']*';?/g, '')
.replace(/^const\s+\w+\s*=[^\n]*\n/gm, '')
.replace('export default', '');
.replace('export default', 'globalThis.spec =');
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);
const spec = vm.runInContext('(' + body + ')', sandbox);
vm.runInContext(body, sandbox);
const spec = sandbox.spec;
function mk() {
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[2].src, /tail_w\.png$/, 'tail texture');
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();
assert.equal(c.model[0].x, 11, 'head moved right 5 steps from x=6');
for (let i = 0; i < 1; i++) c.tick();
assert.equal(c.model[0].x, 7, 'head moved right one step from x=6');
assert.equal(c.over, false);
c.onSwipe({ direction: 'down' });
@@ -47,8 +47,8 @@ c.tick();
assert.equal(c.dir.x, 0, 'reversal keeps x');
assert.equal(c.dir.y, 1, 'reversal blocked');
// run into the wall to trigger game over (GRID=16, last column x=15)
c.model = [{ x: 15, y: 5 }, { x: 14, y: 5 }, { x: 13, y: 5 }];
// Hit the actual circular boundary.
c.model = [{ x: 10, y: 5 }, { x: 9, y: 5 }, { x: 8, y: 5 }];
c.dir = { x: 1, y: 0 };
c.pending = null;
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 dirs=[{x:1,y:0},{x:0,y:1},{x:-1,y:0},{x:0,y:-1}]
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]
for(let tick=0;tick<100;tick++) {
dir=snake.turn(dir,dirs[Math.floor(rand()*4)])
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 r=snake.step(cells,dir,food,16,mode)
const food=rand()<0.3?snake.step([h],dir,null,board,'wrap').snake[0]:null
const r=snake.step(cells,dir,food,board,mode)
if(!r.alive)break
if(Math.abs(r.snake[0].x-h.x)>1||Math.abs(r.snake[0].y-h.y)>1)seams++
cells=r.snake
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 name of snake.renderPieces(cells,dir,16))assert.ok(fs.existsSync(require('node:path').join(__dirname,'../src/common/tex',name+'.png')),name)
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,board))assert.ok(fs.existsSync(require('node:path').join(__dirname,'../src/common/tex',name+'.png')),name)
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', () => {
const {p, timers} = page()
// A serpentine Hamilton path, head (14,0), final food (15,0).
const path = []
for (let y=0;y<16;y++) for (let k=0;k<16;k++) path.push({x:y%2 ? k : 15-k,y})
p.model = path.slice(1); p.dir = {x:1,y:0}; p.foodModel = {x:15,y:0}
// Legal occupancy fixture isolates final-cell victory from pathfinding.
p.model = [{x:5,y:0}, ...snake.ARENA.cells.filter(c=>!(c.y===0 && (c.x===5 || c.x===6)))]
p.dir = {x:1,y:0}; p.foodModel = {x:6,y:0}
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.hasFood, false); assert.equal(p.snake.length, 256); assert.equal(timers.size, 0)
assert.equal(p.scoreText, 'SCORE 86'); assert.equal(p.bestText, 'BEST 86')
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.restart(); assert.equal(p.hasFood, true); assert.equal(p.endText, 'GAME OVER'); assert.equal(p.model.length, 3)
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.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.onTap(); p.model = [{x:15,y:7},{x:14,y:7},{x:13,y:7}]; p.tick()
assert.equal(p.model[0].x, 0); assert.match(p.snake[1].src, /body_h/)
p.onTap(); p.model = [{x:9,y:7},{x:8,y:7},{x:7,y:7}]; p.tick()
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.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)
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)
@@ -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'])
})
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()
}
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')
}
})
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', () => {
let get; const writes = []
const {p} = page({get(o) { get=o }, set(o) { writes.push(o) }})
assert.ok(get, 'best score requested'); assert.equal(get.default, '0')
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()
assert.equal(p.bestText, 'BEST 5'); assert.equal(writes.length, 1)
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()
assert.equal([...timers.values()][0].ms, 300)
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()
assert.equal(p.scoreText, 'SCORE ' + score)
assert.equal(timers.size, 1); assert.equal([...timers.values()][0].ms, Math.max(120, 300-score*10))