One commited on
Commit
4cc5b6b
·
verified ·
1 Parent(s): 95acd79

Upload index.html

Browse files
Files changed (1) hide show
  1. index.html +301 -174
index.html CHANGED
@@ -23,6 +23,8 @@
23
  (() => {
24
  "use strict";
25
  // ============ Aquarium v16 WHISKERS — alt-fg base + spots/stripes + whiskers (~10%) ==
 
 
26
  // Based on v15 SPOTTY-3 (rolling hills, three tail styles, yellow-green foreground grass,
27
  // ~20% spots / ~20% stripes / ~10% both), plus: an independent roll gives ~10% of fish
28
  // whiskers (barbels) — a forward-curving pair from the snout in one of THREE lengths
@@ -290,6 +292,10 @@ class Fish {
290
  this.roll = 0; // current roll angle (rad); 0 = upright
291
  this.rollVel = 0; // angular velocity of the roll
292
  this.inHardTurn = false;// was it in a sharp turn last frame?
 
 
 
 
293
  }
294
  update(dt) {
295
  const night = personalNight(performance.now() / 1000, this); // v10: per-fish early/normal/late sleep & wake
@@ -460,6 +466,12 @@ class Fish {
460
  if (this.roll > 1.15) { this.roll = 1.15; this.rollVel *= -0.25; }
461
  else if (this.roll < -1.15) { this.roll = -1.15; this.rollVel *= -0.25; }
462
 
 
 
 
 
 
 
463
  // Player fish: input-driven movement (keyboard WASD + gamepad sticks),
464
  // overrides the AI cruising so the player is fully in control.
465
  if (this === playerFish) {
@@ -508,12 +520,110 @@ class Fish {
508
  c.save();
509
  c.translate(this.x, this.y);
510
  c.rotate(this.angle);
511
- // body roll from banking into sharp turns: squash vertically as it rolls,
512
- // plus a tiny wobble so the motion reads as a real roll, not a glitch
513
- if (Math.abs(this.roll) > 0.01) {
514
- c.scale(1, Math.max(0.42, Math.cos(this.roll)));
 
 
 
 
 
 
515
  c.rotate(this.roll * 0.1);
516
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
517
  const s = this.size;
518
  // traveling body wave (v3): ripples head→tail so the fish undulates like a real one
519
  const waveK = Math.PI / s; // ~one wavelength across the body length
@@ -521,6 +631,11 @@ class Fish {
521
  // tail fin follows the body wave at its base, amplified (same phase → no fighting)
522
  const sway = Math.sin(-s * waveK + this.phase) * s * 0.34;
523
 
 
 
 
 
 
524
  // tail fin (v7 rounded / v11 three styles): each tip is swept around by a curve
525
  // whose control point sits just past where the old spike was. The style is fixed
526
  // per fish at spawn so the school has variety in its tails.
@@ -531,23 +646,23 @@ class Fish {
531
  // Broad, rounded fan (betta/goldfish-style): wide spread, tips high and far out,
532
  // with only a shallow dip between the two lobes.
533
  c.moveTo(-s * 0.5, 0);
534
- c.quadraticCurveTo(-s * 1.3, -s * 0.28 + sway, -s * 1.42, -s * 0.5 + sway); // up to upper tip
535
- c.quadraticCurveTo(-s * 1.12, sway * 0.5, -s * 1.42, s * 0.5 + sway); // shallow notch → lower tip
536
- c.quadraticCurveTo(-s * 1.3, s * 0.28 + sway, -s * 0.5, 0); // back to the base
537
  } else if (this.tailType === 'streamer') {
538
  // Long forked streamers (angelfish/fancy-guppy-style): two flowing lobes that
539
  // reach further back with a deeper notch between them.
540
  c.moveTo(-s * 0.5, 0);
541
- c.quadraticCurveTo(-s * 1.35, -s * 0.24 + sway, -s * 1.68, -s * 0.56 + sway); // long upper lobe
542
- c.quadraticCurveTo(-s * 0.98, sway * 0.35, -s * 1.68, s * 0.56 + sway); // deep fork → lower tip
543
- c.quadraticCurveTo(-s * 1.35, s * 0.24 + sway, -s * 0.5, 0); // long lower lobe back to base
544
  } else {
545
  // classic (v7): rounded lobes with a soft notch — the original look
546
  c.moveTo(-s * 0.5, 0);
547
- c.quadraticCurveTo(-s * 1.04, -s * 0.36 + sway, -s * 1.22, -s * 0.44 + sway); // upper edge out toward the tip
548
- c.quadraticCurveTo(-s * 1.34, -s * 0.52 + sway, -s * 0.86, sway * 0.5); // rounded upper lobe → notch
549
- c.quadraticCurveTo(-s * 1.34, s * 0.52 + sway, -s * 1.22, s * 0.44 + sway); // notch → rounded lower lobe
550
- c.quadraticCurveTo(-s * 1.04, s * 0.36 + sway, -s * 0.5, 0); // lower edge back to the base
551
  }
552
  c.fill();
553
 
@@ -565,13 +680,15 @@ class Fish {
565
  {
566
  const side = -1; // far side (behind the body)
567
  const rootX = s * 0.6;
568
- const rootY = side * s * 0.09;
 
 
569
  c.beginPath();
570
  c.moveTo(rootX, rootY);
571
  for (let i = 1; i <= NPTS; i++) {
572
  const t = i / NPTS;
573
  const bx = rootX - L * (0.25 + 0.65 * spd) * t;
574
- const by = rootY + side * L * 0.42 * t * t;
575
  const amp = s * 0.17 * t * (0.35 + 0.65 * spd);
576
  const off = Math.sin(t * 5.5 - this.phase * 1.4 + this.whiskPh) * amp;
577
  c.lineTo(bx, by + off);
@@ -712,15 +829,39 @@ class Fish {
712
  c.closePath();
713
  c.fill();
714
 
715
- // eye
716
- c.fillStyle = "#fff";
717
- c.beginPath();
718
- c.arc(s * 0.58, -s * 0.1, s * 0.11, 0, Math.PI * 2);
719
- c.fill();
720
- c.fillStyle = "#0d1420";
721
- c.beginPath();
722
- c.arc(s * 0.61, -s * 0.1, s * 0.055, 0, Math.PI * 2);
723
- c.fill();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
724
 
725
  // v21: near-side whisker — drawn AFTER the body so it sits in front of the fish.
726
  // (The far-side whisker is drawn before the body, above.) Together they form a
@@ -738,13 +879,14 @@ class Fish {
738
  {
739
  const side = 1; // near side (in front of the body)
740
  const rootX = s * 0.6;
741
- const rootY = side * s * 0.09;
 
742
  c.beginPath();
743
  c.moveTo(rootX, rootY);
744
  for (let i = 1; i <= NPTS; i++) {
745
  const t = i / NPTS;
746
  const bx = rootX - L * (0.25 + 0.65 * spd) * t;
747
- const by = rootY + side * L * 0.42 * t * t;
748
  const amp = s * 0.17 * t * (0.35 + 0.65 * spd);
749
  const off = Math.sin(t * 5.5 - this.phase * 1.4 + this.whiskPh) * amp;
750
  c.lineTo(bx, by + off);
@@ -756,51 +898,27 @@ class Fish {
756
 
757
  c.restore();
758
 
759
- // v17: overhead light — a soft elliptical highlight matching the fish body shape,
760
- // positioned on the upper edge and rotated with the fish's angle.
 
761
  {
762
- const ca = Math.cos(this.angle), sa = Math.sin(this.angle);
763
- // Find which side faces up (screen space)
764
- const upY = -sa; // screen-space y-component of the fish's local "up" direction
765
- // Smooth transition instead of hard switch to prevent flickering/snapping
766
- const sideUp = -upY / (Math.abs(upY) + 0.15);
767
-
768
  const a = 0.35 * (1 - curNight * 0.65); // dimmer at night, still present
769
  if (a > 0.02) {
770
- c.save();
771
- // Position the ellipse on the upper edge of the fish body
772
- const offsetX = sideUp * s * 0.18; // offset along the fish's local up axis
773
- const lightX = this.x + offsetX * ca;
774
- const lightY = this.y + offsetX * sa;
775
-
776
- // Draw two concentric ellipses matching the fish body proportions
777
- c.translate(lightX, lightY);
778
- c.rotate(this.angle);
779
-
780
- // Outer soft glow — larger and more transparent
781
  c.fillStyle = `rgba(255,255,255,${(a * 0.3).toFixed(3)})`;
782
  c.beginPath();
783
- c.ellipse(0, 0, s * 0.65, s * 0.25, 0, 0, Math.PI * 2);
784
  c.fill();
785
-
786
- // Inner bright core — smaller and more opaque
787
  c.fillStyle = `rgba(255,255,255,${a.toFixed(3)})`;
788
  c.beginPath();
789
- c.ellipse(0, 0, s * 0.35, s * 0.1, 0, 0, Math.PI * 2);
790
  c.fill();
791
-
792
- c.restore();
793
  }
794
  }
795
- // v19: pectoral fins drawn OVER the reflections for correct layering
 
 
796
  {
797
  c.save();
798
- c.translate(this.x, this.y);
799
- c.rotate(this.angle);
800
- if (Math.abs(this.roll) > 0.01) {
801
- c.scale(1, Math.max(0.42, Math.cos(this.roll)));
802
- c.rotate(this.roll * 0.1);
803
- }
804
 
805
  // v20: render the pectoral fins at 50% opacity
806
  c.globalAlpha = 0.5;
@@ -1017,6 +1135,8 @@ function buildScene() {
1017
  const hue = clusterHue + rand(-15, 15);
1018
  // v4.3: depth split — ~51% "far" (heavy softness), ~49% "near" (sharper) for a layered look
1019
  const near = Math.random() < 0.49;
 
 
1020
  const st = {
1021
  x: cx + rand(-20, 20),
1022
  h: rand(H * 0.35, H * 0.65), // much taller than foreground seaweed
@@ -1025,8 +1145,8 @@ function buildScene() {
1025
  speed: rand(0.7, 1.3), // slower, more gentle sway
1026
  hue,
1027
  near,
1028
- width: near ? rand(2.5, 5) : rand(3, 6),
1029
- tipW: near ? rand(1.2, 2.4) : rand(1.5, 3),
1030
  branches: [],
1031
  // organic curve parameters (same as foreground seaweed)
1032
  cA1: rand(8, 15), cF1: rand(0.8, 1.6), cP1: rand(0, Math.PI * 2),
@@ -1034,17 +1154,24 @@ function buildScene() {
1034
  // v4.3: multi-wave organic motion parameters
1035
  waveA1: rand(6, 14), waveF1: rand(0.5, 1.2), wavePh1: rand(0, Math.PI * 2),
1036
  waveA2: rand(3, 8), waveF2: rand(1.3, 2.1), wavePh2: rand(0, Math.PI * 2),
1037
- waveA3: rand(2, 5), waveF3: rand(2.4, 3.6), wavePh3: rand(0, Math.PI * 2)
 
 
 
 
 
1038
  };
1039
  // v4.1: add branches to background seaweed (like foreground)
1040
  const nBr = 1 + ((Math.random() * 3) | 0); // 1..3 branches per stalk
1041
  for (let k = 0; k < nBr; k++) {
 
1042
  st.branches.push({
1043
  f: rand(0.25, 0.85), // where along the stalk it sprouts
1044
  side: Math.random() < 0.5 ? -1 : 1,
1045
  len: rand(10, 24), // horizontal reach (slightly smaller than fg)
1046
  up: rand(8, 20), // how far it curls upward
1047
- w: rand(1.5, 3),
 
1048
  ph: rand(0, Math.PI * 2)
1049
  });
1050
  }
@@ -1062,6 +1189,8 @@ function buildScene() {
1062
  const nStalks = 2 + ((Math.random() * 3) | 0);
1063
  for (let j = 0; j < nStalks; j++) {
1064
  const hue = clusterHue + rand(-12, 12);
 
 
1065
  // Median properties: height between backSeaweeds (0.35-0.65H) and farSeaweeds (0.4-0.7H)
1066
  const st = {
1067
  x: cx + rand(-22, 22),
@@ -1070,8 +1199,8 @@ function buildScene() {
1070
  phase: rand(0, Math.PI * 2),
1071
  speed: rand(0.6, 1.15), // median sway speed between back (0.7-1.3) and far (0.5-1.0)
1072
  hue,
1073
- width: rand(3, 6.5), // median width
1074
- tipW: rand(1.8, 3.2),
1075
  branches: [],
1076
  // Organic curve parameters (like backSeaweeds for halo pass technique)
1077
  cA1: rand(9, 16), cF1: rand(0.7, 1.5), cP1: rand(0, Math.PI * 2),
@@ -1079,17 +1208,24 @@ function buildScene() {
1079
  // Multi-wave organic motion parameters
1080
  waveA1: rand(7, 15), waveF1: rand(0.45, 1.1), wavePh1: rand(0, Math.PI * 2),
1081
  waveA2: rand(3.5, 9), waveF2: rand(1.2, 2.0), wavePh2: rand(0, Math.PI * 2),
1082
- waveA3: rand(2.5, 6), waveF3: rand(2.2, 3.4), wavePh3: rand(0, Math.PI * 2)
 
 
 
 
 
1083
  };
1084
  // Add branches (median count between back and far)
1085
  const nBr = 1 + ((Math.random() * 3) | 0);
1086
  for (let k = 0; k < nBr; k++) {
 
1087
  st.branches.push({
1088
  f: rand(0.28, 0.82),
1089
  side: Math.random() < 0.5 ? -1 : 1,
1090
  len: rand(11, 26),
1091
  up: rand(9, 22),
1092
- w: rand(1.8, 3.5),
 
1093
  ph: rand(0, Math.PI * 2)
1094
  });
1095
  }
@@ -1324,12 +1460,21 @@ function personalNight(t, f) {
1324
  return c(g + f.riseShift);
1325
  }
1326
 
 
 
1327
  function drawBackground(light) {
1328
- const g = ctx.createLinearGradient(0, 0, 0, H);
1329
- g.addColorStop(0, rgbStr(light.top));
1330
- g.addColorStop(0.5, rgbStr(light.mid));
1331
- g.addColorStop(1, rgbStr(light.bot));
1332
- ctx.fillStyle = g;
 
 
 
 
 
 
 
1333
  ctx.fillRect(0, 0, W, H);
1334
  }
1335
 
@@ -1541,87 +1686,76 @@ function stalkCurveX(w, f) {
1541
  return f * (w.cA1 * Math.sin(f * w.cF1 + w.cP1) + w.cA2 * Math.sin(f * w.cF2 + w.cP2));
1542
  }
1543
 
 
 
 
 
 
 
 
1544
  // v4.3: draw background seaweed procedurally — multi-wave organic motion instead of
1545
  // rigid sprite rotation. Far stalks get a soft low-alpha halo pass to keep their
1546
  // "far away" look without any runtime blur filters (no slowdown).
1547
  // v6: middle-distance seaweed using halo stroke pass (median depth between back and far)
1548
  function drawMidSeaweeds(t) {
 
 
 
1549
  for (const w of midSeaweeds) {
1550
  const baseY = sandAt(w.x) + 10;
1551
  const n = w.segs;
1552
- // Slightly more blue-tinted and softer than backSeaweeds
1553
- const coreStyle = `hsla(${w.hue},48%,30%,0.65)`;
1554
- const haloStyle = `hsla(${w.hue},42%,34%,0.16)`;
1555
-
1556
- // stalk sway at fraction f: three overlapping waves, root planted (f^2), tip free (f)
1557
- const swayAt = (f) =>
1558
- Math.sin(t * w.waveF1 + w.wavePh1 + f * 2.4) * w.waveA1 * f * f +
1559
- Math.sin(t * w.waveF2 + w.wavePh2 + f * 3.8) * w.waveA2 * f * f +
1560
- Math.sin(t * w.waveF3 + w.wavePh3 + f * 5.4) * w.waveA3 * f;
1561
 
1562
  // Wider halo pass for more softness (between backSeaweeds and farSeaweeds)
1563
  for (let pass = 0; pass < 2; pass++) {
1564
  const isHalo = pass === 0;
1565
- ctx.strokeStyle = isHalo ? haloStyle : coreStyle;
1566
- ctx.lineCap = 'round';
1567
- ctx.lineJoin = 'round';
1568
- ctx.lineWidth = (w.width + w.tipW) / 2 * (isHalo ? 2.2 : 1); // wider halo than backSeaweeds
1569
 
1570
- // stalk outline: up one side, round tip, down the other
1571
  ctx.beginPath();
1572
- let prevX = 0, prevY = baseY;
1573
- let lastPx = 0, lastPy = baseY;
1574
  for (let i = 0; i <= n; i++) {
1575
  const f = i / n;
1576
- const px = w.x + stalkCurveX(w, f) + swayAt(f);
1577
- const py = baseY - w.h * f;
1578
  const dx = px - prevX, dy = py - prevY;
1579
- const dl = Math.hypot(dx, dy) || 1;
1580
  const nx = -dy / dl, ny = dx / dl;
1581
- const hw = (w.width * (1 - f) + w.tipW * f) / 2;
1582
  if (i === 0) ctx.moveTo(px + nx * hw, py + ny * hw);
1583
  else ctx.lineTo(px + nx * hw, py + ny * hw);
1584
  prevX = px; prevY = py;
1585
- lastPx = px; lastPy = py;
1586
  }
1587
- // round tip cap
1588
- const dxT = lastPx - prevX, dyT = lastPy - prevY;
1589
- const dlT = Math.hypot(dxT, dyT) || 1;
1590
- const nxT = -dyT / dlT, nyT = dxT / dlT;
1591
- ctx.quadraticCurveTo(lastPx, lastPy, lastPx + dxT * 0.5 + nxT * (w.tipW / 2), lastPy + dyT * 0.5 + nyT * (w.tipW / 2));
1592
- // down the other side
1593
- for (let i = n; i >= 0; i--) {
1594
  const f = i / n;
1595
- const px = w.x + stalkCurveX(w, f) + swayAt(f);
1596
- const py = baseY - w.h * f;
1597
- let dx, dy;
1598
- if (i === n) { dx = prevX - px; dy = prevY - py; }
1599
- else {
1600
- const f2 = (i + 1) / n;
1601
- const px2 = w.x + stalkCurveX(w, f2) + swayAt(f2);
1602
- const py2 = baseY - w.h * f2;
1603
- dx = px2 - px; dy = py2 - py;
1604
- }
1605
- const dl = Math.hypot(dx, dy) || 1;
1606
  const nx = -dy / dl, ny = dx / dl;
1607
- const hw = (w.width * (1 - f) + w.tipW * f) / 2;
1608
  ctx.lineTo(px - nx * hw, py - ny * hw);
1609
  }
1610
  ctx.closePath();
1611
  ctx.stroke();
1612
 
1613
- // branches flutter with the stalk
1614
  for (const b of w.branches) {
1615
  const idx = Math.min(n, Math.max(1, Math.round(b.f * n)));
1616
- const f = idx / n;
1617
- const ax = w.x + stalkCurveX(w, f) + swayAt(f);
1618
- const ay = baseY - w.h * f;
1619
  const flutter = Math.sin(t * w.waveF2 * 1.4 + b.ph) * (3 + w.waveA2 * 0.4);
1620
  const bx = ax + b.side * b.len + flutter;
1621
  const by = ay - b.up;
1622
  ctx.beginPath();
1623
- ctx.lineWidth = isHalo ? b.w * 2.2 : b.w;
1624
- ctx.strokeStyle = isHalo ? haloStyle : `hsla(${w.hue + 6},50%,40%,0.7)`;
1625
  ctx.moveTo(ax, ay);
1626
  ctx.bezierCurveTo(
1627
  ax + b.side * b.len * 0.3, ay - b.up * 0.75,
@@ -1637,82 +1771,63 @@ function drawMidSeaweeds(t) {
1637
  function drawBackSeaweeds(t) {
1638
  // v6: no runtime canvas filter — blur() forces a CPU re-rasterize every frame
1639
  // and was the main FPS cost. Softness now comes from the halo pass below instead.
 
 
1640
  for (const w of backSeaweeds) {
1641
  const baseY = sandAt(w.x) + 10;
1642
  const n = w.segs;
1643
- const coreStyle = `hsla(${w.hue},50%,32%,${w.near ? 0.8 : 0.7})`;
1644
- const haloStyle = `hsla(${w.hue},45%,36%,0.18)`;
1645
-
1646
- // stalk sway at fraction f: three overlapping waves, root planted (f^2), tip free (f)
1647
- const swayAt = (f) =>
1648
- Math.sin(t * w.waveF1 + w.wavePh1 + f * 2.4) * w.waveA1 * f * f +
1649
- Math.sin(t * w.waveF2 + w.wavePh2 + f * 3.8) * w.waveA2 * f * f +
1650
- Math.sin(t * w.waveF3 + w.wavePh3 + f * 5.4) * w.waveA3 * f;
1651
 
1652
  // v6: near stalks also get a halo pass (replaces the old blur(2px) filter)
1653
  for (let pass = 0; pass < 2; pass++) {
1654
- const isHalo = pass === 0; // v6: every stalk gets a halo pass (replaces the old blur filter)
1655
- ctx.strokeStyle = isHalo ? haloStyle : coreStyle;
1656
- ctx.lineCap = 'round';
1657
- ctx.lineJoin = 'round';
1658
- ctx.lineWidth = (w.width + w.tipW) / 2 * (isHalo ? 1.9 : 1);
1659
 
1660
- // stalk outline: up one side, round tip, down the other
1661
  ctx.beginPath();
1662
- let prevX = 0, prevY = baseY;
1663
- let lastPx = 0, lastPy = baseY; // track final position for tip cap
1664
  for (let i = 0; i <= n; i++) {
1665
  const f = i / n;
1666
- const px = w.x + stalkCurveX(w, f) + swayAt(f);
1667
- const py = baseY - w.h * f;
1668
  const dx = px - prevX, dy = py - prevY;
1669
- const dl = Math.hypot(dx, dy) || 1;
1670
  const nx = -dy / dl, ny = dx / dl;
1671
- const hw = (w.width * (1 - f) + w.tipW * f) / 2;
1672
  if (i === 0) ctx.moveTo(px + nx * hw, py + ny * hw);
1673
  else ctx.lineTo(px + nx * hw, py + ny * hw);
1674
  prevX = px; prevY = py;
1675
- lastPx = px; lastPy = py;
1676
  }
1677
- // round tip cap (follows the stalk's actual direction)
1678
- const dxT = lastPx - prevX, dyT = lastPy - prevY;
1679
- const dlT = Math.hypot(dxT, dyT) || 1;
1680
- const nxT = -dyT / dlT, nyT = dxT / dlT;
1681
- ctx.quadraticCurveTo(lastPx, lastPy, lastPx + dxT * 0.5 + nxT * (w.tipW / 2), lastPy + dyT * 0.5 + nyT * (w.tipW / 2));
1682
- // down the other side
1683
- for (let i = n; i >= 0; i--) {
1684
  const f = i / n;
1685
- const px = w.x + stalkCurveX(w, f) + swayAt(f);
1686
- const py = baseY - w.h * f;
1687
- // perpendicular from this segment direction
1688
- let dx, dy;
1689
- if (i === n) { dx = prevX - px; dy = prevY - py; }
1690
- else {
1691
- const f2 = (i + 1) / n;
1692
- const px2 = w.x + stalkCurveX(w, f2) + swayAt(f2);
1693
- const py2 = baseY - w.h * f2;
1694
- dx = px2 - px; dy = py2 - py;
1695
- }
1696
- const dl = Math.hypot(dx, dy) || 1;
1697
  const nx = -dy / dl, ny = dx / dl;
1698
- const hw = (w.width * (1 - f) + w.tipW * f) / 2;
1699
  ctx.lineTo(px - nx * hw, py - ny * hw);
1700
  }
1701
  ctx.closePath();
1702
  ctx.stroke();
1703
 
1704
- // branches flutter with the stalk
1705
  for (const b of w.branches) {
1706
  const idx = Math.min(n, Math.max(1, Math.round(b.f * n)));
1707
- const f = idx / n;
1708
- const ax = w.x + stalkCurveX(w, f) + swayAt(f);
1709
- const ay = baseY - w.h * f;
1710
  const flutter = Math.sin(t * w.waveF2 * 1.4 + b.ph) * (3 + w.waveA2 * 0.4);
1711
  const bx = ax + b.side * b.len + flutter;
1712
  const by = ay - b.up;
1713
  ctx.beginPath();
1714
- ctx.lineWidth = isHalo ? b.w * 1.9 : b.w;
1715
- ctx.strokeStyle = isHalo ? haloStyle : `hsla(${w.hue + 8},55%,42%,0.75)`;
1716
  ctx.moveTo(ax, ay);
1717
  ctx.bezierCurveTo(
1718
  ax + b.side * b.len * 0.3, ay - b.up * 0.75,
@@ -2503,7 +2618,25 @@ function drawCaustics(t) {
2503
  ctx.restore();
2504
  }
2505
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2506
  function drawLightPooling(t) {
 
2507
  // Draw faint bright patches where each god-ray hits the sand
2508
  const sandTop = H * 0.85;
2509
  ctx.save();
@@ -2520,16 +2653,10 @@ function drawLightPooling(t) {
2520
  Math.sin(vM * r.vLen - t * r.vSpd + r.vPh) * r.vAmp;
2521
  const dx = r.x + off + wave * (0.35 + 0.65 * vM);
2522
 
2523
- // Draw a soft bright ellipse at this position on the sand
2524
  const poolW = r.w * 0.7, poolH = 18;
2525
- const g = ctx.createRadialGradient(dx, sandTop + 12, 0, dx, sandTop + 12, poolW);
2526
- // Very faint: this is just a subtle highlight
2527
- g.addColorStop(0, `rgba(255,255,255,${r.alpha * 1.75})`); // v26: +25% brighter
2528
- g.addColorStop(1, "rgba(255,255,255,0)");
2529
- ctx.fillStyle = g;
2530
- ctx.beginPath();
2531
- ctx.ellipse(dx, sandTop + 12, poolW, poolH, 0, 0, Math.PI * 2);
2532
- ctx.fill();
2533
  }
2534
 
2535
  ctx.restore();
 
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
 
292
  this.roll = 0; // current roll angle (rad); 0 = upright
293
  this.rollVel = 0; // angular velocity of the roll
294
  this.inHardTurn = false;// was it in a sharp turn last frame?
295
+
296
+ // v22: Ecco-style viewing angle — determines whether we see the fish from the side
297
+ // or from above/below based on its movement direction (0 = side, 1 = top/bottom)
298
+ this.viewAngle = 0; // current interpolated view angle
299
  }
300
  update(dt) {
301
  const night = personalNight(performance.now() / 1000, this); // v10: per-fish early/normal/late sleep & wake
 
466
  if (this.roll > 1.15) { this.roll = 1.15; this.rollVel *= -0.25; }
467
  else if (this.roll < -1.15) { this.roll = -1.15; this.rollVel *= -0.25; }
468
 
469
+ // v22: Ecco-style viewing angle — when moving vertically, we see the fish from above/below;
470
+ // when moving horizontally, we see its side profile. Smoothly interpolate between views.
471
+ // sin(angle) gives 0 for horizontal (0, π) and ±1 for vertical (±π/2)
472
+ const targetView = Math.abs(Math.sin(this.angle));
473
+ this.viewAngle += (targetView - this.viewAngle) * Math.min(1, dt * 4);
474
+
475
  // Player fish: input-driven movement (keyboard WASD + gamepad sticks),
476
  // overrides the AI cruising so the player is fully in control.
477
  if (this === playerFish) {
 
520
  c.save();
521
  c.translate(this.x, this.y);
522
  c.rotate(this.angle);
523
+
524
+ // v23: Ecco-style perspective WITHOUT a second picture. We keep the one fully-
525
+ // detailed body and foreshorten it as the fish turns to swim vertically.
526
+ // viewAngle 0 = pure side profile (one eye), 1 = seen from above/below (the back).
527
+ // Foreshortening along local up/down makes the dorsal fin slide toward center and
528
+ // the body slim down, reading as "seeing the back" — no snapping, nothing vanishes.
529
+ const v = Math.max(0, Math.min(1, this.viewAngle));
530
+ let scaleY = 1 - 0.34 * v; // foreshorten toward the back view
531
+ if (Math.abs(this.roll) > 0.01) { // banking roll still squashes a touch
532
+ scaleY *= Math.max(0.42, Math.cos(this.roll));
533
  c.rotate(this.roll * 0.1);
534
  }
535
+ c.scale(1, scaleY);
536
+
537
+ this.drawSideView(c); // one body: wave + patterns + fins always on
538
+ c.restore();
539
+ }
540
+
541
+ // v22: Draw fish from above or below (Ecco-style top/bottom view)
542
+ drawTopBottomView(c) {
543
+ const s = this.size;
544
+ // traveling body wave
545
+ const waveK = Math.PI / s;
546
+ const waveAmp = s * 0.12;
547
+ const sway = Math.sin(-s * waveK + this.phase) * s * 0.34;
548
+
549
+ // Top/bottom view: body seen from above or below
550
+ // Dorsal fin visible as a line down the center when viewed from above
551
+ const g = this.bodyGrad;
552
+ const NSEG = 26;
553
+
554
+ // Build body outline (same as side view but we're looking at it from top/bottom)
555
+ const bodyPath = new Path2D();
556
+ for (let i = 0; i <= NSEG; i++) {
557
+ const x = s - (i / NSEG) * 2 * s;
558
+ // Wider body when viewed from top/bottom (we see the full width)
559
+ const hh = s * 0.55 * Math.sqrt(Math.max(0, 1 - (x / s) ** 2));
560
+ if (i === 0) bodyPath.moveTo(x, -hh); else bodyPath.lineTo(x, -hh);
561
+ }
562
+ for (let i = NSEG; i >= 0; i--) {
563
+ const x = s - (i / NSEG) * 2 * s;
564
+ const hh = s * 0.55 * Math.sqrt(Math.max(0, 1 - (x / s) ** 2));
565
+ bodyPath.lineTo(x, hh);
566
+ }
567
+ bodyPath.closePath();
568
+
569
+ c.fillStyle = g;
570
+ c.fill(bodyPath);
571
+
572
+ // Dorsal fin — visible as a line/shape down the center when viewed from above
573
+ c.strokeStyle = this.tailFillStyle;
574
+ c.lineWidth = s * 0.15;
575
+ c.lineCap = "round";
576
+ c.beginPath();
577
+ c.moveTo(s * 0.3, 0);
578
+ // Curved dorsal fin running down the center
579
+ c.quadraticCurveTo(-s * 0.2, -sway * 0.5, -s * 0.6, sway * 0.3);
580
+ c.stroke();
581
+
582
+ // Eyes — visible on both sides of the head when viewed from top/bottom
583
+ c.fillStyle = "#fff";
584
+ // Left eye
585
+ c.beginPath();
586
+ c.arc(s * 0.55, -s * 0.25, s * 0.1, 0, Math.PI * 2);
587
+ c.fill();
588
+ c.fillStyle = "#0d1420";
589
+ c.beginPath();
590
+ c.arc(s * 0.57, -s * 0.25, s * 0.05, 0, Math.PI * 2);
591
+ c.fill();
592
+ // Right eye
593
+ c.fillStyle = "#fff";
594
+ c.beginPath();
595
+ c.arc(s * 0.55, s * 0.25, s * 0.1, 0, Math.PI * 2);
596
+ c.fill();
597
+ c.fillStyle = "#0d1420";
598
+ c.beginPath();
599
+ c.arc(s * 0.57, s * 0.25, s * 0.05, 0, Math.PI * 2);
600
+ c.fill();
601
+
602
+ // Tail fin (simplified for top/bottom view)
603
+ c.fillStyle = this.tailFillStyle;
604
+ c.beginPath();
605
+ if (this.tailType === 'fan') {
606
+ c.moveTo(-s * 0.5, 0);
607
+ c.quadraticCurveTo(-s * 1.3, -s * 0.28 + sway, -s * 1.42, -s * 0.5 + sway);
608
+ c.quadraticCurveTo(-s * 1.12, sway * 0.5, -s * 1.42, s * 0.5 + sway);
609
+ c.quadraticCurveTo(-s * 1.3, s * 0.28 + sway, -s * 0.5, 0);
610
+ } else if (this.tailType === 'streamer') {
611
+ c.moveTo(-s * 0.5, 0);
612
+ c.quadraticCurveTo(-s * 1.35, -s * 0.24 + sway, -s * 1.68, -s * 0.56 + sway);
613
+ c.quadraticCurveTo(-s * 0.98, sway * 0.35, -s * 1.68, s * 0.56 + sway);
614
+ c.quadraticCurveTo(-s * 1.35, s * 0.24 + sway, -s * 0.5, 0);
615
+ } else {
616
+ c.moveTo(-s * 0.5, 0);
617
+ c.quadraticCurveTo(-s * 1.04, -s * 0.36 + sway, -s * 1.22, -s * 0.44 + sway);
618
+ c.quadraticCurveTo(-s * 1.34, -s * 0.52 + sway, -s * 0.86, sway * 0.5);
619
+ c.quadraticCurveTo(-s * 1.34, s * 0.52 + sway, -s * 1.22, s * 0.44 + sway);
620
+ c.quadraticCurveTo(-s * 1.04, s * 0.36 + sway, -s * 0.5, 0);
621
+ }
622
+ c.fill();
623
+ }
624
+
625
+ // v22: Draw fish from the side (original view)
626
+ drawSideView(c) {
627
  const s = this.size;
628
  // traveling body wave (v3): ripples head→tail so the fish undulates like a real one
629
  const waveK = Math.PI / s; // ~one wavelength across the body length
 
631
  // tail fin follows the body wave at its base, amplified (same phase → no fighting)
632
  const sway = Math.sin(-s * waveK + this.phase) * s * 0.34;
633
 
634
+ // v24: Ecco-style blend factor (0 = side profile, 1 = seen from above/below).
635
+ // Flattens the tail lobes and spreads eyes/barbels as we see more of the back.
636
+ const ev = Math.max(0, Math.min(1, this.viewAngle));
637
+ const tailFlat = 1 - 0.4 * ev; // extra foreshortening for the caudal lobes only
638
+
639
  // tail fin (v7 rounded / v11 three styles): each tip is swept around by a curve
640
  // whose control point sits just past where the old spike was. The style is fixed
641
  // per fish at spawn so the school has variety in its tails.
 
646
  // Broad, rounded fan (betta/goldfish-style): wide spread, tips high and far out,
647
  // with only a shallow dip between the two lobes.
648
  c.moveTo(-s * 0.5, 0);
649
+ c.quadraticCurveTo(-s * 1.3, -s * 0.28 * tailFlat + sway, -s * 1.42, -s * 0.5 * tailFlat + sway); // up to upper tip
650
+ c.quadraticCurveTo(-s * 1.12, sway * 0.5, -s * 1.42, s * 0.5 * tailFlat + sway); // shallow notch → lower tip
651
+ c.quadraticCurveTo(-s * 1.3, s * 0.28 * tailFlat + sway, -s * 0.5, 0); // back to the base
652
  } else if (this.tailType === 'streamer') {
653
  // Long forked streamers (angelfish/fancy-guppy-style): two flowing lobes that
654
  // reach further back with a deeper notch between them.
655
  c.moveTo(-s * 0.5, 0);
656
+ c.quadraticCurveTo(-s * 1.35, -s * 0.24 * tailFlat + sway, -s * 1.68, -s * 0.56 * tailFlat + sway); // long upper lobe
657
+ c.quadraticCurveTo(-s * 0.98, sway * 0.35, -s * 1.68, s * 0.56 * tailFlat + sway); // deep fork → lower tip
658
+ c.quadraticCurveTo(-s * 1.35, s * 0.24 * tailFlat + sway, -s * 0.5, 0); // long lower lobe back to base
659
  } else {
660
  // classic (v7): rounded lobes with a soft notch — the original look
661
  c.moveTo(-s * 0.5, 0);
662
+ c.quadraticCurveTo(-s * 1.04, -s * 0.36 * tailFlat + sway, -s * 1.22, -s * 0.44 * tailFlat + sway); // upper edge out toward the tip
663
+ c.quadraticCurveTo(-s * 1.34, -s * 0.52 * tailFlat + sway, -s * 0.86, sway * 0.5); // rounded upper lobe → notch
664
+ c.quadraticCurveTo(-s * 1.34, s * 0.52 * tailFlat + sway, -s * 1.22, s * 0.44 * tailFlat + sway); // notch → rounded lower lobe
665
+ c.quadraticCurveTo(-s * 1.04, s * 0.36 * tailFlat + sway, -s * 0.5, 0); // lower edge back to the base
666
  }
667
  c.fill();
668
 
 
680
  {
681
  const side = -1; // far side (behind the body)
682
  const rootX = s * 0.6;
683
+ // v24: from above, barbels sit on either side of the mouth — spread the roots
684
+ // apart and flare each whisker further out as the fish "rotates" to swim vertically
685
+ const rootY = side * s * (0.09 + 0.07 * ev);
686
  c.beginPath();
687
  c.moveTo(rootX, rootY);
688
  for (let i = 1; i <= NPTS; i++) {
689
  const t = i / NPTS;
690
  const bx = rootX - L * (0.25 + 0.65 * spd) * t;
691
+ const by = rootY + side * L * (0.42 + 0.3 * ev) * t * t;
692
  const amp = s * 0.17 * t * (0.35 + 0.65 * spd);
693
  const off = Math.sin(t * 5.5 - this.phase * 1.4 + this.whiskPh) * amp;
694
  c.lineTo(bx, by + off);
 
829
  c.closePath();
830
  c.fill();
831
 
832
+ // v24: both eyes — as the fish turns to swim vertically (ev→1) we see them from
833
+ // above: the pair spreads wider on either side of the head and each eye squashes
834
+ // into a flatter ellipse (perspective), instead of two overlapping circles.
835
+ {
836
+ const eyeRY = 0.11 * (1 - 0.35 * ev); // vertical squash of the white
837
+ const pupRY = 0.055 * (1 - 0.35 * ev); // pupil squashes with it
838
+ const yNear = -s * (0.1 + 0.1 * ev); // near eye slides outward as we see more of the back
839
+ c.fillStyle = "#fff";
840
+ c.beginPath();
841
+ c.ellipse(s * 0.58, yNear, s * 0.11, s * eyeRY, 0, 0, Math.PI * 2);
842
+ c.fill();
843
+ c.fillStyle = "#0d1420";
844
+ c.beginPath();
845
+ c.ellipse(s * 0.61, yNear, s * 0.055, s * pupRY, 0, 0, Math.PI * 2);
846
+ c.fill();
847
+
848
+ // far-side eye fades in as we see more of the back (Ecco-style top view) —
849
+ // two eyes flanking the head when the fish turns to swim vertically.
850
+ if (ev > 0.03) {
851
+ const yFar = s * (0.14 + 0.08 * ev); // far eye slides outward too — pair widens
852
+ c.save();
853
+ c.globalAlpha = ev * 0.9;
854
+ c.fillStyle = "#fff";
855
+ c.beginPath();
856
+ c.ellipse(s * 0.58, yFar, s * 0.11, s * eyeRY, 0, 0, Math.PI * 2);
857
+ c.fill();
858
+ c.fillStyle = "#0d1420";
859
+ c.beginPath();
860
+ c.ellipse(s * 0.61, yFar, s * 0.055, s * pupRY, 0, 0, Math.PI * 2);
861
+ c.fill();
862
+ c.restore();
863
+ }
864
+ }
865
 
866
  // v21: near-side whisker — drawn AFTER the body so it sits in front of the fish.
867
  // (The far-side whisker is drawn before the body, above.) Together they form a
 
879
  {
880
  const side = 1; // near side (in front of the body)
881
  const rootX = s * 0.6;
882
+ // v24: same spread/flare as the far whisker — a pair flanking the snout from above
883
+ const rootY = side * s * (0.09 + 0.07 * ev);
884
  c.beginPath();
885
  c.moveTo(rootX, rootY);
886
  for (let i = 1; i <= NPTS; i++) {
887
  const t = i / NPTS;
888
  const bx = rootX - L * (0.25 + 0.65 * spd) * t;
889
+ const by = rootY + side * L * (0.42 + 0.3 * ev) * t * t;
890
  const amp = s * 0.17 * t * (0.35 + 0.65 * spd);
891
  const off = Math.sin(t * 5.5 - this.phase * 1.4 + this.whiskPh) * amp;
892
  c.lineTo(bx, by + off);
 
898
 
899
  c.restore();
900
 
901
+ // v17/v23: overhead light — soft elliptical highlight on the fish's upper edge.
902
+ // Drawn in local coords (draw() already positioned + rotated us) so it rides with
903
+ // the body and foreshortens correctly instead of being double-offset.
904
  {
 
 
 
 
 
 
905
  const a = 0.35 * (1 - curNight * 0.65); // dimmer at night, still present
906
  if (a > 0.02) {
 
 
 
 
 
 
 
 
 
 
 
907
  c.fillStyle = `rgba(255,255,255,${(a * 0.3).toFixed(3)})`;
908
  c.beginPath();
909
+ c.ellipse(s * 0.05, -s * 0.26, s * 0.6, s * 0.2, 0, 0, Math.PI * 2);
910
  c.fill();
 
 
911
  c.fillStyle = `rgba(255,255,255,${a.toFixed(3)})`;
912
  c.beginPath();
913
+ c.ellipse(s * 0.05, -s * 0.26, s * 0.34, s * 0.1, 0, 0, Math.PI * 2);
914
  c.fill();
 
 
915
  }
916
  }
917
+ // v19/v23: pectoral fins drawn OVER the reflections for correct layering.
918
+ // draw() already applied translate+rotate+foreshortening, so we draw in local
919
+ // coords (the old duplicate translate/rotate misplaced and hid these fins).
920
  {
921
  c.save();
 
 
 
 
 
 
922
 
923
  // v20: render the pectoral fins at 50% opacity
924
  c.globalAlpha = 0.5;
 
1135
  const hue = clusterHue + rand(-15, 15);
1136
  // v4.3: depth split — ~51% "far" (heavy softness), ~49% "near" (sharper) for a layered look
1137
  const near = Math.random() < 0.49;
1138
+ const w = near ? rand(2.5, 5) : rand(3, 6);
1139
+ const tw = near ? rand(1.2, 2.4) : rand(1.5, 3);
1140
  const st = {
1141
  x: cx + rand(-20, 20),
1142
  h: rand(H * 0.35, H * 0.65), // much taller than foreground seaweed
 
1145
  speed: rand(0.7, 1.3), // slower, more gentle sway
1146
  hue,
1147
  near,
1148
+ width: w,
1149
+ tipW: tw,
1150
  branches: [],
1151
  // organic curve parameters (same as foreground seaweed)
1152
  cA1: rand(8, 15), cF1: rand(0.8, 1.6), cP1: rand(0, Math.PI * 2),
 
1154
  // v4.3: multi-wave organic motion parameters
1155
  waveA1: rand(6, 14), waveF1: rand(0.5, 1.2), wavePh1: rand(0, Math.PI * 2),
1156
  waveA2: rand(3, 8), waveF2: rand(1.3, 2.1), wavePh2: rand(0, Math.PI * 2),
1157
+ waveA3: rand(2, 5), waveF3: rand(2.4, 3.6), wavePh3: rand(0, Math.PI * 2),
1158
+ // PERF: precomputed styles/widths (avoids per-frame string alloc + arithmetic)
1159
+ coreStyle: `hsla(${hue},50%,32%,${near ? 0.8 : 0.7})`,
1160
+ haloStyle: `hsla(${hue},45%,36%,0.18)`,
1161
+ branchCoreStyle: `hsla(${hue + 8},55%,42%,0.75)`,
1162
+ backLineW: (w + tw) / 2
1163
  };
1164
  // v4.1: add branches to background seaweed (like foreground)
1165
  const nBr = 1 + ((Math.random() * 3) | 0); // 1..3 branches per stalk
1166
  for (let k = 0; k < nBr; k++) {
1167
+ const bw = rand(1.5, 3); // PERF: computed once, reused for halo width
1168
  st.branches.push({
1169
  f: rand(0.25, 0.85), // where along the stalk it sprouts
1170
  side: Math.random() < 0.5 ? -1 : 1,
1171
  len: rand(10, 24), // horizontal reach (slightly smaller than fg)
1172
  up: rand(8, 20), // how far it curls upward
1173
+ w: bw,
1174
+ haloW: bw * 1.9, // PERF: precomputed halo width
1175
  ph: rand(0, Math.PI * 2)
1176
  });
1177
  }
 
1189
  const nStalks = 2 + ((Math.random() * 3) | 0);
1190
  for (let j = 0; j < nStalks; j++) {
1191
  const hue = clusterHue + rand(-12, 12);
1192
+ const mw = rand(3, 6.5); // PERF: captured once for precomputed widths
1193
+ const mtw = rand(1.8, 3.2);
1194
  // Median properties: height between backSeaweeds (0.35-0.65H) and farSeaweeds (0.4-0.7H)
1195
  const st = {
1196
  x: cx + rand(-22, 22),
 
1199
  phase: rand(0, Math.PI * 2),
1200
  speed: rand(0.6, 1.15), // median sway speed between back (0.7-1.3) and far (0.5-1.0)
1201
  hue,
1202
+ width: mw, // median width
1203
+ tipW: mtw,
1204
  branches: [],
1205
  // Organic curve parameters (like backSeaweeds for halo pass technique)
1206
  cA1: rand(9, 16), cF1: rand(0.7, 1.5), cP1: rand(0, Math.PI * 2),
 
1208
  // Multi-wave organic motion parameters
1209
  waveA1: rand(7, 15), waveF1: rand(0.45, 1.1), wavePh1: rand(0, Math.PI * 2),
1210
  waveA2: rand(3.5, 9), waveF2: rand(1.2, 2.0), wavePh2: rand(0, Math.PI * 2),
1211
+ waveA3: rand(2.5, 6), waveF3: rand(2.2, 3.4), wavePh3: rand(0, Math.PI * 2),
1212
+ // PERF: precomputed styles/widths (avoids per-frame string alloc + arithmetic)
1213
+ coreStyle: `hsla(${hue},48%,30%,0.65)`,
1214
+ haloStyle: `hsla(${hue},42%,34%,0.16)`,
1215
+ branchCoreStyle: `hsla(${hue + 6},50%,40%,0.7)`,
1216
+ midLineW: (mw + mtw) / 2
1217
  };
1218
  // Add branches (median count between back and far)
1219
  const nBr = 1 + ((Math.random() * 3) | 0);
1220
  for (let k = 0; k < nBr; k++) {
1221
+ const bw = rand(1.8, 3.5); // PERF: computed once, reused for halo width
1222
  st.branches.push({
1223
  f: rand(0.28, 0.82),
1224
  side: Math.random() < 0.5 ? -1 : 1,
1225
  len: rand(11, 26),
1226
  up: rand(9, 22),
1227
+ w: bw,
1228
+ haloW: bw * 2.2, // PERF: precomputed halo width
1229
  ph: rand(0, Math.PI * 2)
1230
  });
1231
  }
 
1460
  return c(g + f.riseShift);
1461
  }
1462
 
1463
+ // PERF: cache background gradient - only recreate when colors change significantly
1464
+ let _bgGrad = null, _bgGradKey = '';
1465
  function drawBackground(light) {
1466
+ // PERF: quantize colors so the gradient only rebuilds on visible color shifts
1467
+ const key = [light.top[0] | 0, light.top[1] | 0, light.top[2] | 0,
1468
+ light.mid[0] | 0, light.mid[1] | 0, light.mid[2] | 0,
1469
+ light.bot[0] | 0, light.bot[1] | 0, light.bot[2] | 0].join(',');
1470
+ if (!_bgGrad || _bgGradKey !== key) {
1471
+ _bgGrad = ctx.createLinearGradient(0, 0, 0, H);
1472
+ _bgGrad.addColorStop(0, rgbStr(light.top));
1473
+ _bgGrad.addColorStop(0.5, rgbStr(light.mid));
1474
+ _bgGrad.addColorStop(1, rgbStr(light.bot));
1475
+ _bgGradKey = key;
1476
+ }
1477
+ ctx.fillStyle = _bgGrad;
1478
  ctx.fillRect(0, 0, W, H);
1479
  }
1480
 
 
1686
  return f * (w.cA1 * Math.sin(f * w.cF1 + w.cP1) + w.cA2 * Math.sin(f * w.cF2 + w.cP2));
1687
  }
1688
 
1689
+ // PERF: shared sway function (was a per-stalk closure recreated every frame)
1690
+ function stalkSwayX(w, t, f) {
1691
+ return Math.sin(t * w.waveF1 + w.wavePh1 + f * 2.4) * w.waveA1 * f * f +
1692
+ Math.sin(t * w.waveF2 + w.wavePh2 + f * 3.8) * w.waveA2 * f * f +
1693
+ Math.sin(t * w.waveF3 + w.wavePh3 + f * 5.4) * w.waveA3 * f;
1694
+ }
1695
+
1696
  // v4.3: draw background seaweed procedurally — multi-wave organic motion instead of
1697
  // rigid sprite rotation. Far stalks get a soft low-alpha halo pass to keep their
1698
  // "far away" look without any runtime blur filters (no slowdown).
1699
  // v6: middle-distance seaweed using halo stroke pass (median depth between back and far)
1700
  function drawMidSeaweeds(t) {
1701
+ // PERF: lineCap/lineJoin are constant — set once instead of per stalk per pass
1702
+ ctx.lineCap = 'round';
1703
+ ctx.lineJoin = 'round';
1704
  for (const w of midSeaweeds) {
1705
  const baseY = sandAt(w.x) + 10;
1706
  const n = w.segs;
1707
+ // PERF: compute sway positions ONCE per frame; both passes and branches reuse them
1708
+ const pxs = w._px || (w._px = new Float64Array(n + 1));
1709
+ const pys = w._py || (w._py = new Float64Array(n + 1));
1710
+ for (let i = 0; i <= n; i++) {
1711
+ const f = i / n;
1712
+ pxs[i] = w.x + stalkCurveX(w, f) + stalkSwayX(w, t, f);
1713
+ pys[i] = baseY - w.h * f;
1714
+ }
 
1715
 
1716
  // Wider halo pass for more softness (between backSeaweeds and farSeaweeds)
1717
  for (let pass = 0; pass < 2; pass++) {
1718
  const isHalo = pass === 0;
1719
+ ctx.strokeStyle = isHalo ? w.haloStyle : w.coreStyle; // PERF: precomputed at build
1720
+ ctx.lineWidth = w.midLineW * (isHalo ? 2.2 : 1);
 
 
1721
 
1722
+ // stalk outline: up one side, down the other
1723
  ctx.beginPath();
1724
+ let prevX = 0, prevY = baseY; // original quirk: first normal measured from origin
 
1725
  for (let i = 0; i <= n; i++) {
1726
  const f = i / n;
1727
+ const px = pxs[i], py = pys[i];
 
1728
  const dx = px - prevX, dy = py - prevY;
1729
+ const dl = Math.sqrt(dx * dx + dy * dy) || 1; // PERF: hypot is slower, not needed here
1730
  const nx = -dy / dl, ny = dx / dl;
1731
+ const hw = (w.width * (1 - f) + w.tipW * f) * 0.5;
1732
  if (i === 0) ctx.moveTo(px + nx * hw, py + ny * hw);
1733
  else ctx.lineTo(px + nx * hw, py + ny * hw);
1734
  prevX = px; prevY = py;
 
1735
  }
1736
+ // down the other side (cached points; i=n no-op point dropped — degenerate in original)
1737
+ for (let i = n - 1; i >= 0; i--) {
 
 
 
 
 
1738
  const f = i / n;
1739
+ const px = pxs[i], py = pys[i];
1740
+ const dx = pxs[i + 1] - px, dy = pys[i + 1] - py;
1741
+ const dl = Math.sqrt(dx * dx + dy * dy) || 1;
 
 
 
 
 
 
 
 
1742
  const nx = -dy / dl, ny = dx / dl;
1743
+ const hw = (w.width * (1 - f) + w.tipW * f) * 0.5;
1744
  ctx.lineTo(px - nx * hw, py - ny * hw);
1745
  }
1746
  ctx.closePath();
1747
  ctx.stroke();
1748
 
1749
+ // branches flutter with the stalk (anchors read from cached positions)
1750
  for (const b of w.branches) {
1751
  const idx = Math.min(n, Math.max(1, Math.round(b.f * n)));
1752
+ const ax = pxs[idx], ay = pys[idx];
 
 
1753
  const flutter = Math.sin(t * w.waveF2 * 1.4 + b.ph) * (3 + w.waveA2 * 0.4);
1754
  const bx = ax + b.side * b.len + flutter;
1755
  const by = ay - b.up;
1756
  ctx.beginPath();
1757
+ ctx.lineWidth = isHalo ? b.haloW : b.w; // PERF: halo width precomputed
1758
+ ctx.strokeStyle = isHalo ? w.haloStyle : w.branchCoreStyle;
1759
  ctx.moveTo(ax, ay);
1760
  ctx.bezierCurveTo(
1761
  ax + b.side * b.len * 0.3, ay - b.up * 0.75,
 
1771
  function drawBackSeaweeds(t) {
1772
  // v6: no runtime canvas filter — blur() forces a CPU re-rasterize every frame
1773
  // and was the main FPS cost. Softness now comes from the halo pass below instead.
1774
+ ctx.lineCap = 'round';
1775
+ ctx.lineJoin = 'round';
1776
  for (const w of backSeaweeds) {
1777
  const baseY = sandAt(w.x) + 10;
1778
  const n = w.segs;
1779
+ // PERF: compute sway positions ONCE per frame; both passes and branches reuse them
1780
+ const pxs = w._px || (w._px = new Float64Array(n + 1));
1781
+ const pys = w._py || (w._py = new Float64Array(n + 1));
1782
+ for (let i = 0; i <= n; i++) {
1783
+ const f = i / n;
1784
+ pxs[i] = w.x + stalkCurveX(w, f) + stalkSwayX(w, t, f);
1785
+ pys[i] = baseY - w.h * f;
1786
+ }
1787
 
1788
  // v6: near stalks also get a halo pass (replaces the old blur(2px) filter)
1789
  for (let pass = 0; pass < 2; pass++) {
1790
+ const isHalo = pass === 0;
1791
+ ctx.strokeStyle = isHalo ? w.haloStyle : w.coreStyle; // PERF: precomputed at build
1792
+ ctx.lineWidth = w.backLineW * (isHalo ? 1.9 : 1);
 
 
1793
 
1794
+ // stalk outline: up one side, down the other
1795
  ctx.beginPath();
1796
+ let prevX = 0, prevY = baseY; // original quirk: first normal measured from origin
 
1797
  for (let i = 0; i <= n; i++) {
1798
  const f = i / n;
1799
+ const px = pxs[i], py = pys[i];
 
1800
  const dx = px - prevX, dy = py - prevY;
1801
+ const dl = Math.sqrt(dx * dx + dy * dy) || 1; // PERF: hypot is slower, not needed here
1802
  const nx = -dy / dl, ny = dx / dl;
1803
+ const hw = (w.width * (1 - f) + w.tipW * f) * 0.5;
1804
  if (i === 0) ctx.moveTo(px + nx * hw, py + ny * hw);
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];
1812
+ const dx = pxs[i + 1] - px, dy = pys[i + 1] - py;
1813
+ const dl = Math.sqrt(dx * dx + dy * dy) || 1;
 
 
 
 
 
 
 
 
 
1814
  const nx = -dy / dl, ny = dx / dl;
1815
+ const hw = (w.width * (1 - f) + w.tipW * f) * 0.5;
1816
  ctx.lineTo(px - nx * hw, py - ny * hw);
1817
  }
1818
  ctx.closePath();
1819
  ctx.stroke();
1820
 
1821
+ // branches flutter with the stalk (anchors read from cached positions)
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 = Math.sin(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();
1829
+ ctx.lineWidth = isHalo ? b.haloW : b.w; // PERF: halo width precomputed
1830
+ ctx.strokeStyle = isHalo ? w.haloStyle : w.branchCoreStyle;
1831
  ctx.moveTo(ax, ay);
1832
  ctx.bezierCurveTo(
1833
  ax + b.side * b.len * 0.3, ay - b.up * 0.75,
 
2618
  ctx.restore();
2619
  }
2620
 
2621
+ // PERF: pre-render a soft radial gradient sprite for light pooling - draw scaled instead of creating gradients each frame
2622
+ let _poolSprite = null, _poolSpriteSize = 0;
2623
+ function buildPoolSprite() {
2624
+ const size = 256; // fixed size, will be scaled per ray
2625
+ if (_poolSprite && _poolSpriteSize === size) return;
2626
+ _poolSprite = document.createElement('canvas');
2627
+ _poolSprite.width = size;
2628
+ _poolSprite.height = size;
2629
+ const pc = _poolSprite.getContext('2d');
2630
+ const g = pc.createRadialGradient(size/2, size/2, 0, size/2, size/2, size/2);
2631
+ g.addColorStop(0, 'rgba(255,255,255,1)'); // full white, alpha controlled via globalAlpha
2632
+ g.addColorStop(1, 'rgba(255,255,255,0)');
2633
+ pc.fillStyle = g;
2634
+ pc.fillRect(0, 0, size, size);
2635
+ _poolSpriteSize = size;
2636
+ }
2637
+
2638
  function drawLightPooling(t) {
2639
+ if (!_poolSprite) buildPoolSprite(); // lazy init (was never called before)
2640
  // Draw faint bright patches where each god-ray hits the sand
2641
  const sandTop = H * 0.85;
2642
  ctx.save();
 
2653
  Math.sin(vM * r.vLen - t * r.vSpd + r.vPh) * r.vAmp;
2654
  const dx = r.x + off + wave * (0.35 + 0.65 * vM);
2655
 
2656
+ // Draw a soft bright ellipse at this position on the sand using pre-rendered sprite
2657
  const poolW = r.w * 0.7, poolH = 18;
2658
+ ctx.globalAlpha = r.alpha * 1.75; // v26: +25% brighter
2659
+ ctx.drawImage(_poolSprite, dx - poolW, sandTop + 12 - poolH, poolW * 2, poolH * 2);
 
 
 
 
 
 
2660
  }
2661
 
2662
  ctx.restore();