DougWare/Resources / aquarium.html
DougWare's picture
download
raw
45.1 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bursting Aquarium</title>
<style>
html,body{margin:0;height:100%;background:#181c20;overflow:hidden;font-family:ui-sans-serif,system-ui,Segoe UI,Roboto,sans-serif}
#hud{position:fixed;left:10px;top:8px;color:#eef4f7;font-size:12.5px;line-height:1.55;
background:rgba(10,14,17,.62);border:1px solid rgba(255,255,255,.14);border-radius:8px;
padding:8px 12px;max-width:560px;user-select:none;pointer-events:none}
#hud b{color:#9fd8ff}
.key{display:inline-block;min-width:150px;color:#ffd98a}
</style>
</head>
<body>
<div id="hud">
<b>BURSTING AQUARIUM</b><br>
<span class="key">Drag the crack vertically</span> on the right glass — a <i>lower</i> crack starts a <i>stronger</i> jet (head water)<br>
<span class="key">Space</span> trigger the burst &nbsp;|&nbsp; <span class="key">R</span> reset &nbsp;|&nbsp; <span class="key">M</span> sound
<div id="stats" style="margin-top:4px;color:#cfe8f5"></div>
</div>
<canvas id="c"></canvas>
<script>
"use strict";
/* =====================================================================
BURSTING AQUARIUM
crack strength : v = C*sqrt(2 g h) (Torricelli) -> jet shrinks as it drains
free flight : gravity on jets, objects, fish, glass
puddle : 1D shallow-water columns, walls reflect + splash
fish : steer against current, tire out, get swept through
density logic : rocks & glass sink, toy floats, fish ~ neutral (steerable)
waterline : continuous analytic level, always drawn
===================================================================== */
const cv = document.getElementById("c"), ctx = cv.getContext("2d");
const hudStats = document.getElementById("stats");
let W = 0, H = 0, DPR = 1;
function resize(){
DPR = Math.min(2, window.devicePixelRatio || 1);
W = window.innerWidth; H = window.innerHeight;
cv.width = W * DPR; cv.height = H * DPR;
cv.style.width = W + "px"; cv.style.height = H + "px";
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
window.addEventListener("resize", resize); resize();
const G = 9.81, C = 0.62, TAU = Math.PI * 2;
const clamp = (v,a,b)=>v<a?a:v>b?b:v;
const lerp = (a,b,t)=>a+(b-a)*t;
const rand = (a=1,b)=>b===undefined?Math.random()*a:a+Math.random()*(b-a);
function gauss(){ let u=0,v=0; while(!u)u=Math.random(); while(!v)v=Math.random();
return Math.sqrt(-2*Math.log(u))*Math.cos(TAU*v); }
/* world <-> screen (world in meters, y up) */
const sX = x => x * (W / scene.roomW);
const sY = y => y * (H / scene.roomH);
const px = x => sX(x);
const py = y => H - sY(y);
const SX = () => W / scene.roomW, SY = () => H / scene.roomH;
/* ---------------- scene constants (meters) ---------------- */
const scene = {
roomW: 6.4, roomH: 3.6,
floorY: 0.05, // floor top
standH: 0.92,
tankW: 3.4, glassT: 0.035, wallT: 0.08,
tankH: 1.34, water0: 1.27,
tankL: 1.5, tankR: 4.9,
crackFrac: 0.42, // 0 = bottom edge, 1 = top edge
breach: {x: 4.9175, y: 0, h: 0},
state: "dragging", t: 0,
puddleVol: 0, // side-view water area (m2)
puddleCX: 5.35, // where the jet lands
puddleH: new Array(257).fill(0), // drawn profile (regenerated each frame)
puddleX0: 0.4, puddleX1: 6.32, puddleN: 256,
};
const TANK_B = () => scene.floorY + scene.standH; // 0.97
const TANK_T = () => TANK_B() + scene.tankH; // 2.31
const SURF0 = () => TANK_B() + scene.water0; // 2.19
const floorPx = () => py(scene.floorY);
const crackWorldY = () => TANK_B() + scene.crackFrac * scene.tankH;
const crackPxY = () => py(crackWorldY());
const glassTopPxY = () => py(TANK_T());
const glassBotPxY = () => py(TANK_B());
let waterIn = 1; // fraction of original water remaining
let tankLevel = SURF0(); // current tank surface (world y)
let breachFlow = 0; // m2/s outflow (for HUD/audio)
let splashes = [], glass = [], objects = [], fish = [], parts = [];
let toy = null;
let shake = 0, muted = false;
const tankDepth = () => Math.max(0, tankLevel - TANK_B());
// Torricelli: outflow speed at height y depends on the head of water ABOVE y.
// A lower crack sits under more head -> a faster, stronger jet.
const jetV = y => {
const head = tankLevel - y; // depth of water above height y
if(head < 0.005) return 0;
return Math.max(0, C * Math.sqrt(2 * G * head));
};
/* ---------------- static room objects ---------------- */
function addRock(x, y, r){
const shape = []; const n = 9;
for(let i=0;i<n;i++) shape.push(0.72 + Math.random()*0.5);
objects.push({type:"rock", x, y, theta:rand(TAU), omega:0, vx:0, vy:0, r,
shape, mass: 2600*Math.PI*r*r*0.6, dragLin:1.0, inWater:false, onFloor:true});
}
function addPlank(x, y, len, w){
objects.push({type:"plank", x, y, theta:0, omega:0, vx:0, vy:0,
r: Math.max(len,w)*0.55, shape:[len,w],
mass: 650*Math.PI*len*w*0.7, dragLin:0.8, inWater:false, onFloor:true});
}
addRock(1.95, TANK_B()+0.34, 0.26);
addRock(2.75, TANK_B()+0.28, 0.20);
addRock(3.55, TANK_B()+0.33, 0.24);
addRock(4.15, TANK_B()+0.22, 0.14);
addPlank(1.8, TANK_B()+0.16, 0.5, 0.09);
function spawnFish(){
fish = [];
for(let i=0;i<7;i++) fish.push({
type:"fish", x: rand(1.9, 4.3), y: rand(TANK_B()+0.45, SURF0()-0.12),
theta: rand(TAU), vx: 0, vy: 0, len: rand(0.15, 0.25),
hue: rand(0, 40), speed: 0.85*rand(0.85,1.2),
stamina: 1, tired:false, state:"swim",
wanderA: rand(TAU), wanderT: 0, wobble: rand(TAU), tail: rand(TAU),
inWater:false, onFloor:false, escaped:false
});
fish.push({ type:"fish", x: 3.0, y: 1.8, theta: 0, vx:0, vy:0,
len: 0.30, hue: 22, speed: 1.05, stamina: 1, tired:false, state:"swim",
wanderA: rand(TAU), wanderT: 0, wobble: rand(TAU), tail: rand(TAU),
inWater:false, onFloor:false, escaped:false });
}
function makeToy(){
toy = {type:"toy", x: 2.4, y: SURF0()-0.10, theta: rand(-0.3,0.3),
omega: 0, vx: 0, vy: 0, r: 0.16, density: 90, mass: 3.8,
inWater:false, grounded:false, onFloor:false, bob: rand(TAU)};
}
function spawnParts(){
parts = [];
const n = 430, L = scene.tankL + 0.05, Rw = scene.tankW - 0.1;
for(let i=0;i<n;i++){
parts.push({ x: lerp(L, L+Rw, Math.random()),
y: rand(TANK_B()+0.03, SURF0()-0.02),
vx: rand(-0.15,0.15), vy: rand(-0.15,0.15) });
}
}
function spawnShards(){
const b = scene.breach;
for(let i=0;i<46;i++){
const a = rand(TAU), s = rand(1.2, 7.5);
const w = rand(0.045, 0.17), h = rand(0.05, 0.22);
glass.push({ x: b.x + 0.02 + Math.random()*0.05,
y: b.y + gauss()*b.h*0.3,
vx: Math.cos(a)*s + 2.2, vy: Math.sin(a)*s,
theta: rand(TAU), omega: rand(-24, 24),
w, h, area: w*h,
dragRot: rand(0.6, 1.8) });
}
}
/* ---------------- audio ---------------- */
let AC = null, noiseBuf = null, jetGain = null;
function initAudio(){
if(AC) return;
try{
AC = new (window.AudioContext || window.webkitAudioContext)();
const len = AC.sampleRate * 1.5;
noiseBuf = AC.createBuffer(1, len, AC.sampleRate);
const d = noiseBuf.getChannelData(0);
for(let i=0;i<len;i++) d[i] = Math.random()*2 - 1;
}catch(e){ AC = null; }
}
function noiseSrc(){ const s = AC.createBufferSource(); s.buffer = noiseBuf; s.loop = true; return s; }
function startRumble(){
if(!AC || jetGain) return;
const src = noiseSrc(), f = AC.createBiquadFilter();
f.type = "lowpass"; f.frequency.value = 900;
const g = AC.createGain(); g.gain.value = 0;
src.connect(f); f.connect(g); g.connect(AC.destination);
src.start(); jetGain = g;
}
function burstSound(){
if(!AC) return;
const t0 = AC.currentTime;
const src = noiseSrc(), f = AC.createBiquadFilter();
f.type = "highpass"; f.frequency.value = 1200;
const g = AC.createGain();
g.gain.setValueAtTime(0.9, t0);
g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.35);
src.connect(f); f.connect(g); g.connect(AC.destination);
src.start(t0); src.stop(t0 + 0.4);
const o = AC.createOscillator(), og = AC.createGain();
o.type = "sine"; o.frequency.setValueAtTime(120, t0);
o.frequency.exponentialRampToValueAtTime(35, t0 + 0.5);
og.gain.setValueAtTime(0.7, t0);
og.gain.exponentialRampToValueAtTime(0.001, t0 + 0.6);
o.connect(og); og.connect(AC.destination);
o.start(t0); o.stop(t0 + 0.65);
}
function updateJetAudio(){
if(!AC || !jetGain) return;
jetGain.gain.setTargetAtTime(muted ? 0 : clamp(breachFlow * 55, 0, 0.5), AC.currentTime, 0.08);
}
/* ---------------- input ---------------- */
let dragging = false;
function crackHit(x, yPx){
const cx = px(scene.tankR + scene.glassT/2);
return Math.abs(x - cx) < 30 && yPx > glassTopPxY() - 14 && yPx < glassBotPxY() + 14;
}
cv.addEventListener("pointerdown", e=>{
initAudio();
if(scene.state === "dragging" && crackHit(e.clientX, e.clientY)){
dragging = true; cv.setPointerCapture(e.pointerId);
moveCrack(e.clientY);
}
});
cv.addEventListener("pointermove", e=>{ if(dragging) moveCrack(e.clientY); });
cv.addEventListener("pointerup", ()=> dragging = false);
function moveCrack(pyv){
const f = 1 - (pyv - glassTopPxY()) / (glassBotPxY() - glassTopPxY());
scene.crackFrac = clamp(f, 0.04, 0.90);
}
window.addEventListener("keydown", e=>{
if(e.code === "Space"){ e.preventDefault(); triggerBurst(); }
if(e.code === "KeyR") reset();
if(e.code === "KeyM"){ muted = !muted; }
});
function triggerBurst(){
if(scene.state !== "dragging") return;
scene.breach = {x: 4.9175, y: crackWorldY(), h: 0.02};
scene.state = "bursting";
spawnShards();
startRumble(); burstSound();
shake = 1.0;
for(const f of fish){ f.state = "flee"; f.stamina = 1; f.tired = false; }
for(const o of objects){ o.vx += rand(0.05, 0.3); o.omega += rand(-1.5, 1.5); o.onFloor = false; }
if(toy) toy.vx += 0.2;
}
function reset(){
scene.state = "dragging"; scene.t = 0;
scene.crackFrac = 0.42;
scene.breach = {x: 4.9175, y: crackWorldY(), h: 0};
waterIn = 1; tankLevel = SURF0(); breachFlow = 0; shake = 0;
splashes = []; glass = [];
scene.puddleVol = 0; scene.puddleCX = 5.35;
for(let i = 0; i < scene.puddleN; i++) scene.puddleH[i] = 0;
if(jetGain && AC) jetGain.gain.setTargetAtTime(0, AC.currentTime, 0.05);
for(const o of objects){ o.vx = 0; o.vy = 0; o.omega = 0; o.onFloor = true; }
spawnFish(); makeToy(); spawnParts();
}
reset();
/* ---------------- queries ---------------- */
function puddleParams(){
const v = scene.puddleVol;
if(v < 1e-4) return null;
const wallL = scene.wallT, wallR = scene.roomW - scene.wallT;
const cx0 = scene.puddleCX;
let halfW = Math.sqrt(v) * 1.15 + 0.35;
let left = Math.max(wallL, cx0 - halfW);
let right = Math.min(wallR, cx0 + halfW);
const width = Math.max(0.05, right - left);
let hmax = v / (width * 0.62); // dome volume factor
hmax = Math.min(hmax, 0.55); // cap visible depth (side view)
return {left, right, width, hmax, hitL: left <= wallL + 0.005, hitR: right >= wallR - 0.005};
}
function puddleHAt(x){
const P = puddleParams();
if(!P) return 0;
if(x < P.left || x > P.right) return 0;
const t = (x - P.left) / P.width;
const h = P.hmax * Math.pow(Math.max(0, 1 - Math.pow(2*t - 1, 2)), 0.8);
return h;
}
function inTank(x){ return x > scene.tankL && x < scene.tankR; }
function inBreachMouth(x, y, pad=0){
const b = scene.breach;
return b.h > 0.015 && x > b.x - 0.04 - pad && x < b.x + 0.3 + pad &&
y > b.y - b.h - pad && y < b.y + b.h + pad;
}
/* ---------------- tank water particles ---------------- */
function stepParticles(dt){
const L = scene.tankL + 0.02, R = scene.tankR - 0.02;
const B = TANK_B() + 0.02;
const b = scene.breach;
const bursting = scene.state === "bursting";
for(const p of parts){
let ax = 0, ay = -G;
const inTankX = p.x > L - 0.25 && p.x < R + 0.25;
if(inTankX && p.y < tankLevel + 0.02){
// submerged in the tank: gentle fill (downward) so water stays put
if(p.y < tankLevel - 0.02) ay += 26 * (tankLevel - p.y) / Math.max(0.2, tankDepth());
p.vx *= (1 - Math.min(1, 1.8 * dt));
p.vy *= (1 - Math.min(1, 1.8 * dt));
if(bursting && b.h > 0.02 && tankDepth() > 0.05){
const dy = Math.abs(p.y - b.y);
if(p.x > scene.tankR - 0.55 && dy < b.h + 0.4){
ax += jetV(clamp(p.y, b.y - b.h, b.y + b.h)) * 2.4 * (1 - dy / (b.h + 0.4));
}
ax += Math.sin(p.y * 5 + scene.t * 3) * 0.18;
ay += Math.cos(p.x * 4 + scene.t * 2.5) * 0.18;
}
} else if(inTankX && p.y >= tankLevel + 0.02 && p.y < TANK_T() + 0.4){
// above the waterline, over the tank: falls back in
p.vx *= (1 - Math.min(1, 0.3 * dt));
}
p.vx += ax * dt; p.vy += ay * dt;
p.x += p.vx * dt; p.y += p.vy * dt;
if(p.x < L){ p.x = L; p.vx = Math.abs(p.vx) * 0.2; }
if(p.x > R){
if(bursting && inBreachMouth(p.x, p.y, 0.02) && p.vx > 0){
p.x = b.x + 0.02;
p.vx = Math.max(p.vx, jetV(p.y) * 0.9 + 0.4);
} else { p.x = R; p.vx = -Math.abs(p.vx) * 0.2; }
}
if(p.y < B){ p.y = B; p.vy = Math.abs(p.vy) * 0.08; p.vx *= 0.98; }
if(p.y > scene.roomH - 0.03){ p.y = scene.roomH - 0.03; p.vy = -Math.abs(p.vy) * 0.2; }
}
}
/* ---------------- objects (rocks / plank / toy) ---------------- */
function stepObject(o, dt){
const B = scene.floorY;
o.grounded = false;
const submerged = inTank(o.x) && o.y - o.r < tankLevel && o.y + o.r > TANK_B() + 0.02;
const pud = puddleHAt(o.x);
const inPuddle = !submerged && o.y - o.r * 0.5 < B + pud + 0.03 && pud > 0.004;
o.inWater = submerged || inPuddle;
let ax = 0, ay = -G;
const area = Math.PI * o.r * o.r;
const k = 0.5 * 1000 * (o.dragLin || 1);
const mass = o.mass;
if(submerged){
const surf = tankLevel;
const subFrac = clamp((surf - (o.y - o.r)) / (2 * o.r), 0, 1);
ay += 1000 * (mass / (o.density || 2600)) * subFrac * G / mass; // = rho_r*V*g/m
let cvx = 0;
if(scene.state === "bursting" && scene.breach.h > 0.02){
const b = scene.breach;
const dy = o.y - b.y;
if(Math.abs(dy) < b.h + o.r && o.x > scene.tankR - 0.95) cvx = jetV(o.y) * 0.85;
else {
// return current: strongest near the bottom where water leaves, fades upward
const depthFrac = (TANK_B() + tankDepth()) / Math.max(0.05, tankLevel - (o.y - o.r));
cvx = 0.5 * clamp(tankDepth(), 0, 1.2) * clamp(0.4 + depthFrac, 0, 1.4)
* clamp(1.5 - (o.y - TANK_B()) / Math.max(0.3, tankDepth() + 0.3), 0, 1.2);
}
if(o.x > scene.tankR - 0.45 && Math.abs(dy) < b.h + o.r * 1.4) cvx += 0.35;
}
const rvx = o.vx - cvx, rvy = o.vy;
const sp = Math.hypot(rvx, rvy);
if(sp > 1e-4){ ax -= k * area * sp * rvx / mass; ay -= k * area * sp * rvy / mass; }
} else if(inPuddle){
const subFrac = clamp(pud / (2 * o.r), 0, 1);
ay += 1000 * (mass / (o.density || 2600)) * subFrac * G * 0.7 / mass;
const P = puddleParams();
// residual flow toward the sheet's downhill side (toward the low point)
let drain = 0.02;
if(P){
const dir = (o.x < scene.puddleCX) ? 1 : -1;
const slope = (pud - puddleHAt(o.x + dir * 0.05));
drain = dir * 0.15 * clamp(slope * 8, 0, 0.4) + (scene.state === "bursting" ? 0.05 + 0.15 * clamp(tankDepth(),0,1.2) * 0 : 0);
}
const rvx = o.vx - drain;
ax -= k * area * 0.6 * Math.abs(rvx) * rvx / mass;
if(o.vy > 0) ay -= 0.5 * k * area * o.vy * Math.abs(o.vy) / mass;
} else {
const sp = Math.hypot(o.vx, o.vy);
if(sp > 1e-4){
const ka = 0.5 * 1.2 * area;
ax -= ka * sp * o.vx / mass; ay -= ka * sp * o.vy / mass;
}
}
o.omega *= (1 - Math.min(1, (o.inWater ? 2.5 : 0.15) * dt));
o.vx += ax * dt; o.vy += ay * dt;
o.x += o.vx * dt; o.y += o.vy * dt;
o.theta += o.omega * dt;
// tank interior walls: solid at all heights (exit = breach only)
const oInTankX = o.x > scene.tankL - 0.05 && o.x < scene.tankR + 0.05;
if(oInTankX && o.x < scene.tankL + o.r){
o.x = scene.tankL + o.r; o.vx = Math.abs(o.vx) * 0.2;
}
if(oInTankX && o.x + o.r > scene.tankR &&
!(scene.state === "bursting" && inBreachMouth(o.x, o.y, o.r))){
o.x = scene.tankR - o.r; o.vx = -Math.abs(o.vx) * 0.2;
}
// breach shove: anything in the hole is flung out at jet speed
if(scene.state === "bursting" && inBreachMouth(o.x, o.y, o.r * 0.5)){
const vj = jetV(o.y);
if(vj > 0.1){ o.vx = Math.max(o.vx, vj * 0.8); o.vy *= 0.5; o.omega += rand(-6, 6); }
}
// room floor (toy floats half-submerged in puddle water)
const restR = o.type === "toy" ? o.r * 0.55 : o.r;
if(o.y < B + restR){
o.y = B + restR;
if(o.vy < -0.6 && pud > 0.02) spawnSplash(o.x, B + 0.03, Math.abs(o.vy) * 0.5, 5);
o.vy = -o.vy * (o.type === "rock" ? 0.18 : 0.32);
if(Math.abs(o.vy) < 0.25) o.vy = 0;
const fr = Math.exp(-2.5 * dt);
o.vx *= fr; o.omega *= fr;
if(Math.abs(o.vx) < 0.02) o.vx = 0;
o.onFloor = true; o.grounded = true;
} else o.onFloor = false;
// tank interior: the bottom is ALWAYS solid — the only exit is the breach
const inTankX = o.x > scene.tankL - 0.01 && o.x < scene.tankR + 0.01;
if(inTankX && o.y < TANK_B() + restR){
o.y = TANK_B() + restR;
if(o.vy < 0) o.vy = -o.vy * 0.15;
const fr = Math.exp(-2 * dt);
o.vx *= fr; o.omega *= fr;
o.onFloor = true; o.grounded = true;
}
// room walls
if(o.x - o.r < scene.wallT){ o.x = scene.wallT + o.r; o.vx = Math.abs(o.vx) * 0.35; o.omega *= 0.8; }
if(o.x + o.r > scene.roomW - scene.wallT){ o.x = scene.roomW - scene.wallT - o.r; o.vx = -Math.abs(o.vx) * 0.35; o.omega *= 0.8; }
if(o.y + o.r > scene.roomH){ o.y = scene.roomH - o.r; o.vy = -Math.abs(o.vy) * 0.3; }
}
/* ---------------- fish ---------------- */
function stepFish(f, dt){
const B = scene.floorY;
const b = scene.breach;
const inTankZone = inTank(f.x) && f.y - f.len*0.4 < tankLevel + 0.05 && f.y + f.len*0.4 > TANK_B() - 0.02;
const pud = puddleHAt(f.x);
const inPud = !inTankZone && f.y - f.len*0.3 < B + pud && pud > 0.004 && f.x < scene.tankR;
f.inWater = inTankZone || inPud;
f.wobble += dt * (4 + Math.abs(f.vx) * 3);
f.tail += dt * (6 + Math.abs(f.vx) * 10 + (f.state === "swim" ? 8 : 0));
const mass = f.len * 60, area = Math.PI * (f.len * 0.5) ** 2;
let ax = 0, ay = -G, cvx = 0;
if(f.inWater){
const surf = inTankZone ? tankLevel : B + pud;
const subFrac = clamp((surf - (f.y - f.len*0.3)) / (f.len*0.9), 0, 1);
ay += 1000 * (mass / 950) * subFrac * G / mass; // near-neutral buoyancy
if(scene.state === "bursting" && b.h > 0.02 && f.x > scene.tankL){
const dy = f.y - b.y;
if(Math.abs(dy) < b.h + f.len && f.x > scene.tankR - 1.25) cvx = jetV(f.y) * 0.9;
else {
const depthFrac = (TANK_B() + tankDepth()) / Math.max(0.05, tankLevel - (f.y - f.len * 0.3));
cvx = 0.3 * clamp(tankDepth(), 0, 1.2) * clamp(0.4 + depthFrac, 0, 1.4)
* clamp(1.5 - (f.y - TANK_B()) / Math.max(0.3, tankDepth() + 0.3), 0, 1.2);
}
} else if(scene.state === "drained"){
cvx = 0.2;
}
if(f.state === "swim"){
f.wanderT = (f.wanderT || 0) + dt;
if(f.wanderT > 2){ f.wanderT = 0; f.wanderA = rand(TAU); }
ax += Math.cos(f.wanderA) * f.speed * 3.5;
ay += Math.sin(f.wanderA) * f.speed * 1.2;
if(f.x < scene.tankL + 0.4) ax += 3;
if(f.x > scene.tankR - 0.4) ax -= 3;
if(f.y > tankLevel - 0.12) ay -= 2.5;
if(f.y < TANK_B() + 0.15) ay += 2.5;
} else if(f.state === "flee"){
const awayX = f.x < b.x ? 1 : -1;
ax += awayX * f.speed * 5.5;
if(cvx > 0.15){ // swim against the current
ax -= cvx * 9;
f.stamina -= dt * clamp(cvx / 1.4, 0.05, 0.5) * 0.55;
if(f.stamina <= 0){ f.tired = true; f.state = "swept"; }
}
if(f.y > tankLevel - 0.3) ay += 3;
if(f.y < TANK_B() + 0.25) ay += 4;
if(f.x < scene.tankL + 0.35) ax += 4;
if(f.x > scene.tankR - 0.5) ax -= 3;
} else if(f.state === "swept"){
ax += Math.sin(f.wobble * 1.7) * 0.4;
ay += Math.cos(f.wobble * 1.3) * 0.3 - 0.4;
}
// in the shallow tail, surviving fish still try to leave through the crack
if((f.state === "flee" || f.state === "swept") && tankDepth() < 0.3 && tankDepth() > 0.01){
const b2 = scene.breach;
const dxm = b2.x - f.x;
if(dxm > 0.2 && Math.abs(f.y - b2.y) < 0.45){
f.vx += 0.6 * dt * 12;
f.vy += clamp(b2.y - f.y, -0.5, 0.5) * 1.5 * dt;
}
}
// drag (relative to current)
const rvx = f.vx - cvx, rvy = f.vy;
const sp = Math.hypot(rvx, rvy);
if(sp > 1e-4){
ax -= 500 * area * sp * rvx / mass;
ay -= 500 * area * sp * rvy / mass;
}
} else {
// airborne: pure ballistic + light air drag
if(f.state !== "floor") f.state = "air";
const sp = Math.hypot(f.vx, f.vy);
if(sp > 1e-4){
const ka = 0.5 * 1.2 * area;
ax -= ka * sp * f.vx / mass; ay -= ka * sp * f.vy / mass;
}
}
f.vx += ax * dt; f.vy += ay * dt;
f.x += f.vx * dt; f.y += f.vy * dt;
if(f.inWater){
const target = Math.atan2(f.vy, f.vx);
let d = target - f.theta;
while(d > Math.PI) d -= TAU; while(d < -Math.PI) d += TAU;
f.theta += d * Math.min(1, 6 * dt);
} else {
f.theta += (f.vx * 0.3 + Math.sin(f.wobble) * 0.5) * dt;
}
// breach passage: the ONLY way a fish leaves the tank
if(scene.state === "bursting" && inBreachMouth(f.x, f.y, f.len * 0.4) && f.vx > 0.05){
const vj = jetV(f.y);
if(f.vx < vj * 0.95) f.vx = vj * 0.95;
f.state = "swept";
f.escaped = true;
}
const outside = f.x > scene.tankR + 0.12;
const Lz = f.len * 0.3;
const gL = scene.tankL + 0.02, gR = scene.tankR - 0.02, gT = TANK_T() - 0.02;
// room floor
if(f.y < B + Lz){
f.y = B + Lz;
if(f.vy < -0.8) spawnSplash(f.x, B + 0.02, 0.6, 6);
f.vy = -f.vy * 0.2;
f.vx *= 0.7;
if(Math.abs(f.vx) < 0.3 && Math.abs(f.vy) < 0.2){ f.vx = 0; f.vy = 0; if(f.state === "air") f.state = "floor"; }
}
if(f.y > scene.roomH - 0.1){ f.y = scene.roomH - 0.1; f.vy = -Math.abs(f.vy) * 0.2; }
// SOLID tank glass: the breach is the only exit
if(!f.escaped){
// tank bottom
if(f.x > gL - 0.05 && f.x < gR + 0.05 && f.y < TANK_B() + Lz){
f.y = TANK_B() + Lz; if(f.vy < 0) f.vy = -f.vy * 0.1;
}
// left glass (always solid)
if(f.x > gL - 0.45 && f.x < gR + 0.05 && f.y > TANK_B() - 0.05 && f.x < gL + Lz){
f.x = gL + Lz; if(f.vx < 0) f.vx = -f.vx * 0.25;
}
// top rim (always solid)
if(f.x > gL - 0.05 && f.x < gR + 0.05 && f.y > gT && f.y < gT + 0.5){
f.y = gT; if(f.vy > 0) f.vy = -f.vy * 0.25;
}
// right glass: solid everywhere except through an open breach
const canBreath = scene.state === "bursting" &&
scene.breach.h > 0.015 &&
Math.abs(f.y - scene.breach.y) < scene.breach.h + Lz &&
f.x < gR + 0.42 && f.x > gR - 0.02;
if(!canBreath && f.x > gL - 0.05 && f.x < gR + 0.45 && f.y > TANK_B() - 0.05 && f.x + Lz > gR){
f.x = gR - Lz; if(f.vx > 0) f.vx = -f.vx * 0.25;
}
}
// room walls (only meaningful once outside)
if(f.x < scene.wallT + Lz){ f.x = scene.wallT + Lz; f.vx = Math.abs(f.vx) * 0.3; }
if(f.x > scene.roomW - scene.wallT - Lz){ f.x = scene.roomW - scene.wallT - Lz; f.vx = -Math.abs(f.vx) * 0.2; }
}
function stepToy(dt){
if(!toy) return;
stepObject(toy, dt);
toy.bob += dt * 3;
}
/* ---------------- glass shards ---------------- */
function stepGlass(dt){
const B = scene.floorY;
for(const g of glass){
const pud = puddleHAt(g.x);
const inTankW = inTank(g.x) && g.y < tankLevel - 0.01 && g.y > TANK_B();
const inPudW = !inTankW && g.y < B + pud + 0.03 && pud > 0.004;
const inW = inTankW || inPudW;
const mass = 2500 * 0.008 * g.area;
let ax = 0, ay = -G;
if(inW){
const cvx = inTankW ? 0.8 : 0.4;
const rvx = g.vx - cvx, rvy = g.vy;
const sp = Math.hypot(rvx, rvy);
if(sp > 1e-4){
ax -= 500 * g.area * sp * rvx / mass;
ay -= 500 * g.area * sp * rvy / mass;
}
g.omega *= (1 - Math.min(1, g.dragRot * dt * 3)); // water kills spin fast
} else {
const sp = Math.hypot(g.vx, g.vy);
if(sp > 1e-4){
const ka = 0.5 * 1.2 * g.area;
ax -= ka * sp * g.vx / mass; ay -= ka * sp * g.vy / mass;
}
g.omega *= (1 - 0.05 * dt);
}
g.vx += ax * dt; g.vy += ay * dt;
g.x += g.vx * dt; g.y += g.vy * dt;
g.theta += g.omega * dt;
const half = Math.max(g.w, g.h) * 0.5;
if(inTank(g.x) && g.y < TANK_B() + half * 0.6){
g.y = TANK_B() + half * 0.6;
if(g.vy < 0) g.vy = -g.vy * 0.25;
g.vx *= Math.exp(-3 * dt); g.omega *= Math.exp(-3 * dt);
}
if(g.y < B + half * 0.6){
g.y = B + half * 0.6;
g.vy = -g.vy * 0.3;
g.vx *= 0.6;
g.omega = g.omega * 0.4 + rand(-3, 3) * (0.5 + Math.abs(g.vy)) + g.vx * 0.8;
if(Math.abs(g.vy) < 0.3) g.vy = 0;
}
if(g.x < scene.wallT + half){ g.x = scene.wallT + half; g.vx = Math.abs(g.vx) * 0.4; g.omega *= 0.8; }
if(g.x > scene.roomW - scene.wallT - half){ g.x = scene.roomW - scene.wallT - half; g.vx = -Math.abs(g.vx) * 0.4; g.omega *= 0.8; }
if(g.y > scene.roomH - 0.05){ g.y = scene.roomH - 0.05; g.vy = -Math.abs(g.vy) * 0.3; }
}
if(glass.length > 240) glass.splice(0, glass.length - 240);
}
/* ---------------- puddle: 1D shallow water ---------------- */
function stepPuddle(dt){
// grow from the jet (only while bursting)
if(scene.state === "bursting" && breachFlow > 1e-5){
scene.puddleVol += breachFlow * dt * 0.9;
}
// slow soak/evap so it eventually recedes
scene.puddleVol = Math.max(0, scene.puddleVol - dt * 0.0009);
const P = puddleParams();
if(!P) return;
// wall collisions: the advancing front splashes when it slams a boundary
if(P.hitR && breachFlow > 0.05 && Math.random() < 0.4)
spawnSplash(P.right - 0.02, scene.floorY + P.hmax * 0.5, P.hmax * 4, 3);
if(P.hitL && breachFlow > 0.05 && Math.random() < 0.4)
spawnSplash(P.left + 0.02, scene.floorY + P.hmax * 0.5, P.hmax * 4, 3);
// regenerate the drawn height profile
const Hx = scene.puddleH, N = scene.puddleN;
for(let k = 0; k < N; k++){
const x = scene.puddleX0 + (scene.puddleX1 - scene.puddleX0) * k / (N - 1);
Hx[k] = puddleHAt(x);
}
}
function spawnSplash(x, y, power, n){
for(let i = 0; i < n; i++){
const a = rand(-Math.PI * 0.85, -Math.PI * 0.15);
const s2 = power * rand(0.4, 1.2);
splashes.push({x, y, vx: Math.cos(a)*s2, vy: Math.sin(a)*s2 * 0.9, life: rand(0.4, 0.9)});
}
if(splashes.length > 600) splashes.splice(0, splashes.length - 600);
}
function stepSplashes(dt){
for(let i = splashes.length - 1; i >= 0; i--){
const s2 = splashes[i];
s2.vy -= G * dt; s2.vx *= (1 - 0.4 * dt);
s2.x += s2.vx * dt; s2.y += s2.vy * dt;
s2.life -= dt;
if(s2.y < scene.floorY + puddleHAt(s2.x)) s2.life = 0;
if(s2.life <= 0) splashes.splice(i, 1);
}
}
/* ---------------- main update ---------------- */
function update(dt){
scene.t += dt;
const b = scene.breach;
if(scene.state === "bursting"){
// crack grows; growth is faster when there is more head pressure
const d = tankDepth();
b.h = Math.min(scene.tankH - 0.05, b.h + dt * (0.35 + 0.8 * Math.min(1, d / scene.water0) + 0.4 * Math.min(1, scene.t * 3)));
// hole's lower lip drops toward the floor as water exits
b.y = lerp(b.y, TANK_B() + 0.02, 0.25 * dt);
// Torricelli outflow -> continuous level drop
// outflow driven by the head of water above the crack (Torricelli)
const head = tankLevel - b.y;
if(head > 0.005 && b.h > 0.01){
// the shattered patch widens as the panel lets go
const holeW = 0.8 + 0.9 * Math.min(1, scene.t / 15);
const hHole = Math.min(b.h, head); // hole height limited by the head
let q;
if(head > hHole * 1.02){
q = C * Math.sqrt(2 * G * head) * hHole; // full orifice
} else {
q = (2/3) * C * Math.sqrt(2 * G) * Math.pow(head, 1.5);// shallow slot
}
breachFlow = q; // m2/s -> puddle inflow
const tankVolume = (scene.tankW - 2 * scene.glassT) * scene.water0; // m3
waterIn = Math.max(0, waterIn - holeW * q * dt / tankVolume);
} else breachFlow = 0;
tankLevel = TANK_B() + waterIn * scene.water0;
if(b.h > d) b.h = Math.max(0.01, d);
if(waterIn < 0.02 && scene.t > 1){ scene.state = "drained"; b.h = 0; breachFlow = 0; waterIn = 0; }
}
stepParticles(dt);
for(const o of objects) stepObject(o, dt);
for(const f of fish) stepFish(f, dt);
stepToy(dt);
stepGlass(dt);
stepPuddle(dt);
stepSplashes(dt);
updateJetAudio();
shake = Math.max(0, shake - dt * 1.4);
}
/* ================= rendering ================= */
function drawRoom(){
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, "#2b333b"); g.addColorStop(1, "#20262c");
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
// picture frame on back wall
ctx.fillStyle = "rgba(0,0,0,0.35)";
ctx.fillRect(px(0.8), py(2.9), sX(0.7), sY(0.5));
ctx.fillStyle = "rgba(120,150,170,0.25)";
ctx.fillRect(px(0.84), py(2.86), sX(0.62), sY(0.42));
// wainscot
ctx.fillStyle = "rgba(255,255,255,0.045)";
ctx.fillRect(0, py(1.05), W, 2);
// floor
const fg = ctx.createLinearGradient(0, py(scene.floorY), 0, H);
fg.addColorStop(0, "#4a3f35"); fg.addColorStop(1, "#37302a");
ctx.fillStyle = fg;
ctx.fillRect(0, floorPx(), W, H - floorPx());
ctx.strokeStyle = "rgba(0,0,0,0.18)"; ctx.lineWidth = 1;
for(let i = 1; i < 12; i++){
const x = i * (W / 12);
ctx.beginPath(); ctx.moveTo(x, floorPx()); ctx.lineTo(x, H); ctx.stroke();
}
ctx.fillStyle = "rgba(0,0,0,0.4)";
ctx.fillRect(0, py(scene.floorY + 0.14), W, sY(0.14));
}
function drawStand(){
const L = px(scene.tankL - 0.12), R = px(scene.tankR + 0.12);
const T = py(TANK_B()), B = floorPx();
const g = ctx.createLinearGradient(L, 0, R, 0);
g.addColorStop(0, "#5b4634"); g.addColorStop(0.5, "#6e563f"); g.addColorStop(1, "#54412f");
ctx.fillStyle = g; ctx.fillRect(L, T, R - L, B - T);
ctx.fillStyle = "rgba(0,0,0,0.25)"; ctx.fillRect(L, T, R - L, 5);
ctx.strokeStyle = "rgba(0,0,0,0.3)";
ctx.strokeRect(L + 14, T + 10, R - L - 28, B - T - 20);
}
function drawPuddle(){
const Hx = scene.puddleH, N = scene.puddleN;
let max = 0; for(let i=0;i<N;i++) if(Hx[i] > max) max = Hx[i];
if(max < 0.004) return;
const x0 = px(scene.puddleX0), x1 = px(scene.puddleX1);
ctx.beginPath();
ctx.moveTo(x0, floorPx());
for(let i = 0; i <= N; i++)
ctx.lineTo(x0 + (x1 - x0) * i / N, py(scene.floorY + Hx[Math.min(N, i)]));
ctx.lineTo(x1, floorPx()); ctx.closePath();
const g = ctx.createLinearGradient(0, py(scene.floorY + 0.4), 0, floorPx());
g.addColorStop(0, "rgba(120,200,235,0.75)");
g.addColorStop(1, "rgba(60,140,190,0.85)");
ctx.fillStyle = g; ctx.fill();
// wet sheen: a thin damp band at the floor under the puddle
ctx.fillStyle = "rgba(150,220,255,0.10)";
ctx.beginPath();
ctx.moveTo(x0, floorPx() + 1);
for(let i = 0; i <= N; i++){
const h = Hx[Math.min(N, i)];
ctx.lineTo(x0 + (x1 - x0) * i / N, floorPx() + 1 + Math.min(26, 3 + Math.max(0, h) * SY() * 0.55));
}
ctx.lineTo(x1, floorPx() + 1); ctx.closePath(); ctx.fill();
}
function drawTankBack(){
const L = px(scene.tankL), R = px(scene.tankR);
const T = py(TANK_T()), B = py(TANK_B());
const g = ctx.createLinearGradient(0, T, 0, B);
g.addColorStop(0, "rgba(180,230,255,0.10)");
g.addColorStop(1, "rgba(120,190,230,0.22)");
ctx.fillStyle = g; ctx.fillRect(L, T, R - L, B - T);
// sand
const sg = ctx.createLinearGradient(0, py(TANK_B()+0.1), 0, B);
sg.addColorStop(0, "#d8c08a"); sg.addColorStop(1, "#b39a6b");
ctx.fillStyle = sg;
ctx.beginPath();
ctx.moveTo(L, B);
ctx.lineTo(L, py(TANK_B() + 0.07));
for(let x = L; x <= R; x += 14)
ctx.lineTo(x, py(TANK_B() + 0.07 + 0.03 * Math.sin(x * 0.05)));
ctx.lineTo(R, B); ctx.closePath(); ctx.fill();
ctx.fillStyle = "rgba(90,70,40,0.35)";
for(let i = 0; i < 60; i++)
ctx.fillRect(L + Math.random() * (R - L), B - Math.random() * sY(0.1), 2, 2);
}
function drawPlants(){
const baseY = py(TANK_B() + 0.08);
const plants = [
{x: 1.7, h: 0.55, c: "#3f8f4f"}, {x: 2.2, h: 0.40, c: "#2f7a44"},
{x: 3.2, h: 0.65, c: "#46a05a"}, {x: 3.75, h: 0.45, c: "#37844a"},
{x: 4.4, h: 0.60, c: "#2f8f50"},
];
for(const p of plants){
ctx.lineCap = "round";
for(let k = 0; k < 4; k++){
ctx.strokeStyle = p.c; ctx.lineWidth = 3;
ctx.beginPath();
const bx = px(p.x) + k * 5 - 8;
const sway = Math.sin(scene.t * 1.3 + k * 1.7 + p.x * 3) * 6;
const topY = baseY - sY(p.h * (0.8 + 0.2 * Math.sin(k * 2.3)));
ctx.moveTo(bx, baseY);
ctx.quadraticCurveTo(bx + sway * 0.4, (baseY + topY) / 2, bx + sway, topY);
ctx.stroke();
ctx.fillStyle = p.c;
for(let l2 = 0.3; l2 < 1; l2 += 0.25){
const lx = bx + sway * l2 * 0.7, ly = baseY - (baseY - topY) * l2;
ctx.save(); ctx.translate(lx, ly);
ctx.rotate(l2 * (k % 2 ? 0.8 : -0.8) + Math.sin(scene.t + k) * 0.05);
ctx.beginPath(); ctx.ellipse(6, 0, 7, 3, 0, 0, TAU); ctx.fill();
ctx.restore();
}
}
}
}
function drawWaterBody(){
const L = px(scene.tankL) + 2, R = px(scene.tankR) - 2;
const B = py(TANK_B()), S = py(tankLevel);
if(S > B - 2) return;
const g = ctx.createLinearGradient(0, S, 0, B);
g.addColorStop(0, "rgba(90,190,235,0.55)");
g.addColorStop(1, "rgba(25,110,170,0.75)");
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(L, B); ctx.lineTo(L, S + 2);
for(let x = L; x <= R; x += 8)
ctx.lineTo(x, S + Math.sin(x * 0.05 + scene.t * 2.2) * 1.5 + Math.sin(x * 0.11 - scene.t * 3.1));
ctx.lineTo(R, B); ctx.closePath(); ctx.fill();
// caustics
ctx.save();
ctx.globalAlpha = 0.10; ctx.strokeStyle = "#bff0ff"; ctx.lineWidth = 2;
for(let i = 0; i < 5; i++){
ctx.beginPath();
const yy = lerp(S, B, 0.25 + i * 0.15);
for(let x = L; x <= R; x += 10)
ctx.lineTo(x, yy + Math.sin(x * 0.08 + scene.t * (1 + i * 0.3) + i * 2) * 4);
ctx.stroke();
}
ctx.restore();
// dashed waterline + label (always visible)
ctx.strokeStyle = "rgba(255,255,255,0.5)";
ctx.setLineDash([6, 5]);
ctx.beginPath(); ctx.moveTo(px(scene.tankL), S); ctx.lineTo(px(scene.tankR), S); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = "rgba(255,255,255,0.75)";
ctx.font = "11px sans-serif";
ctx.fillText(`waterline ${(tankDepth() * 100).toFixed(0)}%`, px(scene.tankL) + 6, S - 5);
}
function drawParticles(){
for(const p of parts){
const inTankP = inTank(p.x) && p.y < tankLevel + 0.05;
ctx.globalAlpha = inTankP ? 0.35 : 0.9;
ctx.fillStyle = "rgba(210,245,255,0.9)";
ctx.fillRect(px(p.x) - 1.5, py(p.y) - 1.5, 3, 3);
}
ctx.globalAlpha = 1;
}
function drawSplashDrops(){
ctx.fillStyle = "rgba(200,240,255,0.9)";
for(const s2 of splashes){
ctx.globalAlpha = clamp(s2.life * 1.5, 0, 1);
ctx.beginPath(); ctx.arc(px(s2.x), py(s2.y), 2.2, 0, TAU); ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawJetAndBreach(){
const b = scene.breach;
if(scene.state !== "bursting") return;
const d = tankDepth();
if(b.h > 0.01 && d > 0.005){
// layered curved jets (ballistic under gravity)
const layers = 5;
for(let li = 0; li < layers; li++){
const fy = lerp(b.y - b.h * 0.8, b.y + b.h * 0.8, li / (layers - 1));
const v = jetV(fy);
if(v < 0.05) continue;
let x = b.x + 0.02, y = fy, vx = v, vy = 0;
ctx.beginPath();
let first = true;
const dtj = 0.016;
for(let t = 0; t < 2.4; t += dtj){
if(y < scene.floorY + puddleHAt(x) + 0.01){ y = scene.floorY + puddleHAt(x) + 0.01; break; }
vy -= G * dtj;
x += vx * dtj; y += vy * dtj;
if(x > scene.roomW) break;
if(first){ ctx.moveTo(px(x), py(y)); first = false; }
else ctx.lineTo(px(x), py(y));
}
ctx.strokeStyle = `rgba(210,245,255,${0.55 - li * 0.08})`;
ctx.lineWidth = Math.max(2, sX(0.05 + v * 0.014) * (1 - li * 0.12));
ctx.lineCap = "round";
ctx.stroke();
}
// water sheet at the hole
ctx.beginPath();
ctx.moveTo(px(b.x), py(b.y + b.h));
ctx.lineTo(px(b.x + 0.14), py(b.y + b.h + 0.02));
ctx.lineTo(px(b.x + 0.14), py(b.y - b.h - 0.02));
ctx.lineTo(px(b.x), py(b.y - b.h));
ctx.closePath();
ctx.fillStyle = "rgba(190,235,255,0.5)";
ctx.fill();
// impact spray (drawn each frame)
const v0 = C * Math.sqrt(2 * G * Math.max(0.001, d - b.h * 0.35));
const hFall = b.y - scene.floorY - 0.05;
if(hFall > 0.02){
const tHit = Math.sqrt(2 * hFall / G);
const xHit = clamp(b.x + v0 * tHit * 0.9, scene.wallT, scene.roomW - scene.wallT);
ctx.save();
ctx.strokeStyle = "rgba(220,248,255,0.5)";
ctx.lineWidth = 1.5;
for(let i = 0; i < 7; i++){
const a = rand(-2.6, -0.5), sp2 = v0 * rand(0.15, 0.5);
ctx.beginPath();
ctx.moveTo(px(xHit), py(scene.floorY + 0.02));
ctx.lineTo(px(xHit + Math.cos(a) * sp2 * 0.25), py(scene.floorY + 0.03 + Math.sin(a) * sp2 * 0.2));
ctx.stroke();
}
ctx.restore();
if(Math.random() < 0.7) spawnSplash(xHit, scene.floorY + 0.05, Math.min(3, v0 * 0.35), 3);
}
}
// jagged hole
if(b.h > 0.005){
const cx = px(scene.tankR + scene.glassT / 2);
const top = py(b.y + b.h), bot = py(b.y - b.h);
const teeth = 7;
ctx.beginPath();
ctx.moveTo(cx - sX(0.01), top);
for(let i = 0; i <= teeth; i++){
const yy = lerp(top, bot, i / teeth);
const xx = cx + (i % 2 === 0 ? -1 : 1) * sX(0.012) * (1 + 0.5 * Math.sin(i * 3.1));
ctx.lineTo(xx, yy);
}
ctx.closePath();
ctx.fillStyle = "rgba(8,12,16,0.9)";
ctx.fill();
ctx.strokeStyle = "rgba(255,255,255,0.55)";
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
function panel(x, T, B){
const w = sY(0.03) * 0.5;
const g = ctx.createLinearGradient(x - w, 0, x + w, 0);
g.addColorStop(0, "rgba(160,215,245,0.16)");
g.addColorStop(0.5, "rgba(220,248,255,0.42)");
g.addColorStop(1, "rgba(160,215,245,0.16)");
ctx.fillStyle = g;
ctx.fillRect(x - w, T, w * 2, B - T);
ctx.strokeStyle = "rgba(235,252,255,0.5)";
ctx.lineWidth = 1;
ctx.strokeRect(x - w, T, w * 2, B - T);
ctx.fillStyle = "rgba(255,255,255,0.15)";
ctx.fillRect(x - w * 0.4, T + 4, w * 0.5, Math.max(0, B - T - 8));
}
function drawGlass(){
const L = px(scene.tankL), rx = px(scene.tankR);
const T = py(TANK_T()), B = py(TANK_B());
panel(L, T, B);
ctx.fillStyle = "rgba(190,235,255,0.28)";
ctx.fillRect(L, T, rx - L + sX(scene.glassT), sY(0.02));
if(scene.state === "dragging"){
panel(rx, T, B);
drawCrack();
} else {
const by = py(scene.breach.y);
panel(rx, T, Math.min(B, by - sY(scene.breach.h)));
panel(rx, Math.max(T, by + sY(scene.breach.h)), B);
}
}
function drawCrack(){
const x = px(scene.tankR + scene.glassT * 0.4);
const cy = crackPxY();
ctx.save();
ctx.strokeStyle = "rgba(255,255,255,0.9)";
ctx.lineWidth = 1.6;
ctx.shadowColor = "rgba(180,230,255,0.8)";
ctx.shadowBlur = 6;
ctx.beginPath();
ctx.moveTo(x, cy - sY(0.34));
ctx.lineTo(x + 2, cy - sY(0.2));
ctx.lineTo(x - 2, cy - sY(0.08));
ctx.lineTo(x + 3, cy);
ctx.lineTo(x - 2, cy + sY(0.09));
ctx.lineTo(x + 2, cy + sY(0.22));
ctx.lineTo(x, cy + sY(0.34));
ctx.stroke();
ctx.beginPath();
for(let i = 0; i < 6; i++){
const yy = cy + rand(-sY(0.3), sY(0.3));
const dir = i % 2 ? 1 : -1;
ctx.moveTo(x + dir * 2, yy);
ctx.lineTo(x + dir * (6 + rand(8)), yy + rand(-10, 10));
ctx.lineTo(x + dir * (10 + rand(10)), yy + rand(-14, 14));
}
ctx.stroke();
ctx.restore();
// drag handle
ctx.fillStyle = "#ffd98a";
ctx.beginPath();
ctx.moveTo(x + 8, cy); ctx.lineTo(x + 22, cy - 7); ctx.lineTo(x + 22, cy + 7); ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.moveTo(x + 8, cy); ctx.lineTo(x - 4, cy - 7); ctx.lineTo(x - 4, cy + 7); ctx.closePath();
ctx.fill();
}
function drawShards(){
for(const g of glass){
ctx.save();
ctx.translate(px(g.x), py(g.y));
ctx.rotate(g.theta);
const w = sX(g.w), h = sY(g.h);
ctx.fillStyle = "rgba(190,235,255,0.6)";
ctx.strokeStyle = "rgba(255,255,255,0.85)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(-w/2, -h/2); ctx.lineTo(w/2, -h/3);
ctx.lineTo(w/4, h/2); ctx.lineTo(-w/3, h/3);
ctx.closePath();
ctx.fill(); ctx.stroke();
ctx.restore();
}
}
function drawRock(o){
ctx.save();
ctx.translate(px(o.x), py(o.y));
ctx.rotate(o.theta);
ctx.beginPath();
const n = o.shape.length;
for(let i = 0; i <= n; i++){
const a = TAU * i / n;
const r = o.r * o.shape[i % n];
const X = Math.cos(a) * sX(r), Y = -Math.sin(a) * sY(r);
if(i === 0) ctx.moveTo(X, Y); else ctx.lineTo(X, Y);
}
ctx.closePath();
const g = ctx.createLinearGradient(0, -sY(o.r), 0, sY(o.r));
g.addColorStop(0, "#8d99a6"); g.addColorStop(1, "#5c6672");
ctx.fillStyle = g; ctx.fill();
ctx.strokeStyle = "rgba(0,0,0,0.35)"; ctx.stroke();
ctx.restore();
}
function drawPlank(o){
ctx.save();
ctx.translate(px(o.x), py(o.y));
ctx.rotate(o.theta);
const [len, wdt] = o.shape;
ctx.fillStyle = "#a07848";
ctx.fillRect(-sX(len)/2, -sY(wdt)/2, sX(len), sY(wdt));
ctx.strokeStyle = "rgba(0,0,0,0.3)";
ctx.strokeRect(-sX(len)/2, -sY(wdt)/2, sX(len), sY(wdt));
ctx.restore();
}
function drawToy(o){
if(!o) return;
ctx.save();
ctx.translate(px(o.x), py(o.y));
ctx.rotate(o.theta);
const r = sX(o.r);
ctx.fillStyle = "#ff9d2e";
ctx.beginPath(); ctx.ellipse(0, 0, r * 1.25, r * 0.8, 0, 0, TAU); ctx.fill();
ctx.fillStyle = "#e8543f";
ctx.beginPath(); ctx.ellipse(0, -r * 0.15, r * 0.55, r * 0.6, 0, Math.PI, 0); ctx.fill();
ctx.fillStyle = "#222";
ctx.beginPath(); ctx.arc(-r * 0.45, -r * 0.15, r * 0.14, 0, TAU); ctx.fill();
ctx.beginPath(); ctx.arc(r * 0.45, -r * 0.15, r * 0.14, 0, TAU); ctx.fill();
ctx.fillStyle = "#fff";
ctx.beginPath(); ctx.arc(-r * 0.5, -r * 0.2, r * 0.05, 0, TAU); ctx.fill();
ctx.beginPath(); ctx.arc(r * 0.4, -r * 0.2, r * 0.05, 0, TAU); ctx.fill();
ctx.fillStyle = "#ffb054";
ctx.beginPath();
ctx.moveTo(r * 1.2, 0); ctx.lineTo(r * 1.6, -r * 0.3); ctx.lineTo(r * 1.6, r * 0.3);
ctx.closePath(); ctx.fill();
ctx.restore();
}
function drawFish(f){
ctx.save();
ctx.translate(px(f.x), py(f.y));
ctx.rotate(f.theta);
const L = sX(f.len), Hh = L * 0.34;
const tw = Math.sin(f.tail) * Hh * 0.5;
ctx.fillStyle = `hsl(${f.hue}, 75%, 55%)`;
ctx.beginPath();
ctx.moveTo(-L * 0.35, 0);
ctx.lineTo(-L * 0.75, -Hh * 0.7 + tw);
ctx.lineTo(-L * 0.62, 0);
ctx.lineTo(-L * 0.75, Hh * 0.7 + tw);
ctx.closePath(); ctx.fill();
const g = ctx.createLinearGradient(0, -Hh, 0, Hh);
g.addColorStop(0, `hsl(${f.hue}, 80%, 68%)`);
g.addColorStop(1, `hsl(${f.hue}, 70%, 45%)`);
ctx.fillStyle = g;
ctx.beginPath(); ctx.ellipse(0, 0, L * 0.4, Hh, 0, 0, TAU); ctx.fill();
ctx.fillStyle = `hsl(${f.hue}, 60%, 40%)`;
ctx.beginPath();
ctx.moveTo(-L * 0.15, -Hh * 0.85);
ctx.lineTo(L * 0.05, -Hh * 1.25);
ctx.lineTo(L * 0.18, -Hh * 0.8);
ctx.closePath(); ctx.fill();
ctx.fillStyle = "#fff";
ctx.beginPath(); ctx.arc(L * 0.26, -Hh * 0.2, Hh * 0.28, 0, TAU); ctx.fill();
ctx.fillStyle = "#111";
ctx.beginPath(); ctx.arc(L * 0.28, -Hh * 0.2, Hh * 0.15, 0, TAU); ctx.fill();
ctx.strokeStyle = "rgba(0,0,0,0.5)";
ctx.beginPath();
ctx.arc(L * 0.38, Hh * 0.12, Hh * 0.18, 0.2, Math.PI - 0.2);
ctx.stroke();
ctx.restore();
}
function render(){
ctx.save();
if(shake > 0) ctx.translate(rand(-1,1) * shake * 8, rand(-1,1) * shake * 8);
drawRoom();
drawStand();
drawPuddle();
drawTankBack();
drawPlants();
drawWaterBody();
drawParticles();
for(const o of objects) o.type === "rock" ? drawRock(o) : drawPlank(o);
drawToy(toy);
for(const f of fish) drawFish(f);
drawShards();
drawSplashDrops();
drawGlass();
drawJetAndBreach();
const d = tankDepth();
const vCrack = (scene.state === "dragging") ? jetV(crackWorldY()) : jetV(scene.breach.y);
hudStats.textContent =
`state: ${scene.state} | crack: ${(scene.crackFrac*100).toFixed(0)}% up | ` +
`level: ${(d*100).toFixed(0)}% | jet@crack: ${vCrack.toFixed(2)} m/s | flow: ${(breachFlow*1000).toFixed(0)} L/s` +
(muted ? " | sound off (M)" : "");
ctx.restore();
}
let last = performance.now();
function frame(now){
let dt = Math.min(0.05, (now - last) / 1000); last = now;
const sub = 3;
for(let i = 0; i < sub; i++) update(dt / sub);
render();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>

Xet Storage Details

Size:
45.1 kB
·
Xet hash:
4da3f380738207c6986d668e1e70a3e814f6a9539cf0158572c42c47d94006ff

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.