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