You make short animated pieces for the web, and you have spent time on the part of the internet that makes cats famous.
Build me a single HTML page: a cat in a living room, which spins when you click it.
data: URIs are fine.localStorage, sessionStorage, cookies or indexedDB — sandboxed frame, they throw.html block.Two sentences: what you think you are being asked to recreate, and what the opening frame has to look like for it to land. If you do not recognize it, say so and build the brief as written — saying so costs you nothing here.
The single fenced html block.
Four bullets.
I read this as the photographic living-room cat the timeline likes to set spinning: quiet until a click, then the cat and the room share one vertical axis while a vocal loop climbs with the rate. The opening frame has to hold as a photograph — light from a real window, the cat sharp on a floor that receives it, the room soft behind — or the spin has nothing to betray.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Living room</title>
<style>
html, body { margin: 0; height: 100%; background: #c4b5a6; overflow: hidden; }
canvas { position: fixed; inset: 0; width: 100%; height: 100%; touch-action: none; cursor: default; }
#mute {
position: fixed; right: 14px; bottom: 12px; z-index: 2;
background: rgba(20, 14, 10, 0.28);
color: rgba(255, 246, 236, 0.72);
border: 1px solid rgba(255, 246, 236, 0.18);
border-radius: 999px;
padding: 5px 11px;
font: 11px/1 system-ui, sans-serif;
letter-spacing: 0.12em;
text-transform: uppercase;
cursor: pointer;
}
#mute:hover { background: rgba(20, 14, 10, 0.5); color: #fff; }
#mute:focus { outline: 1px solid rgba(255, 244, 230, 0.55); outline-offset: 2px; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<button id="mute" type="button" aria-pressed="false" aria-label="Mute">mute</button>
<script>
'use strict';
const TAU = Math.PI * 2;
const IW = 1280, IH = 900;
const FLOOR_Y = 455;
const CATX = 670;
const PAW_Y = 790;
const BODY_ANCHOR = 258;
const PAW_LOCAL = 515;
const CAT_DRAW_X = CATX - BODY_ANCHOR;
const CAT_DRAW_Y = PAW_Y - PAW_LOCAL;
const SPR_W = 500, SPR_H = 580;
const MEOW_DUR = 0.52;
const TURN_MS = 380;
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d', { alpha: false });
const muteBtn = document.getElementById('mute');
const room = makeCanvas(IW, IH);
const photoFront = makeCanvas(IW, IH);
const photoBack = makeCanvas(IW, IH);
const catSide = makeCanvas(SPR_W, SPR_H);
const catShut = makeCanvas(SPR_W, SPR_H);
const catFront = makeCanvas(SPR_W, SPR_H);
const catBack = makeCanvas(SPR_W, SPR_H);
const sprLamp = makeCanvas(300, 480);
const sprPlant = makeCanvas(260, 400);
const sprSofa = makeCanvas(420, 280);
const sprFrame = makeCanvas(180, 140);
const sprTable = makeCanvas(300, 200);
const grainCanvas = makeCanvas(160, 160);
let grainPat = null;
let stage = 0;
let fury = 1;
let turning = false;
let spinning = false;
let baked = false;
let turnStart = 0;
let turnU = 0;
let angle = 0;
let omega = 0;
let targetOmega = 0;
let meowPulse = 0;
let muted = false;
let last = 0;
let t0 = 0;
let ac = null;
let master = null;
let meowBuf = null;
let audioFailed = false;
const voices = [];
const furBits = [];
for (let i = 0; i < 42; i++) {
furBits.push({
a: (i / 42) * TAU,
r: 64 + (i * 37) % 250,
sp: 0.35 + (i % 5) * 0.22,
col: i % 3 === 0 ? 'rgba(244,226,206,0.8)' : 'rgba(198,124,62,0.85)'
});
}
function makeCanvas(w, h) {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
return c;
}
function mulberry32(a) {
return function () {
a |= 0;
a = a + 0x6D2B79F5 | 0;
let t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
function clamp8(v) {
return v < 0 ? 0 : v > 255 ? 255 : v | 0;
}
function mix(a, b, t) {
const u = Math.max(0, Math.min(1, t));
return [a[0] + (b[0] - a[0]) * u, a[1] + (b[1] - a[1]) * u, a[2] + (b[2] - a[2]) * u];
}
function rgb(c, a) {
const r = c[0] | 0, g = c[1] | 0, b = c[2] | 0;
if (a === undefined) return 'rgb(' + r + ',' + g + ',' + b + ')';
return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')';
}
function roundRect(g, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
g.beginPath();
g.moveTo(x + rr, y);
g.arcTo(x + w, y, x + w, y + h, rr);
g.arcTo(x + w, y + h, x, y + h, rr);
g.arcTo(x, y + h, x, y, rr);
g.arcTo(x, y, x + w, y, rr);
g.closePath();
}
function quad(a, b, c, t) {
const u = 1 - t;
return {
x: u * u * a.x + 2 * u * t * b.x + t * t * c.x,
y: u * u * a.y + 2 * u * t * b.y + t * t * c.y
};
}
function ease(u) {
return u < 0.5 ? 2 * u * u : 1 - Math.pow(-2 * u + 2, 2) / 2;
}
function setFilter(f) {
try { ctx.filter = f; } catch (err) { ctx.filter = 'none'; }
}
function blob(g, x, y, rx, ry, inner, outer) {
const grd = g.createRadialGradient(x - rx * 0.35, y - ry * 0.4, Math.max(1, rx * 0.08), x, y, Math.max(rx, ry));
grd.addColorStop(0, inner);
grd.addColorStop(1, outer);
g.fillStyle = grd;
g.beginPath();
g.ellipse(x, y, rx, ry, 0, 0, TAU);
g.fill();
}
function ear(g, x, y, flip, kind) {
g.save();
g.translate(x, y);
if (flip) g.scale(-1, 1);
g.fillStyle = '#c17a3c';
g.beginPath();
g.moveTo(0, 18);
g.quadraticCurveTo(-32, -8, -6, -58);
g.quadraticCurveTo(4, -30, 18, 16);
g.closePath();
g.fill();
g.fillStyle = kind === 'back' ? '#a86430' : '#e8b0a6';
g.beginPath();
g.moveTo(0, 10);
g.quadraticCurveTo(-18, -6, -4, -40);
g.quadraticCurveTo(2, -16, 10, 10);
g.closePath();
g.fill();
g.restore();
}
function paintTail(g, kind) {
const a = kind === 'front' ? { x: 145, y: 455 } : kind === 'back' ? { x: 250, y: 420 } : { x: 345, y: 425 };
const b = kind === 'front' ? { x: 250, y: 448 } : kind === 'back' ? { x: 340, y: 510 } : { x: 240, y: 498 };
const d = kind === 'front' ? { x: 372, y: 522 } : kind === 'back' ? { x: 405, y: 448 } : { x: 112, y: 508 };
for (let i = 0; i <= 11; i++) {
const t = i / 11;
const p = quad(a, b, d, t);
const rad = 22 * (1 - t * 0.62);
g.fillStyle = i === 11 ? '#f0d8c0' : (i % 3 === 2 ? '#5a3018' : '#c47c3e');
g.beginPath();
g.ellipse(p.x, p.y, rad, rad * 0.7, 0, 0, TAU);
g.fill();
}
}
function furRegion(g, rand, cx, cy, rx, ry, n, kind, hx, hy) {
for (let i = 0; i < n; i++) {
const a = rand() * TAU;
const rad = Math.sqrt(rand());
const x = cx + Math.cos(a) * rx * rad;
const y = cy + Math.sin(a) * ry * rad;
const nx = (x - cx) / rx;
const ny = (y - cy) / ry;
const light = Math.max(0, Math.min(1, -nx * 0.72 - ny * 0.42 + 0.32));
const stripe = Math.sin(x * 0.075 + Math.sin(y * 0.035) * 1.6) > 0.42;
const cream = kind !== 'back' && ((x - hx) * (x - hx)) / (46 * 46) + ((y - (hy + 70)) * (y - (hy + 70))) / (50 * 50) < 1 && y > hy + 8;
const muzzle = kind !== 'back' && ((x - hx) * (x - hx)) / (34 * 34) + ((y - (hy + 16)) * (y - (hy + 16))) / (26 * 26) < 1;
let col = (muzzle || cream) ? [244, 226, 206] : [196, 122, 58];
col = mix(col, [236, 190, 140], light * 0.65);
col = mix([78, 40, 20], col, 0.28 + light * 0.72);
if (stripe && !muzzle && !cream) col = mix(col, [62, 32, 16], 0.72);
if (nx < -0.62 && light > 0.4) col = mix(col, [255, 236, 210], 0.35);
const edge = rad > 0.82;
const len = (edge ? 8 : 4) + rand() * (edge ? 7 : 5);
const ang = Math.atan2(ny, nx) + (edge ? 0 : 1.15 + (rand() - 0.5) * 0.8);
g.strokeStyle = rgb(col, edge ? 0.55 : 0.38 + rand() * 0.4);
g.lineWidth = edge ? 1.25 : 0.65 + rand() * 0.7;
g.beginPath();
g.moveTo(x, y);
g.lineTo(x + Math.cos(ang) * len, y + Math.sin(ang) * len);
g.stroke();
}
}
function paintEye(g, x, y, open, look) {
g.save();
g.translate(x, y);
g.scale(1, Math.max(0.07, open));
g.fillStyle = '#d7b56a';
g.beginPath();
g.ellipse(0, 0, 8.5, 9.5, 0, 0, TAU);
g.fill();
g.fillStyle = '#1a120e';
g.beginPath();
g.ellipse(look, 0.8, 3.3, 5.1, 0, 0, TAU);
g.fill();
g.fillStyle = 'rgba(255,255,255,0.92)';
g.beginPath();
g.ellipse(-2.5, -3.2, 1.7, 2.1, 0.2, 0, TAU);
g.fill();
g.fillStyle = 'rgba(255,255,255,0.7)';
g.fillRect(-3.1, -3.6, 1.1, 2.4);
g.restore();
g.strokeStyle = 'rgba(60,30,16,0.55)';
g.lineWidth = 1.3;
g.beginPath();
g.moveTo(x - 9, y);
g.quadraticCurveTo(x, y - 7 * open - 2, x + 9, y);
g.stroke();
}
function nose(g, x, y) {
g.fillStyle = '#c97b72';
g.beginPath();
g.moveTo(x, y - 4);
g.lineTo(x + 7, y + 3);
g.lineTo(x - 7, y + 3);
g.closePath();
g.fill();
g.fillStyle = 'rgba(255,255,255,0.5)';
g.fillRect(x - 3, y - 2, 2, 2);
}
function mouth(g, x, y, front) {
g.strokeStyle = 'rgba(70,30,24,0.6)';
g.lineWidth = 1.15;
g.beginPath();
if (front) {
g.moveTo(x, y);
g.quadraticCurveTo(x + 7, y + 7, x + 13, y + 2);
g.moveTo(x, y);
g.quadraticCurveTo(x - 7, y + 7, x - 13, y + 2);
} else {
g.moveTo(x, y);
g.quadraticCurveTo(x - 8, y + 5, x - 16, y + 1);
}
g.stroke();
}
function whiskers(g, x, y, dir) {
for (let i = -1; i <= 1; i++) {
g.strokeStyle = 'rgba(40,30,20,0.35)';
g.lineWidth = 1.15;
g.beginPath();
g.moveTo(x, y + i * 8);
g.quadraticCurveTo(x + dir * 40, y + i * 14, x + dir * 92, y + i * 20);
g.stroke();
g.strokeStyle = 'rgba(255,250,245,0.88)';
g.lineWidth = 0.8;
g.beginPath();
g.moveTo(x, y + i * 8 - 1);
g.quadraticCurveTo(x + dir * 40, y + i * 14 - 1, x + dir * 92, y + i * 20 - 1);
g.stroke();
}
}
function toes(g, x, y) {
g.fillStyle = '#f0d2b4';
g.beginPath();
g.ellipse(x, y, 16, 11, 0, 0, TAU);
g.fill();
g.strokeStyle = 'rgba(80,40,20,0.35)';
g.lineWidth = 1;
for (let i = -1; i <= 1; i++) {
g.beginPath();
g.moveTo(x + i * 5, y - 4);
g.lineTo(x + i * 6, y + 6);
g.stroke();
}
}
function paintPaws(g, kind) {
if (kind === 'back') { toes(g, 230, 512); toes(g, 278, 514); return; }
if (kind === 'front') { toes(g, 214, 514); toes(g, 292, 514); }
else { toes(g, 196, 508); toes(g, 232, 516); }
}
function paintFace(g, kind, hx, hy, eyeOpen) {
if (kind === 'front') {
paintEye(g, hx - 24, hy + 2, eyeOpen, 0.5);
paintEye(g, hx + 24, hy + 2, eyeOpen, -0.5);
nose(g, hx, hy + 22);
mouth(g, hx, hy + 30, true);
whiskers(g, hx - 8, hy + 24, -1);
whiskers(g, hx + 8, hy + 24, 1);
g.strokeStyle = 'rgba(70,34,16,0.5)';
g.lineWidth = 2.4;
g.beginPath();
g.moveTo(hx - 22, hy - 28);
g.quadraticCurveTo(hx - 8, hy - 8, hx, hy - 26);
g.quadraticCurveTo(hx + 8, hy - 8, hx + 22, hy - 28);
g.stroke();
} else {
paintEye(g, hx - 16, hy + 2, eyeOpen, -1.8);
paintEye(g, hx + 8, hy + 4, eyeOpen * 0.8, -1.1);
nose(g, hx - 28, hy + 20);
mouth(g, hx - 24, hy + 28, false);
whiskers(g, hx - 30, hy + 22, -1);
whiskers(g, hx - 4, hy + 24, 1);
}
}
function paintCat(cnv, kind) {
const g = cnv.getContext('2d');
g.clearRect(0, 0, cnv.width, cnv.height);
g.lineCap = 'round';
const front = kind === 'front';
const back = kind === 'back';
const hx = front || back ? 250 : 186;
const hy = front || back ? 226 : 240;
const bx = front || back ? 258 : 292;
const by = 405;
const eyeOpen = kind === 'shut' ? 0.07 : front ? 0.96 : 0.4;
paintTail(g, kind);
if (front || back) {
blob(g, bx, by, 118, 78, '#e0aa72', '#a86432');
blob(g, bx, by + 8, 90, 58, '#f0c898', '#b07040');
} else {
blob(g, bx + 8, by, 102, 72, '#e0aa72', '#a86432');
blob(g, hx + 16, by + 6, 72, 56, '#f2cba0', '#c48448');
}
if (!back) blob(g, hx + (front ? 0 : -6), hy + 76, 42, 34, '#f6e6d4', '#e2b890');
g.fillStyle = 'rgba(70,36,18,0.22)';
g.beginPath();
g.ellipse(hx + (front ? 0 : 8), hy + 60, 48, 15, 0, 0, TAU);
g.fill();
ear(g, hx - (front || back ? 42 : 28), hy - 68, false, kind);
if (front || back) ear(g, hx + 42, hy - 68, true, kind);
else ear(g, hx + 16, hy - 60, true, kind);
blob(g, hx, hy, front ? 78 : 70, front ? 72 : 66, '#f0c088', '#b06e38');
const rand = mulberry32(kind === 'front' ? 4 : kind === 'back' ? 6 : 2);
furRegion(g, rand, hx, hy, front ? 78 : 70, front ? 72 : 66, 460, kind, hx, hy);
furRegion(g, rand, bx, by, front || back ? 118 : 104, front || back ? 78 : 70, 720, kind, hx, hy);
gradeAndGrain(g, 10, false, true);
if (back) {
g.strokeStyle = 'rgba(62,30,14,0.4)';
g.lineWidth = 7;
g.lineCap = 'round';
g.beginPath();
g.moveTo(hx, hy - 6);
g.quadraticCurveTo(bx, by - 16, bx, by + 28);
g.stroke();
} else {
paintFace(g, kind, hx, hy, eyeOpen);
}
paintPaws(g, kind);
}
function paintFloor(g, dim) {
const n = 14;
for (let i = 0; i < n; i++) {
const t0 = i / n, t1 = (i + 1) / n;
const y0 = FLOOR_Y + (IH - FLOOR_Y) * Math.pow(t0, 0.9);
const y1 = FLOOR_Y + (IH - FLOOR_Y) * Math.pow(t1, 0.9);
const wobble = (i * 17 % 7) - 3;
const r = (132 + wobble + (i % 2 ? 10 : 0)) * dim;
const gg = (86 + wobble * 0.45) * dim;
const b = (52 + (i % 3) * 2) * dim;
g.fillStyle = 'rgb(' + (r | 0) + ',' + (gg | 0) + ',' + (b | 0) + ')';
g.fillRect(0, y0, IW, Math.max(1, y1 - y0 + 0.5));
}
g.strokeStyle = 'rgba(50,28,16,' + (0.18 * dim) + ')';
g.lineWidth = 1;
for (let k = -8; k <= 10; k++) {
g.beginPath();
g.moveTo(640 + k * 22, FLOOR_Y);
g.lineTo(640 + k * 150, IH + 20);
g.stroke();
}
const rand = mulberry32(12);
g.strokeStyle = 'rgba(255,220,180,' + (0.045 * dim) + ')';
for (let i = 0; i < 70; i++) {
const y = FLOOR_Y + rand() * (IH - FLOOR_Y);
g.beginPath();
g.moveTo(rand() * IW, y);
g.lineTo(rand() * IW, y + 1);
g.stroke();
}
g.fillStyle = 'rgba(90,50,30,' + (0.32 * dim) + ')';
g.beginPath();
g.ellipse(420, FLOOR_Y + 180, 14, 8, 0.4, 0, TAU);
g.fill();
g.beginPath();
g.ellipse(900, FLOOR_Y + 280, 10, 6, -0.2, 0, TAU);
g.fill();
const wash = g.createLinearGradient(0, FLOOR_Y, IW * 0.75, IH);
wash.addColorStop(0, 'rgba(255,214,160,' + (0.13 * dim) + ')');
wash.addColorStop(1, 'rgba(255,214,160,0)');
g.fillStyle = wash;
g.fillRect(0, FLOOR_Y, IW, IH - FLOOR_Y);
const near = g.createLinearGradient(0, IH - 130, 0, IH);
near.addColorStop(0, 'rgba(0,0,0,0)');
near.addColorStop(1, 'rgba(20,10,6,' + (0.26 * dim) + ')');
g.fillStyle = near;
g.fillRect(0, IH - 130, IW, 130);
}
function paintBaseboard(g, dim) {
g.fillStyle = 'rgb(' + ((118 * dim) | 0) + ',' + ((94 * dim) | 0) + ',' + ((76 * dim) | 0) + ')';
g.fillRect(0, FLOOR_Y - 11, IW, 11);
g.fillStyle = 'rgba(40,24,16,' + (0.22 * dim) + ')';
g.fillRect(0, FLOOR_Y, IW, 7);
}
function paintRug(g, dim) {
g.save();
g.translate(CATX, PAW_Y - 6);
g.scale(1, 0.32);
g.fillStyle = 'rgb(' + ((92 * dim) | 0) + ',' + ((40 * dim) | 0) + ',' + ((34 * dim) | 0) + ')';
g.beginPath();
g.ellipse(0, 0, 270, 270, 0, 0, TAU);
g.fill();
g.strokeStyle = 'rgb(' + ((68 * dim) | 0) + ',' + ((28 * dim) | 0) + ',' + ((24 * dim) | 0) + ')';
g.lineWidth = 16;
g.stroke();
g.restore();
g.strokeStyle = 'rgba(196,160,120,' + (0.4 * dim) + ')';
g.lineWidth = 1;
for (let i = -16; i <= 16; i++) {
const x = CATX + i * 13;
g.beginPath();
g.moveTo(x, PAW_Y + 76);
g.lineTo(x + (i % 2 ? 2 : -2), PAW_Y + 90);
g.stroke();
}
}
function paintContact(g, alpha) {
g.save();
g.translate(CATX + 30, PAW_Y - 2);
g.scale(1, 0.2);
const grd = g.createRadialGradient(0, 0, 10, 0, 0, 160);
grd.addColorStop(0, 'rgba(42,24,16,' + (0.46 * alpha) + ')');
grd.addColorStop(1, 'rgba(42,24,16,0)');
g.fillStyle = grd;
g.beginPath();
g.arc(0, 0, 160, 0, TAU);
g.fill();
g.restore();
g.save();
g.translate(CATX - 4, PAW_Y + 2);
g.scale(1, 0.16);
g.fillStyle = 'rgba(30,16,10,' + (0.38 * alpha) + ')';
g.beginPath();
g.arc(0, 0, 58, 0, TAU);
g.fill();
g.restore();
}
function paintWalls(g) {
const wall = g.createLinearGradient(0, 0, 0, FLOOR_Y + 20);
wall.addColorStop(0, '#b7a898');
wall.addColorStop(0.35, '#d5c5b4');
wall.addColorStop(1, '#cbbba9');
g.fillStyle = wall;
g.fillRect(0, 0, IW, IH);
g.fillStyle = '#c2b09e';
g.beginPath();
g.moveTo(0, 0);
g.lineTo(150, 28);
g.lineTo(150, FLOOR_Y + 8);
g.lineTo(0, FLOOR_Y + 34);
g.fill();
const ceil = g.createLinearGradient(0, 0, 0, 90);
ceil.addColorStop(0, 'rgba(60,42,32,0.16)');
ceil.addColorStop(1, 'rgba(60,42,32,0)');
g.fillStyle = ceil;
g.fillRect(0, 0, IW, 90);
const wl = g.createRadialGradient(210, 170, 30, 230, 190, 500);
wl.addColorStop(0, 'rgba(255,236,210,0.55)');
wl.addColorStop(1, 'rgba(255,236,210,0)');
g.fillStyle = wl;
g.fillRect(0, 0, 760, FLOOR_Y);
const ll = g.createRadialGradient(980, 170, 10, 980, 210, 280);
ll.addColorStop(0, 'rgba(255,196,120,0.3)');
ll.addColorStop(1, 'rgba(255,196,120,0)');
g.fillStyle = ll;
g.fillRect(740, 30, 460, 420);
}
function stipple(g, seed, n, a) {
const rand = mulberry32(seed);
for (let i = 0; i < n; i++) {
const x = rand() * IW;
const y = rand() * FLOOR_Y;
const v = 150 + rand() * 80;
g.fillStyle = 'rgba(' + (v | 0) + ',' + ((v - 12) | 0) + ',' + ((v - 28) | 0) + ',' + a + ')';
g.fillRect(x, y, 1.3, 1.3);
}
}
function paintWindow(g) {
const x = 170, y = 56, w = 290, h = 286;
const sky = g.createLinearGradient(0, y, 0, y + h);
sky.addColorStop(0, '#e7d3bc');
sky.addColorStop(0.45, '#f3e6d4');
sky.addColorStop(1, '#d5e0d4');
g.fillStyle = sky;
g.fillRect(x, y, w, h);
g.fillStyle = 'rgba(86,112,78,0.55)';
g.beginPath(); g.ellipse(x + 70, y + h - 30, 80, 50, 0, 0, TAU); g.fill();
g.fillStyle = 'rgba(62,92,64,0.5)';
g.beginPath(); g.ellipse(x + 160, y + h - 18, 100, 58, 0, 0, TAU); g.fill();
g.fillStyle = 'rgba(120,140,96,0.4)';
g.beginPath(); g.ellipse(x + 230, y + h - 40, 70, 40, 0, 0, TAU); g.fill();
g.fillStyle = '#5c4636';
g.fillRect(x - 10, y - 10, w + 20, 14);
g.fillRect(x - 10, y + h - 4, w + 20, 16);
g.fillRect(x - 10, y, 14, h);
g.fillRect(x + w - 4, y, 14, h);
g.fillStyle = '#6a5342';
g.fillRect(x + w / 2 - 3, y, 6, h);
g.fillRect(x, y + h / 2 - 3, w, 6);
for (let i = 0; i < 7; i++) {
g.fillStyle = 'rgba(245,236,220,' + (0.14 + (i % 2) * 0.08) + ')';
g.fillRect(x + w - 78 + i * 10, y + 8, 9, h - 20);
}
g.fillStyle = '#4a3a2e';
g.fillRect(x - 16, y - 16, w + 32, 5);
g.fillStyle = '#d9cbb8';
g.fillRect(x - 16, y + h + 8, w + 32, 12);
g.fillStyle = '#c4b29e';
g.fillRect(x - 16, y + h + 20, w + 32, 4);
g.fillStyle = '#efe6da';
g.beginPath();
g.ellipse(x + 46, y + h + 8, 16, 6, 0, 0, TAU);
g.fill();
g.fillStyle = '#8fa888';
g.beginPath();
g.ellipse(x + 46, y + h + 2, 8, 5, 0, 0, TAU);
g.fill();
const bloom = g.createRadialGradient(x + 30, y + 30, 8, x + 50, y + 40, 170);
bloom.addColorStop(0, 'rgba(255,244,220,0.34)');
bloom.addColorStop(1, 'rgba(255,244,220,0)');
g.fillStyle = bloom;
g.fillRect(x - 70, y - 30, 200, 180);
}
function paintShelf(g) {
const x = 1088, y = 52, w = 156, h = 240;
g.fillStyle = '#6a5243';
g.fillRect(x, y, w, h);
const bookColors = ['#8d3a32', '#d8c6a4', '#2f4a44', '#c4a05a', '#3d4a68', '#6e3a28', '#efe6da'];
for (let s = 0; s < 4; s++) {
const shelfY = y + 48 + s * 52;
g.fillStyle = '#5a4336';
g.fillRect(x + 4, shelfY, w - 8, 6);
const rand = mulberry32(30 + s);
let bx = x + 8;
while (bx < x + w - 14) {
const bw = 6 + ((rand() * 9) | 0);
const bh = 26 + ((rand() * 14) | 0);
g.fillStyle = bookColors[(rand() * bookColors.length) | 0];
g.fillRect(bx, shelfY - bh, bw, bh);
bx += bw + 1;
}
}
}
function paintSun(g) {
g.save();
g.translate(520, 730);
g.rotate(-0.25);
g.scale(1.1, 0.42);
const s = g.createRadialGradient(0, 0, 20, 0, 0, 240);
s.addColorStop(0, 'rgba(255,214,150,0.5)');
s.addColorStop(0.6, 'rgba(255,200,130,0.16)');
s.addColorStop(1, 'rgba(255,200,130,0)');
g.fillStyle = s;
g.beginPath();
g.arc(0, 0, 240, 0, TAU);
g.fill();
g.restore();
g.save();
g.translate(540, 710);
g.rotate(-0.22);
g.fillStyle = 'rgba(90,60,30,0.05)';
for (let i = 0; i < 4; i++) g.fillRect(-200, -70 + i * 42, 380, 9);
g.restore();
}
function paintShaft(g) {
const grd = g.createLinearGradient(180, 60, 560, 800);
grd.addColorStop(0, 'rgba(255,236,200,0.2)');
grd.addColorStop(1, 'rgba(255,236,200,0)');
g.fillStyle = grd;
g.beginPath();
g.moveTo(175, 60);
g.lineTo(450, 68);
g.lineTo(740, 880);
g.lineTo(410, 900);
g.closePath();
g.fill();
const rand = mulberry32(5);
for (let i = 0; i < 16; i++) {
const x = 160 + rand() * 460;
const y = 90 + rand() * 640;
g.fillStyle = 'rgba(255,248,230,' + (0.25 + rand() * 0.4) + ')';
g.beginPath();
g.arc(x, y, 0.7 + rand() * 1.5, 0, TAU);
g.fill();
}
}
function paintLamp(cnv) {
const g = cnv.getContext('2d');
const w = cnv.width, h = cnv.height;
const glow = g.createRadialGradient(w / 2, 70, 10, w / 2, 80, 150);
glow.addColorStop(0, 'rgba(255,210,140,0.55)');
glow.addColorStop(1, 'rgba(255,210,140,0)');
g.fillStyle = glow;
g.beginPath();
g.arc(w / 2, 80, 150, 0, TAU);
g.fill();
g.fillStyle = '#5c5148';
g.fillRect(w / 2 - 3, 120, 6, h - 160);
g.fillStyle = '#4a4038';
g.beginPath();
g.ellipse(w / 2, h - 30, 28, 8, 0, 0, TAU);
g.fill();
const sh = g.createLinearGradient(w / 2 - 50, 40, w / 2 + 50, 40);
sh.addColorStop(0, '#f7e6c4');
sh.addColorStop(0.5, '#f3d7a4');
sh.addColorStop(1, '#e0b87a');
g.fillStyle = sh;
g.beginPath();
g.moveTo(w / 2 - 36, 40);
g.lineTo(w / 2 + 36, 40);
g.lineTo(w / 2 + 52, 110);
g.lineTo(w / 2 - 52, 110);
g.closePath();
g.fill();
g.fillStyle = 'rgba(255,255,255,0.25)';
g.fillRect(w / 2 - 28, 48, 8, 50);
g.fillStyle = 'rgba(80,40,10,0.25)';
g.beginPath();
g.ellipse(w / 2, 110, 52, 8, 0, 0, TAU);
g.fill();
}
function paintPlant(cnv) {
const g = cnv.getContext('2d');
const w = cnv.width, h = cnv.height;
const leaves = [
[-50, -40, 36, 16, -0.8, '#2c4a34'],
[-20, -70, 30, 12, -0.3, '#3d6244'],
[10, -90, 34, 13, 0.2, '#2f5138'],
[40, -60, 32, 12, 0.7, '#3a5c40'],
[0, -50, 28, 11, 0.1, '#4e7050'],
[-30, -20, 26, 10, -1.1, '#243f2e']
];
for (let i = 0; i < leaves.length; i++) {
const L = leaves[i];
g.save();
g.translate(w / 2 + L[0], h - 150 + L[1]);
g.rotate(L[4]);
g.fillStyle = L[5];
g.beginPath();
g.ellipse(0, 0, L[2], L[3], 0, 0, TAU);
g.fill();
g.restore();
}
g.fillStyle = '#b56a45';
g.beginPath();
g.moveTo(w / 2 - 36, h - 150);
g.lineTo(w / 2 + 36, h - 150);
g.lineTo(w / 2 + 28, h - 40);
g.lineTo(w / 2 - 28, h - 40);
g.closePath();
g.fill();
g.fillStyle = '#d08a62';
g.fillRect(w / 2 - 40, h - 156, 80, 12);
g.fillStyle = 'rgba(255,255,255,0.16)';
g.fillRect(w / 2 - 22, h - 140, 8, 80);
}
function paintSofa(cnv) {
const g = cnv.getContext('2d');
const w = cnv.width, h = cnv.height;
g.fillStyle = 'rgba(40,28,20,0.28)';
g.beginPath();
g.ellipse(w / 2, h - 16, w * 0.4, 14, 0, 0, TAU);
g.fill();
g.fillStyle = '#3c4a43';
roundRect(g, 16, 48, w - 32, h - 70, 16);
g.fill();
g.fillStyle = '#4a5a52';
roundRect(g, 28, 16, w - 56, 64, 14);
g.fill();
g.fillStyle = '#44564e';
roundRect(g, 36, h - 108, (w - 80) / 2 - 4, 52, 10);
g.fill();
roundRect(g, 40 + (w - 80) / 2, h - 108, (w - 80) / 2 - 4, 52, 10);
g.fill();
g.fillStyle = 'rgba(255,230,190,0.08)';
g.fillRect(20, 50, 40, h - 80);
g.fillStyle = '#5c4636';
g.fillRect(36, h - 28, 10, 22);
g.fillRect(w - 46, h - 28, 10, 22);
const rand = mulberry32(8);
for (let i = 0; i < 160; i++) {
g.fillStyle = 'rgba(255,255,255,' + (0.015 + rand() * 0.03) + ')';
g.fillRect(30 + rand() * (w - 60), 30 + rand() * (h - 60), 8, 1.2);
}
}
function paintFrame(cnv) {
const g = cnv.getContext('2d');
const w = cnv.width, h = cnv.height;
g.fillStyle = '#6b4e38';
g.fillRect(0, 0, w, h);
g.fillStyle = '#efe6da';
g.fillRect(10, 10, w - 20, h - 20);
const sky = g.createLinearGradient(0, 10, 0, h - 10);
sky.addColorStop(0, '#e7c99a');
sky.addColorStop(0.5, '#f0e2c8');
sky.addColorStop(1, '#7f9a78');
g.fillStyle = sky;
g.fillRect(16, 16, w - 32, h - 32);
g.fillStyle = '#6d8a66';
g.beginPath();
g.ellipse(w / 2, h * 0.72, w * 0.28, h * 0.12, 0, 0, TAU);
g.fill();
}
function paintTable(cnv) {
const g = cnv.getContext('2d');
const w = cnv.width, h = cnv.height;
g.fillStyle = '#6b4e38';
g.fillRect(w / 2 - 8, 78, 14, h - 96);
g.fillStyle = '#8a6244';
g.beginPath();
g.ellipse(w / 2, 78, 110, 26, 0, 0, TAU);
g.fill();
g.fillStyle = '#a07852';
g.beginPath();
g.ellipse(w / 2, 70, 110, 26, 0, 0, TAU);
g.fill();
g.save();
g.translate(w / 2 - 40, 60);
g.rotate(-0.3);
g.fillStyle = '#f0e6d8';
g.fillRect(0, 0, 46, 8);
g.fillStyle = '#7a3030';
g.fillRect(0, -3, 46, 5);
g.restore();
g.fillStyle = '#e6d5c4';
g.fillRect(w / 2 + 24, 38, 26, 30);
g.fillStyle = '#f2e4d4';
g.beginPath();
g.ellipse(w / 2 + 37, 38, 13, 5, 0, 0, TAU);
g.fill();
g.fillStyle = '#3a2a22';
g.beginPath();
g.ellipse(w / 2 + 37, 38, 9, 3.4, 0, 0, TAU);
g.fill();
g.strokeStyle = '#e6d5c4';
g.lineWidth = 3;
g.beginPath();
g.arc(w / 2 + 52, 52, 7, -1, 1);
g.stroke();
}
function paintRoom(g) {
paintWalls(g);
stipple(g, 3, 1400, 0.045);
paintWindow(g);
paintShelf(g);
g.fillStyle = '#efe6da';
g.fillRect(1188, 300, 14, 22);
g.fillStyle = '#d9cfc3';
g.fillRect(148, 168, 10, 18);
paintFloor(g, 1);
paintBaseboard(g, 1);
paintRug(g, 1);
g.drawImage(sprLamp, 790, 55);
g.drawImage(sprSofa, 850, 400);
g.drawImage(sprPlant, 120, 310);
g.drawImage(sprFrame, 490, 70);
g.drawImage(sprTable, 130, 610);
paintSun(g);
paintShaft(g);
g.save();
g.translate(990, 700);
g.scale(1, 0.28);
g.fillStyle = 'rgba(255,186,100,0.12)';
g.beginPath();
g.arc(0, 0, 130, 0, TAU);
g.fill();
g.restore();
}
function paintBackRoom(g) {
const wall = g.createLinearGradient(0, 0, 0, FLOOR_Y);
wall.addColorStop(0, '#6e6258');
wall.addColorStop(1, '#8a7d70');
g.fillStyle = wall;
g.fillRect(0, 0, IW, IH);
paintFloor(g, 0.84);
g.fillStyle = '#4a3b32';
g.fillRect(168, 118, 108, FLOOR_Y - 118);
g.strokeStyle = '#2e241e';
g.lineWidth = 6;
g.strokeRect(168, 118, 108, FLOOR_Y - 118);
g.strokeRect(184, 140, 76, 110);
g.strokeRect(184, 268, 76, 120);
g.fillStyle = '#2a241f';
g.fillRect(990, 168, 220, 130);
g.fillStyle = '#14181c';
g.fillRect(1002, 180, 196, 106);
g.fillStyle = 'rgba(255,220,180,0.08)';
g.beginPath();
g.moveTo(1020, 188);
g.lineTo(1140, 188);
g.lineTo(1088, 270);
g.fill();
g.fillStyle = '#5c4a3c';
g.fillRect(970, 308, 260, 22);
paintBaseboard(g, 0.8);
paintRug(g, 0.72);
paintContact(g, 0.5);
g.drawImage(catBack, CAT_DRAW_X, CAT_DRAW_Y);
const rim = g.createRadialGradient(180, 70, 20, 420, 180, 640);
rim.addColorStop(0, 'rgba(255,220,180,0.12)');
rim.addColorStop(1, 'rgba(255,220,180,0)');
g.fillStyle = rim;
g.fillRect(0, 0, IW, IH);
}
function makeBlur(src) {
const w = Math.max(2, Math.round(src.width / 2.4));
const h = Math.max(2, Math.round(src.height / 2.4));
const small = makeCanvas(w, h);
const sg = small.getContext('2d');
sg.imageSmoothingEnabled = true;
sg.drawImage(src, 0, 0, w, h);
const out = makeCanvas(src.width, src.height);
const og = out.getContext('2d');
og.imageSmoothingEnabled = true;
og.drawImage(small, 0, 0, src.width, src.height);
return out;
}
function mixDof(dest, sharp, blurred) {
dest.drawImage(blurred, 0, 0);
const mask = makeCanvas(IW, IH);
const m = mask.getContext('2d');
m.drawImage(sharp, 0, 0);
m.globalCompositeOperation = 'destination-in';
const grd = m.createLinearGradient(0, 0, 0, IH);
grd.addColorStop(0, 'rgba(0,0,0,0)');
grd.addColorStop(0.4, 'rgba(0,0,0,0.04)');
grd.addColorStop(0.52, 'rgba(0,0,0,0.45)');
grd.addColorStop(0.66, 'rgba(0,0,0,1)');
grd.addColorStop(1, 'rgba(0,0,0,1)');
m.fillStyle = grd;
m.fillRect(0, 0, IW, IH);
dest.drawImage(mask, 0, 0);
}
function gradeAndGrain(g, amount, grade, preserveAlpha) {
const cnv = g.canvas;
const id = g.getImageData(0, 0, cnv.width, cnv.height);
const d = id.data;
const rand = mulberry32(grade ? 77 : 99);
for (let i = 0; i < d.length; i += 4) {
if (preserveAlpha && d[i + 3] < 8) continue;
let r = d[i], gg = d[i + 1], b = d[i + 2];
if (grade) {
r = r * 0.95 + 9;
gg = gg * 0.93 + 3;
b = b * 0.9 + 1;
}
const n = (rand() - 0.5) * amount;
d[i] = clamp8(r + n);
d[i + 1] = clamp8(gg + n * 0.9);
d[i + 2] = clamp8(b + n * 0.75);
}
g.putImageData(id, 0, 0);
}
function buildImages() {
paintCat(catSide, 'side');
paintCat(catShut, 'shut');
paintCat(catFront, 'front');
paintCat(catBack, 'back');
paintLamp(sprLamp);
paintPlant(sprPlant);
paintSofa(sprSofa);
paintFrame(sprFrame);
paintTable(sprTable);
const sharp = makeCanvas(IW, IH);
const sg = sharp.getContext('2d');
paintRoom(sg);
const blurred = makeBlur(sharp);
const rg = room.getContext('2d', { willReadFrequently: true });
mixDof(rg, sharp, blurred);
gradeAndGrain(rg, 15, true, false);
const bg = photoBack.getContext('2d', { willReadFrequently: true });
paintBackRoom(bg);
gradeAndGrain(bg, 12, true, false);
const gg = grainCanvas.getContext('2d');
const id = gg.createImageData(160, 160);
const rand = mulberry32(42);
for (let i = 0; i < id.data.length; i += 4) {
const v = 80 + rand() * 120;
id.data[i] = id.data[i + 1] = id.data[i + 2] = v;
id.data[i + 3] = 255;
}
gg.putImageData(id, 0, 0);
}
function bakeFront() {
const g = photoFront.getContext('2d');
g.setTransform(1, 0, 0, 1, 0, 0);
g.fillStyle = '#cbbba9';
g.fillRect(0, 0, IW, IH);
g.drawImage(room, 0, 0);
paintContact(g, 1);
g.drawImage(catFront, CAT_DRAW_X, CAT_DRAW_Y);
baked = true;
}
function ensureAudio() {
if (ac || audioFailed) return;
try {
const AC = window.AudioContext || window.webkitAudioContext;
ac = new AC();
master = ac.createGain();
master.gain.value = muted ? 0 : 1;
const hp = ac.createBiquadFilter();
hp.type = 'highpass';
hp.frequency.value = 150;
const presence = ac.createBiquadFilter();
presence.type = 'peaking';
presence.frequency.value = 1750;
presence.Q.value = 0.7;
presence.gain.value = 1.6;
const lp = ac.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 7400;
lp.Q.value = 0.4;
const comp = ac.createDynamicsCompressor();
comp.threshold.value = -12;
comp.knee.value = 8;
comp.ratio.value = 4;
comp.attack.value = 0.004;
comp.release.value = 0.12;
master.connect(hp);
hp.connect(presence);
presence.connect(lp);
lp.connect(comp);
comp.connect(ac.destination);
meowBuf = synthMeow(ac);
} catch (err) {
audioFailed = true;
ac = null;
}
}
function synthMeow(audio) {
const sr = audio.sampleRate;
const dur = MEOW_DUR;
const n = (sr * dur) | 0;
const buf = audio.createBuffer(1, n, sr);
const d = buf.getChannelData(0);
const R = [{ y1: 0, y2: 0 }, { y1: 0, y2: 0 }, { y1: 0, y2: 0 }];
let phase = 0.15;
let prevG = 0;
let prevOut = 0;
let dc = 0;
let p1 = 0, p2 = 0, p3 = 0;
const raw = new Float32Array(n);
let max = 1e-4;
for (let i = 0; i < n; i++) {
const t = i / sr;
const u = t / dur;
const onset = Math.exp(-Math.pow((u - 0.07) / 0.045, 2));
const vowel = Math.sin(Math.PI * Math.min(1, Math.max(0, (u - 0.05) / 0.92))) * Math.pow(1 - u, 0.32);
let env = Math.min(1, onset * 0.48 + vowel);
env *= Math.min(1, i / 70, (n - i) / 90);
const nasal = Math.max(0, 1 - u / 0.14);
const vib = 1 + 0.012 * Math.sin(TAU * 5.4 * t) * u;
const f0 = (500 + 200 * Math.sin(Math.PI * Math.min(1, u * 1.05)) - 130 * u) * vib * (1 + (Math.random() - 0.5) * 0.006);
phase += f0 / sr;
const p = phase - Math.floor(phase);
const openQ = 0.45;
let gl = 0;
if (p < openQ) gl = 0.5 * (1 - Math.cos(TAU * p / openQ));
else gl = 0.12 * (1 - (p - openQ) / (1 - openQ));
const lip = gl - prevG;
prevG = gl;
const noise = (Math.random() * 2 - 1) * (0.03 + nasal * 0.08 + u * 0.02);
const exc = lip * 1.7 + gl * 0.42 + noise + Math.sin(Math.PI * phase) * 0.09;
const F1 = 800 + 360 * Math.sin(Math.PI * u) - nasal * 160;
const F2 = 2480 - 920 * Math.min(1, u * 2.5) - 400 * u - nasal * 280;
const F3 = 3380 - 440 * u;
const freqs = [F1, F2, F3];
const bws = [100, 150, 220];
const amps = [1, 0.62, 0.28];
let y = 0;
for (let k = 0; k < 3; k++) {
const rk = Math.exp(-Math.PI * bws[k] / sr);
const th = TAU * Math.max(90, freqs[k]) / sr;
const a1 = 2 * rk * Math.cos(th);
const a2 = -rk * rk;
const yy = exc * (1 - rk) + a1 * R[k].y1 + a2 * R[k].y2;
R[k].y2 = R[k].y1;
R[k].y1 = yy;
y += yy * amps[k];
}
p1 += F1 / sr;
p2 += F2 / sr;
p3 += F3 / sr;
const s1 = Math.sin(TAU * p1) * 0.1;
const s2 = Math.sin(TAU * p2) * 0.05;
const s3 = Math.sin(TAU * p3) * 0.025;
let out = (y * 0.5 + s1 + s2 + s3 + nasal * Math.sin(TAU * phase) * 0.18) * env;
if (!isFinite(out)) out = 0;
dc = out - prevOut + 0.995 * dc;
prevOut = out;
if (!isFinite(dc)) dc = 0;
raw[i] = dc;
const am = Math.abs(dc);
if (am > max) max = am;
}
const norm = 0.9 / max;
for (let i = 0; i < n; i++) d[i] = Math.tanh(raw[i] * norm * 1.35);
return buf;
}
function spawnVoice(buf, rate, vol) {
if (!ac || !buf || muted) return;
if (ac.state === 'suspended') ac.resume();
while (voices.length > 10) {
const old = voices.shift();
try { old.stop(); } catch (e) {}
}
const src = ac.createBufferSource();
src.buffer = buf;
src.playbackRate.value = rate;
src.detune.value = (Math.random() - 0.5) * 16;
const g = ac.createGain();
const t0a = ac.currentTime;
g.gain.setValueAtTime(0, t0a);
g.gain.linearRampToValueAtTime(vol, t0a + 0.012);
src.connect(g);
g.connect(master);
src.start(t0a);
voices.push(src);
src.onended = function () {
const i = voices.indexOf(src);
if (i >= 0) voices.splice(i, 1);
};
}
function omegaForStage() {
const base = [0, 10.4, 19, 33, 54][stage] || 54;
return Math.min(80, base * (stage === 4 ? fury : 1));
}
function rateForOmega(om) {
const natural = MEOW_DUR * om / TAU;
const boost = stage >= 4 ? 1.18 : stage === 3 ? 1.1 : 1;
const cap = 3.15 + (stage >= 4 ? (fury - 1) * 0.55 : 0);
return Math.min(cap, Math.max(1, natural * boost));
}
function triggerSpinMeow() {
meowPulse = 1;
if (!ac || muted || !meowBuf) return;
const rate = rateForOmega(Math.max(omega, targetOmega * 0.72));
spawnVoice(meowBuf, rate, 0.78);
if (stage >= 3) spawnVoice(meowBuf, Math.min(rate * 1.5, 4.2), 0.2);
if (stage >= 4) spawnVoice(meowBuf, Math.max(0.55, rate * 0.5), 0.26);
}
function setMuted(v) {
muted = v;
muteBtn.textContent = muted ? 'sound' : 'mute';
muteBtn.setAttribute('aria-pressed', muted ? 'true' : 'false');
muteBtn.setAttribute('aria-label', muted ? 'Unmute' : 'Mute');
if (master && ac) master.gain.setTargetAtTime(muted ? 0 : 1, ac.currentTime, 0.025);
}
function resizeIfNeeded() {
const dpr = Math.min(2, window.devicePixelRatio || 1);
const w = Math.max(1, Math.round(innerWidth * dpr));
const h = Math.max(1, Math.round(innerHeight * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
ctx.imageSmoothingEnabled = true;
}
}
function applyCamera(now) {
const amp = stage === 2 ? 3.5 : stage === 3 ? 6 : stage >= 4 ? 8 * Math.min(fury, 2) : 0;
const shx = amp ? (Math.random() - 0.5) * amp : 0;
const shy = amp ? (Math.random() - 0.5) * amp : 0;
const s = Math.max(canvas.width / IW, canvas.height / IH);
const ox = (canvas.width - IW * s) / 2 + shx;
const oy = (canvas.height - IH * s) / 2 + shy;
ctx.setTransform(s, 0, 0, s, ox, oy);
}
function drawMeowGlow() {
if (meowPulse < 0.02) return;
const a = meowPulse * (stage >= 4 ? 0.15 : 0.09);
const grd = ctx.createRadialGradient(CATX, 530, 16, CATX, 530, 520);
grd.addColorStop(0, stage >= 4 ? 'rgba(255,150,90,' + a + ')' : 'rgba(255,214,160,' + a + ')');
grd.addColorStop(1, 'rgba(255,214,160,0)');
ctx.fillStyle = grd;
ctx.fillRect(0, 0, IW, IH);
}
function drawIdle(now) {
applyCamera(now);
ctx.drawImage(room, 0, 0);
paintContact(ctx, 1);
if (turning) {
const e = ease(Math.min(1, turnU));
const squeeze = 1 - 0.58 * Math.sin(Math.PI * e);
ctx.save();
ctx.translate(CATX, 530);
ctx.rotate(Math.sin(Math.PI * e) * 0.045);
ctx.scale(squeeze, 1);
ctx.translate(-CATX, -530);
ctx.globalAlpha = 1 - e;
ctx.drawImage(catSide, CAT_DRAW_X, CAT_DRAW_Y);
ctx.globalAlpha = e;
ctx.drawImage(catFront, CAT_DRAW_X, CAT_DRAW_Y);
ctx.restore();
ctx.globalAlpha = 1;
} else {
ctx.drawImage(isBlink(now) ? catShut : catSide, CAT_DRAW_X, CAT_DRAW_Y);
}
drawMeowGlow();
}
function drawCardSpin(now) {
applyCamera(now);
ctx.fillStyle = '#241c18';
ctx.fillRect(0, 0, IW, IH);
const ghosts = stage === 1 ? 2 : 6;
const step = stage === 1 ? 0.07 : 0.15;
const ghostA = stage === 1 ? 0.07 : 0.13;
for (let i = ghosts; i >= 0; i--) {
const a = angle - i * step;
const c = Math.cos(a);
ctx.save();
ctx.translate(CATX, IH / 2);
if (stage === 2) ctx.transform(1, Math.sin(a) * 0.035, Math.sin(a) * 0.07, 1, 0, 0);
const sx = Math.max(0.02, Math.abs(c));
ctx.scale(sx, 0.94 + 0.06 * Math.abs(c));
ctx.globalAlpha = i === 0 ? 1 : ghostA;
if (i === 0 && stage === 2) {
setFilter('drop-shadow(3px 0 0 rgba(255,60,40,0.45)) drop-shadow(-3px 0 0 rgba(40,120,255,0.4))');
}
ctx.drawImage(c >= 0 ? photoFront : photoBack, -CATX, -IH / 2);
ctx.filter = 'none';
ctx.restore();
}
const ec = Math.cos(angle);
if (Math.abs(ec) < 0.1) {
ctx.fillStyle = 'rgba(255,232,200,0.32)';
ctx.fillRect(CATX - 2, 36, 4, IH - 72);
}
if (stage === 2) {
ctx.save();
ctx.globalCompositeOperation = 'screen';
ctx.globalAlpha = 0.07;
ctx.fillStyle = '#ffb060';
ctx.fillRect(0, 0, IW, IH);
ctx.restore();
}
drawMeowGlow();
}
function drawWavy(img, amp, phase) {
const strips = 16;
const sh = IH / strips;
for (let i = 0; i < strips; i++) {
const y = i * sh;
const off = Math.sin(phase * 2 + i * 0.55) * amp;
ctx.drawImage(img, 0, y, IW, sh + 1, -CATX + off, -IH / 2 + y, IW, sh + 1);
}
}
function drawCylinder(now) {
applyCamera(now);
ctx.fillStyle = '#1a1412';
ctx.fillRect(0, 0, IW, IH);
const copies = 6;
const radius = 86;
const list = [];
for (let i = 0; i < copies; i++) {
const a = angle + i * TAU / copies;
list.push({ a: a, c: Math.cos(a), s: Math.sin(a) });
}
list.sort(function (a, b) { return a.c - b.c; });
for (let i = 0; i < list.length; i++) {
const it = list[i];
const depth = (it.c + 1) / 2;
ctx.save();
ctx.translate(CATX + it.s * radius, IH / 2);
ctx.scale(Math.max(0.035, Math.abs(it.c)) * (0.7 + 0.3 * depth), 0.7 + 0.3 * depth);
ctx.globalAlpha = 0.1 + 0.9 * depth;
if (it.c > 0.45) {
setFilter('hue-rotate(' + (Math.sin(now / 900) * 28) + 'deg) saturate(1.28) drop-shadow(5px 0 0 rgba(255,40,60,0.5)) drop-shadow(-5px 0 0 rgba(40,140,255,0.45))');
}
const img = it.c >= 0 ? photoFront : photoBack;
if (it.c > 0.4) drawWavy(img, 11, angle);
else ctx.drawImage(img, -CATX, -IH / 2);
ctx.filter = 'none';
ctx.restore();
}
drawMeowGlow();
}
function drawOrbitItem(t, x, y, depth, alpha) {
ctx.save();
ctx.globalAlpha = Math.max(0, Math.min(1, alpha));
if (t.light) {
const rad = 70 + depth * 90;
const grd = ctx.createRadialGradient(x, y, 4, x, y, rad);
grd.addColorStop(0, 'rgba(255,220,170,0.72)');
grd.addColorStop(1, 'rgba(255,220,170,0)');
ctx.globalCompositeOperation = 'lighter';
ctx.fillStyle = grd;
ctx.beginPath();
ctx.arc(x, y, rad, 0, TAU);
ctx.fill();
} else {
const sc = t.sc * (0.62 + depth * 0.5);
const w = t.img.width * sc;
const h = t.img.height * sc;
ctx.drawImage(t.img, x - w / 2, y - h * 0.85, w, h);
}
ctx.restore();
}
function drawRingCats(frontHalf) {
const nCats = 8 + Math.floor(fury * 2);
const rad = 230 + fury * 16;
for (let i = 0; i < nCats; i++) {
const a = -angle * 1.15 + i * TAU / nCats;
const front = Math.sin(a) > 0;
if (front !== frontHalf) continue;
const x = CATX + Math.cos(a) * rad;
const y = 500 + Math.sin(a) * rad * 0.4;
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle * 0.65 + i);
ctx.globalAlpha = front ? 0.42 : 0.22;
const s = 0.26;
ctx.drawImage(catFront, -SPR_W * s / 2, -SPR_H * s / 2, SPR_W * s, SPR_H * s);
ctx.restore();
}
}
function drawVortex(now) {
applyCamera(now);
if ((now % 7400) > 7160) {
ctx.drawImage(photoFront, 0, 0);
drawMeowGlow();
return;
}
const bg = ctx.createRadialGradient(CATX, 480, 30, CATX, 520, 980);
bg.addColorStop(0, '#3a2a24');
bg.addColorStop(0.5, '#1c1614');
bg.addColorStop(1, '#100e10');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, IW, IH);
ctx.save();
ctx.translate(CATX, 520);
ctx.rotate(Math.sin(now / 1700) * 0.05 * fury);
ctx.translate(-CATX, -520);
ctx.save();
ctx.globalAlpha = 0.26;
ctx.translate(IW / 2, IH / 2);
ctx.rotate(Math.sin(now / 1800) * 0.1);
ctx.scale(1.12, 1.12);
ctx.drawImage(photoFront, -IW / 2, -IH / 2);
ctx.restore();
const spokes = 18;
for (let i = 0; i < spokes; i++) {
const a = angle * 0.42 + i * TAU / spokes;
ctx.save();
ctx.translate(CATX, PAW_Y - 16);
ctx.rotate(a);
ctx.globalAlpha = 0.36;
ctx.drawImage(room, 180, 640, 760, 150, -26, 0, 54, 260 + fury * 24);
ctx.restore();
}
const things = [
{ img: sprLamp, r: 230 + fury * 18, sp: 1.05, ph: 0.4, sc: 0.58 },
{ img: sprPlant, r: 300 + fury * 26, sp: -0.72, ph: 1.7, sc: 0.5 },
{ img: sprFrame, r: 168, sp: 1.65, ph: 2.5, sc: 0.72 },
{ img: sprSofa, r: 360 + fury * 36, sp: 0.4, ph: 3.3, sc: 0.46 },
{ img: sprTable, r: 206, sp: -1.22, ph: 4.6, sc: 0.44 },
{ light: true, r: 268, sp: 0.86, ph: 0.9, sc: 1 }
];
const placed = [];
for (let i = 0; i < things.length; i++) {
const t = things[i];
const a = angle * t.sp + t.ph;
placed.push({
t: t, a: a,
x: CATX + Math.cos(a) * t.r,
y: 575 + Math.sin(a) * t.r * 0.34,
depth: (Math.sin(a) + 1) / 2
});
}
placed.sort(function (a, b) { return a.y - b.y; });
for (let i = 0; i < placed.length; i++) {
const p = placed[i];
const dir = p.t.sp < 0 ? -1 : 1;
for (let k = 5; k >= 1; k--) {
const ta = p.a - k * 0.09 * dir;
drawOrbitItem(p.t, CATX + Math.cos(ta) * p.t.r, 575 + Math.sin(ta) * p.t.r * 0.34, (Math.sin(ta) + 1) / 2, 0.07 * (1 - k / 6));
}
drawOrbitItem(p.t, p.x, p.y, p.depth, 0.28 + p.depth * 0.72);
}
drawRingCats(false);
for (let i = 0; i < furBits.length; i++) {
const bit = furBits[i];
const x = CATX + Math.cos(bit.a) * bit.r;
const y = 510 + Math.sin(bit.a) * bit.r * 0.38;
ctx.strokeStyle = bit.col;
ctx.globalAlpha = 0.65;
ctx.lineWidth = 1.3;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + Math.cos(bit.a) * 11, y + 4);
ctx.stroke();
}
ctx.globalAlpha = 1;
ctx.save();
ctx.translate(CATX, 505);
ctx.rotate(-angle * 0.16);
const ps = 1 + 0.055 * meowPulse;
ctx.scale(ps, ps);
ctx.drawImage(catFront, -SPR_W * 0.34, -SPR_H * 0.36, SPR_W * 0.68, SPR_H * 0.72);
ctx.restore();
drawRingCats(true);
ctx.restore();
drawMeowGlow();
}
function drawVignette() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
const w = canvas.width, h = canvas.height;
const g = ctx.createRadialGradient(w * 0.5, h * 0.45, Math.min(w, h) * 0.28, w * 0.5, h * 0.5, Math.max(w, h) * 0.72);
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(1, 'rgba(0,0,0,0.36)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
}
function drawGrainOverlay() {
if (stage < 4 || !grainPat) return;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.save();
ctx.globalAlpha = 0.1;
ctx.globalCompositeOperation = 'overlay';
ctx.fillStyle = grainPat;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.restore();
}
function drawWorld(now) {
if (stage === 0 || turning) {
drawIdle(now);
return;
}
if (!baked) bakeFront();
if (stage < 3) drawCardSpin(now);
else if (stage === 3) drawCylinder(now);
else drawVortex(now);
}
function isBlink(now) {
const s = (now - t0) / 1000;
if (s < 3.6) return false;
const w = (s - 3.6) % 6.8;
return w < 0.15;
}
function catHit(x, y) {
const dx = (x - CATX) / 250;
const dy = (y - 600) / 270;
return dx * dx + dy * dy < 1;
}
function toInternal(e) {
const r = canvas.getBoundingClientRect();
const x = (e.clientX - r.left) * (canvas.width / r.width);
const y = (e.clientY - r.top) * (canvas.height / r.height);
const s = Math.max(canvas.width / IW, canvas.height / IH);
const ox = (canvas.width - IW * s) / 2;
const oy = (canvas.height - IH * s) / 2;
return { x: (x - ox) / s, y: (y - oy) / s };
}
function escalate(now) {
if (stage === 0) {
stage = 1;
turning = true;
turnStart = now;
turnU = 0;
targetOmega = omegaForStage();
ensureAudio();
if (ac && ac.state === 'suspended') ac.resume();
if (!muted && meowBuf) spawnVoice(meowBuf, 1.15, 0.76);
meowPulse = 0.65;
return;
}
if (stage < 4) stage += 1;
else fury = Math.min(3, fury + 0.45);
targetOmega = omegaForStage();
}
function onDown(e) {
if (e.button && e.button !== 0) return;
const p = toInternal(e);
if (stage === 0 && !catHit(p.x, p.y)) return;
escalate(performance.now());
}
function onMove(e) {
if (stage > 0) { canvas.style.cursor = 'pointer'; return; }
const p = toInternal(e);
canvas.style.cursor = catHit(p.x, p.y) ? 'pointer' : 'default';
}
function update(now, dt) {
if (turning) {
turnU = Math.min(1, (now - turnStart) / TURN_MS);
if (turnU >= 1) {
turning = false;
spinning = true;
angle = 0;
bakeFront();
triggerSpinMeow();
}
}
if (spinning) {
targetOmega = omegaForStage();
omega += (targetOmega - omega) * Math.min(1, dt * 2.6);
const prev = angle;
angle += omega * dt;
const crossed = Math.floor(angle / TAU) - Math.floor(prev / TAU);
for (let i = 0; i < crossed && i < 4; i++) triggerSpinMeow();
}
if (stage >= 4) {
for (let i = 0; i < furBits.length; i++) furBits[i].a += furBits[i].sp * omega * dt * 0.07;
}
meowPulse *= Math.exp(-dt * 6.5);
}
function frame(now) {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
resizeIfNeeded();
update(now, dt);
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.filter = 'none';
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = '#161310';
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawWorld(now);
drawVignette();
drawGrainOverlay();
requestAnimationFrame(frame);
}
function init() {
resizeIfNeeded();
buildImages();
grainPat = ctx.createPattern(grainCanvas, 'repeat');
muteBtn.addEventListener('click', function (e) {
e.stopPropagation();
e.preventDefault();
setMuted(!muted);
});
canvas.addEventListener('pointerdown', onDown);
canvas.addEventListener('pointermove', onMove);
canvas.addEventListener('contextmenu', function (e) { e.preventDefault(); });
t0 = performance.now();
last = t0;
requestAnimationFrame(frame);
}
init();
</script>
</body>
</html>