One commited on
Commit
8ed9966
·
verified ·
1 Parent(s): 661630c

Upload index.html

Browse files
Files changed (1) hide show
  1. index.html +275 -84
index.html CHANGED
@@ -43,7 +43,7 @@ function lerpAngle(a, b, t) {
43
  return a + d * t;
44
  }
45
 
46
- const HUES = [8, 35, 46, 172, 195, 215, 262, 320];
47
  const TAIL_TYPES = ['classic', 'fan', 'streamer']; // v11: three tail-fin styles, one per fish
48
  // v18: koi colourations — large irregular patches on a white/ivory base. Three classic
49
  // looks: kohaku (red/orange), sanke (red + black), golden (amber/gold). Patches are
@@ -62,17 +62,22 @@ function genKoiPatches(type) {
62
  () => `hsla(${rand(38, 48)},${rand(85, 98)}%,${rand(55, 66)}%,0.92)`, // gold
63
  () => `hsla(${rand(26, 40)},${rand(85, 98)}%,${rand(50, 62)}%,0.88)`, // amber
64
  ],
 
 
 
 
65
  };
66
  const styles = palettes[type];
67
  const arr = [];
68
- const nPatches = 4 + ((Math.random() * 4) | 0); // 4..7 large blobs
 
69
  for (let i = 0; i < nPatches; i++) {
70
  const th = rand(0, Math.PI * 2);
71
  const rr = Math.sqrt(Math.random()) * 0.85; // uniform in ellipse, kept off the rim
72
  arr.push({
73
  x: Math.cos(th) * rr, // along body axis (units of s)
74
  y: Math.sin(th) * rr * 0.42, // across body (matches the 0.42 ellipse ratio)
75
- r: rand(0.3, 0.52), // large patches (units of s)
76
  style: styles[Math.random() < 0.5 ? 0 : 1](),
77
  });
78
  }
