diff --git a/README.md b/README.md index d37b83e..557518b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/public/css/style.css b/public/css/style.css index 0cff6cb..362195d 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -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);} diff --git a/public/js/main.js b/public/js/main.js index 41cc07e..24e9361 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -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); } @@ -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); @@ -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'; diff --git a/public/js/snake.js b/public/js/snake.js index 7ad1e50..cb4627e 100644 --- a/public/js/snake.js +++ b/public/js/snake.js @@ -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). diff --git a/public/js/ui.js b/public/js/ui.js index 510e841..0c511b9 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -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 ? `
` : `
${bodyTokens[i - 1] !== undefined ? bodyTokens[i - 1] : ''}
`; - 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; } @@ -173,7 +198,7 @@ export function helpHTML(title) {
Eat a number, an operator, a number… to build a running total up to the MAKE number — hit it and it auto-scores.

🔁 Only your latest number & operator count — wrong bite? Just eat the right one next.
- 🧱 Walls, your own tail, or the clock running out each cost a life.
+ 🌀 Hitting a wall wraps you to the opposite side — no penalty. Your own tail or the clock running out costs a life.
🌍 Climb Garden → Twilight → Inferno, where the eraser and ( ) unlock.
${paused diff --git a/tests/snake.test.js b/tests/snake.test.js index 6a23cc2..884d6fc 100644 --- a/tests/snake.test.js +++ b/tests/snake.test.js @@ -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 }); }); });