diff options
Diffstat (limited to 'trick-or-treat-run/index.html')
| -rw-r--r-- | trick-or-treat-run/index.html | 967 |
1 files changed, 967 insertions, 0 deletions
diff --git a/trick-or-treat-run/index.html b/trick-or-treat-run/index.html new file mode 100644 index 0000000..1ac740c --- /dev/null +++ b/trick-or-treat-run/index.html @@ -0,0 +1,967 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> +<title>Trick or Treat Run</title> +<script async src="https://plausible.io/js/pa-JWiyAnkl2l96nMNDBD7OS.js"></script> +<meta name="description" content="An endless Halloween runner. Grab candy, dodge pumpkins, tombstones, and bats."> +<style> + :root { + --bg: #0b0912; + --fg: #ece2f6; + --dim: #9a8bb0; + --orange: #ff8a1f; + --gold: #ffd36b; + } + * { box-sizing: border-box; } + html, body { margin: 0; background: var(--bg); color: var(--fg); } + body { + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + min-height: 100vh; + min-height: 100svh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 16px; + gap: 12px; + } + h1 { + margin: 0; + font-size: clamp(18px, 3vw, 26px); + letter-spacing: 2px; + color: var(--orange); + text-transform: uppercase; + } + #wrap { + position: relative; + width: min(960px, calc(100vw - 32px), calc((100vh - 150px) * 16 / 9)); + width: min(960px, calc(100vw - 32px), calc((100svh - 150px) * 16 / 9)); + aspect-ratio: 16 / 9; + border-radius: 12px; + overflow: hidden; + box-shadow: 0 0 0 1px #2a2238, 0 20px 60px rgba(0,0,0,.6), 0 0 80px rgba(255,138,31,.08); + user-select: none; + -webkit-user-select: none; + -webkit-tap-highlight-color: transparent; + touch-action: none; + cursor: pointer; + } + canvas { display: block; width: 100%; height: 100%; } + #hud { + position: absolute; + top: 0; left: 0; right: 0; + display: flex; + justify-content: space-between; + padding: 10px 14px; + font-weight: 700; + font-size: clamp(13px, 2.2vw, 20px); + text-shadow: 0 2px 6px rgba(0,0,0,.8); + pointer-events: none; + font-variant-numeric: tabular-nums; + } + #hud .rush { color: var(--gold); } + #toast { + position: absolute; + top: 18%; + left: 50%; + transform: translateX(-50%); + font-size: clamp(12px, 2vw, 18px); + font-style: italic; + color: #d9c9ee; + text-shadow: 0 2px 8px #000; + opacity: 0; + transition: opacity .6s; + pointer-events: none; + white-space: nowrap; + } + #toast.show { opacity: 1; } + #screen { + position: absolute; + inset: 0; + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 16px; + background: rgba(8, 6, 14, .62); + gap: clamp(4px, 1.2vw, 12px); + } + #screen.show { display: flex; } + #screen h2 { + margin: 0; + font-size: clamp(22px, 5.5vw, 52px); + color: var(--orange); + letter-spacing: 2px; + text-shadow: 0 0 24px rgba(255,138,31,.45); + } + #screen p { margin: 0; font-size: clamp(12px, 1.9vw, 17px); color: var(--fg); max-width: 34em; line-height: 1.4; } + #screen .dim { color: var(--dim); } + #screen .stats { font-size: clamp(13px, 2.2vw, 20px); font-weight: 700; font-variant-numeric: tabular-nums; } + #screen .best { color: var(--gold); } + #screen .go { + margin-top: clamp(4px, 1vw, 10px); + font-size: clamp(13px, 2vw, 18px); + font-weight: 700; + color: var(--gold); + animation: pulse 1.4s ease-in-out infinite; + } + @keyframes pulse { 50% { opacity: .45; } } + #bar { + display: flex; + gap: 14px; + align-items: center; + flex-wrap: wrap; + justify-content: center; + color: var(--dim); + font-size: 13px; + } + #bar button { + background: transparent; + color: var(--fg); + border: 1px solid #3a2f4d; + border-radius: 8px; + padding: 6px 12px; + font: inherit; + cursor: pointer; + } + #bar button:hover { border-color: var(--orange); } + @media (orientation: portrait) and (max-width: 700px) { + body { padding: 8px; gap: 8px; justify-content: flex-start; } + h1 { font-size: 18px; margin-top: 4px; } + #wrap { + aspect-ratio: auto; + width: calc(100vw - 16px); + height: calc(100vh - 100px); + height: calc(100svh - 100px); + } + #bar span { display: none; } + } + @media (max-height: 520px) { + body { padding: 8px; gap: 8px; } + h1, #bar span { display: none; } + #wrap { + width: min(960px, calc(100vw - 16px), calc((100vh - 60px) * 16 / 9)); + width: min(960px, calc(100vw - 16px), calc((100svh - 60px) * 16 / 9)); + } + #bar button { padding: 4px 10px; font-size: 12px; } + } +</style> +</head> +<body> + <h1>Trick or Treat Run</h1> + <div id="wrap"> + <canvas id="game" aria-label="Trick or Treat Run game"></canvas> + <div id="hud"><span id="hud-candy">🍬 0</span><span id="hud-dist">0 m</span></div> + <div id="toast"></div> + <div id="screen"></div> + </div> + <div id="bar"> + <button id="mute" type="button">Sound: on</button> + <button id="pause" type="button">Pause</button> + <span>Tap, click, or Space to jump. Jump again in the air to double jump. Hold for higher.</span> + </div> + +<script> +(() => { + /* ---------- setup ---------- */ + const W = 800, H = 450, GROUND = 385; + const GRAV = 2300, JUMP = 840, JUMP2 = 720; + const cv = document.getElementById('game'); + const ctx = cv.getContext('2d'); + const $wrap = document.getElementById('wrap'); + const $screen = document.getElementById('screen'); + const $toast = document.getElementById('toast'); + const $candy = document.getElementById('hud-candy'); + const $dist = document.getElementById('hud-dist'); + const $mute = document.getElementById('mute'); + const $pause = document.getElementById('pause'); + const rand = (a, b) => a + Math.random() * (b - a); + const TAU = Math.PI * 2; + + // The game world is always 800 x 450. V is the part of it on screen. In landscape that is + // the whole world. In portrait the camera shows a narrower slice with extra sky above and + // extra ground below, and the game runs a little slower since you see less road ahead. + let V = { x0: 0, x1: W, y0: 0, y1: H }; + let PACE = 1; + let portrait = false; + function resize() { + const r = cv.getBoundingClientRect(); + const dpr = Math.min(2, window.devicePixelRatio || 1); + cv.width = Math.max(1, Math.round(r.width * dpr)); + cv.height = Math.max(1, Math.round(r.height * dpr)); + portrait = r.height > r.width * 1.05; + if (!portrait) { + V = { x0: 0, x1: W, y0: 0, y1: H }; + PACE = 1; + ctx.setTransform(cv.width / W, 0, 0, cv.height / H, 0, 0); + return; + } + const VW = 490, CX = 95; // visible world width, camera left edge + const s = cv.width / VW; // device pixels per world unit + const VH = cv.height / s; // visible world height + const offY = VH * 0.6 - GROUND; // ground line sits a bit below the middle + V = { x0: CX, x1: CX + VW, y0: -offY, y1: VH - offY }; + PACE = 0.76; + ctx.setTransform(s, 0, 0, s, -CX * s, offY * s); + } + window.addEventListener('resize', resize); + + const store = { + get(k, d) { try { const v = localStorage.getItem(k); return v === null ? d : JSON.parse(v); } catch (e) { return d; } }, + set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) { /* storage unavailable */ } } + }; + + /* ---------- sound ---------- */ + let actx = null; + let muted = store.get('totr-muted', false); + function ensureAudio() { + if (actx) { if (actx.state === 'suspended') actx.resume(); return; } + try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { actx = null; } + } + function tone(freq, dur, type = 'square', vol = 0.04, slide = 0, delay = 0) { + if (muted || !actx) return; + const t = actx.currentTime + delay; + const o = actx.createOscillator(), gn = actx.createGain(); + o.type = type; + o.frequency.setValueAtTime(freq, t); + if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(30, freq + slide), t + dur); + gn.gain.setValueAtTime(vol, t); + gn.gain.exponentialRampToValueAtTime(0.0001, t + dur); + o.connect(gn).connect(actx.destination); + o.start(t); + o.stop(t + dur + 0.02); + } + const sfx = { + jump: () => tone(380, 0.13, 'square', 0.03, 320), + jump2: () => tone(560, 0.12, 'square', 0.03, 380), + candy: () => { tone(988, 0.07, 'triangle', 0.05); tone(1318, 0.09, 'triangle', 0.05, 0, 0.06); }, + bar: () => [523, 659, 784, 1047].forEach((f, i) => tone(f, 0.12, 'triangle', 0.05, 0, i * 0.07)), + smash: () => tone(220, 0.15, 'sawtooth', 0.04, -120), + crash: () => { tone(200, 0.45, 'sawtooth', 0.06, -150); tone(90, 0.5, 'square', 0.04, -40, 0.05); } + }; + function setMuteLabel() { $mute.textContent = muted ? 'Sound: off' : 'Sound: on'; } + $mute.addEventListener('click', () => { muted = !muted; store.set('totr-muted', muted); setMuteLabel(); ensureAudio(); }); + setMuteLabel(); + + /* ---------- scenery (generated once) ---------- */ + const STARS = Array.from({ length: 320 }, () => ({ x: Math.random() * W, y: rand(-900, 250), r: Math.random() * 1.3 + 0.3, p: Math.random() * TAU })); + + function makeHouses() { + const arr = []; + let x = 0; + while (x < W * 2.4) { + const w = rand(90, 150), h = rand(70, 120); + const cols = Math.max(2, Math.floor(w / 38)), rows = h > 95 ? 2 : 1; + const wins = []; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + wins.push({ x: 12 + c * ((w - 24) / cols) + ((w - 24) / cols - 16) / 2, y: 16 + r * 34, lit: Math.random() < 0.5, f: Math.random() }); + } + } + arr.push({ x, w, h, roof: rand(26, 50), wins, lantern: Math.random() < 0.4, chimney: Math.random() < 0.5, tone: Math.floor(rand(0, 3)) }); + x += w + rand(40, 90); + } + return { arr, total: x }; + } + function branch(list, x, y, ang, len, wd, depth) { + const x2 = x + Math.cos(ang) * len, y2 = y + Math.sin(ang) * len; + list.push([x, y, x2, y2, wd]); + if (depth > 0) { + branch(list, x2, y2, ang - rand(0.3, 0.65), len * rand(0.6, 0.75), wd * 0.65, depth - 1); + branch(list, x2, y2, ang + rand(0.3, 0.65), len * rand(0.6, 0.75), wd * 0.65, depth - 1); + } + } + function makeTrees() { + const arr = []; + let x = 0; + while (x < W * 2.2) { + const segs = []; + const h = rand(90, 150); + branch(segs, 0, 0, -Math.PI / 2 + rand(-0.12, 0.12), h * 0.42, 7, 4); + arr.push({ x, segs }); + x += rand(240, 420); + } + return { arr, total: x }; + } + const HOUSES = makeHouses(); + const TREES = makeTrees(); + const FOG = Array.from({ length: 7 }, () => ({ x: rand(0, W), y: rand(GROUND - 90, GROUND + 10), r: rand(90, 170), s: rand(0.3, 1) })); + + /* ---------- game state ---------- */ + let state = 'title'; // title | play | paused | over + let g; + let overAt = 0; + let best = store.get('totr-best', 0); + const TOASTS = [ + [250, 'The streetlights are starting to flicker.'], + [600, 'The fog is rolling in.'], + [1000, 'Wait. Is that the same house again?'], + [1500, 'Something is following you.'], + [2200, 'Nobody hands out candy this far down the street.'], + [3000, 'You can\'t remember where you live.'] + ]; + + function reset() { + g = { + t: 0, dist: 0, candy: 0, speed: 360 * PACE, rush: 0, shake: 0, + obs: [], items: [], parts: [], nextSpawn: 700, toastIdx: 0, + player: { x: 140, y: GROUND, vy: 0, onGround: true, jumps: 0, holding: false, run: 0 } + }; + } + const meters = () => Math.floor(g.dist / 50); + const fogLevel = () => Math.min(0.62, meters() / 2600); + const worldSpeed = () => g.speed * (g.rush > 0 ? 1.3 : 1); + + /* ---------- spawning ---------- */ + function candyArc(cx) { + for (let i = 0; i < 5; i++) { + const k = (i - 2) / 2.5; + g.items.push({ kind: 'candy', x: cx + (i - 2) * 34, y: GROUND - 70 - 80 * (1 - k * k), phase: Math.random() * TAU, color: pick(CANDY_COLORS) }); + } + } + function candyLine(x, n, y) { + for (let i = 0; i < n; i++) g.items.push({ kind: 'candy', x: x + i * 36, y, phase: Math.random() * TAU, color: pick(CANDY_COLORS) }); + } + const CANDY_COLORS = ['#ff4f7b', '#ffd36b', '#7ee081', '#5fc8ff', '#c38bff', '#ff8a1f']; + const pick = a => a[Math.floor(Math.random() * a.length)]; + + function spawn() { + const d = Math.min(1, g.t / 80); + const x = W + 60; + const r = Math.random(); + let width = 60; + if (r < 0.30) { + g.obs.push({ type: 'pumpkin', x, w: 42, h: 36 }); + if (Math.random() < 0.6) candyArc(x + 21); + width = 42; + } else if (r < 0.48) { + g.obs.push({ type: 'grave', x, w: 40, h: 62 }); + if (Math.random() < 0.5) candyArc(x + 20); + width = 40; + } else if (r < 0.58 && d > 0.15) { + g.obs.push({ type: 'pumpkin', x, w: 42, h: 36 }, { type: 'pumpkin', x: x + 46, w: 42, h: 36 }); + width = 90; + } else if (r < 0.72 && g.t > 8) { + g.obs.push({ type: 'bat', x: x + 20, baseY: GROUND - 128, y: GROUND - 128, amp: 10, phase: Math.random() * TAU }); + candyLine(x - 50, 4, GROUND - 28); + width = 120; + } else if (r < 0.86 && g.t > 8) { + g.obs.push({ type: 'bat', x: x + 20, baseY: GROUND - 40, y: GROUND - 40, amp: 6, phase: Math.random() * TAU }); + width = 40; + } else { + candyLine(x, 5, Math.random() < 0.5 ? GROUND - 28 : GROUND - 120); + width = 180; + } + if (g.t > 12 && Math.random() < 0.05) { + g.items.push({ kind: 'bar', x: x + width + 120, y: GROUND - 115, phase: 0 }); + } + g.nextSpawn = worldSpeed() * rand(0.8, 1.35) * (1 - 0.25 * d) + 180 + width; + } + + /* ---------- particles ---------- */ + function burst(x, y, color, n = 10, spd = 160) { + for (let i = 0; i < n; i++) { + const a = Math.random() * TAU, s = rand(spd * 0.4, spd); + g.parts.push({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s - 60, life: rand(0.4, 0.8), max: 0.8, color, size: rand(2, 4) }); + } + } + + /* ---------- input ---------- */ + function press() { + ensureAudio(); + if (state === 'title') { startGame(); return; } + if (state === 'over') { if (performance.now() - overAt > 700) startGame(); return; } + if (state === 'paused') { resume(); return; } + const p = g.player; + if (p.onGround) { + p.vy = -JUMP; p.onGround = false; p.jumps = 1; p.holding = true; sfx.jump(); + } else if (p.jumps < 2) { + p.vy = -JUMP2; p.jumps = 2; p.holding = true; sfx.jump2(); + burst(p.x + 20, p.y, 'rgba(255,255,255,.7)', 6, 90); + } + } + function release() { if (g) g.player.holding = false; } + + $wrap.addEventListener('pointerdown', e => { e.preventDefault(); press(); }); + window.addEventListener('pointerup', release); + window.addEventListener('pointercancel', release); + window.addEventListener('keydown', e => { + if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') { + e.preventDefault(); + if (!e.repeat) press(); + } else if (e.code === 'Enter' && (state === 'title' || state === 'over')) { + press(); + } else if (e.code === 'KeyP' || e.code === 'Escape') { + togglePause(); + } + }); + window.addEventListener('keyup', e => { if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'KeyW') release(); }); + $pause.addEventListener('click', togglePause); + document.addEventListener('visibilitychange', () => { if (document.hidden && state === 'play') pause(); }); + + /* ---------- screens ---------- */ + function showScreen(html) { $screen.innerHTML = html; $screen.classList.add('show'); } + function hideScreen() { $screen.classList.remove('show'); } + + function titleScreen() { + state = 'title'; + reset(); + showScreen(` + <h2>TRICK OR TREAT RUN</h2> + <p>The houses on this street just keep going. So does the fog.</p> + <p class="dim">Grab candy. Jump pumpkins and tombstones. Duck under high bats, jump over low ones.<br>A king-size bar makes you unstoppable for a few seconds.</p> + ${best ? `<p class="stats best">Best: ${best}</p>` : ''} + <div class="go">Tap or press Space to start</div>`); + } + function startGame() { + reset(); + state = 'play'; + hideScreen(); + $pause.textContent = 'Pause'; + } + function pause() { + if (state !== 'play') return; + state = 'paused'; + $pause.textContent = 'Resume'; + showScreen('<h2>PAUSED</h2><div class="go">Tap or press Space to keep running</div>'); + } + function resume() { + state = 'play'; + $pause.textContent = 'Pause'; + hideScreen(); + if (g) g.player.holding = false; + } + function togglePause() { if (state === 'play') pause(); else if (state === 'paused') resume(); } + + const CAUSES = { + pumpkin: 'You tripped over a jack-o\'-lantern.', + grave: 'You ran face-first into a tombstone.', + bat: 'A bat got tangled in your sheet.' + }; + function gameOver(cause) { + state = 'over'; + overAt = performance.now(); + g.shake = 0.35; + const p = g.player; + burst(p.x + 20, p.y - 30, '#f2efe9', 18, 220); + burst(p.x + 20, p.y - 30, pick(CANDY_COLORS), 10, 240); + sfx.crash(); + const m = meters(); + const score = m + g.candy * 10; + const isBest = score > best; + if (isBest) { best = score; store.set('totr-best', best); } + setTimeout(() => { + if (state !== 'over') return; + showScreen(` + <h2>${isBest ? 'NEW BEST!' : 'GAME OVER'}</h2> + <p>${CAUSES[cause] || 'Something got you.'}</p> + <p class="stats">${m} m · ${g.candy} candy · score ${score}</p> + <p class="stats best">Best: ${best}</p> + <div class="go">Tap or press Space to run again</div>`); + }, 650); + } + + function toast(text) { + $toast.textContent = text; + $toast.classList.add('show'); + clearTimeout(toast.t); + toast.t = setTimeout(() => $toast.classList.remove('show'), 2800); + } + + /* ---------- update ---------- */ + function hitbox(o) { + if (o.type === 'pumpkin') return { x: o.x + 5, y: GROUND - o.h + 6, w: o.w - 10, h: o.h - 6 }; + if (o.type === 'grave') return { x: o.x + 4, y: GROUND - o.h + 4, w: o.w - 8, h: o.h - 4 }; + return { x: o.x - 13, y: o.y - 8, w: 26, h: 16 }; + } + const overlap = (a, b) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; + + function update(dt) { + g.t += dt; + g.speed = PACE * Math.min(900, 360 + g.t * 9); + if (g.rush > 0) g.rush = Math.max(0, g.rush - dt); + const v = worldSpeed(); + g.dist += v * dt; + + const p = g.player; + p.vy += GRAV * dt; + if (!p.holding && p.vy < 0) p.vy += GRAV * 0.9 * dt; + p.y += p.vy * dt; + if (p.y >= GROUND) { + if (!p.onGround && p.vy > 400) burst(p.x + 20, GROUND, 'rgba(180,170,200,.6)', 5, 70); + p.y = GROUND; p.vy = 0; p.onGround = true; p.jumps = 0; + } + p.run += dt * v / 38; + + g.nextSpawn -= v * dt; + if (g.nextSpawn <= 0) spawn(); + + const pr = { x: p.x + 9, y: p.y - 50, w: 24, h: 48 }; + for (const o of g.obs) { + o.x -= v * dt; + if (o.type === 'bat') { + o.x -= 70 * dt; + o.y = o.baseY + Math.sin(g.t * 3 + o.phase) * o.amp; + } + if (!o.dead && overlap(pr, hitbox(o))) { + if (g.rush > 0) { + o.dead = true; + burst(o.x + (o.w || 0) / 2, o.type === 'bat' ? o.y : GROUND - 20, o.type === 'pumpkin' ? '#ff8a1f' : o.type === 'grave' ? '#8a8e9c' : '#2a1d36', 14, 260); + sfx.smash(); + } else { + gameOver(o.type); + return; + } + } + } + g.obs = g.obs.filter(o => !o.dead && o.x > -80); + + const pcx = p.x + 20, pcy = p.y - 27; + for (const it of g.items) { + it.x -= v * dt; + if (it.got) continue; + const iy = it.y + Math.sin(g.t * 4 + it.phase) * 3; + const dx = it.x - pcx, dy = iy - pcy; + if (dx * dx + dy * dy < (it.kind === 'bar' ? 40 * 40 : 32 * 32)) { + it.got = true; + if (it.kind === 'bar') { + g.rush = 5; g.candy += 5; sfx.bar(); + burst(it.x, iy, '#ffd36b', 22, 260); + } else { + g.candy++; sfx.candy(); + burst(it.x, iy, it.color, 7, 120); + } + } + } + g.items = g.items.filter(it => !it.got && it.x > -60); + + for (const b of FOG) { + b.x -= (v * 0.45 + 25 * b.s) * dt; + if (b.x < -b.r) { b.x = W + b.r + rand(0, 200); b.y = rand(GROUND - 90, GROUND + 10); } + } + + const m = meters(); + if (g.toastIdx < TOASTS.length && m >= TOASTS[g.toastIdx][0]) toast(TOASTS[g.toastIdx++][1]); + } + + function updateParts(dt) { + const v = state === 'play' ? worldSpeed() : 0; + for (const q of g.parts) { + q.vy += 500 * dt; + q.x += (q.vx - v * 0.5) * dt; + q.y += q.vy * dt; + q.life -= dt; + } + g.parts = g.parts.filter(q => q.life > 0); + if (g.shake > 0) g.shake = Math.max(0, g.shake - dt); + } + + /* ---------- drawing ---------- */ + function drawSky(time) { + const sky = ctx.createLinearGradient(0, V.y0, 0, GROUND); + sky.addColorStop(0, '#0d0920'); + sky.addColorStop(1, '#2b163b'); + ctx.fillStyle = sky; + ctx.fillRect(V.x0, V.y0, V.x1 - V.x0, GROUND - V.y0); + ctx.fillStyle = '#fff'; + for (const s of STARS) { + if (s.y < V.y0 || s.x < V.x0 || s.x > V.x1) continue; + ctx.globalAlpha = 0.45 + 0.4 * Math.sin(time * 2 + s.p); + ctx.fillRect(s.x, s.y, s.r, s.r); + } + ctx.globalAlpha = 1; + const mx = V.x1 - 160, my = V.y0 + (GROUND - V.y0) * 0.24; + const mg = ctx.createRadialGradient(mx, my, 20, mx, my, 120); + mg.addColorStop(0, 'rgba(255,236,190,.35)'); + mg.addColorStop(1, 'rgba(255,236,190,0)'); + ctx.fillStyle = mg; + ctx.fillRect(mx - 140, my - 120, 280, 240); + ctx.fillStyle = '#f6e7c1'; + ctx.beginPath(); ctx.arc(mx, my, 38, 0, TAU); ctx.fill(); + ctx.fillStyle = 'rgba(200,180,140,.35)'; + [[-12, -10, 7], [12, 10, 5], [6, -16, 4], [-14, 12, 4]].forEach(([x, y, r]) => { ctx.beginPath(); ctx.arc(mx + x, my + y, r, 0, TAU); ctx.fill(); }); + } + + function drawHills(off) { + ctx.fillStyle = '#170f24'; + ctx.beginPath(); + ctx.moveTo(0, GROUND); + for (let x = 0; x <= W; x += 10) { + const wx = x + off; + ctx.lineTo(x, GROUND - 70 - Math.sin(wx / 110) * 16 - Math.sin(wx / 43) * 6); + } + ctx.lineTo(W, GROUND); + ctx.fill(); + } + + function drawHouses(off, time) { + const base = GROUND - 22; + const tones = ['#1b1428', '#201830', '#181224']; + for (const shift of [0, HOUSES.total]) { + for (const h of HOUSES.arr) { + const x = h.x - off + shift; + if (x > W + 20 || x + h.w < -20) continue; + ctx.fillStyle = tones[h.tone]; + ctx.fillRect(x, base - h.h, h.w, h.h); + ctx.beginPath(); + ctx.moveTo(x - 8, base - h.h); + ctx.lineTo(x + h.w / 2, base - h.h - h.roof); + ctx.lineTo(x + h.w + 8, base - h.h); + ctx.fill(); + if (h.chimney) ctx.fillRect(x + h.w * 0.72, base - h.h - h.roof * 0.75, 12, h.roof * 0.6); + for (const w of h.wins) { + const flick = h.wins.length && w.lit && Math.sin(time * 9 + w.f * 50) > 0.97; + if (w.lit && !flick) { + ctx.fillStyle = 'rgba(255,190,90,.18)'; + ctx.fillRect(x + w.x - 4, base - h.h + w.y - 4, 24, 26); + ctx.fillStyle = '#ffc76b'; + } else { + ctx.fillStyle = '#0d0a14'; + } + ctx.fillRect(x + w.x, base - h.h + w.y, 16, 18); + ctx.fillStyle = tones[h.tone]; + ctx.fillRect(x + w.x + 7, base - h.h + w.y, 2, 18); + } + ctx.fillStyle = '#0d0a14'; + ctx.fillRect(x + h.w / 2 - 9, base - 30, 18, 30); + if (h.lantern) { + // small and dim so it reads as porch decoration, not something to jump + const lx = x + h.w / 2 + 18; + ctx.fillStyle = 'rgba(255,140,40,.14)'; + ctx.beginPath(); ctx.arc(lx, base - 5, 9, 0, TAU); ctx.fill(); + ctx.fillStyle = '#9c4a16'; + ctx.beginPath(); ctx.ellipse(lx, base - 5, 5, 4, 0, 0, TAU); ctx.fill(); + ctx.fillStyle = '#d9a650'; + ctx.fillRect(lx - 3, base - 7, 2, 2); ctx.fillRect(lx + 1, base - 7, 2, 2); + } + } + } + ctx.fillStyle = '#130f1c'; + ctx.fillRect(0, base, W, GROUND - base); + } + + function drawTrees(off) { + ctx.strokeStyle = '#0c0912'; + ctx.lineCap = 'round'; + for (const shift of [0, TREES.total]) { + for (const t of TREES.arr) { + const x = t.x - off + shift; + if (x > W + 120 || x < -120) continue; + for (const [x1, y1, x2, y2, wd] of t.segs) { + ctx.lineWidth = wd; + ctx.beginPath(); + ctx.moveTo(x + x1, GROUND - 8 + y1); + ctx.lineTo(x + x2, GROUND - 8 + y2); + ctx.stroke(); + } + } + } + } + + function drawLamps(dist) { + const sp = 560; + const startIdx = Math.floor(dist / sp); + for (let i = 0; i < 3; i++) { + const idx = startIdx + i; + const lx = idx * sp - dist + 300; + if (lx < -100 || lx > W + 100) continue; + const flaky = meters() > 250 && ((idx * 9301 + 49297) % 233280) / 233280 < 0.35; + const on = !(flaky && Math.random() < 0.18); + if (on) { + const lg = ctx.createRadialGradient(lx + 22, GROUND - 140, 4, lx + 22, GROUND - 70, 120); + lg.addColorStop(0, 'rgba(255,214,140,.30)'); + lg.addColorStop(1, 'rgba(255,214,140,0)'); + ctx.fillStyle = lg; + ctx.fillRect(lx - 100, GROUND - 200, 250, 210); + ctx.fillStyle = 'rgba(255,214,140,.10)'; + ctx.beginPath(); ctx.ellipse(lx + 22, GROUND + 6, 70, 10, 0, 0, TAU); ctx.fill(); + } + ctx.fillStyle = '#0a0810'; + ctx.fillRect(lx - 2, GROUND - 150, 5, 150); + ctx.fillRect(lx - 2, GROUND - 150, 26, 4); + ctx.fillStyle = on ? '#ffe2a6' : '#3a3346'; + ctx.fillRect(lx + 16, GROUND - 146, 12, 8); + } + } + + function drawGround(dist) { + ctx.fillStyle = '#211d2b'; + ctx.fillRect(0, GROUND, W, H - GROUND); + ctx.fillStyle = '#3a3448'; + ctx.fillRect(0, GROUND, W, 2); + ctx.strokeStyle = '#2d283a'; + ctx.lineWidth = 2; + const off = dist % 90; + for (let x = -off; x < W + 20; x += 90) { + ctx.beginPath(); ctx.moveTo(x, GROUND + 2); ctx.lineTo(x - 12, GROUND + 44); ctx.stroke(); + } + ctx.fillStyle = '#17141e'; + ctx.fillRect(0, GROUND + 44, W, Math.max(H, V.y1) - GROUND - 44); + ctx.fillStyle = '#3a3448'; + ctx.fillRect(0, GROUND + 44, W, 3); + if (V.y1 > H) { + // portrait: a dashed center line on the road below the sidewalk + ctx.fillStyle = '#4a4258'; + const ly = GROUND + 44 + Math.min(70, (V.y1 - GROUND - 44) / 2); + const off2 = dist % 120; + for (let x = -off2; x < W + 20; x += 120) ctx.fillRect(x, ly, 60, 4); + } + } + + function drawPumpkin(o) { + const cx = o.x + o.w / 2, cy = GROUND - o.h / 2; + ctx.fillStyle = 'rgba(255,140,40,.18)'; + ctx.beginPath(); ctx.arc(cx, cy, o.w * 0.8, 0, TAU); ctx.fill(); + ctx.fillStyle = '#e8661c'; + ctx.beginPath(); ctx.ellipse(cx, cy, o.w / 2, o.h / 2, 0, 0, TAU); ctx.fill(); + ctx.strokeStyle = '#b8480f'; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.ellipse(cx, cy, o.w / 5, o.h / 2, 0, 0, TAU); ctx.stroke(); + ctx.fillStyle = '#4c6b2a'; + ctx.fillRect(cx - 2, cy - o.h / 2 - 6, 5, 8); + ctx.fillStyle = '#ffd36b'; + ctx.beginPath(); + ctx.moveTo(cx - 12, cy - 2); ctx.lineTo(cx - 7, cy - 9); ctx.lineTo(cx - 3, cy - 2); + ctx.moveTo(cx + 3, cy - 2); ctx.lineTo(cx + 7, cy - 9); ctx.lineTo(cx + 12, cy - 2); + ctx.fill(); + ctx.beginPath(); + ctx.moveTo(cx - 12, cy + 4); + ctx.lineTo(cx - 8, cy + 8); ctx.lineTo(cx - 4, cy + 5); ctx.lineTo(cx, cy + 9); + ctx.lineTo(cx + 4, cy + 5); ctx.lineTo(cx + 8, cy + 8); ctx.lineTo(cx + 12, cy + 4); + ctx.lineTo(cx + 8, cy + 11); ctx.lineTo(cx - 8, cy + 11); + ctx.closePath(); + ctx.fill(); + } + + function drawGrave(o) { + const x = o.x, top = GROUND - o.h, r = o.w / 2; + ctx.fillStyle = '#5d6170'; + ctx.beginPath(); + ctx.moveTo(x, GROUND); + ctx.lineTo(x, top + r); + ctx.arc(x + r, top + r, r, Math.PI, 0); + ctx.lineTo(x + o.w, GROUND); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = '#4a4d5a'; + ctx.fillRect(x + o.w - 7, top + r, 7, o.h - r); + ctx.fillStyle = '#353844'; + ctx.font = 'bold 12px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('RIP', x + r, top + r + 6); + ctx.strokeStyle = '#3c3f4b'; + ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(x + 10, top + 12); ctx.lineTo(x + 15, top + 20); ctx.lineTo(x + 12, top + 26); ctx.stroke(); + ctx.fillStyle = '#1f2a1c'; + ctx.fillRect(x - 4, GROUND - 4, o.w + 8, 4); + } + + function drawBat(o, time) { + const x = o.x, y = o.y, f = Math.sin(time * 20 + o.phase); + ctx.fillStyle = '#120c18'; + for (const s of [-1, 1]) { + ctx.beginPath(); + ctx.moveTo(x + 3 * s, y - 2); + ctx.quadraticCurveTo(x + 14 * s, y - 10 - 8 * f, x + 26 * s, y - 3 - 6 * f); + ctx.lineTo(x + 21 * s, y + 3); + ctx.lineTo(x + 16 * s, y); + ctx.lineTo(x + 11 * s, y + 4); + ctx.lineTo(x + 6 * s, y + 1); + ctx.closePath(); + ctx.fill(); + } + ctx.beginPath(); ctx.ellipse(x, y, 7, 7, 0, 0, TAU); ctx.fill(); + ctx.beginPath(); + ctx.moveTo(x - 6, y - 4); ctx.lineTo(x - 4, y - 11); ctx.lineTo(x - 1, y - 6); + ctx.moveTo(x + 6, y - 4); ctx.lineTo(x + 4, y - 11); ctx.lineTo(x + 1, y - 6); + ctx.fill(); + ctx.fillStyle = '#ff3b3b'; + ctx.fillRect(x - 4, y - 2, 2, 2); + ctx.fillRect(x + 2, y - 2, 2, 2); + } + + function drawCandy(it, time) { + const y = it.y + Math.sin(time * 4 + it.phase) * 3; + if (it.kind === 'bar') { + const pulse = 0.5 + 0.5 * Math.sin(time * 6); + ctx.fillStyle = `rgba(255,211,107,${0.18 + 0.2 * pulse})`; + ctx.beginPath(); ctx.arc(it.x, y, 30, 0, TAU); ctx.fill(); + ctx.save(); + ctx.translate(it.x, y); + ctx.rotate(Math.sin(time * 3) * 0.15); + ctx.fillStyle = '#d63a3a'; + ctx.fillRect(-20, -9, 40, 18); + ctx.fillStyle = '#7a3f1d'; + ctx.fillRect(-20, -9, 7, 18); + ctx.fillRect(13, -9, 7, 18); + ctx.fillStyle = '#fff'; + ctx.font = 'bold 10px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('XL', 0, 4); + ctx.restore(); + return; + } + ctx.save(); + ctx.translate(it.x, y); + ctx.rotate(Math.sin(time * 2 + it.phase) * 0.4); + ctx.fillStyle = it.color; + ctx.beginPath(); + ctx.moveTo(-9, 0); ctx.lineTo(-15, -6); ctx.lineTo(-15, 6); ctx.closePath(); + ctx.moveTo(9, 0); ctx.lineTo(15, -6); ctx.lineTo(15, 6); ctx.closePath(); + ctx.fill(); + ctx.beginPath(); ctx.ellipse(0, 0, 10, 7, 0, 0, TAU); ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,.55)'; + ctx.beginPath(); ctx.ellipse(-3, -2, 3, 2, 0, 0, TAU); ctx.fill(); + ctx.restore(); + } + + function drawPlayer(p, time) { + const x = p.x, y = p.y; + const ground = p.onGround && state !== 'title'; + const swing = ground ? Math.sin(p.run) : (state === 'title' ? Math.sin(time * 6) * 0.6 : 0.5); + const bob = ground ? Math.abs(Math.sin(p.run)) * -2 : 0; + + if (g.rush > 0) { + for (let i = 1; i <= 3; i++) { + ctx.fillStyle = `hsla(${(time * 400 + i * 60) % 360}, 90%, 70%, ${0.25 / i})`; + ctx.beginPath(); ctx.ellipse(x + 20 - i * 14, y - 30, 16, 24, 0, 0, TAU); ctx.fill(); + } + } + + ctx.strokeStyle = '#2b2b3a'; + ctx.lineWidth = 5; + ctx.lineCap = 'round'; + for (const s of [1, -1]) { + ctx.beginPath(); + ctx.moveTo(x + 20, y - 14 + bob); + ctx.lineTo(x + 20 + swing * 9 * s, y - 2); + ctx.stroke(); + ctx.fillStyle = s > 0 ? '#e94e4e' : '#c63c3c'; + ctx.beginPath(); ctx.ellipse(x + 23 + swing * 9 * s, y - 1, 6, 3, 0, 0, TAU); ctx.fill(); + } + + ctx.fillStyle = g.rush > 0 ? `hsl(${(time * 400) % 360}, 90%, 82%)` : '#f2efe9'; + const top = y - 38 + bob, hem = y - 14 + bob; + ctx.beginPath(); + ctx.moveTo(x + 4, hem); + ctx.lineTo(x + 4, top); + ctx.arc(x + 20, top, 16, Math.PI, 0); + ctx.lineTo(x + 36, hem); + const wave = Math.sin(time * 14) * 2; + for (let i = 0; i < 4; i++) { + const x1 = x + 36 - 8 * i, x2 = x1 - 8; + ctx.quadraticCurveTo((x1 + x2) / 2, hem + (i % 2 ? -4 : 5) + wave, x2, hem); + } + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = 'rgba(0,0,0,.08)'; + ctx.fillRect(x + 6, top + 2, 4, hem - top - 4); + + ctx.fillStyle = '#16121e'; + ctx.beginPath(); ctx.ellipse(x + 19, top - 3, 3, 4.5, 0, 0, TAU); ctx.fill(); + ctx.beginPath(); ctx.ellipse(x + 28, top - 3, 3, 4.5, 0, 0, TAU); ctx.fill(); + ctx.beginPath(); ctx.ellipse(x + 23.5, top + 7, 2.5, 3, 0, 0, TAU); ctx.fill(); + + const bx = x + 40, by = y - 22 + bob + swing * 2; + ctx.strokeStyle = '#2b2b3a'; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(bx, by - 6, 7, Math.PI, 0); ctx.stroke(); + ctx.fillStyle = '#ff8a1f'; + ctx.beginPath(); ctx.ellipse(bx, by + 2, 9, 8, 0, 0, TAU); ctx.fill(); + ctx.fillStyle = '#2a1405'; + ctx.fillRect(bx - 5, by - 1, 3, 3); + ctx.fillRect(bx + 2, by - 1, 3, 3); + ctx.fillRect(bx - 4, by + 4, 8, 2); + } + + function drawFog() { + const f = fogLevel(); + if (f <= 0.01) return; + for (const b of FOG) { + const gr = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, b.r); + gr.addColorStop(0, `rgba(170,160,190,${f * 0.45})`); + gr.addColorStop(1, 'rgba(170,160,190,0)'); + ctx.fillStyle = gr; + ctx.fillRect(b.x - b.r, b.y - b.r, b.r * 2, b.r * 2); + } + const vw = V.x1 - V.x0; + const edge = ctx.createLinearGradient(V.x0 + vw * 0.45, 0, V.x1, 0); + edge.addColorStop(0, 'rgba(14,10,22,0)'); + edge.addColorStop(1, `rgba(14,10,22,${Math.min(0.85, f * 1.35)})`); + ctx.fillStyle = edge; + ctx.fillRect(V.x0, V.y0, vw, V.y1 - V.y0); + } + + function drawVignette() { + const cx = (V.x0 + V.x1) / 2, cy = (V.y0 + V.y1) / 2; + const span = Math.max(V.x1 - V.x0, V.y1 - V.y0) / 2; + const vg = ctx.createRadialGradient(cx, cy, span * 0.6, cx, cy, span * 1.15); + vg.addColorStop(0, 'rgba(0,0,0,0)'); + vg.addColorStop(1, 'rgba(0,0,0,.55)'); + ctx.fillStyle = vg; + ctx.fillRect(V.x0, V.y0, V.x1 - V.x0, V.y1 - V.y0); + } + + function draw(time) { + ctx.save(); + if (g.shake > 0) ctx.translate(rand(-6, 6) * g.shake * 3, rand(-6, 6) * g.shake * 3); + drawSky(time); + drawHills(g.dist * 0.08); + drawHouses(g.dist * 0.25 % HOUSES.total, time); + drawTrees(g.dist * 0.55 % TREES.total); + drawGround(g.dist); + drawLamps(g.dist); + for (const it of g.items) drawCandy(it, time); + for (const o of g.obs) { + if (o.type === 'pumpkin') drawPumpkin(o); + else if (o.type === 'grave') drawGrave(o); + else drawBat(o, time); + } + if (state !== 'over') drawPlayer(g.player, time); + for (const q of g.parts) { + ctx.globalAlpha = Math.max(0, q.life / q.max); + ctx.fillStyle = q.color; + ctx.fillRect(q.x, q.y, q.size, q.size); + } + ctx.globalAlpha = 1; + drawFog(); + drawVignette(); + ctx.restore(); + } + + /* ---------- HUD ---------- */ + let hudCandy = -1, hudDist = -1, hudRush = null; + function updateHud() { + const m = meters(), rush = g.rush > 0; + if (g.candy !== hudCandy || rush !== hudRush) { + hudCandy = g.candy; hudRush = rush; + $candy.textContent = `🍬 ${g.candy}` + (rush ? ' SUGAR RUSH!' : ''); + $candy.className = rush ? 'rush' : ''; + } + if (m !== hudDist) { hudDist = m; $dist.textContent = `${m} m`; } + } + + /* ---------- loop ---------- */ + let last = performance.now(); + function frame(now) { + const dt = Math.min(0.033, (now - last) / 1000); + last = now; + const time = now / 1000; + if (state === 'play') update(dt); + else if (state === 'title') g.dist += 140 * dt; + updateParts(dt); + draw(time); + updateHud(); + requestAnimationFrame(frame); + } + + resize(); + titleScreen(); + requestAnimationFrame(frame); +})(); +</script> +</body> +</html> |