@@ -103,10 +108,18 @@ class Fish {
103
  // Koi — a white/ivory base with large coloured patches; the three classic
104
  // colourations are split roughly evenly across this 30% band.
105
  this.isKoi = true;
106
- const koiType = specialRoll < 0.25 ? 'kohaku' : specialRoll < 0.35 ? 'sanke' : 'golden';
107
- this.hue = rand(40, 50); // warm ivory tint for the base
108
- this.saturation = rand(12, 22); // low saturation → white body
109
- this.lightness = rand(88, 94); // high lightness → white/ivory base
 
 
 
 
 
 
 
 
110
  this.koiPatches = genKoiPatches(koiType);
111
  } else {
112
  // Standard colored fish from the original palette
@@ -214,6 +227,22 @@ class Fish {
214
  } else {
215
  this.whiskerLen = null;
216
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  this.phase = rand(0, Math.PI * 2);
218
  // v9: after dark, most fish bed down low in the grass — only a small share still
219
  // hovers by the seaweed, and each of those keeps to its own stalk (no pile-ups)
@@ -459,7 +488,7 @@ class Fish {
459
  // whose control point sits just past where the old spike was. The style is fixed
460
  // per fish at spawn so the school has variety in its tails.
461
  // v14: use per-fish saturation and lightness for expanded color palette
462
- c.fillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.92)`;
463
  c.beginPath();
464
  if (this.tailType === 'fan') {
465
  // Broad, rounded fan (betta/goldfish-style): wide spread, tips high and far out,
@@ -486,10 +515,7 @@ class Fish {
486
  c.fill();
487
 
488
  // body — undulates while swimming: the ellipse flexes along a traveling wave (v3)
489
- const g = c.createLinearGradient(0, -s * 0.5, 0, s * 0.5);
490
- // v14: use per-fish saturation and lightness for expanded color palette
491
- g.addColorStop(0, `hsl(${this.hue},${Math.min(100, this.saturation * 1.08)}%,${this.lightness + 12}%)`);
492
- g.addColorStop(1, `hsl(${this.hue},${this.saturation * 0.9}%,${this.lightness - 8}%)`);
493
  const NSEG = 26; // outline samples for a smooth curve
494
  // wave offset at any x along the body: rigid at head, full flex at tail.
495
  // Shared with the dorsal fin so it stays glued to the moving back (v4)
@@ -584,10 +610,18 @@ class Fish {
584
  // so scales appear to catch the light while moving. One gradient + one fill.
585
  const shimmer = Math.sin(this.phase * 1.6) * 0.5 + 0.5; // 0..1 as it swims
586
  const iridHue = this.hue + 40 * Math.sin(this.phase * 2.3); // hue drifts with phase
587
- const ig = c.createLinearGradient(-s, -s*0.3, s, s*0.35);
588
- ig.addColorStop(0.0, `hsla(${this.hue},95%,72%,${0.10 + 0.18*shimmer})`);
589
- ig.addColorStop(0.5, `hsla(${iridHue},90%,70%,${0.04 + 0.10*shimmer})`);
590
- ig.addColorStop(1.0, `hsla(${this.hue + 200},85%,68%,${0.03 + 0.08*shimmer})`);
 
 
 
 
 
 
 
 
591
  c.save();
592
  c.globalCompositeOperation = "lighter"; // additive sheen: reads as light on scales
593
  c.fillStyle = ig;
@@ -604,7 +638,7 @@ class Fish {
604
  // never disconnects as the back undulates (v4)
605
  // v17: matches the fish's own color instead of a fixed blue — silver fish no longer
606
  // sprout mismatched blue fins
607
- c.fillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.9)`;
608
  c.beginPath();
609
  c.moveTo(-s * 0.15, -s * 0.36 + wy(-s * 0.15));
610
  c.quadraticCurveTo(s * 0.05, -s * 0.78 + wy(s * 0.05), s * 0.38, -s * 0.4 + wy(s * 0.38));
@@ -618,11 +652,11 @@ class Fish {
618
  c.rotate(0.7 + flap);
619
  // v17: pectoral fin matches the body color (no more blue arms on silver fish), but
620
  // gets a thin darker outline so it stays visible sitting on top of the same-colored body
621
- c.fillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.8)`;
622
  c.beginPath();
623
  c.ellipse(0, 0, s * 0.3, s * 0.14, 0, 0, Math.PI * 2);
624
  c.fill();
625
- c.strokeStyle = `hsla(${this.hue},${Math.min(100, this.saturation * 1.15)}%,${Math.max(8, this.lightness - 20)}%,0.5)`;
626
  c.lineWidth = Math.max(1, s * 0.03);
627
  c.stroke();
628
  c.restore();
@@ -649,7 +683,7 @@ class Fish {
649
  const NPTS = 7; // sample points per whisker
650
  c.save();
651
  c.lineCap = "round";
652
- c.strokeStyle = `hsla(${this.hue},55%,82%,${this.whiskerAlpha})`;
653
  c.lineWidth = Math.max(1, s * 0.045);
654
  for (const side of [-1, 1]) { // one whisker each side
655
  const rootX = s * 0.6;
@@ -808,15 +842,27 @@ function buildScene() {
808
  const clusterHue = rand(95, 145);
809
  const nStalks = 3 + ((Math.random() * 4) | 0); // 3..6 stalks per bundle
810
  for (let j = 0; j < nStalks; j++) {
 
811
  const st = {
812
  x: cx + rand(-16, 16),
813
  h: rand(H * 0.12, H * 0.34),
814
  segs: 8,
815
  phase: rand(0, Math.PI * 2),
816
  speed: rand(0.9, 1.7),
817
- hue: clusterHue + rand(-10, 10),
818
  width: rand(4, 8),
819
  touch: 0,
 
 
 
 
 
 
 
 
 
 
 
820
  off: new Float32Array(9), // lateral bend per segment (fish push leaves aside)
821
  offV: new Float32Array(9), // velocity of that bend
822
  branches: [],
@@ -833,12 +879,19 @@ function buildScene() {
833
  ph: rand(0, Math.PI * 2),
834
  flowers: Math.random() < 0.3 ? (() => { // ~30% of branches bloom with 1-3 heads (v3)
835
  const n = 1 + ((Math.random() * 3) | 0); // 1..3 flowers per branch
836
- return Array.from({ length: n }, () => ({
837
- t: rand(0.45, 1), // where along the branch it sits (tip zone)
838
- hue: (Math.random() * 360) | 0, // colourful — full spectrum
839
- r: rand(4, 7), // head size
840
- petals: 5 + ((Math.random() * 3) | 0) // 5..7 petals
841
- }));
 
 
 
 
 
 
 
842
  })() : []
843
  });
844
  }
@@ -864,7 +917,12 @@ function buildScene() {
864
  speed: rand(1.3, 2.8), // quicker, finer sway than seaweed
865
  hue: baseHue + rand(-9, 9),
866
  width: rand(1.4, 2.8),
867
- ripple: 0 // agitation from nearby fish (0..1)
 
 
 
 
 
868
  });
869
  }
870
  }
@@ -888,7 +946,12 @@ function buildScene() {
888
  phase: rand(0, Math.PI * 2),
889
  speed: rand(0.8, 1.6), // slower, heavier sway than the tiny mid-ground grass
890
  hue: baseHue + rand(-10, 10),
891
- width: rand(1.75, 3.5) // 50%: half thickness (was 3.5..7)
 
 
 
 
 
892
  });
893
  }
894
  }
@@ -1003,9 +1066,18 @@ function drawBackground(light) {
1003
  ctx.fillRect(0, 0, W, H);
1004
  }
1005
 
 
 
 
 
 
 
 
 
 
1006
  function drawRays(t, light) {
1007
  const [rr, rg, rb] = light.ray;
1008
- const segs = 18; // vertical resolution of each beam's undulating spine
1009
  for (const r of rays) {
1010
  // slow horizontal drift of the whole beam (kept from v1)
1011
  const off = Math.sin(t * r.speed * 6 + r.x) * 55;
@@ -1016,7 +1088,7 @@ function drawRays(t, light) {
1016
  // Undulating spine: the beam's centerline bends left/right as it descends,
1017
  // driven by two traveling sine waves (different wavelength & speed) so it
1018
  // shimmers like sunlight refracted through moving water, seen from inside.
1019
- const L = [], R = [];
1020
  for (let i = 0; i <= segs; i++) {
1021
  const v = i / segs; // 0 at the surface, 1 near the floor
1022
  const y = -10 + v * (H * 0.95 + 10);
@@ -1026,8 +1098,8 @@ function drawRays(t, light) {
1026
  Math.sin(v * r.vLen - t * r.vSpd + r.vPh) * r.vAmp;
1027
  const cx = x0 + shift * v + wave * grow; // undulating center x at this depth
1028
  const halfW = topW + (botW - topW) * v; // beam widens as it falls
1029
- L.push([cx - halfW, y]);
1030
- R.push([cx + halfW, y]);
1031
  }
1032
 
1033
  // Fill a band between two edge polylines with a vertical fade (matches v1 look).
@@ -1049,13 +1121,13 @@ function drawRays(t, light) {
1049
  band(L, R, r.alpha, 0.55, 0.45);
1050
 
1051
  // brighter, narrower inner core riding the same undulation (v1 core profile)
1052
- const LC = [], RC = [];
1053
  for (let i = 0; i <= segs; i++) {
1054
  const [lx, ly] = L[i], [rx2, ry2] = R[i];
1055
  const cxm = (lx + rx2) / 2;
1056
  const coreHalf = ((rx2 - lx) / 2) * 0.42; // 42% of the local half-width, as in v1
1057
- LC.push([cxm - coreHalf, ly]);
1058
- RC.push([cxm + coreHalf, ry2]);
1059
  }
1060
  band(LC, RC, r.alpha * 1.5, 0.65, 0);
1061
  }
@@ -1106,6 +1178,11 @@ function drawRocks(c = ctx) {
1106
  }
1107
  }
1108
 
 
 
 
 
 
1109
  function updatePlants(t, dt) {
1110
  plantPoints.length = 0;
1111
  for (const w of seaweeds) {
@@ -1117,7 +1194,7 @@ function updatePlants(t, dt) {
1117
  const moveF = Math.min(1, fSpd / 1.2); // v9: how actively this fish is swimming
1118
  for (let i = 1; i <= w.segs; i++) {
1119
  const fl = i / w.segs;
1120
- const px = w.x + w.off[i] + Math.sin(t * w.speed + w.phase + fl * 2.6) * fl * fl * 24;
1121
  const py = sandAt(w.x) + 8 - w.h * fl;
1122
  const dx = px - f.x, dy = py - f.y;
1123
  const d2 = dx * dx + dy * dy;
@@ -1161,7 +1238,7 @@ function updatePlants(t, dt) {
1161
  // normal sway + a fast extra flinch while being pushed
1162
  const sway = Math.sin(t * w.speed + w.phase + f * 2.6) * f * f * 24
1163
  + Math.sin(t * 7 + w.phase) * w.touch * f * 16;
1164
- const px = w.x + (w.off[i] || 0) + sway;
1165
  const py = baseY - w.h * f;
1166
  w.points.push({ x: px, y: py });
1167
  plantPoints.push({ x: px, y: py, r: w.width * 0.55, s: w });
@@ -1199,33 +1276,77 @@ function updateGrass(t, dt) {
1199
 
1200
  function drawSeaweed() {
1201
  for (const w of seaweeds) {
1202
- // main stalk through its precomputed (collision-accurate) points
1203
- ctx.strokeStyle = `hsla(${w.hue},55%,36%,0.92)`;
1204
- ctx.lineWidth = w.width;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1205
  ctx.lineCap = "round";
1206
  ctx.beginPath();
1207
- ctx.moveTo(w.points[0].x, w.points[0].y);
1208
- for (let i = 1; i < w.points.length; i++) ctx.lineTo(w.points[i].x, w.points[i].y);
1209
  ctx.stroke();
1210
 
1211
  // side branches curling up and out from the stalk
1212
  for (const b of w.branches) {
1213
  const a = w.points[Math.min(w.segs, Math.max(1, Math.round(b.f * w.segs)))];
1214
- ctx.strokeStyle = `hsla(${w.hue + 8},60%,46%,0.9)`;
 
 
 
 
 
 
1215
  ctx.lineWidth = b.w;
1216
  ctx.beginPath();
1217
  ctx.moveTo(a.x, a.y);
1218
- ctx.quadraticCurveTo((a.x + b.tx) / 2 + b.side * 3, (a.y + b.ty) / 2 - 5, b.tx, b.ty);
1219
  ctx.stroke();
1220
 
1221
  // colourful flower heads blooming along the branch — 1..3 per branch (v3)
1222
  if (b.flowers.length) {
1223
- const cpX = (a.x + b.tx) / 2 + b.side * 3;
1224
- const cpY = (a.y + b.ty) / 2 - 5;
1225
  for (const fl of b.flowers) {
1226
- const u = fl.t, iu = 1 - u; // point on the branch's quadratic curve
1227
- drawFlowerHead(iu * iu * a.x + 2 * iu * u * cpX + u * u * b.tx,
1228
- iu * iu * a.y + 2 * iu * u * cpY + u * u * b.ty, fl);
 
1229
  }
1230
  }
1231
  }
@@ -1238,12 +1359,12 @@ function drawFlowerHead(x, y, fl) {
1238
  const ang = (i / fl.petals) * Math.PI * 2;
1239
  const px = x + Math.cos(ang) * fl.r * 0.72;
1240
  const py = y + Math.sin(ang) * fl.r * 0.72;
1241
- ctx.fillStyle = `hsla(${fl.hue},85%,64%,0.95)`;
1242
  ctx.beginPath();
1243
  ctx.ellipse(px, py, fl.r * 0.55, fl.r * 0.36, ang, 0, Math.PI * 2);
1244
  ctx.fill();
1245
  }
1246
- ctx.fillStyle = `hsla(${(fl.hue + 45) % 360},92%,78%,0.95)`; // bright contrasting centre
1247
  ctx.beginPath();
1248
  ctx.arc(x, y, fl.r * 0.42, 0, Math.PI * 2);
1249
  ctx.fill();
@@ -1252,22 +1373,56 @@ function drawFlowerHead(x, y, fl) {
1252
 
1253
  function drawGrass(t) {
1254
  for (const g of grass) {
1255
- // gentle constant sway, amplified while rippling from a passing fish
1256
  const amp = 3 + g.ripple * 13;
1257
- const sway = Math.sin(t * g.speed + g.phase) * amp;
1258
- const tipX = g.x + g.lean * g.h + sway;
1259
- const tipY = g.y - g.h * (1 - g.ripple * 0.06);
1260
- ctx.strokeStyle = `hsla(${g.hue},58%,40%,0.92)`;
1261
- ctx.lineWidth = g.width;
1262
- ctx.lineCap = "round";
 
 
 
 
 
 
 
 
 
1263
  ctx.beginPath();
1264
- ctx.moveTo(g.x, g.y);
1265
- ctx.quadraticCurveTo(
1266
- (g.x + tipX) / 2 + sway * 0.4,
1267
- (g.y + tipY) / 2,
1268
- tipX, tipY
1269
- );
1270
- ctx.stroke();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1271
  }
1272
  }
1273
 
@@ -1275,29 +1430,65 @@ function drawGrass(t) {
1275
  // Drawn on top of everything else (it is closest to the viewer). Each blade gets a
1276
  // thin lighter inner stroke over a darker body for a lush, voluminous "closer" look.
1277
  function drawFgGrass(t) {
1278
- ctx.lineCap = "round";
1279
  for (const g of fgGrass) {
1280
- const amp = 4 + g.h * 0.03; // taller blades swing wider at the tip
1281
- const sway = Math.sin(t * g.speed + g.phase) * amp;
1282
- const tipX = g.x + g.lean * g.h + sway;
1283
- const tipY = g.y - g.h;
1284
- const cpx = (g.x + tipX) / 2 + sway * 0.5;
1285
- const cpy = (g.y + tipY) / 2;
1286
-
1287
- // dark body of the blade (in front)
1288
- ctx.strokeStyle = `hsla(${g.hue},60%,31%,0.96)`;
1289
- ctx.lineWidth = g.width;
 
 
 
 
 
 
 
1290
  ctx.beginPath();
1291
- ctx.moveTo(g.x, g.y);
1292
- ctx.quadraticCurveTo(cpx, cpy, tipX, tipY);
1293
- ctx.stroke();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1294
 
1295
- // lighter inner highlight for depth (cheap: one extra thin stroke)
1296
  ctx.strokeStyle = `hsla(${g.hue + 6},64%,45%,0.5)`;
1297
- ctx.lineWidth = g.width * 0.45;
 
1298
  ctx.beginPath();
1299
- ctx.moveTo(g.x, g.y);
1300
- ctx.quadraticCurveTo(cpx, cpy, tipX, tipY);
1301
  ctx.stroke();
1302
  }
1303
  }
 
43
  return a + d * t;
44
  }
45
 
46
+ const HUES = [8, 35, 46, 172, 195, 215, 262];
47
  const TAIL_TYPES = ['classic', 'fan', 'streamer']; // v11: three tail-fin styles, one per fish
48
  // v18: koi colourations — large irregular patches on a white/ivory base. Three classic
49
  // looks: kohaku (red/orange), sanke (red + black), golden (amber/gold). Patches are
 
62
  () => `hsla(${rand(38, 48)},${rand(85, 98)}%,${rand(55, 66)}%,0.92)`, // gold
63
  () => `hsla(${rand(26, 40)},${rand(85, 98)}%,${rand(50, 62)}%,0.88)`, // amber
64
  ],
65
+ goldspot: [
66
+ () => `hsla(${rand(40, 50)},${rand(15, 30)}%,${rand(92, 98)}%,0.9)`, // white spots
67
+ () => `hsla(${rand(40, 50)},${rand(15, 30)}%,${rand(92, 98)}%,0.9)`, // white spots (duplicate for consistent selection)
68
+ ],
69
  };
70
  const styles = palettes[type];
71
  const arr = [];
72
+ const isGoldspot = type === 'goldspot';
73
+ const nPatches = isGoldspot ? (8 + ((Math.random() * 8) | 0)) : (4 + ((Math.random() * 4) | 0)); // goldspot: 8..15, else 4..7
74
  for (let i = 0; i < nPatches; i++) {
75
  const th = rand(0, Math.PI * 2);
76
  const rr = Math.sqrt(Math.random()) * 0.85; // uniform in ellipse, kept off the rim
77
  arr.push({
78
  x: Math.cos(th) * rr, // along body axis (units of s)
79
  y: Math.sin(th) * rr * 0.42, // across body (matches the 0.42 ellipse ratio)
80
+ r: isGoldspot ? rand(0.06, 0.14) : rand(0.3, 0.52), // goldspot: small dots, else large patches
81
  style: styles[Math.random() < 0.5 ? 0 : 1](),
82
  });
83
  }
 
108
  // Koi — a white/ivory base with large coloured patches; the three classic
109
  // colourations are split roughly evenly across this 30% band.
110
  this.isKoi = true;
111
+ const koiType = specialRoll < 0.22 ? 'kohaku' : specialRoll < 0.30 ? 'sanke' : specialRoll < 0.38 ? 'golden' : 'goldspot';
112
+ if (koiType === 'goldspot') {
113
+ // Golden orange fish with white spots
114
+ this.hue = rand(30, 42); // golden orange
115
+ this.saturation = rand(85, 95); // high saturation → vivid color
116
+ this.lightness = rand(50, 65); // medium lightness → rich gold
117
+ } else {
118
+ // Classic koi — white/ivory base
119
+ this.hue = rand(40, 50); // warm ivory tint for the base
120
+ this.saturation = rand(12, 22); // low saturation → white body
121
+ this.lightness = rand(88, 94); // high lightness → white/ivory base
122
+ }
123
  this.koiPatches = genKoiPatches(koiType);
124
  } else {
125
  // Standard colored fish from the original palette
 
227
  } else {
228
  this.whiskerLen = null;
229
  }
230
+
231
+ // PERF: precompute per-fish paint styles once. draw() used to rebuild these
232
+ // template strings every frame for every fish — now it's a property read.
233
+ this.tailFillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.92)`;
234
+ this.dorsalFillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.9)`;
235
+ this.pevFillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.8)`;
236
+ this.pevStrokeStyle = `hsla(${this.hue},${Math.min(100, this.saturation * 1.15)}%,${Math.max(8, this.lightness - 20)}%,0.5)`;
237
+ this.whiskerStyle = `hsla(${this.hue},55%,82%,${this.whiskerAlpha || 0.5})`;
238
+
239
+ // PERF: the body gradient depends only on fixed per-fish colors and fish-local
240
+ // coordinates, so build it once instead of recreating it every frame.
241
+ const bg = ctx.createLinearGradient(0, -this.size * 0.5, 0, this.size * 0.5);
242
+ bg.addColorStop(0, `hsl(${this.hue},${Math.min(100, this.saturation * 1.08)}%,${this.lightness + 12}%)`);
243
+ bg.addColorStop(1, `hsl(${this.hue},${this.saturation * 0.9}%,${this.lightness - 8}%)`);
244
+ this.bodyGrad = bg;
245
+
246
  this.phase = rand(0, Math.PI * 2);
247
  // v9: after dark, most fish bed down low in the grass — only a small share still
248
  // hovers by the seaweed, and each of those keeps to its own stalk (no pile-ups)
 
488
  // whose control point sits just past where the old spike was. The style is fixed
489
  // per fish at spawn so the school has variety in its tails.
490
  // v14: use per-fish saturation and lightness for expanded color palette
491
+ c.fillStyle = this.tailFillStyle;
492
  c.beginPath();
493
  if (this.tailType === 'fan') {
494
  // Broad, rounded fan (betta/goldfish-style): wide spread, tips high and far out,
 
515
  c.fill();
516
 
517
  // body — undulates while swimming: the ellipse flexes along a traveling wave (v3)
518
+ const g = this.bodyGrad; // precomputed in constructor (static colors, local coords)
 
 
 
519
  const NSEG = 26; // outline samples for a smooth curve
520
  // wave offset at any x along the body: rigid at head, full flex at tail.
521
  // Shared with the dorsal fin so it stays glued to the moving back (v4)
 
610
  // so scales appear to catch the light while moving. One gradient + one fill.
611
  const shimmer = Math.sin(this.phase * 1.6) * 0.5 + 0.5; // 0..1 as it swims
612
  const iridHue = this.hue + 40 * Math.sin(this.phase * 2.3); // hue drifts with phase
613
+ // PERF: cache the sheen gradient. Its inputs move smoothly, so quantize them to a
614
+ // tiny grid (1/31 shimmer steps, 1° hue) and reuse until the cell changes — this
615
+ // skips one createLinearGradient + 3 addColorStop calls on most frames.
616
+ const igKey = Math.round(shimmer * 31) * 512 + (Math.round(iridHue) + 64);
617
+ if (igKey !== this._igKey || !this._igGrad) {
618
+ this._igGrad = c.createLinearGradient(-s, -s*0.3, s, s*0.35);
619
+ this._igGrad.addColorStop(0.0, `hsla(${this.hue},95%,72%,${0.10 + 0.18*shimmer})`);
620
+ this._igGrad.addColorStop(0.5, `hsla(${iridHue},90%,70%,${0.04 + 0.10*shimmer})`);
621
+ this._igGrad.addColorStop(1.0, `hsla(${this.hue + 200},85%,68%,${0.03 + 0.08*shimmer})`);
622
+ this._igKey = igKey;
623
+ }
624
+ const ig = this._igGrad;
625
  c.save();
626
  c.globalCompositeOperation = "lighter"; // additive sheen: reads as light on scales
627
  c.fillStyle = ig;
 
638
  // never disconnects as the back undulates (v4)
639
  // v17: matches the fish's own color instead of a fixed blue — silver fish no longer
640
  // sprout mismatched blue fins
641
+ c.fillStyle = this.dorsalFillStyle;
642
  c.beginPath();
643
  c.moveTo(-s * 0.15, -s * 0.36 + wy(-s * 0.15));
644
  c.quadraticCurveTo(s * 0.05, -s * 0.78 + wy(s * 0.05), s * 0.38, -s * 0.4 + wy(s * 0.38));
 
652
  c.rotate(0.7 + flap);
653
  // v17: pectoral fin matches the body color (no more blue arms on silver fish), but
654
  // gets a thin darker outline so it stays visible sitting on top of the same-colored body
655
+ c.fillStyle = this.pevFillStyle;
656
  c.beginPath();
657
  c.ellipse(0, 0, s * 0.3, s * 0.14, 0, 0, Math.PI * 2);
658
  c.fill();
659
+ c.strokeStyle = this.pevStrokeStyle;
660
  c.lineWidth = Math.max(1, s * 0.03);
661
  c.stroke();
662
  c.restore();
 
683
  const NPTS = 7; // sample points per whisker
684
  c.save();
685
  c.lineCap = "round";
686
+ c.strokeStyle = this.whiskerStyle;
687
  c.lineWidth = Math.max(1, s * 0.045);
688
  for (const side of [-1, 1]) { // one whisker each side
689
  const rootX = s * 0.6;
 
842
  const clusterHue = rand(95, 145);
843
  const nStalks = 3 + ((Math.random() * 4) | 0); // 3..6 stalks per bundle
844
  for (let j = 0; j < nStalks; j++) {
845
+ const hue = clusterHue + rand(-10, 10);
846
  const st = {
847
  x: cx + rand(-16, 16),
848
  h: rand(H * 0.12, H * 0.34),
849
  segs: 8,
850
  phase: rand(0, Math.PI * 2),
851
  speed: rand(0.9, 1.7),
852
+ hue,
853
  width: rand(4, 8),
854
  touch: 0,
855
+ // v15: organic rest curve — each stalk leans and waves like real seaweed
856
+ // instead of standing perfectly straight. Also fed into the physics (updatePlants)
857
+ // so fish push the stalk where it actually looks. Weighted by f → root stays planted.
858
+ cA1: rand(5, 12), cF1: rand(0.9, 1.8), cP1: rand(0, Math.PI * 2),
859
+ cA2: rand(2, 6), cF2: rand(2.2, 3.6), cP2: rand(0, Math.PI * 2),
860
+ tipW: rand(1.4, 2.6), // v15: blade tapers from width → tipW up the stipe
861
+ // PERF: stroke styles precomputed once (hue is fixed per stalk) — drawSeaweed
862
+ // used to rebuild both template strings every frame for every stalk.
863
+ stalkStyle: `hsla(${hue},55%,36%,0.92)`,
864
+ branchStyle: `hsla(${hue + 8},60%,46%,0.9)`,
865
+ midStyle: `hsla(${hue - 8},48%,27%,0.45)`, // v15: faint leaf vein down the blade
866
  off: new Float32Array(9), // lateral bend per segment (fish push leaves aside)
867
  offV: new Float32Array(9), // velocity of that bend
868
  branches: [],
 
879
  ph: rand(0, Math.PI * 2),
880
  flowers: Math.random() < 0.3 ? (() => { // ~30% of branches bloom with 1-3 heads (v3)
881
  const n = 1 + ((Math.random() * 3) | 0); // 1..3 flowers per branch
882
+ return Array.from({ length: n }, () => {
883
+ const hue = (Math.random() * 360) | 0; // colourful — full spectrum
884
+ return {
885
+ t: rand(0.45, 1), // where along the branch it sits (tip zone)
886
+ hue,
887
+ r: rand(4, 7), // head size
888
+ petals: 5 + ((Math.random() * 3) | 0), // 5..7 petals
889
+ // PERF: paint styles precomputed once — drawFlowerHead used to rebuild
890
+ // these strings per petal, per frame.
891
+ petalFill: `hsla(${hue},85%,64%,0.95)`,
892
+ centerFill: `hsla(${(hue + 45) % 360},92%,78%,0.95)`
893
+ };
894
+ });
895
  })() : []
896
  });
897
  }
 
917
  speed: rand(1.3, 2.8), // quicker, finer sway than seaweed
918
  hue: baseHue + rand(-9, 9),
919
  width: rand(1.4, 2.8),
920
+ ripple: 0, // agitation from nearby fish (0..1)
921
+ // v16: organic rest-curve + segmented blade outline (like seaweed)
922
+ cA1: rand(-5, 5), // primary bend amplitude
923
+ cF1: rand(2.2, 4.5), // frequency along blade length
924
+ cP1: rand(0, Math.PI * 2), // phase offset
925
+ segs: 3 + ((Math.random() * 2) | 0) // 3-4 segments for tapered outline
926
  });
