← 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>