← back to Model Arena

data/artifacts/035b7009e366/grok.html

1071 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Designer Wallcoverings — Roll</title>
<style>
:root {
  --bg: #0d0d0f;
  --surface: #17171c;
  --ink: #f5f2ea;
  --muted: #8a8578;
  --accent: #c9a961;
  --accent-dim: #7d6a45;
  --line: rgba(245, 242, 234, 0.12);
  --shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
  --ease: cubic-bezier(.22, .9, .24, 1);
  --ease-out: cubic-bezier(.22, 1, .36, 1);
  --ease-pop: cubic-bezier(.34, 1.56, .64, 1);
  --serif: 'Didot', 'Bodoni MT', 'Playfair Display', Georgia, 'Times New Roman', serif;
  --sans: 'Avenir Next', 'Century Gothic', Futura, 'Segoe UI', sans-serif;
  --bar-h: 64px;
}

*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

html, body {
  height: 100%;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--sans);
  font-size: 16px;
  line-height: 1.5;
  overflow: hidden;
  -webkit-font-smoothing: antialiased;
}

/* ── Top bar ── */
.topbar {
  position: fixed;
  top: 0; left: 0; right: 0;
  height: var(--bar-h);
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 28px;
  background: rgba(13, 13, 15, 0.92);
  border-bottom: 1px solid var(--line);
  backdrop-filter: blur(12px);
}

.wordmark {
  font-family: var(--serif);
  font-size: 15px;
  font-weight: 400;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--ink);
  line-height: 1.25;
  white-space: normal;
  max-width: 280px;
  flex-shrink: 0;
}

.hud {
  display: flex;
  align-items: center;
  gap: 28px;
}

.hud-item {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 2px;
}

.hud-label {
  font-family: var(--sans);
  font-size: 10px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--muted);
}

.hud-value {
  font-family: var(--sans);
  font-size: 18px;
  font-weight: 500;
  letter-spacing: 0.06em;
  color: var(--accent);
  font-variant-numeric: tabular-nums;
}

/* ── Stage ── */
.stage {
  position: fixed;
  top: var(--bar-h);
  left: 0; right: 0; bottom: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background:
    radial-gradient(ellipse 80% 60% at 50% 40%, rgba(201, 169, 97, 0.04), transparent 70%),
    var(--bg);
}

canvas {
  display: block;
  width: 100%;
  height: 100%;
  cursor: pointer;
}

/* ── Overlays ─�� */
.overlay {
  position: fixed;
  inset: 0;
  z-index: 200;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(13, 13, 15, 0.82);
  backdrop-filter: blur(16px);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.45s var(--ease-out);
}

.overlay.visible {
  opacity: 1;
  pointer-events: auto;
}

.panel {
  text-align: center;
  padding: 56px 64px;
  background: var(--surface);
  border: 1px solid var(--line);
  box-shadow: var(--shadow);
  max-width: 440px;
  width: calc(100% - 48px);
  transform: translateY(16px);
  transition: transform 0.5s var(--ease-out);
}

.overlay.visible .panel {
  transform: translateY(0);
}

.panel-eyebrow {
  font-family: var(--sans);
  font-size: 11px;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--accent);
  margin-bottom: 20px;
}

.panel-title {
  font-family: var(--serif);
  font-size: 38px;
  font-weight: 400;
  letter-spacing: 0.04em;
  line-height: 1.1;
  color: var(--ink);
  margin-bottom: 16px;
}

.panel-body {
  font-family: var(--sans);
  font-size: 15px;
  line-height: 1.65;
  color: var(--muted);
  margin-bottom: 36px;
  max-width: 32ch;
  margin-left: auto;
  margin-right: auto;
}

.panel-stats {
  display: flex;
  justify-content: center;
  gap: 40px;
  margin-bottom: 36px;
  padding: 20px 0;
  border-top: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
}

.stat-label {
  font-size: 10px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--muted);
  margin-bottom: 4px;
}

.stat-value {
  font-size: 28px;
  font-family: var(--serif);
  color: var(--accent);
  letter-spacing: 0.04em;
}

.btn {
  display: inline-block;
  font-family: var(--sans);
  font-size: 12px;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--bg);
  background: var(--accent);
  border: none;
  padding: 16px 40px;
  cursor: pointer;
  transition: transform 0.3s var(--ease-pop), background 0.25s var(--ease);
}

.btn:hover {
  background: var(--ink);
  transform: scale(1.03);
}

.btn:active {
  transform: scale(0.98);
}

.hint {
  margin-top: 24px;
  font-size: 11px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--muted);
}

.swatch-row {
  display: flex;
  justify-content: center;
  gap: 10px;
  margin-bottom: 28px;
}

.swatch {
  width: 28px;
  height: 28px;
  border: 1px solid var(--line);
  box-shadow: inset 0 0 0 1px rgba(0,0,0,0.2);
}

/* Pattern previews for start screen */
.sw-damask { background: repeating-linear-gradient(45deg, #8b3a4a 0 3px, #6b2a38 3px 6px); }
.sw-chinoiserie { background: radial-gradient(circle at 30% 30%, #2a5a4a 20%, #1a3a2e 21%); background-color: #1a3a2e; }
.sw-art-deco { background: repeating-linear-gradient(90deg, #c9a961 0 4px, #1a1a22 4px 8px); }
.sw-toile { background: repeating-linear-gradient(0deg, #3a4a6b 0 2px, #2a3a5b 2px 8px); }
.sw-botanical { background: radial-gradient(ellipse at 50% 60%, #5a7a3a 15%, #2a4a1a 16%); background-color: #2a4a1a; }
</style>
</head>
<body>

<header class="topbar">
  <div class="wordmark">Designer<br>Wallcoverings</div>
  <div class="hud">
    <div class="hud-item">
      <span class="hud-label">Score</span>
      <span class="hud-value" id="scoreEl">0</span>
    </div>
    <div class="hud-item">
      <span class="hud-label">Best</span>
      <span class="hud-value" id="bestEl">0</span>
    </div>
  </div>
</header>

<div class="stage">
  <canvas id="c"></canvas>
</div>

<!-- Start overlay -->
<div class="overlay visible" id="startOverlay">
  <div class="panel">
    <div class="panel-eyebrow">Endless Collection</div>
    <h1 class="panel-title">Paper the Wall</h1>
    <p class="panel-body">Guide the roller. Collect patterned rolls. Dodge the cracks and fixtures. How far can your eye for design take you?</p>
    <div class="swatch-row">
      <div class="swatch sw-damask"></div>
      <div class="swatch sw-chinoiserie"></div>
      <div class="swatch sw-art-deco"></div>
      <div class="swatch sw-toile"></div>
      <div class="swatch sw-botanical"></div>
    </div>
    <button class="btn" id="startBtn">Begin</button>
    <p class="hint">Space / Tap to jump</p>
  </div>
</div>

<!-- Game over overlay -->
<div class="overlay" id="overOverlay">
  <div class="panel">
    <div class="panel-eyebrow">Collection Complete</div>
    <h2 class="panel-title">Wall's End</h2>
    <div class="panel-stats">
      <div>
        <div class="stat-label">Score</div>
        <div class="stat-value" id="finalScore">0</div>
      </div>
      <div>
        <div class="stat-label">Best</div>
        <div class="stat-value" id="finalBest">0</div>
      </div>
    </div>
    <button class="btn" id="restartBtn">Roll Again</button>
  </div>
</div>

<script>
(function () {
  'use strict';

  // ── Design tokens (mirrored for canvas) ──
  const C = {
    bg: '#0d0d0f',
    surface: '#17171c',
    ink: '#f5f2ea',
    muted: '#8a8578',
    accent: '#c9a961',
    accentDim: '#7d6a45',
    line: 'rgba(245,242,234,0.12)',
    wall: '#1c1c22',
    wallStripe: '#22222a',
    floor: '#121214',
    rollerBody: '#2a2a32',
    rollerMetal: '#c9a961',
    rollerHandle: '#1a1a1e',
    paintTrail: 'rgba(201,169,97,0.15)',
  };

  // Wallpaper styles — visually distinct motifs
  const STYLES = [
    { name: 'damask',      base: '#6b2a38', a: '#8b3a4a', b: '#a05060' },
    { name: 'chinoiserie', base: '#1a3a2e', a: '#2a5a4a', b: '#3a7a5a' },
    { name: 'artdeco',     base: '#1a1a22', a: '#c9a961', b: '#8a7040' },
    { name: 'toile',       base: '#2a3a5b', a: '#3a4a6b', b: '#5a6a8b' },
    { name: 'botanical',   base: '#2a4a1a', a: '#3a6a2a', b: '#5a8a3a' },
  ];

  // ── Canvas setup ──
  const canvas = document.getElementById('c');
  const ctx = canvas.getContext('2d');
  let W = 0, H = 0, dpr = 1;

  function resize() {
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    W = canvas.clientWidth;
    H = canvas.clientHeight;
    canvas.width = W * dpr;
    canvas.height = H * dpr;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }
  window.addEventListener('resize', resize);
  resize();

  // ── Game state ──
  const GROUND_Y_RATIO = 0.72;
  let groundY = 0;
  let state = 'start'; // start | play | over
  let score = 0;
  let best = parseInt(localStorage.getItem('dw_roll_best') || '0', 10);
  let speed = 4.2;
  let distance = 0;
  let frame = 0;

  document.getElementById('bestEl').textContent = best;

  // Player (paint roller)
  const player = {
    x: 0,
    y: 0,
    vy: 0,
    r: 22,          // roller radius
    handleLen: 48,
    onGround: true,
    rot: 0,
    squish: 1,
  };

  const GRAVITY = 0.55;
  const JUMP_V = -12.5;

  // Entities
  let rolls = [];
  let obstacles = [];
  let particles = [];
  let wallOffset = 0;
  let trail = [];

  // Spawn timers
  let nextRoll = 90;
  let nextObs = 150;

  function resetGame() {
    score = 0;
    speed = 4.2;
    distance = 0;
    frame = 0;
    rolls = [];
    obstacles = [];
    particles = [];
    trail = [];
    wallOffset = 0;
    nextRoll = 60;
    nextObs = 120;
    groundY = H * GROUND_Y_RATIO;
    player.x = W * 0.18;
    player.y = groundY - player.r;
    player.vy = 0;
    player.onGround = true;
    player.rot = 0;
    player.squish = 1;
    document.getElementById('scoreEl').textContent = '0';
  }

  // ── Input ──
  function jump() {
    if (state === 'start') {
      startGame();
      return;
    }
    if (state === 'over') {
      restartGame();
      return;
    }
    if (state === 'play' && player.onGround) {
      player.vy = JUMP_V;
      player.onGround = false;
      player.squish = 0.7;
      // jump dust
      for (let i = 0; i < 6; i++) {
        particles.push({
          x: player.x, y: groundY,
          vx: (Math.random() - 0.5) * 3,
          vy: -Math.random() * 2,
          life: 20 + Math.random() * 10,
          max: 30,
          col: C.muted,
          sz: 2 + Math.random() * 2,
        });
      }
    }
  }

  window.addEventListener('keydown', (e) => {
    if (e.code === 'Space' || e.code === 'ArrowUp') {
      e.preventDefault();
      jump();
    }
  });
  canvas.addEventListener('pointerdown', (e) => { e.preventDefault(); jump(); });

  document.getElementById('startBtn').addEventListener('click', startGame);
  document.getElementById('restartBtn').addEventListener('click', restartGame);

  function startGame() {
    document.getElementById('startOverlay').classList.remove('visible');
    document.getElementById('overOverlay').classList.remove('visible');
    resetGame();
    state = 'play';
  }

  function restartGame() {
    document.getElementById('overOverlay').classList.remove('visible');
    resetGame();
    state = 'play';
  }

  function gameOver() {
    state = 'over';
    if (score > best) {
      best = score;
      localStorage.setItem('dw_roll_best', String(best));
    }
    document.getElementById('bestEl').textContent = best;
    document.getElementById('finalScore').textContent = score;
    document.getElementById('finalBest').textContent = best;
    document.getElementById('overOverlay').classList.add('visible');
    // burst
    for (let i = 0; i < 18; i++) {
      const ang = (Math.PI * 2 * i) / 18;
      particles.push({
        x: player.x, y: player.y,
        vx: Math.cos(ang) * (2 + Math.random() * 4),
        vy: Math.sin(ang) * (2 + Math.random() * 4),
        life: 30 + Math.random() * 20,
        max: 50,
        col: C.accent,
        sz: 2 + Math.random() * 3,
      });
    }
  }

  // ── Spawning ──
  function spawnRoll() {
    const style = STYLES[Math.floor(Math.random() * STYLES.length)];
    const lane = Math.random();
    let y;
    if (lane < 0.45) y = groundY - player.r;           // ground
    else if (lane < 0.75) y = groundY - player.r - 70;  // mid jump
    else y = groundY - player.r - 120;                  // high
    rolls.push({
      x: W + 40,
      y,
      r: 16,
      style,
      rot: Math.random() * Math.PI * 2,
      collected: false,
      bob: Math.random() * Math.PI * 2,
    });
  }

  function spawnObstacle() {
    const kinds = ['crack', 'fixture', 'shelf', 'outlet'];
    const kind = kinds[Math.floor(Math.random() * kinds.length)];
    let h, w;
    if (kind === 'crack') { w = 18; h = 50 + Math.random() * 30; }
    else if (kind === 'fixture') { w = 36; h = 36; }
    else if (kind === 'shelf') { w = 70; h = 14; }
    else { w = 28; h = 22; }
    obstacles.push({
      x: W + 50,
      y: groundY - h,
      w, h, kind,
    });
  }

  // ── Collision ──
  function circleRect(cx, cy, cr, rx, ry, rw, rh) {
    const nx = Math.max(rx, Math.min(cx, rx + rw));
    const ny = Math.max(ry, Math.min(cy, ry + rh));
    const dx = cx - nx, dy = cy - ny;
    return dx * dx + dy * dy < cr * cr;
  }

  // ── Pattern drawers (on rolls) ──
  function drawPattern(s, style, r) {
    ctx.save();
    ctx.beginPath();
    ctx.arc(0, 0, r, 0, Math.PI * 2);
    ctx.clip();
    ctx.fillStyle = style.base;
    ctx.fillRect(-r, -r, r * 2, r * 2);

    if (style.name === 'damask') {
      ctx.strokeStyle = style.a;
      ctx.lineWidth = 1.5;
      for (let i = -r; i < r; i += 8) {
        ctx.beginPath();
        for (let x = -r; x <= r; x += 2) {
          const y = i + Math.sin(x * 0.35) * 4;
          x === -r ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
        }
        ctx.stroke();
      }
      ctx.fillStyle = style.b;
      ctx.beginPath();
      ctx.arc(0, 0, r * 0.35, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = style.base;
      ctx.beginPath();
      ctx.arc(0, 0, r * 0.18, 0, Math.PI * 2);
      ctx.fill();
    } else if (style.name === 'chinoiserie') {
      ctx.fillStyle = style.a;
      for (let i = 0; i < 5; i++) {
        const a = (i / 5) * Math.PI * 2 + s.rot;
        const px = Math.cos(a) * r * 0.45;
        const py = Math.sin(a) * r * 0.45;
        ctx.beginPath();
        ctx.ellipse(px, py, 5, 3, a, 0, Math.PI * 2);
        ctx.fill();
      }
      ctx.strokeStyle = style.b;
      ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.arc(0, 0, r * 0.55, 0, Math.PI * 2);
      ctx.stroke();
    } else if (style.name === 'artdeco') {
      ctx.strokeStyle = style.a;
      ctx.lineWidth = 2;
      for (let i = 1; i <= 3; i++) {
        ctx.beginPath();
        ctx.moveTo(0, -r);
        ctx.lineTo(r * 0.7 * (i / 3), 0);
        ctx.lineTo(0, r);
        ctx.lineTo(-r * 0.7 * (i / 3), 0);
        ctx.closePath();
        ctx.stroke();
      }
      ctx.fillStyle = style.a;
      ctx.fillRect(-1.5, -r, 3, r * 2);
    } else if (style.name === 'toile') {
      ctx.strokeStyle = style.a;
      ctx.lineWidth = 1.2;
      for (let y = -r; y < r; y += 6) {
        ctx.beginPath();
        ctx.moveTo(-r, y);
        ctx.lineTo(r, y);
        ctx.stroke();
      }
      ctx.fillStyle = style.b;
      ctx.beginPath();
      ctx.moveTo(0, -r * 0.5);
      ctx.bezierCurveTo(r * 0.4, -r * 0.2, r * 0.4, r * 0.2, 0, r * 0.5);
      ctx.bezierCurveTo(-r * 0.4, r * 0.2, -r * 0.4, -r * 0.2, 0, -r * 0.5);
      ctx.fill();
    } else if (style.name === 'botanical') {
      ctx.fillStyle = style.a;
      for (let i = 0; i < 6; i++) {
        const a = (i / 6) * Math.PI * 2;
        ctx.save();
        ctx.rotate(a);
        ctx.beginPath();
        ctx.ellipse(0, -r * 0.4, 4, 8, 0, 0, Math.PI * 2);
        ctx.fill();
        ctx.restore();
      }
      ctx.fillStyle = style.b;
      ctx.beginPath();
      ctx.arc(0, 0, 4, 0, Math.PI * 2);
      ctx.fill();
    }

    // end-cap ring
    ctx.strokeStyle = 'rgba(245,242,234,0.25)';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.arc(0, 0, r - 1, 0, Math.PI * 2);
    ctx.stroke();
    ctx.restore();
  }

  // ── Draw obstacles ──
  function drawObstacle(o) {
    ctx.save();
    if (o.kind === 'crack') {
      ctx.strokeStyle = C.muted;
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.moveTo(o.x + o.w / 2, o.y);
      let y = o.y;
      while (y < o.y + o.h) {
        const nx = o.x + o.w / 2 + (Math.sin(y * 0.3) * 6);
        y += 8;
        ctx.lineTo(nx, Math.min(y, o.y + o.h));
      }
      ctx.stroke();
      // hairline branches
      ctx.lineWidth = 1;
      ctx.globalAlpha = 0.5;
      ctx.beginPath();
      ctx.moveTo(o.x + o.w / 2, o.y + o.h * 0.4);
      ctx.lineTo(o.x + o.w, o.y + o.h * 0.5);
      ctx.stroke();
      ctx.globalAlpha = 1;
    } else if (o.kind === 'fixture') {
      // wall sconce
      ctx.fillStyle = C.surface;
      ctx.strokeStyle = C.accentDim;
      ctx.lineWidth = 1;
      // backplate
      ctx.beginPath();
      ctx.arc(o.x + o.w / 2, o.y + o.h / 2, o.w / 2, 0, Math.PI * 2);
      ctx.fill();
      ctx.stroke();
      // glow
      const g = ctx.createRadialGradient(o.x + o.w / 2, o.y + o.h / 2, 2, o.x + o.w / 2, o.y + o.h / 2, o.w);
      g.addColorStop(0, 'rgba(201,169,97,0.35)');
      g.addColorStop(1, 'rgba(201,169,97,0)');
      ctx.fillStyle = g;
      ctx.beginPath();
      ctx.arc(o.x + o.w / 2, o.y + o.h / 2, o.w, 0, Math.PI * 2);
      ctx.fill();
      // bulb
      ctx.fillStyle = C.accent;
      ctx.beginPath();
      ctx.arc(o.x + o.w / 2, o.y + o.h / 2, 5, 0, Math.PI * 2);
      ctx.fill();
    } else if (o.kind === 'shelf') {
      ctx.fillStyle = C.surface;
      ctx.strokeStyle = C.line;
      ctx.lineWidth = 1;
      ctx.fillRect(o.x, o.y, o.w, o.h);
      ctx.strokeRect(o.x, o.y, o.w, o.h);
      // bracket
      ctx.strokeStyle = C.accentDim;
      ctx.beginPath();
      ctx.moveTo(o.x + 10, o.y + o.h);
      ctx.lineTo(o.x + 10, o.y + o.h + 16);
      ctx.lineTo(o.x + 22, o.y + o.h);
      ctx.stroke();
      ctx.beginPath();
      ctx.moveTo(o.x + o.w - 10, o.y + o.h);
      ctx.lineTo(o.x + o.w - 10, o.y + o.h + 16);
      ctx.lineTo(o.x + o.w - 22, o.y + o.h);
      ctx.stroke();
      // accent edge
      ctx.fillStyle = C.accent;
      ctx.fillRect(o.x, o.y, o.w, 2);
    } else {
      // outlet
      ctx.fillStyle = C.surface;
      ctx.strokeStyle = C.muted;
      ctx.lineWidth = 1;
      roundRect(ctx, o.x, o.y, o.w, o.h, 3);
      ctx.fill();
      ctx.stroke();
      // slots
      ctx.fillStyle = C.bg;
      ctx.fillRect(o.x + 7, o.y + 5, 4, 8);
      ctx.fillRect(o.x + o.w - 11, o.y + 5, 4, 8);
      ctx.beginPath();
      ctx.arc(o.x + o.w / 2, o.y + o.h - 6, 2.5, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.restore();
  }

  function roundRect(c, x, y, w, h, r) {
    c.beginPath();
    c.moveTo(x + r, y);
    c.arcTo(x + w, y, x + w, y + h, r);
    c.arcTo(x + w, y + h, x, y + h, r);
    c.arcTo(x, y + h, x, y, r);
    c.arcTo(x, y, x + w, y, r);
    c.closePath();
  }

  // ── Draw player (paint roller) ──
  function drawPlayer() {
    const p = player;
    ctx.save();
    ctx.translate(p.x, p.y);

    // handle (angled back)
    ctx.save();
    ctx.strokeStyle = C.rollerHandle;
    ctx.lineWidth = 4;
    ctx.lineCap = 'round';
    ctx.beginPath();
    ctx.moveTo(-4, 0);
    ctx.lineTo(-p.handleLen * 0.55, p.handleLen * 0.75);
    ctx.stroke();
    // grip
    ctx.strokeStyle = C.accentDim;
    ctx.lineWidth = 6;
    ctx.beginPath();
    ctx.moveTo(-p.handleLen * 0.4, p.handleLen * 0.55);
    ctx.lineTo(-p.handleLen * 0.55, p.handleLen * 0.75);
    ctx.stroke();
    // metal joint
    ctx.fillStyle = C.rollerMetal;
    ctx.beginPath();
    ctx.arc(-4, 0, 4, 0, Math.PI * 2);
    ctx.fill();
    ctx.restore();

    // roller cylinder
    ctx.save();
    ctx.scale(1, p.squish);
    ctx.rotate(p.rot);

    // body
    const grd = ctx.createLinearGradient(-p.r, 0, p.r, 0);
    grd.addColorStop(0, '#3a3a44');
    grd.addColorStop(0.3, '#2a2a32');
    grd.addColorStop(0.7, '#22222a');
    grd.addColorStop(1, '#1a1a22');
    ctx.fillStyle = grd;
    ctx.beginPath();
    ctx.arc(0, 0, p.r, 0, Math.PI * 2);
    ctx.fill();

    // nap texture lines
    ctx.strokeStyle = 'rgba(245,242,234,0.06)';
    ctx.lineWidth = 1;
    for (let a = 0; a < Math.PI * 2; a += 0.35) {
      ctx.beginPath();
      ctx.moveTo(Math.cos(a) * (p.r - 6), Math.sin(a) * (p.r - 6));
      ctx.lineTo(Math.cos(a) * (p.r - 1), Math.sin(a) * (p.r - 1));
      ctx.stroke();
    }

    // gold end rings
    ctx.strokeStyle = C.rollerMetal;
    ctx.lineWidth = 2.5;
    ctx.beginPath();
    ctx.arc(0, 0, p.r - 1, 0, Math.PI * 2);
    ctx.stroke();
    ctx.lineWidth = 1;
    ctx.globalAlpha = 0.5;
    ctx.beginPath();
    ctx.arc(0, 0, p.r - 5, 0, Math.PI * 2);
    ctx.stroke();
    ctx.globalAlpha = 1;

    // axle highlight
    ctx.fillStyle = C.accent;
    ctx.beginPath();
    ctx.arc(0, 0, 3, 0, Math.PI * 2);
    ctx.fill();

    ctx.restore();
    ctx.restore();
  }

  // ── Background wall ──
  function drawWall() {
    // wall field
    ctx.fillStyle = C.wall;
    ctx.fillRect(0, 0, W, groundY + 4);

    // subtle vertical panel lines
    ctx.strokeStyle = C.wallStripe;
    ctx.lineWidth = 1;
    const panelW = 120;
    const off = wallOffset % panelW;
    for (let x = -off; x < W + panelW; x += panelW) {
      ctx.beginPath();
      ctx.moveTo(x, 0);
      ctx.lineTo(x, groundY);
      ctx.stroke();
    }

    // painted trail behind roller (wallpaper applied)
    for (const t of trail) {
      ctx.globalAlpha = t.a;
      ctx.fillStyle = t.col;
      ctx.fillRect(t.x, t.y, t.w, t.h);
    }
    ctx.globalAlpha = 1;

    // chair rail
    ctx.fillStyle = C.accentDim;
    ctx.fillRect(0, groundY - 2, W, 2);
    ctx.fillStyle = 'rgba(201,169,97,0.15)';
    ctx.fillRect(0, groundY - 6, W, 4);

    // floor
    ctx.fillStyle = C.floor;
    ctx.fillRect(0, groundY, W, H - groundY);

    // floor perspective lines
    ctx.strokeStyle = 'rgba(245,242,234,0.04)';
    ctx.lineWidth = 1;
    for (let i = 1; i <= 6; i++) {
      const y = groundY + (H - groundY) * (i / 7);
      ctx.beginPath();
      ctx.moveTo(0, y);
      ctx.lineTo(W, y);
      ctx.stroke();
    }

    // baseboard
    ctx.fillStyle = '#1a1a1e';
    ctx.fillRect(0, groundY, W, 8);
    ctx.fillStyle = C.line;
    ctx.fillRect(0, groundY, W, 1);
  }

  // ── Particles ──
  function drawParticles() {
    for (const pt of particles) {
      const a = Math.max(0, pt.life / pt.max);
      ctx.globalAlpha = a;
      ctx.fillStyle = pt.col;
      ctx.beginPath();
      ctx.arc(pt.x, pt.y, pt.sz * a, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.globalAlpha = 1;
  }

  // ── Update ──
  function update() {
    if (state !== 'play') {
      // idle animation on start/over
      if (state === 'start' || state === 'over') {
        player.rot += 0.02;
        player.y = groundY - player.r + Math.sin(frame * 0.04) * 3;
      }
      // still update particles
      for (let i = particles.length - 1; i >= 0; i--) {
        const pt = particles[i];
        pt.x += pt.vx; pt.y += pt.vy; pt.vy += 0.08; pt.life--;
        if (pt.life <= 0) particles.splice(i, 1);
      }
      frame++;
      return;
    }

    frame++;
    distance += speed;
    speed = Math.min(11, 4.2 + distance / 3500);

    // physics
    player.vy += GRAVITY;
    player.y += player.vy;
    if (player.y >= groundY - player.r) {
      player.y = groundY - player.r;
      player.vy = 0;
      if (!player.onGround) {
        player.squish = 1.25;
      }
      player.onGround = true;
    }
    // squish recover
    player.squish += (1 - player.squish) * 0.15;
    // roll rotation
    player.rot += speed / player.r;

    // wall scroll
    wallOffset += speed;

    // paint trail
    if (player.onGround && frame % 3 === 0) {
      trail.push({
        x: player.x - 10,
        y: groundY - 48 - Math.random() * 20,
        w: speed * 3 + 4,
        h: 40 + Math.random() * 30,
        col: Math.random() > 0.5 ? 'rgba(201,169,97,0.06)' : 'rgba(245,242,234,0.03)',
        a: 0.8,
      });
    }
    for (let i = trail.length - 1; i >= 0; i--) {
      trail[i].x -= speed;
      trail[i].a -= 0.004;
      if (trail[i].x + trail[i].w < 0 || trail[i].a <= 0) trail.splice(i, 1);
    }

    // spawn
    nextRoll--;
    nextObs--;
    if (nextRoll <= 0) {
      spawnRoll();
      nextRoll = 50 + Math.random() * 70 - speed * 2;
    }
    if (nextObs <= 0) {
      spawnObstacle();
      nextObs = 90 + Math.random() * 100 - speed * 3;
    }

    // rolls
    for (let i = rolls.length - 1; i >= 0; i--) {
      const r = rolls[i];
      r.x -= speed;
      r.rot += 0.04;
      r.bob += 0.06;
      if (!r.collected) {
        const dy = Math.sin(r.bob) * 4;
        const dx = r.x - player.x;
        const ddy = (r.y + dy) - player.y;
        if (dx * dx + ddy * ddy < (player.r + r.r) * (player.r + r.r)) {
          r.collected = true;
          score += 10;
          document.getElementById('scoreEl').textContent = score;
          // sparkle
          for (let k = 0; k < 10; k++) {
            particles.push({
              x: r.x, y: r.y,
              vx: (Math.random() - 0.5) * 5,
              vy: (Math.random() - 0.5) * 5 - 1,
              life: 20 + Math.random() * 15,
              max: 35,
              col: k % 2 ? C.accent : r.style.a,
              sz: 2 + Math.random() * 2.5,
            });
          }
        }
      }
      if (r.collected || r.x < -40) rolls.splice(i, 1);
    }

    // obstacles
    for (let i = obstacles.length - 1; i >= 0; i--) {
      const o = obstacles[i];
      o.x -= speed;
      if (circleRect(player.x, player.y, player.r * 0.75, o.x, o.y, o.w, o.h)) {
        gameOver();
      }
      if (o.x + o.w < -20) obstacles.splice(i, 1);
    }

    // particles
    for (let i = particles.length - 1; i >= 0; i--) {
      const pt = particles[i];
      pt.x += pt.vx;
      pt.y += pt.vy;
      pt.vy += 0.1;
      pt.life--;
      if (pt.life <= 0) particles.splice(i, 1);
    }

    // passive score
    if (frame % 6 === 0) {
      score += 1;
      document.getElementById('scoreEl').textContent = score;
    }
  }

  // ── Render ──
  function render() {
    groundY = H * GROUND_Y_RATIO;
    if (state === 'start' && player.x === 0) {
      player.x = W * 0.18;
      player.y = groundY - player.r;
    }

    ctx.clearRect(0, 0, W, H);
    drawWall();

    // rolls
    for (const r of rolls) {
      if (r.collected) continue;
      ctx.save();
      ctx.translate(r.x, r.y + Math.sin(r.bob) * 4);
      ctx.rotate(r.rot);
      drawPattern(r, r.style, r.r);
      // soft glow
      ctx.globalAlpha = 0.15;
      ctx.strokeStyle = C.accent;
      ctx.lineWidth = 4;
      ctx.beginPath();
      ctx.arc(0, 0, r.r + 3, 0, Math.PI * 2);
      ctx.stroke();
      ctx.restore();
    }

    // obstacles
    for (const o of obstacles) drawObstacle(o);

    drawPlayer();
    drawParticles();

    // vignette
    const vig = ctx.createRadialGradient(W / 2, H * 0.4, H * 0.2, W / 2, H * 0.4, H * 0.85);
    vig.addColorStop(0, 'rgba(0,0,0,0)');
    vig.addColorStop(1, 'rgba(0,0,0,0.45)');
    ctx.fillStyle = vig;
    ctx.fillRect(0, 0, W, H);
  }

  // ── Loop ──
  let last = performance.now();
  function loop(now) {
    // target ~60fps, single step
    const dt = now - last;
    last = now;
    // run update once per frame (capped)
    if (dt < 100) update();
    render();
    requestAnimationFrame(loop);
  }

  // init positions
  resetGame();
  requestAnimationFrame(loop);
})();
</script>
</body>
</html>