Download index.html from One/Fish: direct link, hf CLI and curl.
- Browser
- Download file 136 kB
-
https://huggingface.co/spaces/One/Fish/resolve/main/index.html
- Command line
-
hf download hf://spaces/One/Fish/index.html
-
curl -L -o index.html https://huggingface.co/spaces/One/Fish/resolve/main/index.html
136 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Digital Aquarium (Optimized)</title> | |
| <style> | |
| html, body { margin:0; padding:0; height:100%; overflow:hidden; background:#02121f; } | |
| #tank { display:block; width:100vw; height:100vh; } | |
| .glass { | |
| position:fixed; inset:0; pointer-events:none; z-index:2; | |
| background: | |
| linear-gradient(115deg, rgba(255,255,255,0.07) 0%, rgba(255,255,255,0) 30%), | |
| linear-gradient(295deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0) 25%); | |
| mix-blend-mode: screen; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="tank"></canvas> | |
| <div class="glass"></div> | |
| <script> | |
| (() => { | |
| ; | |
| // ============ Aquarium v16 WHISKERS â alt-fg base + spots/stripes + whiskers (~10%) == | |
| // v22: Ecco-style rendering â fish show top/bottom view when moving vertically, | |
| // side profile when moving horizontally, like Ecco the Dolphin's 3D perspective | |
| // Based on v15 SPOTTY-3 (rolling hills, three tail styles, yellow-green foreground grass, | |
| // ~20% spots / ~20% stripes / ~10% both), plus: an independent roll gives ~10% of fish | |
| // whiskers (barbels) â a forward-curving pair from the snout in one of THREE lengths | |
| // (short / medium / long), swaying gently as they swim. Whiskers are independent of the | |
| // body markings, so a whiskered fish may also be spotted, striped, both, or plain. | |
| // Iridescence + overhead streak unchanged. | |
| const canvas = document.getElementById("tank"); | |
| const ctx = canvas.getContext("2d"); | |
| let W = 0, H = 0; | |
| const rand = (a, b) => a + Math.random() * (b - a); | |
| const pick = arr => arr[(Math.random() * arr.length) | 0]; | |
| function lerpAngle(a, b, t) { | |
| let d = b - a; | |
| while (d > Math.PI) d -= 2 * Math.PI; | |
| while (d < -Math.PI) d += 2 * Math.PI; | |
| return a + d * t; | |
| } | |
| const HUES = [8, 35, 46, 172, 195, 215]; | |
| const TAIL_TYPES = ['classic', 'fan', 'streamer']; // v11: three tail-fin styles, one per fish | |
| // v18: koi colourations â large irregular patches on a white/ivory base. Three classic | |
| // looks: kohaku (red/orange), sanke (red + black), golden (amber/gold). Patches are | |
| // sampled in units of `size` like the spots, so they scale with each fish. | |
| function genKoiPatches(type) { | |
| const palettes = { | |
| kohaku: [ | |
| () => `hsla(${rand(4, 20)},${rand(85, 98)}%,${rand(50, 62)}%,0.92)`, // vermilion | |
| () => `hsla(${rand(16, 32)},${rand(85, 98)}%,${rand(54, 66)}%,0.88)`, // orange | |
| ], | |
| sanke: [ | |
| () => `hsla(${rand(2, 14)},${rand(85, 98)}%,${rand(48, 60)}%,0.92)`, // red | |
| () => `hsla(${rand(205, 230)},${rand(18, 38)}%,${rand(12, 22)}%,0.9)`, // charcoal/black | |
| ], | |
| golden: [ | |
| () => `hsla(${rand(38, 48)},${rand(85, 98)}%,${rand(55, 66)}%,0.92)`, // gold | |
| () => `hsla(${rand(26, 40)},${rand(85, 98)}%,${rand(50, 62)}%,0.88)`, // amber | |
| ], | |
| goldspot: [ | |
| () => `hsla(${rand(40, 50)},${rand(15, 30)}%,${rand(92, 98)}%,0.9)`, // white spots | |
| () => `hsla(${rand(40, 50)},${rand(15, 30)}%,${rand(92, 98)}%,0.9)`, // white spots (duplicate for consistent selection) | |
| ], | |
| }; | |
| const styles = palettes[type]; | |
| const arr = []; | |
| const isGoldspot = type === 'goldspot'; | |
| const nPatches = isGoldspot ? (8 + ((Math.random() * 8) | 0)) : (4 + ((Math.random() * 4) | 0)); // goldspot: 8..15, else 4..7 | |
| for (let i = 0; i < nPatches; i++) { | |
| const th = rand(0, Math.PI * 2); | |
| const rr = Math.sqrt(Math.random()) * 0.85; // uniform in ellipse, kept off the rim | |
| arr.push({ | |
| x: Math.cos(th) * rr, // along body axis (units of s) | |
| y: Math.sin(th) * rr * 0.42, // across body (matches the 0.42 ellipse ratio) | |
| r: isGoldspot ? rand(0.06, 0.14) : rand(0.3, 0.52), // goldspot: small dots, else large patches | |
| style: styles[Math.random() < 0.5 ? 0 : 1](), | |
| }); | |
| } | |
| return arr; | |
| } | |
| let fishes = [], bubbles = [], seaweeds = [], backSeaweeds = [], midSeaweeds = [], farSeaweeds = [], motes = [], rays = [], rocks = [], grass = [], fgGrass = []; | |
| let playerFish = null; // the fish controlled by gamepad (spawned with F key) | |
| const GAMEPAD_DEADZONE = 0.15; // ignore small stick movements | |
| let curNight = 0; // v7: shared night factor (dims the top-light + sheen) | |
| let plantPoints = [], _ppUsed = 0; // collision points â a pooled set, reused in place | |
| let sandHeights = []; | |
| let sandParticles = []; // v22: grains of sand kicked up when a fast fish skims the sea floor | |
| class Fish { | |
| static nextId = 0; | |
| constructor() { | |
| this.id = Fish.nextId++; | |
| this.size = rand(15, 42) * 1.15; | |
| this.speed = rand(0.55, 1.7) * (this.size / 30 + 0.5) * 0.75; // v3: max speed reduced 25% | |
| this.x = rand(60, W - 60); | |
| this.y = rand(H * 0.15, H * 0.72); | |
| this.angle = Math.random() < 0.5 ? rand(-0.4, 0.4) : Math.PI + rand(-0.4, 0.4); | |
| // Clownfish variant - 5% chance | |
| this.isClownfish = Math.random() < 0.05; | |
| if (this.isClownfish) { | |
| this.hue = 35; | |
| this.saturation = 90; | |
| this.lightness = 55; | |
| this.isKoi = false; | |
| this.koiPatches = null; | |
| this.tailType = pick(TAIL_TYPES); | |
| // Clownfish stripes - three white stripes with black edges | |
| const clownStripes = []; | |
| const stripePositions = [0.65, 0.25, -0.15]; | |
| for (const pos of stripePositions) { | |
| clownStripes.push({ | |
| x: pos, | |
| w: rand(0.06, 0.09), | |
| style: 'hsla(0,0%,98%,0.92)', | |
| cAmp: rand(0.02, 0.05), | |
| cFreq: rand(0.8, 1.5), | |
| cPh: rand(0, Math.PI * 2), | |
| tAmp: rand(0.1, 0.3), | |
| tFreq: rand(0.8, 1.5), | |
| tPh: rand(0, Math.PI * 2), | |
| edgeStyle: 'hsla(0,0%,15%,0.35)', | |
| }); | |
| } | |
| this.stripes = clownStripes; | |
| this.spots = null; | |
| this.whiskerLen = null; | |
| } | |
| // v18: color palette â silver/gray + three koi colourations (replaces dark blue & lime green) | |
| const specialRoll = Math.random(); | |
| this.isKoi = false; | |
| this.koiPatches = null; | |
| if (specialRoll < 0.15) { | |
| // Silver/gray fish â low saturation, medium-high lightness | |
| this.hue = rand(200, 240); // slight blue-gray tint | |
| this.saturation = rand(8, 18); | |
| this.lightness = rand(60, 75); | |
| } else if (specialRoll < 0.45) { | |
| // Koi â a white/ivory base with large coloured patches; the three classic | |
| // colourations are split roughly evenly across this 30% band. | |
| this.isKoi = true; | |
| const koiType = specialRoll < 0.22 ? 'kohaku' : specialRoll < 0.30 ? 'sanke' : specialRoll < 0.38 ? 'golden' : 'goldspot'; | |
| if (koiType === 'goldspot') { | |
| // Golden orange fish with white spots | |
| this.hue = rand(30, 42); // golden orange | |
| this.saturation = rand(85, 95); // high saturation â vivid color | |
| this.lightness = rand(50, 65); // medium lightness â rich gold | |
| } else { | |
| // Classic koi â white/ivory base | |
| this.hue = rand(40, 50); // warm ivory tint for the base | |
| this.saturation = rand(12, 22); // low saturation â white body | |
| this.lightness = rand(88, 94); // high lightness â white/ivory base | |
| } | |
| this.koiPatches = genKoiPatches(koiType); | |
| } else { | |
| // Standard colored fish from the original palette | |
| this.hue = pick(HUES); | |
| this.saturation = 75; | |
| this.lightness = 52; | |
| } | |
| this.tailType = pick(TAIL_TYPES); // v11: random tail-fin style for variety | |
| // v13/v14/v15: random body markings â one roll per fish decides the look: | |
| // ~20% spots, ~20% tiger stripes, ~10% BOTH (spots over stripes), else plain. | |
| // Sampled in units of `size` so they scale with the fish; stored as pre-built | |
| // fill styles â zero per-frame string work. | |
| const genSpots = () => { | |
| const arr = []; | |
| const nSpots = 7 + ((Math.random() * 9) | 0); // 7..15 spots | |
| const pale = Math.random() < 0.45; // cream/ivory vs dark spotting style | |
| for (let i = 0; i < nSpots; i++) { | |
| const th = rand(0, Math.PI * 2); | |
| const rr = Math.sqrt(Math.random()) * 0.9; // uniform in ellipse, kept off the rim | |
| let style; | |
| if (this.saturation < 30) { | |
| // v14: gray/silver fish â keep spots low-saturation to match | |
| const spotLight = pale ? rand(75, 88) : rand(35, 50); | |
| style = `hsla(${this.hue + rand(-24, 24)},${rand(5, 15)}%,${spotLight}%,${rand(0.35, 0.6).toFixed(2)})`; | |
| } else if (pale) { | |
| // cream/ivory spots â pearl-gourami / leopard pattern | |
| style = `hsla(${this.hue + rand(-24, 24)},${rand(45, 70)}%,${rand(82, 94)}%,${rand(0.35, 0.6).toFixed(2)})`; | |
| } else { | |
| // dark spots â dace / leopard pattern | |
| style = `hsla(${this.hue + rand(-18, 18)},${rand(60, 85)}%,${rand(24, 34)}%,${rand(0.45, 0.7).toFixed(2)})`; | |
| } | |
| arr.push({ | |
| x: Math.cos(th) * rr, // along body axis, in units of s | |
| y: Math.sin(th) * rr * 0.42, // across body (matches the 0.42 ellipse ratio) | |
| r: rand(0.05, 0.13), // spot radius, in units of s | |
| style, | |
| }); | |
| } | |
| return arr; | |
| }; | |
| const genStripes = () => { | |
| // v14/v14.1: tiger stripes â vertical bars across the body (betta / tiger-barb | |
| // style), spread from just behind the head to near the tail base with organic | |
| // jitter; each bar bends and tapers instead of being a straight line. | |
| const arr = []; | |
| const nStr = 5 + ((Math.random() * 4) | 0); // 5..8 bars | |
| const darkBars = this.saturation >= 30 && Math.random() < 0.72; // v14: gray fish get light stripes | |
| for (let i = 0; i < nStr; i++) { | |
| const t = nStr > 1 ? i / (nStr - 1) : 0; // 0 behind head â 1 near tail base | |
| let style; | |
| if (this.saturation < 30) { | |
| // v14: gray/silver fish â keep stripes low-saturation to match | |
| const stripeLight = darkBars ? rand(35, 50) : rand(75, 88); | |
| style = `hsla(${this.hue + rand(-14, 14)},${rand(5, 15)}%,${stripeLight}%,${rand(0.5, 0.75).toFixed(2)})`; | |
| } else if (darkBars) { | |
| // classic dark tiger bars | |
| style = `hsla(${this.hue + rand(-14, 14)},${rand(65, 90)}%,${rand(18, 30)}%,${rand(0.5, 0.75).toFixed(2)})`; | |
| } else { | |
| // cream/ivory bars â pale tiger variant | |
| style = `hsla(${this.hue + rand(-24, 24)},${rand(45, 70)}%,${rand(80, 93)}%,${rand(0.35, 0.6).toFixed(2)})`; | |
| } | |
| arr.push({ | |
| x: 0.72 - t * 1.62 + rand(-0.07, 0.07), // +0.72s â -0.90s along the body | |
| w: rand(0.08, 0.2), // bar width, in units of s | |
| style, | |
| // v14.1: organic shape â each bar bends and tapers instead of a straight line | |
| cAmp: rand(0.03, 0.09), // whole-bar bend amplitude (units of s) | |
| cFreq: rand(0.8, 2.0), // waves of bend across the bar's height | |
| cPh: rand(0, Math.PI * 2), | |
| tAmp: rand(0.15, 0.45), // width taper (fraction of half-width) | |
| tFreq: rand(0.8, 2.0), | |
| tPh: rand(0, Math.PI * 2), | |
| }); | |
| } | |
| return arr; | |
| }; | |
| // v18: koi already carry their own large patches â skip the random spots/stripes | |
| const markRoll = this.isKoi ? 1 : Math.random(); | |
| if (markRoll < 0.2) { | |
| this.spots = genSpots(); | |
| this.stripes = null; | |
| } else if (markRoll < 0.4) { | |
| this.spots = null; | |
| this.stripes = genStripes(); | |
| } else if (markRoll < 0.5) { | |
| // v15: the mixed breed â stripes as the base pattern, spots scattered over them | |
| this.spots = genSpots(); | |
| this.stripes = genStripes(); | |
| } else { | |
| this.spots = null; | |
| this.stripes = null; | |
| } | |
| // v16: whiskers (barbels) â an independent ~10% roll. A whiskered fish gets a | |
| // forward-curving pair from the snout in one of three length tiers (~even split), | |
| // so the tank shows short, medium and long whiskers. Independent of spots/stripes, | |
| // so any marked fish can also wear them. | |
| if (Math.random() < 0.1) { | |
| const tier = Math.random(); // pick one of three lengths | |
| this.whiskerLen = tier < 1 / 3 ? rand(0.72, 0.96) // v19: whiskers 60% longer | |
| : tier < 2 / 3 ? rand(1.28, 1.6) | |
| : rand(2.0, 2.4); // in units of size s | |
| this.whiskPh = rand(0, Math.PI * 2); // per-fish wave phase offset | |
| this.whiskerAlpha = 0.5; // v18: fixed 50% opacity | |
| } else { | |
| this.whiskerLen = null; | |
| } | |
| // PERF: precompute per-fish paint styles once. draw() used to rebuild these | |
| // template strings every frame for every fish â now it's a property read. | |
| this.tailFillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.92)`; | |
| this.dorsalFillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.9)`; | |
| this.pevFillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.8)`; | |
| this.pevStrokeStyle = `hsla(${this.hue},${Math.min(100, this.saturation * 1.15)}%,${Math.max(8, this.lightness - 20)}%,0.5)`; | |
| this.whiskerStyle = `hsla(${this.hue},55%,82%,${this.whiskerAlpha || 0.5})`; | |
| // PERF: the body gradient depends only on fixed per-fish colors and fish-local | |
| // coordinates, so build it once instead of recreating it every frame. | |
| const bg = ctx.createLinearGradient(0, -this.size * 0.5, 0, this.size * 0.5); | |
| bg.addColorStop(0, `hsl(${this.hue},${Math.min(100, this.saturation * 1.08)}%,${this.lightness + 12}%)`); | |
| bg.addColorStop(1, `hsl(${this.hue},${this.saturation * 0.9}%,${this.lightness - 8}%)`); | |
| this.bodyGrad = bg; | |
| this.phase = rand(0, Math.PI * 2); | |
| // v9: after dark, most fish bed down low in the grass â only a small share still | |
| // hovers by the seaweed, and each of those keeps to its own stalk (no pile-ups) | |
| this.grassSleeper = Math.random() < 0.85; | |
| this.seaweedPref = -1; // chosen lazily on the first night (needs built scenery) | |
| // v14: every grass-sleeper gets its own personal sleep spot along the tank, | |
| // so at night they spread out instead of all piling onto the same sand line | |
| this.sleepSpotX = W * (0.06 + 0.88 * Math.random()); | |
| // v10: each fish has its own early/normal/late personality for going to bed (dusk) | |
| // and waking up (dawn), so the school doesn't sleep or stir in lockstep. | |
| const pickTrait = () => { const r = Math.random(); return r < 0.34 ? 'early' : r < 0.67 ? 'normal' : 'late'; }; | |
| this.sleepTrait = pickTrait(); // when it goes to bed at dusk | |
| this.riseTrait = pickTrait(); // when it wakes up at dawn | |
| // dusk: early sleepers drift down sooner (+), late sleepers stay up longer (-) | |
| this.sleepShift = this.sleepTrait === 'early' ? rand(0.25, 0.45) | |
| : this.sleepTrait === 'late' ? -rand(0.25, 0.45) | |
| : rand(-0.12, 0.12); | |
| // dawn: late risers linger in bed longer (+), early risers stir sooner (-) | |
| this.riseShift = this.riseTrait === 'late' ? rand(0.25, 0.45) | |
| : this.riseTrait === 'early' ? -rand(0.25, 0.45) | |
| : rand(-0.12, 0.12); | |
| // Wander steering (Reynolds-style): a circle ahead of the fish whose target point jitters | |
| this.wanderTheta = rand(0, Math.PI * 2); | |
| this.wanderRadius = rand(35, 80); | |
| this.wanderDist = rand(90, 170); | |
| this.maxTurn = rand(1.4, 2.2); // max rad/s â organic turns, no orbiting | |
| // Behavior states: fish don't swim non-stop â they cruise, hover, and rest. | |
| this.behavior = 'swim'; // 'swim' | 'hover' | 'pause' | |
| this.behaviorTimer = rand(4, 11); // seconds until it may change behavior | |
| this.speedFactor = 1; // current speed multiplier (eased toward target) | |
| this.targetSpeedFactor = 1; // where speedFactor is easing toward | |
| this.cruiseSpeed = 1; // base cruising power (night lowers it, dusk eases it back) | |
| this.currentVelocity = 0; // actual current velocity in px/s (for phase & collision) | |
| // Occasionally puffs a tiny cluster of bubbles from its mouth | |
| this.bubbleTimer = rand(4, 16); // seconds until the next bubble puff | |
| // Body roll â banks/rolls through sharp turns like a real fish (spring-damper) | |
| this.roll = 0; // current roll angle (rad); 0 = upright | |
| this.rollVel = 0; // angular velocity of the roll | |
| this.inHardTurn = false;// was it in a sharp turn last frame? | |
| // v22: Ecco-style viewing angle â determines whether we see the fish from the side | |
| // or from above/below based on its movement direction (0 = side, 1 = top/bottom) | |
| this.viewAngle = 0; // current interpolated view angle | |
| // v26: pelvic fin on 75% of fish, below the body like a real fish | |
| this.hasPelvicFin = Math.random() < 0.75; | |
| } | |
| update(dt) { | |
| const night = personalNight(performance.now() / 1000, this); // v10: per-fish early/normal/late sleep & wake | |
| // Night: cruise power eases down so even active fish drift lazily after dark, | |
| // and back up again at dawn â smooth, never a sudden speed jump. | |
| this.cruiseSpeed += ((1 - night * 0.55) - this.cruiseSpeed) * Math.min(1, dt * 0.3); | |
| // Behavior state machine â occasionally hover or come to near-rest. | |
| // At night the odds tilt toward hovering and long still rests (v6). | |
| this.behaviorTimer -= dt; | |
| if (this.behaviorTimer <= 0) { | |
| const r = Math.random(); | |
| const swimC = 0.45 * (1 - night * 0.7); // night: far less chance to cruise | |
| if (r < swimC) { | |
| this.behavior = 'swim'; | |
| this.targetSpeedFactor = 1; | |
| this.behaviorTimer = rand(4, 11); | |
| } else if (r < swimC + (0.74 - 0.45) * (1 - night * 0.3)) { | |
| this.behavior = 'hover'; | |
| this.targetSpeedFactor = rand(0.25, 0.45); // drift slowly in place | |
| this.behaviorTimer = rand(2, 6 + night * 8); // longer hovers at night | |
| } else { | |
| this.behavior = 'pause'; | |
| this.targetSpeedFactor = rand(0.03, 0.10); // nearly stopped, just drifting | |
| this.behaviorTimer = rand(1.5 + night * 2, 4 + night * 9); // much longer rests at night | |
| } | |
| } | |
| // Ease speed toward the target so speed-ups/slow-downs are smooth, not abrupt | |
| this.speedFactor += (this.targetSpeedFactor - this.speedFactor) * Math.min(1, dt * 2.2); | |
| // Occasionally release a small cluster of bubbles from the mouth | |
| this.bubbleTimer -= dt; | |
| if (this.bubbleTimer <= 0) { | |
| const mx = this.x + Math.cos(this.angle) * this.size * 0.62; | |
| const my = this.y + Math.sin(this.angle) * this.size * 0.62; | |
| const n = (2 + ((Math.random() * 3) | 0)) * 2; // 4..8 bubbles â doubled from the old 2..4 | |
| for (let i = 0; i < n; i++) { | |
| const bb = new Bubble(false, mx + rand(-3, 3), my + rand(-3, 3), this.size); | |
| bb.owner = this; // v10: a fish never pops its own bubbles | |
| bubbles.push(bb); | |
| } | |
| this.bubbleTimer = rand(8, 22); // wait a while before the next puff | |
| } | |
| // Jitter the wander target point around its circle | |
| this.wanderTheta += rand(-1, 1) * dt * 3.2; | |
| // Target sits ahead of the fish (in heading direction), offset by wanderTheta. | |
| // Steering toward it yields smooth meandering and genuine direction changes. | |
| let wx = this.x + Math.cos(this.angle) * this.wanderDist + Math.cos(this.wanderTheta) * this.wanderRadius; | |
| let wy = this.y + Math.sin(this.angle) * this.wanderDist + Math.sin(this.wanderTheta) * this.wanderRadius; | |
| // v7: after dark, most fish bed down low in the grass; a few still hover by the seaweed. | |
| if (night > 0.01) { | |
| if (this.grassSleeper) { | |
| // v14: drift toward this fish's own sleep spot along the sand line â | |
| // spreads sleeping fish across the tank instead of one big clump | |
| wx += (this.sleepSpotX - wx) * night * 0.75; | |
| const groundY = sandAt(this.x + Math.cos(this.angle) * this.wanderDist) - this.size * 0.5; | |
| wy += (groundY - wy) * night * 0.85; | |
| } else if (seaweeds.length) { | |
| // v9: each seaweed-hungry fish keeps to its own stalk instead of all crowding the nearest one | |
| if (this.seaweedPref < 0 || this.seaweedPref >= seaweeds.length) { | |
| this.seaweedPref = (Math.random() * seaweeds.length) | 0; | |
| } | |
| const w = seaweeds[this.seaweedPref]; | |
| // v13: plinko fix â stalks near the sides pull harder so fish actually reach them | |
| // instead of being deflected back toward the middle by the boundary steering. | |
| const edgeT = Math.abs(w.x / W - 0.5) * 2; // 0 at center â 1 at glass | |
| const k = night * 0.8 * (1 + edgeT * 0.9); // up to ~1.9x at the very edge | |
| wx += (w.x - wx) * k; | |
| wy += (w.yMid - wy) * k; | |
| } | |
| } | |
| let desired = Math.atan2(wy - this.y, wx - this.x); | |
| // Soft boundary steering: kicks in a bit before the glass so fish turn | |
| // gently instead of bonking (v4), roaming freely across the whole tank | |
| let pull = 0; | |
| const mX = W * 0.16, mTop = H * 0.14, mBot = H * 0.16; | |
| if (this.x < mX) pull = Math.max(pull, 1 - this.x / mX); | |
| else if (this.x > W - mX) pull = Math.max(pull, 1 - (W - this.x) / mX); | |
| if (this.y < mTop) pull = Math.max(pull, 1 - this.y / mTop); | |
| else if (this.y > H - mBot) pull = Math.max(pull, 1 - (H - this.y) / mBot); | |
| // v13: plinko fix â grass-sleepers settling at night are less deflected by the | |
| // boundary, so they can reach side positions instead of being pushed to the middle. | |
| if (pull > 0 && this.grassSleeper && night > 0.25) { | |
| const settle = Math.min(1, (night - 0.25) / 0.75); // ramps up as they get sleepier | |
| pull *= (1 - settle * 0.55); // up to ~55% weaker when fully settled | |
| } | |
| if (pull > 0) { | |
| const cx = W / 2, cy = H * 0.45; | |
| desired = lerpAngle(desired, Math.atan2(cy - this.y, cx - this.x), Math.min(1, pull * 1.5)); | |
| } | |
| // Floor steering: as a fish nears the sand bed it is steered upward, | |
| // so it skims above the ground line instead of diving under it | |
| // (v4: starts earlier and pushes harder â grass yes, faceplants no) | |
| const floorY = sandAt(this.x) - this.size * 0.35; | |
| const fZone = H * 0.18; | |
| if (this.y > floorY - fZone) { | |
| const fp = Math.min(1, (this.y - (floorY - fZone)) / fZone); | |
| desired = lerpAngle(desired, Math.atan2(H * 0.45 - this.y, W / 2 - this.x), fp * 1.6); | |
| } | |
| // v7: separation steering â bend around nearby fish instead of ploughing into them. | |
| // The faster we swim, the earlier and harder we steer away. | |
| const mySpeed = this.speed * this.speedFactor; | |
| if (mySpeed > 0.3) { | |
| let ax = 0, ay = 0, wsum = 0; | |
| for (const o of fishes) { | |
| if (o === this) continue; | |
| const dx = o.x - this.x, dy = o.y - this.y; | |
| const d2 = dx * dx + dy * dy; | |
| const zone = (this.size + o.size) * 1.7; // notice radius scales with both fish | |
| if (d2 < zone * zone && d2 > 0.01) { | |
| const dd = Math.sqrt(d2); | |
| const w = (1 - dd / zone) * Math.min(1, mySpeed); // close & fast â strong | |
| ax -= (dx / dd) * w; | |
| ay -= (dy / dd) * w; | |
| wsum += w; | |
| } | |
| } | |
| if (wsum > 0) { | |
| desired = lerpAngle(desired, Math.atan2(ay, ax), Math.min(0.85, wsum * 0.45)); | |
| } | |
| } else if (night > 0.2) { | |
| // v14: at rest after dark â a wider, stronger personal buffer so sleeping | |
| // fish gently push apart instead of settling into one tight clump | |
| let ax = 0, ay = 0, wsum = 0; | |
| for (const o of fishes) { | |
| if (o === this) continue; | |
| const dx = o.x - this.x, dy = o.y - this.y; | |
| const d2 = dx * dx + dy * dy; | |
| const zone = (this.size + o.size) * 2.4; // much wider buffer than the swim radius | |
| if (d2 < zone * zone && d2 > 0.01) { | |
| const dd = Math.sqrt(d2); | |
| const w = 1 - dd / zone; | |
| ax -= (dx / dd) * w; | |
| ay -= (dy / dd) * w; | |
| wsum += w; | |
| } | |
| } | |
| if (wsum > 0) { | |
| desired = lerpAngle(desired, Math.atan2(ay, ax), Math.min(0.85, wsum * 0.6)); | |
| } | |
| } | |
| // Limit turn rate so direction changes are smooth and fish-like | |
| let d = desired - this.angle; | |
| while (d > Math.PI) d -= 2 * Math.PI; | |
| while (d < -Math.PI) d += 2 * Math.PI; | |
| const maxStep = this.maxTurn * dt; | |
| this.angle += Math.max(-maxStep, Math.min(maxStep, d)); | |
| // Fish roll their bodies when banking through sharp turns: the moment a big | |
| // heading change begins, sometimes excite a roll (banking into the turn), | |
| // then let a spring-damper wobble it back upright. | |
| const hardTurn = Math.abs(d) > 1.2; | |
| if (hardTurn && !this.inHardTurn && this.speedFactor > 0.35 && Math.random() < 0.55) { | |
| this.rollVel += Math.sign(d) * rand(3.8, 5.5); // bank into the turn | |
| } | |
| this.inHardTurn = hardTurn; | |
| this.rollVel += (-8 * this.roll - 2.4 * this.rollVel) * dt; | |
| this.roll += this.rollVel * dt; | |
| if (this.roll > 1.15) { this.roll = 1.15; this.rollVel *= -0.25; } | |
| else if (this.roll < -1.15) { this.roll = -1.15; this.rollVel *= -0.25; } | |
| // v22: Ecco-style viewing angle â when moving vertically, we see the fish from above/below; | |
| // when moving horizontally, we see its side profile. Smoothly interpolate between views. | |
| // sin(angle) gives 0 for horizontal (0, Ï) and ±1 for vertical (±Ï/2) | |
| const targetView = Math.abs(Math.sin(this.angle)); | |
| this.viewAngle += (targetView - this.viewAngle) * Math.min(1, dt * 4); | |
| // Player fish: input-driven movement (keyboard WASD + gamepad sticks), | |
| // overrides the AI cruising so the player is fully in control. | |
| if (this === playerFish) { | |
| const inp = getPlayerInput(); | |
| // Turn toward the combined input direction (WASD or left stick). | |
| if (Math.hypot(inp.dx, inp.dy) > GAMEPAD_DEADZONE) { | |
| const targetAngle = Math.atan2(inp.dy, inp.dx); | |
| let d = targetAngle - this.angle; | |
| while (d > Math.PI) d -= 2 * Math.PI; | |
| while (d < -Math.PI) d += 2 * Math.PI; | |
| const turnSpeed = 4.0; // rad/s â responsive turning | |
| this.angle += Math.max(-turnSpeed * dt, Math.min(turnSpeed * dt, d)); | |
| } | |
| // Speed: cruise baseline, boosted by Shift / R-stick down, | |
| // slowed by Space / R-stick up. | |
| let speedMult = 1; | |
| if (inp.boost > 0) speedMult += inp.boost * 2.0; // speed up | |
| if (inp.slow > 0) speedMult -= inp.slow * 0.85; // slow down | |
| speedMult = Math.max(0.15, Math.min(3.0, speedMult)); | |
| const playerSpeed = this.speed * this.cruiseSpeed * speedMult * 60 * dt; | |
| this.x += Math.cos(this.angle) * playerSpeed; | |
| this.y += Math.sin(this.angle) * playerSpeed; | |
| this.currentVelocity = this.speed * this.cruiseSpeed * speedMult * 60; // track actual velocity | |
| } else { | |
| // Normal AI fish movement. | |
| const sp = this.speed * this.cruiseSpeed * this.speedFactor * 60 * dt; | |
| this.x += Math.cos(this.angle) * sp; | |
| this.y += Math.sin(this.angle) * sp; | |
| this.currentVelocity = this.speed * this.cruiseSpeed * this.speedFactor * 60; // track actual velocity | |
| } | |
| // NOTE: fish do NOT collide with plants â they plough straight through. | |
| // The seaweed bends out of the way instead (see updatePlants). | |
| // Hard safety clamp â fish may just about swim off-screen (they never | |
| // despawn or leave), but never below the ground line (sand surface) | |
| this.x = Math.max(-40, Math.min(W + 40, this.x)); | |
| this.y = Math.max(-50, Math.min(sandAt(this.x) - this.size * 0.35, this.y)); | |
| // Tail beat follows actual speed â a resting fish barely moves its tail | |
| // Use the actual current velocity (which accounts for player boost/slow) | |
| const effSpeed = this.currentVelocity / 60; // convert back to base units | |
| this.phase += dt * (4 + effSpeed * 3); | |
| } | |
| draw(c) { | |
| c.save(); | |
| c.translate(this.x, this.y); | |
| c.rotate(this.angle); | |
| // v23: Ecco-style perspective WITHOUT a second picture. We keep the one fully- | |
| // detailed body and foreshorten it as the fish turns to swim vertically. | |
| // viewAngle 0 = pure side profile (one eye), 1 = seen from above/below (the back). | |
| // Foreshortening along local up/down makes the dorsal fin slide toward center and | |
| // the body slim down, reading as "seeing the back" â no snapping, nothing vanishes. | |
| const v = Math.max(0, Math.min(1, this.viewAngle)); | |
| let scaleY = 1 - 0.34 * v; // foreshorten toward the back view | |
| if (Math.abs(this.roll) > 0.01) { // banking roll still squashes a touch | |
| scaleY *= Math.max(0.42, Math.cos(this.roll)); | |
| c.rotate(this.roll * 0.1); | |
| } | |
| c.scale(1, scaleY); | |
| this.drawSideView(c); // one body: wave + patterns + fins always on | |
| c.restore(); | |
| } | |
| // v22: Draw fish from above or below (Ecco-style top/bottom view) | |
| drawTopBottomView(c) { | |
| const s = this.size; | |
| // traveling body wave | |
| const waveK = Math.PI / s; | |
| const waveAmp = s * 0.12; | |
| const sway = Math.sin(-s * waveK + this.phase) * s * 0.34; | |
| // Top/bottom view: body seen from above or below | |
| // Dorsal fin visible as a line down the center when viewed from above | |
| const g = this.bodyGrad; | |
| const NSEG = 26; | |
| // Build body outline (same as side view but we're looking at it from top/bottom) | |
| const bodyPath = new Path2D(); | |
| for (let i = 0; i <= NSEG; i++) { | |
| const x = s - (i / NSEG) * 2 * s; | |
| // Wider body when viewed from top/bottom (we see the full width) | |
| const hh = s * 0.55 * Math.sqrt(Math.max(0, 1 - (x / s) ** 2)); | |
| if (i === 0) bodyPath.moveTo(x, -hh); else bodyPath.lineTo(x, -hh); | |
| } | |
| for (let i = NSEG; i >= 0; i--) { | |
| const x = s - (i / NSEG) * 2 * s; | |
| const hh = s * 0.55 * Math.sqrt(Math.max(0, 1 - (x / s) ** 2)); | |
| bodyPath.lineTo(x, hh); | |
| } | |
| bodyPath.closePath(); | |
| c.fillStyle = g; | |
| c.fill(bodyPath); | |
| // Dorsal fin â visible as a line/shape down the center when viewed from above | |
| c.strokeStyle = this.tailFillStyle; | |
| c.lineWidth = s * 0.15; | |
| c.lineCap = "round"; | |
| c.beginPath(); | |
| c.moveTo(s * 0.3, 0); | |
| // Curved dorsal fin running down the center | |
| c.quadraticCurveTo(-s * 0.2, -sway * 0.5, -s * 0.6, sway * 0.3); | |
| c.stroke(); | |
| // Eyes â visible on both sides of the head when viewed from top/bottom | |
| c.fillStyle = "#fff"; | |
| // Left eye | |
| c.beginPath(); | |
| c.arc(s * 0.55, -s * 0.40, s * 0.1, 0, Math.PI * 2); | |
| c.fill(); | |
| c.fillStyle = "#0d1420"; | |
| c.beginPath(); | |
| c.arc(s * 0.57, -s * 0.36, s * 0.05, 0, Math.PI * 2); | |
| c.fill(); | |
| // Right eye | |
| c.fillStyle = "#fff"; | |
| c.beginPath(); | |
| c.arc(s * 0.55, s * 0.40, s * 0.1, 0, Math.PI * 2); | |
| c.fill(); | |
| c.fillStyle = "#0d1420"; | |
| c.beginPath(); | |
| c.arc(s * 0.57, s * 0.36, s * 0.05, 0, Math.PI * 2); | |
| c.fill(); | |
| // Tail fin (simplified for top/bottom view) | |
| c.fillStyle = this.tailFillStyle; | |
| c.beginPath(); | |
| if (this.tailType === 'fan') { | |
| c.moveTo(-s * 0.5, 0); | |
| c.quadraticCurveTo(-s * 1.3, -s * 0.28 + sway, -s * 1.42, -s * 0.5 + sway); | |
| c.quadraticCurveTo(-s * 1.12, sway * 0.5, -s * 1.42, s * 0.5 + sway); | |
| c.quadraticCurveTo(-s * 1.3, s * 0.28 + sway, -s * 0.5, 0); | |
| } else if (this.tailType === 'streamer') { | |
| c.moveTo(-s * 0.5, 0); | |
| c.quadraticCurveTo(-s * 1.35, -s * 0.24 + sway, -s * 1.68, -s * 0.56 + sway); | |
| c.quadraticCurveTo(-s * 0.98, sway * 0.35, -s * 1.68, s * 0.56 + sway); | |
| c.quadraticCurveTo(-s * 1.35, s * 0.24 + sway, -s * 0.5, 0); | |
| } else { | |
| c.moveTo(-s * 0.5, 0); | |
| c.quadraticCurveTo(-s * 1.04, -s * 0.36 + sway, -s * 1.22, -s * 0.44 + sway); | |
| c.quadraticCurveTo(-s * 1.34, -s * 0.52 + sway, -s * 0.86, sway * 0.5); | |
| c.quadraticCurveTo(-s * 1.34, s * 0.52 + sway, -s * 1.22, s * 0.44 + sway); | |
| c.quadraticCurveTo(-s * 1.04, s * 0.36 + sway, -s * 0.5, 0); | |
| } | |
| c.fill(); | |
| } | |
| // v22: Draw fish from the side (original view) | |
| drawSideView(c) { | |
| const s = this.size; | |
| // traveling body wave (v3): ripples headâtail so the fish undulates like a real one | |
| const waveK = Math.PI / s; // ~one wavelength across the body length | |
| const waveAmp = s * 0.12; // subtle flex amplitude | |
| // tail fin follows the body wave at its base, amplified (same phase â no fighting) | |
| const sway = Math.sin(-s * waveK + this.phase) * s * 0.34; | |
| // v24: Ecco-style blend factor (0 = side profile, 1 = seen from above/below). | |
| // Flattens the tail lobes and spreads eyes/barbels as we see more of the back. | |
| const ev = Math.max(0, Math.min(1, this.viewAngle)); | |
| const tailFlat = 1 - 0.55 * ev; // v25: squish the tail fin a bit more when vertical | |
| // tail fin (v7 rounded / v11 three styles): each tip is swept around by a curve | |
| // whose control point sits just past where the old spike was. The style is fixed | |
| // per fish at spawn so the school has variety in its tails. | |
| // v14: use per-fish saturation and lightness for expanded color palette | |
| c.fillStyle = this.tailFillStyle; | |
| c.beginPath(); | |
| if (this.tailType === 'fan') { | |
| // Broad, rounded fan (betta/goldfish-style): wide spread, tips high and far out, | |
| // with only a shallow dip between the two lobes. | |
| c.moveTo(-s * 0.5, 0); | |
| c.quadraticCurveTo(-s * 1.3, -s * 0.28 * tailFlat + sway, -s * 1.42, -s * 0.5 * tailFlat + sway); // up to upper tip | |
| c.quadraticCurveTo(-s * 1.12, sway * 0.5, -s * 1.42, s * 0.5 * tailFlat + sway); // shallow notch â lower tip | |
| c.quadraticCurveTo(-s * 1.3, s * 0.28 * tailFlat + sway, -s * 0.5, 0); // back to the base | |
| } else if (this.tailType === 'streamer') { | |
| // Long forked streamers (angelfish/fancy-guppy-style): two flowing lobes that | |
| // reach further back with a deeper notch between them. | |
| c.moveTo(-s * 0.5, 0); | |
| c.quadraticCurveTo(-s * 1.35, -s * 0.24 * tailFlat + sway, -s * 1.68, -s * 0.56 * tailFlat + sway); // long upper lobe | |
| c.quadraticCurveTo(-s * 0.98, sway * 0.35, -s * 1.68, s * 0.56 * tailFlat + sway); // deep fork â lower tip | |
| c.quadraticCurveTo(-s * 1.35, s * 0.24 * tailFlat + sway, -s * 0.5, 0); // long lower lobe back to base | |
| } else { | |
| // classic (v7): rounded lobes with a soft notch â the original look | |
| c.moveTo(-s * 0.5, 0); | |
| c.quadraticCurveTo(-s * 1.04, -s * 0.36 * tailFlat + sway, -s * 1.22, -s * 0.44 * tailFlat + sway); // upper edge out toward the tip | |
| c.quadraticCurveTo(-s * 1.34, -s * 0.52 * tailFlat + sway, -s * 0.86, sway * 0.5); // rounded upper lobe â notch | |
| c.quadraticCurveTo(-s * 1.34, s * 0.52 * tailFlat + sway, -s * 1.22, s * 0.44 * tailFlat + sway); // notch â rounded lower lobe | |
| c.quadraticCurveTo(-s * 1.04, s * 0.36 * tailFlat + sway, -s * 0.5, 0); // lower edge back to the base | |
| } | |
| c.fill(); | |
| // v21: far-side whisker â drawn BEFORE the body so it sits behind the fish and is | |
| // partially obscured by the head (only the part that pokes out from behind the snout | |
| // shows). The near-side whisker is drawn after the eye, below. | |
| if (this.whiskerLen) { | |
| const L = this.whiskerLen * s; | |
| const spd = Math.min(1, this.speedFactor); | |
| const NPTS = 7; | |
| c.save(); | |
| c.lineCap = "round"; | |
| c.strokeStyle = this.whiskerStyle; | |
| c.lineWidth = Math.max(1, s * 0.045); | |
| { | |
| const side = -1; // far side (behind the body) | |
| const rootX = s * 0.6; | |
| // v24: from above, barbels sit on either side of the mouth â spread the roots | |
| // apart and flare each whisker further out as the fish "rotates" to swim vertically | |
| const rootY = side * s * (0.09 + 0.07 * ev); | |
| c.beginPath(); | |
| c.moveTo(rootX, rootY); | |
| for (let i = 1; i <= NPTS; i++) { | |
| const t = i / NPTS; | |
| const bx = rootX - L * (0.25 + 0.65 * spd) * t; | |
| const by = rootY + side * L * (0.42 + 0.3 * ev) * t * t; | |
| const amp = s * 0.17 * t * (0.35 + 0.65 * spd); | |
| const off = Math.sin(t * 5.5 - this.phase * 1.4 + this.whiskPh) * amp; | |
| c.lineTo(bx, by + off); | |
| } | |
| c.stroke(); | |
| } | |
| c.restore(); | |
| } | |
| // v26: | |
| // body â undulates while swimming: the ellipse flexes along a traveling wave (v3) | |
| const g = this.bodyGrad; // precomputed in constructor (static colors, local coords) | |
| const NSEG = 26; // outline samples for a smooth curve | |
| // wave offset at any x along the body: rigid at head, full flex at tail. | |
| // Shared with the dorsal fin so it stays glued to the moving back (v4) | |
| const wy = x => Math.sin(x * waveK + this.phase) * waveAmp * ((s - x) / (2 * s)); | |
| // pelvic fin drawn BEFORE the body so it sits behind the fish | |
| if (this.hasPelvicFin) { | |
| try { | |
| c.fillStyle = this.pevFillStyle; | |
| c.beginPath(); | |
| const pelvicBaseY1 = s * 0.36 * (1 - 0.6 * ev); | |
| const pelvicPeakY = s * 0.78 * (1 - ev); | |
| const pelvicBaseY2 = s * 0.4 * (1 - 0.6 * ev); | |
| c.moveTo(-s * 0.15, pelvicBaseY1 + wy(-s * 0.15)); | |
| c.quadraticCurveTo(s * 0.05, pelvicPeakY + wy(s * 0.05), s * 0.38, pelvicBaseY2 + wy(s * 0.38)); | |
| c.closePath(); | |
| c.fill(); | |
| } catch (e) { | |
| console.error('Pelvic fin error:', e); | |
| } | |
| } | |
| // // Build the body outline once as a Path2D â reused for base fill + iridescence (v7) | |
| const bodyPath = new Path2D(); | |
| for (let i = 0; i <= NSEG; i++) { // top edge: head (+s) -> tail (-s) | |
| const x = s - (i / NSEG) * 2 * s; | |
| const hh = s * 0.42 * Math.sqrt(Math.max(0, 1 - (x / s) ** 2)); | |
| if (i === 0) bodyPath.moveTo(x, -hh + wy(x)); else bodyPath.lineTo(x, -hh + wy(x)); | |
| } | |
| for (let i = NSEG; i >= 0; i--) { // bottom edge: tail (-s) -> head (+s) | |
| const x = s - (i / NSEG) * 2 * s; | |
| const hh = s * 0.42 * Math.sqrt(Math.max(0, 1 - (x / s) ** 2)); | |
| bodyPath.lineTo(x, hh + wy(x)); | |
| } | |
| bodyPath.closePath(); | |
| c.fillStyle = g; | |
| c.fill(bodyPath); | |
| // v15: stripes first when both are present â bars form the base pattern, | |
| // spots scatter on top. Each is clipped to the body so nothing spills, and both | |
| // ride the traveling wave (wy) so they flex with the undulating body, drawn | |
| // BEFORE the additive sheen so the iridescent shimmer still plays across them. | |
| if (this.stripes) { | |
| c.save(); | |
| c.clip(bodyPath); | |
| const N = 8; // samples down each edge for smooth organic curves | |
| for (const st of this.stripes) { | |
| const sx = st.x * s; // bar center, fish-local x | |
| const hw0 = st.w * s / 2; // base half-width | |
| const H = s * 1.35; // vertical span (clipped by the body outline) | |
| c.fillStyle = st.style; | |
| c.beginPath(); | |
| // left edge, top â bottom: bend the centerline + taper the width with t | |
| for (let j = 0; j <= N; j++) { | |
| const t = j / N; | |
| const cx = sx + Math.sin(t * st.cFreq * Math.PI + st.cPh) * st.cAmp * s; | |
| const hwL = hw0 * (1 + st.tAmp * Math.sin(t * st.tFreq * Math.PI + st.tPh)); | |
| const xL = cx - hwL, yL = wy(xL) + (t - 0.5) * H; | |
| if (j === 0) c.moveTo(xL, yL); else c.lineTo(xL, yL); | |
| } | |
| // right edge, bottom â top | |
| for (let j = N; j >= 0; j--) { | |
| const t = j / N; | |
| const cx = sx + Math.sin(t * st.cFreq * Math.PI + st.cPh) * st.cAmp * s; | |
| const hwR = hw0 * (1 + st.tAmp * Math.sin(t * st.tFreq * Math.PI + st.tPh)); | |
| const xR = cx + hwR, yR = wy(xR) + (t - 0.5) * H; | |
| c.lineTo(xR, yR); | |
| } | |
| c.closePath(); | |
| c.fill(); | |
| } | |
| c.restore(); | |
| } | |
| // v18: koi patches â large irregular blobs clipped to the body outline, riding the | |
| // traveling wave (wy) so they flex with the undulating body, drawn BEFORE the | |
| // additive sheen so the iridescent shimmer still plays across them. | |
| if (this.koiPatches) { | |
| c.save(); | |
| c.clip(bodyPath); | |
| for (const kp of this.koiPatches) { | |
| const sx = kp.x * s; | |
| c.fillStyle = kp.style; | |
| c.beginPath(); | |
| c.arc(sx, kp.y * s + wy(sx), kp.r * s, 0, Math.PI * 2); | |
| c.fill(); | |
| } | |
| c.restore(); | |
| } | |
| // v13: random spots on ~20% of fish (or over the stripes for the 10% mixed) â | |
| // clipped to the body so they never spill, riding the traveling wave (wy) so they | |
| // flex with the undulating body, and drawn BEFORE the additive sheen so the | |
| // iridescent shimmer still plays across them. | |
| if (this.spots) { | |
| c.save(); | |
| c.clip(bodyPath); | |
| for (const sp of this.spots) { | |
| const sx = sp.x * s; | |
| c.fillStyle = sp.style; | |
| c.beginPath(); | |
| c.arc(sx, sp.y * s + wy(sx), sp.r * s, 0, Math.PI * 2); | |
| c.fill(); | |
| } | |
| c.restore(); | |
| } | |
| // v7: efficient iridescence â a sheen whose hue shimmers with the swim phase, | |
| // so scales appear to catch the light while moving. One gradient + one fill. | |
| const shimmer = Math.sin(this.phase * 1.6) * 0.5 + 0.5; // 0..1 as it swims | |
| const iridHue = this.hue + 40 * Math.sin(this.phase * 2.3); // hue drifts with phase | |
| // PERF: cache the sheen gradient. Its inputs move smoothly, so quantize them to a | |
| // tiny grid (1/31 shimmer steps, 1° hue) and reuse until the cell changes â this | |
| // skips one createLinearGradient + 3 addColorStop calls on most frames. | |
| const igKey = Math.round(shimmer * 31) * 512 + (Math.round(iridHue) + 64); | |
| if (igKey !== this._igKey || !this._igGrad) { | |
| this._igGrad = c.createLinearGradient(-s, -s*0.3, s, s*0.35); | |
| this._igGrad.addColorStop(0.0, `hsla(${this.hue},95%,72%,${0.10 + 0.18*shimmer})`); | |
| this._igGrad.addColorStop(0.5, `hsla(${iridHue},90%,70%,${0.04 + 0.10*shimmer})`); | |
| this._igGrad.addColorStop(1.0, `hsla(${this.hue + 200},85%,68%,${0.03 + 0.08*shimmer})`); | |
| this._igKey = igKey; | |
| } | |
| const ig = this._igGrad; | |
| c.save(); | |
| c.globalCompositeOperation = "lighter"; // additive sheen: reads as light on scales | |
| c.fillStyle = ig; | |
| c.fill(bodyPath); | |
| c.restore(); | |
| // belly highlight | |
| c.fillStyle = "rgba(255,255,255,0.16)"; | |
| c.beginPath(); | |
| c.ellipse(0, s * 0.14, s * 0.82, s * 0.24, 0, 0, Math.PI * 2); | |
| c.fill(); | |
| // dorsal fin â rides the body wave at its attachment points so it | |
| // never disconnects as the back undulates (v4) | |
| // v17: matches the fish's own color instead of a fixed blue â silver fish no longer | |
| // sprout mismatched blue fins | |
| // v25: as the fish turns to swim vertically (evâ1), the dorsal fin slides from | |
| // "on top of the fish" to "in the middle" (centerline), matching the top-down view | |
| c.fillStyle = this.dorsalFillStyle; | |
| c.beginPath(); | |
| const dorsalBaseY1 = -s * 0.36 * (1 - 0.6 * ev); // base point 1 moves toward centerline | |
| const dorsalPeakY = -s * 0.78 * (1 - ev); // peak moves to centerline (y=0) | |
| const dorsalBaseY2 = -s * 0.4 * (1 - 0.6 * ev); // base point 2 moves toward centerline | |
| c.moveTo(-s * 0.15, dorsalBaseY1 + wy(-s * 0.15)); | |
| c.quadraticCurveTo(s * 0.05, dorsalPeakY + wy(s * 0.05), s * 0.38, dorsalBaseY2 + wy(s * 0.38)); | |
| c.closePath(); | |
| c.fill(); | |
| // v24: both eyes â as the fish turns to swim vertically (evâ1) we see them from | |
| // above: the pair spreads wider on either side of the head and each eye squashes | |
| // into a flatter ellipse (perspective), instead of two overlapping circles. | |
| { | |
| const eyeRY = 0.11 * (1 - 0.35 * ev); // vertical squash of the white | |
| const pupRY = 0.055 * (1 - 0.35 * ev); // pupil squashes with it | |
| const yNear = -s * (0.12 + 0.1 * ev); // near eye slides outward as we see more of the back | |
| c.fillStyle = "#fff"; | |
| c.beginPath(); | |
| c.ellipse(s * 0.58, yNear, s * 0.11, s * eyeRY, 0, 0, Math.PI * 2); | |
| c.fill(); | |
| c.fillStyle = "#0d1420"; | |
| c.beginPath(); | |
| c.ellipse(s * 0.61, yNear, s * 0.055, s * pupRY, 0, 0, Math.PI * 2); | |
| c.fill(); | |
| // far-side eye fades in as we see more of the back (Ecco-style top view) â | |
| // two eyes flanking the head when the fish turns to swim vertically. | |
| if (ev > 0.03) { | |
| const yFar = s * (0.16 + 0.08 * ev); // far eye slides outward too â pair widens | |
| c.save(); | |
| // Ease in the far-side eye so it takes longer to appear (25% slower fade-in) | |
| c.globalAlpha = Math.pow(ev, 1.25) * 0.9; | |
| c.fillStyle = "#fff"; | |
| c.beginPath(); | |
| c.ellipse(s * 0.58, yFar, s * 0.11, s * eyeRY, 0, 0, Math.PI * 2); | |
| c.fill(); | |
| c.fillStyle = "#0d1420"; | |
| c.beginPath(); | |
| c.ellipse(s * 0.61, yFar, s * 0.055, s * pupRY, 0, 0, Math.PI * 2); | |
| c.fill(); | |
| c.restore(); | |
| } | |
| } | |
| // v21: near-side whisker â drawn AFTER the body so it sits in front of the fish. | |
| // (The far-side whisker is drawn before the body, above.) Together they form a | |
| // pair from the snout on ~10% of fish, in one of three lengths, with loose ribbons | |
| // flowing in the current and a traveling wave rippling down their length. Drawn at | |
| // a fixed 50% opacity. | |
| if (this.whiskerLen) { | |
| const L = this.whiskerLen * s; | |
| const spd = Math.min(1, this.speedFactor); | |
| const NPTS = 7; | |
| c.save(); | |
| c.lineCap = "round"; | |
| c.strokeStyle = this.whiskerStyle; | |
| c.lineWidth = Math.max(1, s * 0.045); | |
| { | |
| const side = 1; // near side (in front of the body) | |
| const rootX = s * 0.6; | |
| // v24: same spread/flare as the far whisker â a pair flanking the snout from above | |
| const rootY = side * s * (0.09 + 0.07 * ev); | |
| c.beginPath(); | |
| c.moveTo(rootX, rootY); | |
| for (let i = 1; i <= NPTS; i++) { | |
| const t = i / NPTS; | |
| const bx = rootX - L * (0.25 + 0.65 * spd) * t; | |
| const by = rootY + side * L * (0.42 + 0.3 * ev) * t * t; | |
| const amp = s * 0.17 * t * (0.35 + 0.65 * spd); | |
| const off = Math.sin(t * 5.5 - this.phase * 1.4 + this.whiskPh) * amp; | |
| c.lineTo(bx, by + off); | |
| } | |
| c.stroke(); | |
| } | |
| c.restore(); | |
| } | |
| c.restore(); | |
| // v17/v23: overhead light â soft elliptical highlight on the fish's upper edge. | |
| // Drawn in local coords (draw() already positioned + rotated us) so it rides with | |
| // the body and foreshortens correctly instead of being double-offset. | |
| { | |
| const a = 0.35 * (1 - curNight * 0.65); // dimmer at night, still present | |
| if (a > 0.02) { | |
| c.fillStyle = `rgba(255,255,255,${(a * 0.3).toFixed(3)})`; | |
| c.beginPath(); | |
| c.ellipse(s * 0.05, -s * 0.26, s * 0.6, s * 0.2, 0, 0, Math.PI * 2); | |
| c.fill(); | |
| c.fillStyle = `rgba(255,255,255,${a.toFixed(3)})`; | |
| c.beginPath(); | |
| c.ellipse(s * 0.05, -s * 0.26, s * 0.34, s * 0.1, 0, 0, Math.PI * 2); | |
| c.fill(); | |
| } | |
| } | |
| } | |
| } | |
| // v7: helper â stroke a smooth line through the given points | |
| function strokeArc(c, pts) { | |
| c.beginPath(); | |
| c.moveTo(pts[0][0], pts[0][1]); | |
| for (let i = 1; i < pts.length; i++) c.lineTo(pts[i][0], pts[i][1]); | |
| c.stroke(); | |
| } | |
| // v19: each bubble gets a subtle random tint â pure white up to a soft grey-blue | |
| function bubbleColor() { | |
| const t = Math.random() * 0.85; // keep it subtle, most stay near-white | |
| const r = Math.round(255 - (255 - 196) * t); | |
| const g = Math.round(255 - (255 - 214) * t); | |
| const b = Math.round(255 - (255 - 238) * t); | |
| return [r, g, b]; | |
| } | |
| class Bubble { | |
| constructor(init, x, y, size) { | |
| if (x !== undefined) { // spawned from a fish's mouth: tiny, one-shot | |
| this.x = x; this.y = y; | |
| const s = (size || 28) / 28; // v6: scales with the fish that blew it | |
| this.r = rand(1.4, 3.2) * s; | |
| this.speed = rand(45, 85) * (0.7 + 0.3 * s); | |
| this.p = rand(0, Math.PI * 2); | |
| this.rgb = bubbleColor(); | |
| this.isMouth = true; | |
| } else { | |
| this.reset(init); | |
| } | |
| } | |
| reset(init) { | |
| this.x = rand(0, W); | |
| this.y = init ? rand(H * 0.3, H) : H + rand(10, 80); | |
| this.r = rand(2, 7); | |
| this.speed = rand(35, 90); | |
| this.p = rand(0, Math.PI * 2); | |
| this.rgb = bubbleColor(); | |
| this.isMouth = false; | |
| } | |
| update(dt, t) { | |
| this.y -= this.speed * dt; | |
| this.x += Math.sin(t * 1.6 + this.p) * 22 * dt; | |
| if (this.y < -12) { if (this.isMouth) this.dead = true; else this.reset(false); } | |
| } | |
| draw(c) { | |
| const [r, g, b] = this.rgb; | |
| c.lineWidth = 1; | |
| c.strokeStyle = `rgba(${r},${g},${b},0.35)`; | |
| c.fillStyle = `rgba(${r},${g},${b},0.08)`; | |
| c.beginPath(); | |
| c.arc(this.x, this.y, this.r, 0, Math.PI * 2); | |
| c.fill(); | |
| c.stroke(); | |
| c.fillStyle = `rgba(${r},${g},${b},0.45)`; | |
| c.beginPath(); | |
| c.arc(this.x - this.r * 0.3, this.y - this.r * 0.3, this.r * 0.25, 0, Math.PI * 2); | |
| c.fill(); | |
| } | |
| } | |
| function sandAt(x) { | |
| const i = Math.max(0, Math.min(sandHeights.length - 2, x / 40)); | |
| const f = (x / 40) - Math.floor(i); | |
| return sandHeights[Math.floor(i)] * (1 - f) + sandHeights[Math.floor(i) + 1] * f; | |
| } | |
| function buildScene() { | |
| fishes = []; | |
| bubbles = []; | |
| seaweeds = []; | |
| backSeaweeds = []; // v4: blurry background seaweed (not fish-touchable) | |
| midSeaweeds = []; // v6: middle-distance seaweed with halo pass (between back and far) | |
| farSeaweeds = []; // v5: ultra-far pre-rendered blurry sprites (deepest layer) | |
| motes = []; | |
| rays = []; | |
| rocks = []; | |
| sandHeights = []; | |
| sandParticles = []; // v22: fresh bed, no leftover grains | |
| raySpritesBuiltFor = null; // v23: beam sprites are sized to the old canvas â rebuild them | |
| grass = []; // v10.1: clear on rebuild so resize/F11 doesn't stack a second row | |
| fgGrass = []; // v12: same reset for the foreground layer (resize/F11 safety) | |
| // ALT: rolling hills instead of a flat bed â three octaves of random sine waves | |
| // (broad swells + mid undulations + fine ripples), re-rolled on every refresh. | |
| const sandTop = H - Math.max(60, H * 0.12); | |
| const hillUp = rand(34, 58); // tallest crest rises this far above the baseline | |
| const hillDown = rand(14, 26); // deepest dip sinks this far below it | |
| const waves = [ | |
| { wl: rand(380, 720), amp: 1.00, ph: rand(0, Math.PI * 2) }, // broad swells | |
| { wl: rand(170, 320), amp: 0.45, ph: rand(0, Math.PI * 2) }, // mid undulations | |
| { wl: rand(80, 150), amp: 0.18, ph: rand(0, Math.PI * 2) } // fine surface ripples | |
| ]; | |
| const raw = []; | |
| for (let x = 0; x <= W + 40; x += 40) { | |
| let v = 0; | |
| for (const wv of waves) v += Math.sin((x / wv.wl) * Math.PI * 2 + wv.ph) * wv.amp; | |
| raw.push(v); | |
| } | |
| // Rescale so the extremes land exactly at the chosen limits â full relief, no clamping | |
| let lo = Infinity, hi = -Infinity; | |
| for (const v of raw) { if (v < lo) lo = v; if (v > hi) hi = v; } | |
| const span = Math.max(0.001, hi - lo); | |
| sandHeights.length = 0; | |
| for (const v of raw) { | |
| const off = -hillUp + ((hi - v) / span) * (hillUp + hillDown); // crest â -hillUp, dip â +hillDown | |
| sandHeights.push(sandTop + off); | |
| } | |
| // plants grow in bundles of stalks sharing a base patch; each stalk sprouts side branches | |
| const nClusters = Math.max(6, Math.floor(W / 150)); | |
| for (let i = 0; i < nClusters; i++) { | |
| const cx = rand(45, W - 45); | |
| const clusterHue = rand(95, 145); | |
| const nStalks = 3 + ((Math.random() * 4) | 0); // 3..6 stalks per bundle | |
| for (let j = 0; j < nStalks; j++) { | |
| const hue = clusterHue + rand(-10, 10); | |
| const st = { | |
| x: cx + rand(-16, 16), | |
| h: rand(H * 0.12, H * 0.34), | |
| segs: 8, | |
| phase: rand(0, Math.PI * 2), | |
| speed: rand(0.9, 1.7), | |
| hue, | |
| width: rand(4, 8), | |
| touch: 0, | |
| // v15: organic rest curve â each stalk leans and waves like real seaweed | |
| // instead of standing perfectly straight. Also fed into the physics (updatePlants) | |
| // so fish push the stalk where it actually looks. Weighted by f â root stays planted. | |
| cA1: rand(5, 12), cF1: rand(0.9, 1.8), cP1: rand(0, Math.PI * 2), | |
| cA2: rand(2, 6), cF2: rand(2.2, 3.6), cP2: rand(0, Math.PI * 2), | |
| // v17: secondary wobble - a finer inner shimmer for unique motion per stalk | |
| sAmp: rand(2, 5), // amplitude of the secondary wave | |
| sFreq: rand(3.5, 6.5), // higher frequency than primary sway | |
| sPh: rand(0, Math.PI * 2), // phase offset | |
| tipW: rand(1.4, 2.6), // v15: blade tapers from width â tipW up the stipe | |
| // PERF: stroke styles precomputed once (hue is fixed per stalk) â drawSeaweed | |
| // used to rebuild both template strings every frame for every stalk. | |
| stalkStyle: `hsla(${hue},55%,36%,0.92)`, | |
| branchStyle: `hsla(${hue + 8},60%,46%,0.9)`, | |
| midStyle: `hsla(${hue - 8},48%,27%,0.45)`, // v15: faint leaf vein down the blade | |
| off: new Float32Array(9), // lateral bend per segment (fish push leaves aside) | |
| offV: new Float32Array(9), // velocity of that bend | |
| branches: [], | |
| // PERF: stalk points preallocated once â updatePlants reuses these objects | |
| // in place instead of pushing fresh {x,y} pairs every frame (GC churn) | |
| points: Array.from({ length: 9 }, () => ({ x: 0, y: 0 })) // segs+1; can't read st inside its own initializer | |
| }; | |
| const nBr = 1 + ((Math.random() * 3) | 0); // 1..3 branches per stalk | |
| for (let k = 0; k < nBr; k++) { | |
| st.branches.push({ | |
| f: rand(0.25, 0.85), // where along the stalk it sprouts | |
| side: Math.random() < 0.5 ? -1 : 1, | |
| len: rand(12, 30), // horizontal reach | |
| up: rand(10, 26), // how far it curls upward | |
| w: rand(2, 4), | |
| ph: rand(0, Math.PI * 2), | |
| flowers: Math.random() < 0.3 ? (() => { // ~30% of branches bloom with 1-3 heads (v3) | |
| const n = 1 + ((Math.random() * 3) | 0); // 1..3 flowers per branch | |
| return Array.from({ length: n }, () => { | |
| const hue = (Math.random() * 360) | 0; // colourful â full spectrum | |
| return { | |
| t: rand(0.45, 1), // where along the branch it sits (tip zone) | |
| hue, | |
| r: rand(4, 7), // head size | |
| petals: 5 + ((Math.random() * 3) | 0), // 5..7 petals | |
| // PERF: paint styles precomputed once â drawFlowerHead used to rebuild | |
| // these strings per petal, per frame. | |
| petalFill: `hsla(${hue},85%,64%,0.95)`, | |
| centerFill: `hsla(${(hue + 45) % 360},92%,78%,0.95)` | |
| }; | |
| }); | |
| })() : [] | |
| }); | |
| } | |
| seaweeds.push(st); | |
| } | |
| } | |
| // v4: blurry background seaweed â taller, bluer, behind everything else. | |
| // Pre-rendered to offscreen canvases with blur at build time (no runtime cost). | |
| const nBackClusters = Math.max(4, Math.floor(W / 200)); | |
| for (let i = 0; i < nBackClusters; i++) { | |
| const cx = rand(30, W - 30); | |
| const clusterHue = rand(165, 200); // bluer than foreground seaweed (95-145) | |
| const nStalks = 2 + ((Math.random() * 3) | 0); // 2..4 stalks per bundle | |
| for (let j = 0; j < nStalks; j++) { | |
| const hue = clusterHue + rand(-15, 15); | |
| // v4.3: depth split â ~51% "far" (heavy softness), ~49% "near" (sharper) for a layered look | |
| const near = Math.random() < 0.49; | |
| const w = near ? rand(2.5, 5) : rand(3, 6); | |
| const tw = near ? rand(1.2, 2.4) : rand(1.5, 3); | |
| const st = { | |
| x: cx + rand(-20, 20), | |
| h: rand(H * 0.35, H * 0.65), // much taller than foreground seaweed | |
| segs: 10, | |
| phase: rand(0, Math.PI * 2), | |
| speed: rand(0.7, 1.3), // slower, more gentle sway | |
| hue, | |
| near, | |
| width: w, | |
| tipW: tw, | |
| branches: [], | |
| // organic curve parameters (same as foreground seaweed) | |
| cA1: rand(8, 15), cF1: rand(0.8, 1.6), cP1: rand(0, Math.PI * 2), | |
| cA2: rand(3, 8), cF2: rand(2.0, 3.2), cP2: rand(0, Math.PI * 2), | |
| // v4.3: multi-wave organic motion parameters | |
| waveA1: rand(6, 14), waveF1: rand(0.5, 1.2), wavePh1: rand(0, Math.PI * 2), | |
| waveA2: rand(3, 8), waveF2: rand(1.3, 2.1), wavePh2: rand(0, Math.PI * 2), | |
| waveA3: rand(2, 5), waveF3: rand(2.4, 3.6), wavePh3: rand(0, Math.PI * 2), | |
| // PERF: precomputed styles/widths (avoids per-frame string alloc + arithmetic) | |
| coreStyle: `hsla(${hue},50%,32%,${near ? 0.8 : 0.7})`, | |
| haloStyle: `hsla(${hue},45%,36%,0.18)`, | |
| branchCoreStyle: `hsla(${hue + 8},55%,42%,0.75)`, | |
| backLineW: (w + tw) / 2 | |
| }; | |
| // v4.1: add branches to background seaweed (like foreground) | |
| const nBr = 1 + ((Math.random() * 3) | 0); // 1..3 branches per stalk | |
| for (let k = 0; k < nBr; k++) { | |
| const bw = rand(1.5, 3); // PERF: computed once, reused for halo width | |
| st.branches.push({ | |
| f: rand(0.25, 0.85), // where along the stalk it sprouts | |
| side: Math.random() < 0.5 ? -1 : 1, | |
| len: rand(10, 24), // horizontal reach (slightly smaller than fg) | |
| up: rand(8, 20), // how far it curls upward | |
| w: bw, | |
| haloW: bw * 1.9, // PERF: precomputed halo width | |
| ph: rand(0, Math.PI * 2) | |
| }); | |
| } | |
| backSeaweeds.push(st); | |
| } | |
| } | |
| // v6: middle-distance seaweed using halo stroke pass (median properties between back and far) | |
| const nMidClusters = Math.max(5, Math.floor(W / 190)); | |
| for (let i = 0; i < nMidClusters; i++) { | |
| const cx = rand(25, W - 25); | |
| // Median hue between backSeaweeds (165-200) and farSeaweeds (170-210) | |
| const clusterHue = rand(183, 197); // midpoint of both ranges | |
| const nStalks = 2 + ((Math.random() * 3) | 0); | |
| for (let j = 0; j < nStalks; j++) { | |
| const hue = clusterHue + rand(-12, 12); | |
| const mw = rand(3, 6.5); // PERF: captured once for precomputed widths | |
| const mtw = rand(1.8, 3.2); | |
| // Median properties: height between backSeaweeds (0.35-0.65H) and farSeaweeds (0.4-0.7H) | |
| const st = { | |
| x: cx + rand(-22, 22), | |
| h: rand(H * 0.42, H * 0.68), // median height | |
| segs: 9, | |
| phase: rand(0, Math.PI * 2), | |
| speed: rand(0.6, 1.15), // median sway speed between back (0.7-1.3) and far (0.5-1.0) | |
| hue, | |
| width: mw, // median width | |
| tipW: mtw, | |
| branches: [], | |
| // Organic curve parameters (like backSeaweeds for halo pass technique) | |
| cA1: rand(9, 16), cF1: rand(0.7, 1.5), cP1: rand(0, Math.PI * 2), | |
| cA2: rand(3.5, 9), cF2: rand(1.8, 3.0), cP2: rand(0, Math.PI * 2), | |
| // Multi-wave organic motion parameters | |
| waveA1: rand(7, 15), waveF1: rand(0.45, 1.1), wavePh1: rand(0, Math.PI * 2), | |
| waveA2: rand(3.5, 9), waveF2: rand(1.2, 2.0), wavePh2: rand(0, Math.PI * 2), | |
| waveA3: rand(2.5, 6), waveF3: rand(2.2, 3.4), wavePh3: rand(0, Math.PI * 2), | |
| // PERF: precomputed styles/widths (avoids per-frame string alloc + arithmetic) | |
| coreStyle: `hsla(${hue},48%,30%,0.65)`, | |
| haloStyle: `hsla(${hue},42%,34%,0.16)`, | |
| branchCoreStyle: `hsla(${hue + 6},50%,40%,0.7)`, | |
| midLineW: (mw + mtw) / 2 | |
| }; | |
| // Add branches (median count between back and far) | |
| const nBr = 1 + ((Math.random() * 3) | 0); | |
| for (let k = 0; k < nBr; k++) { | |
| const bw = rand(1.8, 3.5); // PERF: computed once, reused for halo width | |
| st.branches.push({ | |
| f: rand(0.28, 0.82), | |
| side: Math.random() < 0.5 ? -1 : 1, | |
| len: rand(11, 26), | |
| up: rand(9, 22), | |
| w: bw, | |
| haloW: bw * 2.2, // PERF: precomputed halo width | |
| ph: rand(0, Math.PI * 2) | |
| }); | |
| } | |
| midSeaweeds.push(st); | |
| } | |
| } | |
| // v5: ultra-far pre-rendered blurry seaweed â deepest layer, heavily blurred sprites | |
| const nFarClusters = Math.max(6, Math.floor(W / 180)); // 50% more than previous far count | |
| for (let i = 0; i < nFarClusters; i++) { | |
| const cx = rand(20, W - 20); | |
| const clusterHue = rand(170, 210); // very blue-tinted for depth | |
| const nStalks = 2 + ((Math.random() * 3) | 0); // 2..4 stalks per bundle | |
| for (let j = 0; j < nStalks; j++) { | |
| const hue = clusterHue + rand(-15, 15); | |
| const st = { | |
| x: cx + rand(-25, 25), | |
| h: rand(H * 0.4, H * 0.7), // very tall | |
| segs: 8, | |
| phase: rand(0, Math.PI * 2), | |
| speed: rand(0.5, 1.0), // very slow sway | |
| hue, | |
| width: rand(4, 8), | |
| tipW: rand(2, 4), | |
| branches: [], | |
| sprite: null, // will be built by buildFarSeaweedSprites() | |
| spriteXOffset: 0, | |
| spriteYOffset: 0 | |
| }; | |
| // Add some branches for visual complexity (will be part of the sprite) | |
| const nBr = 1 + ((Math.random() * 2) | 0); // 1..2 branches | |
| for (let k = 0; k < nBr; k++) { | |
| st.branches.push({ | |
| f: rand(0.3, 0.8), | |
| side: Math.random() < 0.5 ? -1 : 1, | |
| len: rand(12, 28), | |
| up: rand(10, 24), | |
| w: rand(2, 4), | |
| ph: rand(0, Math.PI * 2) | |
| }); | |
| } | |
| farSeaweeds.push(st); | |
| } | |
| } | |
| // Short, dense grass-like ground cover along the sand bed. | |
| // Each tuft is a cluster of thin blades that ripple when fish swim nearby. | |
| const nGrassTufts = Math.max(50, Math.floor(W / 9)); | |
| for (let i = 0; i < nGrassTufts; i++) { | |
| const gx = rand(8, W - 8); | |
| const gy = sandAt(gx) + rand(2, 9); // moved DOWN so every blade roots at/below the sand line â no floating bits, closes the gap fish wedged into | |
| const baseHue = rand(95, 155); | |
| const blades = 2 + ((Math.random() * 4) | 0); // 2..5 blades per tuft | |
| for (let b = 0; b < blades; b++) { | |
| const bladeSegs = 3 + ((Math.random() * 2) | 0); // 3-4 segments for tapered outline | |
| grass.push({ | |
| x: gx + rand(-7, 7), | |
| y: gy, | |
| h: rand(12, 85), // v4: more height variability â some short tufts, some noticeably tall | |
| lean: rand(-0.35, 0.35), // base tilt from vertical | |
| phase: rand(0, Math.PI * 2), | |
| speed: rand(1.3, 2.8), // quicker, finer sway than seaweed | |
| hue: baseHue + rand(-9, 9), | |
| width: rand(1.4, 2.8), | |
| ripple: 0, // agitation from nearby fish (0..1) | |
| // v16: organic rest-curve + segmented blade outline (like seaweed) | |
| cA1: rand(-5, 5), // primary bend amplitude | |
| cF1: rand(2.2, 4.5), // frequency along blade length | |
| cP1: rand(0, Math.PI * 2), // phase offset | |
| segs: bladeSegs, // 3-4 segments for tapered outline | |
| // v17: secondary wobble - finer inner shimmer per blade | |
| sAmp: rand(1.5, 3.5), | |
| sFreq: rand(4.5, 8), | |
| sPh: rand(0, Math.PI * 2), | |
| // PERF: per-blade scratch points â drawGrass reuses them instead of | |
| // allocating a fresh pts array + point objects every frame (GC churn) | |
| pts: Array.from({ length: bladeSegs + 1 }, () => ({ x: 0, y: 0 })) | |
| }); | |
| } | |
| } | |
| // v12: foreground grass â planted on the FRONT glass (closest to the viewer), so it is | |
| // drawn last and foreshortened. It grows in the low band below the sand line, i.e. the | |
| // zone fish never reach, and sways with the water like the other flora but has no | |
| // fish-push physics (purely decorative). ALT-FG: all sizes halved vs the original fg. | |
| // DOUBLED: more foreground grass clusters for denser coverage | |
| const nFgClusters = Math.max(16, Math.floor(W / 60)); | |
| for (let i = 0; i < nFgClusters; i++) { | |
| const cx = rand(-10, W + 10); | |
| const clusterBaseY = sandAt(cx) + rand(8, H * 0.06); // moved down (a touch lower than the mid-ground grass) so it sits planted in front | |
| const baseHue = rand(46, 76); // v12.1: yellowish-green â pops against the mid-ground green grass | |
| const nBlades = 5 + ((Math.random() * 4) | 0); // 5..8 blades per clump (doubled density) | |
| for (let b = 0; b < nBlades; b++) { | |
| const fgSegs = 4 + ((Math.random() * 2) | 0); // 4-5 segments for tapered outline | |
| fgGrass.push({ | |
| x: cx + rand(-7.5, 7.5), // 50%: tighter clumps (was -15..15) | |
| y: clusterBaseY + rand(-2, 4), // 50%: finer vertical jitter (was -4..8) | |
| h: rand(H * 0.08, H * 0.18), // 50%: half height (was H*0.16..H*0.36) | |
| lean: rand(-0.25, 0.25), // base tilt from vertical | |
| phase: rand(0, Math.PI * 2), | |
| speed: rand(0.8, 1.6), // slower, heavier sway than the tiny mid-ground grass | |
| hue: baseHue + rand(-10, 10), | |
| width: rand(2, 4), // kept slightly thicker than the mid-ground grass (1.4..2.8) | |
| // v16: organic rest-curve + segmented blade outline (like seaweed) | |
| cA1: rand(-8, 8), // primary bend amplitude (larger for foreground) | |
| cF1: rand(2, 4), // frequency along blade length | |
| cP1: rand(0, Math.PI * 2), // phase offset | |
| segs: fgSegs, // 4-5 segments for tapered outline | |
| // v17: secondary wobble - finer inner shimmer per blade (foreground) | |
| sAmp: rand(2, 4.5), | |
| sFreq: rand(3.8, 6.5), | |
| sPh: rand(0, Math.PI * 2), | |
| // PERF: per-blade scratch points â drawFgGrass reuses them in place | |
| pts: Array.from({ length: fgSegs + 1 }, () => ({ x: 0, y: 0 })), | |
| // v28: ~40% of blades get a gentle blur (pre-rendered sprite) for depth | |
| blurred: Math.random() < 0.4, | |
| sprite: null, | |
| spriteScale: 1 | |
| }); | |
| } | |
| } | |
| buildFgGrassSprites(); // v28: bake the defocused-blade sprites once (free at runtime) | |
| // PERF: pool the plantPoints collision objects too â updatePlants reuses them | |
| // in place instead of allocating one per stalk/branch point every frame. | |
| let pcap = 8; | |
| for (const w of seaweeds) pcap += (w.segs + 1) + w.branches.length * 2; | |
| while (plantPoints.length < pcap) plantPoints.push({ x: 0, y: 0, r: 0, s: null }); | |
| const nFish = Math.max(7, Math.floor(W / 130)); | |
| for (let i = 0; i < nFish; i++) fishes.push(new Fish()); | |
| const nAmbient = Math.round(26 * 0.75); // v10: ambient bubbles reduced 25% (fish production unchanged) | |
| for (let i = 0; i < nAmbient; i++) bubbles.push(new Bubble(true)); | |
| for (let i = 0; i < 45; i++) { | |
| motes.push({ x: rand(0, W), y: rand(0, H), r: rand(0.6, 1.8), s: rand(3, 9), p: rand(0, Math.PI * 2) }); | |
| } | |
| for (let i = 0; i < 5; i++) { | |
| // random blur state: ~3.75px (semi-blurry), ~8.75px (blurry), or ~17.5px (very blurry) for visual variety | |
| const blurRoll = Math.random(); | |
| const blur = blurRoll < 0.34 ? 3.75 : (blurRoll < 0.67 ? 8.75 : 17.5); | |
| rays.push({ | |
| x: rand(0, W), w: rand(80, 210), tilt: rand(-0.35, 0.35), | |
| speed: rand(0.06, 0.14) * (Math.random() < 0.5 ? 1 : -1), alpha: rand(0.04, 0.09), blur, | |
| // undulation â two traveling waves so each beam shimmers like light refracted through moving water | |
| uAmp: rand(18, 46), uLen: rand(2.2, 4.5), uSpd: rand(0.35, 0.9) * (Math.random() < 0.5 ? 1 : -1), uPh: rand(0, Math.PI * 2), | |
| vAmp: rand(6, 14), vLen: rand(5, 9), vSpd: rand(0.8, 1.6) * (Math.random() < 0.5 ? 1 : -1), vPh: rand(0, Math.PI * 2) | |
| }); | |
| } | |
| const nRocks = Math.max(3, Math.floor(W / 320)); | |
| for (let i = 0; i < nRocks; i++) { | |
| const r = rand(14, 34); | |
| // jagged outline â a ring of uneven points so rocks read as craggy stones, not domes | |
| const nPts = 8 + ((Math.random() * 5) | 0); // 8..12 vertices | |
| const pts = []; | |
| for (let k = 0; k < nPts; k++) { | |
| const a = (k / nPts) * Math.PI * 2 + rand(-0.2, 0.2); | |
| pts.push([Math.cos(a) * r * rand(0.75, 1.15), Math.sin(a) * r * 0.72 * rand(0.65, 1.0)]); | |
| } | |
| rocks.push({ x: rand(40, W - 40), r, tone: rand(0.75, 1), pts }); | |
| } | |
| } | |
| function resize() { | |
| const dpr = Math.min(2, window.devicePixelRatio || 1); | |
| W = window.innerWidth; | |
| H = window.innerHeight; | |
| canvas.width = W * dpr; | |
| canvas.height = H * dpr; | |
| ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| buildScene(); | |
| buildFarSeaweedSprites(); // v5: pre-render ultra-far blurry seaweed sprites | |
| } | |
| // --- slow light cycle: bright day -> dusk -> night -> dawn, over CYCLE seconds --- | |
| const CYCLE = 180; // seconds per full day/night cycle (v9: exactly 3 minutes) | |
| // Phase durations in seconds: daytime blue 90s, dusk 15s, night 60s, dawn 15s. Total = 180s. | |
| // The cycle starts at the beginning of the bright daytime-blue phase. | |
| const DUSK_START = 90 / CYCLE; // p where day ends and dusk begins (0.5) | |
| const NIGHT_START = (90 + 15) / CYCLE; // p where dusk ends and night begins (~0.5833) | |
| const DAWN_START = (90 + 15 + 60) / CYCLE; // p where night ends and dawn begins (~0.9167) | |
| const MID_DUSK = (DUSK_START + NIGHT_START) / 2; // sunset-glow point, mid-dusk | |
| // [t, waterTop, waterMid, waterBot, rayTint, ambientWashRGB, ambientWashAlpha] | |
| const LIGHT_KEYS = [ | |
| [0.00, [13,88,118], [8,58,82], [3,25,43], [190,235,255], [190,235,255], 0.00], | |
| [DUSK_START, [13,88,118], [8,58,82], [3,25,43], [190,235,255], [190,235,255], 0.00], | |
| [MID_DUSK, [224,122,52], [128,60,36], [34,22,26], [255,176,96], [255,150,70], 0.14], | |
| [NIGHT_START,[7,15,32], [4,10,22], [2,6,14], [96,146,206], [3,8,20], 0.40], | |
| [DAWN_START, [7,15,32], [4,10,22], [2,6,14], [96,146,206], [3,8,20], 0.40], | |
| [1.00, [13,88,118], [8,58,82], [3,25,43], [190,235,255], [190,235,255], 0.00] | |
| ]; | |
| function sampleLight(t) { | |
| const p = ((t / CYCLE) % 1 + 1) % 1; | |
| let i = 0; | |
| while (i < LIGHT_KEYS.length - 2 && LIGHT_KEYS[i + 1][0] <= p) i++; | |
| const k0 = LIGHT_KEYS[i], k1 = LIGHT_KEYS[i + 1]; | |
| const f = Math.max(0, Math.min(1, (p - k0[0]) / (k1[0] - k0[0]))); | |
| // each key row: [t, waterTop[r,g,b], waterMid, waterBot, rayTint, washRGB, washA] | |
| const mixC = (a, b) => [0, 1, 2].map(c => Math.round(a[c] + (b[c] - a[c]) * f)); | |
| return { top: mixC(k0[1], k1[1]), mid: mixC(k0[2], k1[2]), bot: mixC(k0[3], k1[3]), ray: mixC(k0[4], k1[4]), washRGB: mixC(k0[5], k1[5]), washA: k0[6] + (k1[6] - k0[6]) * f }; | |
| } | |
| function rgbStr(c) { return `rgb(${c[0]},${c[1]},${c[2]})`; } | |
| // How dark it is right now (0 = bright day, 1 = full night), from the light cycle. | |
| // Drives the sleepier night-time behaviour. Cheap: pure math on one number. | |
| function nightFactor(t) { | |
| const p = ((t / CYCLE) % 1 + 1) % 1; | |
| if (p < DUSK_START) return 0; // bright day | |
| if (p < NIGHT_START) return (p - DUSK_START) / (NIGHT_START - DUSK_START); // dusk: fade to night | |
| if (p < DAWN_START) return 1; // full night | |
| return 1 - (p - DAWN_START) / (1 - DAWN_START); // dawn: fade to day | |
| } | |
| // Per-fish night curve (v10): shifts each fish's dusk/dawn fades by its own early/normal/late | |
| // personality so they don't all sleep or wake in unison. Full day and full night are shared | |
| // by everyone â only the transitions differ. | |
| function personalNight(t, f) { | |
| const p = ((t / CYCLE) % 1 + 1) % 1; | |
| if (p < DUSK_START) return 0; // bright day: all awake | |
| if (p >= NIGHT_START && p < DAWN_START) return 1; // full night: all asleep | |
| const c = v => Math.max(0, Math.min(1, v)); | |
| if (p < NIGHT_START) { // dusk: darkness rising | |
| const g = (p - DUSK_START) / (NIGHT_START - DUSK_START); | |
| return c(g + f.sleepShift); | |
| } | |
| const g = 1 - (p - DAWN_START) / (1 - DAWN_START); // dawn: darkness falling | |
| return c(g + f.riseShift); | |
| } | |
| // PERF: cache background gradient - only recreate when colors change significantly | |
| let _bgGrad = null, _bgGradKey = ''; | |
| function drawBackground(light) { | |
| // PERF: quantize colors so the gradient only rebuilds on visible color shifts | |
| const key = [light.top[0] | 0, light.top[1] | 0, light.top[2] | 0, | |
| light.mid[0] | 0, light.mid[1] | 0, light.mid[2] | 0, | |
| light.bot[0] | 0, light.bot[1] | 0, light.bot[2] | 0].join(','); | |
| if (!_bgGrad || _bgGradKey !== key) { | |
| _bgGrad = ctx.createLinearGradient(0, 0, 0, H); | |
| _bgGrad.addColorStop(0, rgbStr(light.top)); | |
| _bgGrad.addColorStop(0.5, rgbStr(light.mid)); | |
| _bgGrad.addColorStop(1, rgbStr(light.bot)); | |
| _bgGradKey = key; | |
| } | |
| ctx.fillStyle = _bgGrad; | |
| ctx.fillRect(0, 0, W, H); | |
| } | |
| // ============ Light rays â pre-blurred sprites (v23 fast path) ============ | |
| // The old version applied a canvas blur() filter to every beam on every frame; | |
| // canvas filters force slow CPU rasterization over large regions, which tanked FPS. | |
| // Now each beam's shape is rendered ONCE into its own offscreen sprite (blur + the | |
| // vertical fade baked in at build time), and per frame we just blit that sprite in | |
| // horizontal slices with the same undulation offsets as before â wobble + soft | |
| // edges, zero filters at runtime. | |
| const RAY_SEGS = 18; // vertical resolution of each beam's spine (at build time) | |
| const RAY_SLICES = 10; // horizontal slices per beam used for the wobble (per frame) â reduced from 24 | |
| const RAY_SPRITE_SCALE = 0.5; // sprites stored at half resolution â softer + cheaper | |
| const RAY_PAD = 60; // room for the blur to spill past the beam edge | |
| let raySpritesBuiltFor = null; | |
| let rayTintRGB = null; // the light.ray color used when building tinted sprites (perf: rebuild only on big shifts) | |
| function buildRaySprites() { | |
| const S = RAY_SPRITE_SCALE; | |
| const yTop0 = -10, ySpan = H * 0.95 + 10; // beam vertical extent (same as before) | |
| for (const r of rays) { | |
| const shift = r.tilt * H; | |
| const topW = r.w * 0.45, botW = r.w * 1.9; | |
| // static spine in local coords (x relative to the beam origin); the per-frame | |
| // drift + wobble are added when slicing, so each sprite stays reusable | |
| const L = [], Rr = []; | |
| for (let i = 0; i <= RAY_SEGS; i++) { | |
| const v = i / RAY_SEGS; | |
| const y = yTop0 + v * ySpan; | |
| const cx = shift * v; // centerline x at this depth | |
| const halfW = topW + (botW - topW) * v; // beam widens as it falls | |
| L.push([cx - halfW, y]); | |
| Rr.push([cx + halfW, y]); | |
| } | |
| let xMinL = Infinity, xMaxL = -Infinity; | |
| for (const [px] of L) if (px < xMinL) xMinL = px; | |
| for (const [px] of Rr) if (px > xMaxL) xMaxL = px; | |
| const bw = Math.ceil(xMaxL - xMinL + RAY_PAD * 2); | |
| const bh = Math.ceil(ySpan + RAY_PAD * 2); | |
| // draw the beam into a full-res scratch canvas (identity transform so the blur | |
| // radius is unambiguous), then downscale it into the half-res sprite | |
| const full = document.createElement("canvas"); | |
| full.width = bw; full.height = bh; | |
| const fc = full.getContext("2d"); | |
| fc.translate(-xMinL + RAY_PAD, -yTop0 + RAY_PAD); // local beam coords â scratch px | |
| if (r.blur > 0) { try { fc.filter = `blur(${r.blur}px)`; } catch (e) {} } | |
| const poly = (a, b) => { | |
| fc.beginPath(); | |
| fc.moveTo(a[0][0], a[0][1]); | |
| for (let i = 1; i < a.length; i++) fc.lineTo(a[i][0], a[i][1]); | |
| for (let i = b.length - 1; i >= 0; i--) fc.lineTo(b[i][0], b[i][1]); | |
| fc.closePath(); | |
| fc.fill(); | |
| }; | |
| // vertical fade baked into the sprite: bright at the surface, gone by the floor | |
| // (same gradient stops as before, mapped into scratch-canvas coordinates) | |
| const yS0 = -yTop0 + RAY_PAD; // scratch y of world y=0 | |
| const yS1 = H - yTop0 + RAY_PAD; // scratch y of world y=H | |
| const gOuter = fc.createLinearGradient(0, yS0, 0, yS1); | |
| gOuter.addColorStop(0, `rgba(255,255,255,${r.alpha})`); | |
| gOuter.addColorStop(0.55, `rgba(255,255,255,${r.alpha * 0.45})`); | |
| gOuter.addColorStop(1, "rgba(255,255,255,0)"); | |
| fc.fillStyle = gOuter; | |
| poly(L, Rr); | |
| // brighter, narrower inner core riding the same spine (v1 core profile) | |
| const LC = [], RC = []; | |
| for (let i = 0; i <= RAY_SEGS; i++) { | |
| const [lx, ly] = L[i], [rx2, ry2] = Rr[i]; | |
| const cxm = (lx + rx2) / 2; | |
| const coreHalf = ((rx2 - lx) / 2) * 0.42; // 42% of the local half-width, as in v1 | |
| LC.push([cxm - coreHalf, ly]); | |
| RC.push([cxm + coreHalf, ry2]); | |
| } | |
| const gCore = fc.createLinearGradient(0, yS0, 0, yS1); | |
| gCore.addColorStop(0, `rgba(255,255,255,${r.alpha * 1.5})`); | |
| gCore.addColorStop(0.65, "rgba(255,255,255,0)"); | |
| fc.fillStyle = gCore; | |
| poly(LC, RC); | |
| const spr = document.createElement("canvas"); | |
| spr.width = Math.max(1, Math.ceil(bw * S)); | |
| spr.height = Math.max(1, Math.ceil(bh * S)); | |
| const sc = spr.getContext("2d"); | |
| sc.drawImage(full, 0, 0, bw, bh, 0, 0, spr.width, spr.height); | |
| // PERF: store the blurred WHITE beam. Tinting is done separately in applyRayTint() | |
| // so that a light-color change only needs a cheap source-in fill instead of | |
| // re-running this expensive blur rasterization (which caused transition hitching). | |
| r.baseSprite = spr; | |
| r.tw = spr.width; r.th = spr.height; // sprite pixel size | |
| r.bw = bw; // beam width in CSS px (dest size) | |
| r.xMinL = xMinL; // leftmost local x of the beam body | |
| } | |
| raySpritesBuiltFor = W + "x" + H; | |
| applyRayTint(); // ensure every ray has a blittable sprite immediately | |
| } | |
| // Cheaply re-tint the cached blurred sprites with the current light.ray color. | |
| // No blur / no shape rasterization â just a source-in fill over the stored beam, | |
| // so this stays fast even during slow dusk/dawn transitions. | |
| function applyRayTint() { | |
| for (const r of rays) { | |
| if (!r.baseSprite) continue; | |
| if (!r.tintCanvas) { | |
| r.tintCanvas = document.createElement("canvas"); | |
| r.tintCanvas.width = r.tw; r.tintCanvas.height = r.th; | |
| } | |
| const tc = r.tintCanvas.getContext("2d"); | |
| tc.clearRect(0, 0, r.tw, r.th); | |
| tc.drawImage(r.baseSprite, 0, 0); | |
| if (rayTintRGB) { | |
| const [tr, tg, tb] = rayTintRGB; | |
| tc.globalCompositeOperation = "source-in"; | |
| tc.fillStyle = `rgb(${tr},${tg},${tb})`; | |
| tc.fillRect(0, 0, r.tw, r.th); | |
| tc.globalCompositeOperation = "source-over"; | |
| } | |
| r.sprite = r.tintCanvas; | |
| } | |
| } | |
| function drawRays(t, light) { | |
| if (!raySpritesBuiltFor || raySpritesBuiltFor !== W + "x" + H) buildRaySprites(); | |
| const [rr, rg, rb] = light.ray; | |
| const yTop0 = -10, ySpan = H * 0.95 + 10; | |
| // PERF: re-tint the cached blurred sprites only when the light color shifts enough. | |
| // Re-tinting is cheap (a source-in fill) â no blur re-rasterization â so this runs | |
| // smoothly even during slow dusk/dawn transitions instead of hitching on rebuilds. | |
| if (!rayTintRGB || Math.abs(rayTintRGB[0] - rr) + Math.abs(rayTintRGB[1] - rg) + Math.abs(rayTintRGB[2] - rb) > 35) { | |
| rayTintRGB = [rr, rg, rb]; | |
| applyRayTint(); // cheap re-tint of the cached blurred sprites (no blur re-run) | |
| } | |
| ctx.restore(); | |
| for (const r of rays) { | |
| // blit horizontal slices with the undulation offset at each depth â same wave | |
| // math as before, so the beams shimmer exactly like they always have | |
| const off = Math.sin(t * r.speed * 6 + r.x) * 55; // slow drift of the whole beam | |
| for (let i = 0; i < RAY_SLICES; i++) { | |
| const vT = i / RAY_SLICES, vB = (i + 1) / RAY_SLICES, vM = (vT + vB) / 2; | |
| const ya = yTop0 + vT * ySpan, yb = yTop0 + vB * ySpan; | |
| const grow = 0.35 + 0.65 * vM; // straighter up top, bends more with depth | |
| const wave = | |
| Math.sin(vM * r.uLen + t * r.uSpd + r.uPh) * r.uAmp + | |
| Math.sin(vM * r.vLen - t * r.vSpd + r.vPh) * r.vAmp; | |
| const dx = r.x + off + wave * grow; // undulating center x at this depth | |
| ctx.drawImage(r.sprite, | |
| 0, (ya + 10 + RAY_PAD) * RAY_SPRITE_SCALE, r.tw, (yb - ya) * RAY_SPRITE_SCALE, | |
| dx + r.xMinL - RAY_PAD, ya, r.bw, yb - ya); | |
| } | |
| } | |
| } | |
| // v29: Fish occlude godrays - cast angled, blurred shadows that move with the sun | |
| function drawFishShadows(t) { | |
| ctx.save(); | |
| ctx.globalCompositeOperation = 'multiply'; | |
| // Calculate sun position based on raw day phase (linear progression) | |
| const p = ((t / CYCLE) % 1 + 1) % 1; // 0 to 1, linear throughout the day | |
| // Daytime is p=0 to p=0.5 (DUSK_START), night is p=0.5833 to p=0.9167 | |
| // Sun moves from left to right across the sky during the day only | |
| let sunPhase; | |
| if (p < DUSK_START) { | |
| // Daytime: sun moves from left to right | |
| sunPhase = p / DUSK_START; // 0 to 1 during daytime | |
| } else if (p < DAWN_START) { | |
| // Night: sun is set, shadows are at sunset position | |
| sunPhase = 1; | |
| } else { | |
| // Dawn: sun is rising, shadows are at sunrise position | |
| sunPhase = 0; | |
| } | |
| // Sun angle: -1.25 to +1.25 radians (left to right) | |
| const sunAngle = (sunPhase - 0.5) * Math.PI * 0.8; | |
| const shadowAngle = Math.atan2(1, Math.tan(sunAngle + Math.PI/2)); | |
| for (const f of fishes) { | |
| // Fish blocks light, casting a shadow that extends down to the sand at an angle | |
| const shadowTop = f.y - f.size * 0.3; | |
| const shadowBottom = sandAt(f.x) - 5; | |
| const shadowHeight = shadowBottom - shadowTop; | |
| if (shadowHeight > 0) { | |
| // Shadow width tapers from fish size at top to wider at bottom | |
| const topWidth = f.size * 1.875; | |
| const bottomWidth = f.size * 3.90625; | |
| // Shadow descends at an angle based on sun position | |
| const topX = f.x; | |
| const bottomX = f.x + shadowHeight * Math.tan(shadowAngle); | |
| // Create gradient for shadow (darker near fish, fading downward) | |
| const g = ctx.createLinearGradient(topX, shadowTop, bottomX, shadowBottom); | |
| g.addColorStop(0, 'rgba(0,0,0,0.0825)'); | |
| g.addColorStop(0.3, 'rgba(0,0,0,0.045)'); | |
| g.addColorStop(1, 'rgba(0,0,0,0.01125)'); | |
| ctx.fillStyle = g; | |
| ctx.beginPath(); | |
| // Draw tapered shadow shape with soft edges using quadratic curves | |
| const blur = f.size * 0.8; | |
| ctx.moveTo(topX - topWidth/2, shadowTop + blur); | |
| ctx.quadraticCurveTo(topX - topWidth/2, shadowTop, topX, shadowTop); | |
| ctx.quadraticCurveTo(topX + topWidth/2, shadowTop, topX + topWidth/2, shadowTop + blur); | |
| ctx.lineTo(bottomX + bottomWidth/2, shadowBottom - blur); | |
| ctx.quadraticCurveTo(bottomX + bottomWidth/2, shadowBottom, bottomX, shadowBottom); | |
| ctx.quadraticCurveTo(bottomX - bottomWidth/2, shadowBottom, bottomX - bottomWidth/2, shadowBottom - blur); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| } | |
| } | |
| ctx.restore(); | |
| } | |
| function updateMotes(dt, t) { | |
| ctx.fillStyle = "rgba(220,240,255,0.3)"; | |
| for (const m of motes) { | |
| m.y -= m.s * dt; | |
| m.x += Math.sin(t + m.p) * 6 * dt; | |
| if (m.y < -4) { m.y = H + 4; m.x = rand(0, W); } | |
| ctx.beginPath(); | |
| ctx.arc(m.x, m.y, m.r, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| } | |
| function drawSand(c = ctx) { | |
| const g = c.createLinearGradient(0, H * 0.85, 0, H); | |
| g.addColorStop(0, "#cdd8bd"); // v24: pale aqua-tan (cooled from warm brown) | |
| g.addColorStop(1, "#7f938b"); // v24: sea-glass taupe | |
| c.fillStyle = g; | |
| c.beginPath(); | |
| c.moveTo(-10, H + 10); | |
| for (let i = 0; i < sandHeights.length; i++) c.lineTo(i * 40, sandHeights[i]); | |
| c.lineTo(W + 10, H + 10); | |
| c.closePath(); | |
| c.fill(); | |
| } | |
| function drawRocks(c = ctx) { | |
| for (const rk of rocks) { | |
| const y = sandAt(rk.x) + rk.r * 0.35; | |
| // fully opaque â tone now varies lightness instead of alpha | |
| const base = Math.round(28 + rk.tone * 46); | |
| const g = c.createRadialGradient(rk.x - rk.r * 0.3, y - rk.r * 0.5, rk.r * 0.1, rk.x, y, rk.r * 1.1); | |
| g.addColorStop(0, `rgb(${base + 68},${base + 78},${base + 92})`); | |
| g.addColorStop(1, "rgb(38,48,62)"); | |
| c.fillStyle = g; | |
| // jagged polygon outline (precomputed in buildScene) instead of a smooth dome | |
| c.beginPath(); | |
| rk.pts.forEach(([dx, dy], i) => { | |
| const px = rk.x + dx, py = y + dy; | |
| i ? c.lineTo(px, py) : c.moveTo(px, py); | |
| }); | |
| c.closePath(); | |
| c.fill(); | |
| } | |
| } | |
| // v15: static organic lean of a stalk at fraction f up its length (root stays planted) | |
| function stalkCurveX(w, f) { | |
| return f * (w.cA1 * Math.sin(f * w.cF1 + w.cP1) + w.cA2 * Math.sin(f * w.cF2 + w.cP2)); | |
| } | |
| // PERF: shared sway function (was a per-stalk closure recreated every frame) | |
| function stalkSwayX(w, t, f) { | |
| return Math.sin(t * w.waveF1 + w.wavePh1 + f * 2.4) * w.waveA1 * f * f + | |
| Math.sin(t * w.waveF2 + w.wavePh2 + f * 3.8) * w.waveA2 * f * f + | |
| Math.sin(t * w.waveF3 + w.wavePh3 + f * 5.4) * w.waveA3 * f; | |
| } | |
| // v4.3: draw background seaweed procedurally â multi-wave organic motion instead of | |
| // rigid sprite rotation. Far stalks get a soft low-alpha halo pass to keep their | |
| // "far away" look without any runtime blur filters (no slowdown). | |
| // v6: middle-distance seaweed using halo stroke pass (median depth between back and far) | |
| function drawMidSeaweeds(t) { | |
| // PERF: lineCap/lineJoin are constant â set once instead of per stalk per pass | |
| ctx.lineCap = 'round'; | |
| ctx.lineJoin = 'round'; | |
| for (const w of midSeaweeds) { | |
| const baseY = sandAt(w.x) + 10; | |
| const n = w.segs; | |
| // PERF: compute sway positions ONCE per frame; both passes and branches reuse them | |
| const pxs = w._px || (w._px = new Float64Array(n + 1)); | |
| const pys = w._py || (w._py = new Float64Array(n + 1)); | |
| for (let i = 0; i <= n; i++) { | |
| const f = i / n; | |
| pxs[i] = w.x + stalkCurveX(w, f) + stalkSwayX(w, t, f); | |
| pys[i] = baseY - w.h * f; | |
| } | |
| // Wider halo pass for more softness (between backSeaweeds and farSeaweeds) | |
| for (let pass = 0; pass < 2; pass++) { | |
| const isHalo = pass === 0; | |
| ctx.strokeStyle = isHalo ? w.haloStyle : w.coreStyle; // PERF: precomputed at build | |
| ctx.lineWidth = w.midLineW * (isHalo ? 2.2 : 1); | |
| // stalk outline: up one side, down the other | |
| ctx.beginPath(); | |
| let prevX = 0, prevY = baseY; // original quirk: first normal measured from origin | |
| for (let i = 0; i <= n; i++) { | |
| const f = i / n; | |
| const px = pxs[i], py = pys[i]; | |
| const dx = px - prevX, dy = py - prevY; | |
| const dl = Math.sqrt(dx * dx + dy * dy) || 1; // PERF: hypot is slower, not needed here | |
| const nx = -dy / dl, ny = dx / dl; | |
| const hw = (w.width * (1 - f) + w.tipW * f) * 0.5; | |
| if (i === 0) ctx.moveTo(px + nx * hw, py + ny * hw); | |
| else ctx.lineTo(px + nx * hw, py + ny * hw); | |
| prevX = px; prevY = py; | |
| } | |
| // down the other side (cached points; i=n no-op point dropped â degenerate in original) | |
| for (let i = n - 1; i >= 0; i--) { | |
| const f = i / n; | |
| const px = pxs[i], py = pys[i]; | |
| const dx = pxs[i + 1] - px, dy = pys[i + 1] - py; | |
| const dl = Math.sqrt(dx * dx + dy * dy) || 1; | |
| const nx = -dy / dl, ny = dx / dl; | |
| const hw = (w.width * (1 - f) + w.tipW * f) * 0.5; | |
| ctx.lineTo(px - nx * hw, py - ny * hw); | |
| } | |
| ctx.closePath(); | |
| ctx.stroke(); | |
| // branches flutter with the stalk (anchors read from cached positions) | |
| for (const b of w.branches) { | |
| const idx = Math.min(n, Math.max(1, Math.round(b.f * n))); | |
| const ax = pxs[idx], ay = pys[idx]; | |
| const flutter = Math.sin(t * w.waveF2 * 1.4 + b.ph) * (3 + w.waveA2 * 0.4); | |
| const bx = ax + b.side * b.len + flutter; | |
| const by = ay - b.up; | |
| ctx.beginPath(); | |
| ctx.lineWidth = isHalo ? b.haloW : b.w; // PERF: halo width precomputed | |
| ctx.strokeStyle = isHalo ? w.haloStyle : w.branchCoreStyle; | |
| ctx.moveTo(ax, ay); | |
| ctx.bezierCurveTo( | |
| ax + b.side * b.len * 0.3, ay - b.up * 0.75, | |
| bx - b.side * b.len * 0.35, by + b.up * 0.6, | |
| bx, by | |
| ); | |
| ctx.stroke(); | |
| } | |
| } | |
| } | |
| } | |
| function drawBackSeaweeds(t) { | |
| // v6: no runtime canvas filter â blur() forces a CPU re-rasterize every frame | |
| // and was the main FPS cost. Softness now comes from the halo pass below instead. | |
| ctx.lineCap = 'round'; | |
| ctx.lineJoin = 'round'; | |
| for (const w of backSeaweeds) { | |
| const baseY = sandAt(w.x) + 10; | |
| const n = w.segs; | |
| // PERF: compute sway positions ONCE per frame; both passes and branches reuse them | |
| const pxs = w._px || (w._px = new Float64Array(n + 1)); | |
| const pys = w._py || (w._py = new Float64Array(n + 1)); | |
| for (let i = 0; i <= n; i++) { | |
| const f = i / n; | |
| pxs[i] = w.x + stalkCurveX(w, f) + stalkSwayX(w, t, f); | |
| pys[i] = baseY - w.h * f; | |
| } | |
| // v6: near stalks also get a halo pass (replaces the old blur(2px) filter) | |
| for (let pass = 0; pass < 2; pass++) { | |
| const isHalo = pass === 0; | |
| ctx.strokeStyle = isHalo ? w.haloStyle : w.coreStyle; // PERF: precomputed at build | |
| ctx.lineWidth = w.backLineW * (isHalo ? 1.9 : 1); | |
| // stalk outline: up one side, down the other | |
| ctx.beginPath(); | |
| let prevX = 0, prevY = baseY; // original quirk: first normal measured from origin | |
| for (let i = 0; i <= n; i++) { | |
| const f = i / n; | |
| const px = pxs[i], py = pys[i]; | |
| const dx = px - prevX, dy = py - prevY; | |
| const dl = Math.sqrt(dx * dx + dy * dy) || 1; // PERF: hypot is slower, not needed here | |
| const nx = -dy / dl, ny = dx / dl; | |
| const hw = (w.width * (1 - f) + w.tipW * f) * 0.5; | |
| if (i === 0) ctx.moveTo(px + nx * hw, py + ny * hw); | |
| else ctx.lineTo(px + nx * hw, py + ny * hw); | |
| prevX = px; prevY = py; | |
| } | |
| // down the other side (cached points; i=n no-op point dropped â degenerate in original) | |
| for (let i = n - 1; i >= 0; i--) { | |
| const f = i / n; | |
| const px = pxs[i], py = pys[i]; | |
| const dx = pxs[i + 1] - px, dy = pys[i + 1] - py; | |
| const dl = Math.sqrt(dx * dx + dy * dy) || 1; | |
| const nx = -dy / dl, ny = dx / dl; | |
| const hw = (w.width * (1 - f) + w.tipW * f) * 0.5; | |
| ctx.lineTo(px - nx * hw, py - ny * hw); | |
| } | |
| ctx.closePath(); | |
| ctx.stroke(); | |
| // branches flutter with the stalk (anchors read from cached positions) | |
| for (const b of w.branches) { | |
| const idx = Math.min(n, Math.max(1, Math.round(b.f * n))); | |
| const ax = pxs[idx], ay = pys[idx]; | |
| const flutter = Math.sin(t * w.waveF2 * 1.4 + b.ph) * (3 + w.waveA2 * 0.4); | |
| const bx = ax + b.side * b.len + flutter; | |
| const by = ay - b.up; | |
| ctx.beginPath(); | |
| ctx.lineWidth = isHalo ? b.haloW : b.w; // PERF: halo width precomputed | |
| ctx.strokeStyle = isHalo ? w.haloStyle : w.branchCoreStyle; | |
| ctx.moveTo(ax, ay); | |
| ctx.bezierCurveTo( | |
| ax + b.side * b.len * 0.3, ay - b.up * 0.75, | |
| bx - b.side * b.len * 0.35, by + b.up * 0.6, | |
| bx, by | |
| ); | |
| ctx.stroke(); | |
| } | |
| } | |
| } | |
| } | |
| // v5: pre-render ultra-far seaweed sprites with heavy blur baked in at build time | |
| function buildFarSeaweedSprites() { | |
| const BLUR = 7; // v5.1: reduced from 10 (33% less) â slightly closer appearance | |
| const PAD = 40; // extra space for blur spill | |
| for (const w of farSeaweeds) { | |
| // Calculate bounding box by sampling the stalk curve | |
| const baseY = sandAt(w.x) + 10; | |
| let xMin = Infinity, xMax = -Infinity; | |
| for (let i = 0; i <= w.segs; i++) { | |
| const f = i / w.segs; | |
| // Use a static curve for the sprite (no animation baked in) | |
| const px = w.x + f * 10 * Math.sin(f * 2.5 + w.phase); // gentle curve | |
| if (px < xMin) xMin = px; | |
| if (px > xMax) xMax = px; | |
| } | |
| // Add padding for blur + width | |
| xMin -= PAD + w.width; | |
| xMax += PAD + w.width; | |
| const bw = Math.ceil(xMax - xMin); | |
| const bh = Math.ceil(w.h + PAD * 2); | |
| // Create sprite canvas | |
| const spr = document.createElement("canvas"); | |
| spr.width = bw; | |
| spr.height = bh; | |
| const sc = spr.getContext("2d"); | |
| // Apply heavy blur filter (baked in at build time - zero runtime cost) | |
| sc.filter = `blur(${BLUR}px)`; | |
| // Draw the stalk as a tapered blade with branches | |
| const coreStyle = `hsla(${w.hue},45%,28%,0.8125)`; // v5.2: +25% opacity | |
| sc.strokeStyle = coreStyle; | |
| sc.lineCap = 'round'; | |
| sc.lineJoin = 'round'; | |
| // Calculate sprite-local coordinates | |
| const localBaseX = bw / 2; | |
| const localBaseY = bh - PAD; | |
| // Draw main stalk outline | |
| sc.beginPath(); | |
| let prevX = localBaseX, prevY = localBaseY; | |
| for (let i = 0; i <= w.segs; i++) { | |
| const f = i / w.segs; | |
| const curveX = f * 10 * Math.sin(f * 2.5 + w.phase); | |
| const px = localBaseX + curveX; | |
| const py = localBaseY - w.h * f; | |
| if (i === 0) sc.moveTo(px, py); | |
| else sc.lineTo(px, py); | |
| prevX = px; prevY = py; | |
| } | |
| // Draw with width to create the blade shape | |
| sc.lineWidth = (w.width + w.tipW) / 2; | |
| sc.stroke(); | |
| // Draw branches | |
| for (const b of w.branches) { | |
| const idx = Math.min(w.segs, Math.max(1, Math.round(b.f * w.segs))); | |
| const f = idx / w.segs; | |
| const curveX = f * 10 * Math.sin(f * 2.5 + w.phase); | |
| const ax = localBaseX + curveX; | |
| const ay = localBaseY - w.h * f; | |
| sc.beginPath(); | |
| sc.lineWidth = b.w; | |
| sc.strokeStyle = `hsla(${w.hue + 8},40%,32%,0.625)`; // v5.2: +25% opacity | |
| sc.moveTo(ax, ay); | |
| const bx = ax + b.side * b.len; | |
| const by = ay - b.up; | |
| sc.bezierCurveTo( | |
| ax + b.side * b.len * 0.3, ay - b.up * 0.75, | |
| bx - b.side * b.len * 0.35, by + b.up * 0.6, | |
| bx, by | |
| ); | |
| sc.stroke(); | |
| } | |
| // Store sprite and offset for drawing | |
| w.sprite = spr; | |
| w.spriteXOffset = xMin; // left edge of bounding box in world coords | |
| w.spriteYOffset = baseY - bh + PAD; // top edge in world coords (baseY is bottom) | |
| } | |
| } | |
| // v28: pre-render blurred foreground grass sprites for ~40% of blades (gentle depth effect) | |
| function buildFgGrassSprites() { | |
| const BLUR = 1.5; // very gentle blur â just enough to suggest "slightly out of focus" | |
| const PAD = 8; // extra space for blur spill | |
| for (const g of fgGrass) { | |
| if (!g.blurred || g.sprite) continue; // only build for flagged blades that don't have sprites yet | |
| // Bounding box: account for the lean deviation so the tip can't clip out of canvas | |
| const dev = g.lean * g.h; // horizontal offset of tip from base | |
| const bw = Math.ceil(Math.abs(dev) + g.width + PAD * 2); | |
| const bh = Math.ceil(g.h + PAD * 2); | |
| // Create sprite canvas | |
| const spr = document.createElement("canvas"); | |
| spr.width = bw; | |
| spr.height = bh; | |
| const sc = spr.getContext("2d"); | |
| // Apply gentle blur filter (baked in at build time - zero runtime cost) | |
| sc.filter = `blur(${BLUR}px)`; | |
| // Draw the blade as a tapered shape with the same style as drawFgGrass | |
| const localBaseX = PAD - Math.min(0, dev); // base x so both base and tip fit in canvas | |
| const localBaseY = bh - PAD; | |
| // Dark body of the blade (simplified static version) | |
| sc.fillStyle = `hsla(${g.hue},60%,31%,0.96)`; | |
| sc.beginPath(); | |
| // Simple tapered blade shape | |
| const nSegs = g.segs + 1; | |
| for (let i = 0; i < nSegs; i++) { | |
| const f = i / g.segs; | |
| const x = localBaseX + g.lean * g.h * f; | |
| const y = localBaseY - g.h * f; | |
| const hw = (g.width * (1 - f) + 1.2 * f) / 2; | |
| if (i === 0) sc.moveTo(x - hw, y); | |
| else sc.lineTo(x - hw, y); | |
| } | |
| // Rounded tip | |
| const tipX = localBaseX + g.lean * g.h; | |
| const tipY = localBaseY - g.h; | |
| sc.quadraticCurveTo(tipX + 2, tipY + 3, tipX + 1, tipY); | |
| // Right side going back down | |
| for (let i = nSegs - 1; i >= 0; i--) { | |
| const f = i / g.segs; | |
| const x = localBaseX + g.lean * g.h * f; | |
| const y = localBaseY - g.h * f; | |
| const hw = (g.width * (1 - f) + 1.2 * f) / 2; | |
| sc.lineTo(x + hw, y); | |
| } | |
| sc.closePath(); | |
| sc.fill(); | |
| // Lighter inner highlight | |
| sc.strokeStyle = `hsla(${g.hue + 6},64%,45%,0.5)`; | |
| sc.lineWidth = g.width * 0.3; | |
| sc.lineCap = "round"; | |
| sc.beginPath(); | |
| sc.moveTo(localBaseX, localBaseY); | |
| sc.lineTo(tipX, tipY); | |
| sc.stroke(); | |
| // Store sprite and offset for drawing (offsets place the blade base at the draw origin) | |
| g.sprite = spr; | |
| g.spriteXOffset = -localBaseX; | |
| g.spriteYOffset = -localBaseY; | |
| } | |
| } | |
| // v5: draw ultra-far seaweed sprites with simple sway animation | |
| function drawFarSeaweeds(t) { | |
| for (const w of farSeaweeds) { | |
| if (!w.sprite) continue; | |
| const baseY = sandAt(w.x) + 10; | |
| // Simple rotational sway - root stays planted, sprite rotates slightly | |
| const swayAngle = Math.sin(t * w.speed + w.phase) * 0.04; // very subtle rotation | |
| ctx.save(); | |
| // Translate to the base point (where the stalk meets the sand) | |
| ctx.translate(w.x, baseY); | |
| // Rotate around the base point for natural sway | |
| ctx.rotate(swayAngle); | |
| // v28: +25% more visible (brighter/more opaque) for the farthest seaweed layer | |
| ctx.globalAlpha = 1.25; | |
| // Draw sprite at its offset position relative to base | |
| ctx.drawImage(w.sprite, w.spriteXOffset - w.x, w.spriteYOffset - baseY); | |
| ctx.restore(); | |
| } | |
| } | |
| function updatePlants(t, dt) { | |
| plantPoints.length = 0; | |
| for (const w of seaweeds) { | |
| // ---- fish push leaves aside: fish overpower the flora ---- | |
| for (const f of fishes) { | |
| const R = 80, R2 = R * R; // influence radius around fish | |
| const fSpd = f.currentVelocity / 60; // actual current speed (from movement) | |
| const fx = Math.cos(f.angle) * f.currentVelocity; // fish velocity x (px/s) | |
| const moveF = Math.min(1, fSpd / 1.2); // v9: how actively this fish is swimming | |
| for (let i = 1; i <= w.segs; i++) { | |
| const fl = i / w.segs; | |
| const px = w.x + w.off[i] + stalkCurveX(w, fl) + Math.sin(t * w.speed + w.phase + fl * 2.6) * fl * fl * 24; | |
| const py = sandAt(w.x) + 8 - w.h * fl; | |
| const dx = px - f.x, dy = py - f.y; | |
| const d2 = dx * dx + dy * dy; | |
| if (d2 < R2 && d2 > 0.01) { | |
| const d = Math.sqrt(d2); | |
| const weight = (R - d) / R * (0.35 + 0.65 * fl); // upper segments yield more | |
| // shove radially away from the fish + drag along its swim direction. | |
| // v9: a nearly-still fish only gently parts leaves â no shoving, no flinch wobble | |
| w.offV[i] += ((dx / d) * 1500 * (0.25 + 0.75 * moveF) + fx * 9) * weight * dt; | |
| if (weight > 0.25 && moveF > 0.35) w.touch = Math.min(1, w.touch + dt * 5); | |
| } | |
| } | |
| } | |
| // ---- spring back toward rest, with damping (leaves flow back after fish pass) ---- | |
| for (let i = 1; i <= w.segs; i++) { | |
| w.offV[i] += -w.off[i] * 20 * dt; | |
| w.offV[i] *= Math.max(0, 1 - 4 * dt); | |
| } | |
| // ---- neighbour coupling: bend as one flexible stalk, not loose points ---- | |
| for (let i = 1; i < w.segs; i++) { | |
| const F = (w.off[i + 1] - w.off[i]) * 30 * dt; | |
| w.offV[i] += F; | |
| w.offV[i + 1] -= F; | |
| } | |
| // ---- integrate bend (root stays planted) ---- | |
| for (let i = 1; i <= w.segs; i++) { | |
| w.off[i] += w.offV[i] * dt; | |
| if (w.off[i] > 70) { w.off[i] = 70; w.offV[i] *= -0.3; } | |
| else if (w.off[i] < -70) { w.off[i] = -70; w.offV[i] *= -0.3; } | |
| } | |
| if (w.touch > 0) w.touch = Math.max(0, w.touch - dt * 1.2); | |
| const baseY = sandAt(w.x) + 8; | |
| w.yMid = baseY - w.h * 0.5; // v6: stalk midpoint â the night-time gathering point | |
| w.points.length = 0; | |
| for (let i = 0; i <= w.segs; i++) { | |
| const f = i / w.segs; | |
| // normal sway + a fast extra flinch while being pushed | |
| const sway = Math.sin(t * w.speed + w.phase + f * 2.6) * f * f * 24 | |
| + Math.sin(t * 7 + w.phase) * w.touch * f * 16; | |
| // v17: secondary wobble - a finer inner movement for unique per-stalk motion | |
| const sWobble = Math.sin(t * w.sFreq + w.sPh + f * 4.2) * w.sAmp * f; | |
| const px = w.x + (w.off[i] || 0) + stalkCurveX(w, f) + sway + sWobble; | |
| const py = baseY - w.h * f; | |
| w.points.push({ x: px, y: py }); | |
| plantPoints.push({ x: px, y: py, r: w.width * 0.55, s: w }); | |
| } | |
| for (const b of w.branches) { | |
| const a = w.points[Math.min(w.segs, Math.max(1, Math.round(b.f * w.segs)))]; | |
| const flutter = Math.sin(t * w.speed * 1.4 + b.ph) * (3 + w.touch * 9); | |
| b.tx = a.x + b.side * b.len + flutter; | |
| b.ty = a.y - b.up; | |
| plantPoints.push({ x: b.tx, y: b.ty, r: 5, s: w }); | |
| plantPoints.push({ x: (a.x + b.tx) / 2, y: (a.y + b.ty) / 2, r: 4, s: w }); | |
| } | |
| } | |
| } | |
| // Grass blades ripple when fish swim close â stronger the lower the fish is. | |
| // v4: more reactive to fish contact â wider influence radius, faster ripple build, | |
| // and slightly slower decay so the wave lingers a touch longer after a fish passes. | |
| function updateGrass(t, dt) { | |
| const night = nightFactor(t); // v6: after dark the fish drift toward the plants | |
| for (const g of grass) { | |
| if (g.ripple > 0) g.ripple = Math.max(0, g.ripple - dt * 1.3); // v4: slower decay (was 1.6) | |
| for (const f of fishes) { | |
| const dx = g.x - f.x; | |
| const dy = g.y - f.y; | |
| const d2 = dx * dx + dy * dy; | |
| const R = 115, R2 = R * R; // v4: wider influence radius (was 95) | |
| if (d2 < R2 && d2 > 1) { | |
| const d = Math.sqrt(d2); | |
| // fish low near the ground stir the grass far more than high up | |
| const nearGround = f.y > H * 0.58 ? 1 : 0.4; // v4: slightly stronger even when higher (was 0.35) | |
| g.ripple = Math.min(1, g.ripple + ((R - d) / R) * dt * 14 * nearGround); // v4: faster ripple build (was 9) | |
| } | |
| } | |
| } | |
| } | |
| function drawSeaweed() { | |
| for (const w of seaweeds) { | |
| const pts = w.points, n = pts.length; | |
| // v15: main stipe as a tapered leaf-blade â an outline around the same physics | |
| // points, wide at the root and thinning to a soft point up top. No straight-line | |
| // segments anywhere; the stalk's own curve comes from the sway + rest-curve baked | |
| // into those points in updatePlants. | |
| ctx.fillStyle = w.stalkStyle; | |
| ctx.beginPath(); | |
| for (let i = 0; i < n; i++) { // up one side of the blade | |
| const p = pts[i]; | |
| const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)]; | |
| let dx = b.x - a.x, dy = b.y - a.y; | |
| const dl = Math.hypot(dx, dy) || 1; | |
| const nx = -dy / dl, ny = dx / dl; // perpendicular to the local stalk direction | |
| const f = i / (n - 1); | |
| const hw = (w.width * (1 - f) + w.tipW * f) / 2; // half-width: base â tip taper | |
| if (i === 0) ctx.moveTo(p.x + nx * hw, p.y + ny * hw); | |
| else ctx.lineTo(p.x + nx * hw, p.y + ny * hw); | |
| } | |
| { // rounded cap at the tip | |
| const tp = pts[n - 1], pp = pts[n - 2]; | |
| let dx = tp.x - pp.x, dy = tp.y - pp.y; | |
| const dl = Math.hypot(dx, dy) || 1; | |
| const nx = -dy / dl, ny = dx / dl; | |
| const hwT = w.tipW / 2; | |
| ctx.quadraticCurveTo(tp.x + dx, tp.y + dy * 0.6, tp.x - nx * hwT, tp.y - ny * hwT); | |
| } | |
| for (let i = n - 1; i >= 0; i--) { // down the other side | |
| const p = pts[i]; | |
| const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)]; | |
| let dx = b.x - a.x, dy = b.y - a.y; | |
| const dl = Math.hypot(dx, dy) || 1; | |
| const nx = -dy / dl, ny = dx / dl; | |
| const f = i / (n - 1); | |
| const hw = (w.width * (1 - f) + w.tipW * f) / 2; | |
| ctx.lineTo(p.x - nx * hw, p.y - ny * hw); | |
| } | |
| ctx.closePath(); | |
| ctx.fill(); | |
| // v15: faint midrib vein down the blade's centreline for a leafy look | |
| ctx.strokeStyle = w.midStyle; | |
| ctx.lineWidth = 1.4; | |
| ctx.lineCap = "round"; | |
| ctx.beginPath(); | |
| ctx.moveTo(pts[0].x, pts[0].y); | |
| for (let i = 1; i < n; i++) ctx.lineTo(pts[i].x, pts[i].y); | |
| ctx.stroke(); | |
| // side branches curling up and out from the stalk | |
| for (const b of w.branches) { | |
| const a = w.points[Math.min(w.segs, Math.max(1, Math.round(b.f * w.segs)))]; | |
| // v15: S-curved frond â leaves the stalk heading upward, then arcs out and up | |
| // to the tip instead of one plain quadratic bow (same endpoints as before) | |
| const c1x = a.x + b.side * b.len * 0.3; | |
| const c1y = a.y - b.up * 0.75; | |
| const c2x = b.tx - b.side * b.len * 0.35; | |
| const c2y = b.ty + b.up * 0.6; | |
| ctx.strokeStyle = w.branchStyle; | |
| ctx.lineWidth = b.w; | |
| ctx.beginPath(); | |
| ctx.moveTo(a.x, a.y); | |
| ctx.bezierCurveTo(c1x, c1y, c2x, c2y, b.tx, b.ty); | |
| ctx.stroke(); | |
| // colourful flower heads blooming along the branch â 1..3 per branch (v3) | |
| if (b.flowers.length) { | |
| for (const fl of b.flowers) { | |
| const u = fl.t, iu = 1 - u; // point on the branch's cubic curve (v15) | |
| drawFlowerHead( | |
| iu * iu * iu * a.x + 3 * iu * iu * u * c1x + 3 * iu * u * u * c2x + u * u * u * b.tx, | |
| iu * iu * iu * a.y + 3 * iu * iu * u * c1y + 3 * iu * u * u * c2y + u * u * u * b.ty, fl); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| function drawFlowerHead(x, y, fl) { | |
| ctx.save(); | |
| for (let i = 0; i < fl.petals; i++) { // petals radiating from the centre | |
| const ang = (i / fl.petals) * Math.PI * 2; | |
| const px = x + Math.cos(ang) * fl.r * 0.72; | |
| const py = y + Math.sin(ang) * fl.r * 0.72; | |
| ctx.fillStyle = fl.petalFill; | |
| ctx.beginPath(); | |
| ctx.ellipse(px, py, fl.r * 0.55, fl.r * 0.36, ang, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| ctx.fillStyle = fl.centerFill; // bright contrasting centre (precomputed at bloom time) | |
| ctx.beginPath(); | |
| ctx.arc(x, y, fl.r * 0.42, 0, Math.PI * 2); | |
| ctx.fill(); | |
| ctx.restore(); | |
| } | |
| function drawGrass(t) { | |
| for (const g of grass) { | |
| // v16: tapered multi-segment blade â wide at root, thinning to a soft point | |
| const amp = 3 + g.ripple * 13; | |
| const n = g.segs + 1; // number of points along the blade | |
| const pts = []; | |
| for (let i = 0; i < n; i++) { | |
| const f = i / g.segs; // 0 at base, 1 at tip | |
| // organic rest-curve: each blade has its own natural bend (like seaweed) | |
| const curveX = f * g.cA1 * Math.sin(f * g.cF1 + g.cP1); | |
| // constant sway, amplified while rippling from a passing fish | |
| const sway = Math.sin(t * g.speed + g.phase + f * 2.4) * amp * f; | |
| // v17: secondary wobble - finer inner movement for unique per-blade motion | |
| const sWobble = Math.sin(t * g.sFreq + g.sPh + f * 3.8) * g.sAmp * f; | |
| pts.push({ | |
| x: g.x + g.lean * g.h * f + curveX + sway + sWobble, | |
| y: g.y - g.h * f * (1 - g.ripple * 0.06) | |
| }); | |
| } | |
| // draw tapered outline around the physics points | |
| ctx.fillStyle = `hsla(${g.hue},58%,40%,0.92)`; | |
| ctx.beginPath(); | |
| for (let i = 0; i < n; i++) { | |
| const p = pts[i]; | |
| const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)]; | |
| let dx = b.x - a.x, dy = b.y - a.y; | |
| const dl = Math.hypot(dx, dy) || 1; | |
| const nx = -dy / dl, ny = dx / dl; // perpendicular to local blade direction | |
| const f = i / (n - 1); | |
| const hw = (g.width * (1 - f) + 0.8 * f) / 2; // half-width: base â tip taper | |
| if (i === 0) ctx.moveTo(p.x + nx * hw, p.y + ny * hw); | |
| else ctx.lineTo(p.x + nx * hw, p.y + ny * hw); | |
| } | |
| // rounded cap at the tip | |
| { | |
| const tp = pts[n - 1], pp = pts[n - 2]; | |
| let dx = tp.x - pp.x, dy = tp.y - pp.y; | |
| const dl = Math.hypot(dx, dy) || 1; | |
| const nx = -dy / dl, ny = dx / dl; | |
| const hwT = 0.4; // tip half-width | |
| ctx.quadraticCurveTo(tp.x + dx, tp.y + dy * 0.6, tp.x - nx * hwT, tp.y - ny * hwT); | |
| } | |
| for (let i = n - 1; i >= 0; i--) { | |
| const p = pts[i]; | |
| const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)]; | |
| let dx = b.x - a.x, dy = b.y - a.y; | |
| const dl = Math.hypot(dx, dy) || 1; | |
| const nx = -dy / dl, ny = dx / dl; | |
| const f = i / (n - 1); | |
| const hw = (g.width * (1 - f) + 0.8 * f) / 2; | |
| ctx.lineTo(p.x - nx * hw, p.y - ny * hw); | |
| } | |
| ctx.closePath(); | |
| ctx.fill(); | |
| } | |
| } | |
| // v12: foreground grass sways with the water â decorative only, no physics. | |
| // Drawn on top of everything else (it is closest to the viewer). Each blade gets a | |
| // thin lighter inner stroke over a darker body for a lush, voluminous "closer" look. | |
| function drawFgGrass(t) { | |
| for (const g of fgGrass) { | |
| // v28: defocused blades â blit the pre-blurred sprite with a gentle rotational sway | |
| // around its base (same trick as the ultra-far seaweed). The baked blur hides the | |
| // simplified motion, and this path is far cheaper than per-frame canvas filters. | |
| if (g.sprite) { | |
| const swayAngle = Math.sin(t * g.speed + g.phase) * 0.05; // subtle: like farSeaweeds | |
| ctx.save(); | |
| ctx.translate(g.x, g.y); | |
| ctx.rotate(swayAngle); | |
| ctx.drawImage(g.sprite, g.spriteXOffset, g.spriteYOffset); | |
| ctx.restore(); | |
| continue; | |
| } | |
| // v16: tapered multi-segment blade with two-tone depth (like seaweed) | |
| const amp = 4 + g.h * 0.03; | |
| const n = g.segs + 1; | |
| const pts = []; | |
| for (let i = 0; i < n; i++) { | |
| const f = i / g.segs; | |
| // organic rest-curve: each blade has its own natural bend | |
| const curveX = f * g.cA1 * Math.sin(f * g.cF1 + g.cP1); | |
| // constant sway, weighted toward the tip | |
| const sway = Math.sin(t * g.speed + g.phase + f * 2.4) * amp * f; | |
| // v17: secondary wobble - finer inner movement for unique per-blade motion | |
| const sWobble = Math.sin(t * g.sFreq + g.sPh + f * 4.5) * g.sAmp * f; | |
| pts.push({ | |
| x: g.x + g.lean * g.h * f + curveX + sway + sWobble, | |
| y: g.y - g.h * f | |
| }); | |
| } | |
| // dark body of the blade (tapered outline) | |
| ctx.fillStyle = `hsla(${g.hue},60%,31%,0.96)`; | |
| ctx.beginPath(); | |
| for (let i = 0; i < n; i++) { | |
| const p = pts[i]; | |
| const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)]; | |
| let dx = b.x - a.x, dy = b.y - a.y; | |
| const dl = Math.hypot(dx, dy) || 1; | |
| const nx = -dy / dl, ny = dx / dl; | |
| const f = i / (n - 1); | |
| const hw = (g.width * (1 - f) + 1.2 * f) / 2; // half-width: base â tip | |
| if (i === 0) ctx.moveTo(p.x + nx * hw, p.y + ny * hw); | |
| else ctx.lineTo(p.x + nx * hw, p.y + ny * hw); | |
| } | |
| // rounded cap at the tip | |
| { | |
| const tp = pts[n - 1], pp = pts[n - 2]; | |
| let dx = tp.x - pp.x, dy = tp.y - pp.y; | |
| const dl = Math.hypot(dx, dy) || 1; | |
| const nx = -dy / dl, ny = dx / dl; | |
| const hwT = 0.6; // tip half-width | |
| ctx.quadraticCurveTo(tp.x + dx, tp.y + dy * 0.6, tp.x - nx * hwT, tp.y - ny * hwT); | |
| } | |
| for (let i = n - 1; i >= 0; i--) { | |
| const p = pts[i]; | |
| const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)]; | |
| let dx = b.x - a.x, dy = b.y - a.y; | |
| const dl = Math.hypot(dx, dy) || 1; | |
| const nx = -dy / dl, ny = dx / dl; | |
| const f = i / (n - 1); | |
| const hw = (g.width * (1 - f) + 1.2 * f) / 2; | |
| ctx.lineTo(p.x - nx * hw, p.y - ny * hw); | |
| } | |
| ctx.closePath(); | |
| ctx.fill(); | |
| // lighter inner highlight for depth: a thin centerline stroke | |
| ctx.strokeStyle = `hsla(${g.hue + 6},64%,45%,0.5)`; | |
| ctx.lineWidth = g.width * 0.3; | |
| ctx.lineCap = "round"; | |
| ctx.beginPath(); | |
| ctx.moveTo(pts[0].x, pts[0].y); | |
| for (let i = 1; i < n; i++) ctx.lineTo(pts[i].x, pts[i].y); | |
| ctx.stroke(); | |
| } | |
| } | |
| // v6: vignette is static -> pre-render once per resize, blit each frame | |
| // (cheaper than recreating the radial gradient + full-screen fill every frame) | |
| let vignetteLayer = null; | |
| function buildVignette() { | |
| vignetteLayer = document.createElement("canvas"); | |
| vignetteLayer.width = canvas.width; | |
| vignetteLayer.height = canvas.height; | |
| const c2 = vignetteLayer.getContext("2d"); | |
| c2.setTransform(ctx.getTransform()); | |
| const g = c2.createRadialGradient(W / 2, H * 0.45, Math.min(W, H) * 0.35, W / 2, H * 0.55, Math.max(W, H) * 0.78); | |
| g.addColorStop(0, "rgba(0,0,0,0)"); | |
| g.addColorStop(1, "rgba(0,10,20,0.45)"); | |
| c2.fillStyle = g; | |
| c2.fillRect(0, 0, W, H); | |
| } | |
| function drawVignette() { | |
| if (!vignetteLayer) return; | |
| ctx.drawImage(vignetteLayer, 0, 0, W, H); | |
| } | |
| // --- v8: mild film grain over the whole scene. | |
| // Efficient: a small noise tile is generated ONCE and turned into a cached pattern. | |
| // Each frame we just blit that pattern with 'overlay' blending at low opacity and a | |
| // random sub-tile offset, so it shimmers like real film. Cost = one pattern fillRect. | |
| let grainPattern = null; | |
| const GRAIN_TILE = 160; // tile size in px (kept small so the pattern is cheap to repeat) | |
| function buildGrainTile() { | |
| const tile = document.createElement("canvas"); | |
| tile.width = GRAIN_TILE; tile.height = GRAIN_TILE; | |
| const g = tile.getContext("2d"); | |
| const img = g.createImageData(GRAIN_TILE, GRAIN_TILE); | |
| const d = img.data; | |
| for (let i = 0; i < d.length; i += 4) { | |
| // grayscale noise centred on mid-grey (~128): with 'overlay' blend, values near | |
| // 128 are neutral while higher/lower ones gently brighten/darken -> filmic grain. | |
| const v = 96 + Math.floor(Math.random() * 64); // 96..159, centred on 128 | |
| d[i] = v; d[i+1] = v; d[i+2] = v; d[i+3] = 255; | |
| } | |
| g.putImageData(img, 0, 0); | |
| grainPattern = ctx.createPattern(tile, "repeat"); // cached once, reused every frame | |
| } | |
| let grainOx = 0, grainOy = 0, lastGrainTime = 0; | |
| function drawGrain() { | |
| if (!grainPattern) return; | |
| // v17: half-speed grain â re-randomize the offset every other frame instead of every frame | |
| const now = performance.now(); | |
| if (now - lastGrainTime >= 33) { // ~30fps grain updates instead of per-frame (~60fps) | |
| grainOx = (Math.random() * GRAIN_TILE) | 0; | |
| grainOy = (Math.random() * GRAIN_TILE) | 0; | |
| lastGrainTime = now; | |
| lastGrainDrawn = false; // offset changed -> must repaint | |
| } | |
| if (lastGrainDrawn) return; // v6: skip the full-screen overlay blend when nothing changed | |
| const ox = grainOx, oy = grainOy; | |
| ctx.save(); | |
| ctx.globalCompositeOperation = "overlay"; | |
| ctx.globalAlpha = 0.7; // mild: subtle shimmer, not heavy noise | |
| ctx.translate(-ox, -oy); | |
| ctx.fillStyle = grainPattern; | |
| ctx.fillRect(0, 0, W + GRAIN_TILE, H + GRAIN_TILE); // cover full screen even with offset | |
| ctx.restore(); | |
| lastGrainDrawn = true; | |
| } | |
| let lastGrainDrawn = false; | |
| // Precomputed static layers â the sand and rocks never move, so they are | |
| // rendered once to an offscreen canvas and blitted each frame (v6) instead of | |
| // re-drawing their gradients and paths every single frame. | |
| // PERF: half-resolution offscreen buffer for the light rays (see drawRays) â the | |
| // per-ray blur() filter is far cheaper rasterizing small areas than full-screen. | |
| // v24: baked current ripples â faint curved lines following the sand contour | |
| function drawRipples(c) { | |
| c.save(); | |
| c.lineCap = "round"; | |
| const nLines = Math.floor(W / 16); | |
| for (let i = 0; i < nLines; i++) { | |
| const x0 = rand(0, W); | |
| const yBase = sandAt(x0); | |
| const len = rand(35, 95); | |
| const bow = rand(-7, 7); | |
| c.strokeStyle = `rgba(126, 148, 141, ${rand(0.07, 0.16)})`; | |
| c.lineWidth = rand(1, 2); | |
| c.beginPath(); | |
| c.moveTo(x0, yBase + rand(3, H * 0.05)); | |
| c.quadraticCurveTo( | |
| x0 + len / 2, | |
| sandAt(x0 + len / 2) + bow, | |
| x0 + len, | |
| sandAt(x0 + len) + rand(3, H * 0.04) | |
| ); | |
| c.stroke(); | |
| } | |
| c.restore(); | |
| } | |
| // v24: baked pebble/speckle scatter â grainy cool-toned dots across the sand bed | |
| function drawPebbles(c) { | |
| const n = Math.floor(W * H / 1800); | |
| for (let i = 0; i < n; i++) { | |
| const x = rand(0, W); | |
| const y = sandAt(x) + rand(2, H * 0.14); | |
| if (y > H) continue; | |
| // cool palette: grey-teal, muted green, pale blue-grey | |
| const hue = rand(150, 200); | |
| const light = rand(38, 68); | |
| c.fillStyle = `hsla(${hue}, ${rand(10, 28)}%, ${light}%, ${rand(0.25, 0.5)})`; | |
| const r = rand(0.7, 2.4); | |
| if (Math.random() < 0.6) { | |
| // small round dot | |
| c.beginPath(); | |
| c.arc(x, y, r, 0, Math.PI * 2); | |
| c.fill(); | |
| } else { | |
| // small ellipse pebble | |
| c.save(); | |
| c.translate(x, y); | |
| c.rotate(rand(0, Math.PI)); | |
| c.beginPath(); | |
| c.ellipse(0, 0, r * rand(1.2, 2), r * rand(0.5, 0.8), 0, 0, Math.PI * 2); | |
| c.fill(); | |
| c.restore(); | |
| } | |
| } | |
| } | |
| // v24: contact shadows â soft dark ellipses under rocks + faint band at sand surface line | |
| function drawContactShadows(c) { | |
| // Faint darker band right at the sand surface (grounds the whole bed) | |
| // FIXED: follows the actual sand contour instead of a fixed horizontal band | |
| c.save(); | |
| const bandGrad = c.createLinearGradient(0, H * 0.85, 0, H * 0.9); | |
| bandGrad.addColorStop(0, "rgba(50, 72, 68, 0)"); | |
| bandGrad.addColorStop(1, "rgba(50, 72, 68, 0.10)"); | |
| c.fillStyle = bandGrad; | |
| // Draw the band following the sand contour to avoid visible horizontal lines | |
| c.beginPath(); | |
| c.moveTo(-10, H + 10); | |
| for (let i = 0; i < sandHeights.length; i++) { | |
| const x = i * 40; | |
| const y = sandHeights[i] + rand(2, H * 0.05); // offset below the surface | |
| c.lineTo(x, y); | |
| } | |
| c.lineTo(W + 10, H + 10); | |
| c.closePath(); | |
| c.fill(); | |
| // Soft ellipse under each rock | |
| for (const rk of rocks) { | |
| const y = sandAt(rk.x) + rk.r * 0.35; | |
| c.fillStyle = "rgba(42, 60, 58, 0.18)"; | |
| c.beginPath(); | |
| c.ellipse(rk.x, y + rk.r * 0.25, rk.r * 1.25, rk.r * 0.38, 0, 0, Math.PI * 2); | |
| c.fill(); | |
| } | |
| c.restore(); | |
| } | |
| let staticLayer = null; | |
| function buildStaticLayer() { | |
| staticLayer = document.createElement("canvas"); | |
| staticLayer.width = canvas.width; // same device-pixel resolution as the tank | |
| staticLayer.height = canvas.height; | |
| const c2 = staticLayer.getContext("2d"); | |
| c2.setTransform(ctx.getTransform()); // match DPR scaling exactly | |
| drawSand(c2); | |
| drawRipples(c2); // v24: baked current ripples (free at runtime) | |
| drawPebbles(c2); // v24: baked grainy pebble/speckle scatter | |
| drawContactShadows(c2); // v24: soft dark ellipses under rocks + surface band | |
| // NOTE: rocks are no longer baked here â they live in rocksLayer so they can be | |
| // drawn AFTER the mid-ground grass (rocks sit IN FRONT of the blades, not behind them). | |
| } | |
| // Rocks on their own layer: blitted after drawGrass() so stones read as sitting in | |
| // front of the plants instead of the grass appearing to sprout out of them. | |
| let rocksLayer = null; | |
| function buildRocksLayer() { | |
| rocksLayer = document.createElement("canvas"); | |
| rocksLayer.width = canvas.width; // same device-pixel resolution as the tank | |
| rocksLayer.height = canvas.height; | |
| const c3 = rocksLayer.getContext("2d"); | |
| c3.setTransform(ctx.getTransform()); // match DPR scaling exactly | |
| drawRocks(c3); | |
| } | |
| // ============ Tier 2: Caustics + light pooling (v25) ============ | |
| let causticTile = null; | |
| let causticOffsetX = 0, causticOffsetY = 0; | |
| let lastCausticShift = 0; | |
| const CAUSTIC_TILE_SIZE = 160; // small tile â cheap to blit repeatedly | |
| function buildCausticTile() { | |
| causticTile = document.createElement("canvas"); | |
| const s = CAUSTIC_TILE_SIZE; | |
| causticTile.width = s; causticTile.height = s; | |
| const c = causticTile.getContext("2d"); | |
| // Apply blur to make caustics look softer/more realistic | |
| c.filter = "blur(6px)"; // v27: softer blur, more realistic caustics | |
| // Draw a few random bright spots/lines that look like refracted light patterns | |
| for (let i = 0; i < 18; i++) { | |
| const x = Math.random() * s, y = Math.random() * s; | |
| const r = rand(4, 14); | |
| const g = c.createRadialGradient(x, y, 0, x, y, r); | |
| // Faint white-blue glow (will be blended with 'screen') | |
| g.addColorStop(0, "rgba(255,255,255,0.44)"); // v26: +25% brighter | |
| g.addColorStop(1, "rgba(255,255,255,0)"); | |
| c.fillStyle = g; | |
| c.fillRect(x - r, y - r, r * 2, r * 2); | |
| } | |
| // Add some thin curved lines (like caustic web patterns) | |
| for (let i = 0; i < 8; i++) { | |
| const x1 = Math.random() * s, y1 = Math.random() * s; | |
| const cx = x1 + rand(-40, 40), cy = y1 + rand(-40, 40); | |
| const x2 = Math.random() * s, y2 = Math.random() * s; | |
| c.strokeStyle = "rgba(255,255,255,0.23)"; // v26: +25% brighter | |
| c.lineWidth = rand(1.5, 3.5); | |
| c.beginPath(); | |
| c.moveTo(x1, y1); | |
| c.quadraticCurveTo(cx, cy, x2, y2); | |
| c.stroke(); | |
| } | |
| // Reset filter | |
| c.filter = "none"; | |
| } | |
| function drawCaustics(t) { | |
| if (!causticTile || !sandHeights.length) return; | |
| // Shift the tile offset every ~50ms for a subtle shimmer effect (cheap: no redraw, just reposition) | |
| if (t - lastCausticShift > 0.05) { | |
| causticOffsetX = Math.random() * CAUSTIC_TILE_SIZE; | |
| causticOffsetY = Math.random() * CAUSTIC_TILE_SIZE; | |
| lastCausticShift = t; | |
| } | |
| ctx.save(); | |
| // Create a clipping path that follows the actual sand terrain | |
| ctx.beginPath(); | |
| ctx.moveTo(-10, H + 10); | |
| for (let i = 0; i < sandHeights.length; i++) { | |
| ctx.lineTo(i * 40, sandHeights[i]); | |
| } | |
| ctx.lineTo(W + 10, H + 10); | |
| ctx.closePath(); | |
| ctx.clip(); | |
| ctx.globalCompositeOperation = "screen"; // additive light effect | |
| ctx.globalAlpha = 0.35; // v26: +25% brighter so the shimmer stands out from the sand | |
| // Tile the caustic pattern across the entire canvas (clip will handle masking) | |
| const tileW = CAUSTIC_TILE_SIZE, tileH = CAUSTIC_TILE_SIZE; | |
| for (let x = -causticOffsetX % tileW - tileW; x < W + tileW; x += tileW) { | |
| for (let y = -causticOffsetY % tileH - tileH; y < H + tileH; y += tileH) { | |
| ctx.drawImage(causticTile, x, y); | |
| } | |
| } | |
| ctx.restore(); | |
| } | |
| // PERF: pre-render a soft radial gradient sprite for light pooling - draw scaled instead of creating gradients each frame | |
| let _poolSprite = null, _poolSpriteSize = 0; | |
| function buildPoolSprite() { | |
| const size = 256; // fixed size, will be scaled per ray | |
| if (_poolSprite && _poolSpriteSize === size) return; | |
| _poolSprite = document.createElement('canvas'); | |
| _poolSprite.width = size; | |
| _poolSprite.height = size; | |
| const pc = _poolSprite.getContext('2d'); | |
| const g = pc.createRadialGradient(size/2, size/2, 0, size/2, size/2, size/2); | |
| g.addColorStop(0, 'rgba(255,255,255,1)'); // full white, alpha controlled via globalAlpha | |
| g.addColorStop(1, 'rgba(255,255,255,0)'); | |
| pc.fillStyle = g; | |
| pc.fillRect(0, 0, size, size); | |
| _poolSpriteSize = size; | |
| } | |
| function drawLightPooling(t) { | |
| if (!_poolSprite) buildPoolSprite(); // lazy init (was never called before) | |
| // Draw faint bright patches where each god-ray hits the sand | |
| const sandTop = H * 0.85; | |
| ctx.save(); | |
| ctx.globalCompositeOperation = "screen"; // additive light | |
| for (const r of rays) { | |
| // Compute where this ray hits the sand at y â sandTop | |
| // Use the same wave math as the ray drawing to match positions | |
| const off = Math.sin(t * r.speed * 6 + r.x) * 55; | |
| // At the sand depth (vM near the bottom), compute the x position | |
| const vM = 0.95; // approximate depth where sand starts | |
| const wave = | |
| Math.sin(vM * r.uLen + t * r.uSpd + r.uPh) * r.uAmp + | |
| Math.sin(vM * r.vLen - t * r.vSpd + r.vPh) * r.vAmp; | |
| const dx = r.x + off + wave * (0.35 + 0.65 * vM); | |
| // Draw a soft bright ellipse at this position on the sand using pre-rendered sprite | |
| const poolW = r.w * 0.7, poolH = 18; | |
| ctx.globalAlpha = r.alpha * 1.75; // v26: +25% brighter | |
| ctx.drawImage(_poolSprite, dx - poolW, sandTop + 12 - poolH, poolW * 2, poolH * 2); | |
| } | |
| ctx.restore(); | |
| } | |
| let last = performance.now(); | |
| let lastNightUpdate = 0; | |
| let lastSortTime = 0; | |
| // Spatial grid for bubble-fish collision detection | |
| const GRID_SIZE = 80; | |
| let grid = null; | |
| function buildGrid() { | |
| grid = {}; | |
| for (const f of fishes) { | |
| const gx = Math.floor(f.x / GRID_SIZE); | |
| const gy = Math.floor(f.y / GRID_SIZE); | |
| const key = `${gx},${gy}`; | |
| if (!grid[key]) grid[key] = []; | |
| grid[key].push(f); | |
| } | |
| } | |
| function getFishNear(x, y) { | |
| const gx = Math.floor(x / GRID_SIZE); | |
| const gy = Math.floor(y / GRID_SIZE); | |
| const result = []; | |
| for (let dx = -1; dx <= 1; dx++) { | |
| for (let dy = -1; dy <= 1; dy++) { | |
| const key = `${gx + dx},${gy + dy}`; | |
| if (grid[key]) { | |
| for (const f of grid[key]) result.push(f); | |
| } | |
| } | |
| } | |
| return result; | |
| } | |
| function frame(now) { | |
| const dt = Math.min(0.05, (now - last) / 1000); | |
| last = now; | |
| const t = now / 1000; | |
| // Cache night factor - update only once per second | |
| if (t - lastNightUpdate > 1.0) { | |
| curNight = nightFactor(t); | |
| lastNightUpdate = t; | |
| } | |
| const light = sampleLight(t); | |
| drawBackground(light); | |
| drawRays(t, light); | |
| drawFishShadows(t); | |
| updateMotes(dt, t); | |
| // v5: ultra-far pre-rendered blurry seaweed â deepest layer, behind everything else | |
| drawFarSeaweeds(t); | |
| // v6: middle-distance seaweed with halo pass (between far and back) | |
| drawMidSeaweeds(t); | |
| // v4: background seaweed â drawn behind sand/rocks for depth | |
| drawBackSeaweeds(t); // v4.3: procedural multi-wave motion (no separate update pass) | |
| updatePlants(t, dt); | |
| updateGrass(t, dt); | |
| for (const f of fishes) f.update(dt); | |
| // Build spatial grid for collision detection | |
| buildGrid(); | |
| // Build spatial grid for collision detection | |
| buildGrid(); | |
| // v7: fish gently bump each other - overlapping pairs are eased apart along their | |
| // center line (weighted by size), so they nudge instead of stacking on top of each other | |
| // Uses spatial grid for O(n) average instead of O(n²) | |
| // Track processed pairs by ID to avoid double-checking (which causes twitching when sleeping) | |
| const checked = new Set(); | |
| for (const a of fishes) { | |
| const nearby = getFishNear(a.x, a.y); | |
| for (const b of nearby) { | |
| if (a === b) continue; | |
| // Only check each pair once using fish IDs | |
| const pairKey = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`; | |
| if (checked.has(pairKey)) continue; | |
| checked.add(pairKey); | |
| let dx = b.x - a.x, dy = b.y - a.y; | |
| const minD = (a.size + b.size) * 0.62; // gentle: smaller than their visual length | |
| const d2 = dx * dx + dy * dy; | |
| if (d2 <= 0.01) { // exactly overlapping - nudge apart randomly | |
| a.x -= rand(0.5, 1.5); a.y -= rand(0.5, 1.5); | |
| b.x += rand(0.5, 1.5); b.y += rand(0.5, 1.5); | |
| } else if (d2 < minD * minD) { | |
| const dd = Math.sqrt(d2); | |
| const overlap = (minD - dd) / minD; // 0..1 how deep they are in each other | |
| const push = overlap * 24 * dt; // soft: only a few px/s even at full overlap | |
| dx /= dd; dy /= dd; | |
| const ma = b.size / (a.size + b.size), mb = a.size / (a.size + b.size); | |
| a.x -= dx * push * 2 * ma; a.y -= dy * push * 2 * ma; | |
| b.x += dx * push * 2 * mb; b.y += dy * push * 2 * mb; | |
| } | |
| } | |
| } | |
| if (t - lastSortTime > 0.1) { | |
| // Sort fish by size for depth ordering - only re-sort every 100ms | |
| if (t - lastSortTime > 0.1) { | |
| fishes.sort((a, b) => a.size - b.size); | |
| lastSortTime = t; | |
| } | |
| lastSortTime = t; | |
| } | |
| for (const f of fishes) f.draw(ctx); | |
| ctx.drawImage(staticLayer, 0, 0, W, H); | |
| // v27: at night, dim the bare floor ~25% (clipped to the sand shape) BEFORE the caustics, | |
| // so the shimmer reads brighter against a darker bed without touching the caustics themselves. | |
| if (curNight > 0.01) { | |
| ctx.save(); | |
| ctx.beginPath(); | |
| ctx.moveTo(-10, H + 10); | |
| for (let i = 0; i < sandHeights.length; i++) ctx.lineTo(i * 40, sandHeights[i]); | |
| ctx.lineTo(W + 10, H + 10); | |
| ctx.closePath(); | |
| ctx.clip(); | |
| ctx.fillStyle = `rgba(5,10,22,${(curNight * 0.25).toFixed(3)})`; | |
| ctx.fillRect(0, H * 0.8, W, H * 0.2); | |
| ctx.restore(); | |
| } | |
| drawCaustics(t); // v25: animated caustic shimmer on the sand | |
| drawLightPooling(t); // v25: light pools where god-rays hit the floor | |
| drawSeaweed(); | |
| drawGrass(t); | |
| ctx.drawImage(rocksLayer, 0, 0, W, H); // rocks in front of mid-ground grass | |
| for (let i = bubbles.length - 1; i >= 0; i--) { | |
| const b = bubbles[i]; | |
| b.update(dt, t); | |
| if (!b.dead) { | |
| // v10: a fish brushing a bubble pops it â unless it's that same fish's own bubble | |
| // Optimized: use spatial grid instead of checking all fish | |
| const nearby = getFishNear(b.x, b.y); | |
| for (const f of nearby) { | |
| if (f === b.owner) continue; | |
| const dx = f.x - b.x, dy = f.y - b.y; | |
| const rr = f.size * 0.6 + b.r; | |
| if (dx * dx + dy * dy < rr * rr) { b.dead = true; break; } | |
| } | |
| } | |
| if (b.dead) { bubbles.splice(i, 1); continue; } | |
| b.draw(ctx); | |
| } | |
| drawFgGrass(t); // v12: foreground grass â closest to the viewer, drawn on top | |
| // ambient day/night wash over the whole scene | |
| if (light.washA > 0.005) { | |
| ctx.fillStyle = `rgba(${light.washRGB[0]},${light.washRGB[1]},${light.washRGB[2]},${light.washA})`; | |
| ctx.fillRect(0, 0, W, H); | |
| } | |
| drawVignette(); | |
| drawGrain(); // v8: mild film grain on top of everything | |
| requestAnimationFrame(frame); | |
| } | |
| function doResize() { | |
| resize(); | |
| buildRaySprites(); // v23: rebuild the pre-blurred beam sprites for the new size | |
| buildFarSeaweedSprites(); // v5: rebuild ultra-far seaweed sprites for new size | |
| buildVignette(); // v6: rebuild the static vignette layer | |
| buildStaticLayer(); | |
| buildRocksLayer(); // rocks drawn after grass so they sit in front of the plants | |
| buildCausticTile(); // v25: caustic texture tile (built once) | |
| if (!grainPattern) buildGrainTile(); // v8: grain tile is size-independent, build once | |
| } | |
| // ============ Player controls: keyboard + gamepad ============ | |
| const keys = {}; // currently-held keyboard keys (lowercased) | |
| let prevStartPressed = false; // edge-detect the gamepad Start button | |
| // Spawn a fresh player-controlled fish at the centre of the tank. | |
| function spawnPlayerFish() { | |
| if (playerFish) { // replace any existing one | |
| const idx = fishes.indexOf(playerFish); | |
| if (idx !== -1) fishes.splice(idx, 1); | |
| } | |
| playerFish = new Fish(); | |
| playerFish.x = W / 2; | |
| playerFish.y = H / 2; | |
| playerFish.size = rand(25, 43); // ~20% larger than normal fish for visibility | |
| playerFish.speed *= 1.5; // a touch faster than average for fun | |
| playerFish.hue = (Math.random() * 360) | 0; // unique colour so it stands out | |
| playerFish.saturation = 90; | |
| playerFish.lightness = 55; | |
| fishes.push(playerFish); | |
| console.log('Player fish spawned! Steer with WASD or the left stick.'); | |
| } | |
| // Keyboard: WASD steer, Shift speed up, Space slow down, F spawn. | |
| document.addEventListener('keydown', (e) => { | |
| const k = e.key.toLowerCase(); | |
| keys[k] = true; | |
| if (k === ' ') e.preventDefault(); // stop Space from scrolling the page | |
| if (k === 'f' && !e.repeat) spawnPlayerFish(); | |
| }); | |
| document.addEventListener('keyup', (e) => { | |
| keys[e.key.toLowerCase()] = false; | |
| }); | |
| // Poll gamepad state each frame (Gamepad API requires polling). | |
| function pollGamepad() { | |
| const pads = navigator.getGamepads ? navigator.getGamepads() : null; | |
| let startNow = false; | |
| if (pads) { | |
| for (const pad of pads) { | |
| if (!pad || !pad.connected) continue; | |
| // Xbox 360 layout: Start button is index [7]. | |
| if (pad.buttons[7] && pad.buttons[7].pressed) startNow = true; | |
| break; // only the first connected pad | |
| } | |
| } | |
| if (startNow && !prevStartPressed) spawnPlayerFish(); // on-press only | |
| prevStartPressed = startNow; | |
| } | |
| setInterval(pollGamepad, 10); | |
| // Combine keyboard + gamepad into one input vector for the player fish. | |
| function getPlayerInput() { | |
| let dx = 0, dy = 0, boost = 0, slow = 0; | |
| // Keyboard: WASD steer, Shift speed up, Space slow down. | |
| if (keys['a']) dx -= 1; | |
| if (keys['d']) dx += 1; | |
| if (keys['w']) dy -= 1; | |
| if (keys['s']) dy += 1; | |
| if (keys['shift']) boost = 1; | |
| if (keys[' ']) slow = 1; | |
| // Gamepad: left stick steers, right stick down speeds up / up slows down. | |
| const pads = navigator.getGamepads ? navigator.getGamepads() : null; | |
| if (pads) { | |
| for (const pad of pads) { | |
| if (!pad || !pad.connected) continue; | |
| const lx = Math.abs(pad.axes[0]) > GAMEPAD_DEADZONE ? pad.axes[0] : 0; | |
| const ly = Math.abs(pad.axes[1]) > GAMEPAD_DEADZONE ? pad.axes[1] : 0; | |
| dx += lx; | |
| dy += ly; | |
| if (pad.axes[3] > GAMEPAD_DEADZONE) boost = Math.max(boost, pad.axes[3]); // down â faster | |
| if (pad.axes[3] < -GAMEPAD_DEADZONE) slow = Math.max(slow, -pad.axes[3]); // up â slower | |
| break; // only the first connected pad | |
| } | |
| } | |
| return { dx, dy, boost, slow }; | |
| } | |
| // Fade out the controls help text 10s after load/refresh. | |
| setTimeout(() => { | |
| const el = document.getElementById('controls-help'); | |
| if (el) el.style.opacity = '0'; | |
| }, 10000); | |
| window.addEventListener("resize", doResize); | |
| doResize(); | |
| requestAnimationFrame(frame); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |