summaryrefslogtreecommitdiff
path: root/trick-or-treat-run/index.html
diff options
context:
space:
mode:
Diffstat (limited to 'trick-or-treat-run/index.html')
-rw-r--r--trick-or-treat-run/index.html967
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 &nbsp;·&nbsp; ${g.candy} candy &nbsp;·&nbsp; 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>