v0.6: real PNG textures (head with eyes, scaled body, corners, tapering tails, apple, board); renderPieces classifier + tests

This commit is contained in:
2026-09-09 22:06:09 +01:00
parent 4e39e1912a
commit eacad97240
24 changed files with 179 additions and 30 deletions
+25 -1
View File
@@ -34,6 +34,30 @@ function turn(current, desired) {
return desired return desired
} }
// Classify every segment as a texture piece (head/body/corner/tail + compass side),
// so the page can render each cell with the right PNG. Returns one name per segment.
// Sides are screen compass (y grows down): e/w/n/s = right/left/up/down.
function renderPieces(snakeCells, dir) {
const D = { '1,0': 'e', '-1,0': 'w', '0,-1': 'n', '0,1': 's' }
const opp = { e: 'w', w: 'e', n: 's', s: 'n' }
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[(prev.x - s.x) + ',' + (prev.y - s.y)] // side toward head
if (!next) return 'tail_' + opp[from] // tip points away from body
const to = D[(next.x - s.x) + ',' + (next.y - s.y)] // 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
const pair = [from, to].sort()
if (pair[0] === 'e' && pair[1] === 'n') return 'corner_en'
if (pair[0] === 'n' && pair[1] === 'w') return 'corner_nw'
if (pair[0] === 'e' && pair[1] === 's') return 'corner_es'
return 'corner_sw'
})
}
// Rotate a direction 90°: clockwise order right->down->left->up. Always ±90°, never a reversal. // Rotate a direction 90°: clockwise order right->down->left->up. Always ±90°, never a reversal.
function rotateDir(dir, clockwise) { function rotateDir(dir, clockwise) {
const order = [{ x: 1, y: 0 }, { x: 0, y: 1 }, { x: -1, y: 0 }, { x: 0, y: -1 }] const order = [{ x: 1, y: 0 }, { x: 0, y: 1 }, { x: -1, y: 0 }, { x: 0, y: -1 }]
@@ -56,4 +80,4 @@ function spawnFood(snake, N) {
return { x, y } return { x, y }
} }
module.exports = { initialSnake, step, turn, rotateDir, spawnFood } module.exports = { initialSnake, step, turn, renderPieces, rotateDir, spawnFood }
Binary file not shown.

After

Width:  |  Height:  |  Size: 902 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 694 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 727 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 841 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 913 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 848 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 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.5", "versionName": "0.6",
"versionCode": 5, "versionCode": 6,
"minAPILevel": 1, "minAPILevel": 1,
"deviceTypeList": [ "deviceTypeList": [
"watch" "watch"
+12 -22
View File
@@ -1,9 +1,9 @@
<template> <template>
<div class="page" onswipe="onSwipe"> <div class="page" onswipe="onSwipe">
<stack class="dial"> <stack class="dial">
<div class="board"></div> <image class="board" src="/common/tex/board.png"></image>
<div class="food" style="left: {{ food.left }}px; top: {{ food.top }}px;"></div> <image class="food" src="/common/tex/apple.png" style="left: {{ food.left }}px; top: {{ food.top }}px;"></image>
<div class="{{ seg.cls }}" for="{{ seg in snake }}" style="left: {{ seg.left }}px; top: {{ seg.top }}px;"></div> <image class="seg" src="{{ seg.src }}" for="{{ seg in snake }}" style="left: {{ seg.left }}px; top: {{ seg.top }}px;"></image>
<div class="overlay" show="{{ over }}" onclick="onTap"> <div class="overlay" show="{{ over }}" onclick="onTap">
<text class="overText">GAME OVER</text> <text class="overText">GAME OVER</text>
<text class="overSub">{{ scoreText }}</text> <text class="overSub">{{ scoreText }}</text>
@@ -88,19 +88,20 @@ export default {
if (this.over) this.restart() if (this.over) this.restart()
}, },
rebuild() { rebuild() {
const pieces = snake.renderPieces(this.model, this.dir)
const out = [] const out = []
for (let i = 0; i < this.model.length; i++) { for (let i = 0; i < this.model.length; i++) {
const s = this.model[i] const s = this.model[i]
out.push({ out.push({
left: (OFFSET + s.x * CELL + 1) + 'px', left: (OFFSET + s.x * CELL) + 'px',
top: (OFFSET + s.y * CELL + 1) + 'px', top: (OFFSET + s.y * CELL) + 'px',
cls: i === 0 ? 'seg head' : 'seg' src: '/common/tex/' + pieces[i] + '.png'
}) })
} }
this.snake = out this.snake = out
this.food = { this.food = {
left: (OFFSET + this.foodModel.x * CELL + 3) + 'px', left: (OFFSET + this.foodModel.x * CELL) + 'px',
top: (OFFSET + this.foodModel.y * CELL + 3) + 'px' top: (OFFSET + this.foodModel.y * CELL) + 'px'
} }
this.scoreText = 'SCORE ' + (this.model.length - 3) this.scoreText = 'SCORE ' + (this.model.length - 3)
} }
@@ -117,27 +118,16 @@ export default {
width: 466px; height: 466px; width: 466px; height: 466px;
} }
.board { .board {
position: absolute; left: 71px; top: 71px; position: absolute; left: 73px; top: 73px;
width: 320px; height: 320px; width: 320px; height: 320px;
border-radius: 26px;
border-width: 2px;
border-color: #2a3040;
background-color: #0a0f1a;
} }
.seg { .seg {
position: absolute; position: absolute;
width: 18px; height: 18px; width: 20px; height: 20px;
border-radius: 4px;
background-color: #35c26a;
}
.seg.head {
background-color: #7cf0a0;
} }
.food { .food {
position: absolute; position: absolute;
width: 14px; height: 14px; width: 20px; height: 20px;
border-radius: 7px;
background-color: #ff5a5a;
} }
.score { .score {
position: absolute; left: 0px; top: 24px; position: absolute; left: 0px; top: 24px;
+63
View File
@@ -0,0 +1,63 @@
# Bake snake textures. Working resolution 80px, saved at 20px (cell size).
# Base tiles: body, head (eyes), apple, plus rotated variants written into src/common/tex/.
import os
from PIL import Image, ImageDraw
TILE = 80
OUT = os.path.expanduser('~/vela-snake/src/common/tex')
os.makedirs(OUT, exist_ok=True)
def rr(d, box, rad, fill):
d.rounded_rectangle(box, radius=rad, fill=fill)
def body():
W = TILE
img = Image.new('RGBA', (W, W), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
rr(d, (0, 10, W, W - 10), 14, (60, 183, 90, 255))
for cx, cy in ((18, 26), (W - 30, W - 38)):
d.ellipse((cx - 14, cy - 14, cx + 14, cy + 14), fill=(46, 160, 74, 255))
d.ellipse((cx - 6, cy - 6, cx + 6, cy + 6), fill=(60, 183, 90, 255))
return img
def head():
W = TILE
img = Image.new('RGBA', (W, W), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
rr(d, (10, 12, W, W - 12), 22, (64, 188, 110, 255))
d.ellipse((W - 52, 24, W - 32, 44), fill=(255, 255, 255, 255))
d.ellipse((W - 46, 30, W - 38, 38), fill=(30, 30, 30, 255))
return img
def apple():
W = TILE
img = Image.new('RGBA', (W, W), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
d.ellipse((8, 14, W - 8, W - 6), fill=(224, 62, 62, 255))
d.ellipse((16, 24, 34, 42), fill=(255, 255, 255, 70))
d.polygon(((W // 2 - 4, 12), (W // 2 + 18, 4), (W // 2 + 10, 16)), fill=(56, 128, 56, 255))
d.rectangle((W // 2 - 3, 10, W // 2 + 1, 18), fill=(90, 60, 30, 255))
return img
def head_open():
W = TILE
img = head()
d = ImageDraw.Draw(img)
d.rectangle((0, 0, 10, W), fill=(0, 0, 0, 0))
d.rectangle((10, 36, 34, 44), fill=(226, 62, 62, 255))
d.polygon(((10, 36), (26, 28), (30, 40)), fill=(226, 62, 62, 255))
return img
def save20(img, name):
img.resize((20, 20), Image.LANCZOS).save(os.path.join(OUT, name))
# base tiles: body + head drawn mouth-E (E = right); rotated variants cover all directions
b = body()
save20(b, 'body.png') # 4-way base body tile
h = head_open() # head with tongue + eyes, W edge open
save20(h, 'head_e.png') # facing east
save20(h.transpose(Image.FLIP_LEFT_RIGHT), 'head_w.png')
save20(h.transpose(Image.ROTATE_90), 'head_n.png') # E->N under ccw
save20(h.transpose(Image.ROTATE_90).transpose(Image.FLIP_TOP_BOTTOM), 'head_s.png')
save20(apple(), 'apple.png')
print('base tiles done')
+9 -5
View File
@@ -25,10 +25,11 @@ function mk() {
let c = mk(); let c = mk();
assert.equal(c.model.length, 3, 'initialized to length 3'); assert.equal(c.model.length, 3, 'initialized to length 3');
assert.equal(c.snake.length, 3, 'rendered 3 segments'); assert.equal(c.snake.length, 3, 'rendered 3 segments');
assert.equal(c.snake[0].cls, 'seg head', 'head flagged'); assert.match(c.snake[0].src, /head_e\.png$/, 'head texture');
assert.equal(c.snake[1].cls, 'seg', 'body flagged'); 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.scoreText, 'SCORE 0');
assert.equal(c.snake[0].left, (73 + 6 * 20 + 1) + 'px', 'head x mapped to px'); assert.equal(c.snake[0].left, (73 + 6 * 20) + 'px', 'head x mapped to px');
for (let i = 0; i < 5; i++) c.tick(); 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.model[0].x, 11, 'head moved right 5 steps from x=6');
@@ -39,6 +40,7 @@ c.tick();
assert.equal(c.dir.x, 0, 'dir x'); assert.equal(c.dir.x, 0, 'dir x');
assert.equal(c.dir.y, 1, 'direction turned down'); assert.equal(c.dir.y, 1, 'direction turned down');
assert.equal(c.model[0].y, 8, 'head moved down'); assert.equal(c.model[0].y, 8, 'head moved down');
assert.match(c.snake[0].src, /head_s\.png$/, 'head faces south after turn');
c.onSwipe({ direction: 'up' }); c.onSwipe({ direction: 'up' });
c.tick(); c.tick();
@@ -57,11 +59,13 @@ assert.equal(c.over, false);
assert.equal(c.model.length, 3); assert.equal(c.model.length, 3);
assert.equal(c.scoreText, 'SCORE 0'); assert.equal(c.scoreText, 'SCORE 0');
// template uses documented directives/events, no transforms, no crown remnants // template uses image components for textures + documented directives/events
assert.match(ux, /<image class="board" src="\/common\/tex\/board\.png">/);
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, /onswipe="onSwipe"/);
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 (movement, turn, reversal, game over, restart, directives)'); console.log('PAGE WIRING PASS (textures, movement, turn, reversal, game over, restart, directives)');
+68
View File
@@ -0,0 +1,68 @@
// TDD: piece classification for textured rendering.
// renderPieces(snake, dir) -> array of texture names, one per segment (head first).
const assert = require('assert/strict')
const fs = require('fs')
const vm = require('vm')
const src = fs.readFileSync(__dirname + '/../src/common/snake.js', 'utf8')
const sandbox = { module: { exports: {} } }
vm.runInNewContext(src, sandbox)
const snake = sandbox.module.exports
// compass sides: e/w/n/s (screen coords, y grows downward)
function pieces(list, dir) { return snake.renderPieces(list, dir) }
// -- initial snake: head east, horizontal body, tail pointing west
assert.deepEqual(
pieces([{ x: 6, y: 7 }, { x: 5, y: 7 }, { x: 4, y: 7 }], { x: 1, y: 0 }),
['head_e', 'body_h', 'tail_w'])
// -- vertical snake moving south
assert.deepEqual(
pieces([{ x: 3, y: 5 }, { x: 3, y: 4 }, { x: 3, y: 4 + 1 }], { x: 0, y: 1 })
.map((p, i) => i), // shape check below with explicit list instead
[0, 1, 2])
assert.deepEqual(
pieces([{ x: 3, y: 5 }, { x: 3, y: 6 }, { x: 3, y: 7 }], { x: 0, y: 1 }),
['head_s', 'body_v', 'tail_s'])
// -- corner: head turned from east to south
// snake [(6,8),(6,7),(5,7)]: neck connects south (to head) and west (to rest);
// tail cell (5,7)'s neighbor is east, so its tip points west
assert.deepEqual(
pieces([{ x: 6, y: 8 }, { x: 6, y: 7 }, { x: 5, y: 7 }], { x: 0, y: 1 }),
['head_s', 'corner_sw', 'tail_w'])
// -- corner: head turned from east to north
assert.deepEqual(
pieces([{ x: 6, y: 6 }, { x: 6, y: 7 }, { x: 5, y: 7 }], { x: 0, y: -1 }),
['head_n', 'corner_nw', 'tail_w'])
// -- corner heading west turning south (es corner mirrored)
// tail cell (5,7)'s neighbor is west, so its tip points east
assert.deepEqual(
pieces([{ x: 4, y: 8 }, { x: 4, y: 7 }, { x: 5, y: 7 }], { x: -1, y: 0 }),
['head_w', 'corner_es', 'tail_e'])
// -- double corner (S shape): head moving south, west, then north —
// hand-verified: every middle cell of this tight zigzag is a corner
// [(6,8) head][ (6,7) sw ][ (5,7) en ][ (5,6) sw ][ (4,6) en ][ (4,5) tail ]
assert.deepEqual(
pieces([{ x: 6, y: 8 }, { x: 6, y: 7 }, { x: 5, y: 7 }, { x: 5, y: 6 }, { x: 4, y: 6 }, { x: 4, y: 5 }], { x: 0, y: 1 }),
['head_s', 'corner_sw', 'corner_en', 'corner_sw', 'corner_en', 'tail_n'])
// -- every returned piece maps to a texture that must exist
const TEX = ['head_e', 'head_w', 'head_n', 'head_s',
'body_h', 'body_v',
'corner_en', 'corner_es', 'corner_nw', 'corner_sw',
'tail_e', 'tail_w', 'tail_n', 'tail_s']
const dirTex = __dirname + '/../src/common/tex'
for (const p of TEX) {
assert.ok(fs.existsSync(dirTex + '/' + p + '.png'), 'missing texture ' + p)
}
// -- food + board textures
assert.ok(fs.existsSync(dirTex + '/apple.png'), 'missing apple texture')
assert.ok(fs.existsSync(dirTex + '/board.png'), 'missing board texture')
console.log('PIECE CLASSIFICATION PASS')
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB