One commited on
Commit
9c29f44
·
verified ·
1 Parent(s): d1431dd

Upload index.html

Browse files
Files changed (1) hide show
  1. index.html +260 -219
index.html CHANGED
@@ -3,7 +3,7 @@
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Digital Aquarium</title>
7
  <style>
8
  html, body { margin:0; padding:0; height:100%; overflow:hidden; background:#02121f; }
9
  #tank { display:block; width:100vw; height:100vh; }
@@ -20,14 +20,15 @@
20
  <canvas id="tank"></canvas>
21
  <div class="glass"></div>
22
  <script>
 
23
  (() => {
24
  "use strict";
25
- // ============ Aquarium v16 WHISKERS — alt-fg base + spots/stripes + whiskers (~10%) ==
26
- // v22: Ecco-style rendering — fish show top/bottom view when moving vertically,
27
  // side profile when moving horizontally, like Ecco the Dolphin's 3D perspective
28
  // Based on v15 SPOTTY-3 (rolling hills, three tail styles, yellow-green foreground grass,
29
  // ~20% spots / ~20% stripes / ~10% both), plus: an independent roll gives ~10% of fish
30
- // whiskers (barbels) — a forward-curving pair from the snout in one of THREE lengths
31
  // (short / medium / long), swaying gently as they swim. Whiskers are independent of the
32
  // body markings, so a whiskered fish may also be spotted, striped, both, or plain.
33
  // Iridescence + overhead streak unchanged.
@@ -47,7 +48,7 @@ function lerpAngle(a, b, t) {
47
 
48
  const HUES = [8, 35, 46, 172, 195, 215];
49
  const TAIL_TYPES = ['classic', 'fan', 'streamer']; // v11: three tail-fin styles, one per fish
50
- // v18: koi colourations — large irregular patches on a white/ivory base. Three classic
51
  // looks: kohaku (red/orange), sanke (red + black), golden (amber/gold). Patches are
52
  // sampled in units of `size` like the spots, so they scale with each fish.
53
  function genKoiPatches(type) {
@@ -87,15 +88,53 @@ function genKoiPatches(type) {
87
  }
88
  let fishes = [], bubbles = [], seaweeds = [], backSeaweeds = [], midSeaweeds = [], farSeaweeds = [], motes = [], rays = [], rocks = [], grass = [], fgGrass = [];
89
  let playerFish = null; // the fish controlled by gamepad (spawned with F key)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  const GAMEPAD_DEADZONE = 0.15; // ignore small stick movements
91
  let curNight = 0; // v7: shared night factor (dims the top-light + sheen)
92
- let plantPoints = [], _ppUsed = 0; // collision points — a pooled set, reused in place
93
  let sandHeights = [];
94
  let sandParticles = []; // v22: grains of sand kicked up when a fast fish skims the sea floor
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  class Fish {
 
97
  constructor() {
98
- this.size = rand(15, 42);
 
99
  this.speed = rand(0.55, 1.7) * (this.size / 30 + 0.5) * 0.75; // v3: max speed reduced 25%
100
  this.x = rand(60, W - 60);
101
  this.y = rand(H * 0.15, H * 0.72);
@@ -130,30 +169,30 @@ class Fish {
130
  this.spots = null;
131
  this.whiskerLen = null;
132
  }
133
- // v18: color palette — silver/gray + three koi colourations (replaces dark blue & lime green)
134
  const specialRoll = Math.random();
135
  this.isKoi = false;
136
  this.koiPatches = null;
137
  if (specialRoll < 0.15) {
138
- // Silver/gray fish — low saturation, medium-high lightness
139
  this.hue = rand(200, 240); // slight blue-gray tint
140
  this.saturation = rand(8, 18);
141
  this.lightness = rand(60, 75);
142
  } else if (specialRoll < 0.45) {
143
- // Koi — a white/ivory base with large coloured patches; the three classic
144
  // colourations are split roughly evenly across this 30% band.
145
  this.isKoi = true;
146
  const koiType = specialRoll < 0.22 ? 'kohaku' : specialRoll < 0.30 ? 'sanke' : specialRoll < 0.38 ? 'golden' : 'goldspot';
147
  if (koiType === 'goldspot') {
148
  // Golden orange fish with white spots
149
  this.hue = rand(30, 42); // golden orange
150
- this.saturation = rand(85, 95); // high saturation → vivid color
151
- this.lightness = rand(50, 65); // medium lightness → rich gold
152
  } else {
153
- // Classic koi — white/ivory base
154
  this.hue = rand(40, 50); // warm ivory tint for the base
155
- this.saturation = rand(12, 22); // low saturation → white body
156
- this.lightness = rand(88, 94); // high lightness → white/ivory base
157
  }
158
  this.koiPatches = genKoiPatches(koiType);
159
  } else {
@@ -164,10 +203,10 @@ class Fish {
164
  }
165
  this.tailType = pick(TAIL_TYPES); // v11: random tail-fin style for variety
166
 
167
- // v13/v14/v15: random body markings — one roll per fish decides the look:
168
  // ~20% spots, ~20% tiger stripes, ~10% BOTH (spots over stripes), else plain.
169
  // Sampled in units of `size` so they scale with the fish; stored as pre-built
170
- // fill styles — zero per-frame string work.
171
  const genSpots = () => {
172
  const arr = [];
173
  const nSpots = 7 + ((Math.random() * 9) | 0); // 7..15 spots
@@ -177,14 +216,14 @@ class Fish {
177
  const rr = Math.sqrt(Math.random()) * 0.9; // uniform in ellipse, kept off the rim
178
  let style;
179
  if (this.saturation < 30) {
180
- // v14: gray/silver fish — keep spots low-saturation to match
181
  const spotLight = pale ? rand(75, 88) : rand(35, 50);
182
  style = `hsla(${this.hue + rand(-24, 24)},${rand(5, 15)}%,${spotLight}%,${rand(0.35, 0.6).toFixed(2)})`;
183
  } else if (pale) {
184
- // cream/ivory spots — pearl-gourami / leopard pattern
185
  style = `hsla(${this.hue + rand(-24, 24)},${rand(45, 70)}%,${rand(82, 94)}%,${rand(0.35, 0.6).toFixed(2)})`;
186
  } else {
187
- // dark spots — dace / leopard pattern
188
  style = `hsla(${this.hue + rand(-18, 18)},${rand(60, 85)}%,${rand(24, 34)}%,${rand(0.45, 0.7).toFixed(2)})`;
189
  }
190
  arr.push({
@@ -197,31 +236,31 @@ class Fish {
197
  return arr;
198
  };
199
  const genStripes = () => {
200
- // v14/v14.1: tiger stripes — vertical bars across the body (betta / tiger-barb
201
  // style), spread from just behind the head to near the tail base with organic
202
  // jitter; each bar bends and tapers instead of being a straight line.
203
  const arr = [];
204
  const nStr = 5 + ((Math.random() * 4) | 0); // 5..8 bars
205
  const darkBars = this.saturation >= 30 && Math.random() < 0.72; // v14: gray fish get light stripes
206
  for (let i = 0; i < nStr; i++) {
207
- const t = nStr > 1 ? i / (nStr - 1) : 0; // 0 behind head → 1 near tail base
208
  let style;
209
  if (this.saturation < 30) {
210
- // v14: gray/silver fish — keep stripes low-saturation to match
211
  const stripeLight = darkBars ? rand(35, 50) : rand(75, 88);
212
  style = `hsla(${this.hue + rand(-14, 14)},${rand(5, 15)}%,${stripeLight}%,${rand(0.5, 0.75).toFixed(2)})`;
213
  } else if (darkBars) {
214
  // classic dark tiger bars
215
  style = `hsla(${this.hue + rand(-14, 14)},${rand(65, 90)}%,${rand(18, 30)}%,${rand(0.5, 0.75).toFixed(2)})`;
216
  } else {
217
- // cream/ivory bars — pale tiger variant
218
  style = `hsla(${this.hue + rand(-24, 24)},${rand(45, 70)}%,${rand(80, 93)}%,${rand(0.35, 0.6).toFixed(2)})`;
219
  }
220
  arr.push({
221
- x: 0.72 - t * 1.62 + rand(-0.07, 0.07), // +0.72s → -0.90s along the body
222
  w: rand(0.08, 0.2), // bar width, in units of s
223
  style,
224
- // v14.1: organic shape — each bar bends and tapers instead of a straight line
225
  cAmp: rand(0.03, 0.09), // whole-bar bend amplitude (units of s)
226
  cFreq: rand(0.8, 2.0), // waves of bend across the bar's height
227
  cPh: rand(0, Math.PI * 2),
@@ -232,7 +271,7 @@ class Fish {
232
  }
233
  return arr;
234
  };
235
- // v18: koi already carry their own large patches — skip the random spots/stripes
236
  const markRoll = this.isKoi ? 1 : Math.random();
237
  if (markRoll < 0.2) {
238
  this.spots = genSpots();
@@ -241,14 +280,14 @@ class Fish {
241
  this.spots = null;
242
  this.stripes = genStripes();
243
  } else if (markRoll < 0.5) {
244
- // v15: the mixed breed — stripes as the base pattern, spots scattered over them
245
  this.spots = genSpots();
246
  this.stripes = genStripes();
247
  } else {
248
  this.spots = null;
249
  this.stripes = null;
250
  }
251
- // v16: whiskers (barbels) — an independent ~10% roll. A whiskered fish gets a
252
  // forward-curving pair from the snout in one of three length tiers (~even split),
253
  // so the tank shows short, medium and long whiskers. Independent of spots/stripes,
254
  // so any marked fish can also wear them.
@@ -264,7 +303,7 @@ class Fish {
264
  }
265
 
266
  // PERF: precompute per-fish paint styles once. draw() used to rebuild these
267
- // template strings every frame for every fish — now it's a property read.
268
  this.tailFillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.92)`;
269
  this.dorsalFillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.9)`;
270
  this.pevFillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.8)`;
@@ -279,7 +318,7 @@ class Fish {
279
  this.bodyGrad = bg;
280
 
281
  this.phase = rand(0, Math.PI * 2);
282
- // v9: after dark, most fish bed down low in the grass — only a small share still
283
  // hovers by the seaweed, and each of those keeps to its own stalk (no pile-ups)
284
  this.grassSleeper = Math.random() < 0.85;
285
  this.seaweedPref = -1; // chosen lazily on the first night (needs built scenery)
@@ -305,9 +344,9 @@ class Fish {
305
  this.wanderTheta = rand(0, Math.PI * 2);
306
  this.wanderRadius = rand(35, 80);
307
  this.wanderDist = rand(90, 170);
308
- this.maxTurn = rand(1.4, 2.2); // max rad/s — organic turns, no orbiting
309
 
310
- // Behavior states: fish don't swim non-stop — they cruise, hover, and rest.
311
  this.behavior = 'swim'; // 'swim' | 'hover' | 'pause'
312
  this.behaviorTimer = rand(4, 11); // seconds until it may change behavior
313
  this.speedFactor = 1; // current speed multiplier (eased toward target)
@@ -318,12 +357,12 @@ class Fish {
318
  // Occasionally puffs a tiny cluster of bubbles from its mouth
319
  this.bubbleTimer = rand(4, 16); // seconds until the next bubble puff
320
 
321
- // Body roll — banks/rolls through sharp turns like a real fish (spring-damper)
322
  this.roll = 0; // current roll angle (rad); 0 = upright
323
  this.rollVel = 0; // angular velocity of the roll
324
  this.inHardTurn = false;// was it in a sharp turn last frame?
325
 
326
- // v22: Ecco-style viewing angle — determines whether we see the fish from the side
327
  // or from above/below based on its movement direction (0 = side, 1 = top/bottom)
328
  this.viewAngle = 0; // current interpolated view angle
329
 
@@ -334,10 +373,10 @@ class Fish {
334
  const night = personalNight(performance.now() / 1000, this); // v10: per-fish early/normal/late sleep & wake
335
 
336
  // Night: cruise power eases down so even active fish drift lazily after dark,
337
- // and back up again at dawn — smooth, never a sudden speed jump.
338
  this.cruiseSpeed += ((1 - night * 0.55) - this.cruiseSpeed) * Math.min(1, dt * 0.3);
339
 
340
- // Behavior state machine — occasionally hover or come to near-rest.
341
  // At night the odds tilt toward hovering and long still rests (v6).
342
  this.behaviorTimer -= dt;
343
  if (this.behaviorTimer <= 0) {
@@ -363,11 +402,11 @@ class Fish {
363
  // Occasionally release a small cluster of bubbles from the mouth
364
  this.bubbleTimer -= dt;
365
  if (this.bubbleTimer <= 0) {
366
- const mx = this.x + Math.cos(this.angle) * this.size * 0.62;
367
  const my = this.y + Math.sin(this.angle) * this.size * 0.62;
368
- const n = (2 + ((Math.random() * 3) | 0)) * 2; // 4..8 bubbles — doubled from the old 2..4
369
  for (let i = 0; i < n; i++) {
370
- const bb = new Bubble(false, mx + rand(-3, 3), my + rand(-3, 3), this.size);
371
  bb.owner = this; // v10: a fish never pops its own bubbles
372
  bubbles.push(bb);
373
  }
@@ -379,16 +418,16 @@ class Fish {
379
 
380
  // Target sits ahead of the fish (in heading direction), offset by wanderTheta.
381
  // Steering toward it yields smooth meandering and genuine direction changes.
382
- let wx = this.x + Math.cos(this.angle) * this.wanderDist + Math.cos(this.wanderTheta) * this.wanderRadius;
383
  let wy = this.y + Math.sin(this.angle) * this.wanderDist + Math.sin(this.wanderTheta) * this.wanderRadius;
384
 
385
  // v7: after dark, most fish bed down low in the grass; a few still hover by the seaweed.
386
  if (night > 0.01) {
387
  if (this.grassSleeper) {
388
- // v14: drift toward this fish's own sleep spot along the sand line —
389
  // spreads sleeping fish across the tank instead of one big clump
390
  wx += (this.sleepSpotX - wx) * night * 0.75;
391
- const groundY = sandAt(this.x + Math.cos(this.angle) * this.wanderDist) - this.size * 0.5;
392
  wy += (groundY - wy) * night * 0.85;
393
  } else if (seaweeds.length) {
394
  // v9: each seaweed-hungry fish keeps to its own stalk instead of all crowding the nearest one
@@ -396,9 +435,9 @@ class Fish {
396
  this.seaweedPref = (Math.random() * seaweeds.length) | 0;
397
  }
398
  const w = seaweeds[this.seaweedPref];
399
- // v13: plinko fix — stalks near the sides pull harder so fish actually reach them
400
  // instead of being deflected back toward the middle by the boundary steering.
401
- const edgeT = Math.abs(w.x / W - 0.5) * 2; // 0 at center → 1 at glass
402
  const k = night * 0.8 * (1 + edgeT * 0.9); // up to ~1.9x at the very edge
403
  wx += (w.x - wx) * k;
404
  wy += (w.yMid - wy) * k;
@@ -415,7 +454,7 @@ class Fish {
415
  else if (this.x > W - mX) pull = Math.max(pull, 1 - (W - this.x) / mX);
416
  if (this.y < mTop) pull = Math.max(pull, 1 - this.y / mTop);
417
  else if (this.y > H - mBot) pull = Math.max(pull, 1 - (H - this.y) / mBot);
418
- // v13: plinko fix — grass-sleepers settling at night are less deflected by the
419
  // boundary, so they can reach side positions instead of being pushed to the middle.
420
  if (pull > 0 && this.grassSleeper && night > 0.25) {
421
  const settle = Math.min(1, (night - 0.25) / 0.75); // ramps up as they get sleepier
@@ -428,7 +467,7 @@ class Fish {
428
 
429
  // Floor steering: as a fish nears the sand bed it is steered upward,
430
  // so it skims above the ground line instead of diving under it
431
- // (v4: starts earlier and pushes harder — grass yes, faceplants no)
432
  const floorY = sandAt(this.x) - this.size * 0.35;
433
  const fZone = H * 0.18;
434
  if (this.y > floorY - fZone) {
@@ -436,7 +475,7 @@ class Fish {
436
  desired = lerpAngle(desired, Math.atan2(H * 0.45 - this.y, W / 2 - this.x), fp * 1.6);
437
  }
438
 
439
- // v7: separation steering — bend around nearby fish instead of ploughing into them.
440
  // The faster we swim, the earlier and harder we steer away.
441
  const mySpeed = this.speed * this.speedFactor;
442
  if (mySpeed > 0.3) {
@@ -448,7 +487,7 @@ class Fish {
448
  const zone = (this.size + o.size) * 1.7; // notice radius scales with both fish
449
  if (d2 < zone * zone && d2 > 0.01) {
450
  const dd = Math.sqrt(d2);
451
- const w = (1 - dd / zone) * Math.min(1, mySpeed); // close & fast → strong
452
  ax -= (dx / dd) * w;
453
  ay -= (dy / dd) * w;
454
  wsum += w;
@@ -458,7 +497,7 @@ class Fish {
458
  desired = lerpAngle(desired, Math.atan2(ay, ax), Math.min(0.85, wsum * 0.45));
459
  }
460
  } else if (night > 0.2) {
461
- // v14: at rest after dark — a wider, stronger personal buffer so sleeping
462
  // fish gently push apart instead of settling into one tight clump
463
  let ax = 0, ay = 0, wsum = 0;
464
  for (const o of fishes) {
@@ -499,9 +538,9 @@ class Fish {
499
  if (this.roll > 1.15) { this.roll = 1.15; this.rollVel *= -0.25; }
500
  else if (this.roll < -1.15) { this.roll = -1.15; this.rollVel *= -0.25; }
501
 
502
- // v22: Ecco-style viewing angle — when moving vertically, we see the fish from above/below;
503
  // when moving horizontally, we see its side profile. Smoothly interpolate between views.
504
- // sin(angle) gives 0 for horizontal (0, π) and ±1 for vertical (±π/2)
505
  const targetView = Math.abs(Math.sin(this.angle));
506
  this.viewAngle += (targetView - this.viewAngle) * Math.min(1, dt * 4);
507
 
@@ -515,7 +554,7 @@ class Fish {
515
  let d = targetAngle - this.angle;
516
  while (d > Math.PI) d -= 2 * Math.PI;
517
  while (d < -Math.PI) d += 2 * Math.PI;
518
- const turnSpeed = 4.0; // rad/s — responsive turning
519
  this.angle += Math.max(-turnSpeed * dt, Math.min(turnSpeed * dt, d));
520
  }
521
  // Speed: cruise baseline, boosted by Shift / R-stick down,
@@ -525,26 +564,26 @@ class Fish {
525
  if (inp.slow > 0) speedMult -= inp.slow * 0.85; // slow down
526
  speedMult = Math.max(0.15, Math.min(3.0, speedMult));
527
  const playerSpeed = this.speed * this.cruiseSpeed * speedMult * 60 * dt;
528
- this.x += Math.cos(this.angle) * playerSpeed;
529
  this.y += Math.sin(this.angle) * playerSpeed;
530
  this.currentVelocity = this.speed * this.cruiseSpeed * speedMult * 60; // track actual velocity
531
  } else {
532
  // Normal AI fish movement.
533
  const sp = this.speed * this.cruiseSpeed * this.speedFactor * 60 * dt;
534
- this.x += Math.cos(this.angle) * sp;
535
  this.y += Math.sin(this.angle) * sp;
536
  this.currentVelocity = this.speed * this.cruiseSpeed * this.speedFactor * 60; // track actual velocity
537
  }
538
 
539
- // NOTE: fish do NOT collide with plants — they plough straight through.
540
  // The seaweed bends out of the way instead (see updatePlants).
541
 
542
- // Hard safety clamp — fish may just about swim off-screen (they never
543
  // despawn or leave), but never below the ground line (sand surface)
544
  this.x = Math.max(-40, Math.min(W + 40, this.x));
545
  this.y = Math.max(-50, Math.min(sandAt(this.x) - this.size * 0.35, this.y));
546
 
547
- // Tail beat follows actual speed — a resting fish barely moves its tail
548
  // Use the actual current velocity (which accounts for player boost/slow)
549
  const effSpeed = this.currentVelocity / 60; // convert back to base units
550
  this.phase += dt * (4 + effSpeed * 3);
@@ -558,11 +597,11 @@ class Fish {
558
  // detailed body and foreshorten it as the fish turns to swim vertically.
559
  // viewAngle 0 = pure side profile (one eye), 1 = seen from above/below (the back).
560
  // Foreshortening along local up/down makes the dorsal fin slide toward center and
561
- // the body slim down, reading as "seeing the back" — no snapping, nothing vanishes.
562
  const v = Math.max(0, Math.min(1, this.viewAngle));
563
  let scaleY = 1 - 0.34 * v; // foreshorten toward the back view
564
  if (Math.abs(this.roll) > 0.01) { // banking roll still squashes a touch
565
- scaleY *= Math.max(0.42, Math.cos(this.roll));
566
  c.rotate(this.roll * 0.1);
567
  }
568
  c.scale(1, scaleY);
@@ -577,7 +616,7 @@ class Fish {
577
  // traveling body wave
578
  const waveK = Math.PI / s;
579
  const waveAmp = s * 0.12;
580
- const sway = Math.sin(-s * waveK + this.phase) * s * 0.34;
581
 
582
  // Top/bottom view: body seen from above or below
583
  // Dorsal fin visible as a line down the center when viewed from above
@@ -602,7 +641,7 @@ class Fish {
602
  c.fillStyle = g;
603
  c.fill(bodyPath);
604
 
605
- // Dorsal fin — visible as a line/shape down the center when viewed from above
606
  c.strokeStyle = this.tailFillStyle;
607
  c.lineWidth = s * 0.15;
608
  c.lineCap = "round";
@@ -612,7 +651,7 @@ class Fish {
612
  c.quadraticCurveTo(-s * 0.2, -sway * 0.5, -s * 0.6, sway * 0.3);
613
  c.stroke();
614
 
615
- // Eyes — visible on both sides of the head when viewed from top/bottom
616
  c.fillStyle = "#fff";
617
  // Left eye
618
  c.beginPath();
@@ -658,11 +697,11 @@ class Fish {
658
  // v22: Draw fish from the side (original view)
659
  drawSideView(c) {
660
  const s = this.size;
661
- // traveling body wave (v3): ripples head→tail so the fish undulates like a real one
662
  const waveK = Math.PI / s; // ~one wavelength across the body length
663
  const waveAmp = s * 0.12; // subtle flex amplitude
664
- // tail fin follows the body wave at its base, amplified (same phase → no fighting)
665
- const sway = Math.sin(-s * waveK + this.phase) * s * 0.34;
666
 
667
  // v24: Ecco-style blend factor (0 = side profile, 1 = seen from above/below).
668
  // Flattens the tail lobes and spreads eyes/barbels as we see more of the back.
@@ -680,26 +719,26 @@ class Fish {
680
  // with only a shallow dip between the two lobes.
681
  c.moveTo(-s * 0.5, 0);
682
  c.quadraticCurveTo(-s * 1.3, -s * 0.28 * tailFlat + sway, -s * 1.42, -s * 0.5 * tailFlat + sway); // up to upper tip
683
- c.quadraticCurveTo(-s * 1.12, sway * 0.5, -s * 1.42, s * 0.5 * tailFlat + sway); // shallow notch → lower tip
684
  c.quadraticCurveTo(-s * 1.3, s * 0.28 * tailFlat + sway, -s * 0.5, 0); // back to the base
685
  } else if (this.tailType === 'streamer') {
686
  // Long forked streamers (angelfish/fancy-guppy-style): two flowing lobes that
687
  // reach further back with a deeper notch between them.
688
  c.moveTo(-s * 0.5, 0);
689
  c.quadraticCurveTo(-s * 1.35, -s * 0.24 * tailFlat + sway, -s * 1.68, -s * 0.56 * tailFlat + sway); // long upper lobe
690
- c.quadraticCurveTo(-s * 0.98, sway * 0.35, -s * 1.68, s * 0.56 * tailFlat + sway); // deep fork → lower tip
691
  c.quadraticCurveTo(-s * 1.35, s * 0.24 * tailFlat + sway, -s * 0.5, 0); // long lower lobe back to base
692
  } else {
693
- // classic (v7): rounded lobes with a soft notch — the original look
694
  c.moveTo(-s * 0.5, 0);
695
  c.quadraticCurveTo(-s * 1.04, -s * 0.36 * tailFlat + sway, -s * 1.22, -s * 0.44 * tailFlat + sway); // upper edge out toward the tip
696
- c.quadraticCurveTo(-s * 1.34, -s * 0.52 * tailFlat + sway, -s * 0.86, sway * 0.5); // rounded upper lobe → notch
697
- c.quadraticCurveTo(-s * 1.34, s * 0.52 * tailFlat + sway, -s * 1.22, s * 0.44 * tailFlat + sway); // notch → rounded lower lobe
698
  c.quadraticCurveTo(-s * 1.04, s * 0.36 * tailFlat + sway, -s * 0.5, 0); // lower edge back to the base
699
  }
700
  c.fill();
701
 
702
- // v21: far-side whisker — drawn BEFORE the body so it sits behind the fish and is
703
  // partially obscured by the head (only the part that pokes out from behind the snout
704
  // shows). The near-side whisker is drawn after the eye, below.
705
  if (this.whiskerLen) {
@@ -713,7 +752,7 @@ class Fish {
713
  {
714
  const side = -1; // far side (behind the body)
715
  const rootX = s * 0.6;
716
- // v24: from above, barbels sit on either side of the mouth — spread the roots
717
  // apart and flare each whisker further out as the fish "rotates" to swim vertically
718
  const rootY = side * s * (0.09 + 0.07 * ev);
719
  c.beginPath();
@@ -723,7 +762,7 @@ class Fish {
723
  const bx = rootX - L * (0.25 + 0.65 * spd) * t;
724
  const by = rootY + side * L * (0.42 + 0.3 * ev) * t * t;
725
  const amp = s * 0.17 * t * (0.35 + 0.65 * spd);
726
- const off = Math.sin(t * 5.5 - this.phase * 1.4 + this.whiskPh) * amp;
727
  c.lineTo(bx, by + off);
728
  }
729
  c.stroke();
@@ -733,13 +772,13 @@ class Fish {
733
 
734
  // v26:
735
 
736
- // body — undulates while swimming: the ellipse flexes along a traveling wave (v3)
737
  const g = this.bodyGrad; // precomputed in constructor (static colors, local coords)
738
  const NSEG = 26; // outline samples for a smooth curve
739
  // wave offset at any x along the body: rigid at head, full flex at tail.
740
  // Shared with the dorsal fin so it stays glued to the moving back (v4)
741
  const wy = x => Math.sin(x * waveK + this.phase) * waveAmp * ((s - x) / (2 * s));
742
- // pelvic fin � drawn BEFORE the body so it sits behind the fish
743
  if (this.hasPelvicFin) {
744
  try {
745
  c.fillStyle = this.pevFillStyle;
@@ -755,7 +794,7 @@ class Fish {
755
  console.error('Pelvic fin error:', e);
756
  }
757
  }
758
- // // Build the body outline once as a Path2D — reused for base fill + iridescence (v7)
759
  const bodyPath = new Path2D();
760
  for (let i = 0; i <= NSEG; i++) { // top edge: head (+s) -> tail (-s)
761
  const x = s - (i / NSEG) * 2 * s;
@@ -772,7 +811,7 @@ class Fish {
772
  c.fillStyle = g;
773
  c.fill(bodyPath);
774
 
775
- // v15: stripes first when both are present — bars form the base pattern,
776
  // spots scatter on top. Each is clipped to the body so nothing spills, and both
777
  // ride the traveling wave (wy) so they flex with the undulating body, drawn
778
  // BEFORE the additive sheen so the iridescent shimmer still plays across them.
@@ -786,19 +825,19 @@ class Fish {
786
  const H = s * 1.35; // vertical span (clipped by the body outline)
787
  c.fillStyle = st.style;
788
  c.beginPath();
789
- // left edge, top → bottom: bend the centerline + taper the width with t
790
  for (let j = 0; j <= N; j++) {
791
  const t = j / N;
792
- const cx = sx + Math.sin(t * st.cFreq * Math.PI + st.cPh) * st.cAmp * s;
793
- const hwL = hw0 * (1 + st.tAmp * Math.sin(t * st.tFreq * Math.PI + st.tPh));
794
  const xL = cx - hwL, yL = wy(xL) + (t - 0.5) * H;
795
  if (j === 0) c.moveTo(xL, yL); else c.lineTo(xL, yL);
796
  }
797
- // right edge, bottom → top
798
  for (let j = N; j >= 0; j--) {
799
  const t = j / N;
800
- const cx = sx + Math.sin(t * st.cFreq * Math.PI + st.cPh) * st.cAmp * s;
801
- const hwR = hw0 * (1 + st.tAmp * Math.sin(t * st.tFreq * Math.PI + st.tPh));
802
  const xR = cx + hwR, yR = wy(xR) + (t - 0.5) * H;
803
  c.lineTo(xR, yR);
804
  }
@@ -808,7 +847,7 @@ class Fish {
808
  c.restore();
809
  }
810
 
811
- // v18: koi patches — large irregular blobs clipped to the body outline, riding the
812
  // traveling wave (wy) so they flex with the undulating body, drawn BEFORE the
813
  // additive sheen so the iridescent shimmer still plays across them.
814
  if (this.koiPatches) {
@@ -824,7 +863,7 @@ class Fish {
824
  c.restore();
825
  }
826
 
827
- // v13: random spots on ~20% of fish (or over the stripes for the 10% mixed) —
828
  // clipped to the body so they never spill, riding the traveling wave (wy) so they
829
  // flex with the undulating body, and drawn BEFORE the additive sheen so the
830
  // iridescent shimmer still plays across them.
@@ -841,12 +880,12 @@ class Fish {
841
  c.restore();
842
  }
843
 
844
- // v7: efficient iridescence — a sheen whose hue shimmers with the swim phase,
845
  // so scales appear to catch the light while moving. One gradient + one fill.
846
  const shimmer = Math.sin(this.phase * 1.6) * 0.5 + 0.5; // 0..1 as it swims
847
  const iridHue = this.hue + 40 * Math.sin(this.phase * 2.3); // hue drifts with phase
848
  // PERF: cache the sheen gradient. Its inputs move smoothly, so quantize them to a
849
- // tiny grid (1/31 shimmer steps, 1° hue) and reuse until the cell changes — this
850
  // skips one createLinearGradient + 3 addColorStop calls on most frames.
851
  const igKey = Math.round(shimmer * 31) * 512 + (Math.round(iridHue) + 64);
852
  if (igKey !== this._igKey || !this._igGrad) {
@@ -869,11 +908,11 @@ class Fish {
869
  c.ellipse(0, s * 0.14, s * 0.82, s * 0.24, 0, 0, Math.PI * 2);
870
  c.fill();
871
 
872
- // dorsal fin — rides the body wave at its attachment points so it
873
  // never disconnects as the back undulates (v4)
874
- // v17: matches the fish's own color instead of a fixed blue — silver fish no longer
875
  // sprout mismatched blue fins
876
- // v25: as the fish turns to swim vertically (ev→1), the dorsal fin slides from
877
  // "on top of the fish" to "in the middle" (centerline), matching the top-down view
878
  c.fillStyle = this.dorsalFillStyle;
879
  c.beginPath();
@@ -885,7 +924,7 @@ class Fish {
885
  c.closePath();
886
  c.fill();
887
 
888
- // v24: both eyes — as the fish turns to swim vertically (ev→1) we see them from
889
  // above: the pair spreads wider on either side of the head and each eye squashes
890
  // into a flatter ellipse (perspective), instead of two overlapping circles.
891
  {
@@ -901,10 +940,10 @@ class Fish {
901
  c.ellipse(s * 0.61, yNear, s * 0.055, s * pupRY, 0, 0, Math.PI * 2);
902
  c.fill();
903
 
904
- // far-side eye fades in as we see more of the back (Ecco-style top view) —
905
  // two eyes flanking the head when the fish turns to swim vertically.
906
  if (ev > 0.03) {
907
- const yFar = s * (0.16 + 0.08 * ev); // far eye slides outward too — pair widens
908
  c.save();
909
  // Ease in the far-side eye so it takes longer to appear (25% slower fade-in)
910
  c.globalAlpha = Math.pow(ev, 1.25) * 0.9;
@@ -920,7 +959,7 @@ class Fish {
920
  }
921
  }
922
 
923
- // v21: near-side whisker — drawn AFTER the body so it sits in front of the fish.
924
  // (The far-side whisker is drawn before the body, above.) Together they form a
925
  // pair from the snout on ~10% of fish, in one of three lengths, with loose ribbons
926
  // flowing in the current and a traveling wave rippling down their length. Drawn at
@@ -936,7 +975,7 @@ class Fish {
936
  {
937
  const side = 1; // near side (in front of the body)
938
  const rootX = s * 0.6;
939
- // v24: same spread/flare as the far whisker — a pair flanking the snout from above
940
  const rootY = side * s * (0.09 + 0.07 * ev);
941
  c.beginPath();
942
  c.moveTo(rootX, rootY);
@@ -945,7 +984,7 @@ class Fish {
945
  const bx = rootX - L * (0.25 + 0.65 * spd) * t;
946
  const by = rootY + side * L * (0.42 + 0.3 * ev) * t * t;
947
  const amp = s * 0.17 * t * (0.35 + 0.65 * spd);
948
- const off = Math.sin(t * 5.5 - this.phase * 1.4 + this.whiskPh) * amp;
949
  c.lineTo(bx, by + off);
950
  }
951
  c.stroke();
@@ -955,7 +994,7 @@ class Fish {
955
 
956
  c.restore();
957
 
958
- // v17/v23: overhead light — soft elliptical highlight on the fish's upper edge.
959
  // Drawn in local coords (draw() already positioned + rotated us) so it rides with
960
  // the body and foreshortens correctly instead of being double-offset.
961
  {
@@ -976,7 +1015,7 @@ class Fish {
976
  }
977
  }
978
 
979
- // v7: helper — stroke a smooth line through the given points
980
  function strokeArc(c, pts) {
981
  c.beginPath();
982
  c.moveTo(pts[0][0], pts[0][1]);
@@ -984,7 +1023,7 @@ function strokeArc(c, pts) {
984
  c.stroke();
985
  }
986
 
987
- // v19: each bubble gets a subtle random tint — pure white up to a soft grey-blue
988
  function bubbleColor() {
989
  const t = Math.random() * 0.85; // keep it subtle, most stay near-white
990
  const r = Math.round(255 - (255 - 196) * t);
@@ -1018,7 +1057,7 @@ class Bubble {
1018
  }
1019
  update(dt, t) {
1020
  this.y -= this.speed * dt;
1021
- this.x += Math.sin(t * 1.6 + this.p) * 22 * dt;
1022
  if (this.y < -12) { if (this.isMouth) this.dead = true; else this.reset(false); }
1023
  }
1024
  draw(c) {
@@ -1055,11 +1094,11 @@ function buildScene() {
1055
  rocks = [];
1056
  sandHeights = [];
1057
  sandParticles = []; // v22: fresh bed, no leftover grains
1058
- raySpritesBuiltFor = null; // v23: beam sprites are sized to the old canvas — rebuild them
1059
  grass = []; // v10.1: clear on rebuild so resize/F11 doesn't stack a second row
1060
  fgGrass = []; // v12: same reset for the foreground layer (resize/F11 safety)
1061
 
1062
- // ALT: rolling hills instead of a flat bed — three octaves of random sine waves
1063
  // (broad swells + mid undulations + fine ripples), re-rolled on every refresh.
1064
  const sandTop = H - Math.max(60, H * 0.12);
1065
  const hillUp = rand(34, 58); // tallest crest rises this far above the baseline
@@ -1075,13 +1114,13 @@ function buildScene() {
1075
  for (const wv of waves) v += Math.sin((x / wv.wl) * Math.PI * 2 + wv.ph) * wv.amp;
1076
  raw.push(v);
1077
  }
1078
- // Rescale so the extremes land exactly at the chosen limits — full relief, no clamping
1079
  let lo = Infinity, hi = -Infinity;
1080
  for (const v of raw) { if (v < lo) lo = v; if (v > hi) hi = v; }
1081
  const span = Math.max(0.001, hi - lo);
1082
  sandHeights.length = 0;
1083
  for (const v of raw) {
1084
- const off = -hillUp + ((hi - v) / span) * (hillUp + hillDown); // crest → -hillUp, dip → +hillDown
1085
  sandHeights.push(sandTop + off);
1086
  }
1087
 
@@ -1102,17 +1141,17 @@ function buildScene() {
1102
  hue,
1103
  width: rand(4, 8),
1104
  touch: 0,
1105
- // v15: organic rest curve — each stalk leans and waves like real seaweed
1106
  // instead of standing perfectly straight. Also fed into the physics (updatePlants)
1107
- // so fish push the stalk where it actually looks. Weighted by f → root stays planted.
1108
  cA1: rand(5, 12), cF1: rand(0.9, 1.8), cP1: rand(0, Math.PI * 2),
1109
  cA2: rand(2, 6), cF2: rand(2.2, 3.6), cP2: rand(0, Math.PI * 2),
1110
  // v17: secondary wobble - a finer inner shimmer for unique motion per stalk
1111
  sAmp: rand(2, 5), // amplitude of the secondary wave
1112
  sFreq: rand(3.5, 6.5), // higher frequency than primary sway
1113
  sPh: rand(0, Math.PI * 2), // phase offset
1114
- tipW: rand(1.4, 2.6), // v15: blade tapers from width → tipW up the stipe
1115
- // PERF: stroke styles precomputed once (hue is fixed per stalk) — drawSeaweed
1116
  // used to rebuild both template strings every frame for every stalk.
1117
  stalkStyle: `hsla(${hue},55%,36%,0.92)`,
1118
  branchStyle: `hsla(${hue + 8},60%,46%,0.9)`,
@@ -1120,7 +1159,7 @@ function buildScene() {
1120
  off: new Float32Array(9), // lateral bend per segment (fish push leaves aside)
1121
  offV: new Float32Array(9), // velocity of that bend
1122
  branches: [],
1123
- // PERF: stalk points preallocated once — updatePlants reuses these objects
1124
  // in place instead of pushing fresh {x,y} pairs every frame (GC churn)
1125
  points: Array.from({ length: 9 }, () => ({ x: 0, y: 0 })) // segs+1; can't read st inside its own initializer
1126
  };
@@ -1136,13 +1175,13 @@ function buildScene() {
1136
  flowers: Math.random() < 0.3 ? (() => { // ~30% of branches bloom with 1-3 heads (v3)
1137
  const n = 1 + ((Math.random() * 3) | 0); // 1..3 flowers per branch
1138
  return Array.from({ length: n }, () => {
1139
- const hue = (Math.random() * 360) | 0; // colourful — full spectrum
1140
  return {
1141
  t: rand(0.45, 1), // where along the branch it sits (tip zone)
1142
  hue,
1143
  r: rand(4, 7), // head size
1144
  petals: 5 + ((Math.random() * 3) | 0), // 5..7 petals
1145
- // PERF: paint styles precomputed once — drawFlowerHead used to rebuild
1146
  // these strings per petal, per frame.
1147
  petalFill: `hsla(${hue},85%,64%,0.95)`,
1148
  centerFill: `hsla(${(hue + 45) % 360},92%,78%,0.95)`
@@ -1155,7 +1194,7 @@ function buildScene() {
1155
  }
1156
  }
1157
 
1158
- // v4: blurry background seaweed — taller, bluer, behind everything else.
1159
  // Pre-rendered to offscreen canvases with blur at build time (no runtime cost).
1160
  const nBackClusters = Math.max(4, Math.floor(W / 200));
1161
  for (let i = 0; i < nBackClusters; i++) {
@@ -1164,7 +1203,7 @@ function buildScene() {
1164
  const nStalks = 2 + ((Math.random() * 3) | 0); // 2..4 stalks per bundle
1165
  for (let j = 0; j < nStalks; j++) {
1166
  const hue = clusterHue + rand(-15, 15);
1167
- // v4.3: depth split — ~51% "far" (heavy softness), ~49% "near" (sharper) for a layered look
1168
  const near = Math.random() < 0.49;
1169
  const w = near ? rand(2.5, 5) : rand(3, 6);
1170
  const tw = near ? rand(1.2, 2.4) : rand(1.5, 3);
@@ -1264,7 +1303,7 @@ function buildScene() {
1264
  }
1265
  }
1266
 
1267
- // v5: ultra-far pre-rendered blurry seaweed — deepest layer, heavily blurred sprites
1268
  const nFarClusters = Math.max(6, Math.floor(W / 180)); // 50% more than previous far count
1269
  for (let i = 0; i < nFarClusters; i++) {
1270
  const cx = rand(20, W - 20);
@@ -1307,7 +1346,7 @@ function buildScene() {
1307
  const nGrassTufts = Math.max(50, Math.floor(W / 9));
1308
  for (let i = 0; i < nGrassTufts; i++) {
1309
  const gx = rand(8, W - 8);
1310
- 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
1311
  const baseHue = rand(95, 155);
1312
  const blades = 2 + ((Math.random() * 4) | 0); // 2..5 blades per tuft
1313
  for (let b = 0; b < blades; b++) {
@@ -1315,7 +1354,7 @@ function buildScene() {
1315
  grass.push({
1316
  x: gx + rand(-7, 7),
1317
  y: gy,
1318
- h: rand(12, 85), // v4: more height variability — some short tufts, some noticeably tall
1319
  lean: rand(-0.35, 0.35), // base tilt from vertical
1320
  phase: rand(0, Math.PI * 2),
1321
  speed: rand(1.3, 2.8), // quicker, finer sway than seaweed
@@ -1331,14 +1370,15 @@ function buildScene() {
1331
  sAmp: rand(1.5, 3.5),
1332
  sFreq: rand(4.5, 8),
1333
  sPh: rand(0, Math.PI * 2),
1334
- // PERF: per-blade scratch points — drawGrass reuses them instead of
1335
  // allocating a fresh pts array + point objects every frame (GC churn)
1336
- pts: Array.from({ length: bladeSegs + 1 }, () => ({ x: 0, y: 0 }))
1337
- });
 
1338
  }
1339
  }
1340
 
1341
- // v12: foreground grass — planted on the FRONT glass (closest to the viewer), so it is
1342
  // drawn last and foreshortened. It grows in the low band below the sand line, i.e. the
1343
  // zone fish never reach, and sways with the water like the other flora but has no
1344
  // fish-push physics (purely decorative). ALT-FG: all sizes halved vs the original fg.
@@ -1347,7 +1387,7 @@ function buildScene() {
1347
  for (let i = 0; i < nFgClusters; i++) {
1348
  const cx = rand(-10, W + 10);
1349
  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
1350
- const baseHue = rand(46, 76); // v12.1: yellowish-green — pops against the mid-ground green grass
1351
  const nBlades = 5 + ((Math.random() * 4) | 0); // 5..8 blades per clump (doubled density)
1352
  for (let b = 0; b < nBlades; b++) {
1353
  const fgSegs = 4 + ((Math.random() * 2) | 0); // 4-5 segments for tapered outline
@@ -1369,18 +1409,19 @@ function buildScene() {
1369
  sAmp: rand(2, 4.5),
1370
  sFreq: rand(3.8, 6.5),
1371
  sPh: rand(0, Math.PI * 2),
1372
- // PERF: per-blade scratch points — drawFgGrass reuses them in place
1373
  pts: Array.from({ length: fgSegs + 1 }, () => ({ x: 0, y: 0 })),
1374
  // v28: ~40% of blades get a gentle blur (pre-rendered sprite) for depth
1375
  blurred: Math.random() < 0.4,
1376
  sprite: null,
1377
- spriteScale: 1
1378
- });
 
1379
  }
1380
  }
1381
  buildFgGrassSprites(); // v28: bake the defocused-blade sprites once (free at runtime)
1382
 
1383
- // PERF: pool the plantPoints collision objects too — updatePlants reuses them
1384
  // in place instead of allocating one per stalk/branch point every frame.
1385
  let pcap = 8;
1386
  for (const w of seaweeds) pcap += (w.segs + 1) + w.branches.length * 2;
@@ -1390,7 +1431,7 @@ function buildScene() {
1390
  for (let i = 0; i < nFish; i++) fishes.push(new Fish());
1391
 
1392
  const nAmbient = Math.round(26 * 0.75); // v10: ambient bubbles reduced 25% (fish production unchanged)
1393
- for (let i = 0; i < nAmbient; i++) bubbles.push(new Bubble(true));
1394
 
1395
  for (let i = 0; i < 45; i++) {
1396
  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) });
@@ -1403,7 +1444,7 @@ function buildScene() {
1403
  rays.push({
1404
  x: rand(0, W), w: rand(80, 210), tilt: rand(-0.35, 0.35),
1405
  speed: rand(0.06, 0.14) * (Math.random() < 0.5 ? 1 : -1), alpha: rand(0.04, 0.09), blur,
1406
- // undulation — two traveling waves so each beam shimmers like light refracted through moving water
1407
  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),
1408
  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)
1409
  });
@@ -1412,7 +1453,7 @@ function buildScene() {
1412
  const nRocks = Math.max(3, Math.floor(W / 320));
1413
  for (let i = 0; i < nRocks; i++) {
1414
  const r = rand(14, 34);
1415
- // jagged outline — a ring of uneven points so rocks read as craggy stones, not domes
1416
  const nPts = 8 + ((Math.random() * 5) | 0); // 8..12 vertices
1417
  const pts = [];
1418
  for (let k = 0; k < nPts; k++) {
@@ -1477,7 +1518,7 @@ function nightFactor(t) {
1477
 
1478
  // Per-fish night curve (v10): shifts each fish's dusk/dawn fades by its own early/normal/late
1479
  // personality so they don't all sleep or wake in unison. Full day and full night are shared
1480
- // by everyone — only the transitions differ.
1481
  function personalNight(t, f) {
1482
  const p = ((t / CYCLE) % 1 + 1) % 1;
1483
  if (p < DUSK_START) return 0; // bright day: all awake
@@ -1509,16 +1550,16 @@ function drawBackground(light) {
1509
  ctx.fillRect(0, 0, W, H);
1510
  }
1511
 
1512
- // ============ Light rays — pre-blurred sprites (v23 fast path) ============
1513
  // The old version applied a canvas blur() filter to every beam on every frame;
1514
  // canvas filters force slow CPU rasterization over large regions, which tanked FPS.
1515
  // Now each beam's shape is rendered ONCE into its own offscreen sprite (blur + the
1516
  // vertical fade baked in at build time), and per frame we just blit that sprite in
1517
- // horizontal slices with the same undulation offsets as before — wobble + soft
1518
  // edges, zero filters at runtime.
1519
  const RAY_SEGS = 18; // vertical resolution of each beam's spine (at build time)
1520
- const RAY_SLICES = 10; // horizontal slices per beam used for the wobble (per frame) — reduced from 24
1521
- const RAY_SPRITE_SCALE = 0.5; // sprites stored at half resolution → softer + cheaper
1522
  const RAY_PAD = 60; // room for the blur to spill past the beam edge
1523
  let raySpritesBuiltFor = null;
1524
  let rayTintRGB = null; // the light.ray color used when building tinted sprites (perf: rebuild only on big shifts)
@@ -1554,7 +1595,7 @@ function buildRaySprites() {
1554
  const full = document.createElement("canvas");
1555
  full.width = bw; full.height = bh;
1556
  const fc = full.getContext("2d");
1557
- fc.translate(-xMinL + RAY_PAD, -yTop0 + RAY_PAD); // local beam coords → scratch px
1558
  if (r.blur > 0) { try { fc.filter = `blur(${r.blur}px)`; } catch (e) {} }
1559
 
1560
  const poly = (a, b) => {
@@ -1612,7 +1653,7 @@ function buildRaySprites() {
1612
  }
1613
 
1614
  // Cheaply re-tint the cached blurred sprites with the current light.ray color.
1615
- // No blur / no shape rasterization — just a source-in fill over the stored beam,
1616
  // so this stays fast even during slow dusk/dawn transitions.
1617
  function applyRayTint() {
1618
  for (const r of rays) {
@@ -1641,7 +1682,7 @@ function drawRays(t, light) {
1641
  const yTop0 = -10, ySpan = H * 0.95 + 10;
1642
 
1643
  // PERF: re-tint the cached blurred sprites only when the light color shifts enough.
1644
- // Re-tinting is cheap (a source-in fill) — no blur re-rasterization — so this runs
1645
  // smoothly even during slow dusk/dawn transitions instead of hitching on rebuilds.
1646
  if (!rayTintRGB || Math.abs(rayTintRGB[0] - rr) + Math.abs(rayTintRGB[1] - rg) + Math.abs(rayTintRGB[2] - rb) > 35) {
1647
  rayTintRGB = [rr, rg, rb];
@@ -1649,9 +1690,9 @@ function drawRays(t, light) {
1649
  }
1650
 
1651
  for (const r of rays) {
1652
- // blit horizontal slices with the undulation offset at each depth — same wave
1653
  // math as before, so the beams shimmer exactly like they always have
1654
- const off = Math.sin(t * r.speed * 6 + r.x) * 55; // slow drift of the whole beam
1655
  for (let i = 0; i < RAY_SLICES; i++) {
1656
  const vT = i / RAY_SLICES, vB = (i + 1) / RAY_SLICES, vM = (vT + vB) / 2;
1657
  const ya = yTop0 + vT * ySpan, yb = yTop0 + vB * ySpan;
@@ -1695,7 +1736,7 @@ function drawSand(c = ctx) {
1695
  function drawRocks(c = ctx) {
1696
  for (const rk of rocks) {
1697
  const y = sandAt(rk.x) + rk.r * 0.35;
1698
- // fully opaque — tone now varies lightness instead of alpha
1699
  const base = Math.round(28 + rk.tone * 46);
1700
  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);
1701
  g.addColorStop(0, `rgb(${base + 68},${base + 78},${base + 92})`);
@@ -1719,17 +1760,17 @@ function stalkCurveX(w, f) {
1719
 
1720
  // PERF: shared sway function (was a per-stalk closure recreated every frame)
1721
  function stalkSwayX(w, t, f) {
1722
- return Math.sin(t * w.waveF1 + w.wavePh1 + f * 2.4) * w.waveA1 * f * f +
1723
- Math.sin(t * w.waveF2 + w.wavePh2 + f * 3.8) * w.waveA2 * f * f +
1724
- Math.sin(t * w.waveF3 + w.wavePh3 + f * 5.4) * w.waveA3 * f;
1725
  }
1726
 
1727
- // v4.3: draw background seaweed procedurally — multi-wave organic motion instead of
1728
  // rigid sprite rotation. Far stalks get a soft low-alpha halo pass to keep their
1729
  // "far away" look without any runtime blur filters (no slowdown).
1730
  // v6: middle-distance seaweed using halo stroke pass (median depth between back and far)
1731
  function drawMidSeaweeds(t) {
1732
- // PERF: lineCap/lineJoin are constant — set once instead of per stalk per pass
1733
  ctx.lineCap = 'round';
1734
  ctx.lineJoin = 'round';
1735
  for (const w of midSeaweeds) {
@@ -1764,7 +1805,7 @@ function drawMidSeaweeds(t) {
1764
  else ctx.lineTo(px + nx * hw, py + ny * hw);
1765
  prevX = px; prevY = py;
1766
  }
1767
- // down the other side (cached points; i=n no-op point dropped — degenerate in original)
1768
  for (let i = n - 1; i >= 0; i--) {
1769
  const f = i / n;
1770
  const px = pxs[i], py = pys[i];
@@ -1781,7 +1822,7 @@ function drawMidSeaweeds(t) {
1781
  for (const b of w.branches) {
1782
  const idx = Math.min(n, Math.max(1, Math.round(b.f * n)));
1783
  const ax = pxs[idx], ay = pys[idx];
1784
- const flutter = Math.sin(t * w.waveF2 * 1.4 + b.ph) * (3 + w.waveA2 * 0.4);
1785
  const bx = ax + b.side * b.len + flutter;
1786
  const by = ay - b.up;
1787
  ctx.beginPath();
@@ -1800,7 +1841,7 @@ function drawMidSeaweeds(t) {
1800
  }
1801
 
1802
  function drawBackSeaweeds(t) {
1803
- // v6: no runtime canvas filter — blur() forces a CPU re-rasterize every frame
1804
  // and was the main FPS cost. Softness now comes from the halo pass below instead.
1805
  ctx.lineCap = 'round';
1806
  ctx.lineJoin = 'round';
@@ -1836,7 +1877,7 @@ function drawBackSeaweeds(t) {
1836
  else ctx.lineTo(px + nx * hw, py + ny * hw);
1837
  prevX = px; prevY = py;
1838
  }
1839
- // down the other side (cached points; i=n no-op point dropped — degenerate in original)
1840
  for (let i = n - 1; i >= 0; i--) {
1841
  const f = i / n;
1842
  const px = pxs[i], py = pys[i];
@@ -1853,7 +1894,7 @@ function drawBackSeaweeds(t) {
1853
  for (const b of w.branches) {
1854
  const idx = Math.min(n, Math.max(1, Math.round(b.f * n)));
1855
  const ax = pxs[idx], ay = pys[idx];
1856
- const flutter = Math.sin(t * w.waveF2 * 1.4 + b.ph) * (3 + w.waveA2 * 0.4);
1857
  const bx = ax + b.side * b.len + flutter;
1858
  const by = ay - b.up;
1859
  ctx.beginPath();
@@ -1873,7 +1914,7 @@ function drawBackSeaweeds(t) {
1873
 
1874
  // v5: pre-render ultra-far seaweed sprites with heavy blur baked in at build time
1875
  function buildFarSeaweedSprites() {
1876
- const BLUR = 7; // v5.1: reduced from 10 (33% less) — slightly closer appearance
1877
  const PAD = 40; // extra space for blur spill
1878
 
1879
  for (const w of farSeaweeds) {
@@ -1965,7 +2006,7 @@ function buildFarSeaweedSprites() {
1965
 
1966
  // v28: pre-render blurred foreground grass sprites for ~40% of blades (gentle depth effect)
1967
  function buildFgGrassSprites() {
1968
- const BLUR = 1.5; // very gentle blur — just enough to suggest "slightly out of focus"
1969
  const PAD = 8; // extra space for blur spill
1970
 
1971
  for (const g of fgGrass) {
@@ -2044,7 +2085,7 @@ function drawFarSeaweeds(t) {
2044
 
2045
  const baseY = sandAt(w.x) + 10;
2046
  // Simple rotational sway - root stays planted, sprite rotates slightly
2047
- const swayAngle = Math.sin(t * w.speed + w.phase) * 0.04; // very subtle rotation
2048
 
2049
  ctx.save();
2050
  // Translate to the base point (where the stalk meets the sand)
@@ -2070,7 +2111,7 @@ function updatePlants(t, dt) {
2070
  const moveF = Math.min(1, fSpd / 1.2); // v9: how actively this fish is swimming
2071
  for (let i = 1; i <= w.segs; i++) {
2072
  const fl = i / w.segs;
2073
- const px = w.x + w.off[i] + stalkCurveX(w, fl) + Math.sin(t * w.speed + w.phase + fl * 2.6) * fl * fl * 24;
2074
  const py = sandAt(w.x) + 8 - w.h * fl;
2075
  const dx = px - f.x, dy = py - f.y;
2076
  const d2 = dx * dx + dy * dy;
@@ -2078,7 +2119,7 @@ function updatePlants(t, dt) {
2078
  const d = Math.sqrt(d2);
2079
  const weight = (R - d) / R * (0.35 + 0.65 * fl); // upper segments yield more
2080
  // shove radially away from the fish + drag along its swim direction.
2081
- // v9: a nearly-still fish only gently parts leaves — no shoving, no flinch wobble
2082
  w.offV[i] += ((dx / d) * 1500 * (0.25 + 0.75 * moveF) + fx * 9) * weight * dt;
2083
  if (weight > 0.25 && moveF > 0.35) w.touch = Math.min(1, w.touch + dt * 5);
2084
  }
@@ -2107,15 +2148,15 @@ function updatePlants(t, dt) {
2107
 
2108
  if (w.touch > 0) w.touch = Math.max(0, w.touch - dt * 1.2);
2109
  const baseY = sandAt(w.x) + 8;
2110
- w.yMid = baseY - w.h * 0.5; // v6: stalk midpoint — the night-time gathering point
2111
  w.points.length = 0;
2112
  for (let i = 0; i <= w.segs; i++) {
2113
  const f = i / w.segs;
2114
  // normal sway + a fast extra flinch while being pushed
2115
- const sway = Math.sin(t * w.speed + w.phase + f * 2.6) * f * f * 24
2116
- + Math.sin(t * 7 + w.phase) * w.touch * f * 16;
2117
  // v17: secondary wobble - a finer inner movement for unique per-stalk motion
2118
- const sWobble = Math.sin(t * w.sFreq + w.sPh + f * 4.2) * w.sAmp * f;
2119
  const px = w.x + (w.off[i] || 0) + stalkCurveX(w, f) + sway + sWobble;
2120
  const py = baseY - w.h * f;
2121
  w.points.push({ x: px, y: py });
@@ -2123,7 +2164,7 @@ function updatePlants(t, dt) {
2123
  }
2124
  for (const b of w.branches) {
2125
  const a = w.points[Math.min(w.segs, Math.max(1, Math.round(b.f * w.segs)))];
2126
- const flutter = Math.sin(t * w.speed * 1.4 + b.ph) * (3 + w.touch * 9);
2127
  b.tx = a.x + b.side * b.len + flutter;
2128
  b.ty = a.y - b.up;
2129
  plantPoints.push({ x: b.tx, y: b.ty, r: 5, s: w });
@@ -2132,8 +2173,8 @@ function updatePlants(t, dt) {
2132
  }
2133
  }
2134
 
2135
- // Grass blades ripple when fish swim close — stronger the lower the fish is.
2136
- // v4: more reactive to fish contact — wider influence radius, faster ripple build,
2137
  // and slightly slower decay so the wave lingers a touch longer after a fish passes.
2138
  function updateGrass(t, dt) {
2139
  const night = nightFactor(t); // v6: after dark the fish drift toward the plants
@@ -2157,7 +2198,7 @@ function updateGrass(t, dt) {
2157
  function drawSeaweed() {
2158
  for (const w of seaweeds) {
2159
  const pts = w.points, n = pts.length;
2160
- // v15: main stipe as a tapered leaf-blade — an outline around the same physics
2161
  // points, wide at the root and thinning to a soft point up top. No straight-line
2162
  // segments anywhere; the stalk's own curve comes from the sway + rest-curve baked
2163
  // into those points in updatePlants.
@@ -2167,17 +2208,17 @@ function drawSeaweed() {
2167
  const p = pts[i];
2168
  const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
2169
  let dx = b.x - a.x, dy = b.y - a.y;
2170
- const dl = Math.hypot(dx, dy) || 1;
2171
  const nx = -dy / dl, ny = dx / dl; // perpendicular to the local stalk direction
2172
  const f = i / (n - 1);
2173
- const hw = (w.width * (1 - f) + w.tipW * f) / 2; // half-width: base → tip taper
2174
  if (i === 0) ctx.moveTo(p.x + nx * hw, p.y + ny * hw);
2175
  else ctx.lineTo(p.x + nx * hw, p.y + ny * hw);
2176
  }
2177
  { // rounded cap at the tip
2178
  const tp = pts[n - 1], pp = pts[n - 2];
2179
  let dx = tp.x - pp.x, dy = tp.y - pp.y;
2180
- const dl = Math.hypot(dx, dy) || 1;
2181
  const nx = -dy / dl, ny = dx / dl;
2182
  const hwT = w.tipW / 2;
2183
  ctx.quadraticCurveTo(tp.x + dx, tp.y + dy * 0.6, tp.x - nx * hwT, tp.y - ny * hwT);
@@ -2186,7 +2227,7 @@ function drawSeaweed() {
2186
  const p = pts[i];
2187
  const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
2188
  let dx = b.x - a.x, dy = b.y - a.y;
2189
- const dl = Math.hypot(dx, dy) || 1;
2190
  const nx = -dy / dl, ny = dx / dl;
2191
  const f = i / (n - 1);
2192
  const hw = (w.width * (1 - f) + w.tipW * f) / 2;
@@ -2207,7 +2248,7 @@ function drawSeaweed() {
2207
  // side branches curling up and out from the stalk
2208
  for (const b of w.branches) {
2209
  const a = w.points[Math.min(w.segs, Math.max(1, Math.round(b.f * w.segs)))];
2210
- // v15: S-curved frond — leaves the stalk heading upward, then arcs out and up
2211
  // to the tip instead of one plain quadratic bow (same endpoints as before)
2212
  const c1x = a.x + b.side * b.len * 0.3;
2213
  const c1y = a.y - b.up * 0.75;
@@ -2220,7 +2261,7 @@ function drawSeaweed() {
2220
  ctx.bezierCurveTo(c1x, c1y, c2x, c2y, b.tx, b.ty);
2221
  ctx.stroke();
2222
 
2223
- // colourful flower heads blooming along the branch — 1..3 per branch (v3)
2224
  if (b.flowers.length) {
2225
  for (const fl of b.flowers) {
2226
  const u = fl.t, iu = 1 - u; // point on the branch's cubic curve (v15)
@@ -2253,7 +2294,7 @@ function drawFlowerHead(x, y, fl) {
2253
 
2254
  function drawGrass(t) {
2255
  for (const g of grass) {
2256
- // v16: tapered multi-segment blade — wide at root, thinning to a soft point
2257
  const amp = 3 + g.ripple * 13;
2258
  const n = g.segs + 1; // number of points along the blade
2259
  const pts = [];
@@ -2262,25 +2303,25 @@ function drawGrass(t) {
2262
  // organic rest-curve: each blade has its own natural bend (like seaweed)
2263
  const curveX = f * g.cA1 * Math.sin(f * g.cF1 + g.cP1);
2264
  // constant sway, amplified while rippling from a passing fish
2265
- const sway = Math.sin(t * g.speed + g.phase + f * 2.4) * amp * f;
2266
  // v17: secondary wobble - finer inner movement for unique per-blade motion
2267
- const sWobble = Math.sin(t * g.sFreq + g.sPh + f * 3.8) * g.sAmp * f;
2268
  pts.push({
2269
  x: g.x + g.lean * g.h * f + curveX + sway + sWobble,
2270
  y: g.y - g.h * f * (1 - g.ripple * 0.06)
2271
  });
2272
  }
2273
  // draw tapered outline around the physics points
2274
- ctx.fillStyle = `hsla(${g.hue},58%,40%,0.92)`;
2275
  ctx.beginPath();
2276
  for (let i = 0; i < n; i++) {
2277
  const p = pts[i];
2278
  const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
2279
  let dx = b.x - a.x, dy = b.y - a.y;
2280
- const dl = Math.hypot(dx, dy) || 1;
2281
  const nx = -dy / dl, ny = dx / dl; // perpendicular to local blade direction
2282
  const f = i / (n - 1);
2283
- const hw = (g.width * (1 - f) + 0.8 * f) / 2; // half-width: base → tip taper
2284
  if (i === 0) ctx.moveTo(p.x + nx * hw, p.y + ny * hw);
2285
  else ctx.lineTo(p.x + nx * hw, p.y + ny * hw);
2286
  }
@@ -2288,7 +2329,7 @@ function drawGrass(t) {
2288
  {
2289
  const tp = pts[n - 1], pp = pts[n - 2];
2290
  let dx = tp.x - pp.x, dy = tp.y - pp.y;
2291
- const dl = Math.hypot(dx, dy) || 1;
2292
  const nx = -dy / dl, ny = dx / dl;
2293
  const hwT = 0.4; // tip half-width
2294
  ctx.quadraticCurveTo(tp.x + dx, tp.y + dy * 0.6, tp.x - nx * hwT, tp.y - ny * hwT);
@@ -2297,7 +2338,7 @@ function drawGrass(t) {
2297
  const p = pts[i];
2298
  const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
2299
  let dx = b.x - a.x, dy = b.y - a.y;
2300
- const dl = Math.hypot(dx, dy) || 1;
2301
  const nx = -dy / dl, ny = dx / dl;
2302
  const f = i / (n - 1);
2303
  const hw = (g.width * (1 - f) + 0.8 * f) / 2;
@@ -2308,16 +2349,16 @@ function drawGrass(t) {
2308
  }
2309
  }
2310
 
2311
- // v12: foreground grass sways with the water — decorative only, no physics.
2312
  // Drawn on top of everything else (it is closest to the viewer). Each blade gets a
2313
  // thin lighter inner stroke over a darker body for a lush, voluminous "closer" look.
2314
  function drawFgGrass(t) {
2315
  for (const g of fgGrass) {
2316
- // v28: defocused blades — blit the pre-blurred sprite with a gentle rotational sway
2317
  // around its base (same trick as the ultra-far seaweed). The baked blur hides the
2318
  // simplified motion, and this path is far cheaper than per-frame canvas filters.
2319
  if (g.sprite) {
2320
- const swayAngle = Math.sin(t * g.speed + g.phase) * 0.05; // subtle: like farSeaweeds
2321
  ctx.save();
2322
  ctx.translate(g.x, g.y);
2323
  ctx.rotate(swayAngle);
@@ -2334,25 +2375,25 @@ function drawFgGrass(t) {
2334
  // organic rest-curve: each blade has its own natural bend
2335
  const curveX = f * g.cA1 * Math.sin(f * g.cF1 + g.cP1);
2336
  // constant sway, weighted toward the tip
2337
- const sway = Math.sin(t * g.speed + g.phase + f * 2.4) * amp * f;
2338
  // v17: secondary wobble - finer inner movement for unique per-blade motion
2339
- const sWobble = Math.sin(t * g.sFreq + g.sPh + f * 4.5) * g.sAmp * f;
2340
  pts.push({
2341
  x: g.x + g.lean * g.h * f + curveX + sway + sWobble,
2342
  y: g.y - g.h * f
2343
  });
2344
  }
2345
  // dark body of the blade (tapered outline)
2346
- ctx.fillStyle = `hsla(${g.hue},60%,31%,0.96)`;
2347
  ctx.beginPath();
2348
  for (let i = 0; i < n; i++) {
2349
  const p = pts[i];
2350
  const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
2351
  let dx = b.x - a.x, dy = b.y - a.y;
2352
- const dl = Math.hypot(dx, dy) || 1;
2353
  const nx = -dy / dl, ny = dx / dl;
2354
  const f = i / (n - 1);
2355
- const hw = (g.width * (1 - f) + 1.2 * f) / 2; // half-width: base → tip
2356
  if (i === 0) ctx.moveTo(p.x + nx * hw, p.y + ny * hw);
2357
  else ctx.lineTo(p.x + nx * hw, p.y + ny * hw);
2358
  }
@@ -2360,7 +2401,7 @@ function drawFgGrass(t) {
2360
  {
2361
  const tp = pts[n - 1], pp = pts[n - 2];
2362
  let dx = tp.x - pp.x, dy = tp.y - pp.y;
2363
- const dl = Math.hypot(dx, dy) || 1;
2364
  const nx = -dy / dl, ny = dx / dl;
2365
  const hwT = 0.6; // tip half-width
2366
  ctx.quadraticCurveTo(tp.x + dx, tp.y + dy * 0.6, tp.x - nx * hwT, tp.y - ny * hwT);
@@ -2369,7 +2410,7 @@ function drawFgGrass(t) {
2369
  const p = pts[i];
2370
  const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
2371
  let dx = b.x - a.x, dy = b.y - a.y;
2372
- const dl = Math.hypot(dx, dy) || 1;
2373
  const nx = -dy / dl, ny = dx / dl;
2374
  const f = i / (n - 1);
2375
  const hw = (g.width * (1 - f) + 1.2 * f) / 2;
@@ -2379,7 +2420,7 @@ function drawFgGrass(t) {
2379
  ctx.fill();
2380
 
2381
  // lighter inner highlight for depth: a thin centerline stroke
2382
- ctx.strokeStyle = `hsla(${g.hue + 6},64%,45%,0.5)`;
2383
  ctx.lineWidth = g.width * 0.3;
2384
  ctx.lineCap = "round";
2385
  ctx.beginPath();
@@ -2433,7 +2474,7 @@ function buildGrainTile() {
2433
  let grainOx = 0, grainOy = 0, lastGrainTime = 0;
2434
  function drawGrain() {
2435
  if (!grainPattern) return;
2436
- // v17: half-speed grain — re-randomize the offset every other frame instead of every frame
2437
  const now = performance.now();
2438
  if (now - lastGrainTime >= 33) { // ~30fps grain updates instead of per-frame (~60fps)
2439
  grainOx = (Math.random() * GRAIN_TILE) | 0;
@@ -2454,14 +2495,14 @@ function drawGrain() {
2454
  }
2455
  let lastGrainDrawn = false;
2456
 
2457
- // Precomputed static layers — the sand and rocks never move, so they are
2458
  // rendered once to an offscreen canvas and blitted each frame (v6) instead of
2459
  // re-drawing their gradients and paths every single frame.
2460
- // PERF: half-resolution offscreen buffer for the light rays (see drawRays) — the
2461
  // per-ray blur() filter is far cheaper rasterizing small areas than full-screen.
2462
 
2463
 
2464
- // v24: baked current ripples — faint curved lines following the sand contour
2465
  function drawRipples(c) {
2466
  c.save();
2467
  c.lineCap = "round";
@@ -2486,7 +2527,7 @@ function drawRipples(c) {
2486
  c.restore();
2487
  }
2488
 
2489
- // v24: baked pebble/speckle scatter — grainy cool-toned dots across the sand bed
2490
  function drawPebbles(c) {
2491
  const n = Math.floor(W * H / 1800);
2492
  for (let i = 0; i < n; i++) {
@@ -2516,7 +2557,7 @@ function drawPebbles(c) {
2516
  }
2517
  }
2518
 
2519
- // v24: contact shadows — soft dark ellipses under rocks + faint band at sand surface line
2520
  function drawContactShadows(c) {
2521
  // Faint darker band right at the sand surface (grounds the whole bed)
2522
  // FIXED: follows the actual sand contour instead of a fixed horizontal band
@@ -2558,7 +2599,7 @@ function buildStaticLayer() {
2558
  drawRipples(c2); // v24: baked current ripples (free at runtime)
2559
  drawPebbles(c2); // v24: baked grainy pebble/speckle scatter
2560
  drawContactShadows(c2); // v24: soft dark ellipses under rocks + surface band
2561
- // NOTE: rocks are no longer baked here — they live in rocksLayer so they can be
2562
  // drawn AFTER the mid-ground grass (rocks sit IN FRONT of the blades, not behind them).
2563
  }
2564
  // Rocks on their own layer: blitted after drawGrass() so stones read as sitting in
@@ -2577,7 +2618,7 @@ function buildRocksLayer() {
2577
  let causticTile = null;
2578
  let causticOffsetX = 0, causticOffsetY = 0;
2579
  let lastCausticShift = 0;
2580
- const CAUSTIC_TILE_SIZE = 160; // small tile — cheap to blit repeatedly
2581
 
2582
  function buildCausticTile() {
2583
  causticTile = document.createElement("canvas");
@@ -2676,9 +2717,9 @@ function drawLightPooling(t) {
2676
  ctx.globalCompositeOperation = "screen"; // additive light
2677
 
2678
  for (const r of rays) {
2679
- // Compute where this ray hits the sand at y ≈ sandTop
2680
  // Use the same wave math as the ray drawing to match positions
2681
- const off = Math.sin(t * r.speed * 6 + r.x) * 55;
2682
  // At the sand depth (vM near the bottom), compute the x position
2683
  const vM = 0.95; // approximate depth where sand starts
2684
  const wave =
@@ -2744,13 +2785,13 @@ function frame(now) {
2744
  drawRays(t, light);
2745
  updateMotes(dt, t);
2746
 
2747
- // v5: ultra-far pre-rendered blurry seaweed — deepest layer, behind everything else
2748
  drawFarSeaweeds(t);
2749
 
2750
  // v6: middle-distance seaweed with halo pass (between far and back)
2751
  drawMidSeaweeds(t);
2752
 
2753
- // v4: background seaweed — drawn behind sand/rocks for depth
2754
  drawBackSeaweeds(t); // v4.3: procedural multi-wave motion (no separate update pass)
2755
 
2756
  updatePlants(t, dt);
@@ -2764,16 +2805,23 @@ function frame(now) {
2764
  // Build spatial grid for collision detection
2765
  buildGrid();
2766
 
2767
- // v7: fish gently bump each other — overlapping pairs are eased apart along their
2768
  // center line (weighted by size), so they nudge instead of stacking on top of each other
2769
- for (let i = 0; i < fishes.length; i++) {
2770
- const a = fishes[i];
2771
- for (let j = i + 1; j < fishes.length; j++) {
2772
- const b = fishes[j];
 
 
 
 
 
 
 
2773
  let dx = b.x - a.x, dy = b.y - a.y;
2774
  const minD = (a.size + b.size) * 0.62; // gentle: smaller than their visual length
2775
  const d2 = dx * dx + dy * dy;
2776
- if (d2 <= 0.01) { // exactly overlapping — nudge apart randomly
2777
  a.x -= rand(0.5, 1.5); a.y -= rand(0.5, 1.5);
2778
  b.x += rand(0.5, 1.5); b.y += rand(0.5, 1.5);
2779
  } else if (d2 < minD * minD) {
@@ -2787,8 +2835,6 @@ function frame(now) {
2787
  }
2788
  }
2789
  }
2790
-
2791
- // Sort fish by size for depth ordering - only re-sort every 100ms
2792
  if (t - lastSortTime > 0.1) {
2793
  // Sort fish by size for depth ordering - only re-sort every 100ms
2794
  if (t - lastSortTime > 0.1) {
@@ -2826,7 +2872,7 @@ function frame(now) {
2826
  const b = bubbles[i];
2827
  b.update(dt, t);
2828
  if (!b.dead) {
2829
- // v10: a fish brushing a bubble pops it — unless it's that same fish's own bubble
2830
  // Optimized: use spatial grid instead of checking all fish
2831
  const nearby = getFishNear(b.x, b.y);
2832
  for (const f of nearby) {
@@ -2836,11 +2882,11 @@ function frame(now) {
2836
  if (dx * dx + dy * dy < rr * rr) { b.dead = true; break; }
2837
  }
2838
  }
2839
- if (b.dead) { bubbles.splice(i, 1); continue; }
2840
  b.draw(ctx);
2841
  }
2842
 
2843
- drawFgGrass(t); // v12: foreground grass — closest to the viewer, drawn on top
2844
 
2845
  // ambient day/night wash over the whole scene
2846
  if (light.washA > 0.005) {
@@ -2933,8 +2979,8 @@ function getPlayerInput() {
2933
  const ly = Math.abs(pad.axes[1]) > GAMEPAD_DEADZONE ? pad.axes[1] : 0;
2934
  dx += lx;
2935
  dy += ly;
2936
- if (pad.axes[3] > GAMEPAD_DEADZONE) boost = Math.max(boost, pad.axes[3]); // down → faster
2937
- if (pad.axes[3] < -GAMEPAD_DEADZONE) slow = Math.max(slow, -pad.axes[3]); // up → slower
2938
  break; // only the first connected pad
2939
  }
2940
  }
@@ -2951,12 +2997,7 @@ window.addEventListener("resize", doResize);
2951
  doResize();
2952
  requestAnimationFrame(frame);
2953
  })();
2954
- </script>
2955
-
2956
- <div id="controls-help" style="position: fixed; bottom: 15px; left: 15px; color: rgba(255,255,255,0.7); font-family: monospace; font-size: 12px; pointer-events: none; z-index: 1000; line-height: 1.7; transition: opacity 1.5s ease;">
2957
- <strong>Keyboard:</strong> F spawn &nbsp;·&nbsp; WASD steer &nbsp;·&nbsp; Shift speed up &nbsp;·&nbsp; Space slow down<br>
2958
- <strong>Controller:</strong> Start spawn &nbsp;·&nbsp; Left stick steer &nbsp;·&nbsp; Right stick ↓ speed up &nbsp;·&nbsp; Right stick ↑ slow down
2959
- </div>
2960
 
 
2961
  </body>
2962
  </html>
 
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Digital Aquarium (Optimized)</title>
7
  <style>
8
  html, body { margin:0; padding:0; height:100%; overflow:hidden; background:#02121f; }
9
  #tank { display:block; width:100vw; height:100vh; }
 
20
  <canvas id="tank"></canvas>
21
  <div class="glass"></div>
22
  <script>
23
+
24
  (() => {
25
  "use strict";
26
+ // ============ Aquarium v16 WHISKERS — alt-fg base + spots/stripes + whiskers (~10%) ==
27
+ // v22: Ecco-style rendering — fish show top/bottom view when moving vertically,
28
  // side profile when moving horizontally, like Ecco the Dolphin's 3D perspective
29
  // Based on v15 SPOTTY-3 (rolling hills, three tail styles, yellow-green foreground grass,
30
  // ~20% spots / ~20% stripes / ~10% both), plus: an independent roll gives ~10% of fish
31
+ // whiskers (barbels) — a forward-curving pair from the snout in one of THREE lengths
32
  // (short / medium / long), swaying gently as they swim. Whiskers are independent of the
33
  // body markings, so a whiskered fish may also be spotted, striped, both, or plain.
34
  // Iridescence + overhead streak unchanged.
 
48
 
49
  const HUES = [8, 35, 46, 172, 195, 215];
50
  const TAIL_TYPES = ['classic', 'fan', 'streamer']; // v11: three tail-fin styles, one per fish
51
+ // v18: koi colourations — large irregular patches on a white/ivory base. Three classic
52
  // looks: kohaku (red/orange), sanke (red + black), golden (amber/gold). Patches are
53
  // sampled in units of `size` like the spots, so they scale with each fish.
54
  function genKoiPatches(type) {
 
88
  }
89
  let fishes = [], bubbles = [], seaweeds = [], backSeaweeds = [], midSeaweeds = [], farSeaweeds = [], motes = [], rays = [], rocks = [], grass = [], fgGrass = [];
90
  let playerFish = null; // the fish controlled by gamepad (spawned with F key)
91
+ let bubblePool = [];
92
+ let bubblePoolSize = 0;
93
+
94
+ function getBubble() {
95
+ if (bubblePool.length > 0) {
96
+ return bubblePool.pop();
97
+ }
98
+ bubblePoolSize++;
99
+ return new Bubble(true);
100
+ }
101
+ function returnBubble(b) {
102
+ if (bubblePool.length < 50) {
103
+ bubblePool.push(b);
104
+ }
105
+ }
106
  const GAMEPAD_DEADZONE = 0.15; // ignore small stick movements
107
  let curNight = 0; // v7: shared night factor (dims the top-light + sheen)
108
+ let plantPoints = [], _ppUsed = 0; // collision points — a pooled set, reused in place
109
  let sandHeights = [];
110
  let sandParticles = []; // v22: grains of sand kicked up when a fast fish skims the sea floor
111
 
112
+
113
+ // v26: Trig lookup table for faster sin/cos (360 entries, 1 degree resolution)
114
+ const SIN_TABLE = new Float32Array(360);
115
+ const COS_TABLE = new Float32Array(360);
116
+ for (let i = 0; i < 360; i++) {
117
+ const rad = i * Math.PI / 180;
118
+ SIN_TABLE[i] = Math.sin(rad);
119
+ COS_TABLE[i] = Math.cos(rad);
120
+ }
121
+ function fastSin(x) {
122
+ // Convert radians to degrees, normalize to 0-360
123
+ let deg = (x * 180 / Math.PI) % 360;
124
+ if (deg < 0) deg += 360;
125
+ return SIN_TABLE[Math.floor(deg)];
126
+ }
127
+ function fastCos(x) {
128
+ let deg = (x * 180 / Math.PI) % 360;
129
+ if (deg < 0) deg += 360;
130
+ return COS_TABLE[Math.floor(deg)];
131
+ }
132
+
133
  class Fish {
134
+ static nextId = 0;
135
  constructor() {
136
+ this.id = Fish.nextId++;
137
+ this.size = rand(15, 42) * 1.15;
138
  this.speed = rand(0.55, 1.7) * (this.size / 30 + 0.5) * 0.75; // v3: max speed reduced 25%
139
  this.x = rand(60, W - 60);
140
  this.y = rand(H * 0.15, H * 0.72);
 
169
  this.spots = null;
170
  this.whiskerLen = null;
171
  }
172
+ // v18: color palette — silver/gray + three koi colourations (replaces dark blue & lime green)
173
  const specialRoll = Math.random();
174
  this.isKoi = false;
175
  this.koiPatches = null;
176
  if (specialRoll < 0.15) {
177
+ // Silver/gray fish — low saturation, medium-high lightness
178
  this.hue = rand(200, 240); // slight blue-gray tint
179
  this.saturation = rand(8, 18);
180
  this.lightness = rand(60, 75);
181
  } else if (specialRoll < 0.45) {
182
+ // Koi — a white/ivory base with large coloured patches; the three classic
183
  // colourations are split roughly evenly across this 30% band.
184
  this.isKoi = true;
185
  const koiType = specialRoll < 0.22 ? 'kohaku' : specialRoll < 0.30 ? 'sanke' : specialRoll < 0.38 ? 'golden' : 'goldspot';
186
  if (koiType === 'goldspot') {
187
  // Golden orange fish with white spots
188
  this.hue = rand(30, 42); // golden orange
189
+ this.saturation = rand(85, 95); // high saturation → vivid color
190
+ this.lightness = rand(50, 65); // medium lightness → rich gold
191
  } else {
192
+ // Classic koi — white/ivory base
193
  this.hue = rand(40, 50); // warm ivory tint for the base
194
+ this.saturation = rand(12, 22); // low saturation → white body
195
+ this.lightness = rand(88, 94); // high lightness → white/ivory base
196
  }
197
  this.koiPatches = genKoiPatches(koiType);
198
  } else {
 
203
  }
204
  this.tailType = pick(TAIL_TYPES); // v11: random tail-fin style for variety
205
 
206
+ // v13/v14/v15: random body markings — one roll per fish decides the look:
207
  // ~20% spots, ~20% tiger stripes, ~10% BOTH (spots over stripes), else plain.
208
  // Sampled in units of `size` so they scale with the fish; stored as pre-built
209
+ // fill styles — zero per-frame string work.
210
  const genSpots = () => {
211
  const arr = [];
212
  const nSpots = 7 + ((Math.random() * 9) | 0); // 7..15 spots
 
216
  const rr = Math.sqrt(Math.random()) * 0.9; // uniform in ellipse, kept off the rim
217
  let style;
218
  if (this.saturation < 30) {
219
+ // v14: gray/silver fish — keep spots low-saturation to match
220
  const spotLight = pale ? rand(75, 88) : rand(35, 50);
221
  style = `hsla(${this.hue + rand(-24, 24)},${rand(5, 15)}%,${spotLight}%,${rand(0.35, 0.6).toFixed(2)})`;
222
  } else if (pale) {
223
+ // cream/ivory spots — pearl-gourami / leopard pattern
224
  style = `hsla(${this.hue + rand(-24, 24)},${rand(45, 70)}%,${rand(82, 94)}%,${rand(0.35, 0.6).toFixed(2)})`;
225
  } else {
226
+ // dark spots — dace / leopard pattern
227
  style = `hsla(${this.hue + rand(-18, 18)},${rand(60, 85)}%,${rand(24, 34)}%,${rand(0.45, 0.7).toFixed(2)})`;
228
  }
229
  arr.push({
 
236
  return arr;
237
  };
238
  const genStripes = () => {
239
+ // v14/v14.1: tiger stripes — vertical bars across the body (betta / tiger-barb
240
  // style), spread from just behind the head to near the tail base with organic
241
  // jitter; each bar bends and tapers instead of being a straight line.
242
  const arr = [];
243
  const nStr = 5 + ((Math.random() * 4) | 0); // 5..8 bars
244
  const darkBars = this.saturation >= 30 && Math.random() < 0.72; // v14: gray fish get light stripes
245
  for (let i = 0; i < nStr; i++) {
246
+ const t = nStr > 1 ? i / (nStr - 1) : 0; // 0 behind head → 1 near tail base
247
  let style;
248
  if (this.saturation < 30) {
249
+ // v14: gray/silver fish — keep stripes low-saturation to match
250
  const stripeLight = darkBars ? rand(35, 50) : rand(75, 88);
251
  style = `hsla(${this.hue + rand(-14, 14)},${rand(5, 15)}%,${stripeLight}%,${rand(0.5, 0.75).toFixed(2)})`;
252
  } else if (darkBars) {
253
  // classic dark tiger bars
254
  style = `hsla(${this.hue + rand(-14, 14)},${rand(65, 90)}%,${rand(18, 30)}%,${rand(0.5, 0.75).toFixed(2)})`;
255
  } else {
256
+ // cream/ivory bars — pale tiger variant
257
  style = `hsla(${this.hue + rand(-24, 24)},${rand(45, 70)}%,${rand(80, 93)}%,${rand(0.35, 0.6).toFixed(2)})`;
258
  }
259
  arr.push({
260
+ x: 0.72 - t * 1.62 + rand(-0.07, 0.07), // +0.72s → -0.90s along the body
261
  w: rand(0.08, 0.2), // bar width, in units of s
262
  style,
263
+ // v14.1: organic shape — each bar bends and tapers instead of a straight line
264
  cAmp: rand(0.03, 0.09), // whole-bar bend amplitude (units of s)
265
  cFreq: rand(0.8, 2.0), // waves of bend across the bar's height
266
  cPh: rand(0, Math.PI * 2),
 
271
  }
272
  return arr;
273
  };
274
+ // v18: koi already carry their own large patches — skip the random spots/stripes
275
  const markRoll = this.isKoi ? 1 : Math.random();
276
  if (markRoll < 0.2) {
277
  this.spots = genSpots();
 
280
  this.spots = null;
281
  this.stripes = genStripes();
282
  } else if (markRoll < 0.5) {
283
+ // v15: the mixed breed — stripes as the base pattern, spots scattered over them
284
  this.spots = genSpots();
285
  this.stripes = genStripes();
286
  } else {
287
  this.spots = null;
288
  this.stripes = null;
289
  }
290
+ // v16: whiskers (barbels) — an independent ~10% roll. A whiskered fish gets a
291
  // forward-curving pair from the snout in one of three length tiers (~even split),
292
  // so the tank shows short, medium and long whiskers. Independent of spots/stripes,
293
  // so any marked fish can also wear them.
 
303
  }
304
 
305
  // PERF: precompute per-fish paint styles once. draw() used to rebuild these
306
+ // template strings every frame for every fish — now it's a property read.
307
  this.tailFillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.92)`;
308
  this.dorsalFillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.9)`;
309
  this.pevFillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.8)`;
 
318
  this.bodyGrad = bg;
319
 
320
  this.phase = rand(0, Math.PI * 2);
321
+ // v9: after dark, most fish bed down low in the grass — only a small share still
322
  // hovers by the seaweed, and each of those keeps to its own stalk (no pile-ups)
323
  this.grassSleeper = Math.random() < 0.85;
324
  this.seaweedPref = -1; // chosen lazily on the first night (needs built scenery)
 
344
  this.wanderTheta = rand(0, Math.PI * 2);
345
  this.wanderRadius = rand(35, 80);
346
  this.wanderDist = rand(90, 170);
347
+ this.maxTurn = rand(1.4, 2.2); // max rad/s — organic turns, no orbiting
348
 
349
+ // Behavior states: fish don't swim non-stop — they cruise, hover, and rest.
350
  this.behavior = 'swim'; // 'swim' | 'hover' | 'pause'
351
  this.behaviorTimer = rand(4, 11); // seconds until it may change behavior
352
  this.speedFactor = 1; // current speed multiplier (eased toward target)
 
357
  // Occasionally puffs a tiny cluster of bubbles from its mouth
358
  this.bubbleTimer = rand(4, 16); // seconds until the next bubble puff
359
 
360
+ // Body roll — banks/rolls through sharp turns like a real fish (spring-damper)
361
  this.roll = 0; // current roll angle (rad); 0 = upright
362
  this.rollVel = 0; // angular velocity of the roll
363
  this.inHardTurn = false;// was it in a sharp turn last frame?
364
 
365
+ // v22: Ecco-style viewing angle — determines whether we see the fish from the side
366
  // or from above/below based on its movement direction (0 = side, 1 = top/bottom)
367
  this.viewAngle = 0; // current interpolated view angle
368
 
 
373
  const night = personalNight(performance.now() / 1000, this); // v10: per-fish early/normal/late sleep & wake
374
 
375
  // Night: cruise power eases down so even active fish drift lazily after dark,
376
+ // and back up again at dawn — smooth, never a sudden speed jump.
377
  this.cruiseSpeed += ((1 - night * 0.55) - this.cruiseSpeed) * Math.min(1, dt * 0.3);
378
 
379
+ // Behavior state machine — occasionally hover or come to near-rest.
380
  // At night the odds tilt toward hovering and long still rests (v6).
381
  this.behaviorTimer -= dt;
382
  if (this.behaviorTimer <= 0) {
 
402
  // Occasionally release a small cluster of bubbles from the mouth
403
  this.bubbleTimer -= dt;
404
  if (this.bubbleTimer <= 0) {
405
+ const mx = this.x + fastCos(this.angle) * this.size * 0.62;
406
  const my = this.y + Math.sin(this.angle) * this.size * 0.62;
407
+ const n = (2 + ((Math.random() * 3) | 0)) * 2; // 4..8 bubbles — doubled from the old 2..4
408
  for (let i = 0; i < n; i++) {
409
+ const bb = getBubble(); bb.reset(false); bb.x = mx + rand(-3, 3); bb.y = my + rand(-3, 3); bb.r = rand(1.4, 3.2) * (this.size / 28); bb.speed = rand(45, 85) * (0.7 + 0.3 * (this.size / 28)); bb.p = rand(0, Math.PI * 2); bb.rgb = bubbleColor(); bb.isMouth = true;
410
  bb.owner = this; // v10: a fish never pops its own bubbles
411
  bubbles.push(bb);
412
  }
 
418
 
419
  // Target sits ahead of the fish (in heading direction), offset by wanderTheta.
420
  // Steering toward it yields smooth meandering and genuine direction changes.
421
+ let wx = this.x + fastCos(this.angle) * this.wanderDist + Math.cos(this.wanderTheta) * this.wanderRadius;
422
  let wy = this.y + Math.sin(this.angle) * this.wanderDist + Math.sin(this.wanderTheta) * this.wanderRadius;
423
 
424
  // v7: after dark, most fish bed down low in the grass; a few still hover by the seaweed.
425
  if (night > 0.01) {
426
  if (this.grassSleeper) {
427
+ // v14: drift toward this fish's own sleep spot along the sand line —
428
  // spreads sleeping fish across the tank instead of one big clump
429
  wx += (this.sleepSpotX - wx) * night * 0.75;
430
+ const groundY = sandAt(this.x + fastCos(this.angle) * this.wanderDist) - this.size * 0.5;
431
  wy += (groundY - wy) * night * 0.85;
432
  } else if (seaweeds.length) {
433
  // v9: each seaweed-hungry fish keeps to its own stalk instead of all crowding the nearest one
 
435
  this.seaweedPref = (Math.random() * seaweeds.length) | 0;
436
  }
437
  const w = seaweeds[this.seaweedPref];
438
+ // v13: plinko fix — stalks near the sides pull harder so fish actually reach them
439
  // instead of being deflected back toward the middle by the boundary steering.
440
+ const edgeT = Math.abs(w.x / W - 0.5) * 2; // 0 at center → 1 at glass
441
  const k = night * 0.8 * (1 + edgeT * 0.9); // up to ~1.9x at the very edge
442
  wx += (w.x - wx) * k;
443
  wy += (w.yMid - wy) * k;
 
454
  else if (this.x > W - mX) pull = Math.max(pull, 1 - (W - this.x) / mX);
455
  if (this.y < mTop) pull = Math.max(pull, 1 - this.y / mTop);
456
  else if (this.y > H - mBot) pull = Math.max(pull, 1 - (H - this.y) / mBot);
457
+ // v13: plinko fix — grass-sleepers settling at night are less deflected by the
458
  // boundary, so they can reach side positions instead of being pushed to the middle.
459
  if (pull > 0 && this.grassSleeper && night > 0.25) {
460
  const settle = Math.min(1, (night - 0.25) / 0.75); // ramps up as they get sleepier
 
467
 
468
  // Floor steering: as a fish nears the sand bed it is steered upward,
469
  // so it skims above the ground line instead of diving under it
470
+ // (v4: starts earlier and pushes harder — grass yes, faceplants no)
471
  const floorY = sandAt(this.x) - this.size * 0.35;
472
  const fZone = H * 0.18;
473
  if (this.y > floorY - fZone) {
 
475
  desired = lerpAngle(desired, Math.atan2(H * 0.45 - this.y, W / 2 - this.x), fp * 1.6);
476
  }
477
 
478
+ // v7: separation steering — bend around nearby fish instead of ploughing into them.
479
  // The faster we swim, the earlier and harder we steer away.
480
  const mySpeed = this.speed * this.speedFactor;
481
  if (mySpeed > 0.3) {
 
487
  const zone = (this.size + o.size) * 1.7; // notice radius scales with both fish
488
  if (d2 < zone * zone && d2 > 0.01) {
489
  const dd = Math.sqrt(d2);
490
+ const w = (1 - dd / zone) * Math.min(1, mySpeed); // close & fast → strong
491
  ax -= (dx / dd) * w;
492
  ay -= (dy / dd) * w;
493
  wsum += w;
 
497
  desired = lerpAngle(desired, Math.atan2(ay, ax), Math.min(0.85, wsum * 0.45));
498
  }
499
  } else if (night > 0.2) {
500
+ // v14: at rest after dark — a wider, stronger personal buffer so sleeping
501
  // fish gently push apart instead of settling into one tight clump
502
  let ax = 0, ay = 0, wsum = 0;
503
  for (const o of fishes) {
 
538
  if (this.roll > 1.15) { this.roll = 1.15; this.rollVel *= -0.25; }
539
  else if (this.roll < -1.15) { this.roll = -1.15; this.rollVel *= -0.25; }
540
 
541
+ // v22: Ecco-style viewing angle — when moving vertically, we see the fish from above/below;
542
  // when moving horizontally, we see its side profile. Smoothly interpolate between views.
543
+ // sin(angle) gives 0 for horizontal (0, π) and ±1 for vertical (±π/2)
544
  const targetView = Math.abs(Math.sin(this.angle));
545
  this.viewAngle += (targetView - this.viewAngle) * Math.min(1, dt * 4);
546
 
 
554
  let d = targetAngle - this.angle;
555
  while (d > Math.PI) d -= 2 * Math.PI;
556
  while (d < -Math.PI) d += 2 * Math.PI;
557
+ const turnSpeed = 4.0; // rad/s — responsive turning
558
  this.angle += Math.max(-turnSpeed * dt, Math.min(turnSpeed * dt, d));
559
  }
560
  // Speed: cruise baseline, boosted by Shift / R-stick down,
 
564
  if (inp.slow > 0) speedMult -= inp.slow * 0.85; // slow down
565
  speedMult = Math.max(0.15, Math.min(3.0, speedMult));
566
  const playerSpeed = this.speed * this.cruiseSpeed * speedMult * 60 * dt;
567
+ this.x += fastCos(this.angle) * playerSpeed;
568
  this.y += Math.sin(this.angle) * playerSpeed;
569
  this.currentVelocity = this.speed * this.cruiseSpeed * speedMult * 60; // track actual velocity
570
  } else {
571
  // Normal AI fish movement.
572
  const sp = this.speed * this.cruiseSpeed * this.speedFactor * 60 * dt;
573
+ this.x += fastCos(this.angle) * sp;
574
  this.y += Math.sin(this.angle) * sp;
575
  this.currentVelocity = this.speed * this.cruiseSpeed * this.speedFactor * 60; // track actual velocity
576
  }
577
 
578
+ // NOTE: fish do NOT collide with plants — they plough straight through.
579
  // The seaweed bends out of the way instead (see updatePlants).
580
 
581
+ // Hard safety clamp — fish may just about swim off-screen (they never
582
  // despawn or leave), but never below the ground line (sand surface)
583
  this.x = Math.max(-40, Math.min(W + 40, this.x));
584
  this.y = Math.max(-50, Math.min(sandAt(this.x) - this.size * 0.35, this.y));
585
 
586
+ // Tail beat follows actual speed — a resting fish barely moves its tail
587
  // Use the actual current velocity (which accounts for player boost/slow)
588
  const effSpeed = this.currentVelocity / 60; // convert back to base units
589
  this.phase += dt * (4 + effSpeed * 3);
 
597
  // detailed body and foreshorten it as the fish turns to swim vertically.
598
  // viewAngle 0 = pure side profile (one eye), 1 = seen from above/below (the back).
599
  // Foreshortening along local up/down makes the dorsal fin slide toward center and
600
+ // the body slim down, reading as "seeing the back" — no snapping, nothing vanishes.
601
  const v = Math.max(0, Math.min(1, this.viewAngle));
602
  let scaleY = 1 - 0.34 * v; // foreshorten toward the back view
603
  if (Math.abs(this.roll) > 0.01) { // banking roll still squashes a touch
604
+ scaleY *= Math.max(0.42, fastCos(this.roll));
605
  c.rotate(this.roll * 0.1);
606
  }
607
  c.scale(1, scaleY);
 
616
  // traveling body wave
617
  const waveK = Math.PI / s;
618
  const waveAmp = s * 0.12;
619
+ const sway = fastSin(-s * waveK + this.phase) * s * 0.34;
620
 
621
  // Top/bottom view: body seen from above or below
622
  // Dorsal fin visible as a line down the center when viewed from above
 
641
  c.fillStyle = g;
642
  c.fill(bodyPath);
643
 
644
+ // Dorsal fin — visible as a line/shape down the center when viewed from above
645
  c.strokeStyle = this.tailFillStyle;
646
  c.lineWidth = s * 0.15;
647
  c.lineCap = "round";
 
651
  c.quadraticCurveTo(-s * 0.2, -sway * 0.5, -s * 0.6, sway * 0.3);
652
  c.stroke();
653
 
654
+ // Eyes — visible on both sides of the head when viewed from top/bottom
655
  c.fillStyle = "#fff";
656
  // Left eye
657
  c.beginPath();
 
697
  // v22: Draw fish from the side (original view)
698
  drawSideView(c) {
699
  const s = this.size;
700
+ // traveling body wave (v3): ripples head→tail so the fish undulates like a real one
701
  const waveK = Math.PI / s; // ~one wavelength across the body length
702
  const waveAmp = s * 0.12; // subtle flex amplitude
703
+ // tail fin follows the body wave at its base, amplified (same phase → no fighting)
704
+ const sway = fastSin(-s * waveK + this.phase) * s * 0.34;
705
 
706
  // v24: Ecco-style blend factor (0 = side profile, 1 = seen from above/below).
707
  // Flattens the tail lobes and spreads eyes/barbels as we see more of the back.
 
719
  // with only a shallow dip between the two lobes.
720
  c.moveTo(-s * 0.5, 0);
721
  c.quadraticCurveTo(-s * 1.3, -s * 0.28 * tailFlat + sway, -s * 1.42, -s * 0.5 * tailFlat + sway); // up to upper tip
722
+ c.quadraticCurveTo(-s * 1.12, sway * 0.5, -s * 1.42, s * 0.5 * tailFlat + sway); // shallow notch → lower tip
723
  c.quadraticCurveTo(-s * 1.3, s * 0.28 * tailFlat + sway, -s * 0.5, 0); // back to the base
724
  } else if (this.tailType === 'streamer') {
725
  // Long forked streamers (angelfish/fancy-guppy-style): two flowing lobes that
726
  // reach further back with a deeper notch between them.
727
  c.moveTo(-s * 0.5, 0);
728
  c.quadraticCurveTo(-s * 1.35, -s * 0.24 * tailFlat + sway, -s * 1.68, -s * 0.56 * tailFlat + sway); // long upper lobe
729
+ c.quadraticCurveTo(-s * 0.98, sway * 0.35, -s * 1.68, s * 0.56 * tailFlat + sway); // deep fork → lower tip
730
  c.quadraticCurveTo(-s * 1.35, s * 0.24 * tailFlat + sway, -s * 0.5, 0); // long lower lobe back to base
731
  } else {
732
+ // classic (v7): rounded lobes with a soft notch — the original look
733
  c.moveTo(-s * 0.5, 0);
734
  c.quadraticCurveTo(-s * 1.04, -s * 0.36 * tailFlat + sway, -s * 1.22, -s * 0.44 * tailFlat + sway); // upper edge out toward the tip
735
+ c.quadraticCurveTo(-s * 1.34, -s * 0.52 * tailFlat + sway, -s * 0.86, sway * 0.5); // rounded upper lobe → notch
736
+ c.quadraticCurveTo(-s * 1.34, s * 0.52 * tailFlat + sway, -s * 1.22, s * 0.44 * tailFlat + sway); // notch → rounded lower lobe
737
  c.quadraticCurveTo(-s * 1.04, s * 0.36 * tailFlat + sway, -s * 0.5, 0); // lower edge back to the base
738
  }
739
  c.fill();
740
 
741
+ // v21: far-side whisker — drawn BEFORE the body so it sits behind the fish and is
742
  // partially obscured by the head (only the part that pokes out from behind the snout
743
  // shows). The near-side whisker is drawn after the eye, below.
744
  if (this.whiskerLen) {
 
752
  {
753
  const side = -1; // far side (behind the body)
754
  const rootX = s * 0.6;
755
+ // v24: from above, barbels sit on either side of the mouth — spread the roots
756
  // apart and flare each whisker further out as the fish "rotates" to swim vertically
757
  const rootY = side * s * (0.09 + 0.07 * ev);
758
  c.beginPath();
 
762
  const bx = rootX - L * (0.25 + 0.65 * spd) * t;
763
  const by = rootY + side * L * (0.42 + 0.3 * ev) * t * t;
764
  const amp = s * 0.17 * t * (0.35 + 0.65 * spd);
765
+ const off = fastSin(t * 5.5 - this.phase * 1.4 + this.whiskPh) * amp;
766
  c.lineTo(bx, by + off);
767
  }
768
  c.stroke();
 
772
 
773
  // v26:
774
 
775
+ // body — undulates while swimming: the ellipse flexes along a traveling wave (v3)
776
  const g = this.bodyGrad; // precomputed in constructor (static colors, local coords)
777
  const NSEG = 26; // outline samples for a smooth curve
778
  // wave offset at any x along the body: rigid at head, full flex at tail.
779
  // Shared with the dorsal fin so it stays glued to the moving back (v4)
780
  const wy = x => Math.sin(x * waveK + this.phase) * waveAmp * ((s - x) / (2 * s));
781
+ // pelvic fin — drawn BEFORE the body so it sits behind the fish
782
  if (this.hasPelvicFin) {
783
  try {
784
  c.fillStyle = this.pevFillStyle;
 
794
  console.error('Pelvic fin error:', e);
795
  }
796
  }
797
+ // // Build the body outline once as a Path2D — reused for base fill + iridescence (v7)
798
  const bodyPath = new Path2D();
799
  for (let i = 0; i <= NSEG; i++) { // top edge: head (+s) -> tail (-s)
800
  const x = s - (i / NSEG) * 2 * s;
 
811
  c.fillStyle = g;
812
  c.fill(bodyPath);
813
 
814
+ // v15: stripes first when both are present — bars form the base pattern,
815
  // spots scatter on top. Each is clipped to the body so nothing spills, and both
816
  // ride the traveling wave (wy) so they flex with the undulating body, drawn
817
  // BEFORE the additive sheen so the iridescent shimmer still plays across them.
 
825
  const H = s * 1.35; // vertical span (clipped by the body outline)
826
  c.fillStyle = st.style;
827
  c.beginPath();
828
+ // left edge, top → bottom: bend the centerline + taper the width with t
829
  for (let j = 0; j <= N; j++) {
830
  const t = j / N;
831
+ const cx = sx + fastSin(t * st.cFreq * Math.PI + st.cPh) * st.cAmp * s;
832
+ const hwL = hw0 * (1 + st.tAmp * fastSin(t * st.tFreq * Math.PI + st.tPh));
833
  const xL = cx - hwL, yL = wy(xL) + (t - 0.5) * H;
834
  if (j === 0) c.moveTo(xL, yL); else c.lineTo(xL, yL);
835
  }
836
+ // right edge, bottom → top
837
  for (let j = N; j >= 0; j--) {
838
  const t = j / N;
839
+ const cx = sx + fastSin(t * st.cFreq * Math.PI + st.cPh) * st.cAmp * s;
840
+ const hwR = hw0 * (1 + st.tAmp * fastSin(t * st.tFreq * Math.PI + st.tPh));
841
  const xR = cx + hwR, yR = wy(xR) + (t - 0.5) * H;
842
  c.lineTo(xR, yR);
843
  }
 
847
  c.restore();
848
  }
849
 
850
+ // v18: koi patches — large irregular blobs clipped to the body outline, riding the
851
  // traveling wave (wy) so they flex with the undulating body, drawn BEFORE the
852
  // additive sheen so the iridescent shimmer still plays across them.
853
  if (this.koiPatches) {
 
863
  c.restore();
864
  }
865
 
866
+ // v13: random spots on ~20% of fish (or over the stripes for the 10% mixed) —
867
  // clipped to the body so they never spill, riding the traveling wave (wy) so they
868
  // flex with the undulating body, and drawn BEFORE the additive sheen so the
869
  // iridescent shimmer still plays across them.
 
880
  c.restore();
881
  }
882
 
883
+ // v7: efficient iridescence — a sheen whose hue shimmers with the swim phase,
884
  // so scales appear to catch the light while moving. One gradient + one fill.
885
  const shimmer = Math.sin(this.phase * 1.6) * 0.5 + 0.5; // 0..1 as it swims
886
  const iridHue = this.hue + 40 * Math.sin(this.phase * 2.3); // hue drifts with phase
887
  // PERF: cache the sheen gradient. Its inputs move smoothly, so quantize them to a
888
+ // tiny grid (1/31 shimmer steps, 1° hue) and reuse until the cell changes — this
889
  // skips one createLinearGradient + 3 addColorStop calls on most frames.
890
  const igKey = Math.round(shimmer * 31) * 512 + (Math.round(iridHue) + 64);
891
  if (igKey !== this._igKey || !this._igGrad) {
 
908
  c.ellipse(0, s * 0.14, s * 0.82, s * 0.24, 0, 0, Math.PI * 2);
909
  c.fill();
910
 
911
+ // dorsal fin — rides the body wave at its attachment points so it
912
  // never disconnects as the back undulates (v4)
913
+ // v17: matches the fish's own color instead of a fixed blue — silver fish no longer
914
  // sprout mismatched blue fins
915
+ // v25: as the fish turns to swim vertically (ev→1), the dorsal fin slides from
916
  // "on top of the fish" to "in the middle" (centerline), matching the top-down view
917
  c.fillStyle = this.dorsalFillStyle;
918
  c.beginPath();
 
924
  c.closePath();
925
  c.fill();
926
 
927
+ // v24: both eyes — as the fish turns to swim vertically (ev→1) we see them from
928
  // above: the pair spreads wider on either side of the head and each eye squashes
929
  // into a flatter ellipse (perspective), instead of two overlapping circles.
930
  {
 
940
  c.ellipse(s * 0.61, yNear, s * 0.055, s * pupRY, 0, 0, Math.PI * 2);
941
  c.fill();
942
 
943
+ // far-side eye fades in as we see more of the back (Ecco-style top view) —
944
  // two eyes flanking the head when the fish turns to swim vertically.
945
  if (ev > 0.03) {
946
+ const yFar = s * (0.16 + 0.08 * ev); // far eye slides outward too — pair widens
947
  c.save();
948
  // Ease in the far-side eye so it takes longer to appear (25% slower fade-in)
949
  c.globalAlpha = Math.pow(ev, 1.25) * 0.9;
 
959
  }
960
  }
961
 
962
+ // v21: near-side whisker — drawn AFTER the body so it sits in front of the fish.
963
  // (The far-side whisker is drawn before the body, above.) Together they form a
964
  // pair from the snout on ~10% of fish, in one of three lengths, with loose ribbons
965
  // flowing in the current and a traveling wave rippling down their length. Drawn at
 
975
  {
976
  const side = 1; // near side (in front of the body)
977
  const rootX = s * 0.6;
978
+ // v24: same spread/flare as the far whisker — a pair flanking the snout from above
979
  const rootY = side * s * (0.09 + 0.07 * ev);
980
  c.beginPath();
981
  c.moveTo(rootX, rootY);
 
984
  const bx = rootX - L * (0.25 + 0.65 * spd) * t;
985
  const by = rootY + side * L * (0.42 + 0.3 * ev) * t * t;
986
  const amp = s * 0.17 * t * (0.35 + 0.65 * spd);
987
+ const off = fastSin(t * 5.5 - this.phase * 1.4 + this.whiskPh) * amp;
988
  c.lineTo(bx, by + off);
989
  }
990
  c.stroke();
 
994
 
995
  c.restore();
996
 
997
+ // v17/v23: overhead light — soft elliptical highlight on the fish's upper edge.
998
  // Drawn in local coords (draw() already positioned + rotated us) so it rides with
999
  // the body and foreshortens correctly instead of being double-offset.
1000
  {
 
1015
  }
1016
  }
1017
 
1018
+ // v7: helper — stroke a smooth line through the given points
1019
  function strokeArc(c, pts) {
1020
  c.beginPath();
1021
  c.moveTo(pts[0][0], pts[0][1]);
 
1023
  c.stroke();
1024
  }
1025
 
1026
+ // v19: each bubble gets a subtle random tint — pure white up to a soft grey-blue
1027
  function bubbleColor() {
1028
  const t = Math.random() * 0.85; // keep it subtle, most stay near-white
1029
  const r = Math.round(255 - (255 - 196) * t);
 
1057
  }
1058
  update(dt, t) {
1059
  this.y -= this.speed * dt;
1060
+ this.x += fastSin(t * 1.6 + this.p) * 22 * dt;
1061
  if (this.y < -12) { if (this.isMouth) this.dead = true; else this.reset(false); }
1062
  }
1063
  draw(c) {
 
1094
  rocks = [];
1095
  sandHeights = [];
1096
  sandParticles = []; // v22: fresh bed, no leftover grains
1097
+ raySpritesBuiltFor = null; // v23: beam sprites are sized to the old canvas — rebuild them
1098
  grass = []; // v10.1: clear on rebuild so resize/F11 doesn't stack a second row
1099
  fgGrass = []; // v12: same reset for the foreground layer (resize/F11 safety)
1100
 
1101
+ // ALT: rolling hills instead of a flat bed — three octaves of random sine waves
1102
  // (broad swells + mid undulations + fine ripples), re-rolled on every refresh.
1103
  const sandTop = H - Math.max(60, H * 0.12);
1104
  const hillUp = rand(34, 58); // tallest crest rises this far above the baseline
 
1114
  for (const wv of waves) v += Math.sin((x / wv.wl) * Math.PI * 2 + wv.ph) * wv.amp;
1115
  raw.push(v);
1116
  }
1117
+ // Rescale so the extremes land exactly at the chosen limits — full relief, no clamping
1118
  let lo = Infinity, hi = -Infinity;
1119
  for (const v of raw) { if (v < lo) lo = v; if (v > hi) hi = v; }
1120
  const span = Math.max(0.001, hi - lo);
1121
  sandHeights.length = 0;
1122
  for (const v of raw) {
1123
+ const off = -hillUp + ((hi - v) / span) * (hillUp + hillDown); // crest → -hillUp, dip → +hillDown
1124
  sandHeights.push(sandTop + off);
1125
  }
1126
 
 
1141
  hue,
1142
  width: rand(4, 8),
1143
  touch: 0,
1144
+ // v15: organic rest curve — each stalk leans and waves like real seaweed
1145
  // instead of standing perfectly straight. Also fed into the physics (updatePlants)
1146
+ // so fish push the stalk where it actually looks. Weighted by f → root stays planted.
1147
  cA1: rand(5, 12), cF1: rand(0.9, 1.8), cP1: rand(0, Math.PI * 2),
1148
  cA2: rand(2, 6), cF2: rand(2.2, 3.6), cP2: rand(0, Math.PI * 2),
1149
  // v17: secondary wobble - a finer inner shimmer for unique motion per stalk
1150
  sAmp: rand(2, 5), // amplitude of the secondary wave
1151
  sFreq: rand(3.5, 6.5), // higher frequency than primary sway
1152
  sPh: rand(0, Math.PI * 2), // phase offset
1153
+ tipW: rand(1.4, 2.6), // v15: blade tapers from width → tipW up the stipe
1154
+ // PERF: stroke styles precomputed once (hue is fixed per stalk) — drawSeaweed
1155
  // used to rebuild both template strings every frame for every stalk.
1156
  stalkStyle: `hsla(${hue},55%,36%,0.92)`,
1157
  branchStyle: `hsla(${hue + 8},60%,46%,0.9)`,
 
1159
  off: new Float32Array(9), // lateral bend per segment (fish push leaves aside)
1160
  offV: new Float32Array(9), // velocity of that bend
1161
  branches: [],
1162
+ // PERF: stalk points preallocated once — updatePlants reuses these objects
1163
  // in place instead of pushing fresh {x,y} pairs every frame (GC churn)
1164
  points: Array.from({ length: 9 }, () => ({ x: 0, y: 0 })) // segs+1; can't read st inside its own initializer
1165
  };
 
1175
  flowers: Math.random() < 0.3 ? (() => { // ~30% of branches bloom with 1-3 heads (v3)
1176
  const n = 1 + ((Math.random() * 3) | 0); // 1..3 flowers per branch
1177
  return Array.from({ length: n }, () => {
1178
+ const hue = (Math.random() * 360) | 0; // colourful — full spectrum
1179
  return {
1180
  t: rand(0.45, 1), // where along the branch it sits (tip zone)
1181
  hue,
1182
  r: rand(4, 7), // head size
1183
  petals: 5 + ((Math.random() * 3) | 0), // 5..7 petals
1184
+ // PERF: paint styles precomputed once — drawFlowerHead used to rebuild
1185
  // these strings per petal, per frame.
1186
  petalFill: `hsla(${hue},85%,64%,0.95)`,
1187
  centerFill: `hsla(${(hue + 45) % 360},92%,78%,0.95)`
 
1194
  }
1195
  }
1196
 
1197
+ // v4: blurry background seaweed — taller, bluer, behind everything else.
1198
  // Pre-rendered to offscreen canvases with blur at build time (no runtime cost).
1199
  const nBackClusters = Math.max(4, Math.floor(W / 200));
1200
  for (let i = 0; i < nBackClusters; i++) {
 
1203
  const nStalks = 2 + ((Math.random() * 3) | 0); // 2..4 stalks per bundle
1204
  for (let j = 0; j < nStalks; j++) {
1205
  const hue = clusterHue + rand(-15, 15);
1206
+ // v4.3: depth split — ~51% "far" (heavy softness), ~49% "near" (sharper) for a layered look
1207
  const near = Math.random() < 0.49;
1208
  const w = near ? rand(2.5, 5) : rand(3, 6);
1209
  const tw = near ? rand(1.2, 2.4) : rand(1.5, 3);
 
1303
  }
1304
  }
1305
 
1306
+ // v5: ultra-far pre-rendered blurry seaweed — deepest layer, heavily blurred sprites
1307
  const nFarClusters = Math.max(6, Math.floor(W / 180)); // 50% more than previous far count
1308
  for (let i = 0; i < nFarClusters; i++) {
1309
  const cx = rand(20, W - 20);
 
1346
  const nGrassTufts = Math.max(50, Math.floor(W / 9));
1347
  for (let i = 0; i < nGrassTufts; i++) {
1348
  const gx = rand(8, W - 8);
1349
+ 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
1350
  const baseHue = rand(95, 155);
1351
  const blades = 2 + ((Math.random() * 4) | 0); // 2..5 blades per tuft
1352
  for (let b = 0; b < blades; b++) {
 
1354
  grass.push({
1355
  x: gx + rand(-7, 7),
1356
  y: gy,
1357
+ h: rand(12, 85), // v4: more height variability — some short tufts, some noticeably tall
1358
  lean: rand(-0.35, 0.35), // base tilt from vertical
1359
  phase: rand(0, Math.PI * 2),
1360
  speed: rand(1.3, 2.8), // quicker, finer sway than seaweed
 
1370
  sAmp: rand(1.5, 3.5),
1371
  sFreq: rand(4.5, 8),
1372
  sPh: rand(0, Math.PI * 2),
1373
+ // PERF: per-blade scratch points — drawGrass reuses them instead of
1374
  // allocating a fresh pts array + point objects every frame (GC churn)
1375
+ pts: Array.from({ length: bladeSegs + 1 }, () => ({ x: 0, y: 0 })),
1376
+ fillStyle: `hsla(${baseHue + rand(-9, 9)},58%,40%,0.92)`,
1377
+ strokeStyle: `hsla(${baseHue + rand(-9, 9) + 6},64%,45%,0.5)` });
1378
  }
1379
  }
1380
 
1381
+ // v12: foreground grass — planted on the FRONT glass (closest to the viewer), so it is
1382
  // drawn last and foreshortened. It grows in the low band below the sand line, i.e. the
1383
  // zone fish never reach, and sways with the water like the other flora but has no
1384
  // fish-push physics (purely decorative). ALT-FG: all sizes halved vs the original fg.
 
1387
  for (let i = 0; i < nFgClusters; i++) {
1388
  const cx = rand(-10, W + 10);
1389
  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
1390
+ const baseHue = rand(46, 76); // v12.1: yellowish-green — pops against the mid-ground green grass
1391
  const nBlades = 5 + ((Math.random() * 4) | 0); // 5..8 blades per clump (doubled density)
1392
  for (let b = 0; b < nBlades; b++) {
1393
  const fgSegs = 4 + ((Math.random() * 2) | 0); // 4-5 segments for tapered outline
 
1409
  sAmp: rand(2, 4.5),
1410
  sFreq: rand(3.8, 6.5),
1411
  sPh: rand(0, Math.PI * 2),
1412
+ // PERF: per-blade scratch points — drawFgGrass reuses them in place
1413
  pts: Array.from({ length: fgSegs + 1 }, () => ({ x: 0, y: 0 })),
1414
  // v28: ~40% of blades get a gentle blur (pre-rendered sprite) for depth
1415
  blurred: Math.random() < 0.4,
1416
  sprite: null,
1417
+ spriteScale: 1,
1418
+ fillStyle: `hsla(${baseHue + rand(-10, 10)},60%,31%,0.96)`,
1419
+ strokeStyle: `hsla(${baseHue + rand(-10, 10) + 6},64%,45%,0.5)` });
1420
  }
1421
  }
1422
  buildFgGrassSprites(); // v28: bake the defocused-blade sprites once (free at runtime)
1423
 
1424
+ // PERF: pool the plantPoints collision objects too — updatePlants reuses them
1425
  // in place instead of allocating one per stalk/branch point every frame.
1426
  let pcap = 8;
1427
  for (const w of seaweeds) pcap += (w.segs + 1) + w.branches.length * 2;
 
1431
  for (let i = 0; i < nFish; i++) fishes.push(new Fish());
1432
 
1433
  const nAmbient = Math.round(26 * 0.75); // v10: ambient bubbles reduced 25% (fish production unchanged)
1434
+ for (let i = 0; i < nAmbient; i++) { const b = getBubble(); b.reset(true); bubbles.push(b); }
1435
 
1436
  for (let i = 0; i < 45; i++) {
1437
  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) });
 
1444
  rays.push({
1445
  x: rand(0, W), w: rand(80, 210), tilt: rand(-0.35, 0.35),
1446
  speed: rand(0.06, 0.14) * (Math.random() < 0.5 ? 1 : -1), alpha: rand(0.04, 0.09), blur,
1447
+ // undulation — two traveling waves so each beam shimmers like light refracted through moving water
1448
  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),
1449
  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)
1450
  });
 
1453
  const nRocks = Math.max(3, Math.floor(W / 320));
1454
  for (let i = 0; i < nRocks; i++) {
1455
  const r = rand(14, 34);
1456
+ // jagged outline — a ring of uneven points so rocks read as craggy stones, not domes
1457
  const nPts = 8 + ((Math.random() * 5) | 0); // 8..12 vertices
1458
  const pts = [];
1459
  for (let k = 0; k < nPts; k++) {
 
1518
 
1519
  // Per-fish night curve (v10): shifts each fish's dusk/dawn fades by its own early/normal/late
1520
  // personality so they don't all sleep or wake in unison. Full day and full night are shared
1521
+ // by everyone — only the transitions differ.
1522
  function personalNight(t, f) {
1523
  const p = ((t / CYCLE) % 1 + 1) % 1;
1524
  if (p < DUSK_START) return 0; // bright day: all awake
 
1550
  ctx.fillRect(0, 0, W, H);
1551
  }
1552
 
1553
+ // ============ Light rays — pre-blurred sprites (v23 fast path) ============
1554
  // The old version applied a canvas blur() filter to every beam on every frame;
1555
  // canvas filters force slow CPU rasterization over large regions, which tanked FPS.
1556
  // Now each beam's shape is rendered ONCE into its own offscreen sprite (blur + the
1557
  // vertical fade baked in at build time), and per frame we just blit that sprite in
1558
+ // horizontal slices with the same undulation offsets as before — wobble + soft
1559
  // edges, zero filters at runtime.
1560
  const RAY_SEGS = 18; // vertical resolution of each beam's spine (at build time)
1561
+ const RAY_SLICES = 10; // horizontal slices per beam used for the wobble (per frame) — reduced from 24
1562
+ const RAY_SPRITE_SCALE = 0.5; // sprites stored at half resolution → softer + cheaper
1563
  const RAY_PAD = 60; // room for the blur to spill past the beam edge
1564
  let raySpritesBuiltFor = null;
1565
  let rayTintRGB = null; // the light.ray color used when building tinted sprites (perf: rebuild only on big shifts)
 
1595
  const full = document.createElement("canvas");
1596
  full.width = bw; full.height = bh;
1597
  const fc = full.getContext("2d");
1598
+ fc.translate(-xMinL + RAY_PAD, -yTop0 + RAY_PAD); // local beam coords → scratch px
1599
  if (r.blur > 0) { try { fc.filter = `blur(${r.blur}px)`; } catch (e) {} }
1600
 
1601
  const poly = (a, b) => {
 
1653
  }
1654
 
1655
  // Cheaply re-tint the cached blurred sprites with the current light.ray color.
1656
+ // No blur / no shape rasterization — just a source-in fill over the stored beam,
1657
  // so this stays fast even during slow dusk/dawn transitions.
1658
  function applyRayTint() {
1659
  for (const r of rays) {
 
1682
  const yTop0 = -10, ySpan = H * 0.95 + 10;
1683
 
1684
  // PERF: re-tint the cached blurred sprites only when the light color shifts enough.
1685
+ // Re-tinting is cheap (a source-in fill) — no blur re-rasterization — so this runs
1686
  // smoothly even during slow dusk/dawn transitions instead of hitching on rebuilds.
1687
  if (!rayTintRGB || Math.abs(rayTintRGB[0] - rr) + Math.abs(rayTintRGB[1] - rg) + Math.abs(rayTintRGB[2] - rb) > 35) {
1688
  rayTintRGB = [rr, rg, rb];
 
1690
  }
1691
 
1692
  for (const r of rays) {
1693
+ // blit horizontal slices with the undulation offset at each depth — same wave
1694
  // math as before, so the beams shimmer exactly like they always have
1695
+ const off = fastSin(t * r.speed * 6 + r.x) * 55; // slow drift of the whole beam
1696
  for (let i = 0; i < RAY_SLICES; i++) {
1697
  const vT = i / RAY_SLICES, vB = (i + 1) / RAY_SLICES, vM = (vT + vB) / 2;
1698
  const ya = yTop0 + vT * ySpan, yb = yTop0 + vB * ySpan;
 
1736
  function drawRocks(c = ctx) {
1737
  for (const rk of rocks) {
1738
  const y = sandAt(rk.x) + rk.r * 0.35;
1739
+ // fully opaque — tone now varies lightness instead of alpha
1740
  const base = Math.round(28 + rk.tone * 46);
1741
  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);
1742
  g.addColorStop(0, `rgb(${base + 68},${base + 78},${base + 92})`);
 
1760
 
1761
  // PERF: shared sway function (was a per-stalk closure recreated every frame)
1762
  function stalkSwayX(w, t, f) {
1763
+ return fastSin(t * w.waveF1 + w.wavePh1 + f * 2.4) * w.waveA1 * f * f +
1764
+ fastSin(t * w.waveF2 + w.wavePh2 + f * 3.8) * w.waveA2 * f * f +
1765
+ fastSin(t * w.waveF3 + w.wavePh3 + f * 5.4) * w.waveA3 * f;
1766
  }
1767
 
1768
+ // v4.3: draw background seaweed procedurally — multi-wave organic motion instead of
1769
  // rigid sprite rotation. Far stalks get a soft low-alpha halo pass to keep their
1770
  // "far away" look without any runtime blur filters (no slowdown).
1771
  // v6: middle-distance seaweed using halo stroke pass (median depth between back and far)
1772
  function drawMidSeaweeds(t) {
1773
+ // PERF: lineCap/lineJoin are constant — set once instead of per stalk per pass
1774
  ctx.lineCap = 'round';
1775
  ctx.lineJoin = 'round';
1776
  for (const w of midSeaweeds) {
 
1805
  else ctx.lineTo(px + nx * hw, py + ny * hw);
1806
  prevX = px; prevY = py;
1807
  }
1808
+ // down the other side (cached points; i=n no-op point dropped — degenerate in original)
1809
  for (let i = n - 1; i >= 0; i--) {
1810
  const f = i / n;
1811
  const px = pxs[i], py = pys[i];
 
1822
  for (const b of w.branches) {
1823
  const idx = Math.min(n, Math.max(1, Math.round(b.f * n)));
1824
  const ax = pxs[idx], ay = pys[idx];
1825
+ const flutter = fastSin(t * w.waveF2 * 1.4 + b.ph) * (3 + w.waveA2 * 0.4);
1826
  const bx = ax + b.side * b.len + flutter;
1827
  const by = ay - b.up;
1828
  ctx.beginPath();
 
1841
  }
1842
 
1843
  function drawBackSeaweeds(t) {
1844
+ // v6: no runtime canvas filter — blur() forces a CPU re-rasterize every frame
1845
  // and was the main FPS cost. Softness now comes from the halo pass below instead.
1846
  ctx.lineCap = 'round';
1847
  ctx.lineJoin = 'round';
 
1877
  else ctx.lineTo(px + nx * hw, py + ny * hw);
1878
  prevX = px; prevY = py;
1879
  }
1880
+ // down the other side (cached points; i=n no-op point dropped — degenerate in original)
1881
  for (let i = n - 1; i >= 0; i--) {
1882
  const f = i / n;
1883
  const px = pxs[i], py = pys[i];
 
1894
  for (const b of w.branches) {
1895
  const idx = Math.min(n, Math.max(1, Math.round(b.f * n)));
1896
  const ax = pxs[idx], ay = pys[idx];
1897
+ const flutter = fastSin(t * w.waveF2 * 1.4 + b.ph) * (3 + w.waveA2 * 0.4);
1898
  const bx = ax + b.side * b.len + flutter;
1899
  const by = ay - b.up;
1900
  ctx.beginPath();
 
1914
 
1915
  // v5: pre-render ultra-far seaweed sprites with heavy blur baked in at build time
1916
  function buildFarSeaweedSprites() {
1917
+ const BLUR = 7; // v5.1: reduced from 10 (33% less) — slightly closer appearance
1918
  const PAD = 40; // extra space for blur spill
1919
 
1920
  for (const w of farSeaweeds) {
 
2006
 
2007
  // v28: pre-render blurred foreground grass sprites for ~40% of blades (gentle depth effect)
2008
  function buildFgGrassSprites() {
2009
+ const BLUR = 1.5; // very gentle blur — just enough to suggest "slightly out of focus"
2010
  const PAD = 8; // extra space for blur spill
2011
 
2012
  for (const g of fgGrass) {
 
2085
 
2086
  const baseY = sandAt(w.x) + 10;
2087
  // Simple rotational sway - root stays planted, sprite rotates slightly
2088
+ const swayAngle = fastSin(t * w.speed + w.phase) * 0.04; // very subtle rotation
2089
 
2090
  ctx.save();
2091
  // Translate to the base point (where the stalk meets the sand)
 
2111
  const moveF = Math.min(1, fSpd / 1.2); // v9: how actively this fish is swimming
2112
  for (let i = 1; i <= w.segs; i++) {
2113
  const fl = i / w.segs;
2114
+ const px = w.x + w.off[i] + stalkCurveX(w, fl) + fastSin(t * w.speed + w.phase + fl * 2.6) * fl * fl * 24;
2115
  const py = sandAt(w.x) + 8 - w.h * fl;
2116
  const dx = px - f.x, dy = py - f.y;
2117
  const d2 = dx * dx + dy * dy;
 
2119
  const d = Math.sqrt(d2);
2120
  const weight = (R - d) / R * (0.35 + 0.65 * fl); // upper segments yield more
2121
  // shove radially away from the fish + drag along its swim direction.
2122
+ // v9: a nearly-still fish only gently parts leaves — no shoving, no flinch wobble
2123
  w.offV[i] += ((dx / d) * 1500 * (0.25 + 0.75 * moveF) + fx * 9) * weight * dt;
2124
  if (weight > 0.25 && moveF > 0.35) w.touch = Math.min(1, w.touch + dt * 5);
2125
  }
 
2148
 
2149
  if (w.touch > 0) w.touch = Math.max(0, w.touch - dt * 1.2);
2150
  const baseY = sandAt(w.x) + 8;
2151
+ w.yMid = baseY - w.h * 0.5; // v6: stalk midpoint — the night-time gathering point
2152
  w.points.length = 0;
2153
  for (let i = 0; i <= w.segs; i++) {
2154
  const f = i / w.segs;
2155
  // normal sway + a fast extra flinch while being pushed
2156
+ const sway = fastSin(t * w.speed + w.phase + f * 2.6) * f * f * 24
2157
+ + fastSin(t * 7 + w.phase) * w.touch * f * 16;
2158
  // v17: secondary wobble - a finer inner movement for unique per-stalk motion
2159
+ const sWobble = fastSin(t * w.sFreq + w.sPh + f * 4.2) * w.sAmp * f;
2160
  const px = w.x + (w.off[i] || 0) + stalkCurveX(w, f) + sway + sWobble;
2161
  const py = baseY - w.h * f;
2162
  w.points.push({ x: px, y: py });
 
2164
  }
2165
  for (const b of w.branches) {
2166
  const a = w.points[Math.min(w.segs, Math.max(1, Math.round(b.f * w.segs)))];
2167
+ const flutter = fastSin(t * w.speed * 1.4 + b.ph) * (3 + w.touch * 9);
2168
  b.tx = a.x + b.side * b.len + flutter;
2169
  b.ty = a.y - b.up;
2170
  plantPoints.push({ x: b.tx, y: b.ty, r: 5, s: w });
 
2173
  }
2174
  }
2175
 
2176
+ // Grass blades ripple when fish swim close — stronger the lower the fish is.
2177
+ // v4: more reactive to fish contact — wider influence radius, faster ripple build,
2178
  // and slightly slower decay so the wave lingers a touch longer after a fish passes.
2179
  function updateGrass(t, dt) {
2180
  const night = nightFactor(t); // v6: after dark the fish drift toward the plants
 
2198
  function drawSeaweed() {
2199
  for (const w of seaweeds) {
2200
  const pts = w.points, n = pts.length;
2201
+ // v15: main stipe as a tapered leaf-blade — an outline around the same physics
2202
  // points, wide at the root and thinning to a soft point up top. No straight-line
2203
  // segments anywhere; the stalk's own curve comes from the sway + rest-curve baked
2204
  // into those points in updatePlants.
 
2208
  const p = pts[i];
2209
  const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
2210
  let dx = b.x - a.x, dy = b.y - a.y;
2211
+ const dl = Math.sqrt(dx*dx + dy*dy) || 1;
2212
  const nx = -dy / dl, ny = dx / dl; // perpendicular to the local stalk direction
2213
  const f = i / (n - 1);
2214
+ const hw = (w.width * (1 - f) + w.tipW * f) / 2; // half-width: base → tip taper
2215
  if (i === 0) ctx.moveTo(p.x + nx * hw, p.y + ny * hw);
2216
  else ctx.lineTo(p.x + nx * hw, p.y + ny * hw);
2217
  }
2218
  { // rounded cap at the tip
2219
  const tp = pts[n - 1], pp = pts[n - 2];
2220
  let dx = tp.x - pp.x, dy = tp.y - pp.y;
2221
+ const dl = Math.sqrt(dx*dx + dy*dy) || 1;
2222
  const nx = -dy / dl, ny = dx / dl;
2223
  const hwT = w.tipW / 2;
2224
  ctx.quadraticCurveTo(tp.x + dx, tp.y + dy * 0.6, tp.x - nx * hwT, tp.y - ny * hwT);
 
2227
  const p = pts[i];
2228
  const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
2229
  let dx = b.x - a.x, dy = b.y - a.y;
2230
+ const dl = Math.sqrt(dx*dx + dy*dy) || 1;
2231
  const nx = -dy / dl, ny = dx / dl;
2232
  const f = i / (n - 1);
2233
  const hw = (w.width * (1 - f) + w.tipW * f) / 2;
 
2248
  // side branches curling up and out from the stalk
2249
  for (const b of w.branches) {
2250
  const a = w.points[Math.min(w.segs, Math.max(1, Math.round(b.f * w.segs)))];
2251
+ // v15: S-curved frond — leaves the stalk heading upward, then arcs out and up
2252
  // to the tip instead of one plain quadratic bow (same endpoints as before)
2253
  const c1x = a.x + b.side * b.len * 0.3;
2254
  const c1y = a.y - b.up * 0.75;
 
2261
  ctx.bezierCurveTo(c1x, c1y, c2x, c2y, b.tx, b.ty);
2262
  ctx.stroke();
2263
 
2264
+ // colourful flower heads blooming along the branch — 1..3 per branch (v3)
2265
  if (b.flowers.length) {
2266
  for (const fl of b.flowers) {
2267
  const u = fl.t, iu = 1 - u; // point on the branch's cubic curve (v15)
 
2294
 
2295
  function drawGrass(t) {
2296
  for (const g of grass) {
2297
+ // v16: tapered multi-segment blade — wide at root, thinning to a soft point
2298
  const amp = 3 + g.ripple * 13;
2299
  const n = g.segs + 1; // number of points along the blade
2300
  const pts = [];
 
2303
  // organic rest-curve: each blade has its own natural bend (like seaweed)
2304
  const curveX = f * g.cA1 * Math.sin(f * g.cF1 + g.cP1);
2305
  // constant sway, amplified while rippling from a passing fish
2306
+ const sway = fastSin(t * g.speed + g.phase + f * 2.4) * amp * f;
2307
  // v17: secondary wobble - finer inner movement for unique per-blade motion
2308
+ const sWobble = fastSin(t * g.sFreq + g.sPh + f * 3.8) * g.sAmp * f;
2309
  pts.push({
2310
  x: g.x + g.lean * g.h * f + curveX + sway + sWobble,
2311
  y: g.y - g.h * f * (1 - g.ripple * 0.06)
2312
  });
2313
  }
2314
  // draw tapered outline around the physics points
2315
+ ctx.fillStyle = g.fillStyle;
2316
  ctx.beginPath();
2317
  for (let i = 0; i < n; i++) {
2318
  const p = pts[i];
2319
  const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
2320
  let dx = b.x - a.x, dy = b.y - a.y;
2321
+ const dl = Math.sqrt(dx*dx + dy*dy) || 1;
2322
  const nx = -dy / dl, ny = dx / dl; // perpendicular to local blade direction
2323
  const f = i / (n - 1);
2324
+ const hw = (g.width * (1 - f) + 0.8 * f) / 2; // half-width: base → tip taper
2325
  if (i === 0) ctx.moveTo(p.x + nx * hw, p.y + ny * hw);
2326
  else ctx.lineTo(p.x + nx * hw, p.y + ny * hw);
2327
  }
 
2329
  {
2330
  const tp = pts[n - 1], pp = pts[n - 2];
2331
  let dx = tp.x - pp.x, dy = tp.y - pp.y;
2332
+ const dl = Math.sqrt(dx*dx + dy*dy) || 1;
2333
  const nx = -dy / dl, ny = dx / dl;
2334
  const hwT = 0.4; // tip half-width
2335
  ctx.quadraticCurveTo(tp.x + dx, tp.y + dy * 0.6, tp.x - nx * hwT, tp.y - ny * hwT);
 
2338
  const p = pts[i];
2339
  const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
2340
  let dx = b.x - a.x, dy = b.y - a.y;
2341
+ const dl = Math.sqrt(dx*dx + dy*dy) || 1;
2342
  const nx = -dy / dl, ny = dx / dl;
2343
  const f = i / (n - 1);
2344
  const hw = (g.width * (1 - f) + 0.8 * f) / 2;
 
2349
  }
2350
  }
2351
 
2352
+ // v12: foreground grass sways with the water — decorative only, no physics.
2353
  // Drawn on top of everything else (it is closest to the viewer). Each blade gets a
2354
  // thin lighter inner stroke over a darker body for a lush, voluminous "closer" look.
2355
  function drawFgGrass(t) {
2356
  for (const g of fgGrass) {
2357
+ // v28: defocused blades — blit the pre-blurred sprite with a gentle rotational sway
2358
  // around its base (same trick as the ultra-far seaweed). The baked blur hides the
2359
  // simplified motion, and this path is far cheaper than per-frame canvas filters.
2360
  if (g.sprite) {
2361
+ const swayAngle = fastSin(t * g.speed + g.phase) * 0.05; // subtle: like farSeaweeds
2362
  ctx.save();
2363
  ctx.translate(g.x, g.y);
2364
  ctx.rotate(swayAngle);
 
2375
  // organic rest-curve: each blade has its own natural bend
2376
  const curveX = f * g.cA1 * Math.sin(f * g.cF1 + g.cP1);
2377
  // constant sway, weighted toward the tip
2378
+ const sway = fastSin(t * g.speed + g.phase + f * 2.4) * amp * f;
2379
  // v17: secondary wobble - finer inner movement for unique per-blade motion
2380
+ const sWobble = fastSin(t * g.sFreq + g.sPh + f * 4.5) * g.sAmp * f;
2381
  pts.push({
2382
  x: g.x + g.lean * g.h * f + curveX + sway + sWobble,
2383
  y: g.y - g.h * f
2384
  });
2385
  }
2386
  // dark body of the blade (tapered outline)
2387
+ ctx.fillStyle = g.fillStyle;
2388
  ctx.beginPath();
2389
  for (let i = 0; i < n; i++) {
2390
  const p = pts[i];
2391
  const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
2392
  let dx = b.x - a.x, dy = b.y - a.y;
2393
+ const dl = Math.sqrt(dx*dx + dy*dy) || 1;
2394
  const nx = -dy / dl, ny = dx / dl;
2395
  const f = i / (n - 1);
2396
+ const hw = (g.width * (1 - f) + 1.2 * f) / 2; // half-width: base → tip
2397
  if (i === 0) ctx.moveTo(p.x + nx * hw, p.y + ny * hw);
2398
  else ctx.lineTo(p.x + nx * hw, p.y + ny * hw);
2399
  }
 
2401
  {
2402
  const tp = pts[n - 1], pp = pts[n - 2];
2403
  let dx = tp.x - pp.x, dy = tp.y - pp.y;
2404
+ const dl = Math.sqrt(dx*dx + dy*dy) || 1;
2405
  const nx = -dy / dl, ny = dx / dl;
2406
  const hwT = 0.6; // tip half-width
2407
  ctx.quadraticCurveTo(tp.x + dx, tp.y + dy * 0.6, tp.x - nx * hwT, tp.y - ny * hwT);
 
2410
  const p = pts[i];
2411
  const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
2412
  let dx = b.x - a.x, dy = b.y - a.y;
2413
+ const dl = Math.sqrt(dx*dx + dy*dy) || 1;
2414
  const nx = -dy / dl, ny = dx / dl;
2415
  const f = i / (n - 1);
2416
  const hw = (g.width * (1 - f) + 1.2 * f) / 2;
 
2420
  ctx.fill();
2421
 
2422
  // lighter inner highlight for depth: a thin centerline stroke
2423
+ ctx.strokeStyle = g.strokeStyle;
2424
  ctx.lineWidth = g.width * 0.3;
2425
  ctx.lineCap = "round";
2426
  ctx.beginPath();
 
2474
  let grainOx = 0, grainOy = 0, lastGrainTime = 0;
2475
  function drawGrain() {
2476
  if (!grainPattern) return;
2477
+ // v17: half-speed grain — re-randomize the offset every other frame instead of every frame
2478
  const now = performance.now();
2479
  if (now - lastGrainTime >= 33) { // ~30fps grain updates instead of per-frame (~60fps)
2480
  grainOx = (Math.random() * GRAIN_TILE) | 0;
 
2495
  }
2496
  let lastGrainDrawn = false;
2497
 
2498
+ // Precomputed static layers — the sand and rocks never move, so they are
2499
  // rendered once to an offscreen canvas and blitted each frame (v6) instead of
2500
  // re-drawing their gradients and paths every single frame.
2501
+ // PERF: half-resolution offscreen buffer for the light rays (see drawRays) — the
2502
  // per-ray blur() filter is far cheaper rasterizing small areas than full-screen.
2503
 
2504
 
2505
+ // v24: baked current ripples — faint curved lines following the sand contour
2506
  function drawRipples(c) {
2507
  c.save();
2508
  c.lineCap = "round";
 
2527
  c.restore();
2528
  }
2529
 
2530
+ // v24: baked pebble/speckle scatter — grainy cool-toned dots across the sand bed
2531
  function drawPebbles(c) {
2532
  const n = Math.floor(W * H / 1800);
2533
  for (let i = 0; i < n; i++) {
 
2557
  }
2558
  }
2559
 
2560
+ // v24: contact shadows — soft dark ellipses under rocks + faint band at sand surface line
2561
  function drawContactShadows(c) {
2562
  // Faint darker band right at the sand surface (grounds the whole bed)
2563
  // FIXED: follows the actual sand contour instead of a fixed horizontal band
 
2599
  drawRipples(c2); // v24: baked current ripples (free at runtime)
2600
  drawPebbles(c2); // v24: baked grainy pebble/speckle scatter
2601
  drawContactShadows(c2); // v24: soft dark ellipses under rocks + surface band
2602
+ // NOTE: rocks are no longer baked here — they live in rocksLayer so they can be
2603
  // drawn AFTER the mid-ground grass (rocks sit IN FRONT of the blades, not behind them).
2604
  }
2605
  // Rocks on their own layer: blitted after drawGrass() so stones read as sitting in
 
2618
  let causticTile = null;
2619
  let causticOffsetX = 0, causticOffsetY = 0;
2620
  let lastCausticShift = 0;
2621
+ const CAUSTIC_TILE_SIZE = 160; // small tile — cheap to blit repeatedly
2622
 
2623
  function buildCausticTile() {
2624
  causticTile = document.createElement("canvas");
 
2717
  ctx.globalCompositeOperation = "screen"; // additive light
2718
 
2719
  for (const r of rays) {
2720
+ // Compute where this ray hits the sand at y ≈ sandTop
2721
  // Use the same wave math as the ray drawing to match positions
2722
+ const off = fastSin(t * r.speed * 6 + r.x) * 55;
2723
  // At the sand depth (vM near the bottom), compute the x position
2724
  const vM = 0.95; // approximate depth where sand starts
2725
  const wave =
 
2785
  drawRays(t, light);
2786
  updateMotes(dt, t);
2787
 
2788
+ // v5: ultra-far pre-rendered blurry seaweed — deepest layer, behind everything else
2789
  drawFarSeaweeds(t);
2790
 
2791
  // v6: middle-distance seaweed with halo pass (between far and back)
2792
  drawMidSeaweeds(t);
2793
 
2794
+ // v4: background seaweed — drawn behind sand/rocks for depth
2795
  drawBackSeaweeds(t); // v4.3: procedural multi-wave motion (no separate update pass)
2796
 
2797
  updatePlants(t, dt);
 
2805
  // Build spatial grid for collision detection
2806
  buildGrid();
2807
 
2808
+ // v7: fish gently bump each other - overlapping pairs are eased apart along their
2809
  // center line (weighted by size), so they nudge instead of stacking on top of each other
2810
+ // Uses spatial grid for O(n) average instead of O(n²)
2811
+ // Track processed pairs by ID to avoid double-checking (which causes twitching when sleeping)
2812
+ const checked = new Set();
2813
+ for (const a of fishes) {
2814
+ const nearby = getFishNear(a.x, a.y);
2815
+ for (const b of nearby) {
2816
+ if (a === b) continue;
2817
+ // Only check each pair once using fish IDs
2818
+ const pairKey = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`;
2819
+ if (checked.has(pairKey)) continue;
2820
+ checked.add(pairKey);
2821
  let dx = b.x - a.x, dy = b.y - a.y;
2822
  const minD = (a.size + b.size) * 0.62; // gentle: smaller than their visual length
2823
  const d2 = dx * dx + dy * dy;
2824
+ if (d2 <= 0.01) { // exactly overlapping - nudge apart randomly
2825
  a.x -= rand(0.5, 1.5); a.y -= rand(0.5, 1.5);
2826
  b.x += rand(0.5, 1.5); b.y += rand(0.5, 1.5);
2827
  } else if (d2 < minD * minD) {
 
2835
  }
2836
  }
2837
  }
 
 
2838
  if (t - lastSortTime > 0.1) {
2839
  // Sort fish by size for depth ordering - only re-sort every 100ms
2840
  if (t - lastSortTime > 0.1) {
 
2872
  const b = bubbles[i];
2873
  b.update(dt, t);
2874
  if (!b.dead) {
2875
+ // v10: a fish brushing a bubble pops it — unless it's that same fish's own bubble
2876
  // Optimized: use spatial grid instead of checking all fish
2877
  const nearby = getFishNear(b.x, b.y);
2878
  for (const f of nearby) {
 
2882
  if (dx * dx + dy * dy < rr * rr) { b.dead = true; break; }
2883
  }
2884
  }
2885
+ if (b.dead) { returnBubble(b); bubbles.splice(i, 1); continue; }
2886
  b.draw(ctx);
2887
  }
2888
 
2889
+ drawFgGrass(t); // v12: foreground grass — closest to the viewer, drawn on top
2890
 
2891
  // ambient day/night wash over the whole scene
2892
  if (light.washA > 0.005) {
 
2979
  const ly = Math.abs(pad.axes[1]) > GAMEPAD_DEADZONE ? pad.axes[1] : 0;
2980
  dx += lx;
2981
  dy += ly;
2982
+ if (pad.axes[3] > GAMEPAD_DEADZONE) boost = Math.max(boost, pad.axes[3]); // down → faster
2983
+ if (pad.axes[3] < -GAMEPAD_DEADZONE) slow = Math.max(slow, -pad.axes[3]); // up → slower
2984
  break; // only the first connected pad
2985
  }
2986
  }
 
2997
  doResize();
2998
  requestAnimationFrame(frame);
2999
  })();
 
 
 
 
 
 
3000
 
3001
+ </script>
3002
  </body>
3003
  </html>