SNAKE game v0.3: round screen, swipe control

- 16x16 grid, 320px board centered on round 466 screen, designWidth 466
- pure snake logic in src/common/snake.js (step/turn/rotateDir/spawnFood, TDD'd)
- for-directive segment rendering, show-gated game-over overlay, 300ms tick
- swipe direction input (crown rotation confirmed unsupported on Vela S4)
- tests: snake logic + page wiring
This commit is contained in:
2026-09-09 15:09:23 +01:00
commit e618c02c1b
13 changed files with 10875 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
// Page wiring test: drives the VM methods extracted from index.ux.
const fs = require('node:fs');
const vm = require('node:vm');
const assert = require('node:assert/strict');
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+snake\s+from\s+'[^']*';?/, '')
.replace(/^const\s+\w+\s*=[^\n]*\n/gm, '')
.replace('export default', '');
const sandbox = { snake, Date, Math, String, Number, console, setInterval: () => 0, clearInterval: () => {},
CELL: 20, GRID: 16, OFFSET: 73 };
vm.createContext(sandbox);
const spec = vm.runInContext('(' + body + ')', sandbox);
function mk() {
const o = Object.assign({}, spec, JSON.parse(JSON.stringify(spec.private)));
o.$element = () => null;
spec.onInit.call(o);
return o;
}
let c = mk();
assert.equal(c.model.length, 3, 'initialized to length 3');
assert.equal(c.snake.length, 3, 'rendered 3 segments');
assert.equal(c.snake[0].cls, 'seg head', 'head flagged');
assert.equal(c.snake[1].cls, 'seg', 'body flagged');
assert.equal(c.scoreText, 'SCORE 0');
assert.equal(c.snake[0].left, (73 + 6 * 20 + 1) + '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');
assert.equal(c.over, false);
c.onSwipe({ direction: 'down' });
c.tick();
assert.equal(c.dir.x, 0, 'dir x');
assert.equal(c.dir.y, 1, 'direction turned down');
assert.equal(c.model[0].y, 8, 'head moved down');
c.onSwipe({ direction: 'up' });
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 }];
c.dir = { x: 1, y: 0 };
c.pending = null;
c.tick();
assert.equal(c.over, true, 'game over on wall hit');
c.restart();
assert.equal(c.over, false);
assert.equal(c.model.length, 3);
assert.equal(c.scoreText, 'SCORE 0');
// template uses documented directives/events, no transforms, no crown remnants
assert.match(ux, /for="\{\{ seg in snake \}\}"/);
assert.match(ux, /show="\{\{ over \}\}"/);
assert.match(ux, /onswipe="onSwipe"/);
assert.doesNotMatch(ux, /crownrotationchanged/i);
assert.doesNotMatch(ux, /transform\s*:/);
console.log('PAGE WIRING PASS (movement, turn, reversal, game over, restart, directives)');
+70
View File
@@ -0,0 +1,70 @@
// TDD: Snake core — pure functions, no Vela APIs.
const assert = require('node:assert/strict');
const snake = require('../src/common/snake.js');
// initial snake: head first, heading right, length 3
const init = snake.initialSnake();
assert.equal(init.length, 3, 'starts length 3');
assert.deepEqual(init[0], { x: 6, y: 7 }, 'head at start cell');
assert.deepEqual(init[1], { x: 5, y: 7 }, 'body behind head');
assert.deepEqual(init[2], { x: 4, y: 7 }, 'tail');
// move right into empty cell: alive, no food, length unchanged, tail advances
let r = snake.step(init, { x: 1, y: 0 }, { x: 10, y: 10 }, 14);
assert.equal(r.alive, true);
assert.equal(r.ate, false);
assert.equal(r.snake.length, 3);
assert.deepEqual(r.snake[0], { x: 7, y: 7 }, 'head moved right');
assert.deepEqual(r.snake[2], { x: 5, y: 7 }, 'tail advanced');
// eat food: length grows by 1, tail retained
r = snake.step(r.snake, { x: 1, y: 0 }, { x: 8, y: 7 }, 14);
assert.equal(r.ate, true, 'ate the food ahead');
assert.equal(r.snake.length, 4, 'grew by one');
assert.deepEqual(r.snake[0], { x: 8, y: 7 });
// wall collision: move left wall
r = snake.step([{ x: 0, y: 5 }, { x: 1, y: 5 }], { x: -1, y: 0 }, { x: 3, y: 3 }, 14);
assert.equal(r.alive, false, 'hit left wall');
r = snake.step([{ x: 13, y: 5 }, { x: 12, y: 5 }], { x: 1, y: 0 }, { x: 3, y: 3 }, 14);
assert.equal(r.alive, false, 'hit right wall');
r = snake.step([{ x: 5, y: 0 }, { x: 5, y: 1 }], { x: 0, y: -1 }, { x: 3, y: 3 }, 14);
assert.equal(r.alive, false, 'hit top wall');
r = snake.step([{ x: 5, y: 13 }, { x: 5, y: 12 }], { x: 0, y: 1 }, { x: 3, y: 3 }, 14);
assert.equal(r.alive, false, 'hit bottom wall');
// self collision: run into own body
const loop = [{ x: 5, y: 5 }, { x: 5, y: 6 }, { x: 6, y: 6 }, { x: 6, y: 5 }];
r = snake.step(loop, { x: 0, y: -1 }, { x: 3, y: 3 }, 14); // head 5,5 -> 5,4? no, dir -y -> 5,4 empty
assert.equal(r.alive, true, 'moving away from body is safe');
// head at 5,5 move up into 5,4 (empty), then try to move into own neck is blocked by dir reversal
// direct: build snake where head's next cell is body
const sb = [{ x: 5, y: 5 }, { x: 4, y: 5 }, { x: 4, y: 4 }, { x: 5, y: 4 }];
r = snake.step(sb, { x: 0, y: 1 }, { x: 9, y: 9 }, 14); // 5,5 -> 5,6 empty
assert.equal(r.alive, true);
r = snake.step([{ x: 5, y: 5 }, { x: 4, y: 5 }, { x: 4, y: 4 }, { x: 5, y: 4 }, { x: 6, y: 4 }, { x: 6, y: 5 }], { x: 0, y: 1 }, { x: 9, y: 9 }, 14);
assert.equal(r.alive, true, 'moving into vacating tail cell is allowed');
r = snake.step([{ x: 5, y: 5 }, { x: 5, y: 6 }, { x: 4, y: 6 }, { x: 4, y: 5 }], { x: 0, y: 1 }, { x: 9, y: 9 }, 14); // 5,5 -> 5,6 which is a middle body cell
assert.equal(r.alive, false, 'run into body');
// direction reversal guard
assert.deepEqual(snake.turn({ x: 1, y: 0 }, { x: -1, y: 0 }), { x: 1, y: 0 }, 'cannot reverse into itself');
assert.deepEqual(snake.turn({ x: 1, y: 0 }, { x: 0, y: 1 }), { x: 0, y: 1 }, 'turn down allowed');
assert.deepEqual(snake.turn({ x: 1, y: 0 }, { x: 1, y: 0 }), { x: 1, y: 0 });
// crown rotation: rotate direction ±90° (right->down->left->up, clockwise)
assert.deepEqual(snake.rotateDir({ x: 1, y: 0 }, true), { x: 0, y: 1 }, 'clockwise right->down');
assert.deepEqual(snake.rotateDir({ x: 0, y: 1 }, true), { x: -1, y: 0 }, 'clockwise down->left');
assert.deepEqual(snake.rotateDir({ x: -1, y: 0 }, true), { x: 0, y: -1 }, 'clockwise left->up');
assert.deepEqual(snake.rotateDir({ x: 0, y: -1 }, true), { x: 1, y: 0 }, 'clockwise up->right (wrap)');
assert.deepEqual(snake.rotateDir({ x: 1, y: 0 }, false), { x: 0, y: -1 }, 'counterclockwise right->up');
assert.deepEqual(snake.rotateDir({ x: 0, y: -1 }, false), { x: -1, y: 0 }, 'counterclockwise up->left');
// spawnFood lands on an empty cell
for (let i = 0; i < 50; i++) {
const f = snake.spawnFood([{ x: 0, y: 0 }], 14);
assert.ok(f.x >= 0 && f.x < 14 && f.y >= 0 && f.y < 14, 'in bounds');
assert.ok(!(f.x === 0 && f.y === 0), 'not on snake');
}
console.log('ALL SNAKE LOGIC PASS');