← back to Model Arena

data/artifacts/035b7009e366/gpt.html

1211 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Designer Wallcoverings Runner</title>
<style>
:root {
  /* design tokens */
  --bg: #0d0d0f;
  --surface: #17171c;
  --ink: #f5f2ea;
  --muted: #8a8578;
  --accent: #c9a961; /* metallic gold */
  --accent-alt: #7d6a45; /* deep brass */
  --line: rgba(249, 245, 233, 0.16);
  --shadow-soft: 0 18px 45px rgba(0, 0, 0, 0.72);
  --shadow-subtle: 0 1px 0 rgba(255, 255, 255, 0.04);
  --radius-lg: 18px;
  --radius-md: 12px;
  --radius-sm: 999px;

  --font-display: "Playfair Display", Georgia, "Times New Roman", serif;
  --font-serif-wordmark: "Playfair Display", "Cormorant Garamond", "EB Garamond", Georgia, "Times New Roman", serif;
  --font-sans: "Avenir Next", "Century Gothic", "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;

  --fs-display: 2.8rem;
  --fs-h1: 2rem;
  --fs-h2: 1.6rem;
  --fs-body: 1rem;
  --fs-label: 0.72rem;

  --ease-ui: cubic-bezier(.22,.9,.24,1);
  --ease-hero: cubic-bezier(.22,1,.36,1);

  --duration-fast: 160ms;
  --duration-med: 260ms;
}

/* base reset */
*,
*::before,
*::after {
  box-sizing: border-box;
}

html, body {
  margin: 0;
  padding: 0;
  height: 100%;
  background: radial-gradient(circle at top, #181821 0%, var(--bg) 55%);
  color: var(--ink);
  font-family: var(--font-sans);
}

body {
  display: flex;
  flex-direction: column;
}

/* layout */
.app {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}

/* header / wordmark */
.app-header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 10;
  height: 72px;
  padding: 14px 24px 12px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  background:
    linear-gradient(to bottom, rgba(9,9,12,0.9), rgba(8,8,10,0.88)),
    radial-gradient(circle at top left, rgba(201,169,97,0.18), transparent 55%);
  border-bottom: 1px solid var(--line);
  box-shadow: var(--shadow-subtle);
  backdrop-filter: blur(18px);
}

.wordmark-lockup {
  display: inline-flex;
  flex-direction: column;
  justify-content: center;
  max-width: 260px;
}

.wordmark {
  font-family: var(--font-serif-wordmark);
  font-size: 18px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--ink);
  line-height: 1.2;
  white-space: normal;
}

.wordmark-accent-bar {
  margin-top: 6px;
  width: 48px;
  height: 2px;
  border-radius: 999px;
  background: linear-gradient(90deg, var(--accent), #f4e1a8);
}

/* header meta */
.header-meta {
  display: flex;
  align-items: center;
  gap: 18px;
  font-family: var(--font-sans);
}

.meta-block {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  min-width: 76px;
}

.meta-label {
  font-size: var(--fs-label);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--muted);
  margin-bottom: 2px;
}

.meta-value {
  font-size: 0.9rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink);
}

/* main content */
.main-shell {
  flex: 1;
  display: flex;
  align-items: stretch;
  justify-content: center;
  padding: 88px 16px 20px;
}

.card {
  position: relative;
  width: 100%;
  max-width: 980px;
  margin: 0 auto;
  background:
    radial-gradient(circle at top right, rgba(201,169,97,0.25), transparent 55%),
    radial-gradient(circle at bottom left, rgba(40,40,50,0.7), rgba(9,9,12,1) 72%);
  border-radius: 24px;
  border: 1px solid rgba(249, 245, 233, 0.08);
  box-shadow: var(--shadow-soft);
  overflow: hidden;
  display: grid;
  grid-template-columns: minmax(0, 2fr) minmax(260px, 0.9fr);
}

@media (max-width: 820px) {
  .card {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* canvas region */
.game-shell {
  position: relative;
  padding: 18px 18px 18px 18px;
}

.game-surface {
  position: relative;
  background: radial-gradient(circle at top, rgba(248,245,234,0.12), transparent 55%);
  border-radius: 18px;
  border: 1px solid rgba(249,245,233,0.06);
  overflow: hidden;
}

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

/* side panel */
.side-panel {
  position: relative;
  padding: 20px 20px 20px 16px;
  border-left: 1px solid rgba(249,245,233,0.04);
  background: radial-gradient(circle at top left, rgba(20,20,26,0.75), rgba(7,7,10,0.95));
}

@media (max-width: 820px) {
  .side-panel {
    border-left: none;
    border-top: 1px solid rgba(249,245,233,0.04);
  }
}

.side-heading {
  font-family: var(--font-display);
  font-size: var(--fs-h2);
  letter-spacing: 0.02em;
  line-height: 1.05;
  margin: 2px 0 10px;
}

.side-subtitle {
  font-size: 0.92rem;
  line-height: 1.55;
  color: var(--muted);
  max-width: 34ch;
  margin-bottom: 16px;
}

.pattern-swatches {
  display: grid;
  grid-template-columns: repeat(3, minmax(0,1fr));
  gap: 10px;
  margin-bottom: 18px;
}

.swatch {
  position: relative;
  border-radius: 14px;
  border: 1px solid rgba(249,245,233,0.16);
  overflow: hidden;
  background-color: #101014;
  aspect-ratio: 4 / 3;
  box-shadow: 0 10px 22px rgba(0,0,0,0.65);
}

/* swatch motifs: each distinct */
.swatch-gilded-stripe {
  background-image:
    repeating-linear-gradient(
      135deg,
      rgba(201,169,97,0.14) 0,
      rgba(201,169,97,0.14) 2px,
      transparent 2px,
      transparent 12px
    ),
    radial-gradient(circle at top left, #1f1a12, #09090c);
}

.swatch-deco-fan {
  background:
    radial-gradient(circle at 0% 120%, #18121b 0, #050408 58%),
    radial-gradient(circle at 100% -20%, #1d1926 0, #050408 65%);
}
.swatch-deco-fan::before {
  content: "";
  position: absolute;
  inset: 18% 10% 4% 10%;
  background-image:
    radial-gradient(circle at bottom, rgba(201,169,97,0.42) 0, transparent 52%),
    repeating-radial-gradient(
      circle at bottom,
      transparent 0,
      transparent 6px,
      rgba(245,242,234,0.08) 6px,
      rgba(245,242,234,0.08) 7px
    );
  mix-blend-mode: screen;
  opacity: 0.9;
}

.swatch-silk-waves {
  background:
    radial-gradient(circle at top left, #22252c 0, #050408 60%);
}
.swatch-silk-waves::before {
  content: "";
  position: absolute;
  inset: -30%;
  background-image:
    repeating-linear-gradient(
      -45deg,
      rgba(245,242,234,0.24) 0,
      rgba(245,242,234,0.24) 1px,
      transparent 1px,
      transparent 9px
    );
  opacity: 0.45;
  filter: blur(1px);
  transform: skewY(4deg);
}

/* instructions */
.instructions {
  border-radius: 16px;
  border: 1px solid rgba(249,245,233,0.12);
  padding: 10px 12px 12px;
  background:
    linear-gradient(135deg, rgba(22,22,28,0.98), rgba(9,9,12,0.98));
  box-shadow: 0 10px 30px rgba(0,0,0,0.55);
}

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

.instructions-body {
  font-size: 0.9rem;
  line-height: 1.5;
  color: var(--ink);
}

/* key hint */
.key-hints {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin-top: 6px;
  font-size: 0.8rem;
  color: var(--muted);
}

.keycap {
  padding: 2px 7px 3px;
  border-radius: 999px;
  border: 1px solid var(--line);
  background: rgba(10,10,14,0.9);
  box-shadow: 0 1px 0 rgba(255,255,255,0.06);
  font-size: 0.75rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
}

/* overlay */
.game-overlay {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
}

.overlay-panel {
  min-width: 260px;
  max-width: 320px;
  padding: 18px 18px 16px;
  border-radius: var(--radius-lg);
  background:
    linear-gradient(135deg, rgba(11,11,16,0.96), rgba(16,16,22,0.96));
  border: 1px solid rgba(249,245,233,0.18);
  box-shadow: 0 18px 40px rgba(0,0,0,0.8);
  text-align: left;
  opacity: 0;
  transform: translateY(12px) scale(0.98);
  transition:
    opacity var(--duration-med) var(--ease-hero),
    transform var(--duration-med) var(--ease-hero);
  pointer-events: none;
}

.overlay-panel.visible {
  opacity: 1;
  transform: translateY(0) scale(1);
  pointer-events: auto;
}

.overlay-title {
  font-family: var(--font-display);
  font-size: var(--fs-h1);
  margin: 0 0 6px;
}

.overlay-subtitle {
  font-size: 0.9rem;
  color: var(--muted);
  margin-bottom: 10px;
}

.overlay-stats {
  display: flex;
  justify-content: space-between;
  margin-bottom: 14px;
  font-size: 0.9rem;
}

.overlay-stat-label {
  font-size: 0.75rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--muted);
}

.overlay-primary {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 7px 16px 8px;
  border-radius: 999px;
  border: 1px solid rgba(201,169,97,0.8);
  background: radial-gradient(circle at top left, #f4e1a8, #c9a961);
  color: #1b1510;
  font-size: 0.8rem;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  font-weight: 600;
  cursor: pointer;
  transition:
    transform var(--duration-fast) var(--ease-ui),
    box-shadow var(--duration-fast) var(--ease-ui),
    background var(--duration-fast) var(--ease-ui);
  box-shadow: 0 10px 30px rgba(0,0,0,0.7);
}

.overlay-primary:hover {
  transform: translateY(-1px);
  box-shadow: 0 14px 34px rgba(0,0,0,0.85);
  background: radial-gradient(circle at top left, #f8ebbc, #d6b66d);
}

.overlay-primary:active {
  transform: translateY(0);
  box-shadow: 0 6px 18px rgba(0,0,0,0.7);
}

/* pill in-canvas HUD */
.canvas-hud {
  position: absolute;
  top: 12px;
  right: 14px;
  display: flex;
  gap: 8px;
  font-size: 0.8rem;
  z-index: 3;
}

.canvas-pill {
  padding: 4px 9px 5px;
  border-radius: 999px;
  border: 1px solid rgba(249,245,233,0.2);
  background: linear-gradient(135deg, rgba(9,9,14,0.95), rgba(20,20,28,0.96));
  backdrop-filter: blur(10px);
  display: inline-flex;
  align-items: center;
  gap: 6px;
}

.canvas-pill-label {
  font-size: 0.7rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--muted);
}

.canvas-pill-value {
  font-size: 0.85rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

/* decorative roller shadow seam */
.game-surface::before {
  content: "";
  position: absolute;
  inset: auto 0 22%;
  height: 1px;
  background: linear-gradient(
    to right,
    transparent 0,
    rgba(0,0,0,0.9) 10%,
    rgba(0,0,0,0.9) 90%,
    transparent 100%
  );
  opacity: 0.55;
  z-index: 1;
}

/* accessibility helpers */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  border: 0;
  padding: 0;
  clip: rect(0 0 0 0);
  overflow: hidden;
}
</style>
</head>
<body>
<div class="app">
  <header class="app-header">
    <div class="wordmark-lockup" aria-label="Designer Wallcoverings">
      <div class="wordmark">DESIGNER<br>WALLCOVERINGS</div>
      <div class="wordmark-accent-bar"></div>
    </div>
    <div class="header-meta" aria-hidden="true">
      <div class="meta-block">
        <div class="meta-label">Run</div>
        <div class="meta-value" id="runLabel">01</div>
      </div>
      <div class="meta-block">
        <div class="meta-label">Best</div>
        <div class="meta-value" id="bestHeader">0000</div>
      </div>
    </div>
  </header>

  <main class="main-shell">
    <section class="card" aria-label="Designer Wallcoverings endless runner game">
      <div class="game-shell">
        <div class="game-surface">
          <canvas id="gameCanvas"></canvas>
          <div class="canvas-hud">
            <div class="canvas-pill">
              <span class="canvas-pill-label">Score</span>
              <span class="canvas-pill-value" id="scoreHud">0000</span>
            </div>
            <div class="canvas-pill">
              <span class="canvas-pill-label">Pace</span>
              <span class="canvas-pill-value" id="speedHud">01</span>
            </div>
          </div>
          <div class="game-overlay">
            <div class="overlay-panel visible" id="overlayPanel">
              <h1 class="overlay-title" id="overlayTitle">Gilded Warm-Up</h1>
              <p class="overlay-subtitle" id="overlaySubtitle">
                Tap, click or press space to lift the roller. Collect wallpaper rolls, skim past ladders and spotlights. The wall never ends; your line should.
              </p>
              <div class="overlay-stats">
                <div>
                  <div class="overlay-stat-label">Last Run</div>
                  <div id="overlayLast">0000</div>
                </div>
                <div>
                  <div class="overlay-stat-label">Best</div>
                  <div id="overlayBest">0000</div>
                </div>
              </div>
              <button class="overlay-primary" id="overlayButton" type="button">
                Begin Run
              </button>
              <div class="key-hints">
                <div class="keycap">Space</div>
                <span>or tap canvas to jump</span>
              </div>
            </div>
          </div>
        </div>
      </div>

      <aside class="side-panel">
        <h2 class="side-heading">Luxe Wall Run</h2>
        <p class="side-subtitle">
          A paint-roller skims a midnight wall, catching rare wallpapers —
          from gilded stripes to deco fans — while you dodge studio clutter in a single, continuous stroke.
        </p>

        <div class="pattern-swatches" aria-hidden="true">
          <div class="swatch swatch-gilded-stripe"></div>
          <div class="swatch swatch-deco-fan"></div>
          <div class="swatch swatch-silk-waves"></div>
        </div>

        <div class="instructions">
          <div class="instructions-label">Controls</div>
          <div class="instructions-body">
            Tap, click or press space to send the roller upward; gravity does the rest.
            Pace rises as you place more rolls. Brush the gold, not the ladders and lights.
            <div class="key-hints">
              <div class="keycap">Space</div>
              <div class="keycap">Click</div>
              <span>/ tap</span>
            </div>
          </div>
        </div>
      </aside>
    </section>
  </main>
</div>

<script>
(function() {
  const canvas = document.getElementById('gameCanvas');
  const ctx = canvas.getContext('2d');

  let width, height, pixelRatio;
  function resize() {
    const rect = canvas.getBoundingClientRect();
    pixelRatio = window.devicePixelRatio || 1;
    width = rect.width * pixelRatio;
    height = rect.height * pixelRatio;
    canvas.width = width;
    canvas.height = height;
  }
  resize();
  window.addEventListener('resize', resize);

  // HUD & overlay elements
  const scoreHud = document.getElementById('scoreHud');
  const speedHud = document.getElementById('speedHud');
  const bestHeader = document.getElementById('bestHeader');
  const runLabel = document.getElementById('runLabel');
  const overlayPanel = document.getElementById('overlayPanel');
  const overlayTitle = document.getElementById('overlayTitle');
  const overlaySubtitle = document.getElementById('overlaySubtitle');
  const overlayLast = document.getElementById('overlayLast');
  const overlayBest = document.getElementById('overlayBest');
  const overlayButton = document.getElementById('overlayButton');

  let runCount = 1;
  let bestScore = parseInt(localStorage.getItem('dw-luxe-best') || '0', 10);
  updateBestDisplays();

  function pad(num, len) {
    let s = String(num);
    while (s.length < len) s = '0' + s;
    return s;
  }

  function updateBestDisplays() {
    bestHeader.textContent = pad(bestScore, 4);
    overlayBest.textContent = pad(bestScore, 4);
  }

  // Game state
  let lastTime = 0;
  let gameRunning = false;
  let gameStartedOnce = false;
  let score = 0;
  let speed = 320; // pixels per second base
  let baseSpeed = 320;
  let speedLevel = 1;
  let gravity = 1500;
  let jumpImpulse = -520;
  let rollerY = 0;
  let rollerVY = 0;
  let groundY = 0;
  let wallOffset = 0;

  const collectibles = [];
  const obstacles = [];

  const PATTERNS = [
    'stripe',   // gilded stripe
    'fan',      // deco fan
    'waves'     // silk waves
  ];

  function resetGame() {
    score = 0;
    speed = baseSpeed;
    speedLevel = 1;
    rollerVY = 0;
    groundY = height * 0.72;
    rollerY = groundY - 40;
    wallOffset = 0;
    collectibles.length = 0;
    obstacles.length = 0;
    spawnInitial();
    updateScoreDisplay();
    updateSpeedDisplay();
  }

  function updateScoreDisplay() {
    scoreHud.textContent = pad(score, 4);
  }

  function updateSpeedDisplay() {
    const lvl = Math.min(9, 1 + Math.floor((speed - baseSpeed) / 80));
    speedHud.textContent = pad(lvl, 2);
  }

  function spawnInitial() {
    let t = 0;
    for (let i = 0; i < 5; i++) {
      t += 280 + Math.random() * 120;
      spawnCollectible(width + t);
      if (Math.random() < 0.7) {
        spawnObstacle(width + t + 140 + Math.random() * 90);
      }
    }
  }

  function spawnCollectible(x) {
    const pattern = PATTERNS[Math.floor(Math.random() * PATTERNS.length)];
    const row = Math.random();
    let y;
    if (row < 0.35) y = groundY - 36;
    else if (row < 0.7) y = groundY - 110;
    else y = groundY - 180;

    collectibles.push({
      x,
      y,
      size: 26 + Math.random() * 4,
      pattern,
      collected: false,
      shimmer: Math.random() * Math.PI * 2
    });
  }

  function spawnObstacle(x) {
    const type = Math.random() < 0.5 ? 'ladder' : 'spotlight';
    let widthObs, heightObs, y;
    if (type === 'ladder') {
      widthObs = 34;
      heightObs = 130;
      y = groundY - heightObs + 2;
    } else {
      widthObs = 52;
      heightObs = 60;
      y = groundY - heightObs - 90;
    }
    obstacles.push({
      x,
      y,
      w: widthObs,
      h: heightObs,
      type,
      phase: Math.random() * Math.PI * 2
    });
  }

  function scheduleSpawns(dt) {
    // ensure we always have upcoming elements
    const furthest = Math.max(
      collectibles.reduce((m, c) => Math.max(m, c.x), 0),
      obstacles.reduce((m, o) => Math.max(m, o.x), 0)
    );
    if (furthest < width * 1.2) {
      const gap = 260 + Math.random() * 130;
      const baseX = Math.max(width, furthest) + gap;
      spawnCollectible(baseX);
      if (Math.random() < 0.85) {
        spawnObstacle(baseX + 120 + Math.random() * 80);
      }
    }
  }

  // Input
  let isPointerDown = false;
  function jump() {
    if (!gameRunning) return;
    rollerVY = jumpImpulse;
  }

  window.addEventListener('keydown', (e) => {
    if (e.code === 'Space') {
      e.preventDefault();
      if (!gameRunning) {
        startRun();
      } else {
        jump();
      }
    }
  });

  canvas.addEventListener('pointerdown', () => {
    if (!gameRunning) {
      startRun();
    } else {
      isPointerDown = true;
      jump();
    }
  });

  canvas.addEventListener('pointerup', () => {
    isPointerDown = false;
  });

  overlayButton.addEventListener('click', () => {
    startRun();
  });

  function startRun() {
    overlayPanel.classList.remove('visible');
    gameRunning = true;
    gameStartedOnce = true;
    resetGame();
  }

  function endRun() {
    gameRunning = false;
    runCount++;
    runLabel.textContent = pad(runCount, 2);
    overlayLast.textContent = pad(score, 4);
    if (score > bestScore) {
      bestScore = score;
      localStorage.setItem('dw-luxe-best', String(bestScore));
      updateBestDisplays();
      overlayTitle.textContent = 'New Studio Record';
      overlaySubtitle.textContent = 'Flawless placement. The wall remembers every gilded pass — see if the next run can outshine this one.';
    } else {
      overlayTitle.textContent = 'Run Complete';
      overlaySubtitle.textContent = 'The wall keeps going. Let the next pull be smoother, closer, and just a touch braver past the clutter.';
    }
    overlayPanel.classList.add('visible');
    overlayButton.textContent = 'Run Again';
  }

  // Drawing helpers
  function drawBackground(dt) {
    wallOffset += (speed * dt) * 0.25;
    const stripeWidth = 64 * pixelRatio;
    const gradientWidth = stripeWidth * 2;
    const offset = - (wallOffset % gradientWidth);

    const top = 0;
    const bottom = groundY + 4;

    for (let x = offset - gradientWidth; x < width + gradientWidth; x += gradientWidth) {
      const grad = ctx.createLinearGradient(x, 0, x + gradientWidth, 0);
      grad.addColorStop(0, '#101116');
      grad.addColorStop(0.5, '#13141a');
      grad.addColorStop(1, '#101116');
      ctx.fillStyle = grad;
      ctx.fillRect(x, top, gradientWidth, bottom - top);
    }

    // subtle vertical seams
    ctx.strokeStyle = 'rgba(0,0,0,0.65)';
    ctx.lineWidth = 1 * pixelRatio;
    for (let x = offset; x < width + gradientWidth; x += stripeWidth) {
      ctx.beginPath();
      ctx.moveTo(x, top);
      ctx.lineTo(x, bottom);
      ctx.stroke();
    }

    // faint sheen band near top
    const sheenHeight = height * 0.18;
    const sheenGrad = ctx.createLinearGradient(0, 0, 0, sheenHeight);
    sheenGrad.addColorStop(0, 'rgba(244,232,190,0.18)');
    sheenGrad.addColorStop(1, 'rgba(244,232,190,0)');
    ctx.fillStyle = sheenGrad;
    ctx.fillRect(0, 0, width, sheenHeight);

    // ground shadow
    ctx.fillStyle = 'rgba(0,0,0,0.85)';
    ctx.fillRect(0, groundY, width, height - groundY);
  }

  function drawCollectible(c, t) {
    const wobble = Math.sin(t * 3 + c.shimmer) * 3 * pixelRatio;
    const size = c.size * pixelRatio;
    const x = c.x;
    const y = c.y + wobble;

    // roll core
    ctx.save();
    ctx.translate(x, y);
    ctx.shadowColor = 'rgba(0,0,0,0.8)';
    ctx.shadowBlur = 10 * pixelRatio;
    ctx.shadowOffsetY = 6 * pixelRatio;

    const grd = ctx.createLinearGradient(-size*0.5, -size*0.5, size*0.7, size*0.6);
    grd.addColorStop(0, 'rgba(24,23,32,1)');
    grd.addColorStop(0.3, 'rgba(40,35,26,1)');
    grd.addColorStop(0.55, '#c9a961');
    grd.addColorStop(1, '#f4e1a8');
    ctx.fillStyle = grd;

    const radius = size * 0.5;
    ctx.beginPath();
    ctx.roundRect(-radius, -radius, size, size * 0.9, radius * 0.3);
    ctx.fill();

    // pattern overlay distinct per style
    ctx.save();
    ctx.clip();
    if (c.pattern === 'stripe') {
      ctx.globalAlpha = 0.55;
      ctx.fillStyle = 'rgba(6,5,8,0.85)';
      const stripeW = 4 * pixelRatio;
      for (let sx = -radius; sx < radius; sx += stripeW * 2) {
        ctx.fillRect(sx, -radius, stripeW, size);
      }
      ctx.globalAlpha = 0.9;
      ctx.strokeStyle = 'rgba(249,245,233,0.5)';
      ctx.lineWidth = 1 * pixelRatio;
      ctx.beginPath();
      ctx.moveTo(-radius, -radius + size*0.2);
      ctx.lineTo(radius, -radius + size*0.5);
      ctx.stroke();
    } else if (c.pattern === 'fan') {
      ctx.globalAlpha = 0.9;
      const cx = 0;
      const cy = radius * 0.7;
      for (let i = 0; i < 5; i++) {
        const r = radius * (0.3 + i * 0.14);
        ctx.beginPath();
        ctx.strokeStyle = i % 2 ? 'rgba(249,245,233,0.75)' : 'rgba(201,169,97,0.9)';
        ctx.lineWidth = 1 * pixelRatio;
        ctx.arc(cx, cy, r, Math.PI, Math.PI * 2);
        ctx.stroke();
      }
    } else if (c.pattern === 'waves') {
      ctx.globalAlpha = 0.85;
      ctx.strokeStyle = 'rgba(249,245,233,0.8)';
      ctx.lineWidth = 1 * pixelRatio;
      const stepY = 5 * pixelRatio;
      for (let yy = -radius; yy < radius; yy += stepY) {
        ctx.beginPath();
        for (let xx = -radius; xx <= radius; xx += 2 * pixelRatio) {
          const wave = Math.sin((xx / radius) * Math.PI * 1.8 + c.shimmer) * 1.8 * pixelRatio;
          ctx.lineTo(xx, yy + wave);
        }
        ctx.stroke();
      }
    }
    ctx.restore();

    // highlight edge
    ctx.strokeStyle = 'rgba(244,232,190,0.9)';
    ctx.lineWidth = 1.2 * pixelRatio;
    ctx.beginPath();
    ctx.roundRect(-radius, -radius, size, size * 0.9, radius * 0.3);
    ctx.stroke();

    ctx.restore();
  }

  function drawObstacle(o, t) {
    ctx.save();
    ctx.translate(o.x, o.y);
    ctx.shadowColor = 'rgba(0,0,0,0.8)';
    ctx.shadowBlur = 12 * pixelRatio;
    ctx.shadowOffsetY = 6 * pixelRatio;

    if (o.type === 'ladder') {
      const w = o.w * pixelRatio;
      const h = o.h * pixelRatio;
      const railWidth = 4 * pixelRatio;
      const rungSpacing = 22 * pixelRatio;
      const tilt = -4 * Math.PI / 180;

      ctx.rotate(tilt);

      // side rails
      const gradRail = ctx.createLinearGradient(-w/2, 0, w/2, 0);
      gradRail.addColorStop(0, '#111015');
      gradRail.addColorStop(0.3, '#2a2620');
      gradRail.addColorStop(1, '#050408');
      ctx.fillStyle = gradRail;

      ctx.beginPath();
      ctx.roundRect(-w/2, 0, railWidth, h, railWidth/2);
      ctx.roundRect(w/2 - railWidth, 0, railWidth, h, railWidth/2);
      ctx.fill();

      // rungs
      ctx.strokeStyle = 'rgba(201,169,97,0.85)';
      ctx.lineWidth = 2 * pixelRatio;
      for (let y = 8 * pixelRatio; y < h - 8 * pixelRatio; y += rungSpacing) {
        ctx.beginPath();
        ctx.moveTo(-w/2 + railWidth * 1.2, y);
        ctx.lineTo(w/2 - railWidth * 1.2, y);
        ctx.stroke();
      }
    } else {
      const w = o.w * pixelRatio;
      const h = o.h * pixelRatio;
      const bob = Math.sin(t * 2 + o.phase) * 6 * pixelRatio;
      ctx.translate(0, bob);

      // spotlight housing
      ctx.fillStyle = '#111117';
      ctx.beginPath();
      ctx.roundRect(-w/2, 0, w, h*0.45, 8 * pixelRatio);
      ctx.fill();

      // inner bezel
      ctx.strokeStyle = 'rgba(249,245,233,0.4)';
      ctx.lineWidth = 1 * pixelRatio;
      ctx.beginPath();
      ctx.roundRect(-w/2 + 3*pixelRatio, 3*pixelRatio, w - 6*pixelRatio, h*0.45 - 6*pixelRatio, 6 * pixelRatio);
      ctx.stroke();

      // lens glow
      const lensRadius = (w * 0.3);
      const grad = ctx.createRadialGradient(0, h*0.235, lensRadius*0.25, 0, h*0.235, lensRadius);
      grad.addColorStop(0, 'rgba(249,245,233,0.95)');
      grad.addColorStop(0.3, 'rgba(249,245,233,0.6)');
      grad.addColorStop(1, 'rgba(249,245,233,0)');
      ctx.fillStyle = grad;
      ctx.beginPath();
      ctx.arc(0, h*0.24, lensRadius, 0, Math.PI*2);
      ctx.fill();

      // beam
      ctx.globalAlpha = 0.22;
      const beamHeight = groundY - (o.y + bob + h*0.45);
      const beamGrad = ctx.createLinearGradient(0, h*0.45, 0, h*0.45 + beamHeight);
      beamGrad.addColorStop(0, 'rgba(249,245,233,0.9)');
      beamGrad.addColorStop(1, 'rgba(249,245,233,0)');
      ctx.fillStyle = beamGrad;
      ctx.beginPath();
      ctx.moveTo(-w*0.18, h*0.45);
      ctx.lineTo(w*0.18, h*0.45);
      ctx.lineTo(w*0.45, h*0.45 + beamHeight);
      ctx.lineTo(-w*0.45, h*0.45 + beamHeight);
      ctx.closePath();
      ctx.fill();
    }

    ctx.restore();
  }

  function drawRoller() {
    const rollerX = width * 0.25;
    const rollerRadius = 18 * pixelRatio;
    const handleLength = 40 * pixelRatio;

    // shadow on wall
    ctx.save();
    ctx.translate(rollerX + 8 * pixelRatio, rollerY + 10 * pixelRatio);
    ctx.fillStyle = 'rgba(0,0,0,0.7)';
    ctx.beginPath();
    ctx.roundRect(-rollerRadius, -rollerRadius*0.6, rollerRadius*2.2, rollerRadius*1.2, rollerRadius*0.5);
    ctx.fill();
    ctx.restore();

    // roller body
    ctx.save();
    ctx.translate(rollerX, rollerY);
    ctx.lineWidth = 2 * pixelRatio;

    const bodyGrad = ctx.createLinearGradient(-rollerRadius, -rollerRadius*0.6, rollerRadius*1.2, rollerRadius*0.8);
    bodyGrad.addColorStop(0, '#15141c');
    bodyGrad.addColorStop(0.3, '#332f26');
    bodyGrad.addColorStop(0.55, '#c9a961');
    bodyGrad.addColorStop(1, '#f5e6b1');
    ctx.fillStyle = bodyGrad;

    ctx.beginPath();
    ctx.roundRect(-rollerRadius, -rollerRadius*0.6, rollerRadius*2, rollerRadius*1.2, rollerRadius*0.6);
    ctx.fill();

    // roller highlight
    ctx.strokeStyle = 'rgba(255,255,255,0.45)';
    ctx.beginPath();
    ctx.moveTo(-rollerRadius*0.8, -rollerRadius*0.5);
    ctx.lineTo(rollerRadius*1.2, -rollerRadius*0.15);
    ctx.stroke();

    // handle bar
    ctx.strokeStyle = 'rgba(230,220,210,0.9)';
    ctx.lineWidth = 2 * pixelRatio;
    ctx.beginPath();
    ctx.moveTo(rollerRadius*1.1, -rollerRadius*0.1);
    ctx.lineTo(rollerRadius*1.1 + 10*pixelRatio, rollerRadius*0.6);
    ctx.lineTo(rollerRadius*1.1 + 10*pixelRatio, rollerRadius*0.6 + handleLength);
    ctx.stroke();

    // handle grip
    const gripGrad = ctx.createLinearGradient(0, rollerRadius*0.6 + handleLength, 0, rollerRadius*0.6 + handleLength + 22*pixelRatio);
    gripGrad.addColorStop(0, '#111015');
    gripGrad.addColorStop(1, '#252229');
    ctx.fillStyle = gripGrad;
    ctx.beginPath();
    ctx.roundRect(
      rollerRadius*1.1 - 5*pixelRatio,
      rollerRadius*0.6 + handleLength,
      10*pixelRatio,
      22*pixelRatio,
      5*pixelRatio
    );
    ctx.fill();

    ctx.restore();

    // trail of fresh paint
    const trailWidth = 6 * pixelRatio;
    const startX = 0;
    const endX = rollerX - rollerRadius;
    const grad = ctx.createLinearGradient(startX, rollerY, endX, rollerY);
    grad.addColorStop(0, 'rgba(201,169,97,0.08)');
    grad.addColorStop(0.5, 'rgba(201,169,97,0.22)');
    grad.addColorStop(1, 'rgba(201,169,97,0.0)');
    ctx.strokeStyle = grad;
    ctx.lineWidth = trailWidth;
    ctx.lineCap = 'round';
    ctx.beginPath();
    ctx.moveTo(startX, rollerY);
    ctx.lineTo(endX, rollerY);
    ctx.stroke();
  }

  function update(dt) {
    if (!gameRunning) return;

    // physics
    rollerVY += gravity * dt;
    rollerY += rollerVY * dt;

    if (rollerY > groundY - 40) {
      rollerY = groundY - 40;
      rollerVY = 0;
    }
    if (rollerY < 40 * pixelRatio) {
      rollerY = 40 * pixelRatio;
      if (rollerVY < 0) rollerVY *= 0.5;
    }

    const dx = speed * dt;
    collectibles.forEach(c => { c.x -= dx; });
    obstacles.forEach(o => { o.x -= dx; });

    // cull & spawn
    for (let i = collectibles.length - 1; i >= 0; i--) {
      if (collectibles[i].x < -80 * pixelRatio || collectibles[i].collected) {
        collectibles.splice(i, 1);
      }
    }
    for (let i = obstacles.length - 1; i >= 0; i--) {
      if (obstacles[i].x + obstacles[i].w * pixelRatio < -40 * pixelRatio) {
        obstacles.splice(i, 1);
      }
    }

    scheduleSpawns(dt);
    handleCollisions();
  }

  function handleCollisions() {
    const rollerX = width * 0.25;
    const rollerRadius = 16 * pixelRatio;
    const rollerBox = {
      x: rollerX - rollerRadius,
      y: rollerY - rollerRadius,
      w: rollerRadius * 2,
      h: rollerRadius * 2
    };

    // collectibles
    for (let i = 0; i < collectibles.length; i++) {
      const c = collectibles[i];
      const size = c.size * pixelRatio;
      const box = {
        x: c.x - size*0.5,
        y: c.y - size*0.45,
        w: size,
        h: size*0.9
      };
      if (rectsOverlap(rollerBox, box)) {
        c.collected = true;
        score += 10;
        if (score % 80 === 0) {
          speed += 50;
          speedLevel++;
          updateSpeedDisplay();
        }
        updateScoreDisplay();
      }
    }

    // obstacles
    for (let i = 0; i < obstacles.length; i++) {
      const o = obstacles[i];
      const box = {
        x: o.x - (o.type === 'ladder' ? o.w*0.5*pixelRatio : o.w*0.5*pixelRatio),
        y: o.y,
        w: o.w * pixelRatio,
        h: o.h * pixelRatio
      };
      if (rectsOverlap(rollerBox, box)) {
        endRun();
        break;
      }
    }
  }

  function rectsOverlap(a, b) {
    return 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 loop(timestamp) {
    const t = timestamp / 1000;
    const dt = lastTime ? (timestamp - lastTime) / 1000 : 0;
    lastTime = timestamp;

    ctx.clearRect(0, 0, width, height);
    drawBackground(dt);

    collectibles.forEach(c => drawCollectible(c, t));
    obstacles.forEach(o => drawObstacle(o, t));
    drawRoller();

    requestAnimationFrame(loop);
    if (gameRunning) update(dt);
  }

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