This repository has no description
16 kB
551 lines
1// A lane-crossing dodge game on the Tangled punchcard.
2// Guide the chicken from one edge of the grid to the other without getting
3// clipped by the moving hazards in the "traffic" lanes. Arrow keys / WASD
4// once the grid is focused, or tap in the direction you want to move.
5// Reduced motion gets a turn-based variant: hazards only move when you do.
6//
7// Levels alternate direction: odd levels climb to the top, even levels
8// descend to the bottom, and so on forever, getting faster each time.
9// Losing all your lives turns the whole grid into a plate of drumsticks.
10//
11// Tune the constants below to change difficulty and pacing.
12
13const COLS_WIDE = 14;
14const COLS_NARROW = 28;
15const WIDE_QUERY = "(min-width: 768px)";
16const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
17
18const LIVES_START = 3;
19const BASE_SPEED = 0.7; // cells/sec for the easiest danger lane
20const SPEED_RAMP = 0.06; // extra cells/sec per lane closer to the goal
21const SAFE_ROW_INTERVAL = 3; // every Nth interior row is a resting lane
22const CAR_DENSITY_DIVISOR = 11; // larger = fewer cars per lane at level 1
23const CARS_PER_LEVEL = 0.5; // extra cars per lane added every ~2 levels
24const MAX_CARS_PER_LANE = 6; // cap so late levels don't get absurdly crowded
25const WIN_PAUSE_MS = 1600;
26const HIT_FLASH_MS = 180;
27
28const PLAYER_COLOR = "hsl(45 100% 55%)";
29const PLAYER_GLYPH = "🐔";
30const PLAYER_GLYPH_SIZE = "16px"; // tune relative to your actual dot size
31const HIT_COLOR = "hsl(355 85% 55%)";
32const HAZARD_GLYPH = "🚗";
33const HAZARD_GLYPH_SIZE = "16px"; // tune relative to your actual dot size
34const HAZARD_BG = "hsla(330, 85%, 62%, 0.55)";
35const HAZARD_BG_REVERSE = "hsla(280, 80%, 62%, 0.55)";
36const GAME_OVER_GLYPH = "🍗";
37const GAME_OVER_GLYPH_SIZE = "16px";
38const GRASS_HUE = 100;
39const GRASS_HUE_JITTER = 18;
40const GRASS_LIGHTNESS_MIN = 36;
41const GRASS_LIGHTNESS_MAX = 58;
42const GRASS_SCALE_MIN = 0.65;
43const GRASS_SCALE_MAX = 1.3;
44
45function getCols() {
46 return matchMedia(WIDE_QUERY).matches ? COLS_WIDE : COLS_NARROW;
47}
48
49function setupGame(grid) {
50 const cells = Array.from(grid.children, (c) => c.firstElementChild).filter(Boolean);
51 const total = cells.length;
52 if (total === 0) return () => {};
53
54 const reducedMotion = matchMedia(REDUCED_MOTION_QUERY).matches;
55 const wideQuery = matchMedia(WIDE_QUERY);
56
57 let cols = getCols();
58 let rows = Math.ceil(total / cols);
59 let homeRow = total % cols === 0 ? rows - 1 : rows - 2;
60 let prevKey = new Array(total).fill("");
61 let lanes = [];
62 let player = { row: 0, col: 0 };
63 let level = 1;
64 let bestLevel = 1;
65 let lives = LIVES_START;
66 let gameOver = false;
67 let justWon = false;
68 let resolving = false;
69 let raf = 0;
70 let lastTs = 0;
71 let visible = true;
72 let flashTimeoutId = 0;
73 let winTimeoutId = 0;
74 let playerElIdx = -1;
75 let hazardIdxs = new Set();
76 let audioCtx = null;
77
78 cells.forEach((el) => {
79 el.style.transformOrigin = "center";
80 if (!reducedMotion) {
81 el.style.transition = "background-color 0.12s ease, transform 0.12s ease";
82 }
83 });
84
85 grid.tabIndex = 0;
86 grid.setAttribute("aria-label", "Dodge game: use arrow keys or tap to cross");
87
88 const status = document.createElement("div");
89 status.style.fontSize = "11px";
90 status.style.lineHeight = "1.4";
91 status.style.marginTop = "6px";
92 status.style.opacity = "0.75";
93 status.style.fontFamily = "inherit";
94 status.setAttribute("aria-live", "polite");
95 grid.insertAdjacentElement("afterend", status);
96
97 let grassColor = new Array(total);
98 let grassScale = new Array(total);
99 function buildGrassField() {
100 for (let i = 0; i < total; i++) {
101 const hue = GRASS_HUE + (Math.random() * 2 - 1) * GRASS_HUE_JITTER;
102 const light = GRASS_LIGHTNESS_MIN + Math.random() * (GRASS_LIGHTNESS_MAX - GRASS_LIGHTNESS_MIN);
103 grassColor[i] = `hsl(${hue.toFixed(0)} 55% ${light.toFixed(0)}%)`;
104 grassScale[i] = GRASS_SCALE_MIN + Math.random() * (GRASS_SCALE_MAX - GRASS_SCALE_MIN);
105 }
106 }
107 buildGrassField();
108
109 function colsInRow(row) {
110 return row === rows - 1 ? total - cols * (rows - 1) : cols;
111 }
112
113 // Odd levels climb from the bottom (homeRow) to the top (0).
114 // Even levels descend from the top (0) back to the bottom (homeRow).
115 function goalRow() {
116 return level % 2 === 1 ? 0 : homeRow;
117 }
118 function startRow() {
119 return level % 2 === 1 ? homeRow : 0;
120 }
121
122 function laneTypeFor(row) {
123 if (row === goalRow()) return "goal";
124 if (row === startRow()) return "home";
125 if (row % SAFE_ROW_INTERVAL === 0) return "safe";
126 return "danger";
127 }
128
129 function buildLanes() {
130 lanes = new Array(rows);
131 for (let r = 0; r <= homeRow; r++) {
132 const type = laneTypeFor(r);
133 if (type !== "danger") {
134 lanes[r] = { type };
135 continue;
136 }
137 const distFromGoal = Math.abs(r - goalRow());
138 const baseCars = Math.max(1, Math.floor(cols / CAR_DENSITY_DIVISOR));
139 const bonusCars = Math.floor((level - 1) * CARS_PER_LEVEL);
140 lanes[r] = {
141 type,
142 dir: r % 2 === 0 ? 1 : -1,
143 speed: BASE_SPEED + (homeRow - distFromGoal) * SPEED_RAMP + (level - 1) * 0.08,
144 carCount: Math.min(MAX_CARS_PER_LANE, baseCars + bonusCars),
145 offset: Math.random() * cols,
146 };
147 }
148 }
149
150 function carColumnsFor(lane) {
151 const spacing = cols / lane.carCount;
152 const out = [];
153 for (let k = 0; k < lane.carCount; k++) {
154 const pos = ((lane.offset + k * spacing) % cols + cols) % cols;
155 out.push(Math.round(pos) % cols);
156 }
157 return out;
158 }
159
160 function resetPlayer() {
161 const row = startRow();
162 const maxCol = colsInRow(row) - 1;
163 player = { row, col: Math.floor(maxCol / 2) };
164 }
165
166 function applyStyle(i, bg, scale) {
167 const key = bg + "|" + scale;
168 if (prevKey[i] === key) return;
169 prevKey[i] = key;
170 const el = cells[i];
171 el.style.backgroundColor = bg;
172 el.style.transform = scale === 1 ? "" : `scale(${scale})`;
173 }
174
175 function clearCell(el) {
176 el.style.backgroundColor = "";
177 el.style.transform = "";
178 el.textContent = "";
179 el.style.fontSize = "";
180 el.style.display = "";
181 el.style.alignItems = "";
182 el.style.justifyContent = "";
183 }
184
185 function paintPlayer(idx) {
186 if (playerElIdx !== -1 && playerElIdx !== idx) {
187 clearCell(cells[playerElIdx]);
188 prevKey[playerElIdx] = "";
189 }
190 const el = cells[idx];
191 el.style.backgroundColor = PLAYER_COLOR;
192 el.style.display = "flex";
193 el.style.alignItems = "center";
194 el.style.justifyContent = "center";
195 el.style.fontSize = PLAYER_GLYPH_SIZE;
196 el.textContent = PLAYER_GLYPH;
197 prevKey[idx] = "player";
198 playerElIdx = idx;
199 }
200
201 function paintHazard(i, dir) {
202 const el = cells[i];
203 el.style.backgroundColor = dir === 1 ? HAZARD_BG_REVERSE : HAZARD_BG;
204 el.style.display = "flex";
205 el.style.alignItems = "center";
206 el.style.justifyContent = "center";
207 el.style.fontSize = HAZARD_GLYPH_SIZE;
208 el.style.transform = dir === 1 ? "scaleX(-1)" : "";
209 el.textContent = HAZARD_GLYPH;
210 prevKey[i] = "hazard|" + dir;
211 }
212
213 function clearHazard(i) {
214 clearCell(cells[i]);
215 prevKey[i] = "";
216 }
217
218 function renderGameOver() {
219 for (let i = 0; i < total; i++) {
220 const el = cells[i];
221 el.style.backgroundColor = "";
222 el.style.transform = "";
223 el.style.display = "flex";
224 el.style.alignItems = "center";
225 el.style.justifyContent = "center";
226 el.style.fontSize = GAME_OVER_GLYPH_SIZE;
227 el.textContent = GAME_OVER_GLYPH;
228 prevKey[i] = "gameover";
229 }
230 playerElIdx = -1;
231 hazardIdxs = new Set();
232 }
233
234 function render() {
235 if (gameOver) {
236 renderGameOver();
237 return;
238 }
239 const newHazards = new Map();
240 for (let r = 0; r <= homeRow; r++) {
241 const lane = lanes[r];
242 const rowCols = colsInRow(r);
243 const carCols = lane.type === "danger" ? carColumnsFor(lane) : null;
244 for (let c = 0; c < rowCols; c++) {
245 const i = r * cols + c;
246 if (carCols && carCols.includes(c)) {
247 newHazards.set(i, lane.dir);
248 } else {
249 applyStyle(i, grassColor[i], grassScale[i]);
250 }
251 }
252 }
253 for (const i of hazardIdxs) {
254 if (!newHazards.has(i)) clearHazard(i);
255 }
256 for (const [i, dir] of newHazards) {
257 paintHazard(i, dir);
258 }
259 hazardIdxs = new Set(newHazards.keys());
260
261 const pIdx = player.row * cols + player.col;
262 paintPlayer(pIdx);
263 }
264
265 function updateStatus() {
266 if (gameOver) {
267 status.textContent = `Game over — reached level ${level} (best ${bestLevel}). Click the grid to try again.`;
268 return;
269 }
270 if (justWon) {
271 status.textContent = `Level ${level} complete! Best ${bestLevel}`;
272 return;
273 }
274 const filled = "●".repeat(lives);
275 const empty = "○".repeat(Math.max(0, LIVES_START - lives));
276 const dirLabel = level % 2 === 1 ? "climb up" : "climb down";
277 const hint = reducedMotion ? "tap/arrows (turn-based)" : "arrows or tap";
278 status.textContent = `Level ${level}: ${dirLabel} · ${filled}${empty} · Best ${bestLevel} — ${hint}`;
279 }
280
281 function ensureAudio() {
282 const AC = window.AudioContext || window.webkitAudioContext;
283 if (!AC) return null;
284 if (!audioCtx) audioCtx = new AC();
285 if (audioCtx.state === "suspended") audioCtx.resume();
286 return audioCtx;
287 }
288
289 function playTone(freq, duration, type, gainLevel) {
290 const ctx = ensureAudio();
291 if (!ctx) return;
292 const osc = ctx.createOscillator();
293 const gain = ctx.createGain();
294 osc.type = type;
295 osc.frequency.value = freq;
296 gain.gain.value = gainLevel;
297 gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + duration);
298 osc.connect(gain).connect(ctx.destination);
299 osc.start();
300 osc.stop(ctx.currentTime + duration);
301 }
302
303 function sfxHop() {
304 playTone(520, 0.07, "square", 0.07);
305 }
306
307 function sfxHit() {
308 playTone(120, 0.25, "sawtooth", 0.12);
309 }
310
311 function sfxWin() {
312 [523, 659, 784, 1047].forEach((freq, i) => {
313 setTimeout(() => playTone(freq, 0.15, "square", 0.09), i * 90);
314 });
315 }
316
317 function sfxGameOver() {
318 playTone(200, 0.35, "sawtooth", 0.1);
319 setTimeout(() => playTone(140, 0.4, "sawtooth", 0.1), 150);
320 }
321
322 function checkCollision() {
323 if (gameOver || resolving) return false;
324 const lane = lanes[player.row];
325 if (lane.type !== "danger") return false;
326 if (!carColumnsFor(lane).includes(player.col)) return false;
327
328 resolving = true;
329 lives--;
330 sfxHit();
331 const hitIdx = player.row * cols + player.col;
332 clearCell(cells[hitIdx]);
333 cells[hitIdx].style.backgroundColor = HIT_COLOR;
334 cells[hitIdx].style.transform = "scale(1.5)";
335 prevKey[hitIdx] = "hit";
336
337 const finalize = () => {
338 resolving = false;
339 if (lives <= 0) {
340 gameOver = true;
341 cancelAnimationFrame(raf);
342 raf = 0;
343 sfxGameOver();
344 } else {
345 resetPlayer();
346 }
347 updateStatus();
348 render();
349 };
350
351 if (reducedMotion) {
352 finalize();
353 } else {
354 flashTimeoutId = setTimeout(finalize, HIT_FLASH_MS);
355 }
356 return true;
357 }
358
359 function win() {
360 clearTimeout(winTimeoutId);
361 resolving = true;
362 justWon = true;
363 sfxWin();
364 updateStatus();
365 render();
366 winTimeoutId = setTimeout(advanceLevel, WIN_PAUSE_MS);
367 }
368
369 function advanceLevel() {
370 level++;
371 bestLevel = Math.max(bestLevel, level);
372 justWon = false;
373 resolving = false;
374 buildLanes();
375 resetPlayer();
376 prevKey.fill("");
377 updateStatus();
378 render();
379 if (!reducedMotion) {
380 lastTs = 0;
381 if (raf === 0 && visible) raf = requestAnimationFrame(frame);
382 }
383 }
384
385 function advanceLanesOneStep() {
386 for (let r = 0; r <= homeRow; r++) {
387 const lane = lanes[r];
388 if (lane.type !== "danger") continue;
389 const steps = Math.max(1, Math.round(lane.speed));
390 lane.offset = ((lane.offset + lane.dir * steps) % cols + cols) % cols;
391 }
392 }
393
394 function advanceLanesContinuous(dt) {
395 for (let r = 0; r <= homeRow; r++) {
396 const lane = lanes[r];
397 if (lane.type !== "danger") continue;
398 lane.offset = ((lane.offset + lane.dir * lane.speed * dt) % cols + cols) % cols;
399 }
400 }
401
402 function tryMove(dr, dc) {
403 if (gameOver) {
404 newGame();
405 return;
406 }
407 if (resolving) return;
408 const nr = player.row + dr;
409 const nc = player.col + dc;
410 if (nr < 0 || nr > homeRow) return;
411 const maxCol = colsInRow(nr) - 1;
412 if (nc < 0 || nc > maxCol) return;
413
414 player.row = nr;
415 player.col = nc;
416 sfxHop();
417 if (nr === goalRow()) {
418 win();
419 return;
420 }
421 let hit = checkCollision();
422 if (!hit && reducedMotion) {
423 advanceLanesOneStep();
424 hit = checkCollision();
425 }
426 render();
427 updateStatus();
428 }
429
430 function frame(ts) {
431 const dt = lastTs ? (ts - lastTs) / 1000 : 0;
432 lastTs = ts;
433 if (!gameOver && !resolving) {
434 advanceLanesContinuous(dt);
435 checkCollision();
436 }
437 render();
438 if (visible && !gameOver) {
439 raf = requestAnimationFrame(frame);
440 } else {
441 raf = 0;
442 }
443 }
444
445 function newGame() {
446 level = 1;
447 lives = LIVES_START;
448 gameOver = false;
449 justWon = false;
450 resolving = false;
451 playerElIdx = -1;
452 hazardIdxs = new Set();
453 cells.forEach(clearCell);
454 prevKey.fill("");
455 buildLanes();
456 resetPlayer();
457 updateStatus();
458 render();
459 if (!reducedMotion) {
460 lastTs = 0;
461 if (raf === 0 && visible) raf = requestAnimationFrame(frame);
462 }
463 }
464
465 function onKeyDown(e) {
466 const moves = {
467 ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1],
468 w: [-1, 0], s: [1, 0], a: [0, -1], d: [0, 1],
469 W: [-1, 0], S: [1, 0], A: [0, -1], D: [0, 1],
470 };
471 const mv = moves[e.key];
472 if (!mv) return;
473 e.preventDefault();
474 tryMove(mv[0], mv[1]);
475 }
476
477 function onClick(e) {
478 grid.focus({ preventScroll: true });
479 if (gameOver) {
480 newGame();
481 return;
482 }
483 const rect = grid.getBoundingClientRect();
484 const colF = ((e.clientX - rect.left) / rect.width) * cols;
485 const rowF = ((e.clientY - rect.top) / rect.height) * rows;
486 const dc = colF - (player.col + 0.5);
487 const dr = rowF - (player.row + 0.5);
488 if (Math.abs(dc) > Math.abs(dr)) tryMove(0, dc > 0 ? 1 : -1);
489 else tryMove(dr > 0 ? 1 : -1, 0);
490 }
491
492 grid.addEventListener("keydown", onKeyDown);
493 grid.addEventListener("click", onClick);
494
495 function rebuild() {
496 cancelAnimationFrame(raf);
497 raf = 0;
498 cols = getCols();
499 rows = Math.ceil(total / cols);
500 homeRow = total % cols === 0 ? rows - 1 : rows - 2;
501 prevKey = new Array(total).fill("");
502 newGame();
503 }
504 wideQuery.addEventListener("change", rebuild);
505
506 let io = null;
507 if (!reducedMotion) {
508 io = new IntersectionObserver((entries) => {
509 const nowVisible = entries.some((e) => e.isIntersecting);
510 if (nowVisible === visible) return;
511 visible = nowVisible;
512 if (visible && !gameOver && raf === 0) {
513 lastTs = 0;
514 raf = requestAnimationFrame(frame);
515 } else if (!visible) {
516 cancelAnimationFrame(raf);
517 raf = 0;
518 }
519 });
520 io.observe(grid);
521 }
522
523 newGame();
524
525 return () => {
526 cancelAnimationFrame(raf);
527 clearTimeout(flashTimeoutId);
528 clearTimeout(winTimeoutId);
529 grid.removeEventListener("keydown", onKeyDown);
530 grid.removeEventListener("click", onClick);
531 wideQuery.removeEventListener("change", rebuild);
532 if (io) io.disconnect();
533 grid.removeAttribute("tabindex");
534 grid.removeAttribute("aria-label");
535 cells.forEach(clearCell);
536 if (audioCtx) audioCtx.close();
537 status.remove();
538 };
539}
540
541let currentGrid = null;
542let cleanup = null;
543function init() {
544 const grid = document.querySelector("[data-punchcard]");
545 if (grid === currentGrid) return;
546 if (cleanup) cleanup();
547 currentGrid = grid;
548 cleanup = grid ? setupGame(grid) : null;
549}
550init();
551document.addEventListener("htmx:load", init);