Node.js HTTP server serving a canvas Snake game optimised for phones: inline onclick handlers for reliable touch, swipe support, big d-pad, no-cache headers, and responsive canvas sizing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
330 lines
9.4 KiB
HTML
330 lines
9.4 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
<title>Snake</title>
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
html, body {
|
|
height: 100%;
|
|
overflow: hidden;
|
|
background: #0a0a0f;
|
|
}
|
|
|
|
body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: flex-start;
|
|
padding: 10px 8px 6px;
|
|
font-family: 'Courier New', monospace;
|
|
color: #e0e0e0;
|
|
user-select: none;
|
|
gap: 6px;
|
|
}
|
|
|
|
h1 {
|
|
font-size: clamp(1.3rem, 5.5vw, 2.2rem);
|
|
letter-spacing: 0.25em;
|
|
color: #4eff91;
|
|
text-shadow: 0 0 20px #4eff9188;
|
|
}
|
|
|
|
#scoreboard {
|
|
display: flex;
|
|
gap: 2rem;
|
|
font-size: clamp(0.8rem, 3.5vw, 1rem);
|
|
letter-spacing: 0.12em;
|
|
}
|
|
#scoreboard span { color: #888; }
|
|
#scoreboard strong { color: #ffe44e; }
|
|
|
|
#canvas-wrap {
|
|
position: relative;
|
|
border: 2px solid #4eff9155;
|
|
border-radius: 4px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
canvas { display: block; }
|
|
|
|
#overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: rgba(10,10,15,0.9);
|
|
border-radius: 3px;
|
|
gap: 10px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
#overlay.hidden { display: none; }
|
|
|
|
#overlay h2 {
|
|
font-size: clamp(1.3rem, 5.5vw, 1.8rem);
|
|
letter-spacing: 0.2em;
|
|
color: #ff4e4e;
|
|
}
|
|
#overlay p { color: #aaa; font-size: clamp(0.8rem, 3vw, 0.9rem); }
|
|
|
|
#btn {
|
|
padding: 12px 32px;
|
|
font-family: inherit;
|
|
font-size: clamp(0.9rem, 4vw, 1.1rem);
|
|
letter-spacing: 0.2em;
|
|
background: #4eff91;
|
|
color: #0a0a0f;
|
|
border: none;
|
|
border-radius: 4px;
|
|
font-weight: bold;
|
|
cursor: pointer;
|
|
touch-action: manipulation;
|
|
-webkit-tap-highlight-color: rgba(78,255,145,0.3);
|
|
}
|
|
|
|
#btn:active { opacity: 0.8; }
|
|
|
|
/* D-pad */
|
|
#dpad {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
grid-template-rows: repeat(3, 1fr);
|
|
gap: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
@media (min-width: 600px) and (pointer: fine) { #dpad { display: none; } }
|
|
|
|
.db {
|
|
background: #1a1a2e;
|
|
border: 2px solid #4eff9166;
|
|
border-radius: 50%;
|
|
color: #4eff91;
|
|
font-size: clamp(1.1rem, 4.5vw, 1.6rem);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
touch-action: manipulation;
|
|
-webkit-tap-highlight-color: rgba(78,255,145,0.2);
|
|
width: 100%;
|
|
aspect-ratio: 1;
|
|
}
|
|
|
|
.db:active { background: #4eff9133; border-color: #4eff91; }
|
|
.blank { visibility: hidden; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<h1>SNAKE</h1>
|
|
|
|
<div id="scoreboard">
|
|
<div><span>SCORE </span><strong id="score">0</strong></div>
|
|
<div><span>BEST </span><strong id="best">0</strong></div>
|
|
</div>
|
|
|
|
<div id="canvas-wrap">
|
|
<canvas id="c"></canvas>
|
|
<div id="overlay" onclick="doStart()">
|
|
<h2 id="ot">SNAKE</h2>
|
|
<p id="os">tap to start</p>
|
|
<button id="btn" onclick="doStart()">START</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="dpad">
|
|
<div class="blank"></div>
|
|
<button class="db" onclick="doDir('up')">▲</button>
|
|
<div class="blank"></div>
|
|
<button class="db" onclick="doDir('left')">◀</button>
|
|
<div class="blank"></div>
|
|
<button class="db" onclick="doDir('right')">▶</button>
|
|
<div class="blank"></div>
|
|
<button class="db" onclick="doDir('down')">▼</button>
|
|
<div class="blank"></div>
|
|
</div>
|
|
|
|
<script>
|
|
const COLS = 20, ROWS = 20;
|
|
|
|
// Compute cell size to fit screen
|
|
const canvas = document.getElementById('c');
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
function computeCell() {
|
|
const vw = window.innerWidth, vh = window.innerHeight;
|
|
const dpadSize = vw < 600 ? Math.min(vw * 0.72, 260) : 0;
|
|
const availW = Math.min(vw - 16, 540);
|
|
const availH = vh - 120 - dpadSize - 20; // header + dpad + gaps
|
|
return Math.max(10, Math.floor(Math.min(availW / COLS, availH / ROWS)));
|
|
}
|
|
|
|
let CELL = computeCell();
|
|
canvas.width = COLS * CELL;
|
|
canvas.height = ROWS * CELL;
|
|
|
|
// Also size the dpad
|
|
const dpad = document.getElementById('dpad');
|
|
const dpadSize = Math.min(window.innerWidth * 0.72, 260);
|
|
dpad.style.width = dpadSize + 'px';
|
|
dpad.style.height = dpadSize + 'px';
|
|
|
|
let snake, dir, nextDir, food, score, hiscore = 0, timer, alive = false, foodTick = 0;
|
|
|
|
const overlay = document.getElementById('overlay');
|
|
const ot = document.getElementById('ot');
|
|
const os = document.getElementById('os');
|
|
const scoreEl = document.getElementById('score');
|
|
const bestEl = document.getElementById('best');
|
|
|
|
function ri(n) { return Math.floor(Math.random() * n); }
|
|
|
|
function spawnFood() {
|
|
const occ = new Set(snake.map(s => s.x + ',' + s.y));
|
|
let p;
|
|
do { p = { x: ri(COLS), y: ri(ROWS) }; } while (occ.has(p.x + ',' + p.y));
|
|
food = p;
|
|
}
|
|
|
|
// Exposed globally so inline onclick works
|
|
window.doStart = function() {
|
|
if (alive) return;
|
|
const mx = COLS >> 1, my = ROWS >> 1;
|
|
snake = [{ x: mx, y: my }, { x: mx-1, y: my }, { x: mx-2, y: my }];
|
|
dir = { x: 1, y: 0 };
|
|
nextDir = { x: 1, y: 0 };
|
|
score = 0;
|
|
scoreEl.textContent = '0';
|
|
alive = true;
|
|
overlay.classList.add('hidden');
|
|
spawnFood();
|
|
clearInterval(timer);
|
|
timer = setInterval(tick, 120);
|
|
};
|
|
|
|
const DIRS = { up:{x:0,y:-1}, down:{x:0,y:1}, left:{x:-1,y:0}, right:{x:1,y:0} };
|
|
|
|
window.doDir = function(d) {
|
|
if (!alive) { doStart(); return; }
|
|
const nd = DIRS[d];
|
|
if (nd.x === -dir.x && nd.y === -dir.y) return;
|
|
nextDir = nd;
|
|
};
|
|
|
|
function tick() {
|
|
foodTick++;
|
|
dir = nextDir;
|
|
const h = { x: snake[0].x + dir.x, y: snake[0].y + dir.y };
|
|
if (h.x < 0 || h.x >= COLS || h.y < 0 || h.y >= ROWS ||
|
|
snake.some(s => s.x === h.x && s.y === h.y)) {
|
|
clearInterval(timer);
|
|
alive = false;
|
|
draw();
|
|
ot.textContent = 'GAME OVER';
|
|
os.textContent = 'Score: ' + score;
|
|
document.getElementById('btn').textContent = 'PLAY AGAIN';
|
|
overlay.classList.remove('hidden');
|
|
return;
|
|
}
|
|
snake.unshift(h);
|
|
if (h.x === food.x && h.y === food.y) {
|
|
score++;
|
|
if (score > hiscore) { hiscore = score; bestEl.textContent = hiscore; }
|
|
scoreEl.textContent = score;
|
|
spawnFood();
|
|
} else {
|
|
snake.pop();
|
|
}
|
|
draw();
|
|
}
|
|
|
|
function draw() {
|
|
ctx.fillStyle = '#0a0a0f';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// Grid lines
|
|
ctx.strokeStyle = '#ffffff0a';
|
|
ctx.lineWidth = 1;
|
|
for (let x = 0; x <= COLS; x++) { ctx.beginPath(); ctx.moveTo(x*CELL,0); ctx.lineTo(x*CELL,canvas.height); ctx.stroke(); }
|
|
for (let y = 0; y <= ROWS; y++) { ctx.beginPath(); ctx.moveTo(0,y*CELL); ctx.lineTo(canvas.width,y*CELL); ctx.stroke(); }
|
|
|
|
// Food
|
|
const pulse = 0.6 + 0.4 * Math.sin(foodTick * 0.25);
|
|
const fx = food.x*CELL + CELL/2, fy = food.y*CELL + CELL/2;
|
|
const gr = ctx.createRadialGradient(fx, fy, 1, fx, fy, CELL * pulse);
|
|
gr.addColorStop(0, '#ff4e4ecc'); gr.addColorStop(1, '#ff4e4e00');
|
|
ctx.fillStyle = gr;
|
|
ctx.fillRect(food.x*CELL - CELL, food.y*CELL - CELL, CELL*3, CELL*3);
|
|
ctx.fillStyle = '#ff4e4e';
|
|
ctx.beginPath(); ctx.arc(fx, fy, CELL*0.32, 0, Math.PI*2); ctx.fill();
|
|
|
|
// Snake
|
|
snake.forEach((s, i) => {
|
|
const x = s.x*CELL, y = s.y*CELL, p = i ? 2 : 1, r = Math.max(2, CELL*0.22);
|
|
ctx.fillStyle = i === 0 ? '#4eff91' : '#29b861';
|
|
ctx.shadowColor = i === 0 ? '#4eff91' : 'transparent';
|
|
ctx.shadowBlur = i === 0 ? 10 : 0;
|
|
rr(x+p, y+p, CELL-p*2, CELL-p*2, r);
|
|
ctx.fill();
|
|
});
|
|
ctx.shadowBlur = 0;
|
|
}
|
|
|
|
function rr(x, y, w, h, r) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x+r, y); ctx.lineTo(x+w-r, y); ctx.quadraticCurveTo(x+w,y,x+w,y+r);
|
|
ctx.lineTo(x+w, y+h-r); ctx.quadraticCurveTo(x+w,y+h,x+w-r,y+h);
|
|
ctx.lineTo(x+r, y+h); ctx.quadraticCurveTo(x,y+h,x,y+h-r);
|
|
ctx.lineTo(x, y+r); ctx.quadraticCurveTo(x,y,x+r,y);
|
|
ctx.closePath();
|
|
}
|
|
|
|
// Keyboard
|
|
const KEYS = { ArrowUp:'up',ArrowDown:'down',ArrowLeft:'left',ArrowRight:'right',w:'up',s:'down',a:'left',d:'right' };
|
|
document.addEventListener('keydown', e => {
|
|
const d = KEYS[e.key] || KEYS[e.key.toLowerCase()];
|
|
if (!d) return;
|
|
e.preventDefault();
|
|
doDir(d);
|
|
});
|
|
|
|
// Swipe on canvas
|
|
let sw = null;
|
|
canvas.addEventListener('touchstart', e => { sw = { x: e.touches[0].clientX, y: e.touches[0].clientY }; }, { passive: true });
|
|
canvas.addEventListener('touchend', e => {
|
|
if (!sw) return;
|
|
const dx = e.changedTouches[0].clientX - sw.x;
|
|
const dy = e.changedTouches[0].clientY - sw.y;
|
|
sw = null;
|
|
if (Math.abs(dx) < 15 && Math.abs(dy) < 15) { doStart(); return; }
|
|
doDir(Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'down' : 'up'));
|
|
}, { passive: true });
|
|
|
|
// Touchstart on dpad for instant response (no 300ms delay)
|
|
document.querySelectorAll('.db[onclick]').forEach(b => {
|
|
b.addEventListener('touchstart', e => {
|
|
e.preventDefault();
|
|
b.onclick();
|
|
}, { passive: false });
|
|
});
|
|
|
|
// Draw blank initial frame
|
|
ctx.fillStyle = '#0a0a0f';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
window.addEventListener('resize', () => {
|
|
CELL = computeCell();
|
|
canvas.width = COLS * CELL;
|
|
canvas.height = ROWS * CELL;
|
|
if (snake) draw();
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|