Neon Serpent
game.js6.7 KB
1// Neon Serpent — an Arcadia seed game. Fork it and make it better!2(() => {3 const W = 640, H = 700, N = 24, CELL = 25, OX = (W - N * CELL) / 2, OY = 80;4 const c = document.getElementById("c");5 const ctx = c.getContext("2d");6 function fit() {7 const s = Math.min(innerWidth / W, innerHeight / H), d = window.devicePixelRatio || 1;8 c.style.width = W * s + "px"; c.style.height = H * s + "px";9 c.width = Math.round(W * s * d); c.height = Math.round(H * s * d);10 ctx.setTransform(s * d, 0, 0, s * d, 0, 0);11 }12 addEventListener("resize", fit); fit();13 14 const best = () => Number(localStorage.getItem("serpent.best") || 0);15 let state = "title", snake, dir, queue, food, bonus, score, stepTime, acc, grow, flash = 0, t = 0, paused = false;16 17 function reset() {18 snake = [{ x: 8, y: 12 }, { x: 7, y: 12 }, { x: 6, y: 12 }];19 dir = { x: 1, y: 0 }; queue = []; score = 0; stepTime = 0.13; acc = 0; grow = 0; bonus = null;20 placeFood(); state = "play";21 }22 function freeCell() {23 for (;;) {24 const p = { x: (Math.random() * N) | 0, y: (Math.random() * N) | 0 };25 if (!snake.some((s) => s.x === p.x && s.y === p.y) && !(food && food.x === p.x && food.y === p.y)) return p;26 }27 }28 function placeFood() { food = freeCell(); }29 30 function turn(x, y) {31 if (state !== "play") { reset(); return; }32 const lastDir = queue.length ? queue[queue.length - 1] : dir;33 if (lastDir.x === -x && lastDir.y === -y) return;34 if (lastDir.x === x && lastDir.y === y) return;35 if (queue.length < 3) queue.push({ x, y });36 }37 const KEYS = { ArrowUp: [0, -1], w: [0, -1], ArrowDown: [0, 1], s: [0, 1], ArrowLeft: [-1, 0], a: [-1, 0], ArrowRight: [1, 0], d: [1, 0] };38 addEventListener("keydown", (e) => {39 if (KEYS[e.key]) { turn(...KEYS[e.key]); e.preventDefault(); }40 else if ((e.key === " " || e.key === "Enter") && state !== "play") reset();41 });42 let touch = null;43 c.addEventListener("pointerdown", (e) => { touch = { x: e.clientX, y: e.clientY }; if (state !== "play") reset(); });44 addEventListener("pointermove", (e) => {45 if (!touch) return;46 const dx = e.clientX - touch.x, dy = e.clientY - touch.y;47 if (Math.hypot(dx, dy) > 24) {48 Math.abs(dx) > Math.abs(dy) ? turn(Math.sign(dx), 0) : turn(0, Math.sign(dy));49 touch = { x: e.clientX, y: e.clientY };50 }51 });52 addEventListener("pointerup", () => (touch = null));53 if (window.Arcadia) { Arcadia.onPause(() => (paused = true)); Arcadia.onResume(() => (paused = false)); }54 55 function step() {56 if (queue.length) dir = queue.shift();57 const head = { x: snake[0].x + dir.x, y: snake[0].y + dir.y };58 if (head.x < 0 || head.y < 0 || head.x >= N || head.y >= N || snake.some((s, i) => i < snake.length - (grow ? 0 : 1) && s.x === head.x && s.y === head.y)) {59 state = "over"; flash = 1;60 if (score > best()) localStorage.setItem("serpent.best", String(score));61 if (window.Arcadia) { Arcadia.submitScore(score); Arcadia.gameOver(); }62 return;63 }64 snake.unshift(head);65 if (grow > 0) grow--; else snake.pop();66 if (head.x === food.x && head.y === food.y) {67 score += 10; grow += 2; placeFood();68 stepTime = Math.max(0.055, stepTime * 0.975);69 if (!bonus && Math.random() < 0.25) bonus = { ...freeCell(), ttl: 6 };70 }71 if (bonus && head.x === bonus.x && head.y === bonus.y) { score += 50; grow += 4; bonus = null; flash = 0.4; }72 }73 74 function update(dt) {75 t += dt; flash = Math.max(0, flash - dt * 2);76 if (state !== "play") return;77 if (bonus && (bonus.ttl -= dt) <= 0) bonus = null;78 acc += dt;79 while (acc >= stepTime && state === "play") { acc -= stepTime; step(); }80 }81 82 function draw() {83 ctx.fillStyle = "#050814"; ctx.fillRect(0, 0, W, H);84 ctx.fillStyle = "#0b1230"; ctx.fillRect(OX, OY, N * CELL, N * CELL);85 ctx.strokeStyle = "rgba(80,120,255,.08)"; ctx.lineWidth = 1;86 for (let i = 0; i <= N; i++) {87 ctx.beginPath(); ctx.moveTo(OX + i * CELL, OY); ctx.lineTo(OX + i * CELL, OY + N * CELL); ctx.stroke();88 ctx.beginPath(); ctx.moveTo(OX, OY + i * CELL); ctx.lineTo(OX + N * CELL, OY + i * CELL); ctx.stroke();89 }90 ctx.strokeStyle = "#3a5cff"; ctx.lineWidth = 2; ctx.strokeRect(OX, OY, N * CELL, N * CELL);91 92 if (snake) {93 const pulse = 0.6 + 0.4 * Math.sin(t * 6);94 ctx.shadowBlur = 18; ctx.shadowColor = "#ff3df0";95 ctx.fillStyle = "#ff3df0";96 ctx.beginPath(); ctx.arc(OX + food.x * CELL + CELL / 2, OY + food.y * CELL + CELL / 2, 7 + pulse * 2, 0, Math.PI * 2); ctx.fill();97 if (bonus) {98 ctx.shadowColor = "#ffd23f"; ctx.fillStyle = "#ffd23f";99 ctx.globalAlpha = bonus.ttl < 2 ? 0.4 + 0.6 * Math.abs(Math.sin(t * 10)) : 1;100 ctx.beginPath(); ctx.arc(OX + bonus.x * CELL + CELL / 2, OY + bonus.y * CELL + CELL / 2, 10, 0, Math.PI * 2); ctx.fill();101 ctx.globalAlpha = 1;102 }103 ctx.shadowColor = "#2ef2ff";104 snake.forEach((s, i) => {105 const k = 1 - i / (snake.length + 6);106 ctx.fillStyle = `hsl(${185 - i * 2}, 100%, ${45 + k * 20}%)`;107 const pad = i === 0 ? 1 : 3;108 ctx.beginPath();109 ctx.roundRect(OX + s.x * CELL + pad, OY + s.y * CELL + pad, CELL - pad * 2, CELL - pad * 2, 6);110 ctx.fill();111 });112 ctx.shadowBlur = 0;113 const h = snake[0];114 ctx.fillStyle = "#050814";115 const ex = dir.y !== 0 ? 5 : 0, ey = dir.x !== 0 ? 5 : 0;116 for (const sgn of [-1, 1]) {117 ctx.beginPath();118 ctx.arc(OX + h.x * CELL + CELL / 2 + dir.x * 5 + sgn * ex, OY + h.y * CELL + CELL / 2 + dir.y * 5 + sgn * ey, 2.6, 0, Math.PI * 2);119 ctx.fill();120 }121 }122 123 ctx.fillStyle = "#e6f0ff"; ctx.font = "800 26px system-ui"; ctx.textBaseline = "middle";124 ctx.textAlign = "left"; ctx.fillText("NEON SERPENT", OX, 42);125 ctx.textAlign = "right"; ctx.font = "700 22px system-ui"; ctx.fillText(`${score ?? 0} · best ${best()}`, OX + N * CELL, 42);126 if (flash) { ctx.fillStyle = `rgba(255,61,240,${flash * 0.25})`; ctx.fillRect(0, 0, W, H); }127 128 if (state !== "play") {129 ctx.fillStyle = "rgba(5,8,20,.75)"; ctx.fillRect(OX, OY, N * CELL, N * CELL);130 ctx.fillStyle = "#fff"; ctx.textAlign = "center";131 ctx.font = "900 54px system-ui"; ctx.fillText(state === "over" ? "GAME OVER" : "NEON SERPENT", W / 2, OY + 250);132 ctx.font = "600 20px system-ui"; ctx.fillStyle = "#9fb4ff";133 ctx.fillText(state === "over" ? `Score ${score}` : "Eat the pink orbs. Gold ones are worth 50.", W / 2, OY + 310);134 ctx.fillText("Arrow keys / WASD or swipe · tap to start", W / 2, OY + 345);135 }136 }137 138 let last = performance.now();139 function loop(now) {140 const dt = Math.min(0.05, (now - last) / 1000); last = now;141 if (!paused) update(dt);142 draw();143 requestAnimationFrame(loop);144 }145 requestAnimationFrame(loop);146})();147