Upload index.html
Browse files- 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 |
-
|
|
|
|
| 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 =
|
| 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, "#
|
| 1503 |
-
g.addColorStop(1, "#
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ============
|