927
  }
928
  }
 
946
  phase: rand(0, Math.PI * 2),
947
  speed: rand(0.8, 1.6), // slower, heavier sway than the tiny mid-ground grass
948
  hue: baseHue + rand(-10, 10),
949
+ width: rand(1.75, 3.5), // 50%: half thickness (was 3.5..7)
950
+ // v16: organic rest-curve + segmented blade outline (like seaweed)
951
+ cA1: rand(-8, 8), // primary bend amplitude (larger for foreground)
952
+ cF1: rand(2, 4), // frequency along blade length
953
+ cP1: rand(0, Math.PI * 2), // phase offset
954
+ segs: 4 + ((Math.random() * 2) | 0) // 4-5 segments for tapered outline
955
  });
956
  }
957
  }
 
1066
  ctx.fillRect(0, 0, W, H);
1067
  }
1068
 
1069
+ // PERF: shared scratch arrays for drawRays. The beam spines used to allocate fresh
1070
+ // point arrays — plus ~72 tiny [x,y] pairs per ray, per frame. Rendering is sequential,
1071
+ // so one set of preallocated slots serves every ray with zero per-frame allocation.
1072
+ const RAY_SEGS = 18; // vertical resolution of each beam's undulating spine
1073
+ const _rayL = [], _rayR = [], _rayLC = [], _rayRC = [];
1074
+ for (let i = 0; i <= RAY_SEGS; i++) {
1075
+ _rayL.push([0, 0]); _rayR.push([0, 0]); _rayLC.push([0, 0]); _rayRC.push([0, 0]);
1076
+ }
1077
+
1078
  function drawRays(t, light) {
1079
  const [rr, rg, rb] = light.ray;
1080
+ const segs = RAY_SEGS;
1081
  for (const r of rays) {
1082
  // slow horizontal drift of the whole beam (kept from v1)
1083
  const off = Math.sin(t * r.speed * 6 + r.x) * 55;
 
1088
  // Undulating spine: the beam's centerline bends left/right as it descends,
1089
  // driven by two traveling sine waves (different wavelength & speed) so it
1090
  // shimmers like sunlight refracted through moving water, seen from inside.
1091
+ const L = _rayL, R = _rayR;
1092
  for (let i = 0; i <= segs; i++) {
1093
  const v = i / segs; // 0 at the surface, 1 near the floor
1094
  const y = -10 + v * (H * 0.95 + 10);
 
1098
  Math.sin(v * r.vLen - t * r.vSpd + r.vPh) * r.vAmp;
1099
  const cx = x0 + shift * v + wave * grow; // undulating center x at this depth
1100
  const halfW = topW + (botW - topW) * v; // beam widens as it falls
1101
+ L[i][0] = cx - halfW; L[i][1] = y;
1102
+ R[i][0] = cx + halfW; R[i][1] = y;
1103
  }
1104
 
1105
  // Fill a band between two edge polylines with a vertical fade (matches v1 look).
 
1121
  band(L, R, r.alpha, 0.55, 0.45);
1122
 
1123
  // brighter, narrower inner core riding the same undulation (v1 core profile)
1124
+ const LC = _rayLC, RC = _rayRC;
1125
  for (let i = 0; i <= segs; i++) {
1126
  const [lx, ly] = L[i], [rx2, ry2] = R[i];
1127
  const cxm = (lx + rx2) / 2;
1128
  const coreHalf = ((rx2 - lx) / 2) * 0.42; // 42% of the local half-width, as in v1
1129
+ LC[i][0] = cxm - coreHalf; LC[i][1] = ly;
1130
+ RC[i][0] = cxm + coreHalf; RC[i][1] = ry2;
1131
  }
1132
  band(LC, RC, r.alpha * 1.5, 0.65, 0);
1133
  }
 
1178
  }
