Fish / index.html
One's picture
Upload index.html
43abd05 verified
Raw History Blame Contribute Delete
136 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Digital Aquarium (Optimized)</title>
<style>
html, body { margin:0; padding:0; height:100%; overflow:hidden; background:#02121f; }
#tank { display:block; width:100vw; height:100vh; }
.glass {
position:fixed; inset:0; pointer-events:none; z-index:2;
background:
linear-gradient(115deg, rgba(255,255,255,0.07) 0%, rgba(255,255,255,0) 30%),
linear-gradient(295deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0) 25%);
mix-blend-mode: screen;
}
</style>
</head>
<body>
<canvas id="tank"></canvas>
<div class="glass"></div>
<script>
(() => {
"use strict";
// ============ Aquarium v16 WHISKERS — alt-fg base + spots/stripes + whiskers (~10%) ==
// v22: Ecco-style rendering — fish show top/bottom view when moving vertically,
// side profile when moving horizontally, like Ecco the Dolphin's 3D perspective
// Based on v15 SPOTTY-3 (rolling hills, three tail styles, yellow-green foreground grass,
// ~20% spots / ~20% stripes / ~10% both), plus: an independent roll gives ~10% of fish
// whiskers (barbels) — a forward-curving pair from the snout in one of THREE lengths
// (short / medium / long), swaying gently as they swim. Whiskers are independent of the
// body markings, so a whiskered fish may also be spotted, striped, both, or plain.
// Iridescence + overhead streak unchanged.
const canvas = document.getElementById("tank");
const ctx = canvas.getContext("2d");
let W = 0, H = 0;
const rand = (a, b) => a + Math.random() * (b - a);
const pick = arr => arr[(Math.random() * arr.length) | 0];
function lerpAngle(a, b, t) {
let d = b - a;
while (d > Math.PI) d -= 2 * Math.PI;
while (d < -Math.PI) d += 2 * Math.PI;
return a + d * t;
}
const HUES = [8, 35, 46, 172, 195, 215];
const TAIL_TYPES = ['classic', 'fan', 'streamer']; // v11: three tail-fin styles, one per fish
// v18: koi colourations — large irregular patches on a white/ivory base. Three classic
// looks: kohaku (red/orange), sanke (red + black), golden (amber/gold). Patches are
// sampled in units of `size` like the spots, so they scale with each fish.
function genKoiPatches(type) {
const palettes = {
kohaku: [
() => `hsla(${rand(4, 20)},${rand(85, 98)}%,${rand(50, 62)}%,0.92)`, // vermilion
() => `hsla(${rand(16, 32)},${rand(85, 98)}%,${rand(54, 66)}%,0.88)`, // orange
],
sanke: [
() => `hsla(${rand(2, 14)},${rand(85, 98)}%,${rand(48, 60)}%,0.92)`, // red
() => `hsla(${rand(205, 230)},${rand(18, 38)}%,${rand(12, 22)}%,0.9)`, // charcoal/black
],
golden: [
() => `hsla(${rand(38, 48)},${rand(85, 98)}%,${rand(55, 66)}%,0.92)`, // gold
() => `hsla(${rand(26, 40)},${rand(85, 98)}%,${rand(50, 62)}%,0.88)`, // amber
],
goldspot: [
() => `hsla(${rand(40, 50)},${rand(15, 30)}%,${rand(92, 98)}%,0.9)`, // white spots
() => `hsla(${rand(40, 50)},${rand(15, 30)}%,${rand(92, 98)}%,0.9)`, // white spots (duplicate for consistent selection)
],
};
const styles = palettes[type];
const arr = [];
const isGoldspot = type === 'goldspot';
const nPatches = isGoldspot ? (8 + ((Math.random() * 8) | 0)) : (4 + ((Math.random() * 4) | 0)); // goldspot: 8..15, else 4..7
for (let i = 0; i < nPatches; i++) {
const th = rand(0, Math.PI * 2);
const rr = Math.sqrt(Math.random()) * 0.85; // uniform in ellipse, kept off the rim
arr.push({
x: Math.cos(th) * rr, // along body axis (units of s)
y: Math.sin(th) * rr * 0.42, // across body (matches the 0.42 ellipse ratio)
r: isGoldspot ? rand(0.06, 0.14) : rand(0.3, 0.52), // goldspot: small dots, else large patches
style: styles[Math.random() < 0.5 ? 0 : 1](),
});
}
return arr;
}
let fishes = [], bubbles = [], seaweeds = [], backSeaweeds = [], midSeaweeds = [], farSeaweeds = [], motes = [], rays = [], rocks = [], grass = [], fgGrass = [];
let playerFish = null; // the fish controlled by gamepad (spawned with F key)
const GAMEPAD_DEADZONE = 0.15; // ignore small stick movements
let curNight = 0; // v7: shared night factor (dims the top-light + sheen)
let plantPoints = [], _ppUsed = 0; // collision points — a pooled set, reused in place
let sandHeights = [];
let sandParticles = []; // v22: grains of sand kicked up when a fast fish skims the sea floor
class Fish {
static nextId = 0;
constructor() {
this.id = Fish.nextId++;
this.size = rand(15, 42) * 1.15;
this.speed = rand(0.55, 1.7) * (this.size / 30 + 0.5) * 0.75; // v3: max speed reduced 25%
this.x = rand(60, W - 60);
this.y = rand(H * 0.15, H * 0.72);
this.angle = Math.random() < 0.5 ? rand(-0.4, 0.4) : Math.PI + rand(-0.4, 0.4);
// Clownfish variant - 5% chance
this.isClownfish = Math.random() < 0.05;
if (this.isClownfish) {
this.hue = 35;
this.saturation = 90;
this.lightness = 55;
this.isKoi = false;
this.koiPatches = null;
this.tailType = pick(TAIL_TYPES);
// Clownfish stripes - three white stripes with black edges
const clownStripes = [];
const stripePositions = [0.65, 0.25, -0.15];
for (const pos of stripePositions) {
clownStripes.push({
x: pos,
w: rand(0.06, 0.09),
style: 'hsla(0,0%,98%,0.92)',
cAmp: rand(0.02, 0.05),
cFreq: rand(0.8, 1.5),
cPh: rand(0, Math.PI * 2),
tAmp: rand(0.1, 0.3),
tFreq: rand(0.8, 1.5),
tPh: rand(0, Math.PI * 2),
edgeStyle: 'hsla(0,0%,15%,0.35)',
});
}
this.stripes = clownStripes;
this.spots = null;
this.whiskerLen = null;
}
// v18: color palette — silver/gray + three koi colourations (replaces dark blue & lime green)
const specialRoll = Math.random();
this.isKoi = false;
this.koiPatches = null;
if (specialRoll < 0.15) {
// Silver/gray fish — low saturation, medium-high lightness
this.hue = rand(200, 240); // slight blue-gray tint
this.saturation = rand(8, 18);
this.lightness = rand(60, 75);
} else if (specialRoll < 0.45) {
// Koi — a white/ivory base with large coloured patches; the three classic
// colourations are split roughly evenly across this 30% band.
this.isKoi = true;
const koiType = specialRoll < 0.22 ? 'kohaku' : specialRoll < 0.30 ? 'sanke' : specialRoll < 0.38 ? 'golden' : 'goldspot';
if (koiType === 'goldspot') {
// Golden orange fish with white spots
this.hue = rand(30, 42); // golden orange
this.saturation = rand(85, 95); // high saturation → vivid color
this.lightness = rand(50, 65); // medium lightness → rich gold
} else {
// Classic koi — white/ivory base
this.hue = rand(40, 50); // warm ivory tint for the base
this.saturation = rand(12, 22); // low saturation → white body
this.lightness = rand(88, 94); // high lightness → white/ivory base
}
this.koiPatches = genKoiPatches(koiType);
} else {
// Standard colored fish from the original palette
this.hue = pick(HUES);
this.saturation = 75;
this.lightness = 52;
}
this.tailType = pick(TAIL_TYPES); // v11: random tail-fin style for variety
// v13/v14/v15: random body markings — one roll per fish decides the look:
// ~20% spots, ~20% tiger stripes, ~10% BOTH (spots over stripes), else plain.
// Sampled in units of `size` so they scale with the fish; stored as pre-built
// fill styles — zero per-frame string work.
const genSpots = () => {
const arr = [];
const nSpots = 7 + ((Math.random() * 9) | 0); // 7..15 spots
const pale = Math.random() < 0.45; // cream/ivory vs dark spotting style
for (let i = 0; i < nSpots; i++) {
const th = rand(0, Math.PI * 2);
const rr = Math.sqrt(Math.random()) * 0.9; // uniform in ellipse, kept off the rim
let style;
if (this.saturation < 30) {
// v14: gray/silver fish — keep spots low-saturation to match
const spotLight = pale ? rand(75, 88) : rand(35, 50);
style = `hsla(${this.hue + rand(-24, 24)},${rand(5, 15)}%,${spotLight}%,${rand(0.35, 0.6).toFixed(2)})`;
} else if (pale) {
// cream/ivory spots — pearl-gourami / leopard pattern
style = `hsla(${this.hue + rand(-24, 24)},${rand(45, 70)}%,${rand(82, 94)}%,${rand(0.35, 0.6).toFixed(2)})`;
} else {
// dark spots — dace / leopard pattern
style = `hsla(${this.hue + rand(-18, 18)},${rand(60, 85)}%,${rand(24, 34)}%,${rand(0.45, 0.7).toFixed(2)})`;
}
arr.push({
x: Math.cos(th) * rr, // along body axis, in units of s
y: Math.sin(th) * rr * 0.42, // across body (matches the 0.42 ellipse ratio)
r: rand(0.05, 0.13), // spot radius, in units of s
style,
});
}
return arr;
};
const genStripes = () => {
// v14/v14.1: tiger stripes — vertical bars across the body (betta / tiger-barb
// style), spread from just behind the head to near the tail base with organic
// jitter; each bar bends and tapers instead of being a straight line.
const arr = [];
const nStr = 5 + ((Math.random() * 4) | 0); // 5..8 bars
const darkBars = this.saturation >= 30 && Math.random() < 0.72; // v14: gray fish get light stripes
for (let i = 0; i < nStr; i++) {
const t = nStr > 1 ? i / (nStr - 1) : 0; // 0 behind head → 1 near tail base
let style;
if (this.saturation < 30) {
// v14: gray/silver fish — keep stripes low-saturation to match
const stripeLight = darkBars ? rand(35, 50) : rand(75, 88);
style = `hsla(${this.hue + rand(-14, 14)},${rand(5, 15)}%,${stripeLight}%,${rand(0.5, 0.75).toFixed(2)})`;
} else if (darkBars) {
// classic dark tiger bars
style = `hsla(${this.hue + rand(-14, 14)},${rand(65, 90)}%,${rand(18, 30)}%,${rand(0.5, 0.75).toFixed(2)})`;
} else {
// cream/ivory bars — pale tiger variant
style = `hsla(${this.hue + rand(-24, 24)},${rand(45, 70)}%,${rand(80, 93)}%,${rand(0.35, 0.6).toFixed(2)})`;
}
arr.push({
x: 0.72 - t * 1.62 + rand(-0.07, 0.07), // +0.72s → -0.90s along the body
w: rand(0.08, 0.2), // bar width, in units of s
style,
// v14.1: organic shape — each bar bends and tapers instead of a straight line
cAmp: rand(0.03, 0.09), // whole-bar bend amplitude (units of s)
cFreq: rand(0.8, 2.0), // waves of bend across the bar's height
cPh: rand(0, Math.PI * 2),
tAmp: rand(0.15, 0.45), // width taper (fraction of half-width)
tFreq: rand(0.8, 2.0),
tPh: rand(0, Math.PI * 2),
});
}
return arr;
};
// v18: koi already carry their own large patches — skip the random spots/stripes
const markRoll = this.isKoi ? 1 : Math.random();
if (markRoll < 0.2) {
this.spots = genSpots();
this.stripes = null;
} else if (markRoll < 0.4) {
this.spots = null;
this.stripes = genStripes();
} else if (markRoll < 0.5) {
// v15: the mixed breed — stripes as the base pattern, spots scattered over them
this.spots = genSpots();
this.stripes = genStripes();
} else {
this.spots = null;
this.stripes = null;
}
// v16: whiskers (barbels) — an independent ~10% roll. A whiskered fish gets a
// forward-curving pair from the snout in one of three length tiers (~even split),
// so the tank shows short, medium and long whiskers. Independent of spots/stripes,
// so any marked fish can also wear them.
if (Math.random() < 0.1) {
const tier = Math.random(); // pick one of three lengths
this.whiskerLen = tier < 1 / 3 ? rand(0.72, 0.96) // v19: whiskers 60% longer
: tier < 2 / 3 ? rand(1.28, 1.6)
: rand(2.0, 2.4); // in units of size s
this.whiskPh = rand(0, Math.PI * 2); // per-fish wave phase offset
this.whiskerAlpha = 0.5; // v18: fixed 50% opacity
} else {
this.whiskerLen = null;
}
// PERF: precompute per-fish paint styles once. draw() used to rebuild these
// template strings every frame for every fish — now it's a property read.
this.tailFillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.92)`;
this.dorsalFillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.9)`;
this.pevFillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.8)`;
this.pevStrokeStyle = `hsla(${this.hue},${Math.min(100, this.saturation * 1.15)}%,${Math.max(8, this.lightness - 20)}%,0.5)`;
this.whiskerStyle = `hsla(${this.hue},55%,82%,${this.whiskerAlpha || 0.5})`;
// PERF: the body gradient depends only on fixed per-fish colors and fish-local
// coordinates, so build it once instead of recreating it every frame.
const bg = ctx.createLinearGradient(0, -this.size * 0.5, 0, this.size * 0.5);
bg.addColorStop(0, `hsl(${this.hue},${Math.min(100, this.saturation * 1.08)}%,${this.lightness + 12}%)`);
bg.addColorStop(1, `hsl(${this.hue},${this.saturation * 0.9}%,${this.lightness - 8}%)`);
this.bodyGrad = bg;
this.phase = rand(0, Math.PI * 2);
// v9: after dark, most fish bed down low in the grass — only a small share still
// hovers by the seaweed, and each of those keeps to its own stalk (no pile-ups)
this.grassSleeper = Math.random() < 0.85;
this.seaweedPref = -1; // chosen lazily on the first night (needs built scenery)
// v14: every grass-sleeper gets its own personal sleep spot along the tank,
// so at night they spread out instead of all piling onto the same sand line
this.sleepSpotX = W * (0.06 + 0.88 * Math.random());
// v10: each fish has its own early/normal/late personality for going to bed (dusk)
// and waking up (dawn), so the school doesn't sleep or stir in lockstep.
const pickTrait = () => { const r = Math.random(); return r < 0.34 ? 'early' : r < 0.67 ? 'normal' : 'late'; };
this.sleepTrait = pickTrait(); // when it goes to bed at dusk
this.riseTrait = pickTrait(); // when it wakes up at dawn
// dusk: early sleepers drift down sooner (+), late sleepers stay up longer (-)
this.sleepShift = this.sleepTrait === 'early' ? rand(0.25, 0.45)
: this.sleepTrait === 'late' ? -rand(0.25, 0.45)
: rand(-0.12, 0.12);
// dawn: late risers linger in bed longer (+), early risers stir sooner (-)
this.riseShift = this.riseTrait === 'late' ? rand(0.25, 0.45)
: this.riseTrait === 'early' ? -rand(0.25, 0.45)
: rand(-0.12, 0.12);
// Wander steering (Reynolds-style): a circle ahead of the fish whose target point jitters
this.wanderTheta = rand(0, Math.PI * 2);
this.wanderRadius = rand(35, 80);
this.wanderDist = rand(90, 170);
this.maxTurn = rand(1.4, 2.2); // max rad/s — organic turns, no orbiting
// Behavior states: fish don't swim non-stop — they cruise, hover, and rest.
this.behavior = 'swim'; // 'swim' | 'hover' | 'pause'
this.behaviorTimer = rand(4, 11); // seconds until it may change behavior
this.speedFactor = 1; // current speed multiplier (eased toward target)
this.targetSpeedFactor = 1; // where speedFactor is easing toward
this.cruiseSpeed = 1; // base cruising power (night lowers it, dusk eases it back)
this.currentVelocity = 0; // actual current velocity in px/s (for phase & collision)
// Occasionally puffs a tiny cluster of bubbles from its mouth
this.bubbleTimer = rand(4, 16); // seconds until the next bubble puff
// Body roll — banks/rolls through sharp turns like a real fish (spring-damper)
this.roll = 0; // current roll angle (rad); 0 = upright
this.rollVel = 0; // angular velocity of the roll
this.inHardTurn = false;// was it in a sharp turn last frame?
// v22: Ecco-style viewing angle — determines whether we see the fish from the side
// or from above/below based on its movement direction (0 = side, 1 = top/bottom)
this.viewAngle = 0; // current interpolated view angle
// v26: pelvic fin on 75% of fish, below the body like a real fish
this.hasPelvicFin = Math.random() < 0.75;
}
update(dt) {
const night = personalNight(performance.now() / 1000, this); // v10: per-fish early/normal/late sleep & wake
// Night: cruise power eases down so even active fish drift lazily after dark,
// and back up again at dawn — smooth, never a sudden speed jump.
this.cruiseSpeed += ((1 - night * 0.55) - this.cruiseSpeed) * Math.min(1, dt * 0.3);
// Behavior state machine — occasionally hover or come to near-rest.
// At night the odds tilt toward hovering and long still rests (v6).
this.behaviorTimer -= dt;
if (this.behaviorTimer <= 0) {
const r = Math.random();
const swimC = 0.45 * (1 - night * 0.7); // night: far less chance to cruise
if (r < swimC) {
this.behavior = 'swim';
this.targetSpeedFactor = 1;
this.behaviorTimer = rand(4, 11);
} else if (r < swimC + (0.74 - 0.45) * (1 - night * 0.3)) {
this.behavior = 'hover';
this.targetSpeedFactor = rand(0.25, 0.45); // drift slowly in place
this.behaviorTimer = rand(2, 6 + night * 8); // longer hovers at night
} else {
this.behavior = 'pause';
this.targetSpeedFactor = rand(0.03, 0.10); // nearly stopped, just drifting
this.behaviorTimer = rand(1.5 + night * 2, 4 + night * 9); // much longer rests at night
}
}
// Ease speed toward the target so speed-ups/slow-downs are smooth, not abrupt
this.speedFactor += (this.targetSpeedFactor - this.speedFactor) * Math.min(1, dt * 2.2);
// Occasionally release a small cluster of bubbles from the mouth
this.bubbleTimer -= dt;
if (this.bubbleTimer <= 0) {
const mx = this.x + Math.cos(this.angle) * this.size * 0.62;
const my = this.y + Math.sin(this.angle) * this.size * 0.62;
const n = (2 + ((Math.random() * 3) | 0)) * 2; // 4..8 bubbles — doubled from the old 2..4
for (let i = 0; i < n; i++) {
const bb = new Bubble(false, mx + rand(-3, 3), my + rand(-3, 3), this.size);
bb.owner = this; // v10: a fish never pops its own bubbles
bubbles.push(bb);
}
this.bubbleTimer = rand(8, 22); // wait a while before the next puff
}
// Jitter the wander target point around its circle
this.wanderTheta += rand(-1, 1) * dt * 3.2;
// Target sits ahead of the fish (in heading direction), offset by wanderTheta.
// Steering toward it yields smooth meandering and genuine direction changes.
let wx = this.x + Math.cos(this.angle) * this.wanderDist + Math.cos(this.wanderTheta) * this.wanderRadius;
let wy = this.y + Math.sin(this.angle) * this.wanderDist + Math.sin(this.wanderTheta) * this.wanderRadius;
// v7: after dark, most fish bed down low in the grass; a few still hover by the seaweed.
if (night > 0.01) {
if (this.grassSleeper) {
// v14: drift toward this fish's own sleep spot along the sand line —
// spreads sleeping fish across the tank instead of one big clump
wx += (this.sleepSpotX - wx) * night * 0.75;
const groundY = sandAt(this.x + Math.cos(this.angle) * this.wanderDist) - this.size * 0.5;
wy += (groundY - wy) * night * 0.85;
} else if (seaweeds.length) {
// v9: each seaweed-hungry fish keeps to its own stalk instead of all crowding the nearest one
if (this.seaweedPref < 0 || this.seaweedPref >= seaweeds.length) {
this.seaweedPref = (Math.random() * seaweeds.length) | 0;
}
const w = seaweeds[this.seaweedPref];
// v13: plinko fix — stalks near the sides pull harder so fish actually reach them
// instead of being deflected back toward the middle by the boundary steering.
const edgeT = Math.abs(w.x / W - 0.5) * 2; // 0 at center → 1 at glass
const k = night * 0.8 * (1 + edgeT * 0.9); // up to ~1.9x at the very edge
wx += (w.x - wx) * k;
wy += (w.yMid - wy) * k;
}
}
let desired = Math.atan2(wy - this.y, wx - this.x);
// Soft boundary steering: kicks in a bit before the glass so fish turn
// gently instead of bonking (v4), roaming freely across the whole tank
let pull = 0;
const mX = W * 0.16, mTop = H * 0.14, mBot = H * 0.16;
if (this.x < mX) pull = Math.max(pull, 1 - this.x / mX);
else if (this.x > W - mX) pull = Math.max(pull, 1 - (W - this.x) / mX);
if (this.y < mTop) pull = Math.max(pull, 1 - this.y / mTop);
else if (this.y > H - mBot) pull = Math.max(pull, 1 - (H - this.y) / mBot);
// v13: plinko fix — grass-sleepers settling at night are less deflected by the
// boundary, so they can reach side positions instead of being pushed to the middle.
if (pull > 0 && this.grassSleeper && night > 0.25) {
const settle = Math.min(1, (night - 0.25) / 0.75); // ramps up as they get sleepier
pull *= (1 - settle * 0.55); // up to ~55% weaker when fully settled
}
if (pull > 0) {
const cx = W / 2, cy = H * 0.45;
desired = lerpAngle(desired, Math.atan2(cy - this.y, cx - this.x), Math.min(1, pull * 1.5));
}
// Floor steering: as a fish nears the sand bed it is steered upward,
// so it skims above the ground line instead of diving under it
// (v4: starts earlier and pushes harder — grass yes, faceplants no)
const floorY = sandAt(this.x) - this.size * 0.35;
const fZone = H * 0.18;
if (this.y > floorY - fZone) {
const fp = Math.min(1, (this.y - (floorY - fZone)) / fZone);
desired = lerpAngle(desired, Math.atan2(H * 0.45 - this.y, W / 2 - this.x), fp * 1.6);
}
// v7: separation steering — bend around nearby fish instead of ploughing into them.
// The faster we swim, the earlier and harder we steer away.
const mySpeed = this.speed * this.speedFactor;
if (mySpeed > 0.3) {
let ax = 0, ay = 0, wsum = 0;
for (const o of fishes) {
if (o === this) continue;
const dx = o.x - this.x, dy = o.y - this.y;
const d2 = dx * dx + dy * dy;
const zone = (this.size + o.size) * 1.7; // notice radius scales with both fish
if (d2 < zone * zone && d2 > 0.01) {
const dd = Math.sqrt(d2);
const w = (1 - dd / zone) * Math.min(1, mySpeed); // close & fast → strong
ax -= (dx / dd) * w;
ay -= (dy / dd) * w;
wsum += w;
}
}
if (wsum > 0) {
desired = lerpAngle(desired, Math.atan2(ay, ax), Math.min(0.85, wsum * 0.45));
}
} else if (night > 0.2) {
// v14: at rest after dark — a wider, stronger personal buffer so sleeping
// fish gently push apart instead of settling into one tight clump
let ax = 0, ay = 0, wsum = 0;
for (const o of fishes) {
if (o === this) continue;
const dx = o.x - this.x, dy = o.y - this.y;
const d2 = dx * dx + dy * dy;
const zone = (this.size + o.size) * 2.4; // much wider buffer than the swim radius
if (d2 < zone * zone && d2 > 0.01) {
const dd = Math.sqrt(d2);
const w = 1 - dd / zone;
ax -= (dx / dd) * w;
ay -= (dy / dd) * w;
wsum += w;
}
}
if (wsum > 0) {
desired = lerpAngle(desired, Math.atan2(ay, ax), Math.min(0.85, wsum * 0.6));
}
}
// Limit turn rate so direction changes are smooth and fish-like
let d = desired - this.angle;
while (d > Math.PI) d -= 2 * Math.PI;
while (d < -Math.PI) d += 2 * Math.PI;
const maxStep = this.maxTurn * dt;
this.angle += Math.max(-maxStep, Math.min(maxStep, d));
// Fish roll their bodies when banking through sharp turns: the moment a big
// heading change begins, sometimes excite a roll (banking into the turn),
// then let a spring-damper wobble it back upright.
const hardTurn = Math.abs(d) > 1.2;
if (hardTurn && !this.inHardTurn && this.speedFactor > 0.35 && Math.random() < 0.55) {
this.rollVel += Math.sign(d) * rand(3.8, 5.5); // bank into the turn
}
this.inHardTurn = hardTurn;
this.rollVel += (-8 * this.roll - 2.4 * this.rollVel) * dt;
this.roll += this.rollVel * dt;
if (this.roll > 1.15) { this.roll = 1.15; this.rollVel *= -0.25; }
else if (this.roll < -1.15) { this.roll = -1.15; this.rollVel *= -0.25; }
// v22: Ecco-style viewing angle — when moving vertically, we see the fish from above/below;
// when moving horizontally, we see its side profile. Smoothly interpolate between views.
// sin(angle) gives 0 for horizontal (0, π) and ±1 for vertical (±π/2)
const targetView = Math.abs(Math.sin(this.angle));
this.viewAngle += (targetView - this.viewAngle) * Math.min(1, dt * 4);
// Player fish: input-driven movement (keyboard WASD + gamepad sticks),
// overrides the AI cruising so the player is fully in control.
if (this === playerFish) {
const inp = getPlayerInput();
// Turn toward the combined input direction (WASD or left stick).
if (Math.hypot(inp.dx, inp.dy) > GAMEPAD_DEADZONE) {
const targetAngle = Math.atan2(inp.dy, inp.dx);
let d = targetAngle - this.angle;
while (d > Math.PI) d -= 2 * Math.PI;
while (d < -Math.PI) d += 2 * Math.PI;
const turnSpeed = 4.0; // rad/s — responsive turning
this.angle += Math.max(-turnSpeed * dt, Math.min(turnSpeed * dt, d));
}
// Speed: cruise baseline, boosted by Shift / R-stick down,
// slowed by Space / R-stick up.
let speedMult = 1;
if (inp.boost > 0) speedMult += inp.boost * 2.0; // speed up
if (inp.slow > 0) speedMult -= inp.slow * 0.85; // slow down
speedMult = Math.max(0.15, Math.min(3.0, speedMult));
const playerSpeed = this.speed * this.cruiseSpeed * speedMult * 60 * dt;
this.x += Math.cos(this.angle) * playerSpeed;
this.y += Math.sin(this.angle) * playerSpeed;
this.currentVelocity = this.speed * this.cruiseSpeed * speedMult * 60; // track actual velocity
} else {
// Normal AI fish movement.
const sp = this.speed * this.cruiseSpeed * this.speedFactor * 60 * dt;
this.x += Math.cos(this.angle) * sp;
this.y += Math.sin(this.angle) * sp;
this.currentVelocity = this.speed * this.cruiseSpeed * this.speedFactor * 60; // track actual velocity
}
// NOTE: fish do NOT collide with plants — they plough straight through.
// The seaweed bends out of the way instead (see updatePlants).
// Hard safety clamp — fish may just about swim off-screen (they never
// despawn or leave), but never below the ground line (sand surface)
this.x = Math.max(-40, Math.min(W + 40, this.x));
this.y = Math.max(-50, Math.min(sandAt(this.x) - this.size * 0.35, this.y));
// Tail beat follows actual speed — a resting fish barely moves its tail
// Use the actual current velocity (which accounts for player boost/slow)
const effSpeed = this.currentVelocity / 60; // convert back to base units
this.phase += dt * (4 + effSpeed * 3);
}
draw(c) {
c.save();
c.translate(this.x, this.y);
c.rotate(this.angle);
// v23: Ecco-style perspective WITHOUT a second picture. We keep the one fully-
// detailed body and foreshorten it as the fish turns to swim vertically.
// viewAngle 0 = pure side profile (one eye), 1 = seen from above/below (the back).
// Foreshortening along local up/down makes the dorsal fin slide toward center and
// the body slim down, reading as "seeing the back" — no snapping, nothing vanishes.
const v = Math.max(0, Math.min(1, this.viewAngle));
let scaleY = 1 - 0.34 * v; // foreshorten toward the back view
if (Math.abs(this.roll) > 0.01) { // banking roll still squashes a touch
scaleY *= Math.max(0.42, Math.cos(this.roll));
c.rotate(this.roll * 0.1);
}
c.scale(1, scaleY);
this.drawSideView(c); // one body: wave + patterns + fins always on
c.restore();
}
// v22: Draw fish from above or below (Ecco-style top/bottom view)
drawTopBottomView(c) {
const s = this.size;
// traveling body wave
const waveK = Math.PI / s;
const waveAmp = s * 0.12;
const sway = Math.sin(-s * waveK + this.phase) * s * 0.34;
// Top/bottom view: body seen from above or below
// Dorsal fin visible as a line down the center when viewed from above
const g = this.bodyGrad;
const NSEG = 26;
// Build body outline (same as side view but we're looking at it from top/bottom)
const bodyPath = new Path2D();
for (let i = 0; i <= NSEG; i++) {
const x = s - (i / NSEG) * 2 * s;
// Wider body when viewed from top/bottom (we see the full width)
const hh = s * 0.55 * Math.sqrt(Math.max(0, 1 - (x / s) ** 2));
if (i === 0) bodyPath.moveTo(x, -hh); else bodyPath.lineTo(x, -hh);
}
for (let i = NSEG; i >= 0; i--) {
const x = s - (i / NSEG) * 2 * s;
const hh = s * 0.55 * Math.sqrt(Math.max(0, 1 - (x / s) ** 2));
bodyPath.lineTo(x, hh);
}
bodyPath.closePath();
c.fillStyle = g;
c.fill(bodyPath);
// Dorsal fin — visible as a line/shape down the center when viewed from above
c.strokeStyle = this.tailFillStyle;
c.lineWidth = s * 0.15;
c.lineCap = "round";
c.beginPath();
c.moveTo(s * 0.3, 0);
// Curved dorsal fin running down the center
c.quadraticCurveTo(-s * 0.2, -sway * 0.5, -s * 0.6, sway * 0.3);
c.stroke();
// Eyes — visible on both sides of the head when viewed from top/bottom
c.fillStyle = "#fff";
// Left eye
c.beginPath();
c.arc(s * 0.55, -s * 0.40, s * 0.1, 0, Math.PI * 2);
c.fill();
c.fillStyle = "#0d1420";
c.beginPath();
c.arc(s * 0.57, -s * 0.36, s * 0.05, 0, Math.PI * 2);
c.fill();
// Right eye
c.fillStyle = "#fff";
c.beginPath();
c.arc(s * 0.55, s * 0.40, s * 0.1, 0, Math.PI * 2);
c.fill();
c.fillStyle = "#0d1420";
c.beginPath();
c.arc(s * 0.57, s * 0.36, s * 0.05, 0, Math.PI * 2);
c.fill();
// Tail fin (simplified for top/bottom view)
c.fillStyle = this.tailFillStyle;
c.beginPath();
if (this.tailType === 'fan') {
c.moveTo(-s * 0.5, 0);
c.quadraticCurveTo(-s * 1.3, -s * 0.28 + sway, -s * 1.42, -s * 0.5 + sway);
c.quadraticCurveTo(-s * 1.12, sway * 0.5, -s * 1.42, s * 0.5 + sway);
c.quadraticCurveTo(-s * 1.3, s * 0.28 + sway, -s * 0.5, 0);
} else if (this.tailType === 'streamer') {
c.moveTo(-s * 0.5, 0);
c.quadraticCurveTo(-s * 1.35, -s * 0.24 + sway, -s * 1.68, -s * 0.56 + sway);
c.quadraticCurveTo(-s * 0.98, sway * 0.35, -s * 1.68, s * 0.56 + sway);
c.quadraticCurveTo(-s * 1.35, s * 0.24 + sway, -s * 0.5, 0);
} else {
c.moveTo(-s * 0.5, 0);
c.quadraticCurveTo(-s * 1.04, -s * 0.36 + sway, -s * 1.22, -s * 0.44 + sway);
c.quadraticCurveTo(-s * 1.34, -s * 0.52 + sway, -s * 0.86, sway * 0.5);
c.quadraticCurveTo(-s * 1.34, s * 0.52 + sway, -s * 1.22, s * 0.44 + sway);
c.quadraticCurveTo(-s * 1.04, s * 0.36 + sway, -s * 0.5, 0);
}
c.fill();
}
// v22: Draw fish from the side (original view)
drawSideView(c) {
const s = this.size;
// traveling body wave (v3): ripples head→tail so the fish undulates like a real one
const waveK = Math.PI / s; // ~one wavelength across the body length
const waveAmp = s * 0.12; // subtle flex amplitude
// tail fin follows the body wave at its base, amplified (same phase → no fighting)
const sway = Math.sin(-s * waveK + this.phase) * s * 0.34;
// v24: Ecco-style blend factor (0 = side profile, 1 = seen from above/below).
// Flattens the tail lobes and spreads eyes/barbels as we see more of the back.
const ev = Math.max(0, Math.min(1, this.viewAngle));
const tailFlat = 1 - 0.55 * ev; // v25: squish the tail fin a bit more when vertical
// tail fin (v7 rounded / v11 three styles): each tip is swept around by a curve
// whose control point sits just past where the old spike was. The style is fixed
// per fish at spawn so the school has variety in its tails.
// v14: use per-fish saturation and lightness for expanded color palette
c.fillStyle = this.tailFillStyle;
c.beginPath();
if (this.tailType === 'fan') {
// Broad, rounded fan (betta/goldfish-style): wide spread, tips high and far out,
// with only a shallow dip between the two lobes.
c.moveTo(-s * 0.5, 0);
c.quadraticCurveTo(-s * 1.3, -s * 0.28 * tailFlat + sway, -s * 1.42, -s * 0.5 * tailFlat + sway); // up to upper tip
c.quadraticCurveTo(-s * 1.12, sway * 0.5, -s * 1.42, s * 0.5 * tailFlat + sway); // shallow notch → lower tip
c.quadraticCurveTo(-s * 1.3, s * 0.28 * tailFlat + sway, -s * 0.5, 0); // back to the base
} else if (this.tailType === 'streamer') {
// Long forked streamers (angelfish/fancy-guppy-style): two flowing lobes that
// reach further back with a deeper notch between them.
c.moveTo(-s * 0.5, 0);
c.quadraticCurveTo(-s * 1.35, -s * 0.24 * tailFlat + sway, -s * 1.68, -s * 0.56 * tailFlat + sway); // long upper lobe
c.quadraticCurveTo(-s * 0.98, sway * 0.35, -s * 1.68, s * 0.56 * tailFlat + sway); // deep fork → lower tip
c.quadraticCurveTo(-s * 1.35, s * 0.24 * tailFlat + sway, -s * 0.5, 0); // long lower lobe back to base
} else {
// classic (v7): rounded lobes with a soft notch — the original look
c.moveTo(-s * 0.5, 0);
c.quadraticCurveTo(-s * 1.04, -s * 0.36 * tailFlat + sway, -s * 1.22, -s * 0.44 * tailFlat + sway); // upper edge out toward the tip
c.quadraticCurveTo(-s * 1.34, -s * 0.52 * tailFlat + sway, -s * 0.86, sway * 0.5); // rounded upper lobe → notch
c.quadraticCurveTo(-s * 1.34, s * 0.52 * tailFlat + sway, -s * 1.22, s * 0.44 * tailFlat + sway); // notch → rounded lower lobe
c.quadraticCurveTo(-s * 1.04, s * 0.36 * tailFlat + sway, -s * 0.5, 0); // lower edge back to the base
}
c.fill();
// v21: far-side whisker — drawn BEFORE the body so it sits behind the fish and is
// partially obscured by the head (only the part that pokes out from behind the snout
// shows). The near-side whisker is drawn after the eye, below.
if (this.whiskerLen) {
const L = this.whiskerLen * s;
const spd = Math.min(1, this.speedFactor);
const NPTS = 7;
c.save();
c.lineCap = "round";
c.strokeStyle = this.whiskerStyle;
c.lineWidth = Math.max(1, s * 0.045);
{
const side = -1; // far side (behind the body)
const rootX = s * 0.6;
// v24: from above, barbels sit on either side of the mouth — spread the roots
// apart and flare each whisker further out as the fish "rotates" to swim vertically
const rootY = side * s * (0.09 + 0.07 * ev);
c.beginPath();
c.moveTo(rootX, rootY);
for (let i = 1; i <= NPTS; i++) {
const t = i / NPTS;
const bx = rootX - L * (0.25 + 0.65 * spd) * t;
const by = rootY + side * L * (0.42 + 0.3 * ev) * t * t;
const amp = s * 0.17 * t * (0.35 + 0.65 * spd);
const off = Math.sin(t * 5.5 - this.phase * 1.4 + this.whiskPh) * amp;
c.lineTo(bx, by + off);
}
c.stroke();
}
c.restore();
}
// v26:
// body — undulates while swimming: the ellipse flexes along a traveling wave (v3)
const g = this.bodyGrad; // precomputed in constructor (static colors, local coords)
const NSEG = 26; // outline samples for a smooth curve
// wave offset at any x along the body: rigid at head, full flex at tail.
// Shared with the dorsal fin so it stays glued to the moving back (v4)
const wy = x => Math.sin(x * waveK + this.phase) * waveAmp * ((s - x) / (2 * s));
// pelvic fin — drawn BEFORE the body so it sits behind the fish
if (this.hasPelvicFin) {
try {
c.fillStyle = this.pevFillStyle;
c.beginPath();
const pelvicBaseY1 = s * 0.36 * (1 - 0.6 * ev);
const pelvicPeakY = s * 0.78 * (1 - ev);
const pelvicBaseY2 = s * 0.4 * (1 - 0.6 * ev);
c.moveTo(-s * 0.15, pelvicBaseY1 + wy(-s * 0.15));
c.quadraticCurveTo(s * 0.05, pelvicPeakY + wy(s * 0.05), s * 0.38, pelvicBaseY2 + wy(s * 0.38));
c.closePath();
c.fill();
} catch (e) {
console.error('Pelvic fin error:', e);
}
}
// // Build the body outline once as a Path2D — reused for base fill + iridescence (v7)
const bodyPath = new Path2D();
for (let i = 0; i <= NSEG; i++) { // top edge: head (+s) -> tail (-s)
const x = s - (i / NSEG) * 2 * s;
const hh = s * 0.42 * Math.sqrt(Math.max(0, 1 - (x / s) ** 2));
if (i === 0) bodyPath.moveTo(x, -hh + wy(x)); else bodyPath.lineTo(x, -hh + wy(x));
}
for (let i = NSEG; i >= 0; i--) { // bottom edge: tail (-s) -> head (+s)
const x = s - (i / NSEG) * 2 * s;
const hh = s * 0.42 * Math.sqrt(Math.max(0, 1 - (x / s) ** 2));
bodyPath.lineTo(x, hh + wy(x));
}
bodyPath.closePath();
c.fillStyle = g;
c.fill(bodyPath);
// v15: stripes first when both are present — bars form the base pattern,
// spots scatter on top. Each is clipped to the body so nothing spills, and both
// ride the traveling wave (wy) so they flex with the undulating body, drawn
// BEFORE the additive sheen so the iridescent shimmer still plays across them.
if (this.stripes) {
c.save();
c.clip(bodyPath);
const N = 8; // samples down each edge for smooth organic curves
for (const st of this.stripes) {
const sx = st.x * s; // bar center, fish-local x
const hw0 = st.w * s / 2; // base half-width
const H = s * 1.35; // vertical span (clipped by the body outline)
c.fillStyle = st.style;
c.beginPath();
// left edge, top → bottom: bend the centerline + taper the width with t
for (let j = 0; j <= N; j++) {
const t = j / N;
const cx = sx + Math.sin(t * st.cFreq * Math.PI + st.cPh) * st.cAmp * s;
const hwL = hw0 * (1 + st.tAmp * Math.sin(t * st.tFreq * Math.PI + st.tPh));
const xL = cx - hwL, yL = wy(xL) + (t - 0.5) * H;
if (j === 0) c.moveTo(xL, yL); else c.lineTo(xL, yL);
}
// right edge, bottom → top
for (let j = N; j >= 0; j--) {
const t = j / N;
const cx = sx + Math.sin(t * st.cFreq * Math.PI + st.cPh) * st.cAmp * s;
const hwR = hw0 * (1 + st.tAmp * Math.sin(t * st.tFreq * Math.PI + st.tPh));
const xR = cx + hwR, yR = wy(xR) + (t - 0.5) * H;
c.lineTo(xR, yR);
}
c.closePath();
c.fill();
}
c.restore();
}
// v18: koi patches — large irregular blobs clipped to the body outline, riding the
// traveling wave (wy) so they flex with the undulating body, drawn BEFORE the
// additive sheen so the iridescent shimmer still plays across them.
if (this.koiPatches) {
c.save();
c.clip(bodyPath);
for (const kp of this.koiPatches) {
const sx = kp.x * s;
c.fillStyle = kp.style;
c.beginPath();
c.arc(sx, kp.y * s + wy(sx), kp.r * s, 0, Math.PI * 2);
c.fill();
}
c.restore();
}
// v13: random spots on ~20% of fish (or over the stripes for the 10% mixed) —
// clipped to the body so they never spill, riding the traveling wave (wy) so they
// flex with the undulating body, and drawn BEFORE the additive sheen so the
// iridescent shimmer still plays across them.
if (this.spots) {
c.save();
c.clip(bodyPath);
for (const sp of this.spots) {
const sx = sp.x * s;
c.fillStyle = sp.style;
c.beginPath();
c.arc(sx, sp.y * s + wy(sx), sp.r * s, 0, Math.PI * 2);
c.fill();
}
c.restore();
}
// v7: efficient iridescence — a sheen whose hue shimmers with the swim phase,
// so scales appear to catch the light while moving. One gradient + one fill.
const shimmer = Math.sin(this.phase * 1.6) * 0.5 + 0.5; // 0..1 as it swims
const iridHue = this.hue + 40 * Math.sin(this.phase * 2.3); // hue drifts with phase
// PERF: cache the sheen gradient. Its inputs move smoothly, so quantize them to a
// tiny grid (1/31 shimmer steps, 1° hue) and reuse until the cell changes — this
// skips one createLinearGradient + 3 addColorStop calls on most frames.
const igKey = Math.round(shimmer * 31) * 512 + (Math.round(iridHue) + 64);
if (igKey !== this._igKey || !this._igGrad) {
this._igGrad = c.createLinearGradient(-s, -s*0.3, s, s*0.35);
this._igGrad.addColorStop(0.0, `hsla(${this.hue},95%,72%,${0.10 + 0.18*shimmer})`);
this._igGrad.addColorStop(0.5, `hsla(${iridHue},90%,70%,${0.04 + 0.10*shimmer})`);
this._igGrad.addColorStop(1.0, `hsla(${this.hue + 200},85%,68%,${0.03 + 0.08*shimmer})`);
this._igKey = igKey;
}
const ig = this._igGrad;
c.save();
c.globalCompositeOperation = "lighter"; // additive sheen: reads as light on scales
c.fillStyle = ig;
c.fill(bodyPath);
c.restore();
// belly highlight
c.fillStyle = "rgba(255,255,255,0.16)";
c.beginPath();
c.ellipse(0, s * 0.14, s * 0.82, s * 0.24, 0, 0, Math.PI * 2);
c.fill();
// dorsal fin — rides the body wave at its attachment points so it
// never disconnects as the back undulates (v4)
// v17: matches the fish's own color instead of a fixed blue — silver fish no longer
// sprout mismatched blue fins
// v25: as the fish turns to swim vertically (ev→1), the dorsal fin slides from
// "on top of the fish" to "in the middle" (centerline), matching the top-down view
c.fillStyle = this.dorsalFillStyle;
c.beginPath();
const dorsalBaseY1 = -s * 0.36 * (1 - 0.6 * ev); // base point 1 moves toward centerline
const dorsalPeakY = -s * 0.78 * (1 - ev); // peak moves to centerline (y=0)
const dorsalBaseY2 = -s * 0.4 * (1 - 0.6 * ev); // base point 2 moves toward centerline
c.moveTo(-s * 0.15, dorsalBaseY1 + wy(-s * 0.15));
c.quadraticCurveTo(s * 0.05, dorsalPeakY + wy(s * 0.05), s * 0.38, dorsalBaseY2 + wy(s * 0.38));
c.closePath();
c.fill();
// v24: both eyes — as the fish turns to swim vertically (ev→1) we see them from
// above: the pair spreads wider on either side of the head and each eye squashes
// into a flatter ellipse (perspective), instead of two overlapping circles.
{
const eyeRY = 0.11 * (1 - 0.35 * ev); // vertical squash of the white
const pupRY = 0.055 * (1 - 0.35 * ev); // pupil squashes with it
const yNear = -s * (0.12 + 0.1 * ev); // near eye slides outward as we see more of the back
c.fillStyle = "#fff";
c.beginPath();
c.ellipse(s * 0.58, yNear, s * 0.11, s * eyeRY, 0, 0, Math.PI * 2);
c.fill();
c.fillStyle = "#0d1420";
c.beginPath();
c.ellipse(s * 0.61, yNear, s * 0.055, s * pupRY, 0, 0, Math.PI * 2);
c.fill();
// far-side eye fades in as we see more of the back (Ecco-style top view) —
// two eyes flanking the head when the fish turns to swim vertically.
if (ev > 0.03) {
const yFar = s * (0.16 + 0.08 * ev); // far eye slides outward too — pair widens
c.save();
// Ease in the far-side eye so it takes longer to appear (25% slower fade-in)
c.globalAlpha = Math.pow(ev, 1.25) * 0.9;
c.fillStyle = "#fff";
c.beginPath();
c.ellipse(s * 0.58, yFar, s * 0.11, s * eyeRY, 0, 0, Math.PI * 2);
c.fill();
c.fillStyle = "#0d1420";
c.beginPath();
c.ellipse(s * 0.61, yFar, s * 0.055, s * pupRY, 0, 0, Math.PI * 2);
c.fill();
c.restore();
}
}
// v21: near-side whisker — drawn AFTER the body so it sits in front of the fish.
// (The far-side whisker is drawn before the body, above.) Together they form a
// pair from the snout on ~10% of fish, in one of three lengths, with loose ribbons
// flowing in the current and a traveling wave rippling down their length. Drawn at
// a fixed 50% opacity.
if (this.whiskerLen) {
const L = this.whiskerLen * s;
const spd = Math.min(1, this.speedFactor);
const NPTS = 7;
c.save();
c.lineCap = "round";
c.strokeStyle = this.whiskerStyle;
c.lineWidth = Math.max(1, s * 0.045);
{
const side = 1; // near side (in front of the body)
const rootX = s * 0.6;
// v24: same spread/flare as the far whisker — a pair flanking the snout from above
const rootY = side * s * (0.09 + 0.07 * ev);
c.beginPath();
c.moveTo(rootX, rootY);
for (let i = 1; i <= NPTS; i++) {
const t = i / NPTS;
const bx = rootX - L * (0.25 + 0.65 * spd) * t;
const by = rootY + side * L * (0.42 + 0.3 * ev) * t * t;
const amp = s * 0.17 * t * (0.35 + 0.65 * spd);
const off = Math.sin(t * 5.5 - this.phase * 1.4 + this.whiskPh) * amp;
c.lineTo(bx, by + off);
}
c.stroke();
}
c.restore();
}
c.restore();
// v17/v23: overhead light — soft elliptical highlight on the fish's upper edge.
// Drawn in local coords (draw() already positioned + rotated us) so it rides with
// the body and foreshortens correctly instead of being double-offset.
{
const a = 0.35 * (1 - curNight * 0.65); // dimmer at night, still present
if (a > 0.02) {
c.fillStyle = `rgba(255,255,255,${(a * 0.3).toFixed(3)})`;
c.beginPath();
c.ellipse(s * 0.05, -s * 0.26, s * 0.6, s * 0.2, 0, 0, Math.PI * 2);
c.fill();
c.fillStyle = `rgba(255,255,255,${a.toFixed(3)})`;
c.beginPath();
c.ellipse(s * 0.05, -s * 0.26, s * 0.34, s * 0.1, 0, 0, Math.PI * 2);
c.fill();
}
}
}
}
// v7: helper — stroke a smooth line through the given points
function strokeArc(c, pts) {
c.beginPath();
c.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i++) c.lineTo(pts[i][0], pts[i][1]);
c.stroke();
}
// v19: each bubble gets a subtle random tint — pure white up to a soft grey-blue
function bubbleColor() {
const t = Math.random() * 0.85; // keep it subtle, most stay near-white
const r = Math.round(255 - (255 - 196) * t);
const g = Math.round(255 - (255 - 214) * t);
const b = Math.round(255 - (255 - 238) * t);
return [r, g, b];
}
class Bubble {
constructor(init, x, y, size) {
if (x !== undefined) { // spawned from a fish's mouth: tiny, one-shot
this.x = x; this.y = y;
const s = (size || 28) / 28; // v6: scales with the fish that blew it
this.r = rand(1.4, 3.2) * s;
this.speed = rand(45, 85) * (0.7 + 0.3 * s);
this.p = rand(0, Math.PI * 2);
this.rgb = bubbleColor();
this.isMouth = true;
} else {
this.reset(init);
}
}
reset(init) {
this.x = rand(0, W);
this.y = init ? rand(H * 0.3, H) : H + rand(10, 80);
this.r = rand(2, 7);
this.speed = rand(35, 90);
this.p = rand(0, Math.PI * 2);
this.rgb = bubbleColor();
this.isMouth = false;
}
update(dt, t) {
this.y -= this.speed * dt;
this.x += Math.sin(t * 1.6 + this.p) * 22 * dt;
if (this.y < -12) { if (this.isMouth) this.dead = true; else this.reset(false); }
}
draw(c) {
const [r, g, b] = this.rgb;
c.lineWidth = 1;
c.strokeStyle = `rgba(${r},${g},${b},0.35)`;
c.fillStyle = `rgba(${r},${g},${b},0.08)`;
c.beginPath();
c.arc(this.x, this.y, this.r, 0, Math.PI * 2);
c.fill();
c.stroke();
c.fillStyle = `rgba(${r},${g},${b},0.45)`;
c.beginPath();
c.arc(this.x - this.r * 0.3, this.y - this.r * 0.3, this.r * 0.25, 0, Math.PI * 2);
c.fill();
}
}
function sandAt(x) {
const i = Math.max(0, Math.min(sandHeights.length - 2, x / 40));
const f = (x / 40) - Math.floor(i);
return sandHeights[Math.floor(i)] * (1 - f) + sandHeights[Math.floor(i) + 1] * f;
}
function buildScene() {
fishes = [];
bubbles = [];
seaweeds = [];
backSeaweeds = []; // v4: blurry background seaweed (not fish-touchable)
midSeaweeds = []; // v6: middle-distance seaweed with halo pass (between back and far)
farSeaweeds = []; // v5: ultra-far pre-rendered blurry sprites (deepest layer)
motes = [];
rays = [];
rocks = [];
sandHeights = [];
sandParticles = []; // v22: fresh bed, no leftover grains
raySpritesBuiltFor = null; // v23: beam sprites are sized to the old canvas — rebuild them
grass = []; // v10.1: clear on rebuild so resize/F11 doesn't stack a second row
fgGrass = []; // v12: same reset for the foreground layer (resize/F11 safety)
// ALT: rolling hills instead of a flat bed — three octaves of random sine waves
// (broad swells + mid undulations + fine ripples), re-rolled on every refresh.
const sandTop = H - Math.max(60, H * 0.12);
const hillUp = rand(34, 58); // tallest crest rises this far above the baseline
const hillDown = rand(14, 26); // deepest dip sinks this far below it
const waves = [
{ wl: rand(380, 720), amp: 1.00, ph: rand(0, Math.PI * 2) }, // broad swells
{ wl: rand(170, 320), amp: 0.45, ph: rand(0, Math.PI * 2) }, // mid undulations
{ wl: rand(80, 150), amp: 0.18, ph: rand(0, Math.PI * 2) } // fine surface ripples
];
const raw = [];
for (let x = 0; x <= W + 40; x += 40) {
let v = 0;
for (const wv of waves) v += Math.sin((x / wv.wl) * Math.PI * 2 + wv.ph) * wv.amp;
raw.push(v);
}
// Rescale so the extremes land exactly at the chosen limits — full relief, no clamping
let lo = Infinity, hi = -Infinity;
for (const v of raw) { if (v < lo) lo = v; if (v > hi) hi = v; }
const span = Math.max(0.001, hi - lo);
sandHeights.length = 0;
for (const v of raw) {
const off = -hillUp + ((hi - v) / span) * (hillUp + hillDown); // crest → -hillUp, dip → +hillDown
sandHeights.push(sandTop + off);
}
// plants grow in bundles of stalks sharing a base patch; each stalk sprouts side branches
const nClusters = Math.max(6, Math.floor(W / 150));
for (let i = 0; i < nClusters; i++) {
const cx = rand(45, W - 45);
const clusterHue = rand(95, 145);
const nStalks = 3 + ((Math.random() * 4) | 0); // 3..6 stalks per bundle
for (let j = 0; j < nStalks; j++) {
const hue = clusterHue + rand(-10, 10);
const st = {
x: cx + rand(-16, 16),
h: rand(H * 0.12, H * 0.34),
segs: 8,
phase: rand(0, Math.PI * 2),
speed: rand(0.9, 1.7),
hue,
width: rand(4, 8),
touch: 0,
// v15: organic rest curve — each stalk leans and waves like real seaweed
// instead of standing perfectly straight. Also fed into the physics (updatePlants)
// so fish push the stalk where it actually looks. Weighted by f → root stays planted.
cA1: rand(5, 12), cF1: rand(0.9, 1.8), cP1: rand(0, Math.PI * 2),
cA2: rand(2, 6), cF2: rand(2.2, 3.6), cP2: rand(0, Math.PI * 2),
// v17: secondary wobble - a finer inner shimmer for unique motion per stalk
sAmp: rand(2, 5), // amplitude of the secondary wave
sFreq: rand(3.5, 6.5), // higher frequency than primary sway
sPh: rand(0, Math.PI * 2), // phase offset
tipW: rand(1.4, 2.6), // v15: blade tapers from width → tipW up the stipe
// PERF: stroke styles precomputed once (hue is fixed per stalk) — drawSeaweed
// used to rebuild both template strings every frame for every stalk.
stalkStyle: `hsla(${hue},55%,36%,0.92)`,
branchStyle: `hsla(${hue + 8},60%,46%,0.9)`,
midStyle: `hsla(${hue - 8},48%,27%,0.45)`, // v15: faint leaf vein down the blade
off: new Float32Array(9), // lateral bend per segment (fish push leaves aside)
offV: new Float32Array(9), // velocity of that bend
branches: [],
// PERF: stalk points preallocated once — updatePlants reuses these objects
// in place instead of pushing fresh {x,y} pairs every frame (GC churn)
points: Array.from({ length: 9 }, () => ({ x: 0, y: 0 })) // segs+1; can't read st inside its own initializer
};
const nBr = 1 + ((Math.random() * 3) | 0); // 1..3 branches per stalk
for (let k = 0; k < nBr; k++) {
st.branches.push({
f: rand(0.25, 0.85), // where along the stalk it sprouts
side: Math.random() < 0.5 ? -1 : 1,
len: rand(12, 30), // horizontal reach
up: rand(10, 26), // how far it curls upward
w: rand(2, 4),
ph: rand(0, Math.PI * 2),
flowers: Math.random() < 0.3 ? (() => { // ~30% of branches bloom with 1-3 heads (v3)
const n = 1 + ((Math.random() * 3) | 0); // 1..3 flowers per branch
return Array.from({ length: n }, () => {
const hue = (Math.random() * 360) | 0; // colourful — full spectrum
return {
t: rand(0.45, 1), // where along the branch it sits (tip zone)
hue,
r: rand(4, 7), // head size
petals: 5 + ((Math.random() * 3) | 0), // 5..7 petals
// PERF: paint styles precomputed once — drawFlowerHead used to rebuild
// these strings per petal, per frame.
petalFill: `hsla(${hue},85%,64%,0.95)`,
centerFill: `hsla(${(hue + 45) % 360},92%,78%,0.95)`
};
});
})() : []
});
}
seaweeds.push(st);
}
}
// v4: blurry background seaweed — taller, bluer, behind everything else.
// Pre-rendered to offscreen canvases with blur at build time (no runtime cost).
const nBackClusters = Math.max(4, Math.floor(W / 200));
for (let i = 0; i < nBackClusters; i++) {
const cx = rand(30, W - 30);
const clusterHue = rand(165, 200); // bluer than foreground seaweed (95-145)
const nStalks = 2 + ((Math.random() * 3) | 0); // 2..4 stalks per bundle
for (let j = 0; j < nStalks; j++) {
const hue = clusterHue + rand(-15, 15);
// v4.3: depth split — ~51% "far" (heavy softness), ~49% "near" (sharper) for a layered look
const near = Math.random() < 0.49;
const w = near ? rand(2.5, 5) : rand(3, 6);
const tw = near ? rand(1.2, 2.4) : rand(1.5, 3);
const st = {
x: cx + rand(-20, 20),
h: rand(H * 0.35, H * 0.65), // much taller than foreground seaweed
segs: 10,
phase: rand(0, Math.PI * 2),
speed: rand(0.7, 1.3), // slower, more gentle sway
hue,
near,
width: w,
tipW: tw,
branches: [],
// organic curve parameters (same as foreground seaweed)
cA1: rand(8, 15), cF1: rand(0.8, 1.6), cP1: rand(0, Math.PI * 2),
cA2: rand(3, 8), cF2: rand(2.0, 3.2), cP2: rand(0, Math.PI * 2),
// v4.3: multi-wave organic motion parameters
waveA1: rand(6, 14), waveF1: rand(0.5, 1.2), wavePh1: rand(0, Math.PI * 2),
waveA2: rand(3, 8), waveF2: rand(1.3, 2.1), wavePh2: rand(0, Math.PI * 2),
waveA3: rand(2, 5), waveF3: rand(2.4, 3.6), wavePh3: rand(0, Math.PI * 2),
// PERF: precomputed styles/widths (avoids per-frame string alloc + arithmetic)
coreStyle: `hsla(${hue},50%,32%,${near ? 0.8 : 0.7})`,
haloStyle: `hsla(${hue},45%,36%,0.18)`,
branchCoreStyle: `hsla(${hue + 8},55%,42%,0.75)`,
backLineW: (w + tw) / 2
};
// v4.1: add branches to background seaweed (like foreground)
const nBr = 1 + ((Math.random() * 3) | 0); // 1..3 branches per stalk
for (let k = 0; k < nBr; k++) {
const bw = rand(1.5, 3); // PERF: computed once, reused for halo width
st.branches.push({
f: rand(0.25, 0.85), // where along the stalk it sprouts
side: Math.random() < 0.5 ? -1 : 1,
len: rand(10, 24), // horizontal reach (slightly smaller than fg)
up: rand(8, 20), // how far it curls upward
w: bw,
haloW: bw * 1.9, // PERF: precomputed halo width
ph: rand(0, Math.PI * 2)
});
}
backSeaweeds.push(st);
}
}
// v6: middle-distance seaweed using halo stroke pass (median properties between back and far)
const nMidClusters = Math.max(5, Math.floor(W / 190));
for (let i = 0; i < nMidClusters; i++) {
const cx = rand(25, W - 25);
// Median hue between backSeaweeds (165-200) and farSeaweeds (170-210)
const clusterHue = rand(183, 197); // midpoint of both ranges
const nStalks = 2 + ((Math.random() * 3) | 0);
for (let j = 0; j < nStalks; j++) {
const hue = clusterHue + rand(-12, 12);
const mw = rand(3, 6.5); // PERF: captured once for precomputed widths
const mtw = rand(1.8, 3.2);
// Median properties: height between backSeaweeds (0.35-0.65H) and farSeaweeds (0.4-0.7H)
const st = {
x: cx + rand(-22, 22),
h: rand(H * 0.42, H * 0.68), // median height
segs: 9,
phase: rand(0, Math.PI * 2),
speed: rand(0.6, 1.15), // median sway speed between back (0.7-1.3) and far (0.5-1.0)
hue,
width: mw, // median width
tipW: mtw,
branches: [],
// Organic curve parameters (like backSeaweeds for halo pass technique)
cA1: rand(9, 16), cF1: rand(0.7, 1.5), cP1: rand(0, Math.PI * 2),
cA2: rand(3.5, 9), cF2: rand(1.8, 3.0), cP2: rand(0, Math.PI * 2),
// Multi-wave organic motion parameters
waveA1: rand(7, 15), waveF1: rand(0.45, 1.1), wavePh1: rand(0, Math.PI * 2),
waveA2: rand(3.5, 9), waveF2: rand(1.2, 2.0), wavePh2: rand(0, Math.PI * 2),
waveA3: rand(2.5, 6), waveF3: rand(2.2, 3.4), wavePh3: rand(0, Math.PI * 2),
// PERF: precomputed styles/widths (avoids per-frame string alloc + arithmetic)
coreStyle: `hsla(${hue},48%,30%,0.65)`,
haloStyle: `hsla(${hue},42%,34%,0.16)`,
branchCoreStyle: `hsla(${hue + 6},50%,40%,0.7)`,
midLineW: (mw + mtw) / 2
};
// Add branches (median count between back and far)
const nBr = 1 + ((Math.random() * 3) | 0);
for (let k = 0; k < nBr; k++) {
const bw = rand(1.8, 3.5); // PERF: computed once, reused for halo width
st.branches.push({
f: rand(0.28, 0.82),
side: Math.random() < 0.5 ? -1 : 1,
len: rand(11, 26),
up: rand(9, 22),
w: bw,
haloW: bw * 2.2, // PERF: precomputed halo width
ph: rand(0, Math.PI * 2)
});
}
midSeaweeds.push(st);
}
}
// v5: ultra-far pre-rendered blurry seaweed — deepest layer, heavily blurred sprites
const nFarClusters = Math.max(6, Math.floor(W / 180)); // 50% more than previous far count
for (let i = 0; i < nFarClusters; i++) {
const cx = rand(20, W - 20);
const clusterHue = rand(170, 210); // very blue-tinted for depth
const nStalks = 2 + ((Math.random() * 3) | 0); // 2..4 stalks per bundle
for (let j = 0; j < nStalks; j++) {
const hue = clusterHue + rand(-15, 15);
const st = {
x: cx + rand(-25, 25),
h: rand(H * 0.4, H * 0.7), // very tall
segs: 8,
phase: rand(0, Math.PI * 2),
speed: rand(0.5, 1.0), // very slow sway
hue,
width: rand(4, 8),
tipW: rand(2, 4),
branches: [],
sprite: null, // will be built by buildFarSeaweedSprites()
spriteXOffset: 0,
spriteYOffset: 0
};
// Add some branches for visual complexity (will be part of the sprite)
const nBr = 1 + ((Math.random() * 2) | 0); // 1..2 branches
for (let k = 0; k < nBr; k++) {
st.branches.push({
f: rand(0.3, 0.8),
side: Math.random() < 0.5 ? -1 : 1,
len: rand(12, 28),
up: rand(10, 24),
w: rand(2, 4),
ph: rand(0, Math.PI * 2)
});
}
farSeaweeds.push(st);
}
}
// Short, dense grass-like ground cover along the sand bed.
// Each tuft is a cluster of thin blades that ripple when fish swim nearby.
const nGrassTufts = Math.max(50, Math.floor(W / 9));
for (let i = 0; i < nGrassTufts; i++) {
const gx = rand(8, W - 8);
const gy = sandAt(gx) + rand(2, 9); // moved DOWN so every blade roots at/below the sand line — no floating bits, closes the gap fish wedged into
const baseHue = rand(95, 155);
const blades = 2 + ((Math.random() * 4) | 0); // 2..5 blades per tuft
for (let b = 0; b < blades; b++) {
const bladeSegs = 3 + ((Math.random() * 2) | 0); // 3-4 segments for tapered outline
grass.push({
x: gx + rand(-7, 7),
y: gy,
h: rand(12, 85), // v4: more height variability — some short tufts, some noticeably tall
lean: rand(-0.35, 0.35), // base tilt from vertical
phase: rand(0, Math.PI * 2),
speed: rand(1.3, 2.8), // quicker, finer sway than seaweed
hue: baseHue + rand(-9, 9),
width: rand(1.4, 2.8),
ripple: 0, // agitation from nearby fish (0..1)
// v16: organic rest-curve + segmented blade outline (like seaweed)
cA1: rand(-5, 5), // primary bend amplitude
cF1: rand(2.2, 4.5), // frequency along blade length
cP1: rand(0, Math.PI * 2), // phase offset
segs: bladeSegs, // 3-4 segments for tapered outline
// v17: secondary wobble - finer inner shimmer per blade
sAmp: rand(1.5, 3.5),
sFreq: rand(4.5, 8),
sPh: rand(0, Math.PI * 2),
// PERF: per-blade scratch points — drawGrass reuses them instead of
// allocating a fresh pts array + point objects every frame (GC churn)
pts: Array.from({ length: bladeSegs + 1 }, () => ({ x: 0, y: 0 }))
});
}
}
// v12: foreground grass — planted on the FRONT glass (closest to the viewer), so it is
// drawn last and foreshortened. It grows in the low band below the sand line, i.e. the
// zone fish never reach, and sways with the water like the other flora but has no
// fish-push physics (purely decorative). ALT-FG: all sizes halved vs the original fg.
// DOUBLED: more foreground grass clusters for denser coverage
const nFgClusters = Math.max(16, Math.floor(W / 60));
for (let i = 0; i < nFgClusters; i++) {
const cx = rand(-10, W + 10);
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
const baseHue = rand(46, 76); // v12.1: yellowish-green — pops against the mid-ground green grass
const nBlades = 5 + ((Math.random() * 4) | 0); // 5..8 blades per clump (doubled density)
for (let b = 0; b < nBlades; b++) {
const fgSegs = 4 + ((Math.random() * 2) | 0); // 4-5 segments for tapered outline
fgGrass.push({
x: cx + rand(-7.5, 7.5), // 50%: tighter clumps (was -15..15)
y: clusterBaseY + rand(-2, 4), // 50%: finer vertical jitter (was -4..8)
h: rand(H * 0.08, H * 0.18), // 50%: half height (was H*0.16..H*0.36)
lean: rand(-0.25, 0.25), // base tilt from vertical
phase: rand(0, Math.PI * 2),
speed: rand(0.8, 1.6), // slower, heavier sway than the tiny mid-ground grass
hue: baseHue + rand(-10, 10),
width: rand(2, 4), // kept slightly thicker than the mid-ground grass (1.4..2.8)
// v16: organic rest-curve + segmented blade outline (like seaweed)
cA1: rand(-8, 8), // primary bend amplitude (larger for foreground)
cF1: rand(2, 4), // frequency along blade length
cP1: rand(0, Math.PI * 2), // phase offset
segs: fgSegs, // 4-5 segments for tapered outline
// v17: secondary wobble - finer inner shimmer per blade (foreground)
sAmp: rand(2, 4.5),
sFreq: rand(3.8, 6.5),
sPh: rand(0, Math.PI * 2),
// PERF: per-blade scratch points — drawFgGrass reuses them in place
pts: Array.from({ length: fgSegs + 1 }, () => ({ x: 0, y: 0 })),
// v28: ~40% of blades get a gentle blur (pre-rendered sprite) for depth
blurred: Math.random() < 0.4,
sprite: null,
spriteScale: 1
});
}
}
buildFgGrassSprites(); // v28: bake the defocused-blade sprites once (free at runtime)
// PERF: pool the plantPoints collision objects too — updatePlants reuses them
// in place instead of allocating one per stalk/branch point every frame.
let pcap = 8;
for (const w of seaweeds) pcap += (w.segs + 1) + w.branches.length * 2;
while (plantPoints.length < pcap) plantPoints.push({ x: 0, y: 0, r: 0, s: null });
const nFish = Math.max(7, Math.floor(W / 130));
for (let i = 0; i < nFish; i++) fishes.push(new Fish());
const nAmbient = Math.round(26 * 0.75); // v10: ambient bubbles reduced 25% (fish production unchanged)
for (let i = 0; i < nAmbient; i++) bubbles.push(new Bubble(true));
for (let i = 0; i < 45; i++) {
motes.push({ x: rand(0, W), y: rand(0, H), r: rand(0.6, 1.8), s: rand(3, 9), p: rand(0, Math.PI * 2) });
}
for (let i = 0; i < 5; i++) {
// random blur state: ~3.75px (semi-blurry), ~8.75px (blurry), or ~17.5px (very blurry) for visual variety
const blurRoll = Math.random();
const blur = blurRoll < 0.34 ? 3.75 : (blurRoll < 0.67 ? 8.75 : 17.5);
rays.push({
x: rand(0, W), w: rand(80, 210), tilt: rand(-0.35, 0.35),
speed: rand(0.06, 0.14) * (Math.random() < 0.5 ? 1 : -1), alpha: rand(0.04, 0.09), blur,
// undulation — two traveling waves so each beam shimmers like light refracted through moving water
uAmp: rand(18, 46), uLen: rand(2.2, 4.5), uSpd: rand(0.35, 0.9) * (Math.random() < 0.5 ? 1 : -1), uPh: rand(0, Math.PI * 2),
vAmp: rand(6, 14), vLen: rand(5, 9), vSpd: rand(0.8, 1.6) * (Math.random() < 0.5 ? 1 : -1), vPh: rand(0, Math.PI * 2)
});
}
const nRocks = Math.max(3, Math.floor(W / 320));
for (let i = 0; i < nRocks; i++) {
const r = rand(14, 34);
// jagged outline — a ring of uneven points so rocks read as craggy stones, not domes
const nPts = 8 + ((Math.random() * 5) | 0); // 8..12 vertices
const pts = [];
for (let k = 0; k < nPts; k++) {
const a = (k / nPts) * Math.PI * 2 + rand(-0.2, 0.2);
pts.push([Math.cos(a) * r * rand(0.75, 1.15), Math.sin(a) * r * 0.72 * rand(0.65, 1.0)]);
}
rocks.push({ x: rand(40, W - 40), r, tone: rand(0.75, 1), pts });
}
}
function resize() {
const dpr = Math.min(2, window.devicePixelRatio || 1);
W = window.innerWidth;
H = window.innerHeight;
canvas.width = W * dpr;
canvas.height = H * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
buildScene();
buildFarSeaweedSprites(); // v5: pre-render ultra-far blurry seaweed sprites
}
// --- slow light cycle: bright day -> dusk -> night -> dawn, over CYCLE seconds ---
const CYCLE = 180; // seconds per full day/night cycle (v9: exactly 3 minutes)
// Phase durations in seconds: daytime blue 90s, dusk 15s, night 60s, dawn 15s. Total = 180s.
// The cycle starts at the beginning of the bright daytime-blue phase.
const DUSK_START = 90 / CYCLE; // p where day ends and dusk begins (0.5)
const NIGHT_START = (90 + 15) / CYCLE; // p where dusk ends and night begins (~0.5833)
const DAWN_START = (90 + 15 + 60) / CYCLE; // p where night ends and dawn begins (~0.9167)
const MID_DUSK = (DUSK_START + NIGHT_START) / 2; // sunset-glow point, mid-dusk
// [t, waterTop, waterMid, waterBot, rayTint, ambientWashRGB, ambientWashAlpha]
const LIGHT_KEYS = [
[0.00, [13,88,118], [8,58,82], [3,25,43], [190,235,255], [190,235,255], 0.00],
[DUSK_START, [13,88,118], [8,58,82], [3,25,43], [190,235,255], [190,235,255], 0.00],
[MID_DUSK, [224,122,52], [128,60,36], [34,22,26], [255,176,96], [255,150,70], 0.14],
[NIGHT_START,[7,15,32], [4,10,22], [2,6,14], [96,146,206], [3,8,20], 0.40],
[DAWN_START, [7,15,32], [4,10,22], [2,6,14], [96,146,206], [3,8,20], 0.40],
[1.00, [13,88,118], [8,58,82], [3,25,43], [190,235,255], [190,235,255], 0.00]
];
function sampleLight(t) {
const p = ((t / CYCLE) % 1 + 1) % 1;
let i = 0;
while (i < LIGHT_KEYS.length - 2 && LIGHT_KEYS[i + 1][0] <= p) i++;
const k0 = LIGHT_KEYS[i], k1 = LIGHT_KEYS[i + 1];
const f = Math.max(0, Math.min(1, (p - k0[0]) / (k1[0] - k0[0])));
// each key row: [t, waterTop[r,g,b], waterMid, waterBot, rayTint, washRGB, washA]
const mixC = (a, b) => [0, 1, 2].map(c => Math.round(a[c] + (b[c] - a[c]) * f));
return { top: mixC(k0[1], k1[1]), mid: mixC(k0[2], k1[2]), bot: mixC(k0[3], k1[3]), ray: mixC(k0[4], k1[4]), washRGB: mixC(k0[5], k1[5]), washA: k0[6] + (k1[6] - k0[6]) * f };
}
function rgbStr(c) { return `rgb(${c[0]},${c[1]},${c[2]})`; }
// How dark it is right now (0 = bright day, 1 = full night), from the light cycle.
// Drives the sleepier night-time behaviour. Cheap: pure math on one number.
function nightFactor(t) {
const p = ((t / CYCLE) % 1 + 1) % 1;
if (p < DUSK_START) return 0; // bright day
if (p < NIGHT_START) return (p - DUSK_START) / (NIGHT_START - DUSK_START); // dusk: fade to night
if (p < DAWN_START) return 1; // full night
return 1 - (p - DAWN_START) / (1 - DAWN_START); // dawn: fade to day
}
// Per-fish night curve (v10): shifts each fish's dusk/dawn fades by its own early/normal/late
// personality so they don't all sleep or wake in unison. Full day and full night are shared
// by everyone — only the transitions differ.
function personalNight(t, f) {
const p = ((t / CYCLE) % 1 + 1) % 1;
if (p < DUSK_START) return 0; // bright day: all awake
if (p >= NIGHT_START && p < DAWN_START) return 1; // full night: all asleep
const c = v => Math.max(0, Math.min(1, v));
if (p < NIGHT_START) { // dusk: darkness rising
const g = (p - DUSK_START) / (NIGHT_START - DUSK_START);
return c(g + f.sleepShift);
}
const g = 1 - (p - DAWN_START) / (1 - DAWN_START); // dawn: darkness falling
return c(g + f.riseShift);
}
// PERF: cache background gradient - only recreate when colors change significantly
let _bgGrad = null, _bgGradKey = '';
function drawBackground(light) {
// PERF: quantize colors so the gradient only rebuilds on visible color shifts
const key = [light.top[0] | 0, light.top[1] | 0, light.top[2] | 0,
light.mid[0] | 0, light.mid[1] | 0, light.mid[2] | 0,
light.bot[0] | 0, light.bot[1] | 0, light.bot[2] | 0].join(',');
if (!_bgGrad || _bgGradKey !== key) {
_bgGrad = ctx.createLinearGradient(0, 0, 0, H);
_bgGrad.addColorStop(0, rgbStr(light.top));
_bgGrad.addColorStop(0.5, rgbStr(light.mid));
_bgGrad.addColorStop(1, rgbStr(light.bot));
_bgGradKey = key;
}
ctx.fillStyle = _bgGrad;
ctx.fillRect(0, 0, W, H);
}
// ============ Light rays — pre-blurred sprites (v23 fast path) ============
// The old version applied a canvas blur() filter to every beam on every frame;
// canvas filters force slow CPU rasterization over large regions, which tanked FPS.
// Now each beam's shape is rendered ONCE into its own offscreen sprite (blur + the
// vertical fade baked in at build time), and per frame we just blit that sprite in
// horizontal slices with the same undulation offsets as before — wobble + soft
// edges, zero filters at runtime.
const RAY_SEGS = 18; // vertical resolution of each beam's spine (at build time)
const RAY_SLICES = 10; // horizontal slices per beam used for the wobble (per frame) — reduced from 24
const RAY_SPRITE_SCALE = 0.5; // sprites stored at half resolution → softer + cheaper
const RAY_PAD = 60; // room for the blur to spill past the beam edge
let raySpritesBuiltFor = null;
let rayTintRGB = null; // the light.ray color used when building tinted sprites (perf: rebuild only on big shifts)
function buildRaySprites() {
const S = RAY_SPRITE_SCALE;
const yTop0 = -10, ySpan = H * 0.95 + 10; // beam vertical extent (same as before)
for (const r of rays) {
const shift = r.tilt * H;
const topW = r.w * 0.45, botW = r.w * 1.9;
// static spine in local coords (x relative to the beam origin); the per-frame
// drift + wobble are added when slicing, so each sprite stays reusable
const L = [], Rr = [];
for (let i = 0; i <= RAY_SEGS; i++) {
const v = i / RAY_SEGS;
const y = yTop0 + v * ySpan;
const cx = shift * v; // centerline x at this depth
const halfW = topW + (botW - topW) * v; // beam widens as it falls
L.push([cx - halfW, y]);
Rr.push([cx + halfW, y]);
}
let xMinL = Infinity, xMaxL = -Infinity;
for (const [px] of L) if (px < xMinL) xMinL = px;
for (const [px] of Rr) if (px > xMaxL) xMaxL = px;
const bw = Math.ceil(xMaxL - xMinL + RAY_PAD * 2);
const bh = Math.ceil(ySpan + RAY_PAD * 2);
// draw the beam into a full-res scratch canvas (identity transform so the blur
// radius is unambiguous), then downscale it into the half-res sprite
const full = document.createElement("canvas");
full.width = bw; full.height = bh;
const fc = full.getContext("2d");
fc.translate(-xMinL + RAY_PAD, -yTop0 + RAY_PAD); // local beam coords → scratch px
if (r.blur > 0) { try { fc.filter = `blur(${r.blur}px)`; } catch (e) {} }
const poly = (a, b) => {
fc.beginPath();
fc.moveTo(a[0][0], a[0][1]);
for (let i = 1; i < a.length; i++) fc.lineTo(a[i][0], a[i][1]);
for (let i = b.length - 1; i >= 0; i--) fc.lineTo(b[i][0], b[i][1]);
fc.closePath();
fc.fill();
};
// vertical fade baked into the sprite: bright at the surface, gone by the floor
// (same gradient stops as before, mapped into scratch-canvas coordinates)
const yS0 = -yTop0 + RAY_PAD; // scratch y of world y=0
const yS1 = H - yTop0 + RAY_PAD; // scratch y of world y=H
const gOuter = fc.createLinearGradient(0, yS0, 0, yS1);
gOuter.addColorStop(0, `rgba(255,255,255,${r.alpha})`);
gOuter.addColorStop(0.55, `rgba(255,255,255,${r.alpha * 0.45})`);
gOuter.addColorStop(1, "rgba(255,255,255,0)");
fc.fillStyle = gOuter;
poly(L, Rr);
// brighter, narrower inner core riding the same spine (v1 core profile)
const LC = [], RC = [];
for (let i = 0; i <= RAY_SEGS; i++) {
const [lx, ly] = L[i], [rx2, ry2] = Rr[i];
const cxm = (lx + rx2) / 2;
const coreHalf = ((rx2 - lx) / 2) * 0.42; // 42% of the local half-width, as in v1
LC.push([cxm - coreHalf, ly]);
RC.push([cxm + coreHalf, ry2]);
}
const gCore = fc.createLinearGradient(0, yS0, 0, yS1);
gCore.addColorStop(0, `rgba(255,255,255,${r.alpha * 1.5})`);
gCore.addColorStop(0.65, "rgba(255,255,255,0)");
fc.fillStyle = gCore;
poly(LC, RC);
const spr = document.createElement("canvas");
spr.width = Math.max(1, Math.ceil(bw * S));
spr.height = Math.max(1, Math.ceil(bh * S));
const sc = spr.getContext("2d");
sc.drawImage(full, 0, 0, bw, bh, 0, 0, spr.width, spr.height);
// PERF: store the blurred WHITE beam. Tinting is done separately in applyRayTint()
// so that a light-color change only needs a cheap source-in fill instead of
// re-running this expensive blur rasterization (which caused transition hitching).
r.baseSprite = spr;
r.tw = spr.width; r.th = spr.height; // sprite pixel size
r.bw = bw; // beam width in CSS px (dest size)
r.xMinL = xMinL; // leftmost local x of the beam body
}
raySpritesBuiltFor = W + "x" + H;
applyRayTint(); // ensure every ray has a blittable sprite immediately
}
// Cheaply re-tint the cached blurred sprites with the current light.ray color.
// No blur / no shape rasterization — just a source-in fill over the stored beam,
// so this stays fast even during slow dusk/dawn transitions.
function applyRayTint() {
for (const r of rays) {
if (!r.baseSprite) continue;
if (!r.tintCanvas) {
r.tintCanvas = document.createElement("canvas");
r.tintCanvas.width = r.tw; r.tintCanvas.height = r.th;
}
const tc = r.tintCanvas.getContext("2d");
tc.clearRect(0, 0, r.tw, r.th);
tc.drawImage(r.baseSprite, 0, 0);
if (rayTintRGB) {
const [tr, tg, tb] = rayTintRGB;
tc.globalCompositeOperation = "source-in";
tc.fillStyle = `rgb(${tr},${tg},${tb})`;
tc.fillRect(0, 0, r.tw, r.th);
tc.globalCompositeOperation = "source-over";
}
r.sprite = r.tintCanvas;
}
}
function drawRays(t, light) {
if (!raySpritesBuiltFor || raySpritesBuiltFor !== W + "x" + H) buildRaySprites();
const [rr, rg, rb] = light.ray;
const yTop0 = -10, ySpan = H * 0.95 + 10;
// PERF: re-tint the cached blurred sprites only when the light color shifts enough.
// Re-tinting is cheap (a source-in fill) — no blur re-rasterization — so this runs
// smoothly even during slow dusk/dawn transitions instead of hitching on rebuilds.
if (!rayTintRGB || Math.abs(rayTintRGB[0] - rr) + Math.abs(rayTintRGB[1] - rg) + Math.abs(rayTintRGB[2] - rb) > 35) {
rayTintRGB = [rr, rg, rb];
applyRayTint(); // cheap re-tint of the cached blurred sprites (no blur re-run)
}
ctx.restore();
for (const r of rays) {
// blit horizontal slices with the undulation offset at each depth — same wave
// math as before, so the beams shimmer exactly like they always have
const off = Math.sin(t * r.speed * 6 + r.x) * 55; // slow drift of the whole beam
for (let i = 0; i < RAY_SLICES; i++) {
const vT = i / RAY_SLICES, vB = (i + 1) / RAY_SLICES, vM = (vT + vB) / 2;
const ya = yTop0 + vT * ySpan, yb = yTop0 + vB * ySpan;
const grow = 0.35 + 0.65 * vM; // straighter up top, bends more with depth
const wave =
Math.sin(vM * r.uLen + t * r.uSpd + r.uPh) * r.uAmp +
Math.sin(vM * r.vLen - t * r.vSpd + r.vPh) * r.vAmp;
const dx = r.x + off + wave * grow; // undulating center x at this depth
ctx.drawImage(r.sprite,
0, (ya + 10 + RAY_PAD) * RAY_SPRITE_SCALE, r.tw, (yb - ya) * RAY_SPRITE_SCALE,
dx + r.xMinL - RAY_PAD, ya, r.bw, yb - ya);
}
}
}
// v29: Fish occlude godrays - cast angled, blurred shadows that move with the sun
function drawFishShadows(t) {
ctx.save();
ctx.globalCompositeOperation = 'multiply';
// Calculate sun position based on raw day phase (linear progression)
const p = ((t / CYCLE) % 1 + 1) % 1; // 0 to 1, linear throughout the day
// Daytime is p=0 to p=0.5 (DUSK_START), night is p=0.5833 to p=0.9167
// Sun moves from left to right across the sky during the day only
let sunPhase;
if (p < DUSK_START) {
// Daytime: sun moves from left to right
sunPhase = p / DUSK_START; // 0 to 1 during daytime
} else if (p < DAWN_START) {
// Night: sun is set, shadows are at sunset position
sunPhase = 1;
} else {
// Dawn: sun is rising, shadows are at sunrise position
sunPhase = 0;
}
// Sun angle: -1.25 to +1.25 radians (left to right)
const sunAngle = (sunPhase - 0.5) * Math.PI * 0.8;
const shadowAngle = Math.atan2(1, Math.tan(sunAngle + Math.PI/2));
for (const f of fishes) {
// Fish blocks light, casting a shadow that extends down to the sand at an angle
const shadowTop = f.y - f.size * 0.3;
const shadowBottom = sandAt(f.x) - 5;
const shadowHeight = shadowBottom - shadowTop;
if (shadowHeight > 0) {
// Shadow width tapers from fish size at top to wider at bottom
const topWidth = f.size * 1.875;
const bottomWidth = f.size * 3.90625;
// Shadow descends at an angle based on sun position
const topX = f.x;
const bottomX = f.x + shadowHeight * Math.tan(shadowAngle);
// Create gradient for shadow (darker near fish, fading downward)
const g = ctx.createLinearGradient(topX, shadowTop, bottomX, shadowBottom);
g.addColorStop(0, 'rgba(0,0,0,0.0825)');
g.addColorStop(0.3, 'rgba(0,0,0,0.045)');
g.addColorStop(1, 'rgba(0,0,0,0.01125)');
ctx.fillStyle = g;
ctx.beginPath();
// Draw tapered shadow shape with soft edges using quadratic curves
const blur = f.size * 0.8;
ctx.moveTo(topX - topWidth/2, shadowTop + blur);
ctx.quadraticCurveTo(topX - topWidth/2, shadowTop, topX, shadowTop);
ctx.quadraticCurveTo(topX + topWidth/2, shadowTop, topX + topWidth/2, shadowTop + blur);
ctx.lineTo(bottomX + bottomWidth/2, shadowBottom - blur);
ctx.quadraticCurveTo(bottomX + bottomWidth/2, shadowBottom, bottomX, shadowBottom);
ctx.quadraticCurveTo(bottomX - bottomWidth/2, shadowBottom, bottomX - bottomWidth/2, shadowBottom - blur);
ctx.closePath();
ctx.fill();
}
}
ctx.restore();
}
function updateMotes(dt, t) {
ctx.fillStyle = "rgba(220,240,255,0.3)";
for (const m of motes) {
m.y -= m.s * dt;
m.x += Math.sin(t + m.p) * 6 * dt;
if (m.y < -4) { m.y = H + 4; m.x = rand(0, W); }
ctx.beginPath();
ctx.arc(m.x, m.y, m.r, 0, Math.PI * 2);
ctx.fill();
}
}
function drawSand(c = ctx) {
const g = c.createLinearGradient(0, H * 0.85, 0, H);
g.addColorStop(0, "#cdd8bd"); // v24: pale aqua-tan (cooled from warm brown)
g.addColorStop(1, "#7f938b"); // v24: sea-glass taupe
c.fillStyle = g;
c.beginPath();
c.moveTo(-10, H + 10);
for (let i = 0; i < sandHeights.length; i++) c.lineTo(i * 40, sandHeights[i]);
c.lineTo(W + 10, H + 10);
c.closePath();
c.fill();
}
function drawRocks(c = ctx) {
for (const rk of rocks) {
const y = sandAt(rk.x) + rk.r * 0.35;
// fully opaque — tone now varies lightness instead of alpha
const base = Math.round(28 + rk.tone * 46);
const g = c.createRadialGradient(rk.x - rk.r * 0.3, y - rk.r * 0.5, rk.r * 0.1, rk.x, y, rk.r * 1.1);
g.addColorStop(0, `rgb(${base + 68},${base + 78},${base + 92})`);
g.addColorStop(1, "rgb(38,48,62)");
c.fillStyle = g;
// jagged polygon outline (precomputed in buildScene) instead of a smooth dome
c.beginPath();
rk.pts.forEach(([dx, dy], i) => {
const px = rk.x + dx, py = y + dy;
i ? c.lineTo(px, py) : c.moveTo(px, py);
});
c.closePath();
c.fill();
}
}
// v15: static organic lean of a stalk at fraction f up its length (root stays planted)
function stalkCurveX(w, f) {
return f * (w.cA1 * Math.sin(f * w.cF1 + w.cP1) + w.cA2 * Math.sin(f * w.cF2 + w.cP2));
}
// PERF: shared sway function (was a per-stalk closure recreated every frame)
function stalkSwayX(w, t, f) {
return Math.sin(t * w.waveF1 + w.wavePh1 + f * 2.4) * w.waveA1 * f * f +
Math.sin(t * w.waveF2 + w.wavePh2 + f * 3.8) * w.waveA2 * f * f +
Math.sin(t * w.waveF3 + w.wavePh3 + f * 5.4) * w.waveA3 * f;
}
// v4.3: draw background seaweed procedurally — multi-wave organic motion instead of
// rigid sprite rotation. Far stalks get a soft low-alpha halo pass to keep their
// "far away" look without any runtime blur filters (no slowdown).
// v6: middle-distance seaweed using halo stroke pass (median depth between back and far)
function drawMidSeaweeds(t) {
// PERF: lineCap/lineJoin are constant — set once instead of per stalk per pass
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
for (const w of midSeaweeds) {
const baseY = sandAt(w.x) + 10;
const n = w.segs;
// PERF: compute sway positions ONCE per frame; both passes and branches reuse them
const pxs = w._px || (w._px = new Float64Array(n + 1));
const pys = w._py || (w._py = new Float64Array(n + 1));
for (let i = 0; i <= n; i++) {
const f = i / n;
pxs[i] = w.x + stalkCurveX(w, f) + stalkSwayX(w, t, f);
pys[i] = baseY - w.h * f;
}
// Wider halo pass for more softness (between backSeaweeds and farSeaweeds)
for (let pass = 0; pass < 2; pass++) {
const isHalo = pass === 0;
ctx.strokeStyle = isHalo ? w.haloStyle : w.coreStyle; // PERF: precomputed at build
ctx.lineWidth = w.midLineW * (isHalo ? 2.2 : 1);
// stalk outline: up one side, down the other
ctx.beginPath();
let prevX = 0, prevY = baseY; // original quirk: first normal measured from origin
for (let i = 0; i <= n; i++) {
const f = i / n;
const px = pxs[i], py = pys[i];
const dx = px - prevX, dy = py - prevY;
const dl = Math.sqrt(dx * dx + dy * dy) || 1; // PERF: hypot is slower, not needed here
const nx = -dy / dl, ny = dx / dl;
const hw = (w.width * (1 - f) + w.tipW * f) * 0.5;
if (i === 0) ctx.moveTo(px + nx * hw, py + ny * hw);
else ctx.lineTo(px + nx * hw, py + ny * hw);
prevX = px; prevY = py;
}
// down the other side (cached points; i=n no-op point dropped — degenerate in original)
for (let i = n - 1; i >= 0; i--) {
const f = i / n;
const px = pxs[i], py = pys[i];
const dx = pxs[i + 1] - px, dy = pys[i + 1] - py;
const dl = Math.sqrt(dx * dx + dy * dy) || 1;
const nx = -dy / dl, ny = dx / dl;
const hw = (w.width * (1 - f) + w.tipW * f) * 0.5;
ctx.lineTo(px - nx * hw, py - ny * hw);
}
ctx.closePath();
ctx.stroke();
// branches flutter with the stalk (anchors read from cached positions)
for (const b of w.branches) {
const idx = Math.min(n, Math.max(1, Math.round(b.f * n)));
const ax = pxs[idx], ay = pys[idx];
const flutter = Math.sin(t * w.waveF2 * 1.4 + b.ph) * (3 + w.waveA2 * 0.4);
const bx = ax + b.side * b.len + flutter;
const by = ay - b.up;
ctx.beginPath();
ctx.lineWidth = isHalo ? b.haloW : b.w; // PERF: halo width precomputed
ctx.strokeStyle = isHalo ? w.haloStyle : w.branchCoreStyle;
ctx.moveTo(ax, ay);
ctx.bezierCurveTo(
ax + b.side * b.len * 0.3, ay - b.up * 0.75,
bx - b.side * b.len * 0.35, by + b.up * 0.6,
bx, by
);
ctx.stroke();
}
}
}
}
function drawBackSeaweeds(t) {
// v6: no runtime canvas filter — blur() forces a CPU re-rasterize every frame
// and was the main FPS cost. Softness now comes from the halo pass below instead.
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
for (const w of backSeaweeds) {
const baseY = sandAt(w.x) + 10;
const n = w.segs;
// PERF: compute sway positions ONCE per frame; both passes and branches reuse them
const pxs = w._px || (w._px = new Float64Array(n + 1));
const pys = w._py || (w._py = new Float64Array(n + 1));
for (let i = 0; i <= n; i++) {
const f = i / n;
pxs[i] = w.x + stalkCurveX(w, f) + stalkSwayX(w, t, f);
pys[i] = baseY - w.h * f;
}
// v6: near stalks also get a halo pass (replaces the old blur(2px) filter)
for (let pass = 0; pass < 2; pass++) {
const isHalo = pass === 0;
ctx.strokeStyle = isHalo ? w.haloStyle : w.coreStyle; // PERF: precomputed at build
ctx.lineWidth = w.backLineW * (isHalo ? 1.9 : 1);
// stalk outline: up one side, down the other
ctx.beginPath();
let prevX = 0, prevY = baseY; // original quirk: first normal measured from origin
for (let i = 0; i <= n; i++) {
const f = i / n;
const px = pxs[i], py = pys[i];
const dx = px - prevX, dy = py - prevY;
const dl = Math.sqrt(dx * dx + dy * dy) || 1; // PERF: hypot is slower, not needed here
const nx = -dy / dl, ny = dx / dl;
const hw = (w.width * (1 - f) + w.tipW * f) * 0.5;
if (i === 0) ctx.moveTo(px + nx * hw, py + ny * hw);
else ctx.lineTo(px + nx * hw, py + ny * hw);
prevX = px; prevY = py;
}
// down the other side (cached points; i=n no-op point dropped — degenerate in original)
for (let i = n - 1; i >= 0; i--) {
const f = i / n;
const px = pxs[i], py = pys[i];
const dx = pxs[i + 1] - px, dy = pys[i + 1] - py;
const dl = Math.sqrt(dx * dx + dy * dy) || 1;
const nx = -dy / dl, ny = dx / dl;
const hw = (w.width * (1 - f) + w.tipW * f) * 0.5;
ctx.lineTo(px - nx * hw, py - ny * hw);
}
ctx.closePath();
ctx.stroke();
// branches flutter with the stalk (anchors read from cached positions)
for (const b of w.branches) {
const idx = Math.min(n, Math.max(1, Math.round(b.f * n)));
const ax = pxs[idx], ay = pys[idx];
const flutter = Math.sin(t * w.waveF2 * 1.4 + b.ph) * (3 + w.waveA2 * 0.4);
const bx = ax + b.side * b.len + flutter;
const by = ay - b.up;
ctx.beginPath();
ctx.lineWidth = isHalo ? b.haloW : b.w; // PERF: halo width precomputed
ctx.strokeStyle = isHalo ? w.haloStyle : w.branchCoreStyle;
ctx.moveTo(ax, ay);
ctx.bezierCurveTo(
ax + b.side * b.len * 0.3, ay - b.up * 0.75,
bx - b.side * b.len * 0.35, by + b.up * 0.6,
bx, by
);
ctx.stroke();
}
}
}
}
// v5: pre-render ultra-far seaweed sprites with heavy blur baked in at build time
function buildFarSeaweedSprites() {
const BLUR = 7; // v5.1: reduced from 10 (33% less) — slightly closer appearance
const PAD = 40; // extra space for blur spill
for (const w of farSeaweeds) {
// Calculate bounding box by sampling the stalk curve
const baseY = sandAt(w.x) + 10;
let xMin = Infinity, xMax = -Infinity;
for (let i = 0; i <= w.segs; i++) {
const f = i / w.segs;
// Use a static curve for the sprite (no animation baked in)
const px = w.x + f * 10 * Math.sin(f * 2.5 + w.phase); // gentle curve
if (px < xMin) xMin = px;
if (px > xMax) xMax = px;
}
// Add padding for blur + width
xMin -= PAD + w.width;
xMax += PAD + w.width;
const bw = Math.ceil(xMax - xMin);
const bh = Math.ceil(w.h + PAD * 2);
// Create sprite canvas
const spr = document.createElement("canvas");
spr.width = bw;
spr.height = bh;
const sc = spr.getContext("2d");
// Apply heavy blur filter (baked in at build time - zero runtime cost)
sc.filter = `blur(${BLUR}px)`;
// Draw the stalk as a tapered blade with branches
const coreStyle = `hsla(${w.hue},45%,28%,0.8125)`; // v5.2: +25% opacity
sc.strokeStyle = coreStyle;
sc.lineCap = 'round';
sc.lineJoin = 'round';
// Calculate sprite-local coordinates
const localBaseX = bw / 2;
const localBaseY = bh - PAD;
// Draw main stalk outline
sc.beginPath();
let prevX = localBaseX, prevY = localBaseY;
for (let i = 0; i <= w.segs; i++) {
const f = i / w.segs;
const curveX = f * 10 * Math.sin(f * 2.5 + w.phase);
const px = localBaseX + curveX;
const py = localBaseY - w.h * f;
if (i === 0) sc.moveTo(px, py);
else sc.lineTo(px, py);
prevX = px; prevY = py;
}
// Draw with width to create the blade shape
sc.lineWidth = (w.width + w.tipW) / 2;
sc.stroke();
// Draw branches
for (const b of w.branches) {
const idx = Math.min(w.segs, Math.max(1, Math.round(b.f * w.segs)));
const f = idx / w.segs;
const curveX = f * 10 * Math.sin(f * 2.5 + w.phase);
const ax = localBaseX + curveX;
const ay = localBaseY - w.h * f;
sc.beginPath();
sc.lineWidth = b.w;
sc.strokeStyle = `hsla(${w.hue + 8},40%,32%,0.625)`; // v5.2: +25% opacity
sc.moveTo(ax, ay);
const bx = ax + b.side * b.len;
const by = ay - b.up;
sc.bezierCurveTo(
ax + b.side * b.len * 0.3, ay - b.up * 0.75,
bx - b.side * b.len * 0.35, by + b.up * 0.6,
bx, by
);
sc.stroke();
}
// Store sprite and offset for drawing
w.sprite = spr;
w.spriteXOffset = xMin; // left edge of bounding box in world coords
w.spriteYOffset = baseY - bh + PAD; // top edge in world coords (baseY is bottom)
}
}
// v28: pre-render blurred foreground grass sprites for ~40% of blades (gentle depth effect)
function buildFgGrassSprites() {
const BLUR = 1.5; // very gentle blur — just enough to suggest "slightly out of focus"
const PAD = 8; // extra space for blur spill
for (const g of fgGrass) {
if (!g.blurred || g.sprite) continue; // only build for flagged blades that don't have sprites yet
// Bounding box: account for the lean deviation so the tip can't clip out of canvas
const dev = g.lean * g.h; // horizontal offset of tip from base
const bw = Math.ceil(Math.abs(dev) + g.width + PAD * 2);
const bh = Math.ceil(g.h + PAD * 2);
// Create sprite canvas
const spr = document.createElement("canvas");
spr.width = bw;
spr.height = bh;
const sc = spr.getContext("2d");
// Apply gentle blur filter (baked in at build time - zero runtime cost)
sc.filter = `blur(${BLUR}px)`;
// Draw the blade as a tapered shape with the same style as drawFgGrass
const localBaseX = PAD - Math.min(0, dev); // base x so both base and tip fit in canvas
const localBaseY = bh - PAD;
// Dark body of the blade (simplified static version)
sc.fillStyle = `hsla(${g.hue},60%,31%,0.96)`;
sc.beginPath();
// Simple tapered blade shape
const nSegs = g.segs + 1;
for (let i = 0; i < nSegs; i++) {
const f = i / g.segs;
const x = localBaseX + g.lean * g.h * f;
const y = localBaseY - g.h * f;
const hw = (g.width * (1 - f) + 1.2 * f) / 2;
if (i === 0) sc.moveTo(x - hw, y);
else sc.lineTo(x - hw, y);
}
// Rounded tip
const tipX = localBaseX + g.lean * g.h;
const tipY = localBaseY - g.h;
sc.quadraticCurveTo(tipX + 2, tipY + 3, tipX + 1, tipY);
// Right side going back down
for (let i = nSegs - 1; i >= 0; i--) {
const f = i / g.segs;
const x = localBaseX + g.lean * g.h * f;
const y = localBaseY - g.h * f;
const hw = (g.width * (1 - f) + 1.2 * f) / 2;
sc.lineTo(x + hw, y);
}
sc.closePath();
sc.fill();
// Lighter inner highlight
sc.strokeStyle = `hsla(${g.hue + 6},64%,45%,0.5)`;
sc.lineWidth = g.width * 0.3;
sc.lineCap = "round";
sc.beginPath();
sc.moveTo(localBaseX, localBaseY);
sc.lineTo(tipX, tipY);
sc.stroke();
// Store sprite and offset for drawing (offsets place the blade base at the draw origin)
g.sprite = spr;
g.spriteXOffset = -localBaseX;
g.spriteYOffset = -localBaseY;
}
}
// v5: draw ultra-far seaweed sprites with simple sway animation
function drawFarSeaweeds(t) {
for (const w of farSeaweeds) {
if (!w.sprite) continue;
const baseY = sandAt(w.x) + 10;
// Simple rotational sway - root stays planted, sprite rotates slightly
const swayAngle = Math.sin(t * w.speed + w.phase) * 0.04; // very subtle rotation
ctx.save();
// Translate to the base point (where the stalk meets the sand)
ctx.translate(w.x, baseY);
// Rotate around the base point for natural sway
ctx.rotate(swayAngle);
// v28: +25% more visible (brighter/more opaque) for the farthest seaweed layer
ctx.globalAlpha = 1.25;
// Draw sprite at its offset position relative to base
ctx.drawImage(w.sprite, w.spriteXOffset - w.x, w.spriteYOffset - baseY);
ctx.restore();
}
}
function updatePlants(t, dt) {
plantPoints.length = 0;
for (const w of seaweeds) {
// ---- fish push leaves aside: fish overpower the flora ----
for (const f of fishes) {
const R = 80, R2 = R * R; // influence radius around fish
const fSpd = f.currentVelocity / 60; // actual current speed (from movement)
const fx = Math.cos(f.angle) * f.currentVelocity; // fish velocity x (px/s)
const moveF = Math.min(1, fSpd / 1.2); // v9: how actively this fish is swimming
for (let i = 1; i <= w.segs; i++) {
const fl = i / w.segs;
const px = w.x + w.off[i] + stalkCurveX(w, fl) + Math.sin(t * w.speed + w.phase + fl * 2.6) * fl * fl * 24;
const py = sandAt(w.x) + 8 - w.h * fl;
const dx = px - f.x, dy = py - f.y;
const d2 = dx * dx + dy * dy;
if (d2 < R2 && d2 > 0.01) {
const d = Math.sqrt(d2);
const weight = (R - d) / R * (0.35 + 0.65 * fl); // upper segments yield more
// shove radially away from the fish + drag along its swim direction.
// v9: a nearly-still fish only gently parts leaves — no shoving, no flinch wobble
w.offV[i] += ((dx / d) * 1500 * (0.25 + 0.75 * moveF) + fx * 9) * weight * dt;
if (weight > 0.25 && moveF > 0.35) w.touch = Math.min(1, w.touch + dt * 5);
}
}
}
// ---- spring back toward rest, with damping (leaves flow back after fish pass) ----
for (let i = 1; i <= w.segs; i++) {
w.offV[i] += -w.off[i] * 20 * dt;
w.offV[i] *= Math.max(0, 1 - 4 * dt);
}
// ---- neighbour coupling: bend as one flexible stalk, not loose points ----
for (let i = 1; i < w.segs; i++) {
const F = (w.off[i + 1] - w.off[i]) * 30 * dt;
w.offV[i] += F;
w.offV[i + 1] -= F;
}
// ---- integrate bend (root stays planted) ----
for (let i = 1; i <= w.segs; i++) {
w.off[i] += w.offV[i] * dt;
if (w.off[i] > 70) { w.off[i] = 70; w.offV[i] *= -0.3; }
else if (w.off[i] < -70) { w.off[i] = -70; w.offV[i] *= -0.3; }
}
if (w.touch > 0) w.touch = Math.max(0, w.touch - dt * 1.2);
const baseY = sandAt(w.x) + 8;
w.yMid = baseY - w.h * 0.5; // v6: stalk midpoint — the night-time gathering point
w.points.length = 0;
for (let i = 0; i <= w.segs; i++) {
const f = i / w.segs;
// normal sway + a fast extra flinch while being pushed
const sway = Math.sin(t * w.speed + w.phase + f * 2.6) * f * f * 24
+ Math.sin(t * 7 + w.phase) * w.touch * f * 16;
// v17: secondary wobble - a finer inner movement for unique per-stalk motion
const sWobble = Math.sin(t * w.sFreq + w.sPh + f * 4.2) * w.sAmp * f;
const px = w.x + (w.off[i] || 0) + stalkCurveX(w, f) + sway + sWobble;
const py = baseY - w.h * f;
w.points.push({ x: px, y: py });
plantPoints.push({ x: px, y: py, r: w.width * 0.55, s: w });
}
for (const b of w.branches) {
const a = w.points[Math.min(w.segs, Math.max(1, Math.round(b.f * w.segs)))];
const flutter = Math.sin(t * w.speed * 1.4 + b.ph) * (3 + w.touch * 9);
b.tx = a.x + b.side * b.len + flutter;
b.ty = a.y - b.up;
plantPoints.push({ x: b.tx, y: b.ty, r: 5, s: w });
plantPoints.push({ x: (a.x + b.tx) / 2, y: (a.y + b.ty) / 2, r: 4, s: w });
}
}
}
// Grass blades ripple when fish swim close — stronger the lower the fish is.
// v4: more reactive to fish contact — wider influence radius, faster ripple build,
// and slightly slower decay so the wave lingers a touch longer after a fish passes.
function updateGrass(t, dt) {
const night = nightFactor(t); // v6: after dark the fish drift toward the plants
for (const g of grass) {
if (g.ripple > 0) g.ripple = Math.max(0, g.ripple - dt * 1.3); // v4: slower decay (was 1.6)
for (const f of fishes) {
const dx = g.x - f.x;
const dy = g.y - f.y;
const d2 = dx * dx + dy * dy;
const R = 115, R2 = R * R; // v4: wider influence radius (was 95)
if (d2 < R2 && d2 > 1) {
const d = Math.sqrt(d2);
// fish low near the ground stir the grass far more than high up
const nearGround = f.y > H * 0.58 ? 1 : 0.4; // v4: slightly stronger even when higher (was 0.35)
g.ripple = Math.min(1, g.ripple + ((R - d) / R) * dt * 14 * nearGround); // v4: faster ripple build (was 9)
}
}
}
}
function drawSeaweed() {
for (const w of seaweeds) {
const pts = w.points, n = pts.length;
// v15: main stipe as a tapered leaf-blade — an outline around the same physics
// points, wide at the root and thinning to a soft point up top. No straight-line
// segments anywhere; the stalk's own curve comes from the sway + rest-curve baked
// into those points in updatePlants.
ctx.fillStyle = w.stalkStyle;
ctx.beginPath();
for (let i = 0; i < n; i++) { // up one side of the blade
const p = pts[i];
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
let dx = b.x - a.x, dy = b.y - a.y;
const dl = Math.hypot(dx, dy) || 1;
const nx = -dy / dl, ny = dx / dl; // perpendicular to the local stalk direction
const f = i / (n - 1);
const hw = (w.width * (1 - f) + w.tipW * f) / 2; // half-width: base → tip taper
if (i === 0) ctx.moveTo(p.x + nx * hw, p.y + ny * hw);
else ctx.lineTo(p.x + nx * hw, p.y + ny * hw);
}
{ // rounded cap at the tip
const tp = pts[n - 1], pp = pts[n - 2];
let dx = tp.x - pp.x, dy = tp.y - pp.y;
const dl = Math.hypot(dx, dy) || 1;
const nx = -dy / dl, ny = dx / dl;
const hwT = w.tipW / 2;
ctx.quadraticCurveTo(tp.x + dx, tp.y + dy * 0.6, tp.x - nx * hwT, tp.y - ny * hwT);
}
for (let i = n - 1; i >= 0; i--) { // down the other side
const p = pts[i];
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
let dx = b.x - a.x, dy = b.y - a.y;
const dl = Math.hypot(dx, dy) || 1;
const nx = -dy / dl, ny = dx / dl;
const f = i / (n - 1);
const hw = (w.width * (1 - f) + w.tipW * f) / 2;
ctx.lineTo(p.x - nx * hw, p.y - ny * hw);
}
ctx.closePath();
ctx.fill();
// v15: faint midrib vein down the blade's centreline for a leafy look
ctx.strokeStyle = w.midStyle;
ctx.lineWidth = 1.4;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < n; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
// side branches curling up and out from the stalk
for (const b of w.branches) {
const a = w.points[Math.min(w.segs, Math.max(1, Math.round(b.f * w.segs)))];
// v15: S-curved frond — leaves the stalk heading upward, then arcs out and up
// to the tip instead of one plain quadratic bow (same endpoints as before)
const c1x = a.x + b.side * b.len * 0.3;
const c1y = a.y - b.up * 0.75;
const c2x = b.tx - b.side * b.len * 0.35;
const c2y = b.ty + b.up * 0.6;
ctx.strokeStyle = w.branchStyle;
ctx.lineWidth = b.w;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, b.tx, b.ty);
ctx.stroke();
// colourful flower heads blooming along the branch — 1..3 per branch (v3)
if (b.flowers.length) {
for (const fl of b.flowers) {
const u = fl.t, iu = 1 - u; // point on the branch's cubic curve (v15)
drawFlowerHead(
iu * iu * iu * a.x + 3 * iu * iu * u * c1x + 3 * iu * u * u * c2x + u * u * u * b.tx,
iu * iu * iu * a.y + 3 * iu * iu * u * c1y + 3 * iu * u * u * c2y + u * u * u * b.ty, fl);
}
}
}
}
}
function drawFlowerHead(x, y, fl) {
ctx.save();
for (let i = 0; i < fl.petals; i++) { // petals radiating from the centre
const ang = (i / fl.petals) * Math.PI * 2;
const px = x + Math.cos(ang) * fl.r * 0.72;
const py = y + Math.sin(ang) * fl.r * 0.72;
ctx.fillStyle = fl.petalFill;
ctx.beginPath();
ctx.ellipse(px, py, fl.r * 0.55, fl.r * 0.36, ang, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = fl.centerFill; // bright contrasting centre (precomputed at bloom time)
ctx.beginPath();
ctx.arc(x, y, fl.r * 0.42, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawGrass(t) {
for (const g of grass) {
// v16: tapered multi-segment blade — wide at root, thinning to a soft point
const amp = 3 + g.ripple * 13;
const n = g.segs + 1; // number of points along the blade
const pts = [];
for (let i = 0; i < n; i++) {
const f = i / g.segs; // 0 at base, 1 at tip
// organic rest-curve: each blade has its own natural bend (like seaweed)
const curveX = f * g.cA1 * Math.sin(f * g.cF1 + g.cP1);
// constant sway, amplified while rippling from a passing fish
const sway = Math.sin(t * g.speed + g.phase + f * 2.4) * amp * f;
// v17: secondary wobble - finer inner movement for unique per-blade motion
const sWobble = Math.sin(t * g.sFreq + g.sPh + f * 3.8) * g.sAmp * f;
pts.push({
x: g.x + g.lean * g.h * f + curveX + sway + sWobble,
y: g.y - g.h * f * (1 - g.ripple * 0.06)
});
}
// draw tapered outline around the physics points
ctx.fillStyle = `hsla(${g.hue},58%,40%,0.92)`;
ctx.beginPath();
for (let i = 0; i < n; i++) {
const p = pts[i];
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
let dx = b.x - a.x, dy = b.y - a.y;
const dl = Math.hypot(dx, dy) || 1;
const nx = -dy / dl, ny = dx / dl; // perpendicular to local blade direction
const f = i / (n - 1);
const hw = (g.width * (1 - f) + 0.8 * f) / 2; // half-width: base → tip taper
if (i === 0) ctx.moveTo(p.x + nx * hw, p.y + ny * hw);
else ctx.lineTo(p.x + nx * hw, p.y + ny * hw);
}
// rounded cap at the tip
{
const tp = pts[n - 1], pp = pts[n - 2];
let dx = tp.x - pp.x, dy = tp.y - pp.y;
const dl = Math.hypot(dx, dy) || 1;
const nx = -dy / dl, ny = dx / dl;
const hwT = 0.4; // tip half-width
ctx.quadraticCurveTo(tp.x + dx, tp.y + dy * 0.6, tp.x - nx * hwT, tp.y - ny * hwT);
}
for (let i = n - 1; i >= 0; i--) {
const p = pts[i];
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
let dx = b.x - a.x, dy = b.y - a.y;
const dl = Math.hypot(dx, dy) || 1;
const nx = -dy / dl, ny = dx / dl;
const f = i / (n - 1);
const hw = (g.width * (1 - f) + 0.8 * f) / 2;
ctx.lineTo(p.x - nx * hw, p.y - ny * hw);
}
ctx.closePath();
ctx.fill();
}
}
// v12: foreground grass sways with the water — decorative only, no physics.
// Drawn on top of everything else (it is closest to the viewer). Each blade gets a
// thin lighter inner stroke over a darker body for a lush, voluminous "closer" look.
function drawFgGrass(t) {
for (const g of fgGrass) {
// v28: defocused blades — blit the pre-blurred sprite with a gentle rotational sway
// around its base (same trick as the ultra-far seaweed). The baked blur hides the
// simplified motion, and this path is far cheaper than per-frame canvas filters.
if (g.sprite) {
const swayAngle = Math.sin(t * g.speed + g.phase) * 0.05; // subtle: like farSeaweeds
ctx.save();
ctx.translate(g.x, g.y);
ctx.rotate(swayAngle);
ctx.drawImage(g.sprite, g.spriteXOffset, g.spriteYOffset);
ctx.restore();
continue;
}
// v16: tapered multi-segment blade with two-tone depth (like seaweed)
const amp = 4 + g.h * 0.03;
const n = g.segs + 1;
const pts = [];
for (let i = 0; i < n; i++) {
const f = i / g.segs;
// organic rest-curve: each blade has its own natural bend
const curveX = f * g.cA1 * Math.sin(f * g.cF1 + g.cP1);
// constant sway, weighted toward the tip
const sway = Math.sin(t * g.speed + g.phase + f * 2.4) * amp * f;
// v17: secondary wobble - finer inner movement for unique per-blade motion
const sWobble = Math.sin(t * g.sFreq + g.sPh + f * 4.5) * g.sAmp * f;
pts.push({
x: g.x + g.lean * g.h * f + curveX + sway + sWobble,
y: g.y - g.h * f
});
}
// dark body of the blade (tapered outline)
ctx.fillStyle = `hsla(${g.hue},60%,31%,0.96)`;
ctx.beginPath();
for (let i = 0; i < n; i++) {
const p = pts[i];
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
let dx = b.x - a.x, dy = b.y - a.y;
const dl = Math.hypot(dx, dy) || 1;
const nx = -dy / dl, ny = dx / dl;
const f = i / (n - 1);
const hw = (g.width * (1 - f) + 1.2 * f) / 2; // half-width: base → tip
if (i === 0) ctx.moveTo(p.x + nx * hw, p.y + ny * hw);
else ctx.lineTo(p.x + nx * hw, p.y + ny * hw);
}
// rounded cap at the tip
{
const tp = pts[n - 1], pp = pts[n - 2];
let dx = tp.x - pp.x, dy = tp.y - pp.y;
const dl = Math.hypot(dx, dy) || 1;
const nx = -dy / dl, ny = dx / dl;
const hwT = 0.6; // tip half-width
ctx.quadraticCurveTo(tp.x + dx, tp.y + dy * 0.6, tp.x - nx * hwT, tp.y - ny * hwT);
}
for (let i = n - 1; i >= 0; i--) {
const p = pts[i];
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
let dx = b.x - a.x, dy = b.y - a.y;
const dl = Math.hypot(dx, dy) || 1;
const nx = -dy / dl, ny = dx / dl;
const f = i / (n - 1);
const hw = (g.width * (1 - f) + 1.2 * f) / 2;
ctx.lineTo(p.x - nx * hw, p.y - ny * hw);
}
ctx.closePath();
ctx.fill();
// lighter inner highlight for depth: a thin centerline stroke
ctx.strokeStyle = `hsla(${g.hue + 6},64%,45%,0.5)`;
ctx.lineWidth = g.width * 0.3;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < n; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
}
}
// v6: vignette is static -> pre-render once per resize, blit each frame
// (cheaper than recreating the radial gradient + full-screen fill every frame)
let vignetteLayer = null;
function buildVignette() {
vignetteLayer = document.createElement("canvas");
vignetteLayer.width = canvas.width;
vignetteLayer.height = canvas.height;
const c2 = vignetteLayer.getContext("2d");
c2.setTransform(ctx.getTransform());
const g = c2.createRadialGradient(W / 2, H * 0.45, Math.min(W, H) * 0.35, W / 2, H * 0.55, Math.max(W, H) * 0.78);
g.addColorStop(0, "rgba(0,0,0,0)");
g.addColorStop(1, "rgba(0,10,20,0.45)");
c2.fillStyle = g;
c2.fillRect(0, 0, W, H);
}
function drawVignette() {
if (!vignetteLayer) return;
ctx.drawImage(vignetteLayer, 0, 0, W, H);
}
// --- v8: mild film grain over the whole scene.
// Efficient: a small noise tile is generated ONCE and turned into a cached pattern.
// Each frame we just blit that pattern with 'overlay' blending at low opacity and a
// random sub-tile offset, so it shimmers like real film. Cost = one pattern fillRect.
let grainPattern = null;
const GRAIN_TILE = 160; // tile size in px (kept small so the pattern is cheap to repeat)
function buildGrainTile() {
const tile = document.createElement("canvas");
tile.width = GRAIN_TILE; tile.height = GRAIN_TILE;
const g = tile.getContext("2d");
const img = g.createImageData(GRAIN_TILE, GRAIN_TILE);
const d = img.data;
for (let i = 0; i < d.length; i += 4) {
// grayscale noise centred on mid-grey (~128): with 'overlay' blend, values near
// 128 are neutral while higher/lower ones gently brighten/darken -> filmic grain.
const v = 96 + Math.floor(Math.random() * 64); // 96..159, centred on 128
d[i] = v; d[i+1] = v; d[i+2] = v; d[i+3] = 255;
}
g.putImageData(img, 0, 0);
grainPattern = ctx.createPattern(tile, "repeat"); // cached once, reused every frame
}
let grainOx = 0, grainOy = 0, lastGrainTime = 0;
function drawGrain() {
if (!grainPattern) return;
// v17: half-speed grain — re-randomize the offset every other frame instead of every frame
const now = performance.now();
if (now - lastGrainTime >= 33) { // ~30fps grain updates instead of per-frame (~60fps)
grainOx = (Math.random() * GRAIN_TILE) | 0;
grainOy = (Math.random() * GRAIN_TILE) | 0;
lastGrainTime = now;
lastGrainDrawn = false; // offset changed -> must repaint
}
if (lastGrainDrawn) return; // v6: skip the full-screen overlay blend when nothing changed
const ox = grainOx, oy = grainOy;
ctx.save();
ctx.globalCompositeOperation = "overlay";
ctx.globalAlpha = 0.7; // mild: subtle shimmer, not heavy noise
ctx.translate(-ox, -oy);
ctx.fillStyle = grainPattern;
ctx.fillRect(0, 0, W + GRAIN_TILE, H + GRAIN_TILE); // cover full screen even with offset
ctx.restore();
lastGrainDrawn = true;
}
let lastGrainDrawn = false;
// Precomputed static layers — the sand and rocks never move, so they are
// rendered once to an offscreen canvas and blitted each frame (v6) instead of
// re-drawing their gradients and paths every single frame.
// PERF: half-resolution offscreen buffer for the light rays (see drawRays) — the
// per-ray blur() filter is far cheaper rasterizing small areas than full-screen.
// v24: baked current ripples — faint curved lines following the sand contour
function drawRipples(c) {
c.save();
c.lineCap = "round";
const nLines = Math.floor(W / 16);
for (let i = 0; i < nLines; i++) {
const x0 = rand(0, W);
const yBase = sandAt(x0);
const len = rand(35, 95);
const bow = rand(-7, 7);
c.strokeStyle = `rgba(126, 148, 141, ${rand(0.07, 0.16)})`;
c.lineWidth = rand(1, 2);
c.beginPath();
c.moveTo(x0, yBase + rand(3, H * 0.05));
c.quadraticCurveTo(
x0 + len / 2,
sandAt(x0 + len / 2) + bow,
x0 + len,
sandAt(x0 + len) + rand(3, H * 0.04)
);
c.stroke();
}
c.restore();
}
// v24: baked pebble/speckle scatter — grainy cool-toned dots across the sand bed
function drawPebbles(c) {
const n = Math.floor(W * H / 1800);
for (let i = 0; i < n; i++) {
const x = rand(0, W);
const y = sandAt(x) + rand(2, H * 0.14);
if (y > H) continue;
// cool palette: grey-teal, muted green, pale blue-grey
const hue = rand(150, 200);
const light = rand(38, 68);
c.fillStyle = `hsla(${hue}, ${rand(10, 28)}%, ${light}%, ${rand(0.25, 0.5)})`;
const r = rand(0.7, 2.4);
if (Math.random() < 0.6) {
// small round dot
c.beginPath();
c.arc(x, y, r, 0, Math.PI * 2);
c.fill();
} else {
// small ellipse pebble
c.save();
c.translate(x, y);
c.rotate(rand(0, Math.PI));
c.beginPath();
c.ellipse(0, 0, r * rand(1.2, 2), r * rand(0.5, 0.8), 0, 0, Math.PI * 2);
c.fill();
c.restore();
}
}
}
// v24: contact shadows — soft dark ellipses under rocks + faint band at sand surface line
function drawContactShadows(c) {
// Faint darker band right at the sand surface (grounds the whole bed)
// FIXED: follows the actual sand contour instead of a fixed horizontal band
c.save();
const bandGrad = c.createLinearGradient(0, H * 0.85, 0, H * 0.9);
bandGrad.addColorStop(0, "rgba(50, 72, 68, 0)");
bandGrad.addColorStop(1, "rgba(50, 72, 68, 0.10)");
c.fillStyle = bandGrad;
// Draw the band following the sand contour to avoid visible horizontal lines
c.beginPath();
c.moveTo(-10, H + 10);
for (let i = 0; i < sandHeights.length; i++) {
const x = i * 40;
const y = sandHeights[i] + rand(2, H * 0.05); // offset below the surface
c.lineTo(x, y);
}
c.lineTo(W + 10, H + 10);
c.closePath();
c.fill();
// Soft ellipse under each rock
for (const rk of rocks) {
const y = sandAt(rk.x) + rk.r * 0.35;
c.fillStyle = "rgba(42, 60, 58, 0.18)";
c.beginPath();
c.ellipse(rk.x, y + rk.r * 0.25, rk.r * 1.25, rk.r * 0.38, 0, 0, Math.PI * 2);
c.fill();
}
c.restore();
}
let staticLayer = null;
function buildStaticLayer() {
staticLayer = document.createElement("canvas");
staticLayer.width = canvas.width; // same device-pixel resolution as the tank
staticLayer.height = canvas.height;
const c2 = staticLayer.getContext("2d");
c2.setTransform(ctx.getTransform()); // match DPR scaling exactly
drawSand(c2);
drawRipples(c2); // v24: baked current ripples (free at runtime)
drawPebbles(c2); // v24: baked grainy pebble/speckle scatter
drawContactShadows(c2); // v24: soft dark ellipses under rocks + surface band
// NOTE: rocks are no longer baked here — they live in rocksLayer so they can be
// drawn AFTER the mid-ground grass (rocks sit IN FRONT of the blades, not behind them).
}
// Rocks on their own layer: blitted after drawGrass() so stones read as sitting in
// front of the plants instead of the grass appearing to sprout out of them.
let rocksLayer = null;
function buildRocksLayer() {
rocksLayer = document.createElement("canvas");
rocksLayer.width = canvas.width; // same device-pixel resolution as the tank
rocksLayer.height = canvas.height;
const c3 = rocksLayer.getContext("2d");
c3.setTransform(ctx.getTransform()); // match DPR scaling exactly
drawRocks(c3);
}
// ============ Tier 2: Caustics + light pooling (v25) ============
let causticTile = null;
let causticOffsetX = 0, causticOffsetY = 0;
let lastCausticShift = 0;
const CAUSTIC_TILE_SIZE = 160; // small tile — cheap to blit repeatedly
function buildCausticTile() {
causticTile = document.createElement("canvas");
const s = CAUSTIC_TILE_SIZE;
causticTile.width = s; causticTile.height = s;
const c = causticTile.getContext("2d");
// Apply blur to make caustics look softer/more realistic
c.filter = "blur(6px)"; // v27: softer blur, more realistic caustics
// Draw a few random bright spots/lines that look like refracted light patterns
for (let i = 0; i < 18; i++) {
const x = Math.random() * s, y = Math.random() * s;
const r = rand(4, 14);
const g = c.createRadialGradient(x, y, 0, x, y, r);
// Faint white-blue glow (will be blended with 'screen')
g.addColorStop(0, "rgba(255,255,255,0.44)"); // v26: +25% brighter
g.addColorStop(1, "rgba(255,255,255,0)");
c.fillStyle = g;
c.fillRect(x - r, y - r, r * 2, r * 2);
}
// Add some thin curved lines (like caustic web patterns)
for (let i = 0; i < 8; i++) {
const x1 = Math.random() * s, y1 = Math.random() * s;
const cx = x1 + rand(-40, 40), cy = y1 + rand(-40, 40);
const x2 = Math.random() * s, y2 = Math.random() * s;
c.strokeStyle = "rgba(255,255,255,0.23)"; // v26: +25% brighter
c.lineWidth = rand(1.5, 3.5);
c.beginPath();
c.moveTo(x1, y1);
c.quadraticCurveTo(cx, cy, x2, y2);
c.stroke();
}
// Reset filter
c.filter = "none";
}
function drawCaustics(t) {
if (!causticTile || !sandHeights.length) return;
// Shift the tile offset every ~50ms for a subtle shimmer effect (cheap: no redraw, just reposition)
if (t - lastCausticShift > 0.05) {
causticOffsetX = Math.random() * CAUSTIC_TILE_SIZE;
causticOffsetY = Math.random() * CAUSTIC_TILE_SIZE;
lastCausticShift = t;
}
ctx.save();
// Create a clipping path that follows the actual sand terrain
ctx.beginPath();
ctx.moveTo(-10, H + 10);
for (let i = 0; i < sandHeights.length; i++) {
ctx.lineTo(i * 40, sandHeights[i]);
}
ctx.lineTo(W + 10, H + 10);
ctx.closePath();
ctx.clip();
ctx.globalCompositeOperation = "screen"; // additive light effect
ctx.globalAlpha = 0.35; // v26: +25% brighter so the shimmer stands out from the sand
// Tile the caustic pattern across the entire canvas (clip will handle masking)
const tileW = CAUSTIC_TILE_SIZE, tileH = CAUSTIC_TILE_SIZE;
for (let x = -causticOffsetX % tileW - tileW; x < W + tileW; x += tileW) {
for (let y = -causticOffsetY % tileH - tileH; y < H + tileH; y += tileH) {
ctx.drawImage(causticTile, x, y);
}
}
ctx.restore();
}
// PERF: pre-render a soft radial gradient sprite for light pooling - draw scaled instead of creating gradients each frame
let _poolSprite = null, _poolSpriteSize = 0;
function buildPoolSprite() {
const size = 256; // fixed size, will be scaled per ray
if (_poolSprite && _poolSpriteSize === size) return;
_poolSprite = document.createElement('canvas');
_poolSprite.width = size;
_poolSprite.height = size;
const pc = _poolSprite.getContext('2d');
const g = pc.createRadialGradient(size/2, size/2, 0, size/2, size/2, size/2);
g.addColorStop(0, 'rgba(255,255,255,1)'); // full white, alpha controlled via globalAlpha
g.addColorStop(1, 'rgba(255,255,255,0)');
pc.fillStyle = g;
pc.fillRect(0, 0, size, size);
_poolSpriteSize = size;
}
function drawLightPooling(t) {
if (!_poolSprite) buildPoolSprite(); // lazy init (was never called before)
// Draw faint bright patches where each god-ray hits the sand
const sandTop = H * 0.85;
ctx.save();
ctx.globalCompositeOperation = "screen"; // additive light
for (const r of rays) {
// Compute where this ray hits the sand at y ≈ sandTop
// Use the same wave math as the ray drawing to match positions
const off = Math.sin(t * r.speed * 6 + r.x) * 55;
// At the sand depth (vM near the bottom), compute the x position
const vM = 0.95; // approximate depth where sand starts
const wave =
Math.sin(vM * r.uLen + t * r.uSpd + r.uPh) * r.uAmp +
Math.sin(vM * r.vLen - t * r.vSpd + r.vPh) * r.vAmp;
const dx = r.x + off + wave * (0.35 + 0.65 * vM);
// Draw a soft bright ellipse at this position on the sand using pre-rendered sprite
const poolW = r.w * 0.7, poolH = 18;
ctx.globalAlpha = r.alpha * 1.75; // v26: +25% brighter
ctx.drawImage(_poolSprite, dx - poolW, sandTop + 12 - poolH, poolW * 2, poolH * 2);
}
ctx.restore();
}
let last = performance.now();
let lastNightUpdate = 0;
let lastSortTime = 0;
// Spatial grid for bubble-fish collision detection
const GRID_SIZE = 80;
let grid = null;
function buildGrid() {
grid = {};
for (const f of fishes) {
const gx = Math.floor(f.x / GRID_SIZE);
const gy = Math.floor(f.y / GRID_SIZE);
const key = `${gx},${gy}`;
if (!grid[key]) grid[key] = [];
grid[key].push(f);
}
}
function getFishNear(x, y) {
const gx = Math.floor(x / GRID_SIZE);
const gy = Math.floor(y / GRID_SIZE);
const result = [];
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
const key = `${gx + dx},${gy + dy}`;
if (grid[key]) {
for (const f of grid[key]) result.push(f);
}
}
}
return result;
}
function frame(now) {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
const t = now / 1000;
// Cache night factor - update only once per second
if (t - lastNightUpdate > 1.0) {
curNight = nightFactor(t);
lastNightUpdate = t;
}
const light = sampleLight(t);
drawBackground(light);
drawRays(t, light);
drawFishShadows(t);
updateMotes(dt, t);
// v5: ultra-far pre-rendered blurry seaweed — deepest layer, behind everything else
drawFarSeaweeds(t);
// v6: middle-distance seaweed with halo pass (between far and back)
drawMidSeaweeds(t);
// v4: background seaweed — drawn behind sand/rocks for depth
drawBackSeaweeds(t); // v4.3: procedural multi-wave motion (no separate update pass)
updatePlants(t, dt);
updateGrass(t, dt);
for (const f of fishes) f.update(dt);
// Build spatial grid for collision detection
buildGrid();
// Build spatial grid for collision detection
buildGrid();
// v7: fish gently bump each other - overlapping pairs are eased apart along their
// center line (weighted by size), so they nudge instead of stacking on top of each other
// Uses spatial grid for O(n) average instead of O(n²)
// Track processed pairs by ID to avoid double-checking (which causes twitching when sleeping)
const checked = new Set();
for (const a of fishes) {
const nearby = getFishNear(a.x, a.y);
for (const b of nearby) {
if (a === b) continue;
// Only check each pair once using fish IDs
const pairKey = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`;
if (checked.has(pairKey)) continue;
checked.add(pairKey);
let dx = b.x - a.x, dy = b.y - a.y;
const minD = (a.size + b.size) * 0.62; // gentle: smaller than their visual length
const d2 = dx * dx + dy * dy;
if (d2 <= 0.01) { // exactly overlapping - nudge apart randomly
a.x -= rand(0.5, 1.5); a.y -= rand(0.5, 1.5);
b.x += rand(0.5, 1.5); b.y += rand(0.5, 1.5);
} else if (d2 < minD * minD) {
const dd = Math.sqrt(d2);
const overlap = (minD - dd) / minD; // 0..1 how deep they are in each other
const push = overlap * 24 * dt; // soft: only a few px/s even at full overlap
dx /= dd; dy /= dd;
const ma = b.size / (a.size + b.size), mb = a.size / (a.size + b.size);
a.x -= dx * push * 2 * ma; a.y -= dy * push * 2 * ma;
b.x += dx * push * 2 * mb; b.y += dy * push * 2 * mb;
}
}
}
if (t - lastSortTime > 0.1) {
// Sort fish by size for depth ordering - only re-sort every 100ms
if (t - lastSortTime > 0.1) {
fishes.sort((a, b) => a.size - b.size);
lastSortTime = t;
}
lastSortTime = t;
}
for (const f of fishes) f.draw(ctx);
ctx.drawImage(staticLayer, 0, 0, W, H);
// v27: at night, dim the bare floor ~25% (clipped to the sand shape) BEFORE the caustics,
// so the shimmer reads brighter against a darker bed without touching the caustics themselves.
if (curNight > 0.01) {
ctx.save();
ctx.beginPath();
ctx.moveTo(-10, H + 10);
for (let i = 0; i < sandHeights.length; i++) ctx.lineTo(i * 40, sandHeights[i]);
ctx.lineTo(W + 10, H + 10);
ctx.closePath();
ctx.clip();
ctx.fillStyle = `rgba(5,10,22,${(curNight * 0.25).toFixed(3)})`;
ctx.fillRect(0, H * 0.8, W, H * 0.2);
ctx.restore();
}
drawCaustics(t); // v25: animated caustic shimmer on the sand
drawLightPooling(t); // v25: light pools where god-rays hit the floor
drawSeaweed();
drawGrass(t);
ctx.drawImage(rocksLayer, 0, 0, W, H); // rocks in front of mid-ground grass
for (let i = bubbles.length - 1; i >= 0; i--) {
const b = bubbles[i];
b.update(dt, t);
if (!b.dead) {
// v10: a fish brushing a bubble pops it — unless it's that same fish's own bubble
// Optimized: use spatial grid instead of checking all fish
const nearby = getFishNear(b.x, b.y);
for (const f of nearby) {
if (f === b.owner) continue;
const dx = f.x - b.x, dy = f.y - b.y;
const rr = f.size * 0.6 + b.r;
if (dx * dx + dy * dy < rr * rr) { b.dead = true; break; }
}
}
if (b.dead) { bubbles.splice(i, 1); continue; }
b.draw(ctx);
}
drawFgGrass(t); // v12: foreground grass — closest to the viewer, drawn on top
// ambient day/night wash over the whole scene
if (light.washA > 0.005) {
ctx.fillStyle = `rgba(${light.washRGB[0]},${light.washRGB[1]},${light.washRGB[2]},${light.washA})`;
ctx.fillRect(0, 0, W, H);
}
drawVignette();
drawGrain(); // v8: mild film grain on top of everything
requestAnimationFrame(frame);
}
function doResize() {
resize();
buildRaySprites(); // v23: rebuild the pre-blurred beam sprites for the new size
buildFarSeaweedSprites(); // v5: rebuild ultra-far seaweed sprites for new size
buildVignette(); // v6: rebuild the static vignette layer
buildStaticLayer();
buildRocksLayer(); // rocks drawn after grass so they sit in front of the plants
buildCausticTile(); // v25: caustic texture tile (built once)
if (!grainPattern) buildGrainTile(); // v8: grain tile is size-independent, build once
}
// ============ Player controls: keyboard + gamepad ============
const keys = {}; // currently-held keyboard keys (lowercased)
let prevStartPressed = false; // edge-detect the gamepad Start button
// Spawn a fresh player-controlled fish at the centre of the tank.
function spawnPlayerFish() {
if (playerFish) { // replace any existing one
const idx = fishes.indexOf(playerFish);
if (idx !== -1) fishes.splice(idx, 1);
}
playerFish = new Fish();
playerFish.x = W / 2;
playerFish.y = H / 2;
playerFish.size = rand(25, 43); // ~20% larger than normal fish for visibility
playerFish.speed *= 1.5; // a touch faster than average for fun
playerFish.hue = (Math.random() * 360) | 0; // unique colour so it stands out
playerFish.saturation = 90;
playerFish.lightness = 55;
fishes.push(playerFish);
console.log('Player fish spawned! Steer with WASD or the left stick.');
}
// Keyboard: WASD steer, Shift speed up, Space slow down, F spawn.
document.addEventListener('keydown', (e) => {
const k = e.key.toLowerCase();
keys[k] = true;
if (k === ' ') e.preventDefault(); // stop Space from scrolling the page
if (k === 'f' && !e.repeat) spawnPlayerFish();
});
document.addEventListener('keyup', (e) => {
keys[e.key.toLowerCase()] = false;
});
// Poll gamepad state each frame (Gamepad API requires polling).
function pollGamepad() {
const pads = navigator.getGamepads ? navigator.getGamepads() : null;
let startNow = false;
if (pads) {
for (const pad of pads) {
if (!pad || !pad.connected) continue;
// Xbox 360 layout: Start button is index [7].
if (pad.buttons[7] && pad.buttons[7].pressed) startNow = true;
break; // only the first connected pad
}
}
if (startNow && !prevStartPressed) spawnPlayerFish(); // on-press only
prevStartPressed = startNow;
}
setInterval(pollGamepad, 10);
// Combine keyboard + gamepad into one input vector for the player fish.
function getPlayerInput() {
let dx = 0, dy = 0, boost = 0, slow = 0;
// Keyboard: WASD steer, Shift speed up, Space slow down.
if (keys['a']) dx -= 1;
if (keys['d']) dx += 1;
if (keys['w']) dy -= 1;
if (keys['s']) dy += 1;
if (keys['shift']) boost = 1;
if (keys[' ']) slow = 1;
// Gamepad: left stick steers, right stick down speeds up / up slows down.
const pads = navigator.getGamepads ? navigator.getGamepads() : null;
if (pads) {
for (const pad of pads) {
if (!pad || !pad.connected) continue;
const lx = Math.abs(pad.axes[0]) > GAMEPAD_DEADZONE ? pad.axes[0] : 0;
const ly = Math.abs(pad.axes[1]) > GAMEPAD_DEADZONE ? pad.axes[1] : 0;
dx += lx;
dy += ly;
if (pad.axes[3] > GAMEPAD_DEADZONE) boost = Math.max(boost, pad.axes[3]); // down → faster
if (pad.axes[3] < -GAMEPAD_DEADZONE) slow = Math.max(slow, -pad.axes[3]); // up → slower
break; // only the first connected pad
}
}
return { dx, dy, boost, slow };
}
// Fade out the controls help text 10s after load/refresh.
setTimeout(() => {
const el = document.getElementById('controls-help');
if (el) el.style.opacity = '0';
}, 10000);
window.addEventListener("resize", doResize);
doResize();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>