Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ Collect `6`, then `×`, then `4` → `6 × 4 = 24` ✓

- Collect a number, an operator, a number… to build a running total.
- Only your **latest** number and operator count — eat a better one to replace it.
- Hitting a wall, biting your own tail, or letting the clock expire each costs a life.
- Hitting a wall wraps the snake to the opposite side — no penalty.
- Biting your own tail or letting the clock expire costs a life.
- Three lives per run.

### Progression
Expand Down
2 changes: 1 addition & 1 deletion public/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ body{
.flash.good{background:radial-gradient(circle,rgba(120,220,150,.0),rgba(90,210,140,.35));animation:flash .5s ease;}
@keyframes flash{0%{opacity:0}30%{opacity:1}100%{opacity:0}}

.ent{position:absolute;left:0;top:0;width:var(--cell);height:var(--cell);display:flex;align-items:center;justify-content:center;transition:transform .075s linear;will-change:transform;}
.ent{position:absolute;left:0;top:0;width:var(--cell);height:var(--cell);display:flex;align-items:center;justify-content:center;transition:transform .075s linear,opacity .075s linear;will-change:transform;}
.ent.notrans{transition:none;}
.box{width:84%;height:84%;display:flex;align-items:center;justify-content:center;font-family:'Fredoka';font-weight:600;font-size:calc(var(--cell)*.42);position:relative;}
.box.num{background:var(--num-bg);color:var(--num-fg);border-radius:50%;box-shadow:0 3px 0 rgba(0,0,0,.14);}
Expand Down
11 changes: 9 additions & 2 deletions public/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ function render() {
renderRibbon({ tokens: ft, total: totalValue(), solved: isSolved(), pendingOp: curFrame().op, openGroups: G.frames.length > 1 });
renderGrid(G, cell, ft);
G.noTrans = false;
G.wrapGhost = null;
}

function schedule() { clearTimeout(tickTimer); if (G.running) tickTimer = setTimeout(step, G.tickMs); }
Expand All @@ -160,8 +161,14 @@ function step() {
if (!G.running) return;
G.dir = G.nextDir;
const pos = nextHead(G.snake, G.dir, COLS, ROWS);
if (!pos) { crash(); schedule(); return; }
if (collidesWithSelf(G.snake, pos)) { crash(); schedule(); return; }
const head = G.snake[0];
const isWrap = Math.abs(head.x - pos.x) > 1 || Math.abs(head.y - pos.y) > 1;
G.wrapGhost = isWrap ? {
entryFrom: { x: pos.x - G.dir.x, y: pos.y - G.dir.y }, // one cell outside entry wall
exitFrom: { x: head.x, y: head.y }, // old head position
exitTo: { x: head.x + G.dir.x, y: head.y + G.dir.y }, // one cell past exit wall
} : null;
const ti = G.tiles.findIndex(t => t.x === pos.x && t.y === pos.y);
if (ti >= 0) { eatTile(ti, pos); schedule(); return; }
G.snake = advance(G.snake, pos);
Expand Down Expand Up @@ -313,7 +320,7 @@ function startGame() {
biome: 'garden', score: 0, lives: 3, target: 10, timeLeft: 26, timerMax: 26,
frames: [{ tokens: [], acc: null, op: null }], history: [], snake: [],
dir: { x: 1, y: 0 }, nextDir: { x: 1, y: 0 }, tiles: [],
tickMs: 160, baseTick: 160, running: false, solved: 0, longest: 0, noTrans: true
tickMs: 160, baseTick: 160, running: false, solved: 0, longest: 0, noTrans: true, wrapGhost: null
};
applyBiome('garden');
app.dataset.screen = 'game';
Expand Down
8 changes: 4 additions & 4 deletions public/js/snake.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ export function createSnake(cols = COLS, rows = ROWS) {
return [{ x: Math.floor(cols / 2), y: Math.floor(rows / 2) }];
}

// Returns the new head position, or null if it would leave the grid.
export function nextHead(snake, dir, cols = COLS, rows = ROWS) {
const nx = snake[0].x + dir.x, ny = snake[0].y + dir.y;
if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) return null;
return { x: nx, y: ny };
return {
x: (snake[0].x + dir.x + cols) % cols,
y: (snake[0].y + dir.y + rows) % rows,
};
}

// Checks all segments except the last tail cell (which will vacate on the same tick).
Expand Down
29 changes: 27 additions & 2 deletions public/js/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,18 +78,43 @@ export function renderGrid(G, cell, tokens) {

// Body segments spell the running expression, most-recent token nearest the head.
const bodyTokens = tokens.slice().reverse();
let headHTML = null;
G.snake.forEach((p, i) => {
const inner = i === 0
? `<div class="box head"><span class="eye l"></span><span class="eye r"></span></div>`
: `<div class="box seg">${bodyTokens[i - 1] !== undefined ? bodyTokens[i - 1] : ''}</div>`;
ents.push({ k: 's' + i, x: p.x, y: p.y, html: inner });
if (i === 0) headHTML = inner;
const wrapFrom = (i === 0 && G.wrapGhost) ? G.wrapGhost.entryFrom : null;
ents.push({ k: 's' + i, x: p.x, y: p.y, html: inner, wrapFrom });
});

// Exit ghost: a temporary head element that slides off the exit wall and fades out,
// giving the illusion the snake is traversing rather than teleporting.
if (G.wrapGhost && headHTML) {
const { exitFrom, exitTo } = G.wrapGhost;
const ghost = document.createElement('div');
ghost.className = 'ent notrans';
ghost.innerHTML = headHTML;
ghost.style.transform = `translate(${exitFrom.x * cell}px,${exitFrom.y * cell}px)`;
grid.appendChild(ghost);
ghost.offsetWidth; // force reflow
ghost.classList.remove('notrans');
ghost.style.transform = `translate(${exitTo.x * cell}px,${exitTo.y * cell}px)`;
ghost.style.opacity = '0';
setTimeout(() => ghost.remove(), 120);
}

while (pool.length < ents.length) { const e = document.createElement('div'); e.className = 'ent'; grid.appendChild(e); pool.push(e); }
while (pool.length > ents.length) { pool.pop().remove(); }

ents.forEach((e, idx) => {
const el = pool[idx];
if (e.wrapFrom) {
el.classList.add('notrans');
el.style.transform = `translate(${e.wrapFrom.x * cell}px,${e.wrapFrom.y * cell}px)`;
el.offsetWidth; // force reflow so browser registers the snap before animating
el.classList.remove('notrans');
}
el.style.transform = `translate(${e.x * cell}px,${e.y * cell}px)`;
el.classList.toggle('notrans', !!G.noTrans);
if (el._html !== e.html) { el.innerHTML = e.html; el._html = e.html; }
Expand Down Expand Up @@ -173,7 +198,7 @@ export function helpHTML(title) {
<div class="ov-sub" style="text-align:left;max-width:300px;">
Eat a number, an operator, a number… to build a running total up to the <b style="color:var(--target)">MAKE</b> number — hit it and it auto-scores.<br><br>
🔁 Only your <b>latest</b> number &amp; operator count — wrong bite? Just eat the right one next.<br>
🧱 Walls, your own tail, or the clock running out each cost a life.<br>
🌀 Hitting a wall wraps you to the opposite side — no penalty. Your own tail or the clock running out costs a life.<br>
🌍 Climb Garden → Twilight → Inferno, where the <b>eraser</b> and <b>( )</b> unlock.
</div>
${paused
Expand Down
20 changes: 10 additions & 10 deletions tests/snake.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,20 @@ describe('nextHead — movement', () => {
});
});

describe('nextHead — wall collisions', () => {
it('returns null at right wall', () => {
expect(nextHead([{ x: COLS - 1, y: 5 }], { x: 1, y: 0 }, COLS, ROWS)).toBeNull();
describe('nextHead — wall wrapping', () => {
it('wraps from right wall to left', () => {
expect(nextHead([{ x: COLS - 1, y: 5 }], { x: 1, y: 0 }, COLS, ROWS)).toEqual({ x: 0, y: 5 });
});
it('returns null at left wall', () => {
expect(nextHead([{ x: 0, y: 5 }], { x: -1, y: 0 }, COLS, ROWS)).toBeNull();
it('wraps from left wall to right', () => {
expect(nextHead([{ x: 0, y: 5 }], { x: -1, y: 0 }, COLS, ROWS)).toEqual({ x: COLS - 1, y: 5 });
});
it('returns null at top wall', () => {
expect(nextHead([{ x: 5, y: 0 }], { x: 0, y: -1 }, COLS, ROWS)).toBeNull();
it('wraps from top wall to bottom', () => {
expect(nextHead([{ x: 5, y: 0 }], { x: 0, y: -1 }, COLS, ROWS)).toEqual({ x: 5, y: ROWS - 1 });
});
it('returns null at bottom wall', () => {
expect(nextHead([{ x: 5, y: ROWS - 1 }], { x: 0, y: 1 }, COLS, ROWS)).toBeNull();
it('wraps from bottom wall to top', () => {
expect(nextHead([{ x: 5, y: ROWS - 1 }], { x: 0, y: 1 }, COLS, ROWS)).toEqual({ x: 5, y: 0 });
});
it('does not wrap — step just inside right wall is valid', () => {
it('normal move inside the grid is unaffected', () => {
expect(nextHead([{ x: COLS - 2, y: 5 }], { x: 1, y: 0 }, COLS, ROWS)).toEqual({ x: COLS - 1, y: 5 });
});
});
Expand Down