27:18Gemini 4 Hydrogen: Cheaper Tokens, More Expensive Answers?
Nov 13, 2026 · YouTube
18:40ERNIE 5.5 Flash Preview vs GLM 5.3 Flash: Baidu's new model is very impressive!
Nov 11, 2026 · YouTube
36:22Fable 5.2 robbing me blind, plus: Nex AGI's N2.6 vs GLM 5.3 Flash
Nov 11, 2026 · YouTube
14:03Gemini 4 Carbon: Can it handle my challenges? (First look with one-shot coding tests)
Nov 10, 2026 · YouTube
47:51Ranking major U.S. Open-Weights Models. (Where does Ripple 65B fit in?)
Nov 6, 2026 · YouTube
10:57"Crouton Alpha": the latest super-efficient coding model by Meta?
Nov 3, 2026 · YouTube
38:58Testing Particle-1: a post-trained DeepSeek V4.1 Flash specializing in coding and efficiency
Oct 28, 2026 · YouTube
29:55Kimi K3.1 Pro Looked Incredible. Then I Kept Testing.
Oct 26, 2026 · YouTube
31:28Is this company reselling GPT Sol 6.1 at a loss (for non-agentic/text-only)?🤔 Is this sustainable?
Oct 20, 2026 · YouTube
17:58Nemotron 3.5 Super VL vs Opus 5.5: Testing NVIDIA’s new coding juggernaut
Oct 15, 2026 · YouTube
16:04Testing a *massive* new open-weights model 🤯: Muse Spark Open (vs. Nemotron, Opus 5.5, and more)
Oct 15, 2026 · YouTube
16:43Gemini 4 Argon: Great 🐈 Animation, but a broken escalator. (Coding challenge vs Opus 5.5, Sol 6.1)
Oct 13, 2026 · YouTube
16:37Meta's new "Muse Wonder" AI is impressive... but can it compete with Anthropic's models?
Oct 12, 2026 · YouTube
11:12WOW... Haiku 5.5 is very impressive. RIP OpenAI?
Oct 7, 2026 · YouTube
22:47Mistral Large 4 Is Cheap. But Is It Good Enough to Compete?
Oct 6, 2026 · YouTube
22:02Is Pareto 26.10 Preview Just Routing to GPT Sol? I Tested It Against Sol 6.1 Pro
Oct 1, 2026 · YouTube
11:22I Tested Sonnet 5.5 Against Opus 5.5. Is It Actually Cheaper?
Sep 28, 2026 · YouTube
19:21I Tested the Stealth "Space Bunny Alpha" Model Before It Went Offline
Sep 23, 2026 · YouTube
10:58So... how much better is Claude Opus 5.5 vs. GPT-6 Sol, really?
Sep 23, 2026 · YouTube
9:38Claude Opus 5.5... the BEST new model for coding!! 🤩
Sep 22, 2026 · YouTube
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 that lifts off the floor and spins in the air when you click it.
Make this the Oiia Cat — the spinning cat meme, the one whose loop is written out as "oiia oiia oiiaiooooiai". Match the original's look and feel, not a generic rotation:
Build toward what that meme feels like at full speed: too fast, too loud, and funnier every time it goes further.
data: URIs are fine.localStorage, sessionStorage, cookies or indexedDB — sandboxed frame, they throw.html block.The single fenced html block.
Five bullets.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>Living Room</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #141210;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
user-select: none;
-webkit-user-select: none;
}
canvas#stage {
display: block;
width: 100%;
height: 100%;
cursor: default;
}
#controls {
position: fixed;
top: 14px;
right: 14px;
display: flex;
gap: 8px;
align-items: center;
z-index: 10;
}
.pill {
appearance: none;
border: 1px solid rgba(255, 248, 236, 0.18);
background: rgba(24, 20, 17, 0.42);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: rgba(255, 246, 234, 0.85);
font-size: 12px;
letter-spacing: 0.04em;
padding: 6px 11px;
border-radius: 999px;
cursor: pointer;
transition: background 0.2s, opacity 0.3s, transform 0.15s;
opacity: 0.65;
}
.pill:hover {
background: rgba(24, 20, 17, 0.72);
opacity: 1;
}
.pill:active { transform: scale(0.96); }
#stageBadge {
opacity: 0;
pointer-events: none;
font-weight: 600;
letter-spacing: 0.08em;
font-size: 11px;
}
#stageBadge.visible { opacity: 0.88; }
#hint {
position: fixed;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
color: rgba(255, 248, 236, 0.62);
font-size: 12px;
letter-spacing: 0.12em;
text-transform: lowercase;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.55);
pointer-events: none;
opacity: 0;
transition: opacity 0.8s ease;
z-index: 10;
}
#hint.visible { opacity: 1; }
</style>
</head>
<body>
<canvas id="stage"></canvas>
<div id="controls">
<div id="stageBadge" class="pill"></div>
<button id="muteBtn" class="pill" type="button" aria-label="Toggle sound">Sound: Off</button>
</div>
<div id="hint">click the cat</div>
<script>
(() => {
'use strict';
const canvas = document.getElementById('stage');
const ctx = canvas.getContext('2d', { alpha: false });
const muteBtn = document.getElementById('muteBtn');
const stageBadge = document.getElementById('stageBadge');
const hintEl = document.getElementById('hint');
let W = 0, H = 0, DPR = 1;
let idleTime = 0;
let stage = 0; // 0: quiet room, 1..4: escalating Oiia stages
let stageTime = 0;
let lastTs = 0;
let hasInteracted = false;
// Deterministic PRNG for procedural textures & fur
function mulberry32(seed) {
let a = seed >>> 0;
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 clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }
function lerp(a, b, t) { return a + (b - a) * t; }
function smoothstep(e0, e1, x) {
const t = clamp((x - e0) / (e1 - e0), 0, 1);
return t * t * (3 - 2 * t);
}
// =========================================================================
// 1. PROCEDURAL LIVING ROOM ASSETS (Photorealistic Layered Staging)
// =========================================================================
const room = {
bg: null,
horizonY: 0,
floorTopY: 0,
catBaseX: 0,
catBaseY: 0,
catScale: 1,
props: {}
};
function makeOffscreen(w, h) {
const c = document.createElement('canvas');
c.width = Math.max(1, Math.round(w));
c.height = Math.max(1, Math.round(h));
return c;
}
function buildRoomAssets() {
const w = W;
const h = H;
const rng = mulberry32(133742);
const horizonY = h * 0.56;
room.horizonY = horizonY;
room.floorTopY = horizonY;
room.catBaseX = w * 0.51;
room.catBaseY = h * 0.78;
room.catScale = Math.min(w * 0.24, h * 0.34);
// --- A. ROOM SHELL (Walls, Window, Baseboard, Hardwood Floor) ---
const bg = makeOffscreen(w, h);
const g = bg.getContext('2d');
const leftWallX = w * 0.14;
const rightWallX = w * 0.91;
// Back plaster wall (warm sunlit limestone/greige)
const wallGrad = g.createLinearGradient(0, 0, w, horizonY);
wallGrad.addColorStop(0, '#ddd2c1');
wallGrad.addColorStop(0.35, '#cfc1ae');
wallGrad.addColorStop(0.75, '#b8a995');
wallGrad.addColorStop(1, '#9d8f7d');
g.fillStyle = wallGrad;
g.fillRect(0, 0, w, horizonY);
// Subtle plaster roller texture
for (let i = 0; i < 4200; i++) {
const px = rng() * w;
const py = rng() * horizonY;
const r = 1 + rng() * 3.5;
g.fillStyle = rng() > 0.5 ? 'rgba(255,248,236,0.018)' : 'rgba(60,48,36,0.02)';
g.fillRect(px, py, r, r);
}
// Left angled window wall
g.save();
g.beginPath();
g.moveTo(0, 0);
g.lineTo(leftWallX, h * 0.04);
g.lineTo(leftWallX, horizonY);
g.lineTo(0, horizonY + h * 0.12);
g.closePath();
const lwGrad = g.createLinearGradient(0, 0, leftWallX, 0);
lwGrad.addColorStop(0, '#eee5d8');
lwGrad.addColorStop(1, '#c9baa5');
g.fillStyle = lwGrad;
g.fill();
// Corner crease AO
const aoLeft = g.createLinearGradient(leftWallX - 24, 0, leftWallX + 18, 0);
aoLeft.addColorStop(0, 'rgba(60,46,35,0)');
aoLeft.addColorStop(0.5, 'rgba(55,42,31,0.18)');
aoLeft.addColorStop(1, 'rgba(55,42,31,0)');
g.fillStyle = aoLeft;
g.fillRect(leftWallX - 24, 0, 42, horizonY);
// Window cutout & sheer daylight
const wx1 = w * 0.015, wx2 = leftWallX * 0.84;
const wy1 = h * 0.06, wy2 = horizonY * 0.83;
const wy1R = h * 0.085, wy2R = horizonY * 0.82;
g.beginPath();
g.moveTo(wx1, wy1);
g.lineTo(wx2, wy1R);
g.lineTo(wx2, wy2R);
g.lineTo(wx1, wy2);
g.closePath();
const winSky = g.createLinearGradient(wx1, wy1, wx2, wy2R);
winSky.addColorStop(0, '#fffdf7');
winSky.addColorStop(0.45, '#fff6e3');
winSky.addColorStop(1, '#f3e5cb');
g.fillStyle = winSky;
g.fill();
// Window frame & muntin bars
g.strokeStyle = '#d6cbb9';
g.lineWidth = Math.max(3, w * 0.005);
g.stroke();
g.beginPath();
g.moveTo((wx1 + wx2) * 0.5, (wy1 + wy1R) * 0.5);
g.lineTo((wx1 + wx2) * 0.5, (wy2 + wy2R) * 0.5);
g.moveTo(wx1, (wy1 + wy2) * 0.48);
g.lineTo(wx2, (wy1R + wy2R) * 0.48);
g.strokeStyle = '#cabda8';
g.lineWidth = Math.max(2, w * 0.0035);
g.stroke();
// Sheer curtain folds softly draping the right side of the window
for (let f = 0; f < 6; f++) {
const fx = lerp(wx2 * 0.65, wx2 + 12, f / 5);
const cg = g.createLinearGradient(fx - 10, 0, fx + 10, 0);
cg.addColorStop(0, 'rgba(255,250,242,0.08)');
cg.addColorStop(0.5, 'rgba(255,250,242,0.42)');
cg.addColorStop(1, 'rgba(210,196,178,0.16)');
g.fillStyle = cg;
g.fillRect(fx - 10, wy1R - 8, 20, wy2R - wy1R + 28);
}
g.restore();
// Right wall sliver for enclosure depth
g.beginPath();
g.moveTo(rightWallX, h * 0.03);
g.lineTo(w, 0);
g.lineTo(w, horizonY + h * 0.09);
g.lineTo(rightWallX, horizonY);
g.closePath();
const rwGrad = g.createLinearGradient(rightWallX, 0, w, 0);
rwGrad.addColorStop(0, '#968775');
rwGrad.addColorStop(1, '#7c6e5e');
g.fillStyle = rwGrad;
g.fill();
// Ceiling & crown shadow
const ceilGrad = g.createLinearGradient(0, 0, 0, h * 0.05);
ceilGrad.addColorStop(0, '#d8cebf');
ceilGrad.addColorStop(1, '#c3b6a4');
g.fillStyle = ceilGrad;
g.beginPath();
g.moveTo(0, 0);
g.lineTo(w, 0);
g.lineTo(rightWallX, h * 0.03);
g.lineTo(leftWallX, h * 0.04);
g.closePath();
g.fill();
// Baseboard along back wall
const bbH = Math.max(10, h * 0.026);
const bbGrad = g.createLinearGradient(0, horizonY - bbH, 0, horizonY);
bbGrad.addColorStop(0, '#ede6da');
bbGrad.addColorStop(0.25, '#dcd2c2');
bbGrad.addColorStop(1, '#bfb3a1');
g.fillStyle = bbGrad;
g.fillRect(leftWallX, horizonY - bbH, rightWallX - leftWallX, bbH);
g.fillStyle = 'rgba(255,255,255,0.45)';
g.fillRect(leftWallX, horizonY - bbH, rightWallX - leftWallX, 1.5);
// Hardwood floor in 1-point perspective
const floorGrad = g.createLinearGradient(0, horizonY, 0, h);
floorGrad.addColorStop(0, '#8d6c4d');
floorGrad.addColorStop(0.35, '#9d7752');
floorGrad.addColorStop(1, '#6c4d33');
g.fillStyle = floorGrad;
g.fillRect(0, horizonY, w, h - horizonY);
// Perspective oak planks
const vpX = w * 0.50;
const vpY = horizonY - h * 0.24;
const plankCount = 26;
const floorH = h - horizonY;
// Horizontal plank course rows (foreshortened)
const rowYs = [horizonY];
for (let r = 1; r <= 12; r++) {
const t = Math.pow(r / 12, 1.75);
rowYs.push(horizonY + t * floorH);
}
for (let r = 0; r < rowYs.length - 1; r++) {
const y0 = rowYs[r];
const y1 = rowYs[r + 1];
const t0 = (y0 - vpY) / (h - vpY);
const t1 = (y1 - vpY) / (h - vpY);
for (let p = -plankCount; p <= plankCount; p++) {
const bottomX0 = vpX + (p - 0.5) * (w * 0.085);
const bottomX1 = vpX + (p + 0.5) * (w * 0.085);
const x00 = lerp(vpX, bottomX0, t0);
const x01 = lerp(vpX, bottomX1, t0);
const x10 = lerp(vpX, bottomX0, t1);
const x11 = lerp(vpX, bottomX1, t1);
if (x11 < -40 || x10 > w + 40) continue;
const toneShift = (rng() - 0.5) * 22;
const rCol = clamp(Math.round(145 + toneShift), 90, 190);
const gCol = clamp(Math.round(107 + toneShift * 0.78), 65, 148);
const bCol = clamp(Math.round(72 + toneShift * 0.55), 40, 108);
g.beginPath();
g.moveTo(x00, y0);
g.lineTo(x01, y0);
g.lineTo(x11, y1);
g.lineTo(x10, y1);
g.closePath();
g.fillStyle = `rgb(${rCol},${gCol},${bCol})`;
g.fill();
// Subtle plank grain lines
g.strokeStyle = 'rgba(55, 36, 20, 0.14)';
g.lineWidth = 1;
g.stroke();
if (rng() > 0.35) {
g.beginPath();
const gx0 = lerp(x00, x01, 0.3 + rng() * 0.4);
const gx1 = lerp(x10, x11, 0.3 + rng() * 0.4);
g.moveTo(gx0, y0 + 1);
g.lineTo(gx1, y1 - 1);
g.strokeStyle = 'rgba(72, 46, 24, 0.1)';
g.stroke();
}
}
}
// Window sun patch & specular sheen across the hardwood floor
g.save();
g.beginPath();
g.moveTo(leftWallX * 0.82, horizonY);
g.lineTo(w * 0.48, horizonY + floorH * 0.18);
g.lineTo(w * 0.68, h);
g.lineTo(w * 0.05, h);
g.closePath();
const sunFloor = g.createLinearGradient(0, horizonY, w * 0.6, h);
sunFloor.addColorStop(0, 'rgba(255, 244, 214, 0.34)');
sunFloor.addColorStop(0.55, 'rgba(255, 236, 194, 0.16)');
sunFloor.addColorStop(1, 'rgba(255, 232, 185, 0.02)');
g.fillStyle = sunFloor;
g.fill();
g.restore();
// Baseboard floor shadow
const bbFloorAO = g.createLinearGradient(0, horizonY, 0, horizonY + 26);
bbFloorAO.addColorStop(0, 'rgba(35, 24, 15, 0.42)');
bbFloorAO.addColorStop(1, 'rgba(35, 24, 15, 0)');
g.fillStyle = bbFloorAO;
g.fillRect(0, horizonY, w, 26);
room.bg = bg;
// --- B. WOVEN AREA RUG ---
const rugW = w * 0.56;
const rugH = floorH * 0.52;
const rugC = makeOffscreen(rugW + 40, rugH + 30);
const rg = rugC.getContext('2d');
const rx0 = 20, ry0 = 12;
rg.save();
rg.beginPath();
rg.moveTo(rx0 + rugW * 0.10, ry0);
rg.lineTo(rx0 + rugW * 0.90, ry0);
rg.lineTo(rx0 + rugW, ry0 + rugH);
rg.lineTo(rx0, ry0 + rugH);
rg.closePath();
const rugGrad = rg.createLinearGradient(0, ry0, rugW, ry0 + rugH);
rugGrad.addColorStop(0, '#e3d9c8');
rugGrad.addColorStop(0.5, '#d7cbb7');
rugGrad.addColorStop(1, '#c3b59f');
rg.fillStyle = rugGrad;
rg.fill();
rg.clip();
// Subtle geometric border & woven wool texture
rg.strokeStyle = 'rgba(138, 112, 88, 0.25)';
rg.lineWidth = Math.max(3, rugH * 0.04);
rg.strokeRect(rx0 + rugW * 0.08, ry0 + rugH * 0.12, rugW * 0.84, rugH * 0.76);
for (let i = 0; i < 3800; i++) {
const tx = rx0 + rng() * rugW;
const ty = ry0 + rng() * rugH;
rg.fillStyle = rng() > 0.5 ? 'rgba(255,251,245,0.16)' : 'rgba(110,92,74,0.12)';
rg.fillRect(tx, ty, 3, 1.5);
}
rg.restore();
// Fringe tassels on left and right edges
rg.strokeStyle = '#dcd2c0';
rg.lineWidth = 1.5;
for (let i = 0; i < 24; i++) {
const t = i / 23;
const lx = lerp(rx0 + rugW * 0.10, rx0, t);
const ly = lerp(ry0, ry0 + rugH, t);
rg.beginPath();
rg.moveTo(lx, ly);
rg.lineTo(lx - 10 - rng() * 5, ly + 1);
rg.stroke();
const rx = lerp(rx0 + rugW * 0.90, rx0 + rugW, t);
rg.beginPath();
rg.moveTo(rx, ly);
rg.lineTo(rx + 10 + rng() * 5, ly + 1);
rg.stroke();
}
room.props.rug = {
canvas: rugC,
x: w * 0.50,
y: horizonY + floorH * 0.49
};
// --- C. MID-CENTURY SOFA ---
const sofaW = w * 0.44;
const sofaH = h * 0.27;
const sofaC = makeOffscreen(sofaW + 30, sofaH + 30);
const sg = sofaC.getContext('2d');
const sx = 15, sy = 15;
// Tapered walnut legs
const legCols = [sx + sofaW * 0.09, sx + sofaW * 0.89];
for (const lx of legCols) {
sg.fillStyle = '#4a301e';
sg.beginPath();
sg.moveTo(lx - 6, sy + sofaH * 0.78);
sg.lineTo(lx + 6, sy + sofaH * 0.78);
sg.lineTo(lx + 3, sy + sofaH);
sg.lineTo(lx - 3, sy + sofaH);
sg.closePath();
sg.fill();
sg.fillStyle = '#b9924b'; // brass ferrule
sg.fillRect(lx - 3, sy + sofaH - 7, 6, 7);
}
// Main sofa frame
function roundRect(c2, x, y, rw, rh, rad) {
c2.beginPath();
c2.moveTo(x + rad, y);
c2.lineTo(x + rw - rad, y);
c2.quadraticCurveTo(x + rw, y, x + rw, y + rad);
c2.lineTo(x + rw, y + rh - rad);
c2.quadraticCurveTo(x + rw, y + rh, x + rw - rad, y + rh);
c2.lineTo(x + rad, y + rh);
c2.quadraticCurveTo(x, y + rh, x, y + rh - rad);
c2.lineTo(x, y + rad);
c2.quadraticCurveTo(x, y, x + rad, y);
c2.closePath();
}
// Back cushions (3 panels)
for (let i = 0; i < 3; i++) {
const cw = (sofaW * 0.84) / 3;
const cx = sx + sofaW * 0.08 + i * cw;
const cy = sy + sofaH * 0.04;
const ch = sofaH * 0.48;
roundRect(sg, cx + 2, cy, cw - 4, ch, 14);
const cg = sg.createLinearGradient(cx, cy, cx + cw, cy + ch);
cg.addColorStop(0, '#8d8b84');
cg.addColorStop(0.55, '#77756f');
cg.addColorStop(1, '#5e5d58');
sg.fillStyle = cg;
sg.fill();
sg.strokeStyle = 'rgba(255,250,242,0.16)';
sg.lineWidth = 1.5;
sg.stroke();
}
// Seat cushions
for (let i = 0; i < 3; i++) {
const cw = (sofaW * 0.84) / 3;
const cx = sx + sofaW * 0.08 + i * cw;
const cy = sy + sofaH * 0.44;
const ch = sofaH * 0.24;
roundRect(sg, cx + 1, cy, cw - 2, ch, 10);
const cg = sg.createLinearGradient(cx, cy, cx, cy + ch);
cg.addColorStop(0, '#96948c');
cg.addColorStop(0.45, '#7e7c75');
cg.addColorStop(1, '#615f59');
sg.fillStyle = cg;
sg.fill();
}
// Lower deck & armrests
roundRect(sg, sx + sofaW * 0.05, sy + sofaH * 0.63, sofaW * 0.90, sofaH * 0.18, 8);
const deckG = sg.createLinearGradient(0, sy + sofaH * 0.63, 0, sy + sofaH * 0.81);
deckG.addColorStop(0, '#706e67');
deckG.addColorStop(1, '#4f4e48');
sg.fillStyle = deckG;
sg.fill();
// Left & right armrests
for (let side = 0; side < 2; side++) {
const ax = side === 0 ? sx : sx + sofaW * 0.89;
roundRect(sg, ax, sy + sofaH * 0.28, sofaW * 0.11, sofaH * 0.50, 12);
const ag = sg.createLinearGradient(ax, 0, ax + sofaW * 0.11, 0);
ag.addColorStop(0, side === 0 ? '#a19f97' : '#7b7972');
ag.addColorStop(1, side === 0 ? '#78766f' : '#575650');
sg.fillStyle = ag;
sg.fill();
}
// Linen fabric grain over sofa
sg.fillStyle = 'rgba(255,255,255,0.03)';
for (let i = 0; i < 2400; i++) {
sg.fillRect(sx + rng() * sofaW, sy + rng() * sofaH * 0.78, 2, 1);
}
room.props.sofa = {
canvas: sofaC,
x: w * 0.54,
y: horizonY - sofaH * 0.16
};
// --- D. TERRACOTTA THROW PILLOW (separate movable prop!) ---
const pilW = sofaW * 0.17;
const pilH = sofaH * 0.34;
const pilC = makeOffscreen(pilW + 16, pilH + 16);
const pg = pilC.getContext('2d');
pg.save();
pg.translate(pilW * 0.5 + 8, pilH * 0.5 + 8);
pg.rotate(-0.18);
roundRect(pg, -pilW * 0.45, -pilH * 0.45, pilW * 0.9, pilH * 0.9, 12);
const pGrad = pg.createLinearGradient(-pilW * 0.4, -pilH * 0.4, pilW * 0.4, pilH * 0.4);
pGrad.addColorStop(0, '#d27c53');
pGrad.addColorStop(0.55, '#b6613b');
pGrad.addColorStop(1, '#8b4426');
pg.fillStyle = pGrad;
pg.fill();
pg.strokeStyle = 'rgba(255,232,214,0.22)';
pg.lineWidth = 1.5;
pg.stroke();
pg.restore();
room.props.pillow = {
canvas: pilC,
x: w * 0.37,
y: horizonY - sofaH * 0.18
};
// --- E. FRAMED ARTWORK ON BACK WALL ---
const frW = w * 0.135;
const frH = h * 0.21;
const frC = makeOffscreen(frW + 20, frH + 20);
const fg = frC.getContext('2d');
const fx = 10, fy = 10;
// Oak frame
fg.fillStyle = '#8b623f';
fg.fillRect(fx, fy, frW, frH);
fg.strokeStyle = '#b4875d';
fg.lineWidth = 2;
fg.strokeRect(fx + 1, fy + 1, frW - 2, frH - 2);
// Warm cream mat
const pad = Math.max(7, frW * 0.10);
fg.fillStyle = '#f2ece1';
fg.fillRect(fx + pad * 0.45, fy + pad * 0.45, frW - pad * 0.9, frH - pad * 0.9);
// Minimalist botanical print inside
fg.fillStyle = '#e5ded0';
fg.fillRect(fx + pad, fy + pad, frW - pad * 2, frH - pad * 2);
fg.fillStyle = '#c47b54';
fg.beginPath();
fg.arc(fx + frW * 0.56, fy + frH * 0.42, frW * 0.18, 0, Math.PI * 2);
fg.fill();
fg.fillStyle = '#475c48';
fg.beginPath();
fg.ellipse(fx + frW * 0.44, fy + frH * 0.56, frW * 0.14, frH * 0.21, -0.35, 0, Math.PI * 2);
fg.fill();
// Diagonal window glass reflection
const glassG = fg.createLinearGradient(fx, fy, fx + frW, fy + frH);
glassG.addColorStop(0, 'rgba(255,252,244,0.22)');
glassG.addColorStop(0.45, 'rgba(255,252,244,0.03)');
glassG.addColorStop(1, 'rgba(255,252,244,0)');
fg.fillStyle = glassG;
fg.fillRect(fx + 4, fy + 4, frW - 8, frH - 8);
room.props.frame = {
canvas: frC,
x: w * 0.54,
y: h * 0.25
};
// --- F. POTTED MONSTERA PLANT ---
const plW = w * 0.16;
const plH = h * 0.34;
const plC = makeOffscreen(plW + 20, plH + 20);
const plg = plC.getContext('2d');
const px0 = plW * 0.5 + 10, py0 = plH + 6;
// Ceramic pot
const potH = plH * 0.29;
const potW = plW * 0.44;
plg.beginPath();
plg.moveTo(px0 - potW * 0.5, py0 - potH);
plg.lineTo(px0 + potW * 0.5, py0 - potH);
plg.lineTo(px0 + potW * 0.38, py0);
plg.lineTo(px0 - potW * 0.38, py0);
plg.closePath();
const potG = plg.createLinearGradient(px0 - potW * 0.5, 0, px0 + potW * 0.5, 0);
potG.addColorStop(0, '#e39a76');
potG.addColorStop(0.5, '#c8734d');
potG.addColorStop(1, '#8c492c');
plg.fillStyle = potG;
plg.fill();
// Stems & broad glossy monstera leaves
const leaves = [
{ ax: -0.32, ay: -0.62, ang: -0.75, sc: 0.95, col: '#4a6e43' },
{ ax: 0.30, ay: -0.58, ang: 0.72, sc: 0.90, col: '#3a5935' },
{ ax: -0.18, ay: -0.84, ang: -0.28, sc: 1.05, col: '#567c4d' },
{ ax: 0.16, ay: -0.80, ang: 0.35, sc: 0.92, col: '#42643b' },
{ ax: 0.0, ay: -0.94, ang: 0.05, sc: 0.85, col: '#5c8553' },
{ ax: -0.40, ay: -0.42, ang: -1.10, sc: 0.75, col: '#486b42' }
];
for (const lf of leaves) {
const lx = px0 + lf.ax * plW;
const ly = py0 + lf.ay * plH;
plg.strokeStyle = '#3b5734';
plg.lineWidth = 3.5;
plg.beginPath();
plg.moveTo(px0, py0 - potH * 0.95);
plg.quadraticCurveTo(px0 + lf.ax * plW * 0.3, ly + 25, lx, ly);
plg.stroke();
plg.save();
plg.translate(lx, ly);
plg.rotate(lf.ang);
const lw = plW * 0.24 * lf.sc;
const lh = plH * 0.21 * lf.sc;
plg.beginPath();
plg.moveTo(0, lh * 0.45);
plg.bezierCurveTo(-lw, lh * 0.3, -lw * 0.95, -lh * 0.6, 0, -lh * 0.85);
plg.bezierCurveTo(lw * 0.95, -lh * 0.6, lw, lh * 0.3, 0, lh * 0.45);
plg.closePath();
const lGrad = plg.createLinearGradient(-lw, -lh, lw, lh);
lGrad.addColorStop(0, '#6a945c');
lGrad.addColorStop(0.5, lf.col);
lGrad.addColorStop(1, '#263b23');
plg.fillStyle = lGrad;
plg.fill();
// Midrib highlight
plg.strokeStyle = 'rgba(180,215,155,0.28)';
plg.lineWidth = 1.5;
plg.beginPath();
plg.moveTo(0, lh * 0.35);
plg.lineTo(0, -lh * 0.72);
plg.stroke();
plg.restore();
}
room.props.plant = {
canvas: plC,
x: w * 0.23,
y: horizonY - plH * 0.22
};
// --- G. FLOOR LAMP ON RIGHT ---
const lpW = w * 0.11;
const lpH = h * 0.47;
const lpC = makeOffscreen(lpW + 20, lpH + 20);
const lpg = lpC.getContext('2d');
const lpx = lpW * 0.5 + 10, lpy = lpH + 10;
// Brass pole & base
lpg.fillStyle = '#755c33';
lpg.beginPath();
lpg.ellipse(lpx, lpy - 6, lpW * 0.28, 6, 0, 0, Math.PI * 2);
lpg.fill();
const poleG = lpg.createLinearGradient(lpx - 3, 0, lpx + 3, 0);
poleG.addColorStop(0, '#c8a666');
poleG.addColorStop(1, '#6b5328');
lpg.fillStyle = poleG;
lpg.fillRect(lpx - 2.5, lpy - lpH * 0.85, 5, lpH * 0.84);
// Warm linen shade
const shH = lpH * 0.21;
lpg.beginPath();
lpg.moveTo(lpx - lpW * 0.28, lpy - lpH * 0.96);
lpg.lineTo(lpx + lpW * 0.28, lpy - lpH * 0.96);
lpg.lineTo(lpx + lpW * 0.39, lpy - lpH * 0.96 + shH);
lpg.lineTo(lpx - lpW * 0.39, lpy - lpH * 0.96 + shH);
lpg.closePath();
const shG = lpg.createLinearGradient(lpx - lpW * 0.35, 0, lpx + lpW * 0.35, 0);
shG.addColorStop(0, '#f4ebd9');
shG.addColorStop(0.6, '#ded0ba');
shG.addColorStop(1, '#b6a58e');
lpg.fillStyle = shG;
lpg.fill();
room.props.lamp = {
canvas: lpC,
x: w * 0.84,
y: horizonY - lpH * 0.22
};
}
// =========================================================================
// 2. 3D PROCEDURAL OIIA CAT (True 3D Furred Anatomy + Face + Spin Blur)
// =========================================================================
// Local coordinates: y = 0 (floor) .. 0.95 (ear tips), +z = forward, +x = right
const furTufts = [];
function initCatFur() {
furTufts.length = 0;
const rng = mulberry32(90210);
function coatColor(zone, x, y, z) {
// Cream-and-white Ethel / Oiia cat markings
const tabbyWave = Math.sin(x * 26 + y * 14) * 0.5 + Math.cos(z * 22 - y * 10) * 0.5;
const isGingerPatch = (() => {
if (zone === 'earBack') return true;
if (zone === 'tail') return Math.sin(y * 16 + x * 24) > -0.35;
if (zone === 'head') {
// Inverted-V white blaze up the forehead between eyes
const blazeHalfW = 0.03 + clamp(0.76 - y, 0, 0.2) * 0.42;
if (z > 0.09 && Math.abs(x) < blazeHalfW && y < 0.78) return false;
if (z > 0.13 && y < 0.67) return false; // white muzzle & chin
return y > 0.67 || z < 0.05;
}
if (zone === 'torso') {
// White chest bib & belly, cream saddle/shoulders/back
if (z > 0.06 && Math.abs(x) < 0.13) return false;
if (z > 0.12) return false;
return z < 0.06 && y > 0.08;
}
if (zone === 'paw') return false;
return false;
})();
if (!isGingerPatch) {
// Warm creamy white fur
const v = 236 + Math.round((rng() - 0.5) * 14);
return [v, v - 5, v - 14];
}
// Cream-ginger / dilute apricot tabby coat
const stripe = tabbyWave > 0.25 ? -18 : tabbyWave < -0.35 ? 10 : 0;
const r = clamp(216 + stripe + Math.round((rng() - 0.5) * 16), 160, 245);
const g = clamp(166 + stripe * 0.85 + Math.round((rng() - 0.5) * 14), 110, 210);
const b = clamp(114 + stripe * 0.7 + Math.round((rng() - 0.5) * 12), 70, 165);
return [r, g, b];
}
function addEllipsoidTufts(count, cx, cy, cz, rx, ry, rz, zone, flowFn) {
for (let i = 0; i < count; i++) {
// Uniform sphere point
const u = rng() * 2 - 1;
const phi = rng() * Math.PI * 2;
const s = Math.sqrt(1 - u * u);
const nx0 = s * Math.cos(phi);
const ny0 = u;
const nz0 = s * Math.sin(phi);
const x = cx + nx0 * rx;
const y = cy + ny0 * ry;
const z = cz + nz0 * rz;
if (y < 0.015) continue;
const nLen = Math.hypot(nx0 / rx, ny0 / ry, nz0 / rz) || 1;
const nx = (nx0 / rx) / nLen;
const ny = (ny0 / ry) / nLen;
const nz = (nz0 / rz) / nLen;
const flow = flowFn ? flowFn(x, y, z, nx, ny, nz) : [0, -1, 0];
const col = coatColor(zone, x, y, z);
furTufts.push({
x, y, z,
nx, ny, nz,
fx: flow[0], fy: flow[1], fz: flow[2],
col,
len: 0.028 + rng() * 0.022,
wid: 0.014 + rng() * 0.010,
isFace: zone === 'head' && nz > 0.35
});
}
}
// Torso (plump sitting loaf)
addEllipsoidTufts(480, 0, 0.27, -0.02, 0.23, 0.27, 0.22, 'torso', (x, y, z) => [x * 0.35, -0.9, z * 0.25]);
// Chest ruff
addEllipsoidTufts(140, 0, 0.43, 0.11, 0.16, 0.15, 0.13, 'torso', (x) => [x * 0.4, -0.92, 0.2]);
// Left & right haunches
addEllipsoidTufts(130, -0.17, 0.15, -0.05, 0.11, 0.14, 0.15, 'torso', () => [-0.25, -0.95, 0]);
addEllipsoidTufts(130, 0.17, 0.15, -0.05, 0.11, 0.14, 0.15, 'torso', () => [ 0.25, -0.95, 0]);
// Front legs & paws
addEllipsoidTufts(70, -0.085, 0.19, 0.155, 0.058, 0.17, 0.065, 'paw', () => [0, -1, 0.1]);
addEllipsoidTufts(70, 0.085, 0.19, 0.155, 0.058, 0.17, 0.065, 'paw', () => [0, -1, 0.1]);
addEllipsoidTufts(45, -0.085, 0.045, 0.185, 0.062, 0.042, 0.075, 'paw', () => [0, -0.2, 0.9]);
addEllipsoidTufts(45, 0.085, 0.045, 0.185, 0.062, 0.042, 0.075, 'paw', () => [0, -0.2, 0.9]);
// Head & chubby cheeks
addEllipsoidTufts(340, 0, 0.675, 0.045, 0.205, 0.178, 0.19, 'head', (x, y, z) => [x * 1.4, (y - 0.64) * 1.1, z * 0.4]);
addEllipsoidTufts(55, -0.105, 0.615, 0.155, 0.085, 0.065, 0.07, 'head', () => [-0.8, -0.5, 0.2]);
addEllipsoidTufts(55, 0.105, 0.615, 0.155, 0.085, 0.065, 0.07, 'head', () => [ 0.8, -0.5, 0.2]);
// Curled tail around right side
for (let t = 0; t <= 1; t += 0.08) {
const ang = lerp(-1.7, 0.35, t);
const tx = Math.cos(ang) * 0.25 + 0.03;
const tz = Math.sin(ang) * 0.22 - 0.04;
const ty = 0.055 + t * 0.035;
addEllipsoidTufts(14, tx, ty, tz, 0.052, 0.048, 0.052, 'tail', () => [-Math.sin(ang), 0.1, Math.cos(ang)]);
}
// Ears (back fur tufts)
for (const side of [-1, 1]) {
for (let i = 0; i < 42; i++) {
const u = rng();
const v = (rng() - 0.5) * (1 - u * 0.85);
const ex = side * (0.115 + u * 0.05 + v * 0.065);
const ey = 0.81 + u * 0.155;
const ez = 0.02 - u * 0.02 + v * 0.025;
furTufts.push({
x: ex, y: ey, z: ez,
nx: side * 0.3, ny: 0.3, nz: -0.85,
fx: side * 0.35, fy: 0.9, fz: 0,
col: coatColor('earBack', ex, ey, ez),
len: 0.025,
wid: 0.013,
isFace: false
});
}
}
}
initCatFur();
// Reusable projection buffer to avoid GC churn per frame
const hullParts = [
// Torso core, chest, haunches, legs, paws, head, ears
{ x: 0, y: 0.27, z: -0.02, rx: 0.225, ry: 0.265, rz: 0.215, col: [212, 165, 118], frontCol: [236, 231, 222] },
{ x: 0, y: 0.43, z: 0.10, rx: 0.155, ry: 0.145, rz: 0.13, col: [238, 233, 225] },
{ x: -0.165, y: 0.15, z: -0.05, rx: 0.105, ry: 0.135, rz: 0.14, col: [214, 168, 122] },
{ x: 0.165, y: 0.15, z: -0.05, rx: 0.105, ry: 0.135, rz: 0.14, col: [214, 168, 122] },
{ x: -0.085, y: 0.17, z: 0.155, rx: 0.055, ry: 0.155, rz: 0.06, col: [238, 234, 227] },
{ x: 0.085, y: 0.17, z: 0.155, rx: 0.055, ry: 0.155, rz: 0.06, col: [238, 234, 227] },
{ x: -0.085, y: 0.045, z: 0.185, rx: 0.06, ry: 0.038, rz: 0.07, col: [242, 238, 232] },
{ x: 0.085, y: 0.045, z: 0.185, rx: 0.06, ry: 0.038, rz: 0.07, col: [242, 238, 232] },
{ x: 0, y: 0.675, z: 0.045, rx: 0.20, ry: 0.175, rz: 0.185, col: [215, 166, 118], frontCol: [238, 233, 225] }
];
// Tail spheres in hull
for (let t = 0; t <= 1; t += 0.14) {
const ang = lerp(-1.7, 0.35, t);
hullParts.push({
x: Math.cos(ang) * 0.25 + 0.03,
y: 0.055 + t * 0.035,
z: Math.sin(ang) * 0.22 - 0.04,
rx: 0.048, ry: 0.044, rz: 0.048,
col: Math.sin(t * 14) > 0 ? [208, 155, 106] : [230, 198, 162]
});
}
function rotY(x, y, z, cy, sy) {
return [x * cy + z * sy, y, -x * sy + z * cy];
}
function rotTilt(x, y, z, tiltX, tiltZ) {
// Small precessing wobble in stages 2-4
const ct = Math.cos(tiltZ), st = Math.sin(tiltZ);
const x1 = x * ct - y * st;
const y1 = x * st + y * ct;
const cx = Math.cos(tiltX), sx = Math.sin(tiltX);
const y2 = y1 * cx - z * sx;
const z2 = y1 * sx + z * cx;
return [x1, y2, z2];
}
function drawCat3D(targetCtx, opts) {
const {
cx, cy, scale, yaw, tiltX = 0, tiltZ = 0,
squashX = 1, squashY = 1,
omega = 0, blink = 0, pupilDilate = 0,
breathe = 0, alpha = 1, tintMode = 0, eyeGlow = 0
} = opts;
targetCtx.save();
targetCtx.globalAlpha = alpha;
const cYaw = Math.cos(yaw), sYaw = Math.sin(yaw);
const fov = 3.2;
function project(lx, ly, lz) {
const sx = lx * squashX;
const sy = (ly + (ly > 0.25 && ly < 0.55 ? breathe * 0.008 : 0)) * squashY;
const sz = lz * squashX;
const [rx, ry, rz] = rotY(sx, sy, sz, cYaw, sYaw);
const [tx, ty, tz] = rotTilt(rx, ry - 0.45, rz, tiltX, tiltZ);
const wy = ty + 0.45;
const p = fov / (fov - tz * 0.55);
return {
sx: cx + tx * scale * p,
sy: cy - wy * scale * p,
z: tz,
p
};
}
// Light comes from the window on the left-front-top
const Lx = -0.72, Ly = 0.48, Lz = 0.50;
// 1. Collect & depth-sort drawables (Hull spheres, Ears, Face, Fur Tufts)
// When spinning fast, blend into smooth motion-blur smear bands!
const blurFactor = clamp((Math.abs(omega) - 10) / 24, 0, 0.88);
const drawList = [];
// Hull ellipsoids
for (let i = 0; i < hullParts.length; i++) {
const hp = hullParts[i];
const pr = project(hp.x, hp.y, hp.z);
let col = hp.col;
if (hp.frontCol) {
// Smooth front-facing white blend
const facing = clamp(cYaw * 0.65 + 0.35, 0, 1);
col = [
Math.round(lerp(col[0], hp.frontCol[0], facing)),
Math.round(lerp(col[1], hp.frontCol[1], facing)),
Math.round(lerp(col[2], hp.frontCol[2], facing))
];
}
drawList.push({
type: 'hull',
z: pr.z,
sx: pr.sx,
sy: pr.sy,
rx: hp.rx * squashX * scale * pr.p,
ry: hp.ry * squashY * scale * pr.p,
col
});
}
// 3D Ears
for (const side of [-1, 1]) {
const base = project(side * 0.115, 0.79, 0.03);
const tip = project(side * 0.165, 0.96, 0.01);
const out = project(side * 0.195, 0.77, 0.03);
const inn = project(side * 0.055, 0.815, 0.05);
const [enx, eny, enz] = rotY(side * 0.15, 0.15, 0.95, cYaw, sYaw);
drawList.push({
type: 'ear',
z: (base.z + tip.z) * 0.5 + 0.02,
pts: [inn, tip, out],
frontFacing: enz > 0.05,
light: clamp(enx * Lx + eny * Ly + enz * Lz, -1, 1)
});
}
// Fur tufts (thin out gracefully as motion blur rises to keep 60fps silky)
const step = blurFactor > 0.45 ? 3 : blurFactor > 0.15 ? 2 : 1;
for (let i = 0; i < furTufts.length; i += step) {
const f = furTufts[i];
const pr = project(f.x, f.y, f.z);
const [rnx, rny, rnz] = rotY(f.nx, f.ny, f.nz, cYaw, sYaw);
// Back-face cull tufts pointing well away unless near silhouette rim
if (rnz < -0.25) continue;
const [rfx, rfy] = rotY(f.fx, f.fy, f.fz, cYaw, sYaw);
const nDotL = rnx * Lx + rny * Ly + rnz * Lz;
const rim = Math.pow(1 - clamp(rnz, 0, 1), 2.2) * clamp(-rnx * 0.8 + 0.3, 0, 1);
drawList.push({
type: 'fur',
z: pr.z + 0.008,
sx: pr.sx,
sy: pr.sy,
dx: rfx * f.len * scale * pr.p * (1 + blurFactor * 1.4),
dy: -rfy * f.len * scale * pr.p,
wid: f.wid * scale * pr.p,
col: f.col,
light: nDotL,
rim
});
}
// Sort back-to-front
drawList.sort((a, b) => a.z - b.z);
// Draw sorted geometry
for (let i = 0; i < drawList.length; i++) {
const d = drawList[i];
if (d.type === 'hull') {
const g = targetCtx.createRadialGradient(
d.sx - d.rx * 0.35, d.sy - d.ry * 0.28, d.rx * 0.08,
d.sx, d.sy, Math.max(d.rx, d.ry) * 1.05
);
const r = d.col[0], gg = d.col[1], b = d.col[2];
g.addColorStop(0, `rgb(${Math.min(255, r + 24)},${Math.min(255, gg + 20)},${Math.min(255, b + 16)})`);
g.addColorStop(0.6, `rgb(${r},${gg},${b})`);
g.addColorStop(1, `rgb(${Math.max(40, r - 56)},${Math.max(34, gg - 48)},${Math.max(28, b - 42)})`);
targetCtx.fillStyle = g;
targetCtx.beginPath();
targetCtx.ellipse(d.sx, d.sy, Math.max(1, d.rx), Math.max(1, d.ry), 0, 0, Math.PI * 2);
targetCtx.fill();
} else if (d.type === 'ear') {
const [p0, p1, p2] = d.pts;
targetCtx.beginPath();
targetCtx.moveTo(p0.sx, p0.sy);
targetCtx.quadraticCurveTo(lerp(p0.sx, p1.sx, 0.65), lerp(p0.sy, p1.sy, 0.72), p1.sx, p1.sy);
targetCtx.quadraticCurveTo(lerp(p1.sx, p2.sx, 0.3), lerp(p1.sy, p2.sy, 0.3), p2.sx, p2.sy);
targetCtx.closePath();
const shade = 0.78 + d.light * 0.22;
targetCtx.fillStyle = `rgb(${Math.round(212 * shade)},${Math.round(158 * shade)},${Math.round(108 * shade)})`;
targetCtx.fill();
if (d.frontFacing) {
// Pink inner ear hollow
const cxE = (p0.sx + p1.sx + p2.sx) / 3;
const cyE = (p0.sy + p1.sy + p2.sy) / 3;
targetCtx.beginPath();
targetCtx.moveTo(lerp(p0.sx, cxE, 0.35), lerp(p0.sy, cyE, 0.35));
targetCtx.lineTo(lerp(p1.sx, cxE, 0.28), lerp(p1.sy, cyE, 0.28));
targetCtx.lineTo(lerp(p2.sx, cxE, 0.35), lerp(p2.sy, cyE, 0.35));
targetCtx.closePath();
targetCtx.fillStyle = '#d99a93';
targetCtx.fill();
}
} else {
// Fur tuft stroke
const shade = clamp(0.76 + d.light * 0.28, 0.45, 1.15);
const rimBoost = d.rim * 42;
const r = clamp(Math.round(d.col[0] * shade + rimBoost), 0, 255);
const gg = clamp(Math.round(d.col[1] * shade + rimBoost * 0.9), 0, 255);
const b = clamp(Math.round(d.col[2] * shade + rimBoost * 0.7), 0, 255);
targetCtx.strokeStyle = `rgba(${r},${gg},${b},${0.66 - blurFactor * 0.35})`;
targetCtx.lineWidth = Math.max(1, d.wid);
targetCtx.lineCap = 'round';
targetCtx.beginPath();
targetCtx.moveTo(d.sx, d.sy);
targetCtx.lineTo(d.sx + d.dx, d.sy + d.dy);
targetCtx.stroke();
}
}
// 2. High-detail 3D Face Features (when face is turned toward viewer)
if (cYaw > -0.25) {
const faceVis = smoothstep(-0.25, 0.25, cYaw) * (1 - blurFactor * 0.78);
if (faceVis > 0.02) {
targetCtx.save();
targetCtx.globalAlpha = alpha * faceVis;
// Eyes
const openAmt = 1 - blink;
for (const side of [-1, 1]) {
const ep = project(side * 0.076, 0.692, 0.205);
const erx = 0.036 * scale * ep.p * squashX * clamp(cYaw + 0.35, 0.25, 1);
const ery = 0.036 * scale * ep.p * squashY * Math.max(0.08, openAmt);
// Upper eyelid soft crease shadow
targetCtx.fillStyle = 'rgba(72, 54, 42, 0.34)';
targetCtx.beginPath();
targetCtx.ellipse(ep.sx, ep.sy - ery * 0.16, erx * 1.12, ery * 1.15, 0, 0, Math.PI * 2);
targetCtx.fill();
if (openAmt > 0.12) {
// Pale green-gold iris
const irisG = targetCtx.createRadialGradient(
ep.sx, ep.sy, erx * 0.15,
ep.sx, ep.sy, erx
);
irisG.addColorStop(0, eyeGlow > 0.1 ? '#fff4b8' : '#d5ca7c');
irisG.addColorStop(0.65, '#9ab274');
irisG.addColorStop(1, '#586943');
targetCtx.fillStyle = irisG;
targetCtx.beginPath();
targetCtx.ellipse(ep.sx, ep.sy, erx, ery, 0, 0, Math.PI * 2);
targetCtx.fill();
// Dark limbal rim
targetCtx.strokeStyle = '#25211c';
targetCtx.lineWidth = Math.max(1, erx * 0.11);
targetCtx.stroke();
// Big round Oiia meme pupil (expands wide when activated!)
const pRatio = lerp(0.46, 0.72, pupilDilate);
targetCtx.fillStyle = '#0f0e0d';
targetCtx.beginPath();
targetCtx.ellipse(ep.sx, ep.sy, erx * pRatio, ery * (pRatio + 0.06), 0, 0, Math.PI * 2);
targetCtx.fill();
if (eyeGlow > 0.05) {
targetCtx.fillStyle = `rgba(255, 240, 170, ${eyeGlow * 0.45})`;
targetCtx.beginPath();
targetCtx.ellipse(ep.sx, ep.sy, erx * 1.4, ery * 1.4, 0, 0, Math.PI * 2);
targetCtx.fill();
}
// Crisp window catchlight (reflects left window)
targetCtx.fillStyle = 'rgba(255, 253, 246, 0.94)';
targetCtx.beginPath();
targetCtx.ellipse(ep.sx - erx * 0.34, ep.sy - ery * 0.32, erx * 0.24, ery * 0.22, -0.25, 0, Math.PI * 2);
targetCtx.fill();
targetCtx.fillStyle = 'rgba(255, 253, 246, 0.55)';
targetCtx.beginPath();
targetCtx.arc(ep.sx + erx * 0.26, ep.sy + ery * 0.28, erx * 0.10, 0, Math.PI * 2);
targetCtx.fill();
} else {
// Closed blink line
targetCtx.strokeStyle = '#3b2e25';
targetCtx.lineWidth = Math.max(1.5, scale * 0.008);
targetCtx.beginPath();
targetCtx.moveTo(ep.sx - erx, ep.sy);
targetCtx.quadraticCurveTo(ep.sx, ep.sy + 2, ep.sx + erx, ep.sy);
targetCtx.stroke();
}
}
// Cute pink nose leather
const np = project(0, 0.625, 0.238);
const nw = 0.024 * scale * np.p * squashX;
const nh = 0.017 * scale * np.p * squashY;
targetCtx.beginPath();
targetCtx.moveTo(np.sx - nw, np.sy - nh * 0.45);
targetCtx.quadraticCurveTo(np.sx, np.sy - nh * 0.85, np.sx + nw, np.sy - nh * 0.45);
targetCtx.quadraticCurveTo(np.sx + nw * 0.45, np.sy + nh * 0.45, np.sx, np.sy + nh);
targetCtx.quadraticCurveTo(np.sx - nw * 0.45, np.sy + nh * 0.45, np.sx - nw, np.sy - nh * 0.45);
targetCtx.closePath();
const noseG = targetCtx.createLinearGradient(np.sx, np.sy - nh, np.sx, np.sy + nh);
noseG.addColorStop(0, '#eaaea9');
noseG.addColorStop(1, '#c97d78');
targetCtx.fillStyle = noseG;
targetCtx.fill();
// Delicate 'w' mouth
const mp = project(0, 0.592, 0.232);
targetCtx.strokeStyle = 'rgba(72, 54, 46, 0.65)';
targetCtx.lineWidth = Math.max(1, scale * 0.006);
targetCtx.lineCap = 'round';
targetCtx.beginPath();
targetCtx.moveTo(np.sx, np.sy + nh * 0.9);
targetCtx.lineTo(mp.sx, mp.sy);
targetCtx.quadraticCurveTo(mp.sx - nw * 0.8, mp.sy + nh * 0.7, mp.sx - nw * 1.45, mp.sy + nh * 0.1);
targetCtx.moveTo(mp.sx, mp.sy);
targetCtx.quadraticCurveTo(mp.sx + nw * 0.8, mp.sy + nh * 0.7, mp.sx + nw * 1.45, mp.sy + nh * 0.1);
targetCtx.stroke();
// 3D Whiskers catching the light
targetCtx.strokeStyle = 'rgba(255, 252, 245, 0.55)';
targetCtx.lineWidth = Math.max(0.8, scale * 0.004);
for (const side of [-1, 1]) {
for (let w = 0; w < 4; w++) {
const wy = 0.618 - w * 0.011;
const wp0 = project(side * 0.06, wy, 0.22);
const wp1 = project(side * (0.24 + w * 0.015), wy - 0.015 + w * 0.018, 0.16);
targetCtx.beginPath();
targetCtx.moveTo(wp0.sx, wp0.sy);
targetCtx.quadraticCurveTo(
(wp0.sx + wp1.sx) * 0.5,
wp0.sy - 2,
wp1.sx,
wp1.sy
);
targetCtx.stroke();
}
}
targetCtx.restore();
}
}
// 3. Rotational Spin-Blur Smear Bands (turns cat into true Oiia vortex blur!)
if (blurFactor > 0.05) {
targetCtx.save();
const topY = cy - 0.88 * scale * squashY;
const botY = cy - 0.04 * scale * squashY;
const bands = 14;
for (let b = 0; b < bands; b++) {
const t = b / (bands - 1);
const by = lerp(topY, botY, t);
const bw = (t < 0.34 ? 0.20 : 0.24) * scale * squashX * (1 + blurFactor * 0.25);
const isCreamBand = t < 0.22 || (t > 0.42 && t < 0.84);
const grad = targetCtx.createLinearGradient(cx - bw, by, cx + bw, by);
const a = blurFactor * 0.46;
if (isCreamBand) {
grad.addColorStop(0, 'rgba(218,168,120,0)');
grad.addColorStop(0.25, `rgba(235,204,170,${a})`);
grad.addColorStop(0.5, `rgba(244,236,225,${a * 1.1})`);
grad.addColorStop(0.75, `rgba(212,158,108,${a})`);
grad.addColorStop(1, 'rgba(212,158,108,0)');
} else {
grad.addColorStop(0, 'rgba(245,240,232,0)');
grad.addColorStop(0.3, `rgba(248,244,238,${a * 1.15})`);
grad.addColorStop(0.7, `rgba(236,228,216,${a * 1.15})`);
grad.addColorStop(1, 'rgba(236,228,216,0)');
}
targetCtx.fillStyle = grad;
targetCtx.fillRect(cx - bw, by - scale * 0.045, bw * 2, scale * 0.09);
}
targetCtx.restore();
}
targetCtx.restore();
}
// =========================================================================
// 3. WEB AUDIO "OIIA" VOICE SYNTHESIZER (Formant Filter Bank Locked to Spin)
// =========================================================================
// Oiia rhythmic vowel phrase: "oi-i-a oi-i-a oi-i-a oooo-i-a-i"
const OIIA_PHRASE = [
{ v: 'o', dur: 0.9, pitchMul: 1.00, accent: 1.0 },
{ v: 'i', dur: 0.7, pitchMul: 1.18, accent: 0.95 },
{ v: 'i', dur: 0.7, pitchMul: 1.24, accent: 0.9 },
{ v: 'a', dur: 1.1, pitchMul: 1.04, accent: 1.05 },
{ v: 'o', dur: 0.9, pitchMul: 1.02, accent: 1.0 },
{ v: 'i', dur: 0.7, pitchMul: 1.22, accent: 0.95 },
{ v: 'i', dur: 0.7, pitchMul: 1.28, accent: 0.95 },
{ v: 'a', dur: 1.1, pitchMul: 1.08, accent: 1.05 },
{ v: 'o', dur: 0.9, pitchMul: 1.05, accent: 1.0 },
{ v: 'i', dur: 0.7, pitchMul: 1.26, accent: 0.98 },
{ v: 'i', dur: 0.7, pitchMul: 1.34, accent: 0.98 },
{ v: 'a', dur: 1.1, pitchMul: 1.12, accent: 1.1 },
{ v: 'o', dur: 1.4, pitchMul: 0.96, accent: 1.12 },
{ v: 'o', dur: 1.1, pitchMul: 1.06, accent: 0.95 },
{ v: 'i', dur: 0.8, pitchMul: 1.36, accent: 1.05 },
{ v: 'a', dur: 0.8, pitchMul: 1.20, accent: 1.0 },
{ v: 'i', dur: 1.2, pitchMul: 1.42, accent: 1.15 }
];
const VOWEL_FORMANTS = {
o: { f1: 430, f2: 810, f3: 2400, g1: 1.0, g2: 0.65, g3: 0.22 },
i: { f1: 300, f2: 2220, f3: 2950, g1: 0.9, g2: 0.85, g3: 0.35 },
a: { f1: 720, f2: 1220, f3: 2550, g1: 1.05, g2: 0.80, g3: 0.28 }
};
const audio = {
ctx: null,
muted: false,
masterGain: null,
panner: null,
voiceGain: null,
choirGain: null,
oscMain: null,
oscSub: null,
oscChoir: null,
noiseGain: null,
f1: null,
f2: null,
f3: null,
f1Gain: null,
f2Gain: null,
f3Gain: null,
lastSyllableIdx: -1
};
function initAudio() {
if (audio.ctx) {
if (audio.ctx.state === 'suspended') audio.ctx.resume();
return;
}
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
const ac = new AC();
audio.ctx = ac;
const comp = ac.createDynamicsCompressor();
comp.threshold.value = -14;
comp.knee.value = 12;
comp.ratio.value = 6;
comp.attack.value = 0.004;
comp.release.value = 0.12;
const masterGain = ac.createGain();
masterGain.gain.value = audio.muted ? 0 : 0.82;
const panner = ac.createStereoPanner ? ac.createStereoPanner() : null;
if (panner) {
comp.connect(panner);
panner.connect(masterGain);
} else {
comp.connect(masterGain);
}
masterGain.connect(ac.destination);
// Glottal pulse sources (sawtooth + triangle chest warmth + choir fifth/octave)
const oscMain = ac.createOscillator();
oscMain.type = 'sawtooth';
oscMain.frequency.value = 210;
const oscSub = ac.createOscillator();
oscSub.type = 'triangle';
oscSub.frequency.value = 210;
const oscChoir = ac.createOscillator();
oscChoir.type = 'sawtooth';
oscChoir.frequency.value = 315;
const mainIn = ac.createGain();
mainIn.gain.value = 0.65;
const subIn = ac.createGain();
subIn.gain.value = 0.45;
const choirGain = ac.createGain();
choirGain.gain.value = 0;
// Breathy vocal noise for natural consonant/airiness
const bufLen = ac.sampleRate;
const noiseBuf = ac.createBuffer(1, bufLen, ac.sampleRate);
const data = noiseBuf.getChannelData(0);
for (let i = 0; i < bufLen; i++) data[i] = (Math.random() * 2 - 1) * 0.25;
const noiseSrc = ac.createBufferSource();
noiseSrc.buffer = noiseBuf;
noiseSrc.loop = true;
const noiseBP = ac.createBiquadFilter();
noiseBP.type = 'bandpass';
noiseBP.frequency.value = 1800;
noiseBP.Q.value = 1.8;
const noiseGain = ac.createGain();
noiseGain.gain.value = 0.12;
noiseSrc.connect(noiseBP);
noiseBP.connect(noiseGain);
// Vocal tract sum -> 3 parallel formant bandpass filters
const sourceSum = ac.createGain();
sourceSum.gain.value = 1.0;
oscMain.connect(mainIn);
oscSub.connect(subIn);
oscChoir.connect(choirGain);
mainIn.connect(sourceSum);
subIn.connect(sourceSum);
choirGain.connect(sourceSum);
noiseGain.connect(sourceSum);
const f1 = ac.createBiquadFilter();
const f2 = ac.createBiquadFilter();
const f3 = ac.createBiquadFilter();
f1.type = f2.type = f3.type = 'bandpass';
f1.Q.value = 7.5;
f2.Q.value = 10.5;
f3.Q.value = 14.0;
const f1Gain = ac.createGain();
const f2Gain = ac.createGain();
const f3Gain = ac.createGain();
const voiceGain = ac.createGain();
voiceGain.gain.value = 0;
sourceSum.connect(f1); f1.connect(f1Gain); f1Gain.connect(voiceGain);
sourceSum.connect(f2); f2.connect(f2Gain); f2Gain.connect(voiceGain);
sourceSum.connect(f3); f3.connect(f3Gain); f3Gain.connect(voiceGain);
// Warm nasal/throat body lowpass blend so it isn't thin
const throatLP = ac.createBiquadFilter();
throatLP.type = 'lowpass';
throatLP.frequency.value = 520;
const throatGain = ac.createGain();
throatGain.gain.value = 0.34;
sourceSum.connect(throatLP);
throatLP.connect(throatGain);
throatGain.connect(voiceGain);
voiceGain.connect(comp);
oscMain.start();
oscSub.start();
oscChoir.start();
noiseSrc.start();
audio.masterGain = masterGain;
audio.panner = panner;
audio.voiceGain = voiceGain;
audio.choirGain = choirGain;
audio.oscMain = oscMain;
audio.oscSub = oscSub;
audio.oscChoir = oscChoir;
audio.noiseGain = noiseGain;
audio.f1 = f1;
audio.f2 = f2;
audio.f3 = f3;
audio.f1Gain = f1Gain;
audio.f2Gain = f2Gain;
audio.f3Gain = f3Gain;
}
function triggerSubThump(strength) {
if (!audio.ctx || audio.muted) return;
const ac = audio.ctx;
const now = ac.currentTime;
const osc = ac.createOscillator();
const g = ac.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(115, now);
osc.frequency.exponentialRampToValueAtTime(38, now + 0.14);
g.gain.setValueAtTime(0.32 * strength, now);
g.gain.exponentialRampToValueAtTime(0.001, now + 0.16);
osc.connect(g);
g.connect(audio.masterGain);
osc.start(now);
osc.stop(now + 0.18);
}
function updateAudioLockToSpin(spinPhase, omega, currentStage, catYaw, frozen) {
if (!audio.ctx) return;
const ac = audio.ctx;
const now = ac.currentTime;
if (currentStage === 0 || frozen || omega < 0.8) {
audio.voiceGain.gain.setTargetAtTime(0, now, 0.012);
audio.lastSyllableIdx = -1;
return;
}
// Lock syllable directly to spinPhase (each ~1.4 radians advances a syllable step)
const totalStep = spinPhase / 1.42;
const idx = Math.floor(totalStep) % OIIA_PHRASE.length;
const stepFrac = totalStep - Math.floor(totalStep);
const syl = OIIA_PHRASE[idx];
const vf = VOWEL_FORMANTS[syl.v];
// Pitch climbs together with angular speed omega
const speedNorm = clamp((omega - 4) / 48, 0, 1);
const baseF0 = lerp(195, 440, Math.pow(speedNorm, 0.85)) * syl.pitchMul;
const vibrato = Math.sin(now * 28) * 6;
const f0 = baseF0 + vibrato;
// Formants shift slightly upward with pitch so it reads like a pitched-up vocal sample
const formantStretch = lerp(1.0, 1.32, speedNorm);
audio.oscMain.frequency.setTargetAtTime(f0, now, 0.014);
audio.oscSub.frequency.setTargetAtTime(f0, now, 0.014);
audio.oscChoir.frequency.setTargetAtTime(f0 * (currentStage >= 4 ? 1.5 : 1.498), now, 0.016);
audio.choirGain.gain.setTargetAtTime(currentStage >= 3 ? 0.36 : 0, now, 0.04);
if (idx !== audio.lastSyllableIdx) {
audio.lastSyllableIdx = idx;
audio.f1.frequency.setTargetAtTime(vf.f1 * formantStretch, now, 0.014);
audio.f2.frequency.setTargetAtTime(vf.f2 * formantStretch, now, 0.014);
audio.f3.frequency.setTargetAtTime(vf.f3 * formantStretch, now, 0.014);
audio.f1Gain.gain.setTargetAtTime(vf.g1, now, 0.014);
audio.f2Gain.gain.setTargetAtTime(vf.g2, now, 0.014);
audio.f3Gain.gain.setTargetAtTime(vf.g3, now, 0.014);
if (currentStage >= 3 && idx % 4 === 0) {
triggerSubThump(currentStage === 4 ? 0.95 : 0.55);
}
}
// Crisp vocal chop/gate shape per syllable
const env = stepFrac < 0.14
? stepFrac / 0.14
: stepFrac > 0.78
? Math.max(0.08, 1 - (stepFrac - 0.78) / 0.22)
: 1.0;
const stageLoudness = [0, 0.72, 0.85, 0.95, 1.05][currentStage];
audio.voiceGain.gain.setTargetAtTime(env * syl.accent * stageLoudness, now, 0.008);
// Stereo swirl in stages 2+
if (audio.panner) {
const panAmt = currentStage >= 2 ? Math.sin(catYaw) * clamp(currentStage * 0.22, 0, 0.75) : 0;
audio.panner.pan.setTargetAtTime(panAmt, now, 0.015);
}
}
function updateMuteUI() {
if (!audio.ctx) {
muteBtn.textContent = audio.muted ? 'Sound: Muted' : 'Sound: Off';
} else {
muteBtn.textContent = audio.muted ? 'Sound: Muted' : 'Sound: On';
}
}
muteBtn.addEventListener('click', (e) => {
e.stopPropagation();
audio.muted = !audio.muted;
if (audio.masterGain && audio.ctx) {
audio.masterGain.gain.setTargetAtTime(audio.muted ? 0 : 0.82, audio.ctx.currentTime, 0.02);
}
updateMuteUI();
});
// =========================================================================
// 4. SIMULATION STATE, DUST MOTES, SHOCKWAVES & STAGE CHOREOGRAPHY
// =========================================================================
const dustMotes = [];
for (let i = 0; i < 65; i++) {
dustMotes.push({
x: Math.random(),
y: Math.random() * 0.85,
r: 0.8 + Math.random() * 1.9,
speed: 0.005 + Math.random() * 0.012,
phase: Math.random() * Math.PI * 2,
orbitAng: Math.random() * Math.PI * 2,
orbitRad: 0.15 + Math.random() * 0.85
});
}
const shockwaves = [];
let catState = {
yaw: 0,
omega: 0,
spinPhase: 0,
lift: 0,
tiltX: 0,
tiltZ: 0,
squashX: 1,
squashY: 1,
pupil: 0.08,
blinkTimer: 2.8,
blinkVal: 0,
lastWaveBeat: -1,
freezeTimer: 0,
isFrozen: false
};
const STAGE_NAMES = [
'',
'STAGE 1 · OIIA LIFT',
'STAGE 2 · ROOM VORTEX',
'STAGE 3 · ZERO-G CHOIR',
'STAGE 4 · HYPER SINGULARITY'
];
function advanceStage() {
hasInteracted = true;
hintEl.classList.remove('visible');
initAudio();
updateMuteUI();
if (stage < 4) {
stage += 1;
stageTime = 0;
catState.isFrozen = false;
catState.freezeTimer = 0;
shockwaves.push({ r: 0.05, alpha: 0.9, hue: stage * 42 });
if (stage === 1) {
catState.omega = Math.max(catState.omega, 5.5);
}
} else {
// Comedic deadpan reset back to quiet Stage 0!
stage = 0;
stageTime = 0;
catState.omega = 0;
catState.yaw = 0;
catState.lift = 0;
catState.tiltX = 0;
catState.tiltZ = 0;
catState.squashX = 1;
catState.squashY = 1;
catState.isFrozen = false;
shockwaves.length = 0;
}
if (stage > 0) {
stageBadge.textContent = `${STAGE_NAMES[stage]} · CLICK`;
stageBadge.classList.add('visible');
} else {
stageBadge.classList.remove('visible');
}
}
// Hit-testing on Stage 0 (click the cat), anywhere on Stage 1+
function isOverCat(clientX, clientY) {
const dx = Math.abs(clientX - room.catBaseX);
const dy = (room.catBaseY - clientY);
return dx < room.catScale * 0.48 && dy > -room.catScale * 0.12 && dy < room.catScale * 1.15;
}
canvas.addEventListener('pointermove', (e) => {
if (stage > 0 || isOverCat(e.clientX, e.clientY)) {
canvas.style.cursor = 'pointer';
} else {
canvas.style.cursor = 'default';
}
});
canvas.addEventListener('pointerdown', (e) => {
if (stage === 0 && !isOverCat(e.clientX, e.clientY)) return;
advanceStage();
});
window.addEventListener('keydown', (e) => {
if (e.code === 'Space' || e.code === 'Enter') {
e.preventDefault();
advanceStage();
} else if (e.key === 'm' || e.key === 'M') {
muteBtn.click();
}
});
// =========================================================================
// 5. MAIN RENDER LOOP
// =========================================================================
function drawShadowEllipse(x, y, rx, ry, alpha) {
if (alpha <= 0.01) return;
const g = ctx.createRadialGradient(x, y, rx * 0.1, x, y, rx);
g.addColorStop(0, `rgba(28, 20, 14, ${alpha})`);
g.addColorStop(0.6, `rgba(34, 24, 17, ${alpha * 0.45})`);
g.addColorStop(1, 'rgba(34, 24, 17, 0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, Math.PI * 2);
ctx.fill();
}
function drawSunbeam(bendAmt, chaosAmt) {
ctx.save();
const wx = W * 0.09, wy = H * 0.10;
const tx = W * 0.62, ty = H * 0.94;
const cx = lerp(W * 0.34, room.catBaseX, bendAmt);
const cy = lerp(H * 0.48, room.catBaseY - catState.lift * H - room.catScale * 0.4, bendAmt);
ctx.beginPath();
ctx.moveTo(wx, wy);
ctx.quadraticCurveTo(cx, cy - H * 0.14, tx, ty);
ctx.lineTo(tx - W * 0.36, ty + H * 0.08);
ctx.quadraticCurveTo(cx - W * 0.12, cy + H * 0.06, wx * 0.4, wy + H * 0.34);
ctx.closePath();
const g = ctx.createLinearGradient(wx, wy, tx, ty);
g.addColorStop(0, `rgba(255, 246, 214, ${0.14 + chaosAmt * 0.08})`);
g.addColorStop(0.55, `rgba(255, 236, 188, ${0.07 + chaosAmt * 0.06})`);
g.addColorStop(1, 'rgba(255, 230, 180, 0)');
ctx.fillStyle = g;
ctx.fill();
ctx.restore();
}
function drawProp(prop, x, y, rot = 0, sc = 1, alpha = 1) {
if (!prop || !prop.canvas) return;
ctx.save();
ctx.globalAlpha = alpha;
ctx.translate(x, y);
if (rot) ctx.rotate(rot);
if (sc !== 1) ctx.scale(sc, sc);
ctx.drawImage(prop.canvas, -prop.canvas.width * 0.5, -prop.canvas.height * 0.5);
ctx.restore();
}
function drawCosmicVoid(t, intensity) {
ctx.save();
ctx.fillStyle = '#0c0814';
ctx.fillRect(0, 0, W, H);
// Subtle radial nebula glow centered behind the cat
const cx = room.catBaseX;
const cy = room.catBaseY - catState.lift * H - room.catScale * 0.4;
const neb = ctx.createRadialGradient(cx, cy, 20, cx, cy, Math.max(W, H) * 0.72);
neb.addColorStop(0, `rgba(255, 198, 120, ${0.28 * intensity})`);
neb.addColorStop(0.35, `rgba(148, 72, 186, ${0.24 * intensity})`);
neb.addColorStop(0.7, `rgba(38, 28, 78, ${0.35 * intensity})`);
neb.addColorStop(1, 'rgba(10, 6, 18, 0)');
ctx.fillStyle = neb;
ctx.fillRect(0, 0, W, H);
// Perspective warp speed / star grid
const count = stage === 4 ? 72 : 42;
ctx.lineWidth = stage === 4 ? 1.8 : 1.1;
for (let i = 0; i < count; i++) {
const a = (i / count) * Math.PI * 2 + t * (stage === 4 ? 0.55 : 0.18);
const r0 = ((t * (stage === 4 ? 2.4 : 0.9) + i * 0.17) % 1) * Math.max(W, H) * 0.8;
const r1 = r0 + (stage === 4 ? 95 : 32);
ctx.strokeStyle = i % 3 === 0
? `rgba(255, 224, 168, ${0.42 * intensity})`
: `rgba(176, 154, 255, ${0.32 * intensity})`;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * r0, cy + Math.sin(a) * r0);
ctx.lineTo(cx + Math.cos(a) * r1, cy + Math.sin(a) * r1);
ctx.stroke();
}
ctx.restore();
}
function frame(ts) {
if (!lastTs) lastTs = ts;
const dt = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
idleTime += dt;
if (stage > 0) stageTime += dt;
// Gentle delayed hint if user hasn't clicked after 3.5s
if (!hasInteracted && idleTime > 3.5) {
hintEl.classList.add('visible');
}
// --- Update Cat Physics & Stage State ---
if (stage === 0) {
// Quiet living room: subtle blink and breathing
catState.omega = 0;
catState.yaw = 0;
catState.lift = 0;
catState.pupil = lerp(catState.pupil, 0.12, dt * 4);
catState.blinkTimer -= dt;
if (catState.blinkTimer <= 0) {
catState.blinkVal = Math.sin(clamp(-catState.blinkTimer / 0.18, 0, 1) * Math.PI);
if (catState.blinkTimer <= -0.18) {
catState.blinkTimer = 2.6 + Math.random() * 3.8;
catState.blinkVal = 0;
}
}
} else {
catState.blinkVal = 0;
catState.pupil = lerp(catState.pupil, 0.92, dt * 7);
// Stage 4 signature deadpan comedic freeze every ~2.7s!
if (stage === 4) {
catState.freezeTimer += dt;
const cycle = catState.freezeTimer % 2.85;
catState.isFrozen = cycle > 2.55;
} else {
catState.isFrozen = false;
}
const targetOmegas = [0, 19.5, 31.0, 42.0, 56.0];
const targetLifts = [0, 0.12, 0.18, 0.24, 0.27];
if (catState.isFrozen) {
// Snap dead square to camera!
catState.yaw = Math.round(catState.yaw / (Math.PI * 2)) * Math.PI * 2;
catState.tiltX = 0;
catState.tiltZ = 0;
catState.squashX = 1;
catState.squashY = 1;
} else {
const accel = stage === 1 ? 2.4 : 4.2;
catState.omega = lerp(catState.omega, targetOmegas[stage], dt * accel);
const dTheta = catState.omega * dt;
catState.yaw += dTheta;
catState.spinPhase += dTheta;
catState.lift = lerp(catState.lift, targetLifts[stage], dt * 3.2)
+ Math.sin(stageTime * (stage >= 3 ? 6.5 : 3.2)) * (stage >= 2 ? 0.018 : 0.008);
// Precession wobble & elastic Oiia squash-stretch
const wobbleAmp = stage === 1 ? 0.03 : stage === 2 ? 0.10 : stage === 3 ? 0.16 : 0.20;
catState.tiltZ = Math.sin(stageTime * 5.2) * wobbleAmp;
catState.tiltX = Math.cos(stageTime * 4.1) * wobbleAmp * 0.65;
if (stage >= 3) {
const bounce = Math.sin(catState.spinPhase * 0.95);
catState.squashY = 1 + bounce * (stage === 4 ? 0.16 : 0.10);
catState.squashX = 1 / Math.sqrt(catState.squashY);
} else {
catState.squashX = lerp(catState.squashX, 1, dt * 8);
catState.squashY = lerp(catState.squashY, 1, dt * 8);
}
// Periodic shockwave rings in Stages 3 & 4
if (stage >= 3) {
const beat = Math.floor(catState.spinPhase / 5.6);
if (beat !== catState.lastWaveBeat) {
catState.lastWaveBeat = beat;
shockwaves.push({ r: 0.06, alpha: stage === 4 ? 0.85 : 0.55, hue: (beat * 37) % 360 });
}
}
}
}
// Lock audio synthesis directly to current spin state
updateAudioLockToSpin(catState.spinPhase, catState.omega, stage, catState.yaw, catState.isFrozen);
// --- Begin Scene Drawing ---
ctx.save();
// Camera zoom + beat micro-shake in Stages 1-4
let camZoom = 1;
let camX = 0, camY = 0, camRot = 0;
if (stage > 0 && !catState.isFrozen) {
camZoom = 1 + [0, 0.04, 0.08, 0.11, 0.13][stage];
const shake = [0, 1.2, 3.2, 5.5, 8.5][stage];
camX = Math.sin(stageTime * 37) * shake;
camY = Math.cos(stageTime * 43) * shake * 0.7;
if (stage >= 2) {
camRot = Math.sin(stageTime * 2.7) * (stage === 2 ? 0.015 : stage === 3 ? 0.032 : 0.048);
}
}
ctx.translate(W * 0.5 + camX, H * 0.5 + camY);
if (camRot) ctx.rotate(camRot);
if (camZoom !== 1) ctx.scale(camZoom, camZoom);
ctx.translate(-W * 0.5, -H * 0.5);
// 1. Background: Living Room or Cosmic Fractured Room (Stages 3-4)
if (stage >= 3) {
const voidAmt = smoothstep(0, 0.9, stageTime) * (stage === 4 ? 1.0 : 0.72);
drawCosmicVoid(stageTime, voidAmt);
// Draw fracturing living room faded & tilting behind
ctx.save();
ctx.globalAlpha = stage === 4 ? 0.34 : 0.56;
const roomTilt = Math.sin(stageTime * 1.1) * (stage === 4 ? 0.12 : 0.05);
ctx.translate(W * 0.5, H * 0.5);
ctx.rotate(roomTilt);
ctx.scale(1 - voidAmt * 0.15, 1 - voidAmt * 0.15);
ctx.drawImage(room.bg, -W * 0.5, -H * 0.5);
ctx.restore();
} else {
ctx.drawImage(room.bg, 0, 0);
}
// 2. Props & Cast Shadows (Stage 0-1 grounded, Stage 2-4 orbiting in zero-G!)
const catCenterX = room.catBaseX;
const catDrawY = room.catBaseY - catState.lift * H;
if (stage <= 1) {
// Grounded cast shadows (from left window toward back-right)
drawShadowEllipse(room.props.frame.x + 8, room.props.frame.y + 10, W * 0.075, H * 0.11, 0.24);
drawShadowEllipse(room.props.plant.x + 22, room.horizonY + 8, W * 0.09, H * 0.028, 0.28);
drawShadowEllipse(room.props.sofa.x + 18, room.horizonY + 12, W * 0.23, H * 0.035, 0.34);
drawShadowEllipse(room.props.lamp.x + 16, room.horizonY + 10, W * 0.055, H * 0.022, 0.26);
drawProp(room.props.frame, room.props.frame.x, room.props.frame.y);
drawProp(room.props.rug, room.props.rug.x, room.props.rug.y);
drawProp(room.props.sofa, room.props.sofa.x, room.props.sofa.y);
drawProp(room.props.pillow, room.props.pillow.x, room.props.pillow.y);
drawProp(room.props.plant, room.props.plant.x, room.props.plant.y);
drawProp(room.props.lamp, room.props.lamp.x, room.props.lamp.y);
} else {
// Rug ripples or lifts slightly
const rugLift = stage >= 3 ? Math.sin(stageTime * 2.4) * 18 - 20 : 0;
const rugRot = stage >= 3 ? Math.sin(stageTime * 1.4) * 0.08 : 0;
drawProp(room.props.rug, room.props.rug.x, room.props.rug.y + rugLift, rugRot, 1, stage === 4 ? 0.65 : 0.95);
// Orbiting living room furniture!
const orbitCenterY = catDrawY - room.catScale * 0.42;
const orbitSpeed = stage === 2 ? 0.9 : stage === 3 ? 1.55 : 2.35;
const orbitAmt = smoothstep(0, 0.8, stageTime);
const orbitProps = [
{ p: room.props.frame, rx: W * 0.30, ry: H * 0.18, ph: 0.4, rotS: 0.7, sc: 0.92, alwaysOrbit: true },
{ p: room.props.pillow, rx: W * 0.26, ry: H * 0.14, ph: 2.1, rotS: -1.6, sc: 1.0, alwaysOrbit: true },
{ p: room.props.plant, rx: W * 0.34, ry: H * 0.16, ph: 3.7, rotS: 0.85, sc: 0.88, alwaysOrbit: true },
{ p: room.props.lamp, rx: W * 0.38, ry: H * 0.19, ph: 5.0, rotS: -0.6, sc: 0.85, alwaysOrbit: stage >= 3 },
{ p: room.props.sofa, rx: W * 0.28, ry: H * 0.12, ph: 1.25, rotS: 0.35, sc: 0.82, alwaysOrbit: stage >= 3 }
];
// Draw grounded props first if not yet orbiting
for (const op of orbitProps) {
if (!op.alwaysOrbit) {
drawProp(op.p, op.p.x, op.p.y + Math.sin(stageTime * 3 + op.ph) * 4);
}
}
// Draw orbiting props sorted simply by orbital depth sin(ang)
const activeOrbits = orbitProps.filter(op => op.alwaysOrbit).map(op => {
const ang = stageTime * orbitSpeed + op.ph;
const ox = catCenterX + Math.cos(ang) * op.rx;
const oy = orbitCenterY + Math.sin(ang) * op.ry;
return {
op,
depth: Math.sin(ang),
x: lerp(op.p.x, ox, orbitAmt),
y: lerp(op.p.y, oy, orbitAmt),
rot: Math.sin(stageTime * op.rotS + op.ph) * (stage >= 3 ? 0.55 : 0.24) * orbitAmt,
sc: lerp(1, op.sc * (0.85 + Math.sin(ang) * 0.18), orbitAmt)
};
});
// Back-orbiting props go behind the cat
for (const item of activeOrbits) {
if (item.depth < 0) drawProp(item.op.p, item.x, item.y, item.rot, item.sc);
}
// Store front-orbiting items to draw after cat
room._frontOrbits = activeOrbits.filter(item => item.depth >= 0);
}
// 3. Cat Contact / Levitation Shadow on the Rug
const shadowScale = clamp(1 - catState.lift * 2.1, 0.28, 1);
const shadowAlpha = clamp(0.42 - catState.lift * 0.85, 0.12, 0.42);
// Ground reflection hint on polished floor near rug edge
drawShadowEllipse(
catCenterX + catState.lift * 45,
room.catBaseY + 6,
room.catScale * 0.36 * shadowScale,
room.catScale * 0.10 * shadowScale,
shadowAlpha
);
// 4. Sunbeam & Dust Motes
drawSunbeam(stage > 0 ? clamp(stage * 0.25, 0, 0.85) : 0, stage >= 3 ? 1 : 0);
for (let i = 0; i < dustMotes.length; i++) {
const m = dustMotes[i];
let mx, my;
if (stage === 0) {
mx = ((m.x + idleTime * m.speed * 0.4) % 1) * W * 0.72 + W * 0.06;
my = m.y * H + Math.sin(idleTime * 0.8 + m.phase) * 8;
} else {
const swirlSpeed = (0.8 + stage * 1.1) * (catState.isFrozen ? 0 : 1);
m.orbitAng += swirlSpeed * dt * (1.3 - m.orbitRad * 0.5);
mx = catCenterX + Math.cos(m.orbitAng) * (W * 0.34 * m.orbitRad);
my = (catDrawY - room.catScale * 0.45) + Math.sin(m.orbitAng * 1.4) * (H * 0.22 * m.orbitRad);
}
ctx.fillStyle = `rgba(255, 246, 214, ${stage === 0 ? 0.24 : 0.42})`;
ctx.beginPath();
ctx.arc(mx, my, m.r, 0, Math.PI * 2);
ctx.fill();
}
// 5. Shockwaves & Energy Tornado Halo (Stages 3-4)
for (let i = shockwaves.length - 1; i >= 0; i--) {
const sw = shockwaves[i];
sw.r += dt * (stage === 4 ? 1.45 : 0.95);
sw.alpha -= dt * 1.25;
if (sw.alpha <= 0) {
shockwaves.splice(i, 1);
continue;
}
ctx.save();
ctx.strokeStyle = `hsla(${sw.hue}, 88%, 74%, ${sw.alpha})`;
ctx.lineWidth = 3.5;
ctx.beginPath();
ctx.ellipse(
catCenterX,
catDrawY - room.catScale * 0.42,
sw.r * W * 0.55,
sw.r * H * 0.36,
0, 0, Math.PI * 2
);
ctx.stroke();
ctx.restore();
}
if (stage === 4 && !catState.isFrozen) {
// Glowing cream-gold Oiia vortex tornado ribbons around cat
ctx.save();
const vy0 = catDrawY + room.catScale * 0.1;
const vy1 = catDrawY - room.catScale * 1.05;
for (let r = 0; r < 6; r++) {
const t = r / 5;
const ry = lerp(vy0, vy1, t);
const rw = room.catScale * lerp(0.28, 0.72, t);
const rot = stageTime * (8 + r * 1.5);
ctx.strokeStyle = `rgba(255, 224, 152, ${0.42 - t * 0.18})`;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.ellipse(catCenterX, ry, rw, rw * 0.24, Math.sin(rot) * 0.08, 0, Math.PI * 1.55);
ctx.stroke();
}
ctx.restore();
}
// 6. Stage 3 Echo Choir Clones & Stage 4 Miniature Satellites
if (stage === 3 && !catState.isFrozen) {
const echoSpread = room.catScale * 0.95 * smoothstep(0, 0.6, stageTime);
for (const side of [-1, 1]) {
drawCat3D(ctx, {
cx: catCenterX + side * echoSpread,
cy: catDrawY + Math.sin(stageTime * 4 + side) * 14,
scale: room.catScale * 0.72,
yaw: -catState.yaw + side * 1.2,
tiltZ: -catState.tiltZ,
squashX: catState.squashX,
squashY: catState.squashY,
omega: catState.omega * 0.85,
pupilDilate: 1,
alpha: 0.45,
eyeGlow: 0.7
});
}
}
if (stage === 4) {
const satCount = 6;
const satRadX = W * 0.25;
const satRadY = H * 0.17;
for (let s = 0; s < satCount; s++) {
const a = (s / satCount) * Math.PI * 2 + stageTime * (catState.isFrozen ? 0 : 2.6);
const sx = catCenterX + Math.cos(a) * satRadX;
const sy = (catDrawY - room.catScale * 0.35) + Math.sin(a) * satRadY;
const depthSc = 0.28 + Math.sin(a) * 0.06;
drawCat3D(ctx, {
cx: sx,
cy: sy,
scale: room.catScale * depthSc,
yaw: catState.isFrozen ? 0 : catState.yaw * 1.25 + s,
omega: catState.isFrozen ? 0 : catState.omega,
pupilDilate: 1,
alpha: 0.88,
eyeGlow: catState.isFrozen ? 0 : 0.8
});
}
}
// 7. HERO OIIA CAT
drawCat3D(ctx, {
cx: catCenterX,
cy: catDrawY,
scale: room.catScale,
yaw: catState.yaw,
tiltX: catState.tiltX,
tiltZ: catState.tiltZ,
squashX: catState.squashX,
squashY: catState.squashY,
omega: catState.isFrozen ? 0 : catState.omega,
blink: catState.blinkVal,
pupilDilate: catState.pupil,
breathe: stage === 0 ? Math.sin(idleTime * 2.1) : 0,
alpha: 1,
eyeGlow: stage >= 3 && !catState.isFrozen ? 0.65 : 0
});
// Front-orbiting props drawn over the cat
if (stage >= 2 && room._frontOrbits) {
for (const item of room._frontOrbits) {
drawProp(item.op.p, item.x, item.y, item.rot, item.sc);
}
room._frontOrbits = null;
}
ctx.restore();
requestAnimationFrame(frame);
}
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, 2);
W = window.innerWidth;
H = window.innerHeight;
canvas.width = Math.round(W * DPR);
canvas.height = Math.round(H * DPR);
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
buildRoomAssets();
}
window.addEventListener('resize', resize);
resize();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
F1, F2, F3) plus a warm throat lowpass filter. A 17-step vowel table (o, i, a matching "oi-i-a oi-i-a oi-i-a oooo-i-a-i") glides formant targets smoothly to form real diphthongs. Both the syllable index and the vocal fundamental frequency read directly from the cat's cumulative spinPhase and angular velocity omega every frame (including stereo panning from sin(yaw) and instant gating to zero during Stage 4 freeze frames), so the voice cannot drift from the rotation.