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

Upload index.html

Browse files
Files changed (1) hide show
  1. index.html +331 -6
index.html CHANGED
@@ -1175,12 +1175,13 @@ function buildScene() {
1175
  // drawn last and foreshortened. It grows in the low band below the sand line, i.e. the
1176
  // zone fish never reach, and sways with the water like the other flora but has no
1177
  // fish-push physics (purely decorative). ALT-FG: all sizes halved vs the original fg.
1178
- const nFgClusters = Math.max(8, Math.floor(W / 120));
 
1179
  for (let i = 0; i < nFgClusters; i++) {
1180
  const cx = rand(-10, W + 10);
1181
  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
1182
  const baseHue = rand(46, 76); // v12.1: yellowish-green — pops against the mid-ground green grass
1183
- const nBlades = 3 + ((Math.random() * 4) | 0); // 3..6 blades per clump
1184
  for (let b = 0; b < nBlades; b++) {
1185
  const fgSegs = 4 + ((Math.random() * 2) | 0); // 4-5 segments for tapered outline
1186
  fgGrass.push({
@@ -1202,10 +1203,15 @@ function buildScene() {
1202
  sFreq: rand(3.8, 6.5),
1203
  sPh: rand(0, Math.PI * 2),
1204
  // PERF: per-blade scratch points — drawFgGrass reuses them in place
1205
- pts: Array.from({ length: fgSegs + 1 }, () => ({ x: 0, y: 0 }))
 
 
 
 
1206
  });
1207
  }
1208
  }
 
1209
 
1210
  // PERF: pool the plantPoints collision objects too — updatePlants reuses them
1211
  // in place instead of allocating one per stalk/branch point every frame.
@@ -1499,8 +1505,8 @@ function updateMotes(dt, t) {
1499
 
1500
  function drawSand(c = ctx) {
1501
  const g = c.createLinearGradient(0, H * 0.85, 0, H);
1502
- g.addColorStop(0, "#d9b97c");
1503
- g.addColorStop(1, "#7e653d");
1504
  c.fillStyle = g;
1505
  c.beginPath();
1506
  c.moveTo(-10, H + 10);
@@ -1811,6 +1817,80 @@ function buildFarSeaweedSprites() {
1811
  }
1812
  }
1813
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1814
  // v5: draw ultra-far seaweed sprites with simple sway animation
1815
  function drawFarSeaweeds(t) {
1816
  for (const w of farSeaweeds) {
@@ -2085,6 +2165,18 @@ function drawGrass(t) {
2085
  // thin lighter inner stroke over a darker body for a lush, voluminous "closer" look.
2086
  function drawFgGrass(t) {
2087
  for (const g of fgGrass) {
 
 
 
 
 
 
 
 
 
 
 
 
2088
  // v16: tapered multi-segment blade with two-tone depth (like seaweed)
2089
  const amp = 4 + g.h * 0.03;
2090
  const n = g.segs + 1;
@@ -2221,6 +2313,92 @@ let lastGrainDrawn = false;
2221
  // per-ray blur() filter is far cheaper rasterizing small areas than full-screen.
2222
 
2223
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2224
  let staticLayer = null;
2225
  function buildStaticLayer() {
2226
  staticLayer = document.createElement("canvas");
@@ -2229,9 +2407,135 @@ function buildStaticLayer() {
2229
  const c2 = staticLayer.getContext("2d");
2230
  c2.setTransform(ctx.getTransform()); // match DPR scaling exactly
2231
  drawSand(c2);
2232
- drawRocks(c2);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2233
  }
2234
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2235
  let last = performance.now();
2236
  function frame(now) {
2237
  const dt = Math.min(0.05, (now - last) / 1000);
@@ -2286,8 +2590,27 @@ function frame(now) {
2286
  for (const f of fishes) f.draw(ctx);
2287
 
2288
  ctx.drawImage(staticLayer, 0, 0, W, H);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2289
  drawSeaweed();
2290
  drawGrass(t);
 
2291
 
2292
  for (let i = bubbles.length - 1; i >= 0; i--) {
2293
  const b = bubbles[i];
@@ -2324,6 +2647,8 @@ function doResize() {
2324
  buildFarSeaweedSprites(); // v5: rebuild ultra-far seaweed sprites for new size
2325
  buildVignette(); // v6: rebuild the static vignette layer
2326
  buildStaticLayer();
 
 
2327
  if (!grainPattern) buildGrainTile(); // v8: grain tile is size-independent, build once
2328
  }
2329
  // ============ Player controls: keyboard + gamepad ============
 
1175
  // drawn last and foreshortened. It grows in the low band below the sand line, i.e. the
1176
  // zone fish never reach, and sways with the water like the other flora but has no
1177
  // fish-push physics (purely decorative). ALT-FG: all sizes halved vs the original fg.
1178
+ // DOUBLED: more foreground grass clusters for denser coverage
1179
+ const nFgClusters = Math.max(16, Math.floor(W / 60));
1180
  for (let i = 0; i < nFgClusters; i++) {
1181
  const cx = rand(-10, W + 10);
1182
  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
1183
  const baseHue = rand(46, 76); // v12.1: yellowish-green — pops against the mid-ground green grass
1184
+ const nBlades = 5 + ((Math.random() * 4) | 0); // 5..8 blades per clump (doubled density)
1185
  for (let b = 0; b < nBlades; b++) {
1186
  const fgSegs = 4 + ((Math.random() * 2) | 0); // 4-5 segments for tapered outline
1187
  fgGrass.push({
 
1203
  sFreq: rand(3.8, 6.5),
1204
  sPh: rand(0, Math.PI * 2),
1205
  // PERF: per-blade scratch points — drawFgGrass reuses them in place
1206
+ pts: Array.from({ length: fgSegs + 1 }, () => ({ x: 0, y: 0 })),
1207
+ // v28: ~40% of blades get a gentle blur (pre-rendered sprite) for depth
1208
+ blurred: Math.random() < 0.4,
1209
+ sprite: null,
1210
+ spriteScale: 1
1211
  });
1212
  }
1213
  }
1214
+ buildFgGrassSprites(); // v28: bake the defocused-blade sprites once (free at runtime)
1215
 
1216
  // PERF: pool the plantPoints collision objects too — updatePlants reuses them
1217
  // in place instead of allocating one per stalk/branch point every frame.
 
1505
 
1506
  function drawSand(c = ctx) {
1507
  const g = c.createLinearGradient(0, H * 0.85, 0, H);
1508
+ g.addColorStop(0, "#cdd8bd"); // v24: pale aqua-tan (cooled from warm brown)
1509
+ g.addColorStop(1, "#7f938b"); // v24: sea-glass taupe
1510
  c.fillStyle = g;
1511
  c.beginPath();
1512
  c.moveTo(-10, H + 10);
 
1817
  }
1818
  }
1819
 
1820
+ // v28: pre-render blurred foreground grass sprites for ~40% of blades (gentle depth effect)
1821
+ function buildFgGrassSprites() {
1822
+ const BLUR = 1.5; // very gentle blur — just enough to suggest "slightly out of focus"
1823
+ const PAD = 8; // extra space for blur spill
1824
+
1825
+ for (const g of fgGrass) {
1826
+ if (!g.blurred || g.sprite) continue; // only build for flagged blades that don't have sprites yet
1827
+
1828
+ // Bounding box: account for the lean deviation so the tip can't clip out of canvas
1829
+ const dev = g.lean * g.h; // horizontal offset of tip from base
1830
+ const bw = Math.ceil(Math.abs(dev) + g.width + PAD * 2);
1831
+ const bh = Math.ceil(g.h + PAD * 2);
1832
+
1833
+ // Create sprite canvas
1834
+ const spr = document.createElement("canvas");
1835
+ spr.width = bw;
1836
+ spr.height = bh;
1837
+ const sc = spr.getContext("2d");
1838
+
1839
+ // Apply gentle blur filter (baked in at build time - zero runtime cost)
1840
+ sc.filter = `blur(${BLUR}px)`;
1841
+
1842
+ // Draw the blade as a tapered shape with the same style as drawFgGrass
1843
+ const localBaseX = PAD - Math.min(0, dev); // base x so both base and tip fit in canvas
1844
+ const localBaseY = bh - PAD;
1845
+
1846
+ // Dark body of the blade (simplified static version)
1847
+ sc.fillStyle = `hsla(${g.hue},60%,31%,0.96)`;
1848
+ sc.beginPath();
1849
+
1850
+ // Simple tapered blade shape
1851
+ const nSegs = g.segs + 1;
1852
+ for (let i = 0; i < nSegs; i++) {
1853
+ const f = i / g.segs;
1854
+ const x = localBaseX + g.lean * g.h * f;
1855
+ const y = localBaseY - g.h * f;
1856
+ const hw = (g.width * (1 - f) + 1.2 * f) / 2;
1857
+
1858
+ if (i === 0) sc.moveTo(x - hw, y);
1859
+ else sc.lineTo(x - hw, y);
1860
+ }
1861
+ // Rounded tip
1862
+ const tipX = localBaseX + g.lean * g.h;
1863
+ const tipY = localBaseY - g.h;
1864
+ sc.quadraticCurveTo(tipX + 2, tipY + 3, tipX + 1, tipY);
1865
+
1866
+ // Right side going back down
1867
+ for (let i = nSegs - 1; i >= 0; i--) {
1868
+ const f = i / g.segs;
1869
+ const x = localBaseX + g.lean * g.h * f;
1870
+ const y = localBaseY - g.h * f;
1871
+ const hw = (g.width * (1 - f) + 1.2 * f) / 2;
1872
+ sc.lineTo(x + hw, y);
1873
+ }
1874
+
1875
+ sc.closePath();
1876
+ sc.fill();
1877
+
1878
+ // Lighter inner highlight
1879
+ sc.strokeStyle = `hsla(${g.hue + 6},64%,45%,0.5)`;
1880
+ sc.lineWidth = g.width * 0.3;
1881
+ sc.lineCap = "round";
1882
+ sc.beginPath();
1883
+ sc.moveTo(localBaseX, localBaseY);
1884
+ sc.lineTo(tipX, tipY);
1885
+ sc.stroke();
1886
+
1887
+ // Store sprite and offset for drawing (offsets place the blade base at the draw origin)
1888
+ g.sprite = spr;
1889
+ g.spriteXOffset = -localBaseX;
1890
+ g.spriteYOffset = -localBaseY;
1891
+ }
1892
+ }
1893
+
1894
  // v5: draw ultra-far seaweed sprites with simple sway animation
1895
  function drawFarSeaweeds(t) {
1896
  for (const w of farSeaweeds) {
 
2165
  // thin lighter inner stroke over a darker body for a lush, voluminous "closer" look.
2166
  function drawFgGrass(t) {
2167
  for (const g of fgGrass) {
2168
+ // v28: defocused blades — blit the pre-blurred sprite with a gentle rotational sway
2169
+ // around its base (same trick as the ultra-far seaweed). The baked blur hides the
2170
+ // simplified motion, and this path is far cheaper than per-frame canvas filters.
2171
+ if (g.sprite) {
2172
+ const swayAngle = Math.sin(t * g.speed + g.phase) * 0.05; // subtle: like farSeaweeds
2173
+ ctx.save();
2174
+ ctx.translate(g.x, g.y);
2175
+ ctx.rotate(swayAngle);
2176
+ ctx.drawImage(g.sprite, g.spriteXOffset, g.spriteYOffset);
2177
+ ctx.restore();
2178
+ continue;
2179
+ }
2180
  // v16: tapered multi-segment blade with two-tone depth (like seaweed)
2181
  const amp = 4 + g.h * 0.03;
2182
  const n = g.segs + 1;
 
2313
  // per-ray blur() filter is far cheaper rasterizing small areas than full-screen.
2314
 
2315
 
2316
+ // v24: baked current ripples — faint curved lines following the sand contour
2317
+ function drawRipples(c) {
2318
+ c.save();
2319
+ c.lineCap = "round";
2320
+ const nLines = Math.floor(W / 16);
2321
+ for (let i = 0; i < nLines; i++) {
2322
+ const x0 = rand(0, W);
2323
+ const yBase = sandAt(x0);
2324
+ const len = rand(35, 95);
2325
+ const bow = rand(-7, 7);
2326
+ c.strokeStyle = `rgba(126, 148, 141, ${rand(0.07, 0.16)})`;
2327
+ c.lineWidth = rand(1, 2);
2328
+ c.beginPath();
2329
+ c.moveTo(x0, yBase + rand(3, H * 0.05));
2330
+ c.quadraticCurveTo(
2331
+ x0 + len / 2,
2332
+ sandAt(x0 + len / 2) + bow,
2333
+ x0 + len,
2334
+ sandAt(x0 + len) + rand(3, H * 0.04)
2335
+ );
2336
+ c.stroke();
2337
+ }
2338
+ c.restore();
2339
+ }
2340
+
2341
+ // v24: baked pebble/speckle scatter — grainy cool-toned dots across the sand bed
2342
+ function drawPebbles(c) {
2343
+ const n = Math.floor(W * H / 1800);
2344
+ for (let i = 0; i < n; i++) {
2345
+ const x = rand(0, W);
2346
+ const y = sandAt(x) + rand(2, H * 0.14);
2347
+ if (y > H) continue;
2348
+ // cool palette: grey-teal, muted green, pale blue-grey
2349
+ const hue = rand(150, 200);
2350
+ const light = rand(38, 68);
2351
+ c.fillStyle = `hsla(${hue}, ${rand(10, 28)}%, ${light}%, ${rand(0.25, 0.5)})`;
2352
+ const r = rand(0.7, 2.4);
2353
+ if (Math.random() < 0.6) {
2354
+ // small round dot
2355
+ c.beginPath();
2356
+ c.arc(x, y, r, 0, Math.PI * 2);
2357
+ c.fill();
2358
+ } else {
2359
+ // small ellipse pebble
2360
+ c.save();
2361
+ c.translate(x, y);
2362
+ c.rotate(rand(0, Math.PI));
2363
+ c.beginPath();
2364
+ c.ellipse(0, 0, r * rand(1.2, 2), r * rand(0.5, 0.8), 0, 0, Math.PI * 2);
2365
+ c.fill();
2366
+ c.restore();
2367
+ }
2368
+ }
2369
+ }
2370
+
2371
+ // v24: contact shadows — soft dark ellipses under rocks + faint band at sand surface line
2372
+ function drawContactShadows(c) {
2373
+ // Faint darker band right at the sand surface (grounds the whole bed)
2374
+ // FIXED: follows the actual sand contour instead of a fixed horizontal band
2375
+ c.save();
2376
+ const bandGrad = c.createLinearGradient(0, H * 0.85, 0, H * 0.9);
2377
+ bandGrad.addColorStop(0, "rgba(50, 72, 68, 0)");
2378
+ bandGrad.addColorStop(1, "rgba(50, 72, 68, 0.10)");
2379
+ c.fillStyle = bandGrad;
2380
+ // Draw the band following the sand contour to avoid visible horizontal lines
2381
+ c.beginPath();
2382
+ c.moveTo(-10, H + 10);
2383
+ for (let i = 0; i < sandHeights.length; i++) {
2384
+ const x = i * 40;
2385
+ const y = sandHeights[i] + rand(2, H * 0.05); // offset below the surface
2386
+ c.lineTo(x, y);
2387
+ }
2388
+ c.lineTo(W + 10, H + 10);
2389
+ c.closePath();
2390
+ c.fill();
2391
+ // Soft ellipse under each rock
2392
+ for (const rk of rocks) {
2393
+ const y = sandAt(rk.x) + rk.r * 0.35;
2394
+ c.fillStyle = "rgba(42, 60, 58, 0.18)";
2395
+ c.beginPath();
2396
+ c.ellipse(rk.x, y + rk.r * 0.25, rk.r * 1.25, rk.r * 0.38, 0, 0, Math.PI * 2);
2397
+ c.fill();
2398
+ }
2399
+ c.restore();
2400
+ }
2401
+
2402
  let staticLayer = null;
2403
  function buildStaticLayer() {
2404
  staticLayer = document.createElement("canvas");
 
2407
  const c2 = staticLayer.getContext("2d");
2408
  c2.setTransform(ctx.getTransform()); // match DPR scaling exactly
2409
  drawSand(c2);
2410
+ drawRipples(c2); // v24: baked current ripples (free at runtime)
2411
+ drawPebbles(c2); // v24: baked grainy pebble/speckle scatter
2412
+ drawContactShadows(c2); // v24: soft dark ellipses under rocks + surface band
2413
+ // NOTE: rocks are no longer baked here — they live in rocksLayer so they can be
2414
+ // drawn AFTER the mid-ground grass (rocks sit IN FRONT of the blades, not behind them).
2415
+ }
2416
+ // Rocks on their own layer: blitted after drawGrass() so stones read as sitting in
2417
+ // front of the plants instead of the grass appearing to sprout out of them.
2418
+ let rocksLayer = null;
2419
+ function buildRocksLayer() {
2420
+ rocksLayer = document.createElement("canvas");
2421
+ rocksLayer.width = canvas.width; // same device-pixel resolution as the tank
2422
+ rocksLayer.height = canvas.height;
2423
+ const c3 = rocksLayer.getContext("2d");
2424
+ c3.setTransform(ctx.getTransform()); // match DPR scaling exactly
2425
+ drawRocks(c3);
2426
+ }
2427
+
2428
+ // ============ Tier 2: Caustics + light pooling (v25) ============
2429
+ let causticTile = null;
2430
+ let causticOffsetX = 0, causticOffsetY = 0;
2431
+ let lastCausticShift = 0;
2432
+ const CAUSTIC_TILE_SIZE = 160; // small tile — cheap to blit repeatedly
2433
+
2434
+ function buildCausticTile() {
2435
+ causticTile = document.createElement("canvas");
2436
+ const s = CAUSTIC_TILE_SIZE;
2437
+ causticTile.width = s; causticTile.height = s;
2438
+ const c = causticTile.getContext("2d");
2439
+
2440
+ // Apply blur to make caustics look softer/more realistic
2441
+ c.filter = "blur(4px)"; // v26: tighter blur so the pattern reads clearly
2442
+
2443
+ // Draw a few random bright spots/lines that look like refracted light patterns
2444
+ for (let i = 0; i < 18; i++) {
2445
+ const x = Math.random() * s, y = Math.random() * s;
2446
+ const r = rand(4, 14);
2447
+ const g = c.createRadialGradient(x, y, 0, x, y, r);
2448
+ // Faint white-blue glow (will be blended with 'screen')
2449
+ g.addColorStop(0, "rgba(255,255,255,0.44)"); // v26: +25% brighter
2450
+ g.addColorStop(1, "rgba(255,255,255,0)");
2451
+ c.fillStyle = g;
2452
+ c.fillRect(x - r, y - r, r * 2, r * 2);
2453
+ }
2454
+ // Add some thin curved lines (like caustic web patterns)
2455
+ for (let i = 0; i < 8; i++) {
2456
+ const x1 = Math.random() * s, y1 = Math.random() * s;
2457
+ const cx = x1 + rand(-40, 40), cy = y1 + rand(-40, 40);
2458
+ const x2 = Math.random() * s, y2 = Math.random() * s;
2459
+ c.strokeStyle = "rgba(255,255,255,0.23)"; // v26: +25% brighter
2460
+ c.lineWidth = rand(1.5, 3.5);
2461
+ c.beginPath();
2462
+ c.moveTo(x1, y1);
2463
+ c.quadraticCurveTo(cx, cy, x2, y2);
2464
+ c.stroke();
2465
+ }
2466
+
2467
+ // Reset filter
2468
+ c.filter = "none";
2469
+ }
2470
+
2471
+ function drawCaustics(t) {
2472
+ if (!causticTile || !sandHeights.length) return;
2473
+ // Shift the tile offset every ~50ms for a subtle shimmer effect (cheap: no redraw, just reposition)
2474
+ if (t - lastCausticShift > 0.05) {
2475
+ causticOffsetX = Math.random() * CAUSTIC_TILE_SIZE;
2476
+ causticOffsetY = Math.random() * CAUSTIC_TILE_SIZE;
2477
+ lastCausticShift = t;
2478
+ }
2479
+
2480
+ ctx.save();
2481
+
2482
+ // Create a clipping path that follows the actual sand terrain
2483
+ ctx.beginPath();
2484
+ ctx.moveTo(-10, H + 10);
2485
+ for (let i = 0; i < sandHeights.length; i++) {
2486
+ ctx.lineTo(i * 40, sandHeights[i]);
2487
+ }
2488
+ ctx.lineTo(W + 10, H + 10);
2489
+ ctx.closePath();
2490
+ ctx.clip();
2491
+
2492
+ ctx.globalCompositeOperation = "screen"; // additive light effect
2493
+ ctx.globalAlpha = 0.35; // v26: +25% brighter so the shimmer stands out from the sand
2494
+
2495
+ // Tile the caustic pattern across the entire canvas (clip will handle masking)
2496
+ const tileW = CAUSTIC_TILE_SIZE, tileH = CAUSTIC_TILE_SIZE;
2497
+ for (let x = -causticOffsetX % tileW - tileW; x < W + tileW; x += tileW) {
2498
+ for (let y = -causticOffsetY % tileH - tileH; y < H + tileH; y += tileH) {
2499
+ ctx.drawImage(causticTile, x, y);
2500
+ }
2501
+ }
2502
+
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();
2510
+ ctx.globalCompositeOperation = "screen"; // additive light
2511
+
2512
+ for (const r of rays) {
2513
+ // Compute where this ray hits the sand at y ≈ sandTop
2514
+ // Use the same wave math as the ray drawing to match positions
2515
+ const off = Math.sin(t * r.speed * 6 + r.x) * 55;
2516
+ // At the sand depth (vM near the bottom), compute the x position
2517
+ const vM = 0.95; // approximate depth where sand starts
2518
+ const wave =
2519
+ Math.sin(vM * r.uLen + t * r.uSpd + r.uPh) * r.uAmp +
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();
2536
+ }
2537
+
2538
+
2539
  let last = performance.now();
2540
  function frame(now) {
2541
  const dt = Math.min(0.05, (now - last) / 1000);
 
2590
  for (const f of fishes) f.draw(ctx);
2591
 
2592
  ctx.drawImage(staticLayer, 0, 0, W, H);
2593
+
2594
+ // v27: at night, dim the bare floor ~25% (clipped to the sand shape) BEFORE the caustics,
2595
+ // so the shimmer reads brighter against a darker bed without touching the caustics themselves.
2596
+ if (curNight > 0.01) {
2597
+ ctx.save();
2598
+ ctx.beginPath();
2599
+ ctx.moveTo(-10, H + 10);
2600
+ for (let i = 0; i < sandHeights.length; i++) ctx.lineTo(i * 40, sandHeights[i]);
2601
+ ctx.lineTo(W + 10, H + 10);
2602
+ ctx.closePath();
2603
+ ctx.clip();
2604
+ ctx.fillStyle = `rgba(5,10,22,${(curNight * 0.25).toFixed(3)})`;
2605
+ ctx.fillRect(0, H * 0.8, W, H * 0.2);
2606
+ ctx.restore();
2607
+ }
2608
+
2609
+ drawCaustics(t); // v25: animated caustic shimmer on the sand
2610
+ drawLightPooling(t); // v25: light pools where god-rays hit the floor
2611
  drawSeaweed();
2612
  drawGrass(t);
2613
+ ctx.drawImage(rocksLayer, 0, 0, W, H); // rocks in front of mid-ground grass
2614
 
2615
  for (let i = bubbles.length - 1; i >= 0; i--) {
2616
  const b = bubbles[i];
 
2647
  buildFarSeaweedSprites(); // v5: rebuild ultra-far seaweed sprites for new size
2648
  buildVignette(); // v6: rebuild the static vignette layer
2649
  buildStaticLayer();
2650
+ buildRocksLayer(); // rocks drawn after grass so they sit in front of the plants
2651
+ buildCausticTile(); // v25: caustic texture tile (built once)
2652
  if (!grainPattern) buildGrainTile(); // v8: grain tile is size-independent, build once
2653
  }
2654
  // ============ Player controls: keyboard + gamepad ============