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.0">
<title>OIIA</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; height: 100%; overflow: hidden;
background: #0d0b09;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
user-select: none; -webkit-user-select: none;
}
canvas {
display: block; width: 100%; height: 100%;
cursor: pointer;
}
#ui {
position: fixed; top: 18px; left: 18px; right: 18px;
display: flex; justify-content: space-between; align-items: flex-start;
pointer-events: none; z-index: 10;
}
.pill {
pointer-events: auto;
background: rgba(18, 15, 12, 0.72);
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 245, 230, 0.14);
color: rgba(255, 248, 240, 0.88);
padding: 8px 14px; border-radius: 999px;
font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase;
font-weight: 600; display: flex; align-items: center; gap: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
transition: transform 0.15s ease, background 0.2s ease, border-color 0.2s ease;
cursor: pointer;
}
.pill:hover {
background: rgba(32, 27, 22, 0.85);
border-color: rgba(255, 245, 230, 0.3);
}
.pill:active { transform: scale(0.96); }
#stage-badge {
pointer-events: none;
opacity: 0; transform: translateY(-6px);
transition: opacity 0.25s ease, transform 0.25s ease;
}
#stage-badge.show {
opacity: 1; transform: translateY(0);
}
.dot {
width: 8px; height: 8px; border-radius: 50%;
background: #888; box-shadow: 0 0 8px currentColor;
}
#hint {
position: fixed; bottom: 26px; left: 50%; transform: translateX(-50%);
color: rgba(255, 248, 238, 0.72);
background: rgba(15, 12, 10, 0.58);
backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.1);
padding: 8px 18px; border-radius: 999px;
font-size: 13px; letter-spacing: 0.04em;
pointer-events: none;
transition: opacity 0.4s ease, transform 0.4s ease;
box-shadow: 0 6px 24px rgba(0,0,0,0.45);
}
#hint.hide {
opacity: 0; transform: translateX(-50%) translateY(10px);
}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="ui">
<div id="stage-badge" class="pill">
<span class="dot" id="stage-dot"></span>
<span id="stage-text">STAGE 0 // STILL</span>
</div>
<div style="display:flex; gap:10px;">
<button id="reset-btn" class="pill" style="display:none;" title="Return to calm">
<span>RESET</span>
</button>
<button id="mute-btn" class="pill" title="Toggle Audio">
<span id="mute-icon">🔊</span>
<span id="mute-label">SOUND ON</span>
</button>
</div>
</div>
<div id="hint">Click Ethel</div>
<script>
(() => {
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d', { alpha: false });
const stageBadge = document.getElementById('stage-badge');
const stageText = document.getElementById('stage-text');
const stageDot = document.getElementById('stage-dot');
const muteBtn = document.getElementById('mute-btn');
const muteIcon = document.getElementById('mute-icon');
const muteLabel = document.getElementById('mute-label');
const resetBtn = document.getElementById('reset-btn');
const hintEl = document.getElementById('hint');
let W = 0, H = 0, DPR = 1;
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, 2);
W = Math.floor(window.innerWidth);
H = Math.floor(window.innerHeight);
canvas.width = W * DPR;
canvas.height = H * DPR;
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
window.addEventListener('resize', resize);
resize();
// ============================================================================
// PRE-RENDERED HIGH-REALISM TEXTURES & ASSETS (Procedural Photo-Realism)
// ============================================================================
// Wood floor plank texture
const woodCanvas = document.createElement('canvas');
woodCanvas.width = 512; woodCanvas.height = 512;
const wctx = woodCanvas.getContext('2d');
(function buildWood() {
wctx.fillStyle = '#5c3c24';
wctx.fillRect(0, 0, 512, 512);
// wood grain lines
for (let i = 0; i < 1600; i++) {
const y = Math.random() * 512;
const h = 1 + Math.random() * 3;
const l = 25 + Math.random() * 20;
wctx.fillStyle = `hsla(26, 48%, ${l}%, ${0.06 + Math.random() * 0.08})`;
wctx.fillRect(0, y, 512, h);
}
// plank seams
const planks = 6;
const pw = 512 / planks;
for (let p = 0; p < planks; p++) {
const y = p * pw;
wctx.fillStyle = 'rgba(15, 8, 3, 0.65)';
wctx.fillRect(0, y, 512, 2);
wctx.fillStyle = 'rgba(255, 210, 160, 0.08)';
wctx.fillRect(0, y + 2, 512, 1.5);
// vertical joints
const jx = ((p * 173) % 420) + 40;
wctx.fillStyle = 'rgba(15, 8, 3, 0.55)';
wctx.fillRect(jx, y, 2, pw);
}
})();
// Subtle wall plaster / grain noise
const noiseCanvas = document.createElement('canvas');
noiseCanvas.width = 180; noiseCanvas.height = 180;
const nctx = noiseCanvas.getContext('2d');
(function buildNoise() {
const img = nctx.createImageData(180, 180);
const d = img.data;
for (let i = 0; i < d.length; i += 4) {
const v = (Math.random() * 255) | 0;
d[i] = v; d[i+1] = v; d[i+2] = v;
d[i+3] = 14;
}
nctx.putImageData(img, 0, 0);
})();
const noisePattern = ctx.createPattern(noiseCanvas, 'repeat');
// Pre-rendered detailed static Oiia Cat ("Ethel") portrait & 3D spin slices
// Plus a blurred high-speed cylindrical smear texture for authentic meme motion blur.
const catIdleCanvas = document.createElement('canvas');
catIdleCanvas.width = 420; catIdleCanvas.height = 460;
const cctx = catIdleCanvas.getContext('2d');
// Helper to paint fur strands along a region
function strokeFur(g, x, y, len, angle, color, alpha, width = 1.3) {
g.save();
g.translate(x, y);
g.rotate(angle);
g.strokeStyle = color;
g.globalAlpha = alpha;
g.lineWidth = width;
g.lineCap = 'round';
g.beginPath();
g.moveTo(0, 0);
g.quadraticCurveTo(len * 0.5, (Math.random() - 0.5) * 3, len, (Math.random() - 0.5) * 4);
g.stroke();
g.restore();
}
// Deterministic pseudo-random for consistent fur texture
let seed = 1337;
function srand() {
seed = (seed * 16807) % 2147483647;
return (seed - 1) / 2147483646;
}
function renderCatRig(g, yaw = 0, blurFactor = 0, laserPower = 0, squash = 1, stretch = 1) {
// Center of cat canvas: (210, 255)
g.save();
g.translate(210, 395);
g.scale(squash, stretch);
g.translate(0, -140);
const cosY = Math.cos(yaw);
const sinY = Math.sin(yaw);
// At high blurFactor, the cat compresses horizontally into the iconic OIIA pill-cylinder
const pillTighten = 1 - blurFactor * 0.14;
const bodyW = (112 + Math.abs(sinY) * 14) * pillTighten;
const bodyH = 148;
// 1. TAIL (rotates around the back/side depending on yaw)
const tailX = -sinY * 86;
const tailZ = -cosY; // positive when facing away
const drawTail = () => {
g.save();
g.translate(tailX, 118);
const tailCurl = sinY * 0.45;
g.beginPath();
g.moveTo(0, 0);
g.bezierCurveTo(-sinY * 45 - 28, 12, -sinY * 65 - 15, -35, -sinY * 40, -52);
g.lineWidth = 26 * (1 - blurFactor * 0.4);
g.lineCap = 'round';
g.strokeStyle = '#c89e70';
g.stroke();
g.lineWidth = 20 * (1 - blurFactor * 0.4);
g.strokeStyle = '#dfc09a';
g.stroke();
g.restore();
};
if (tailZ <= 0) drawTail();
// 2. PLUMP LOAF / SITTING TORSO (Pear-shaped cream & white cat)
// Shadowed base body shape
g.save();
g.beginPath();
g.moveTo(-bodyW * 0.62, -45);
g.bezierCurveTo(-bodyW * 0.95, 10, -bodyW * 1.06, 88, -bodyW * 0.78, 128);
g.bezierCurveTo(-bodyW * 0.42, 146, bodyW * 0.42, 146, bodyW * 0.78, 128);
g.bezierCurveTo(bodyW * 1.06, 88, bodyW * 0.95, 10, bodyW * 0.62, -45);
g.closePath();
// Rich 3D cylindrical shading simulating window light from left-front
const bodyGrad = g.createLinearGradient(-bodyW, -20, bodyW, 40);
bodyGrad.addColorStop(0, '#f3dfc3');
bodyGrad.addColorStop(0.28, '#e8caa4');
bodyGrad.addColorStop(0.65, '#cfa679');
bodyGrad.addColorStop(1, '#8c6744');
g.fillStyle = bodyGrad;
g.fill();
// White bib / chest patch (rotates with cosY/sinY)
if (cosY > -0.45 || blurFactor > 0.3) {
const bibX = sinY * 52 * (1 - blurFactor * 0.7);
const bibW = Math.max(18, 68 * Math.max(0.15, (cosY + 0.45) / 1.45) + blurFactor * 42);
const bibGrad = g.createRadialGradient(bibX - 10, 35, 8, bibX, 45, bibW * 1.35);
bibGrad.addColorStop(0, '#fffdf9');
bibGrad.addColorStop(0.55, '#f7efe4');
bibGrad.addColorStop(0.85, 'rgba(235, 214, 188, 0.6)');
bibGrad.addColorStop(1, 'rgba(220, 190, 155, 0)');
g.fillStyle = bibGrad;
g.beginPath();
g.ellipse(bibX, 48, bibW, 82, 0, 0, Math.PI * 2);
g.fill();
}
// Back ginger/cream saddle markings (visible when turned sideways or backward, or smeared)
if (cosY < 0.5 || blurFactor > 0.25) {
const backX = -sinY * 45 * (1 - blurFactor * 0.7);
const backAlpha = Math.min(1, Math.max(0, (0.5 - cosY) * 0.9) + blurFactor * 0.55);
const saddleGrad = g.createRadialGradient(backX, 25, 10, backX, 35, 95);
saddleGrad.addColorStop(0, `rgba(196, 142, 88, ${backAlpha})`);
saddleGrad.addColorStop(0.6, `rgba(175, 122, 70, ${backAlpha * 0.7})`);
saddleGrad.addColorStop(1, 'rgba(175, 122, 70, 0)');
g.fillStyle = saddleGrad;
g.beginPath();
g.ellipse(backX, 35, 88, 86, 0, 0, Math.PI * 2);
g.fill();
}
// Horizontal motion-blur banding when spinning fast (key OIIA meme visual!)
if (blurFactor > 0.08) {
g.save();
g.clip();
const bands = 18;
for (let b = 0; b < bands; b++) {
const by = -45 + (b / bands) * 185;
const bh = 6 + (b % 3) * 4;
const phase = (yaw * 3 + b * 1.7);
const lum = 72 + Math.sin(phase) * 16;
g.fillStyle = `hsla(33, 46%, ${lum}%, ${blurFactor * 0.48})`;
g.fillRect(-bodyW * 1.2, by, bodyW * 2.4, bh);
}
g.restore();
}
g.restore();
// 3. FRONT PAWS (neat little white mittens tucked in front, disappear into blur when spinning)
const pawVis = Math.max(0, 1 - blurFactor * 1.45) * Math.max(0, cosY + 0.25);
if (pawVis > 0.02) {
g.save();
g.globalAlpha = Math.min(1, pawVis);
const pawShift = sinY * 42;
[-32, 32].forEach((px, idx) => {
const x = px * (0.7 + 0.3 * Math.abs(cosY)) + pawShift;
const y = 128 + (idx === 0 ? -sinY * 4 : sinY * 4);
// Shadow under paw
g.fillStyle = 'rgba(65, 42, 24, 0.38)';
g.beginPath();
g.ellipse(x, y + 8, 25, 9, 0, 0, Math.PI * 2);
g.fill();
// Paw mound
const pGrad = g.createRadialGradient(x - 5, y - 6, 3, x, y, 26);
pGrad.addColorStop(0, '#ffffff');
pGrad.addColorStop(0.65, '#f2e8dc');
pGrad.addColorStop(1, '#cbb69e');
g.fillStyle = pGrad;
g.beginPath();
g.ellipse(x, y, 23, 15, 0, 0, Math.PI * 2);
g.fill();
// Toe beans crease lines
g.strokeStyle = 'rgba(130, 100, 75, 0.35)';
g.lineWidth = 1.4;
for (let t = -1; t <= 1; t += 2) {
g.beginPath();
g.moveTo(x + t * 7, y + 4);
g.lineTo(x + t * 6, y + 12);
g.stroke();
}
});
g.restore();
}
// 4. HEAD & EARS
const headY = -58;
const headW = (86 + Math.abs(sinY) * 6) * pillTighten;
const headH = 74;
// Ears (positioned on 3D head crown)
const drawEar = (side) => {
// side: -1 left, +1 right
const earAngle = yaw + side * 0.62;
const ex = Math.sin(earAngle) * 64 * (1 - blurFactor * 0.25);
const ez = Math.cos(earAngle);
const ey = headY - 40 + ez * 4;
g.save();
g.translate(ex, ey);
const tilt = side * 0.28 * Math.max(-0.4, cosY);
g.rotate(tilt);
// Outer ear
g.beginPath();
g.moveTo(-24, 18);
g.quadraticCurveTo(-18, -28, 0, -34);
g.quadraticCurveTo(18, -28, 24, 18);
g.closePath();
const eGrad = g.createLinearGradient(-18, -30, 18, 18);
eGrad.addColorStop(0, '#d8aa79');
eGrad.addColorStop(1, '#9e7248');
g.fillStyle = eGrad;
g.fill();
// Inner pink ear (visible when ear faces forward)
if (ez > -0.2 && blurFactor < 0.7) {
g.globalAlpha = Math.min(1, (ez + 0.2) * (1 - blurFactor));
g.beginPath();
g.moveTo(-13, 14);
g.quadraticCurveTo(-9, -18, 0, -23);
g.quadraticCurveTo(9, -18, 13, 14);
g.closePath();
const pinkG = g.createLinearGradient(0, -22, 0, 14);
pinkG.addColorStop(0, '#dba096');
pinkG.addColorStop(1, '#f0cfc7');
g.fillStyle = pinkG;
g.fill();
}
g.restore();
};
// Draw back-most ear first, then head, then front-most ear
if (sinY > 0) { drawEar(-1); drawEar(1); }
else { drawEar(1); drawEar(-1); }
// Head sphere/cheeks (Ethel has round British-shorthair-like cheeks)
g.save();
g.beginPath();
g.ellipse(0, headY, headW, headH, 0, 0, Math.PI * 2);
const hGrad = g.createRadialGradient(-headW * 0.28, headY - 18, 10, 0, headY, headW * 1.15);
hGrad.addColorStop(0, '#f8e8d0');
hGrad.addColorStop(0.45, '#e5c49c');
hGrad.addColorStop(0.82, '#c5996b');
hGrad.addColorStop(1, '#87623f');
g.fillStyle = hGrad;
g.fill();
// Forehead tabby 'M' & cream patches
const faceX = sinY * 54;
if (cosY > -0.2) {
g.save();
g.globalAlpha = Math.max(0, cosY) * (1 - blurFactor * 0.7);
// White muzzle blaze
const muzGrad = g.createRadialGradient(faceX, headY + 24, 4, faceX, headY + 22, 46);
muzGrad.addColorStop(0, '#ffffff');
muzGrad.addColorStop(0.6, '#f8f0e6');
muzGrad.addColorStop(1, 'rgba(240, 220, 195, 0)');
g.fillStyle = muzGrad;
g.beginPath();
g.ellipse(faceX, headY + 22, 44 * Math.max(0.25, cosY), 35, 0, 0, Math.PI * 2);
g.fill();
// Subtle orange forehead stripes
g.strokeStyle = 'rgba(168, 112, 62, 0.45)';
g.lineWidth = 4;
g.lineCap = 'round';
for (let s = -1.5; s <= 1.5; s += 1) {
g.beginPath();
g.moveTo(faceX + s * 11 * cosY, headY - 46);
g.quadraticCurveTo(faceX + s * 14 * cosY, headY - 30, faceX + s * 9 * cosY, headY - 18);
g.stroke();
}
g.restore();
}
// Horizontal head smear streaks during fast spin
if (blurFactor > 0.08) {
g.save();
g.clip();
for (let b = 0; b < 10; b++) {
const by = headY - headH + (b / 10) * headH * 2;
const lum = 66 + Math.cos(yaw * 4 + b * 1.9) * 18;
g.fillStyle = `hsla(32, 42%, ${lum}%, ${blurFactor * 0.52})`;
g.fillRect(-headW * 1.2, by, headW * 2.4, 8);
}
// Eye-band dark streak when face smears around
g.fillStyle = `rgba(22, 18, 14, ${blurFactor * 0.42 * Math.max(0.2, cosY + 0.5)})`;
g.fillRect(-headW, headY - 10, headW * 2, 15);
g.restore();
}
g.restore();
// 5. FACIAL FEATURES (Wide unblinking Oiia stare straight into the camera)
if (cosY > -0.05) {
const fVis = Math.min(1, (cosY + 0.05) * 1.3) * Math.max(0.08, 1 - blurFactor * 0.88);
g.save();
g.globalAlpha = fVis;
const eyeSpread = 31 * Math.max(0.18, cosY);
const eyeY = headY - 2;
[-1, 1].forEach((side) => {
const ex = faceX + side * eyeSpread;
const ew = 16.5 * Math.max(0.32, cosY + side * sinY * 0.25);
const eh = 16.5;
// Dark eyeliner rim
g.fillStyle = '#241a12';
g.beginPath();
g.ellipse(ex, eyeY, ew + 2.2, eh + 2.2, 0, 0, Math.PI * 2);
g.fill();
// Olive-amber iris
const irisG = g.createRadialGradient(ex - 2, eyeY - 2, 2, ex, eyeY, ew);
irisG.addColorStop(0, '#d8c96c');
irisG.addColorStop(0.65, '#9e8f3e');
irisG.addColorStop(1, '#4f461a');
g.fillStyle = irisG;
g.beginPath();
g.ellipse(ex, eyeY, ew, eh, 0, 0, Math.PI * 2);
g.fill();
// Huge dilated Oiia pupils (staring into your soul)
g.fillStyle = '#090807';
g.beginPath();
g.ellipse(ex, eyeY, ew * 0.72, eh * 0.82, 0, 0, Math.PI * 2);
g.fill();
// Crisp window catchlight in eyes
g.fillStyle = 'rgba(255, 253, 245, 0.95)';
g.beginPath();
g.ellipse(ex - ew * 0.32, eyeY - eh * 0.32, ew * 0.26, eh * 0.22, -0.3, 0, Math.PI * 2);
g.fill();
g.fillStyle = 'rgba(255, 253, 245, 0.55)';
g.beginPath();
g.arc(ex + ew * 0.28, eyeY + eh * 0.3, ew * 0.12, 0, Math.PI * 2);
g.fill();
// Stage 3/4 cosmic laser eyes glow
if (laserPower > 0.01) {
g.save();
g.globalCompositeOperation = 'lighter';
const lg = g.createRadialGradient(ex, eyeY, 2, ex, eyeY, 34);
lg.addColorStop(0, `rgba(255, 255, 255, ${laserPower})`);
lg.addColorStop(0.3, `rgba(0, 245, 255, ${laserPower * 0.9})`);
lg.addColorStop(1, 'rgba(255, 0, 180, 0)');
g.fillStyle = lg;
g.beginPath();
g.arc(ex, eyeY, 34, 0, Math.PI * 2);
g.fill();
g.restore();
}
});
// Little terracotta-pink nose
const noseX = faceX + sinY * 6;
const noseY = headY + 18;
const nw = 9 * Math.max(0.35, cosY);
g.fillStyle = '#d98279';
g.beginPath();
g.moveTo(noseX - nw, noseY - 4);
g.quadraticCurveTo(noseX, noseY - 7, noseX + nw, noseY - 4);
g.lineTo(noseX, noseY + 5);
g.closePath();
g.fill();
// Nose nostril shadow
g.strokeStyle = '#6e342f';
g.lineWidth = 1.6;
g.beginPath();
g.moveTo(noseX, noseY + 5);
g.lineTo(noseX, noseY + 13);
g.moveTo(noseX - nw * 1.1, noseY + 16);
g.quadraticCurveTo(noseX, noseY + 11, noseX + nw * 1.1, noseY + 16);
g.stroke();
// Little dark chin spot (signature Oiia cat expression)
g.fillStyle = 'rgba(70, 50, 38, 0.55)';
g.beginPath();
g.ellipse(noseX, noseY + 17, nw * 0.85, 3.2, 0, 0, Math.PI * 2);
g.fill();
// Long fine white whiskers
if (blurFactor < 0.5) {
g.strokeStyle = 'rgba(255, 252, 246, 0.72)';
g.lineWidth = 1.15;
[-1, 1].forEach((side) => {
for (let w = 0; w < 4; w++) {
const wy = noseY + 6 + w * 3.2;
const wx = noseX + side * 14 * cosY;
g.beginPath();
g.moveTo(wx, wy);
g.quadraticCurveTo(wx + side * 42, wy - 3 + w * 2, wx + side * 78, wy + 6 + w * 4);
g.stroke();
}
});
}
g.restore();
}
if (tailZ > 0) drawTail();
g.restore();
}
// Paint the high-detail realistic resting portrait onto catIdleCanvas (with hundreds of individual fur hairs)
(function buildIdleCat() {
seed = 4242;
renderCatRig(cctx, 0, 0, 0, 1, 1);
// Layer realistic perimeter & surface fur strands so the silhouette isn't a vector cutout
for (let i = 0; i < 1900; i++) {
const t = srand() * Math.PI * 2;
const isHead = srand() < 0.42;
let cx = 210, cy = isHead ? 197 : 292;
let rx = isHead ? 84 : 106;
let ry = isHead ? 70 : 92;
const rJitter = 0.55 + srand() * 0.48;
const x = cx + Math.cos(t) * rx * rJitter;
const y = cy + Math.sin(t) * ry * rJitter;
if (y > 395) continue;
// Determine fur color based on region (bib vs cream coat vs shadow side)
const distCenter = Math.hypot((x - 210) / 65, (y - 295) / 80);
let col;
if (distCenter < 0.85 && y > 205) {
const v = 242 + (srand() * 13) | 0;
col = `rgb(${v}, ${v - 4}, ${v - 10})`;
} else {
const shade = (x - 210) / 110; // darker on right
const r = Math.min(255, Math.max(110, (226 - shade * 45 + (srand() - 0.5) * 26) | 0));
const g = Math.min(255, Math.max(85, (190 - shade * 42 + (srand() - 0.5) * 22) | 0));
const b = Math.min(255, Math.max(60, (148 - shade * 38 + (srand() - 0.5) * 18) | 0));
col = `rgb(${r},${g},${b})`;
}
// Avoid painting over the eyes/nose
if (isHead && Math.abs(x - 210) < 52 && y > 175 && y < 228) continue;
const ang = Math.atan2(y - cy, x - cx) * 0.35 + Math.PI * 0.5;
strokeFur(cctx, x, y, 7 + srand() * 9, ang, col, 0.38 + srand() * 0.35, 1.15);
}
})();
// Pre-render 24 frames of 3D rotation + 8 frames of high-speed Oiia motion-blurred spin
// so runtime stays locked at silky 60fps even with clones in Stage 4.
const SPIN_FRAMES = 24;
const spinSlowCanvases = [];
const spinFastCanvases = [];
for (let i = 0; i < SPIN_FRAMES; i++) {
const yaw = (i / SPIN_FRAMES) * Math.PI * 2;
const cSlow = document.createElement('canvas');
cSlow.width = 420; cSlow.height = 460;
renderCatRig(cSlow.getContext('2d'), yaw, 0.12, 0, 1, 1);
spinSlowCanvases.push(cSlow);
const cFast = document.createElement('canvas');
cFast.width = 420; cFast.height = 460;
renderCatRig(cFast.getContext('2d'), yaw, 0.82, 0.3, 0.94, 1.04);
spinFastCanvases.push(cFast);
}
// ============================================================================
// WEB AUDIO SYNTHESIZER: FORMANT VOCAL "OIIA OIIA OIIIAIOOOOIAI" ENGINE
// ============================================================================
let audioCtx = null;
let masterGain = null;
let isMuted = false;
// Classic Oiia meme syllable sequence:
// 'O' -> 'I' -> 'I' -> 'A' (short pause) 'O' -> 'I' -> 'I' -> 'A' ...
// Each vowel is defined by its first 3 vocal tract formant frequencies (F1, F2, F3) in Hz
// and pitch inflection multiplier so it genuinely talks/sings "o-i-i-a"!
const VOWELS = {
O: { f1: 430, f2: 820, f3: 2550, p: 0.96, amp: 0.95 },
U: { f1: 350, f2: 700, f3: 2450, p: 0.92, amp: 0.90 },
I: { f1: 290, f2: 2320, f3: 3050, p: 1.12, amp: 1.00 },
E: { f1: 480, f2: 1950, f3: 2650, p: 1.05, amp: 0.95 },
A: { f1: 780, f2: 1280, f3: 2500, p: 0.94, amp: 1.00 },
_: { f1: 400, f2: 1000, f3: 2400, p: 0.90, amp: 0.04 } // micro-glottal stop between phrases
};
// Exact phonetic pattern of "o-i-i-a · o-i-i-a · o-i-i-a-i-o-o-o-o-i-a-i"
const PHRASE = [
{ v: 'O', d: 1.1 }, { v: 'I', d: 0.75 }, { v: 'I', d: 0.85 }, { v: 'A', d: 1.35 }, { v: '_', d: 0.55 },
{ v: 'O', d: 1.0 }, { v: 'I', d: 0.75 }, { v: 'I', d: 0.85 }, { v: 'A', d: 1.35 }, { v: '_', d: 0.45 },
{ v: 'O', d: 0.9 }, { v: 'I', d: 0.65 }, { v: 'I', d: 0.65 }, { v: 'A', d: 0.85 },
{ v: 'I', d: 0.75 }, { v: 'O', d: 0.75 }, { v: 'U', d: 0.75 }, { v: 'O', d: 0.75 }, { v: 'O', d: 0.75 },
{ v: 'I', d: 0.75 }, { v: 'A', d: 0.95 }, { v: 'I', d: 0.95 }, { v: '_', d: 0.45 }
];
let voiceNodes = null;
let phraseIdx = 0;
let syllableProgress = 0;
function initAudio() {
if (audioCtx) {
if (audioCtx.state === 'suspended') audioCtx.resume();
return;
}
const AC = window.AudioContext || window.webkitAudioContext;
audioCtx = new AC();
masterGain = audioCtx.createGain();
masterGain.gain.value = isMuted ? 0 : 0.75;
// Gentle compressor so stage 4 rave bass + vocals stay punchy and never clip
const comp = audioCtx.createDynamicsCompressor();
comp.threshold.value = -14;
comp.knee.value = 12;
comp.ratio.value = 6;
comp.attack.value = 0.005;
comp.release.value = 0.12;
masterGain.connect(comp);
comp.connect(audioCtx.destination);
// Build glottal vocal cord source (sawtooth + narrow pulse + breath noise)
const glottalSaw = audioCtx.createOscillator();
glottalSaw.type = 'sawtooth';
glottalSaw.frequency.value = 175;
const glottalSub = audioCtx.createOscillator();
glottalSub.type = 'triangle';
glottalSub.frequency.value = 175;
const voiceGain = audioCtx.createGain();
voiceGain.gain.value = 0;
// Three parallel bandpass formant filters (F1, F2, F3)
const f1 = audioCtx.createBiquadFilter();
const f2 = audioCtx.createBiquadFilter();
const f3 = audioCtx.createBiquadFilter();
[f1, f2, f3].forEach((f, idx) => {
f.type = 'bandpass';
f.Q.value = idx === 0 ? 5.5 : (idx === 1 ? 7.5 : 9.0);
});
const f1Gain = audioCtx.createGain(); f1Gain.gain.value = 1.0;
const f2Gain = audioCtx.createGain(); f2Gain.gain.value = 0.85;
const f3Gain = audioCtx.createGain(); f3Gain.gain.value = 0.38;
// Nasal presence peak (gives it that distinct compressed meme vocal recording character)
const memeBoxFilter = audioCtx.createBiquadFilter();
memeBoxFilter.type = 'peaking';
memeBoxFilter.frequency.value = 1450;
memeBoxFilter.Q.value = 1.8;
memeBoxFilter.gain.value = 5;
glottalSaw.connect(f1); glottalSaw.connect(f2); glottalSaw.connect(f3);
glottalSub.connect(f1);
f1.connect(f1Gain); f2.connect(f2Gain); f3.connect(f3Gain);
f1Gain.connect(memeBoxFilter);
f2Gain.connect(memeBoxFilter);
f3Gain.connect(memeBoxFilter);
memeBoxFilter.connect(voiceGain);
voiceGain.connect(masterGain);
// Stage 3/4 Eurodance / Rave Backing Synth & Sub Drone
const raveOsc = audioCtx.createOscillator();
raveOsc.type = 'sawtooth';
raveOsc.frequency.value = 110;
const raveFilter = audioCtx.createBiquadFilter();
raveFilter.type = 'lowpass';
raveFilter.frequency.value = 900;
raveFilter.Q.value = 4;
const raveGain = audioCtx.createGain();
raveGain.gain.value = 0;
raveOsc.connect(raveFilter);
raveFilter.connect(raveGain);
raveGain.connect(masterGain);
glottalSaw.start();
glottalSub.start();
raveOsc.start();
voiceNodes = {
glottalSaw, glottalSub, voiceGain, f1, f2, f3,
raveOsc, raveFilter, raveGain
};
}
// Trigger a short percussive kick/pop when clicking to escalate
function playClickImpact(stageNum) {
if (!audioCtx || isMuted) return;
const now = audioCtx.currentTime;
const osc = audioCtx.createOscillator();
const g = audioCtx.createGain();
osc.type = 'sine';
const startF = 140 + stageNum * 55;
osc.frequency.setValueAtTime(startF, now);
osc.frequency.exponentialRampToValueAtTime(38, now + 0.16);
g.gain.setValueAtTime(0.45, now);
g.gain.exponentialRampToValueAtTime(0.001, now + 0.17);
osc.connect(g);
g.connect(masterGain);
osc.start(now);
osc.stop(now + 0.18);
}
function updateAudio(dt, spinSpeed, stage) {
if (!audioCtx || !voiceNodes) return;
const now = audioCtx.currentTime;
if (stage === 0 || spinSpeed < 0.2) {
voiceNodes.voiceGain.gain.setTargetAtTime(0, now, 0.05);
voiceNodes.raveGain.gain.setTargetAtTime(0, now, 0.08);
return;
}
// Advance phoneme clock strictly proportional to angular velocity (spinSpeed)
// so the "O-I-I-A" cadence accelerates and pitches up hand-in-hand with the cat's spin!
const syllablesPerSec = 2.2 + spinSpeed * 0.42;
const curStep = PHRASE[phraseIdx];
syllableProgress += (dt * syllablesPerSec) / curStep.d;
if (syllableProgress >= 1) {
syllableProgress -= 1;
phraseIdx = (phraseIdx + 1) % PHRASE.length;
}
const vowel = VOWELS[PHRASE[phraseIdx].v];
// Pitch climbs with spin speed + slight vibrato
const speedNorm = Math.min(1, spinSpeed / 58);
const pitchScale = 1.0 + speedNorm * 0.88;
const vibrato = 1 + Math.sin(now * 28) * 0.015;
const basePitch = 168 * pitchScale * vowel.p * vibrato;
// Formants also shift slightly upward as the voice gets more frantic/chipmunked
const formantShift = 1.0 + speedNorm * 0.34;
const glideTime = Math.max(0.008, 0.028 - speedNorm * 0.018);
voiceNodes.glottalSaw.frequency.setTargetAtTime(basePitch, now, glideTime);
voiceNodes.glottalSub.frequency.setTargetAtTime(basePitch * 0.5, now, glideTime);
voiceNodes.f1.frequency.setTargetAtTime(vowel.f1 * formantShift, now, glideTime);
voiceNodes.f2.frequency.setTargetAtTime(vowel.f2 * formantShift, now, glideTime);
voiceNodes.f3.frequency.setTargetAtTime(vowel.f3 * formantShift, now, glideTime);
// Amplitude envelope per syllable for crisp "o-i-i-a" articulation
const envShape = Math.sin(Math.min(1, syllableProgress) * Math.PI);
const targetAmp = vowel.amp * (0.65 + 0.35 * envShape) * Math.min(1, spinSpeed / 5);
voiceNodes.voiceGain.gain.setTargetAtTime(targetAmp * 0.62, now, 0.012);
// Stage 2+ rave bassline & arpeggio driven by the syllable index
if (stage >= 2) {
const notes = [110, 130.81, 146.83, 164.81, 196.00, 220.00];
const note = notes[phraseIdx % notes.length] * (stage >= 4 ? (phraseIdx % 2 === 0 ? 1 : 2) : 1);
voiceNodes.raveOsc.frequency.setTargetAtTime(note, now, 0.015);
voiceNodes.raveFilter.frequency.setTargetAtTime(400 + speedNorm * 2400, now, 0.03);
const raveVol = stage === 2 ? 0.08 : (stage === 3 ? 0.16 : 0.25);
voiceNodes.raveGain.gain.setTargetAtTime(raveVol * (0.4 + 0.6 * envShape), now, 0.02);
} else {
voiceNodes.raveGain.gain.setTargetAtTime(0, now, 0.05);
}
}
// ============================================================================
// SCENE STATE, ESCALATION STAGES & PHYSICS
// ============================================================================
let stage = 0;
let totalTime = 0;
let stageTime = 0;
// Cat kinematics
const cat = {
yaw: 0,
spinSpeed: 0, // rad/sec
targetSpeed: 0,
hoverY: 0, // pixels lifted off floor
targetHoverY: 0,
bobPhase: 0,
tilt: 0,
scale: 1
};
// Floating living room props that get yanked into orbit in Stage 2+
const roomProps = [
{ id: 'mug', name: 'mug', homeX: -0.26, homeY: 0.03, x: 0, y: 0, z: 0, ang: 0, orbitPhase: 0.2, orbitR: 180, orbitY: -70 },
{ id: 'cushion', name: 'cushion', homeX: -0.19, homeY: -0.11, x: 0, y: 0, z: 0, ang: 0, orbitPhase: 1.5, orbitR: 250, orbitY: -120 },
{ id: 'book', name: 'book', homeX: -0.29, homeY: 0.05, x: 0, y: 0, z: 0, ang: 0, orbitPhase: 2.8, orbitR: 210, orbitY: -30 },
{ id: 'plant', name: 'plant', homeX: 0.31, homeY: -0.05, x: 0, y: 0, z: 0, ang: 0, orbitPhase: 4.1, orbitR: 280, orbitY: -90 },
{ id: 'frame', name: 'frame', homeX: 0.16, homeY: -0.31, x: 0, y: 0, z: 0, ang: 0, orbitPhase: 5.3, orbitR: 240, orbitY: -155 }
];
// Ambient dust motes (Stage 0-1) -> Vortex energy particles (Stage 2-4)
const particles = Array.from({ length: 85 }, () => ({
x: Math.random(),
y: Math.random(),
z: 0.2 + Math.random() * 0.8,
vx: (Math.random() - 0.5) * 0.015,
vy: -0.005 - Math.random() * 0.012,
hue: Math.random() * 360,
size: 1.2 + Math.random() * 2.5
}));
// Orbiting mini-Oiia clones for Stage 4 Singularity
const miniCats = Array.from({ length: 6 }, (_, i) => ({
angle: (i / 6) * Math.PI * 2,
radius: 220 + (i % 2) * 75,
yOffset: (i - 2.5) * 36,
yaw: i * 1.1,
scale: 0.34 + (i % 3) * 0.06
}));
// Shockwave rings triggered on click
const shockwaves = [];
const STAGE_INFO = [
{ label: 'STAGE 0 // QUIET AFTERNOON', color: '#a39686', targetSpin: 0, hover: 0 },
{ label: 'STAGE 1 // LIFTOFF (OIIA)', color: '#f5c26b', targetSpin: 14, hover: 78 },
{ label: 'STAGE 2 // POLTERGEIST SPIN', color: '#4deeea', targetSpin: 29, hover: 128 },
{ label: 'STAGE 3 // RAVE VORTEX', color: '#ff44cc', targetSpin: 46, hover: 155 },
{ label: 'STAGE 4 // OIIA SINGULARITY', color: '#fffb00', targetSpin: 68, hover: 175 }
];
function updateStageUI() {
const info = STAGE_INFO[Math.min(stage, 4)];
stageDot.style.background = info.color;
stageDot.style.color = info.color;
stageText.textContent = stage > 4
? `STAGE ${stage} // OVERDRIVE (${Math.round(cat.targetSpeed * 9.55)} RPM)`
: info.label;
if (stage > 0) {
stageBadge.classList.add('show');
resetBtn.style.display = 'flex';
hintEl.classList.add('hide');
} else {
stageBadge.classList.remove('show');
resetBtn.style.display = 'none';
hintEl.classList.remove('hide');
}
}
function advanceStage(clickX, clickY) {
initAudio();
stage++;
stageTime = 0;
const info = STAGE_INFO[Math.min(stage, 4)];
cat.targetSpeed = stage <= 4 ? info.targetSpin : 68 + (stage - 4) * 14;
cat.targetHoverY = info.hover;
playClickImpact(stage);
updateStageUI();
shockwaves.push({
x: clickX,
y: clickY,
r: 15,
maxR: Math.max(W, H) * (0.45 + Math.min(stage, 4) * 0.18),
alpha: 0.9,
hue: (stage * 78) % 360
});
}
function resetExperience(e) {
if (e) e.stopPropagation();
stage = 0;
stageTime = 0;
cat.targetSpeed = 0;
cat.targetHoverY = 0;
updateStageUI();
}
resetBtn.addEventListener('click', resetExperience);
muteBtn.addEventListener('click', (e) => {
e.stopPropagation();
initAudio();
isMuted = !isMuted;
if (masterGain && audioCtx) {
masterGain.gain.setTargetAtTime(isMuted ? 0 : 0.75, audioCtx.currentTime, 0.03);
}
muteIcon.textContent = isMuted ? '🔇' : '🔊';
muteLabel.textContent = isMuted ? 'MUTED' : 'SOUND ON';
});
canvas.addEventListener('pointerdown', (e) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// On Stage 0, require clicking near the cat (or anywhere if already spinning)
const cx = W * 0.5;
const floorY = H * 0.79;
const cy = floorY - cat.hoverY - 95 * cat.scale;
const dist = Math.hypot(x - cx, y - cy);
if (stage === 0 && dist > 190 * cat.scale) {
// Subtle nudge on the hint pill if they clicked far away in the room
hintEl.style.transform = 'translateX(-50%) scale(1.08)';
setTimeout(() => { hintEl.style.transform = ''; }, 180);
return;
}
advanceStage(x, y);
});
// ============================================================================
// RENDERING THE REALISTIC LIVING ROOM & ESCALATING REALITY WARP
// ============================================================================
function drawLivingRoom(chaos, raveAmt, vortexAmt, floorY, cx) {
const s = Math.min(W, H) / 900; // responsive layout scale
// 1. BACK WALL WITH WARM AFTERNOON LIGHTING GRADIENT
const wallGrad = ctx.createLinearGradient(0, 0, W, floorY);
if (raveAmt < 0.95) {
wallGrad.addColorStop(0, '#cfc2b0');
wallGrad.addColorStop(0.38, '#dfd3c3');
wallGrad.addColorStop(0.75, '#9d8f7e');
wallGrad.addColorStop(1, '#685d50');
ctx.fillStyle = wallGrad;
ctx.fillRect(0, 0, W, floorY);
// Plaster subtle grain
ctx.fillStyle = noisePattern;
ctx.fillRect(0, 0, W, floorY);
}
// 2. PERSPECTIVE HARDWOOD FLOOR
ctx.save();
ctx.beginPath();
ctx.rect(0, floorY, W, H - floorY);
ctx.clip();
const floorGrad = ctx.createLinearGradient(0, floorY, 0, H);
floorGrad.addColorStop(0, '#3d2717');
floorGrad.addColorStop(0.25, '#593922');
floorGrad.addColorStop(1, '#26170c');
ctx.fillStyle = floorGrad;
ctx.fillRect(0, floorY, W, H - floorY);
// Perspective floorboards radiating from vanishing point behind the cat
const vpX = cx - 60 * s + Math.sin(totalTime * 3) * chaos * 30;
const vpY = floorY - 120 * s;
ctx.strokeStyle = 'rgba(18, 10, 4, 0.42)';
ctx.lineWidth = 1.5;
for (let i = -18; i <= 18; i++) {
ctx.beginPath();
ctx.moveTo(vpX + i * 28 * s, floorY);
ctx.lineTo(vpX + i * 190 * s, H + 40);
ctx.stroke();
}
// Horizontal plank rows with perspective spacing
for (let r = 1; r <= 12; r++) {
const py = floorY + Math.pow(r / 12, 1.75) * (H - floorY);
ctx.fillStyle = 'rgba(15, 8, 3, 0.35)';
ctx.fillRect(0, py, W, 1.5);
ctx.fillStyle = 'rgba(255, 215, 170, 0.05)';
ctx.fillRect(0, py + 1.5, W, 1);
}
// Sunlight pool on the floorboards
const floorSun = ctx.createRadialGradient(cx - 80 * s, floorY + 75 * s, 20, cx - 40 * s, floorY + 85 * s, 440 * s);
floorSun.addColorStop(0, 'rgba(255, 236, 195, 0.34)');
floorSun.addColorStop(0.55, 'rgba(255, 220, 165, 0.12)');
floorSun.addColorStop(1, 'rgba(255, 210, 150, 0)');
ctx.fillStyle = floorSun;
ctx.fillRect(0, floorY, W, H - floorY);
ctx.restore();
// Baseboard molding separating wall and floor (bends when vortexAmt > 0)
const baseH = 22 * s;
ctx.fillStyle = '#e8dfd3';
ctx.beginPath();
ctx.moveTo(0, floorY - baseH);
ctx.quadraticCurveTo(cx, floorY - baseH + vortexAmt * 45 * Math.sin(totalTime * 6), W, floorY - baseH);
ctx.lineTo(W, floorY);
ctx.lineTo(0, floorY);
ctx.closePath();
ctx.fill();
ctx.fillStyle = 'rgba(0,0,0,0.28)';
ctx.fillRect(0, floorY - 2, W, 3);
// 3. LEFT SUNLIT WINDOW & SHEER CURTAINS
const winX = Math.max(24 * s, cx - 520 * s);
const winY = floorY - 490 * s;
const winW = 145 * s;
const winH = 340 * s;
ctx.save();
// Window exterior sky glow
const skyG = ctx.createLinearGradient(winX, winY, winX + winW, winY + winH);
skyG.addColorStop(0, '#fffdf7');
skyG.addColorStop(0.6, '#ffefd2');
skyG.addColorStop(1, '#f7d59c');
ctx.fillStyle = skyG;
ctx.fillRect(winX, winY, winW, winH);
// Distant blurred backyard foliage outside window
ctx.fillStyle = 'rgba(135, 158, 112, 0.35)';
ctx.beginPath();
ctx.arc(winX + 45 * s, winY + 220 * s, 55 * s, 0, Math.PI * 2);
ctx.arc(winX + 105 * s, winY + 250 * s, 65 * s, 0, Math.PI * 2);
ctx.fill();
// Window mullions & casing
ctx.strokeStyle = '#f2ece1';
ctx.lineWidth = 10 * s;
ctx.strokeRect(winX, winY, winW, winH);
ctx.lineWidth = 5 * s;
ctx.beginPath();
ctx.moveTo(winX + winW * 0.5, winY);
ctx.lineTo(winX + winW * 0.5, winY + winH);
ctx.moveTo(winX, winY + winH * 0.5);
ctx.lineTo(winX + winW, winY + winH * 0.5);
ctx.stroke();
// Billowing sheer curtain (whips wildly when cat spins in Stage 2+)
const curtainSway = Math.sin(totalTime * (1.5 + chaos * 10)) * (4 + chaos * 38) * s;
const curtGrad = ctx.createLinearGradient(winX + winW - 25 * s, winY, winX + winW + 45 * s, winY);
curtGrad.addColorStop(0, 'rgba(255, 252, 245, 0.85)');
curtGrad.addColorStop(1, 'rgba(225, 215, 200, 0.45)');
ctx.fillStyle = curtGrad;
ctx.beginPath();
ctx.moveTo(winX + winW - 20 * s, winY - 15 * s);
ctx.quadraticCurveTo(winX + winW + 25 * s + curtainSway, winY + winH * 0.5, winX + winW + 5 * s + curtainSway * 1.3, winY + winH + 35 * s);
ctx.lineTo(winX + winW - 28 * s, winY + winH + 35 * s);
ctx.closePath();
ctx.fill();
ctx.restore();
// 4. BACKGROUND SOFA (Mid-century warm cognac/olive fabric couch with depth shadow)
const sofaW = 470 * s;
const sofaH = 175 * s;
const sofaX = cx - sofaW * 0.52 + Math.sin(totalTime * 8) * vortexAmt * 18;
const sofaY = floorY - sofaH - 8 * s - vortexAmt * 28 * Math.abs(Math.sin(totalTime * 3));
ctx.save();
// Cast shadow behind and under sofa
ctx.fillStyle = 'rgba(20, 12, 6, 0.52)';
ctx.beginPath();
ctx.ellipse(sofaX + sofaW * 0.5, floorY + 2 * s, sofaW * 0.54, 18 * s, 0, 0, Math.PI * 2);
ctx.fill();
// Wooden tapered legs
ctx.fillStyle = '#362010';
[28 * s, sofaW - 36 * s].forEach((lx) => {
ctx.fillRect(sofaX + lx, sofaY + sofaH - 8 * s, 10 * s, 22 * s);
});
// Sofa backrest
const backG = ctx.createLinearGradient(sofaX, sofaY, sofaX + sofaW, sofaY + sofaH);
backG.addColorStop(0, '#6a7562');
backG.addColorStop(0.45, '#55604e');
backG.addColorStop(1, '#394234');
ctx.fillStyle = backG;
ctx.beginPath();
ctx.roundRect(sofaX + 18 * s, sofaY, sofaW - 36 * s, sofaH * 0.68, 16 * s);
ctx.fill();
// Sofa seat cushions (2 plush cushions)
const seatY = sofaY + sofaH * 0.48;
const seatH = sofaH * 0.46;
for (let c = 0; c < 2; c++) {
const cx0 = sofaX + 22 * s + c * (sofaW - 44 * s) * 0.5;
const cw = (sofaW - 46 * s) * 0.5;
const cG = ctx.createLinearGradient(cx0, seatY, cx0, seatY + seatH);
cG.addColorStop(0, '#78846f');
cG.addColorStop(0.3, '#5d6856');
cG.addColorStop(1, '#384034');
ctx.fillStyle = cG;
ctx.beginPath();
ctx.roundRect(cx0, seatY, cw, seatH, 12 * s);
ctx.fill();
}
// Left & Right armrests
ctx.fillStyle = '#626d5b';
ctx.beginPath();
ctx.roundRect(sofaX, sofaY + 26 * s, 34 * s, sofaH - 28 * s, 12 * s);
ctx.roundRect(sofaX + sofaW - 34 * s, sofaY + 26 * s, 34 * s, sofaH - 28 * s, 12 * s);
ctx.fill();
ctx.restore();
// 5. FLOOR LAMP ON RIGHT (Flickers to the beat in Stage 2+)
const lampX = cx + 275 * s;
const lampBaseY = floorY + 6 * s;
ctx.save();
// Lamp stem
ctx.strokeStyle = '#2a241e';
ctx.lineWidth = 4.5 * s;
ctx.beginPath();
ctx.moveTo(lampX, lampBaseY);
ctx.lineTo(lampX, lampBaseY - 310 * s);
ctx.stroke();
// Base disc
ctx.fillStyle = '#383028';
ctx.beginPath();
ctx.ellipse(lampX, lampBaseY, 28 * s, 7 * s, 0, 0, Math.PI * 2);
ctx.fill();
// Warm glowing lampshade
const bulbPulse = stage === 0 ? 1 : (0.6 + 0.4 * Math.sin(totalTime * 22));
const shadeY = lampBaseY - 355 * s;
const lampGlow = ctx.createRadialGradient(lampX, shadeY + 35 * s, 10, lampX, shadeY + 35 * s, 210 * s);
const glowHue = stage >= 3 ? (totalTime * 180) % 360 : 38;
const glowSat = stage >= 3 ? '95%' : '90%';
lampGlow.addColorStop(0, `hsla(${glowHue}, ${glowSat}, 70%, ${0.42 * bulbPulse})`);
lampGlow.addColorStop(1, `hsla(${glowHue}, ${glowSat}, 50%, 0)`);
ctx.fillStyle = lampGlow;
ctx.beginPath();
ctx.arc(lampX, shadeY + 35 * s, 210 * s, 0, Math.PI * 2);
ctx.fill();
// Linen shade trapezoid
const shGrad = ctx.createLinearGradient(lampX - 38 * s, shadeY, lampX + 38 * s, shadeY);
shGrad.addColorStop(0, '#fff3d6');
shGrad.addColorStop(0.5, '#ffe6b3');
shGrad.addColorStop(1, '#c9ae7e');
ctx.fillStyle = shGrad;
ctx.beginPath();
ctx.moveTo(lampX - 28 * s, shadeY);
ctx.lineTo(lampX + 28 * s, shadeY);
ctx.lineTo(lampX + 42 * s, shadeY + 62 * s);
ctx.lineTo(lampX - 42 * s, shadeY + 62 * s);
ctx.closePath();
ctx.fill();
ctx.restore();
// 6. WOVEN LIVING ROOM RUG UNDER ETHEL (Starts spinning like a portal in Stage 2+)
const rugY = floorY + 72 * s;
const rugW = 315 * s;
const rugH = 68 * s;
ctx.save();
ctx.translate(cx, rugY);
// Perspective flatten
ctx.scale(1, rugH / rugW);
ctx.rotate(stage >= 2 ? totalTime * (stage * 1.8) : 0);
// Outer rug fringe & shadow
ctx.fillStyle = 'rgba(20, 11, 5, 0.55)';
ctx.beginPath();
ctx.arc(0, 6 * s, rugW * 1.02, 0, Math.PI * 2);
ctx.fill();
const rugGrad = ctx.createRadialGradient(-40 * s, -20 * s, 15, 0, 0, rugW);
if (stage >= 3) {
const h1 = (totalTime * 120) % 360;
const h2 = (h1 + 140) % 360;
rugGrad.addColorStop(0, `hsl(${h1}, 85%, 55%)`);
rugGrad.addColorStop(0.5, `hsl(${h2}, 80%, 35%)`);
rugGrad.addColorStop(0.85, '#1a1025');
rugGrad.addColorStop(1, `hsl(${h1}, 90%, 60%)`);
} else {
rugGrad.addColorStop(0, '#dfd4c3');
rugGrad.addColorStop(0.55, '#c7b8a3');
rugGrad.addColorStop(0.82, '#8c4a36'); // warm terracotta border stripe
rugGrad.addColorStop(0.91, '#d6c8b4');
rugGrad.addColorStop(1, '#9e8e79');
}
ctx.fillStyle = rugGrad;
ctx.beginPath();
ctx.arc(0, 0, rugW, 0, Math.PI * 2);
ctx.fill();
// Geometric woven pattern on rug so its rotation is unmistakable
ctx.strokeStyle = stage >= 3 ? 'rgba(255,255,255,0.5)' : 'rgba(95, 65, 45, 0.28)';
ctx.lineWidth = 6 * s;
for (let k = 0; k < 12; k++) {
const a = (k / 12) * Math.PI * 2;
ctx.beginPath();
ctx.moveTo(Math.cos(a) * rugW * 0.35, Math.sin(a) * rugW * 0.35);
ctx.lineTo(Math.cos(a) * rugW * 0.82, Math.sin(a) * rugW * 0.82);
ctx.stroke();
}
ctx.restore();
}
// Draw the room props (in Stage 0-1 they rest naturally in the room; in Stage 2+ they get sucked into 3D orbit around the cat!)
function updateAndDrawProps(dt, pass, cx, floorY) {
// pass: 'back' (behind cat) or 'front' (in front of cat)
const s = Math.min(W, H) / 900;
const pull = stage <= 1 ? 0 : Math.min(1, (cat.spinSpeed - 12) / 16);
roomProps.forEach((p) => {
const hx = cx + p.homeX * 920 * s;
const hy = floorY + p.homeY * 720 * s;
p.orbitPhase += dt * (1.8 + stage * 0.95);
const ox = cx + Math.cos(p.orbitPhase) * p.orbitR * s * (1 + (stage >= 4 ? 0.25 * Math.sin(totalTime * 4) : 0));
const oy = (floorY - cat.hoverY - 85 * s) + p.orbitY * s + Math.sin(p.orbitPhase * 2) * 35 * s;
const oz = Math.sin(p.orbitPhase); // <0 behind cat, >=0 in front
const curX = hx * (1 - pull) + ox * pull;
const curY = hy * (1 - pull) + oy * pull;
const curZ = pull > 0.1 ? oz : -1; // all background props behind cat when resting
if ((pass === 'back' && curZ >= 0) || (pass === 'front' && curZ < 0)) return;
p.ang = pull * (p.orbitPhase * 1.7);
const pScale = s * (1 + pull * curZ * 0.22);
ctx.save();
ctx.translate(curX, curY);
ctx.rotate(p.ang);
ctx.scale(pScale, pScale);
if (p.id === 'frame') {
// Framed wall art
ctx.fillStyle = 'rgba(0,0,0,0.22)';
ctx.fillRect(-44, -56, 94, 118);
ctx.fillStyle = '#3b2818';
ctx.fillRect(-48, -60, 96, 120);
ctx.fillStyle = '#f4efe6';
ctx.fillRect(-40, -52, 80, 104);
// Minimalist botanical print inside frame
ctx.fillStyle = '#c97b53';
ctx.beginPath();
ctx.arc(0, 6, 22, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#3d4f3c';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(-12, 32);
ctx.quadraticCurveTo(4, -5, 14, -26);
ctx.stroke();
} else if (p.id === 'cushion') {
// Mustard throw pillow on sofa
const cg = ctx.createLinearGradient(-35, -35, 35, 35);
cg.addColorStop(0, '#dfb04b');
cg.addColorStop(1, '#9e721e');
ctx.fillStyle = cg;
ctx.beginPath();
ctx.roundRect(-36, -34, 72, 68, 14);
ctx.fill();
} else if (p.id === 'plant') {
// Potted Monstera / Houseplant on right
ctx.fillStyle = '#b86846'; // terracotta pot
ctx.beginPath();
ctx.moveTo(-26, 0);
ctx.lineTo(26, 0);
ctx.lineTo(20, 46);
ctx.lineTo(-20, 46);
ctx.closePath();
ctx.fill();
// Leaves
const leaves = [[-28, -38, -0.5], [0, -52, 0], [32, -36, 0.5], [-16, -22, -0.25], [20, -20, 0.3]];
leaves.forEach(([lx, ly, rot]) => {
ctx.save();
ctx.translate(lx, ly);
ctx.rotate(rot + Math.sin(totalTime * 4 + lx) * pull * 0.25);
ctx.fillStyle = '#3b6640';
ctx.beginPath();
ctx.ellipse(0, 0, 16, 26, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#629468';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(0, -20); ctx.lineTo(0, 22);
ctx.stroke();
ctx.restore();
});
} else if (p.id === 'book') {
// Coffee table / floor hardcover book stack
ctx.fillStyle = '#8c3b2b';
ctx.fillRect(-34, -8, 68, 14);
ctx.fillStyle = '#f2eadc';
ctx.fillRect(-31, -6, 62, 10);
} else if (p.id === 'mug') {
// Ceramic coffee mug
ctx.fillStyle = '#e8dfd1';
ctx.beginPath();
ctx.roundRect(-14, -18, 28, 32, 5);
ctx.fill();
ctx.strokeStyle = '#e8dfd1';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.arc(14, -2, 8, -Math.PI * 0.5, Math.PI * 0.5);
ctx.stroke();
}
ctx.restore();
});
}
// Stage 3 & 4 Cosmic Rave Vortex & Wormhole Tunnel
function drawCosmicTunnel(cx, cy, s, raveAmt, singAmt) {
if (raveAmt <= 0.01) return;
ctx.save();
ctx.globalAlpha = Math.min(1, raveAmt);
// Deep space / rave backdrop blend
const bgG = ctx.createRadialGradient(cx, cy, 10, cx, cy, Math.max(W, H) * 0.85);
const baseHue = (totalTime * 95) % 360;
bgG.addColorStop(0, `hsla(${(baseHue + 180) % 360}, 95%, ${22 + singAmt * 25}%, 0.92)`);
bgG.addColorStop(0.45, `hsla(${baseHue}, 90%, 14%, 0.88)`);
bgG.addColorStop(1, 'rgba(6, 3, 14, 0.95)');
ctx.fillStyle = bgG;
ctx.fillRect(0, 0, W, H);
// Hypnotic zooming polygon rings & laser beams
ctx.translate(cx, cy);
const numBeams = stage >= 4 ? 24 : 14;
for (let i = 0; i < numBeams; i++) {
const a = (i / numBeams) * Math.PI * 2 + totalTime * (stage >= 4 ? 2.6 : 1.4);
const beamHue = (baseHue + i * 28) % 360;
ctx.fillStyle = `hsla(${beamHue}, 100%, 60%, ${0.14 + singAmt * 0.14})`;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, Math.max(W, H), a, a + 0.09);
ctx.closePath();
ctx.fill();
}
// Zooming perspective warp rings
const rings = 10;
for (let r = 0; r < rings; r++) {
const p = ((r / rings) + totalTime * (0.45 + singAmt * 0.55)) % 1;
const rad = Math.pow(p, 2.2) * Math.max(W, H) * 0.9;
const rHue = (baseHue + r * 36) % 360;
ctx.strokeStyle = `hsla(${rHue}, 100%, 65%, ${(1 - p) * 0.75})`;
ctx.lineWidth = (2 + p * 10) * s;
ctx.beginPath();
if (stage >= 4) {
// Warped star-polygon in Stage 4
const sides = 8;
for (let k = 0; k <= sides; k++) {
const ka = (k / sides) * Math.PI * 2 - totalTime * 2 + p * 3;
const kr = rad * (k % 2 === 0 ? 1 : 0.78);
if (k === 0) ctx.moveTo(Math.cos(ka) * kr, Math.sin(ka) * kr);
else ctx.lineTo(Math.cos(ka) * kr, Math.sin(ka) * kr);
}
} else {
ctx.arc(0, 0, rad, 0, Math.PI * 2);
}
ctx.stroke();
}
// Stage 4 phonetic O-I-I-A ring orbiting the singularity
if (singAmt > 0.05) {
const chars = 'O I I A O I I A O I I A I O O O I A I ';
const ringR = 265 * s;
ctx.font = `900 ${Math.round(24 * s)}px system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (let c = 0; c < chars.length; c++) {
const ch = chars[c];
if (ch === ' ') continue;
const ca = (c / chars.length) * Math.PI * 2 - totalTime * 3.8;
ctx.save();
ctx.rotate(ca);
ctx.translate(0, -ringR);
ctx.fillStyle = `hsla(${(c * 19 + totalTime * 240) % 360}, 100%, 72%, ${singAmt * 0.9})`;
ctx.fillText(ch, 0, 0);
ctx.restore();
}
}
ctx.restore();
}
// Draw a cat instance (used for both main Ethel and Stage 4 mini-clones)
function drawCatInstance(x, y, scale, yaw, spinSpeed, isMain) {
ctx.save();
ctx.translate(x, y);
ctx.scale(scale, scale);
// At rest in Stage 0, use the ultra-detailed fur-stroked portrait + gentle breathing
if (stage === 0 && spinSpeed < 0.3 && isMain) {
const breath = 1 + Math.sin(totalTime * 2.1) * 0.008;
ctx.scale(1 / breath, breath);
ctx.drawImage(catIdleCanvas, -210, -395);
ctx.restore();
return;
}
// Dynamic vertical stretch & horizontal pill-tightening as spin accelerates
const blurAmt = Math.min(1, spinSpeed / 26);
const stretchY = 1 + Math.min(0.14, spinSpeed * 0.0022);
const squashX = 1 - Math.min(0.12, spinSpeed * 0.0018);
ctx.scale(squashX, stretchY);
// Aura glow behind spinning cat in Stage 2+
if (stage >= 2 && isMain) {
const auraR = 165 + Math.sin(totalTime * 18) * 18;
const auraHue = (totalTime * 160) % 360;
const auraG = ctx.createRadialGradient(0, -135, 20, 0, -135, auraR);
auraG.addColorStop(0, `hsla(${auraHue}, 100%, 75%, ${Math.min(0.75, blurAmt * 0.65)})`);
auraG.addColorStop(0.55, `hsla(${(auraHue + 60) % 360}, 100%, 55%, ${Math.min(0.35, blurAmt * 0.35)})`);
auraG.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = auraG;
ctx.beginPath();
ctx.arc(0, -135, auraR, 0, Math.PI * 2);
ctx.fill();
}
const normYaw = ((yaw % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
const frameIdx = Math.floor((normYaw / (Math.PI * 2)) * SPIN_FRAMES) % SPIN_FRAMES;
// Crossfade between crisp 3D rotation frames and high-speed Oiia motion-blur frames
// Plus draw ghost echo trails when spinning at extreme speed
if (blurAmt < 0.85) {
ctx.globalAlpha = 1;
ctx.drawImage(spinSlowCanvases[frameIdx], -210, -395);
}
if (blurAmt > 0.15) {
// Draw 2 motion-trail ghost frames behind the current angle for authentic shutter smear
const ghostIdx1 = (frameIdx + SPIN_FRAMES - 1) % SPIN_FRAMES;
const ghostIdx2 = (frameIdx + SPIN_FRAMES - 2) % SPIN_FRAMES;
ctx.globalAlpha = blurAmt * 0.45;
ctx.drawImage(spinFastCanvases[ghostIdx2], -216, -395);
ctx.drawImage(spinFastCanvases[ghostIdx1], -204, -395);
ctx.globalAlpha = Math.min(1, blurAmt * 1.15);
ctx.drawImage(spinFastCanvases[frameIdx], -210, -395);
}
// Stage 3 & 4 Sweeping Lighthouse Laser Eyes when the face sweeps past the camera
if (stage >= 3 && isMain) {
const cosY = Math.cos(normYaw);
const sinY = Math.sin(normYaw);
if (cosY > -0.2) {
ctx.save();
ctx.globalCompositeOperation = 'lighter';
const eyeY = -198;
const eyeX = sinY * 54;
const beamLen = 650;
const bGrad = ctx.createLinearGradient(eyeX, eyeY, eyeX + sinY * beamLen, eyeY);
bGrad.addColorStop(0, 'rgba(0, 255, 255, 0.95)');
bGrad.addColorStop(0.3, 'rgba(255, 0, 200, 0.55)');
bGrad.addColorStop(1, 'rgba(255, 0, 200, 0)');
ctx.fillStyle = bGrad;
ctx.beginPath();
ctx.moveTo(eyeX - 18, eyeY - 8);
ctx.lineTo(eyeX + sinY * beamLen - 90, eyeY - 95);
ctx.lineTo(eyeX + sinY * beamLen + 90, eyeY + 95);
ctx.lineTo(eyeX + 18, eyeY + 8);
ctx.closePath();
ctx.fill();
ctx.restore();
}
}
ctx.restore();
}
// ============================================================================
// MAIN ANIMATION LOOP
// ============================================================================
let lastTS = performance.now();
function tick(now) {
const dt = Math.min(0.05, (now - lastTS) / 1000);
lastTS = now;
totalTime += dt;
stageTime += dt;
const s = Math.min(W, H) / 900;
cat.scale = s * 0.92;
// Smoothly accelerate spin speed and hover elevation toward current stage target
const accelRate = stage === 0 ? 3.2 : (1.8 + stage * 0.65);
cat.spinSpeed += (cat.targetSpeed - cat.spinSpeed) * Math.min(1, dt * accelRate);
if (stage === 0 && cat.spinSpeed < 0.4) {
// Settle smoothly back to facing front (yaw = 0) when reset
cat.spinSpeed = 0;
const diff = ((0 - cat.yaw + Math.PI) % (Math.PI * 2)) - Math.PI;
cat.yaw += diff * Math.min(1, dt * 6);
} else {
cat.yaw += cat.spinSpeed * dt;
}
cat.hoverY += (cat.targetHoverY * s - cat.hoverY) * Math.min(1, dt * 3.2);
const bobOffset = stage > 0
? Math.sin(totalTime * (3.5 + stage * 1.4)) * (9 + stage * 3) * s
: 0;
// Update synthesized formant voice
updateAudio(dt, cat.spinSpeed, stage);
// Stage intensity factors
const chaos = Math.min(1, cat.spinSpeed / 30);
const vortexAmt = stage >= 2 ? Math.min(1, (cat.spinSpeed - 14) / 16) : 0;
const raveAmt = stage >= 3 ? Math.min(1, (cat.spinSpeed - 28) / 18) : 0;
const singAmt = stage >= 4 ? Math.min(1, (cat.spinSpeed - 44) / 20) : 0;
// Camera shake & Dutch tilt at high stages
ctx.save();
if (stage >= 2) {
const shakeMag = (stage === 2 ? 2.2 : (stage === 3 ? 5.5 : 9.5)) * s * Math.min(1, cat.spinSpeed / 35);
const sx = (Math.random() - 0.5) * shakeMag;
const sy = (Math.random() - 0.5) * shakeMag;
const camTilt = Math.sin(totalTime * (stage >= 4 ? 7 : 3.5)) * (stage >= 4 ? 0.045 : 0.018) * vortexAmt;
ctx.translate(W * 0.5 + sx, H * 0.5 + sy);
ctx.rotate(camTilt);
ctx.translate(-W * 0.5, -H * 0.5);
}
const cx = W * 0.5;
const floorY = H * 0.79;
const catFootY = floorY + 55 * s - cat.hoverY + bobOffset;
// 1. Draw Living Room (warps & dims as rave tunnel takes over)
drawLivingRoom(chaos, raveAmt * 0.82, vortexAmt, floorY, cx);
// 2. Draw Cosmic Rave Tunnel (Stage 3 & 4)
if (raveAmt > 0.01) {
drawCosmicTunnel(cx, catFootY - 135 * cat.scale, s, raveAmt * 0.85, singAmt);
}
// 3. Draw background props (or back-half of orbiting props)
updateAndDrawProps(dt, 'back', cx, floorY);
// 4. Draw Stage 4 Mini-Oiia Clones (behind main cat)
if (singAmt > 0.02) {
miniCats.forEach((mc) => {
mc.angle += dt * (2.6 + singAmt * 2.2);
mc.yaw += dt * (32 + singAmt * 25);
const z = Math.sin(mc.angle);
if (z < 0) {
const mx = cx + Math.cos(mc.angle) * mc.radius * s;
const my = (catFootY - 90 * s) + mc.yOffset * s + Math.sin(mc.angle * 2) * 28 * s;
drawCatInstance(mx, my, cat.scale * mc.scale * (0.85 + z * 0.15) * singAmt, mc.yaw, 45, false);
}
});
}
// 5. Realistic Contact & Levitation Shadow on the Rug
const shadowScale = Math.max(0.28, 1 - (cat.hoverY / (260 * s)));
const shadowAlpha = Math.max(0.18, 0.68 - (cat.hoverY / (310 * s)));
ctx.save();
const shGrad = ctx.createRadialGradient(
cx, floorY + 58 * s, 8 * s,
cx, floorY + 58 * s, 125 * s * shadowScale
);
if (stage >= 3) {
// Glowing portal shadow under floating cat in Stage 3-4
const pHue = (totalTime * 200) % 360;
shGrad.addColorStop(0, `hsla(${pHue}, 100%, 65%, 0.75)`);
shGrad.addColorStop(0.5, `hsla(${(pHue + 90) % 360}, 100%, 45%, 0.35)`);
shGrad.addColorStop(1, 'rgba(0, 0, 0, 0)');
} else {
shGrad.addColorStop(0, `rgba(18, 10, 4, ${shadowAlpha})`);
shGrad.addColorStop(0.55, `rgba(28, 16, 8, ${shadowAlpha * 0.55})`);
shGrad.addColorStop(1, 'rgba(28, 16, 8, 0)');
}
ctx.fillStyle = shGrad;
ctx.beginPath();
ctx.ellipse(cx, floorY + 58 * s, 122 * s * shadowScale, 32 * s * shadowScale, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// 6. Draw Main Oiia Cat (Ethel)
drawCatInstance(cx, catFootY, cat.scale, cat.yaw, cat.spinSpeed, true);
// 7. Draw Stage 4 Mini-Oiia Clones (in front of main cat)
if (singAmt > 0.02) {
miniCats.forEach((mc) => {
const z = Math.sin(mc.angle);
if (z >= 0) {
const mx = cx + Math.cos(mc.angle) * mc.radius * s;
const my = (catFootY - 90 * s) + mc.yOffset * s + Math.sin(mc.angle * 2) * 28 * s;
drawCatInstance(mx, my, cat.scale * mc.scale * (0.85 + z * 0.2) * singAmt, mc.yaw, 45, false);
}
});
}
// 8. Draw foreground orbiting props
updateAndDrawProps(dt, 'front', cx, floorY);
// 9. Volumetric Sunbeams (Stage 0-2) & Swirling Vortex Particles
if (raveAmt < 0.7) {
ctx.save();
ctx.globalCompositeOperation = 'lighter';
const sunAlpha = (1 - raveAmt) * 0.11;
const beamG = ctx.createLinearGradient(0, 0, cx + 180 * s, floorY + 120 * s);
beamG.addColorStop(0, `rgba(255, 244, 214, ${sunAlpha * 1.4})`);
beamG.addColorStop(0.6, `rgba(255, 232, 185, ${sunAlpha})`);
beamG.addColorStop(1, 'rgba(255, 225, 170, 0)');
ctx.fillStyle = beamG;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(W * 0.35, 0);
ctx.lineTo(cx + 260 * s, H);
ctx.lineTo(cx - 320 * s, H);
ctx.closePath();
ctx.fill();
ctx.restore();
}
// Dust motes / Rave sparks
ctx.save();
particles.forEach((pt) => {
if (stage === 0) {
pt.x = (pt.x + pt.vx * dt + 1) % 1;
pt.y = (pt.y + pt.vy * dt + 1) % 1;
const px = pt.x * W;
const py = pt.y * H;
ctx.fillStyle = `rgba(255, 245, 220, ${0.22 + 0.18 * Math.sin(totalTime * 2 + pt.hue)})`;
ctx.beginPath();
ctx.arc(px, py, pt.size * s, 0, Math.PI * 2);
ctx.fill();
} else {
// Swirl around cat
const ang = Math.atan2(pt.y - 0.5, pt.x - 0.5) + dt * (1.2 + cat.spinSpeed * 0.12) * pt.z;
const rad = Math.hypot(pt.x - 0.5, pt.y - 0.5);
const newR = rad < 0.04 ? 0.65 : rad - dt * 0.15 * chaos;
pt.x = 0.5 + Math.cos(ang) * newR;
pt.y = 0.5 + Math.sin(ang) * newR;
const px = pt.x * W;
const py = pt.y * H;
ctx.strokeStyle = stage >= 3
? `hsla(${(pt.hue + totalTime * 200) % 360}, 100%, 70%, 0.75)`
: `rgba(255, 235, 190, ${0.35 + chaos * 0.4})`;
ctx.lineWidth = pt.size * s * (1 + chaos);
ctx.beginPath();
ctx.moveTo(px, py);
ctx.lineTo(px - Math.sin(ang) * (4 + cat.spinSpeed * 0.7) * s, py + Math.cos(ang) * (4 + cat.spinSpeed * 0.7) * s);
ctx.stroke();
}
});
ctx.restore();
// 10. Expanding Click Shockwaves
for (let i = shockwaves.length - 1; i >= 0; i--) {
const sw = shockwaves[i];
sw.r += dt * 980 * s;
sw.alpha = Math.max(0, 1 - sw.r / sw.maxR);
if (sw.alpha <= 0.01) {
shockwaves.splice(i, 1);
continue;
}
ctx.save();
ctx.strokeStyle = stage >= 3
? `hsla(${sw.hue}, 100%, 70%, ${sw.alpha})`
: `rgba(255, 245, 220, ${sw.alpha * 0.65})`;
ctx.lineWidth = (3 + stage * 2) * sw.alpha;
ctx.beginPath();
ctx.arc(sw.x, sw.y, sw.r, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
// 11. Cinematic Lens Vignette (grounds the opening frame like a real photograph)
const vig = ctx.createRadialGradient(cx, H * 0.5, Math.min(W, H) * 0.32, cx, H * 0.5, Math.max(W, H) * 0.78);
vig.addColorStop(0, 'rgba(0,0,0,0)');
vig.addColorStop(1, stage >= 3 ? 'rgba(5, 0, 15, 0.52)' : 'rgba(12, 8, 5, 0.62)');
ctx.fillStyle = vig;
ctx.fillRect(0, 0, W, H);
ctx.restore();
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
})();
</script>
</body>
</html>
"O I I A" text ring and six miniature spinning Oiia clones orbiting the prime cat in 3D depth.O-I-I-A · O-I-I-A · O-I-I-A-I-O-O-O-O-I-A-I. Because the phoneme clock advances strictly as a multiple of dt * spinSpeed while fundamental pitch and formant frequencies scale upward with angular velocity, the vocal cadence and pitch are mathematically locked to the rotation.