This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / pages / profile-fx / danschmidt.js
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);