arcadia / neon-serpent

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