1179
  }
1180
 
1181
+ // v15: static organic lean of a stalk at fraction f up its length (root stays planted)
1182
+ function stalkCurveX(w, f) {
1183
+ return f * (w.cA1 * Math.sin(f * w.cF1 + w.cP1) + w.cA2 * Math.sin(f * w.cF2 + w.cP2));
1184
+ }
1185
+
1186
  function updatePlants(t, dt) {
1187
  plantPoints.length = 0;
1188
  for (const w of seaweeds) {
 
1194
  const moveF = Math.min(1, fSpd / 1.2); // v9: how actively this fish is swimming
1195
  for (let i = 1; i <= w.segs; i++) {
1196
  const fl = i / w.segs;
1197
+ const px = w.x + w.off[i] + stalkCurveX(w, fl) + Math.sin(t * w.speed + w.phase + fl * 2.6) * fl * fl * 24;
1198
  const py = sandAt(w.x) + 8 - w.h * fl;
1199
  const dx = px - f.x, dy = py - f.y;
1200
  const d2 = dx * dx + dy * dy;
 
1238
  // normal sway + a fast extra flinch while being pushed
1239
  const sway = Math.sin(t * w.speed + w.phase + f * 2.6) * f * f * 24
1240
  + Math.sin(t * 7 + w.phase) * w.touch * f * 16;
1241
+ const px = w.x + (w.off[i] || 0) + stalkCurveX(w, f) + sway;
1242
  const py = baseY - w.h * f;
1243
  w.points.push({ x: px, y: py });
1244
  plantPoints.push({ x: px, y: py, r: w.width * 0.55, s: w });
 
1276
 
1277
  function drawSeaweed() {
1278
  for (const w of seaweeds) {
1279
+ const pts = w.points, n = pts.length;
1280
+ // v15: main stipe as a tapered leaf-blade — an outline around the same physics
1281
+ // points, wide at the root and thinning to a soft point up top. No straight-line
1282
+ // segments anywhere; the stalk's own curve comes from the sway + rest-curve baked
1283
+ // into those points in updatePlants.
1284
+ ctx.fillStyle = w.stalkStyle;
1285
+ ctx.beginPath();
1286
+ for (let i = 0; i < n; i++) { // up one side of the blade
1287
+ const p = pts[i];
1288
+ const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
1289
+ let dx = b.x - a.x, dy = b.y - a.y;
1290
+ const dl = Math.hypot(dx, dy) || 1;
1291
+ const nx = -dy / dl, ny = dx / dl; // perpendicular to the local stalk direction
1292
+ const f = i / (n - 1);
1293
+ const hw = (w.width * (1 - f) + w.tipW * f) / 2; // half-width: base → tip taper
1294
+ if (i === 0) ctx.moveTo(p.x + nx * hw, p.y + ny * hw);
1295
+ else ctx.lineTo(p.x + nx * hw, p.y + ny * hw);
1296
+ }
1297
+ { // rounded cap at the tip
1298
+ const tp = pts[n - 1], pp = pts[n - 2];
1299
+ let dx = tp.x - pp.x, dy = tp.y - pp.y;
1300
+ const dl = Math.hypot(dx, dy) || 1;
1301
+ const nx = -dy / dl, ny = dx / dl;
1302
+ const hwT = w.tipW / 2;
1303
+ ctx.quadraticCurveTo(tp.x + dx, tp.y + dy * 0.6, tp.x - nx * hwT, tp.y - ny * hwT);
1304
+ }
1305
+ for (let i = n - 1; i >= 0; i--) { // down the other side
1306
+ const p = pts[i];
1307
+ const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
1308
+ let dx = b.x - a.x, dy = b.y - a.y;
1309
+ const dl = Math.hypot(dx, dy) || 1;
1310
+ const nx = -dy / dl, ny = dx / dl;
1311
+ const f = i / (n - 1);
1312
+ const hw = (w.width * (1 - f) + w.tipW * f) / 2;
1313
+ ctx.lineTo(p.x - nx * hw, p.y - ny * hw);
1314
+ }
1315
+ ctx.closePath();
1316
+ ctx.fill();
1317
+
1318
+ // v15: faint midrib vein down the blade's centreline for a leafy look
1319
+ ctx.strokeStyle = w.midStyle;
1320
+ ctx.lineWidth = 1.4;
1321
  ctx.lineCap = "round";
1322
  ctx.beginPath();
1323
+ ctx.moveTo(pts[0].x, pts[0].y);
1324
+ for (let i = 1; i < n; i++) ctx.lineTo(pts[i].x, pts[i].y);
1325
  ctx.stroke();
1326
 
1327
  // side branches curling up and out from the stalk
1328
  for (const b of w.branches) {
1329
  const a = w.points[Math.min(w.segs, Math.max(1, Math.round(b.f * w.segs)))];
1330
+ // v15: S-curved frond — leaves the stalk heading upward, then arcs out and up
1331
+ // to the tip instead of one plain quadratic bow (same endpoints as before)
1332
+ const c1x = a.x + b.side * b.len * 0.3;
1333
+ const c1y = a.y - b.up * 0.75;
1334
+ const c2x = b.tx - b.side * b.len * 0.35;
1335
+ const c2y = b.ty + b.up * 0.6;
1336
+ ctx.strokeStyle = w.branchStyle;
1337
  ctx.lineWidth = b.w;
1338
  ctx.beginPath();
1339
  ctx.moveTo(a.x, a.y);
1340
+ ctx.bezierCurveTo(c1x, c1y, c2x, c2y, b.tx, b.ty);
1341
  ctx.stroke();
1342
 
1343
  // colourful flower heads blooming along the branch — 1..3 per branch (v3)
1344
  if (b.flowers.length) {
 
 
1345
  for (const fl of b.flowers) {
1346
+ const u = fl.t, iu = 1 - u; // point on the branch's cubic curve (v15)
1347
+ drawFlowerHead(
1348
+ iu * iu * iu * a.x + 3 * iu * iu * u * c1x + 3 * iu * u * u * c2x + u * u * u * b.tx,
1349
+ iu * iu * iu * a.y + 3 * iu * iu * u * c1y + 3 * iu * u * u * c2y + u * u * u * b.ty, fl);
1350
  }
1351
  }
1352
  }
 
1359
  const ang = (i / fl.petals) * Math.PI * 2;
1360
  const px = x + Math.cos(ang) * fl.r * 0.72;
1361
  const py = y + Math.sin(ang) * fl.r * 0.72;
1362
+ ctx.fillStyle = fl.petalFill;
1363
  ctx.beginPath();
1364
  ctx.ellipse(px, py, fl.r * 0.55, fl.r * 0.36, ang, 0, Math.PI * 2);
1365
  ctx.fill();
1366
  }
1367
+ ctx.fillStyle = fl.centerFill; // bright contrasting centre (precomputed at bloom time)
1368
  ctx.beginPath();
1369
  ctx.arc(x, y, fl.r * 0.42, 0, Math.PI * 2);
1370
  ctx.fill();
 
1373
 
1374
  function drawGrass(t) {
1375
  for (const g of grass) {
1376
+ // v16: tapered multi-segment blade — wide at root, thinning to a soft point
1377
  const amp = 3 + g.ripple * 13;
1378
+ const n = g.segs + 1; // number of points along the blade
1379
+ const pts = [];
1380
+ for (let i = 0; i < n; i++) {
1381
+ const f = i / g.segs; // 0 at base, 1 at tip
1382
+ // organic rest-curve: each blade has its own natural bend (like seaweed)
1383
+ const curveX = f * g.cA1 * Math.sin(f * g.cF1 + g.cP1);
1384
+ // constant sway, amplified while rippling from a passing fish
1385
+ const sway = Math.sin(t * g.speed + g.phase + f * 2.4) * amp * f;
1386
+ pts.push({
1387
+ x: g.x + g.lean * g.h * f + curveX + sway,
1388
+ y: g.y - g.h * f * (1 - g.ripple * 0.06)
1389
+ });
1390
+ }
1391
+ // draw tapered outline around the physics points
1392
+ ctx.fillStyle = `hsla(${g.hue},58%,40%,0.92)`;
1393
  ctx.beginPath();
1394
+ for (let i = 0; i < n; i++) {
1395
+ const p = pts[i];
1396
+ const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
1397
+ let dx = b.x - a.x, dy = b.y - a.y;
1398
+ const dl = Math.hypot(dx, dy) || 1;
1399
+ const nx = -dy / dl, ny = dx / dl; // perpendicular to local blade direction
1400
+ const f = i / (n - 1);
1401
+ const hw = (g.width * (1 - f) + 0.8 * f) / 2; // half-width: base → tip taper
1402
+ if (i === 0) ctx.moveTo(p.x + nx * hw, p.y + ny * hw);
1403
+ else ctx.lineTo(p.x + nx * hw, p.y + ny * hw);
1404
+ }
1405
+ // rounded cap at the tip
1406
+ {
1407
+ const tp = pts[n - 1], pp = pts[n - 2];
1408
+ let dx = tp.x - pp.x, dy = tp.y - pp.y;
1409
+ const dl = Math.hypot(dx, dy) || 1;
1410
+ const nx = -dy / dl, ny = dx / dl;
1411
+ const hwT = 0.4; // tip half-width
1412
+ ctx.quadraticCurveTo(tp.x + dx, tp.y + dy * 0.6, tp.x - nx * hwT, tp.y - ny * hwT);
1413
+ }
1414
+ for (let i = n - 1; i >= 0; i--) {
1415
+ const p = pts[i];
1416
+ const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
1417
+ let dx = b.x - a.x, dy = b.y - a.y;
1418
+ const dl = Math.hypot(dx, dy) || 1;
1419
+ const nx = -dy / dl, ny = dx / dl;
1420
+ const f = i / (n - 1);
1421
+ const hw = (g.width * (1 - f) + 0.8 * f) / 2;
1422
+ ctx.lineTo(p.x - nx * hw, p.y - ny * hw);
1423
+ }
1424
+ ctx.closePath();
1425
+ ctx.fill();
1426
  }
1427
  }
1428
 
 
1430
  // Drawn on top of everything else (it is closest to the viewer). Each blade gets a
1431
  // thin lighter inner stroke over a darker body for a lush, voluminous "closer" look.
1432
  function drawFgGrass(t) {
 
1433
  for (const g of fgGrass) {
1434
+ // v16: tapered multi-segment blade with two-tone depth (like seaweed)
1435
+ const amp = 4 + g.h * 0.03;
1436
+ const n = g.segs + 1;
1437
+ const pts = [];
1438
+ for (let i = 0; i < n; i++) {
1439
+ const f = i / g.segs;
1440
+ // organic rest-curve: each blade has its own natural bend
1441
+ const curveX = f * g.cA1 * Math.sin(f * g.cF1 + g.cP1);
1442
+ // constant sway, weighted toward the tip
1443
+ const sway = Math.sin(t * g.speed + g.phase + f * 2.4) * amp * f;
1444
+ pts.push({
1445
+ x: g.x + g.lean * g.h * f + curveX + sway,
1446
+ y: g.y - g.h * f
1447
+ });
1448
+ }
1449
+ // dark body of the blade (tapered outline)
1450
+ ctx.fillStyle = `hsla(${g.hue},60%,31%,0.96)`;
1451
  ctx.beginPath();
1452
+ for (let i = 0; i < n; i++) {
1453
+ const p = pts[i];
1454
+ const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
1455
+ let dx = b.x - a.x, dy = b.y - a.y;
1456
+ const dl = Math.hypot(dx, dy) || 1;
1457
+ const nx = -dy / dl, ny = dx / dl;
1458
+ const f = i / (n - 1);
1459
+ const hw = (g.width * (1 - f) + 1.2 * f) / 2; // half-width: base → tip
1460
+ if (i === 0) ctx.moveTo(p.x + nx * hw, p.y + ny * hw);
1461
+ else ctx.lineTo(p.x + nx * hw, p.y + ny * hw);
1462
+ }
1463
+ // rounded cap at the tip
1464
+ {
1465
+ const tp = pts[n - 1], pp = pts[n - 2];
1466
+ let dx = tp.x - pp.x, dy = tp.y - pp.y;
1467
+ const dl = Math.hypot(dx, dy) || 1;
1468
+ const nx = -dy / dl, ny = dx / dl;
1469
+ const hwT = 0.6; // tip half-width
1470
+ ctx.quadraticCurveTo(tp.x + dx, tp.y + dy * 0.6, tp.x - nx * hwT, tp.y - ny * hwT);
1471
+ }
1472
+ for (let i = n - 1; i >= 0; i--) {
1473
+ const p = pts[i];
1474
+ const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
1475
+ let dx = b.x - a.x, dy = b.y - a.y;
1476
+ const dl = Math.hypot(dx, dy) || 1;
1477
+ const nx = -dy / dl, ny = dx / dl;
1478
+ const f = i / (n - 1);
1479
+ const hw = (g.width * (1 - f) + 1.2 * f) / 2;
1480
+ ctx.lineTo(p.x - nx * hw, p.y - ny * hw);
1481
+ }
1482
+ ctx.closePath();
1483
+ ctx.fill();
1484
 
1485
+ // lighter inner highlight for depth: a thin centerline stroke
1486
  ctx.strokeStyle = `hsla(${g.hue + 6},64%,45%,0.5)`;
1487
+ ctx.lineWidth = g.width * 0.3;
1488
+ ctx.lineCap = "round";
1489
  ctx.beginPath();
1490
+ ctx.moveTo(pts[0].x, pts[0].y);
1491
+ for (let i = 1; i < n; i++) ctx.lineTo(pts[i].x, pts[i].y);
1492
  ctx.stroke();
1493
  }
1494
  }