| <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 | <span class="key">R</span> reset | <span class="key">M</span> sound | |
| <div id="stats" style="margin-top:4px;color:#cfe8f5"></div> | |
| </div> | |
| <canvas id="c"></canvas> | |
| <script> | |
| ; | |
| /* ===================================================================== | |
| 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.