Upload index.html
Browse files- index.html +260 -219
index.html
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
-
<title>Digital Aquarium</title>
|
| 7 |
<style>
|
| 8 |
html, body { margin:0; padding:0; height:100%; overflow:hidden; background:#02121f; }
|
| 9 |
#tank { display:block; width:100vw; height:100vh; }
|
|
@@ -20,14 +20,15 @@
|
|
| 20 |
<canvas id="tank"></canvas>
|
| 21 |
<div class="glass"></div>
|
| 22 |
<script>
|
|
|
|
| 23 |
(() => {
|
| 24 |
"use strict";
|
| 25 |
-
// ============ Aquarium v16 WHISKERS
|
| 26 |
-
// v22: Ecco-style rendering
|
| 27 |
// side profile when moving horizontally, like Ecco the Dolphin's 3D perspective
|
| 28 |
// Based on v15 SPOTTY-3 (rolling hills, three tail styles, yellow-green foreground grass,
|
| 29 |
// ~20% spots / ~20% stripes / ~10% both), plus: an independent roll gives ~10% of fish
|
| 30 |
-
// whiskers (barbels)
|
| 31 |
// (short / medium / long), swaying gently as they swim. Whiskers are independent of the
|
| 32 |
// body markings, so a whiskered fish may also be spotted, striped, both, or plain.
|
| 33 |
// Iridescence + overhead streak unchanged.
|
|
@@ -47,7 +48,7 @@ function lerpAngle(a, b, t) {
|
|
| 47 |
|
| 48 |
const HUES = [8, 35, 46, 172, 195, 215];
|
| 49 |
const TAIL_TYPES = ['classic', 'fan', 'streamer']; // v11: three tail-fin styles, one per fish
|
| 50 |
-
// v18: koi colourations
|
| 51 |
// looks: kohaku (red/orange), sanke (red + black), golden (amber/gold). Patches are
|
| 52 |
// sampled in units of `size` like the spots, so they scale with each fish.
|
| 53 |
function genKoiPatches(type) {
|
|
@@ -87,15 +88,53 @@ function genKoiPatches(type) {
|
|
| 87 |
}
|
| 88 |
let fishes = [], bubbles = [], seaweeds = [], backSeaweeds = [], midSeaweeds = [], farSeaweeds = [], motes = [], rays = [], rocks = [], grass = [], fgGrass = [];
|
| 89 |
let playerFish = null; // the fish controlled by gamepad (spawned with F key)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
const GAMEPAD_DEADZONE = 0.15; // ignore small stick movements
|
| 91 |
let curNight = 0; // v7: shared night factor (dims the top-light + sheen)
|
| 92 |
-
let plantPoints = [], _ppUsed = 0; // collision points
|
| 93 |
let sandHeights = [];
|
| 94 |
let sandParticles = []; // v22: grains of sand kicked up when a fast fish skims the sea floor
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
class Fish {
|
|
|
|
| 97 |
constructor() {
|
| 98 |
-
this.
|
|
|
|
| 99 |
this.speed = rand(0.55, 1.7) * (this.size / 30 + 0.5) * 0.75; // v3: max speed reduced 25%
|
| 100 |
this.x = rand(60, W - 60);
|
| 101 |
this.y = rand(H * 0.15, H * 0.72);
|
|
@@ -130,30 +169,30 @@ class Fish {
|
|
| 130 |
this.spots = null;
|
| 131 |
this.whiskerLen = null;
|
| 132 |
}
|
| 133 |
-
// v18: color palette
|
| 134 |
const specialRoll = Math.random();
|
| 135 |
this.isKoi = false;
|
| 136 |
this.koiPatches = null;
|
| 137 |
if (specialRoll < 0.15) {
|
| 138 |
-
// Silver/gray fish
|
| 139 |
this.hue = rand(200, 240); // slight blue-gray tint
|
| 140 |
this.saturation = rand(8, 18);
|
| 141 |
this.lightness = rand(60, 75);
|
| 142 |
} else if (specialRoll < 0.45) {
|
| 143 |
-
// Koi
|
| 144 |
// colourations are split roughly evenly across this 30% band.
|
| 145 |
this.isKoi = true;
|
| 146 |
const koiType = specialRoll < 0.22 ? 'kohaku' : specialRoll < 0.30 ? 'sanke' : specialRoll < 0.38 ? 'golden' : 'goldspot';
|
| 147 |
if (koiType === 'goldspot') {
|
| 148 |
// Golden orange fish with white spots
|
| 149 |
this.hue = rand(30, 42); // golden orange
|
| 150 |
-
this.saturation = rand(85, 95); // high saturation
|
| 151 |
-
this.lightness = rand(50, 65); // medium lightness
|
| 152 |
} else {
|
| 153 |
-
// Classic koi
|
| 154 |
this.hue = rand(40, 50); // warm ivory tint for the base
|
| 155 |
-
this.saturation = rand(12, 22); // low saturation
|
| 156 |
-
this.lightness = rand(88, 94); // high lightness
|
| 157 |
}
|
| 158 |
this.koiPatches = genKoiPatches(koiType);
|
| 159 |
} else {
|
|
@@ -164,10 +203,10 @@ class Fish {
|
|
| 164 |
}
|
| 165 |
this.tailType = pick(TAIL_TYPES); // v11: random tail-fin style for variety
|
| 166 |
|
| 167 |
-
// v13/v14/v15: random body markings
|
| 168 |
// ~20% spots, ~20% tiger stripes, ~10% BOTH (spots over stripes), else plain.
|
| 169 |
// Sampled in units of `size` so they scale with the fish; stored as pre-built
|
| 170 |
-
// fill styles
|
| 171 |
const genSpots = () => {
|
| 172 |
const arr = [];
|
| 173 |
const nSpots = 7 + ((Math.random() * 9) | 0); // 7..15 spots
|
|
@@ -177,14 +216,14 @@ class Fish {
|
|
| 177 |
const rr = Math.sqrt(Math.random()) * 0.9; // uniform in ellipse, kept off the rim
|
| 178 |
let style;
|
| 179 |
if (this.saturation < 30) {
|
| 180 |
-
// v14: gray/silver fish
|
| 181 |
const spotLight = pale ? rand(75, 88) : rand(35, 50);
|
| 182 |
style = `hsla(${this.hue + rand(-24, 24)},${rand(5, 15)}%,${spotLight}%,${rand(0.35, 0.6).toFixed(2)})`;
|
| 183 |
} else if (pale) {
|
| 184 |
-
// cream/ivory spots
|
| 185 |
style = `hsla(${this.hue + rand(-24, 24)},${rand(45, 70)}%,${rand(82, 94)}%,${rand(0.35, 0.6).toFixed(2)})`;
|
| 186 |
} else {
|
| 187 |
-
// dark spots
|
| 188 |
style = `hsla(${this.hue + rand(-18, 18)},${rand(60, 85)}%,${rand(24, 34)}%,${rand(0.45, 0.7).toFixed(2)})`;
|
| 189 |
}
|
| 190 |
arr.push({
|
|
@@ -197,31 +236,31 @@ class Fish {
|
|
| 197 |
return arr;
|
| 198 |
};
|
| 199 |
const genStripes = () => {
|
| 200 |
-
// v14/v14.1: tiger stripes
|
| 201 |
// style), spread from just behind the head to near the tail base with organic
|
| 202 |
// jitter; each bar bends and tapers instead of being a straight line.
|
| 203 |
const arr = [];
|
| 204 |
const nStr = 5 + ((Math.random() * 4) | 0); // 5..8 bars
|
| 205 |
const darkBars = this.saturation >= 30 && Math.random() < 0.72; // v14: gray fish get light stripes
|
| 206 |
for (let i = 0; i < nStr; i++) {
|
| 207 |
-
const t = nStr > 1 ? i / (nStr - 1) : 0; // 0 behind head
|
| 208 |
let style;
|
| 209 |
if (this.saturation < 30) {
|
| 210 |
-
// v14: gray/silver fish
|
| 211 |
const stripeLight = darkBars ? rand(35, 50) : rand(75, 88);
|
| 212 |
style = `hsla(${this.hue + rand(-14, 14)},${rand(5, 15)}%,${stripeLight}%,${rand(0.5, 0.75).toFixed(2)})`;
|
| 213 |
} else if (darkBars) {
|
| 214 |
// classic dark tiger bars
|
| 215 |
style = `hsla(${this.hue + rand(-14, 14)},${rand(65, 90)}%,${rand(18, 30)}%,${rand(0.5, 0.75).toFixed(2)})`;
|
| 216 |
} else {
|
| 217 |
-
// cream/ivory bars
|
| 218 |
style = `hsla(${this.hue + rand(-24, 24)},${rand(45, 70)}%,${rand(80, 93)}%,${rand(0.35, 0.6).toFixed(2)})`;
|
| 219 |
}
|
| 220 |
arr.push({
|
| 221 |
-
x: 0.72 - t * 1.62 + rand(-0.07, 0.07), // +0.72s
|
| 222 |
w: rand(0.08, 0.2), // bar width, in units of s
|
| 223 |
style,
|
| 224 |
-
// v14.1: organic shape
|
| 225 |
cAmp: rand(0.03, 0.09), // whole-bar bend amplitude (units of s)
|
| 226 |
cFreq: rand(0.8, 2.0), // waves of bend across the bar's height
|
| 227 |
cPh: rand(0, Math.PI * 2),
|
|
@@ -232,7 +271,7 @@ class Fish {
|
|
| 232 |
}
|
| 233 |
return arr;
|
| 234 |
};
|
| 235 |
-
// v18: koi already carry their own large patches
|
| 236 |
const markRoll = this.isKoi ? 1 : Math.random();
|
| 237 |
if (markRoll < 0.2) {
|
| 238 |
this.spots = genSpots();
|
|
@@ -241,14 +280,14 @@ class Fish {
|
|
| 241 |
this.spots = null;
|
| 242 |
this.stripes = genStripes();
|
| 243 |
} else if (markRoll < 0.5) {
|
| 244 |
-
// v15: the mixed breed
|
| 245 |
this.spots = genSpots();
|
| 246 |
this.stripes = genStripes();
|
| 247 |
} else {
|
| 248 |
this.spots = null;
|
| 249 |
this.stripes = null;
|
| 250 |
}
|
| 251 |
-
// v16: whiskers (barbels)
|
| 252 |
// forward-curving pair from the snout in one of three length tiers (~even split),
|
| 253 |
// so the tank shows short, medium and long whiskers. Independent of spots/stripes,
|
| 254 |
// so any marked fish can also wear them.
|
|
@@ -264,7 +303,7 @@ class Fish {
|
|
| 264 |
}
|
| 265 |
|
| 266 |
// PERF: precompute per-fish paint styles once. draw() used to rebuild these
|
| 267 |
-
// template strings every frame for every fish
|
| 268 |
this.tailFillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.92)`;
|
| 269 |
this.dorsalFillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.9)`;
|
| 270 |
this.pevFillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.8)`;
|
|
@@ -279,7 +318,7 @@ class Fish {
|
|
| 279 |
this.bodyGrad = bg;
|
| 280 |
|
| 281 |
this.phase = rand(0, Math.PI * 2);
|
| 282 |
-
// v9: after dark, most fish bed down low in the grass
|
| 283 |
// hovers by the seaweed, and each of those keeps to its own stalk (no pile-ups)
|
| 284 |
this.grassSleeper = Math.random() < 0.85;
|
| 285 |
this.seaweedPref = -1; // chosen lazily on the first night (needs built scenery)
|
|
@@ -305,9 +344,9 @@ class Fish {
|
|
| 305 |
this.wanderTheta = rand(0, Math.PI * 2);
|
| 306 |
this.wanderRadius = rand(35, 80);
|
| 307 |
this.wanderDist = rand(90, 170);
|
| 308 |
-
this.maxTurn = rand(1.4, 2.2); // max rad/s
|
| 309 |
|
| 310 |
-
// Behavior states: fish don't swim non-stop
|
| 311 |
this.behavior = 'swim'; // 'swim' | 'hover' | 'pause'
|
| 312 |
this.behaviorTimer = rand(4, 11); // seconds until it may change behavior
|
| 313 |
this.speedFactor = 1; // current speed multiplier (eased toward target)
|
|
@@ -318,12 +357,12 @@ class Fish {
|
|
| 318 |
// Occasionally puffs a tiny cluster of bubbles from its mouth
|
| 319 |
this.bubbleTimer = rand(4, 16); // seconds until the next bubble puff
|
| 320 |
|
| 321 |
-
// Body roll
|
| 322 |
this.roll = 0; // current roll angle (rad); 0 = upright
|
| 323 |
this.rollVel = 0; // angular velocity of the roll
|
| 324 |
this.inHardTurn = false;// was it in a sharp turn last frame?
|
| 325 |
|
| 326 |
-
// v22: Ecco-style viewing angle
|
| 327 |
// or from above/below based on its movement direction (0 = side, 1 = top/bottom)
|
| 328 |
this.viewAngle = 0; // current interpolated view angle
|
| 329 |
|
|
@@ -334,10 +373,10 @@ class Fish {
|
|
| 334 |
const night = personalNight(performance.now() / 1000, this); // v10: per-fish early/normal/late sleep & wake
|
| 335 |
|
| 336 |
// Night: cruise power eases down so even active fish drift lazily after dark,
|
| 337 |
-
// and back up again at dawn
|
| 338 |
this.cruiseSpeed += ((1 - night * 0.55) - this.cruiseSpeed) * Math.min(1, dt * 0.3);
|
| 339 |
|
| 340 |
-
// Behavior state machine
|
| 341 |
// At night the odds tilt toward hovering and long still rests (v6).
|
| 342 |
this.behaviorTimer -= dt;
|
| 343 |
if (this.behaviorTimer <= 0) {
|
|
@@ -363,11 +402,11 @@ class Fish {
|
|
| 363 |
// Occasionally release a small cluster of bubbles from the mouth
|
| 364 |
this.bubbleTimer -= dt;
|
| 365 |
if (this.bubbleTimer <= 0) {
|
| 366 |
-
const mx = this.x +
|
| 367 |
const my = this.y + Math.sin(this.angle) * this.size * 0.62;
|
| 368 |
-
const n = (2 + ((Math.random() * 3) | 0)) * 2; // 4..8 bubbles
|
| 369 |
for (let i = 0; i < n; i++) {
|
| 370 |
-
const bb =
|
| 371 |
bb.owner = this; // v10: a fish never pops its own bubbles
|
| 372 |
bubbles.push(bb);
|
| 373 |
}
|
|
@@ -379,16 +418,16 @@ class Fish {
|
|
| 379 |
|
| 380 |
// Target sits ahead of the fish (in heading direction), offset by wanderTheta.
|
| 381 |
// Steering toward it yields smooth meandering and genuine direction changes.
|
| 382 |
-
let wx = this.x +
|
| 383 |
let wy = this.y + Math.sin(this.angle) * this.wanderDist + Math.sin(this.wanderTheta) * this.wanderRadius;
|
| 384 |
|
| 385 |
// v7: after dark, most fish bed down low in the grass; a few still hover by the seaweed.
|
| 386 |
if (night > 0.01) {
|
| 387 |
if (this.grassSleeper) {
|
| 388 |
-
// v14: drift toward this fish's own sleep spot along the sand line
|
| 389 |
// spreads sleeping fish across the tank instead of one big clump
|
| 390 |
wx += (this.sleepSpotX - wx) * night * 0.75;
|
| 391 |
-
const groundY = sandAt(this.x +
|
| 392 |
wy += (groundY - wy) * night * 0.85;
|
| 393 |
} else if (seaweeds.length) {
|
| 394 |
// v9: each seaweed-hungry fish keeps to its own stalk instead of all crowding the nearest one
|
|
@@ -396,9 +435,9 @@ class Fish {
|
|
| 396 |
this.seaweedPref = (Math.random() * seaweeds.length) | 0;
|
| 397 |
}
|
| 398 |
const w = seaweeds[this.seaweedPref];
|
| 399 |
-
// v13: plinko fix
|
| 400 |
// instead of being deflected back toward the middle by the boundary steering.
|
| 401 |
-
const edgeT = Math.abs(w.x / W - 0.5) * 2; // 0 at center
|
| 402 |
const k = night * 0.8 * (1 + edgeT * 0.9); // up to ~1.9x at the very edge
|
| 403 |
wx += (w.x - wx) * k;
|
| 404 |
wy += (w.yMid - wy) * k;
|
|
@@ -415,7 +454,7 @@ class Fish {
|
|
| 415 |
else if (this.x > W - mX) pull = Math.max(pull, 1 - (W - this.x) / mX);
|
| 416 |
if (this.y < mTop) pull = Math.max(pull, 1 - this.y / mTop);
|
| 417 |
else if (this.y > H - mBot) pull = Math.max(pull, 1 - (H - this.y) / mBot);
|
| 418 |
-
// v13: plinko fix
|
| 419 |
// boundary, so they can reach side positions instead of being pushed to the middle.
|
| 420 |
if (pull > 0 && this.grassSleeper && night > 0.25) {
|
| 421 |
const settle = Math.min(1, (night - 0.25) / 0.75); // ramps up as they get sleepier
|
|
@@ -428,7 +467,7 @@ class Fish {
|
|
| 428 |
|
| 429 |
// Floor steering: as a fish nears the sand bed it is steered upward,
|
| 430 |
// so it skims above the ground line instead of diving under it
|
| 431 |
-
// (v4: starts earlier and pushes harder
|
| 432 |
const floorY = sandAt(this.x) - this.size * 0.35;
|
| 433 |
const fZone = H * 0.18;
|
| 434 |
if (this.y > floorY - fZone) {
|
|
@@ -436,7 +475,7 @@ class Fish {
|
|
| 436 |
desired = lerpAngle(desired, Math.atan2(H * 0.45 - this.y, W / 2 - this.x), fp * 1.6);
|
| 437 |
}
|
| 438 |
|
| 439 |
-
// v7: separation steering
|
| 440 |
// The faster we swim, the earlier and harder we steer away.
|
| 441 |
const mySpeed = this.speed * this.speedFactor;
|
| 442 |
if (mySpeed > 0.3) {
|
|
@@ -448,7 +487,7 @@ class Fish {
|
|
| 448 |
const zone = (this.size + o.size) * 1.7; // notice radius scales with both fish
|
| 449 |
if (d2 < zone * zone && d2 > 0.01) {
|
| 450 |
const dd = Math.sqrt(d2);
|
| 451 |
-
const w = (1 - dd / zone) * Math.min(1, mySpeed); // close & fast
|
| 452 |
ax -= (dx / dd) * w;
|
| 453 |
ay -= (dy / dd) * w;
|
| 454 |
wsum += w;
|
|
@@ -458,7 +497,7 @@ class Fish {
|
|
| 458 |
desired = lerpAngle(desired, Math.atan2(ay, ax), Math.min(0.85, wsum * 0.45));
|
| 459 |
}
|
| 460 |
} else if (night > 0.2) {
|
| 461 |
-
// v14: at rest after dark
|
| 462 |
// fish gently push apart instead of settling into one tight clump
|
| 463 |
let ax = 0, ay = 0, wsum = 0;
|
| 464 |
for (const o of fishes) {
|
|
@@ -499,9 +538,9 @@ class Fish {
|
|
| 499 |
if (this.roll > 1.15) { this.roll = 1.15; this.rollVel *= -0.25; }
|
| 500 |
else if (this.roll < -1.15) { this.roll = -1.15; this.rollVel *= -0.25; }
|
| 501 |
|
| 502 |
-
// v22: Ecco-style viewing angle
|
| 503 |
// when moving horizontally, we see its side profile. Smoothly interpolate between views.
|
| 504 |
-
// sin(angle) gives 0 for horizontal (0,
|
| 505 |
const targetView = Math.abs(Math.sin(this.angle));
|
| 506 |
this.viewAngle += (targetView - this.viewAngle) * Math.min(1, dt * 4);
|
| 507 |
|
|
@@ -515,7 +554,7 @@ class Fish {
|
|
| 515 |
let d = targetAngle - this.angle;
|
| 516 |
while (d > Math.PI) d -= 2 * Math.PI;
|
| 517 |
while (d < -Math.PI) d += 2 * Math.PI;
|
| 518 |
-
const turnSpeed = 4.0; // rad/s
|
| 519 |
this.angle += Math.max(-turnSpeed * dt, Math.min(turnSpeed * dt, d));
|
| 520 |
}
|
| 521 |
// Speed: cruise baseline, boosted by Shift / R-stick down,
|
|
@@ -525,26 +564,26 @@ class Fish {
|
|
| 525 |
if (inp.slow > 0) speedMult -= inp.slow * 0.85; // slow down
|
| 526 |
speedMult = Math.max(0.15, Math.min(3.0, speedMult));
|
| 527 |
const playerSpeed = this.speed * this.cruiseSpeed * speedMult * 60 * dt;
|
| 528 |
-
this.x +=
|
| 529 |
this.y += Math.sin(this.angle) * playerSpeed;
|
| 530 |
this.currentVelocity = this.speed * this.cruiseSpeed * speedMult * 60; // track actual velocity
|
| 531 |
} else {
|
| 532 |
// Normal AI fish movement.
|
| 533 |
const sp = this.speed * this.cruiseSpeed * this.speedFactor * 60 * dt;
|
| 534 |
-
this.x +=
|
| 535 |
this.y += Math.sin(this.angle) * sp;
|
| 536 |
this.currentVelocity = this.speed * this.cruiseSpeed * this.speedFactor * 60; // track actual velocity
|
| 537 |
}
|
| 538 |
|
| 539 |
-
// NOTE: fish do NOT collide with plants
|
| 540 |
// The seaweed bends out of the way instead (see updatePlants).
|
| 541 |
|
| 542 |
-
// Hard safety clamp
|
| 543 |
// despawn or leave), but never below the ground line (sand surface)
|
| 544 |
this.x = Math.max(-40, Math.min(W + 40, this.x));
|
| 545 |
this.y = Math.max(-50, Math.min(sandAt(this.x) - this.size * 0.35, this.y));
|
| 546 |
|
| 547 |
-
// Tail beat follows actual speed
|
| 548 |
// Use the actual current velocity (which accounts for player boost/slow)
|
| 549 |
const effSpeed = this.currentVelocity / 60; // convert back to base units
|
| 550 |
this.phase += dt * (4 + effSpeed * 3);
|
|
@@ -558,11 +597,11 @@ class Fish {
|
|
| 558 |
// detailed body and foreshorten it as the fish turns to swim vertically.
|
| 559 |
// viewAngle 0 = pure side profile (one eye), 1 = seen from above/below (the back).
|
| 560 |
// Foreshortening along local up/down makes the dorsal fin slide toward center and
|
| 561 |
-
// the body slim down, reading as "seeing the back"
|
| 562 |
const v = Math.max(0, Math.min(1, this.viewAngle));
|
| 563 |
let scaleY = 1 - 0.34 * v; // foreshorten toward the back view
|
| 564 |
if (Math.abs(this.roll) > 0.01) { // banking roll still squashes a touch
|
| 565 |
-
scaleY *= Math.max(0.42,
|
| 566 |
c.rotate(this.roll * 0.1);
|
| 567 |
}
|
| 568 |
c.scale(1, scaleY);
|
|
@@ -577,7 +616,7 @@ class Fish {
|
|
| 577 |
// traveling body wave
|
| 578 |
const waveK = Math.PI / s;
|
| 579 |
const waveAmp = s * 0.12;
|
| 580 |
-
const sway =
|
| 581 |
|
| 582 |
// Top/bottom view: body seen from above or below
|
| 583 |
// Dorsal fin visible as a line down the center when viewed from above
|
|
@@ -602,7 +641,7 @@ class Fish {
|
|
| 602 |
c.fillStyle = g;
|
| 603 |
c.fill(bodyPath);
|
| 604 |
|
| 605 |
-
// Dorsal fin
|
| 606 |
c.strokeStyle = this.tailFillStyle;
|
| 607 |
c.lineWidth = s * 0.15;
|
| 608 |
c.lineCap = "round";
|
|
@@ -612,7 +651,7 @@ class Fish {
|
|
| 612 |
c.quadraticCurveTo(-s * 0.2, -sway * 0.5, -s * 0.6, sway * 0.3);
|
| 613 |
c.stroke();
|
| 614 |
|
| 615 |
-
// Eyes
|
| 616 |
c.fillStyle = "#fff";
|
| 617 |
// Left eye
|
| 618 |
c.beginPath();
|
|
@@ -658,11 +697,11 @@ class Fish {
|
|
| 658 |
// v22: Draw fish from the side (original view)
|
| 659 |
drawSideView(c) {
|
| 660 |
const s = this.size;
|
| 661 |
-
// traveling body wave (v3): ripples
|
| 662 |
const waveK = Math.PI / s; // ~one wavelength across the body length
|
| 663 |
const waveAmp = s * 0.12; // subtle flex amplitude
|
| 664 |
-
// tail fin follows the body wave at its base, amplified (same phase
|
| 665 |
-
const sway =
|
| 666 |
|
| 667 |
// v24: Ecco-style blend factor (0 = side profile, 1 = seen from above/below).
|
| 668 |
// Flattens the tail lobes and spreads eyes/barbels as we see more of the back.
|
|
@@ -680,26 +719,26 @@ class Fish {
|
|
| 680 |
// with only a shallow dip between the two lobes.
|
| 681 |
c.moveTo(-s * 0.5, 0);
|
| 682 |
c.quadraticCurveTo(-s * 1.3, -s * 0.28 * tailFlat + sway, -s * 1.42, -s * 0.5 * tailFlat + sway); // up to upper tip
|
| 683 |
-
c.quadraticCurveTo(-s * 1.12, sway * 0.5, -s * 1.42, s * 0.5 * tailFlat + sway); // shallow notch
|
| 684 |
c.quadraticCurveTo(-s * 1.3, s * 0.28 * tailFlat + sway, -s * 0.5, 0); // back to the base
|
| 685 |
} else if (this.tailType === 'streamer') {
|
| 686 |
// Long forked streamers (angelfish/fancy-guppy-style): two flowing lobes that
|
| 687 |
// reach further back with a deeper notch between them.
|
| 688 |
c.moveTo(-s * 0.5, 0);
|
| 689 |
c.quadraticCurveTo(-s * 1.35, -s * 0.24 * tailFlat + sway, -s * 1.68, -s * 0.56 * tailFlat + sway); // long upper lobe
|
| 690 |
-
c.quadraticCurveTo(-s * 0.98, sway * 0.35, -s * 1.68, s * 0.56 * tailFlat + sway); // deep fork
|
| 691 |
c.quadraticCurveTo(-s * 1.35, s * 0.24 * tailFlat + sway, -s * 0.5, 0); // long lower lobe back to base
|
| 692 |
} else {
|
| 693 |
-
// classic (v7): rounded lobes with a soft notch
|
| 694 |
c.moveTo(-s * 0.5, 0);
|
| 695 |
c.quadraticCurveTo(-s * 1.04, -s * 0.36 * tailFlat + sway, -s * 1.22, -s * 0.44 * tailFlat + sway); // upper edge out toward the tip
|
| 696 |
-
c.quadraticCurveTo(-s * 1.34, -s * 0.52 * tailFlat + sway, -s * 0.86, sway * 0.5); // rounded upper lobe
|
| 697 |
-
c.quadraticCurveTo(-s * 1.34, s * 0.52 * tailFlat + sway, -s * 1.22, s * 0.44 * tailFlat + sway); // notch
|
| 698 |
c.quadraticCurveTo(-s * 1.04, s * 0.36 * tailFlat + sway, -s * 0.5, 0); // lower edge back to the base
|
| 699 |
}
|
| 700 |
c.fill();
|
| 701 |
|
| 702 |
-
// v21: far-side whisker
|
| 703 |
// partially obscured by the head (only the part that pokes out from behind the snout
|
| 704 |
// shows). The near-side whisker is drawn after the eye, below.
|
| 705 |
if (this.whiskerLen) {
|
|
@@ -713,7 +752,7 @@ class Fish {
|
|
| 713 |
{
|
| 714 |
const side = -1; // far side (behind the body)
|
| 715 |
const rootX = s * 0.6;
|
| 716 |
-
// v24: from above, barbels sit on either side of the mouth
|
| 717 |
// apart and flare each whisker further out as the fish "rotates" to swim vertically
|
| 718 |
const rootY = side * s * (0.09 + 0.07 * ev);
|
| 719 |
c.beginPath();
|
|
@@ -723,7 +762,7 @@ class Fish {
|
|
| 723 |
const bx = rootX - L * (0.25 + 0.65 * spd) * t;
|
| 724 |
const by = rootY + side * L * (0.42 + 0.3 * ev) * t * t;
|
| 725 |
const amp = s * 0.17 * t * (0.35 + 0.65 * spd);
|
| 726 |
-
const off =
|
| 727 |
c.lineTo(bx, by + off);
|
| 728 |
}
|
| 729 |
c.stroke();
|
|
@@ -733,13 +772,13 @@ class Fish {
|
|
| 733 |
|
| 734 |
// v26:
|
| 735 |
|
| 736 |
-
// body
|
| 737 |
const g = this.bodyGrad; // precomputed in constructor (static colors, local coords)
|
| 738 |
const NSEG = 26; // outline samples for a smooth curve
|
| 739 |
// wave offset at any x along the body: rigid at head, full flex at tail.
|
| 740 |
// Shared with the dorsal fin so it stays glued to the moving back (v4)
|
| 741 |
const wy = x => Math.sin(x * waveK + this.phase) * waveAmp * ((s - x) / (2 * s));
|
| 742 |
-
// pelvic fin
|
| 743 |
if (this.hasPelvicFin) {
|
| 744 |
try {
|
| 745 |
c.fillStyle = this.pevFillStyle;
|
|
@@ -755,7 +794,7 @@ class Fish {
|
|
| 755 |
console.error('Pelvic fin error:', e);
|
| 756 |
}
|
| 757 |
}
|
| 758 |
-
// // Build the body outline once as a Path2D
|
| 759 |
const bodyPath = new Path2D();
|
| 760 |
for (let i = 0; i <= NSEG; i++) { // top edge: head (+s) -> tail (-s)
|
| 761 |
const x = s - (i / NSEG) * 2 * s;
|
|
@@ -772,7 +811,7 @@ class Fish {
|
|
| 772 |
c.fillStyle = g;
|
| 773 |
c.fill(bodyPath);
|
| 774 |
|
| 775 |
-
// v15: stripes first when both are present
|
| 776 |
// spots scatter on top. Each is clipped to the body so nothing spills, and both
|
| 777 |
// ride the traveling wave (wy) so they flex with the undulating body, drawn
|
| 778 |
// BEFORE the additive sheen so the iridescent shimmer still plays across them.
|
|
@@ -786,19 +825,19 @@ class Fish {
|
|
| 786 |
const H = s * 1.35; // vertical span (clipped by the body outline)
|
| 787 |
c.fillStyle = st.style;
|
| 788 |
c.beginPath();
|
| 789 |
-
// left edge, top
|
| 790 |
for (let j = 0; j <= N; j++) {
|
| 791 |
const t = j / N;
|
| 792 |
-
const cx = sx +
|
| 793 |
-
const hwL = hw0 * (1 + st.tAmp *
|
| 794 |
const xL = cx - hwL, yL = wy(xL) + (t - 0.5) * H;
|
| 795 |
if (j === 0) c.moveTo(xL, yL); else c.lineTo(xL, yL);
|
| 796 |
}
|
| 797 |
-
// right edge, bottom
|
| 798 |
for (let j = N; j >= 0; j--) {
|
| 799 |
const t = j / N;
|
| 800 |
-
const cx = sx +
|
| 801 |
-
const hwR = hw0 * (1 + st.tAmp *
|
| 802 |
const xR = cx + hwR, yR = wy(xR) + (t - 0.5) * H;
|
| 803 |
c.lineTo(xR, yR);
|
| 804 |
}
|
|
@@ -808,7 +847,7 @@ class Fish {
|
|
| 808 |
c.restore();
|
| 809 |
}
|
| 810 |
|
| 811 |
-
// v18: koi patches
|
| 812 |
// traveling wave (wy) so they flex with the undulating body, drawn BEFORE the
|
| 813 |
// additive sheen so the iridescent shimmer still plays across them.
|
| 814 |
if (this.koiPatches) {
|
|
@@ -824,7 +863,7 @@ class Fish {
|
|
| 824 |
c.restore();
|
| 825 |
}
|
| 826 |
|
| 827 |
-
// v13: random spots on ~20% of fish (or over the stripes for the 10% mixed)
|
| 828 |
// clipped to the body so they never spill, riding the traveling wave (wy) so they
|
| 829 |
// flex with the undulating body, and drawn BEFORE the additive sheen so the
|
| 830 |
// iridescent shimmer still plays across them.
|
|
@@ -841,12 +880,12 @@ class Fish {
|
|
| 841 |
c.restore();
|
| 842 |
}
|
| 843 |
|
| 844 |
-
// v7: efficient iridescence
|
| 845 |
// so scales appear to catch the light while moving. One gradient + one fill.
|
| 846 |
const shimmer = Math.sin(this.phase * 1.6) * 0.5 + 0.5; // 0..1 as it swims
|
| 847 |
const iridHue = this.hue + 40 * Math.sin(this.phase * 2.3); // hue drifts with phase
|
| 848 |
// PERF: cache the sheen gradient. Its inputs move smoothly, so quantize them to a
|
| 849 |
-
// tiny grid (1/31 shimmer steps,
|
| 850 |
// skips one createLinearGradient + 3 addColorStop calls on most frames.
|
| 851 |
const igKey = Math.round(shimmer * 31) * 512 + (Math.round(iridHue) + 64);
|
| 852 |
if (igKey !== this._igKey || !this._igGrad) {
|
|
@@ -869,11 +908,11 @@ class Fish {
|
|
| 869 |
c.ellipse(0, s * 0.14, s * 0.82, s * 0.24, 0, 0, Math.PI * 2);
|
| 870 |
c.fill();
|
| 871 |
|
| 872 |
-
// dorsal fin
|
| 873 |
// never disconnects as the back undulates (v4)
|
| 874 |
-
// v17: matches the fish's own color instead of a fixed blue
|
| 875 |
// sprout mismatched blue fins
|
| 876 |
-
// v25: as the fish turns to swim vertically (
|
| 877 |
// "on top of the fish" to "in the middle" (centerline), matching the top-down view
|
| 878 |
c.fillStyle = this.dorsalFillStyle;
|
| 879 |
c.beginPath();
|
|
@@ -885,7 +924,7 @@ class Fish {
|
|
| 885 |
c.closePath();
|
| 886 |
c.fill();
|
| 887 |
|
| 888 |
-
// v24: both eyes
|
| 889 |
// above: the pair spreads wider on either side of the head and each eye squashes
|
| 890 |
// into a flatter ellipse (perspective), instead of two overlapping circles.
|
| 891 |
{
|
|
@@ -901,10 +940,10 @@ class Fish {
|
|
| 901 |
c.ellipse(s * 0.61, yNear, s * 0.055, s * pupRY, 0, 0, Math.PI * 2);
|
| 902 |
c.fill();
|
| 903 |
|
| 904 |
-
// far-side eye fades in as we see more of the back (Ecco-style top view)
|
| 905 |
// two eyes flanking the head when the fish turns to swim vertically.
|
| 906 |
if (ev > 0.03) {
|
| 907 |
-
const yFar = s * (0.16 + 0.08 * ev); // far eye slides outward too
|
| 908 |
c.save();
|
| 909 |
// Ease in the far-side eye so it takes longer to appear (25% slower fade-in)
|
| 910 |
c.globalAlpha = Math.pow(ev, 1.25) * 0.9;
|
|
@@ -920,7 +959,7 @@ class Fish {
|
|
| 920 |
}
|
| 921 |
}
|
| 922 |
|
| 923 |
-
// v21: near-side whisker
|
| 924 |
// (The far-side whisker is drawn before the body, above.) Together they form a
|
| 925 |
// pair from the snout on ~10% of fish, in one of three lengths, with loose ribbons
|
| 926 |
// flowing in the current and a traveling wave rippling down their length. Drawn at
|
|
@@ -936,7 +975,7 @@ class Fish {
|
|
| 936 |
{
|
| 937 |
const side = 1; // near side (in front of the body)
|
| 938 |
const rootX = s * 0.6;
|
| 939 |
-
// v24: same spread/flare as the far whisker
|
| 940 |
const rootY = side * s * (0.09 + 0.07 * ev);
|
| 941 |
c.beginPath();
|
| 942 |
c.moveTo(rootX, rootY);
|
|
@@ -945,7 +984,7 @@ class Fish {
|
|
| 945 |
const bx = rootX - L * (0.25 + 0.65 * spd) * t;
|
| 946 |
const by = rootY + side * L * (0.42 + 0.3 * ev) * t * t;
|
| 947 |
const amp = s * 0.17 * t * (0.35 + 0.65 * spd);
|
| 948 |
-
const off =
|
| 949 |
c.lineTo(bx, by + off);
|
| 950 |
}
|
| 951 |
c.stroke();
|
|
@@ -955,7 +994,7 @@ class Fish {
|
|
| 955 |
|
| 956 |
c.restore();
|
| 957 |
|
| 958 |
-
// v17/v23: overhead light
|
| 959 |
// Drawn in local coords (draw() already positioned + rotated us) so it rides with
|
| 960 |
// the body and foreshortens correctly instead of being double-offset.
|
| 961 |
{
|
|
@@ -976,7 +1015,7 @@ class Fish {
|
|
| 976 |
}
|
| 977 |
}
|
| 978 |
|
| 979 |
-
// v7: helper
|
| 980 |
function strokeArc(c, pts) {
|
| 981 |
c.beginPath();
|
| 982 |
c.moveTo(pts[0][0], pts[0][1]);
|
|
@@ -984,7 +1023,7 @@ function strokeArc(c, pts) {
|
|
| 984 |
c.stroke();
|
| 985 |
}
|
| 986 |
|
| 987 |
-
// v19: each bubble gets a subtle random tint
|
| 988 |
function bubbleColor() {
|
| 989 |
const t = Math.random() * 0.85; // keep it subtle, most stay near-white
|
| 990 |
const r = Math.round(255 - (255 - 196) * t);
|
|
@@ -1018,7 +1057,7 @@ class Bubble {
|
|
| 1018 |
}
|
| 1019 |
update(dt, t) {
|
| 1020 |
this.y -= this.speed * dt;
|
| 1021 |
-
this.x +=
|
| 1022 |
if (this.y < -12) { if (this.isMouth) this.dead = true; else this.reset(false); }
|
| 1023 |
}
|
| 1024 |
draw(c) {
|
|
@@ -1055,11 +1094,11 @@ function buildScene() {
|
|
| 1055 |
rocks = [];
|
| 1056 |
sandHeights = [];
|
| 1057 |
sandParticles = []; // v22: fresh bed, no leftover grains
|
| 1058 |
-
raySpritesBuiltFor = null; // v23: beam sprites are sized to the old canvas
|
| 1059 |
grass = []; // v10.1: clear on rebuild so resize/F11 doesn't stack a second row
|
| 1060 |
fgGrass = []; // v12: same reset for the foreground layer (resize/F11 safety)
|
| 1061 |
|
| 1062 |
-
// ALT: rolling hills instead of a flat bed
|
| 1063 |
// (broad swells + mid undulations + fine ripples), re-rolled on every refresh.
|
| 1064 |
const sandTop = H - Math.max(60, H * 0.12);
|
| 1065 |
const hillUp = rand(34, 58); // tallest crest rises this far above the baseline
|
|
@@ -1075,13 +1114,13 @@ function buildScene() {
|
|
| 1075 |
for (const wv of waves) v += Math.sin((x / wv.wl) * Math.PI * 2 + wv.ph) * wv.amp;
|
| 1076 |
raw.push(v);
|
| 1077 |
}
|
| 1078 |
-
// Rescale so the extremes land exactly at the chosen limits
|
| 1079 |
let lo = Infinity, hi = -Infinity;
|
| 1080 |
for (const v of raw) { if (v < lo) lo = v; if (v > hi) hi = v; }
|
| 1081 |
const span = Math.max(0.001, hi - lo);
|
| 1082 |
sandHeights.length = 0;
|
| 1083 |
for (const v of raw) {
|
| 1084 |
-
const off = -hillUp + ((hi - v) / span) * (hillUp + hillDown); // crest
|
| 1085 |
sandHeights.push(sandTop + off);
|
| 1086 |
}
|
| 1087 |
|
|
@@ -1102,17 +1141,17 @@ function buildScene() {
|
|
| 1102 |
hue,
|
| 1103 |
width: rand(4, 8),
|
| 1104 |
touch: 0,
|
| 1105 |
-
// v15: organic rest curve
|
| 1106 |
// instead of standing perfectly straight. Also fed into the physics (updatePlants)
|
| 1107 |
-
// so fish push the stalk where it actually looks. Weighted by f
|
| 1108 |
cA1: rand(5, 12), cF1: rand(0.9, 1.8), cP1: rand(0, Math.PI * 2),
|
| 1109 |
cA2: rand(2, 6), cF2: rand(2.2, 3.6), cP2: rand(0, Math.PI * 2),
|
| 1110 |
// v17: secondary wobble - a finer inner shimmer for unique motion per stalk
|
| 1111 |
sAmp: rand(2, 5), // amplitude of the secondary wave
|
| 1112 |
sFreq: rand(3.5, 6.5), // higher frequency than primary sway
|
| 1113 |
sPh: rand(0, Math.PI * 2), // phase offset
|
| 1114 |
-
tipW: rand(1.4, 2.6), // v15: blade tapers from width
|
| 1115 |
-
// PERF: stroke styles precomputed once (hue is fixed per stalk)
|
| 1116 |
// used to rebuild both template strings every frame for every stalk.
|
| 1117 |
stalkStyle: `hsla(${hue},55%,36%,0.92)`,
|
| 1118 |
branchStyle: `hsla(${hue + 8},60%,46%,0.9)`,
|
|
@@ -1120,7 +1159,7 @@ function buildScene() {
|
|
| 1120 |
off: new Float32Array(9), // lateral bend per segment (fish push leaves aside)
|
| 1121 |
offV: new Float32Array(9), // velocity of that bend
|
| 1122 |
branches: [],
|
| 1123 |
-
// PERF: stalk points preallocated once
|
| 1124 |
// in place instead of pushing fresh {x,y} pairs every frame (GC churn)
|
| 1125 |
points: Array.from({ length: 9 }, () => ({ x: 0, y: 0 })) // segs+1; can't read st inside its own initializer
|
| 1126 |
};
|
|
@@ -1136,13 +1175,13 @@ function buildScene() {
|
|
| 1136 |
flowers: Math.random() < 0.3 ? (() => { // ~30% of branches bloom with 1-3 heads (v3)
|
| 1137 |
const n = 1 + ((Math.random() * 3) | 0); // 1..3 flowers per branch
|
| 1138 |
return Array.from({ length: n }, () => {
|
| 1139 |
-
const hue = (Math.random() * 360) | 0; // colourful
|
| 1140 |
return {
|
| 1141 |
t: rand(0.45, 1), // where along the branch it sits (tip zone)
|
| 1142 |
hue,
|
| 1143 |
r: rand(4, 7), // head size
|
| 1144 |
petals: 5 + ((Math.random() * 3) | 0), // 5..7 petals
|
| 1145 |
-
// PERF: paint styles precomputed once
|
| 1146 |
// these strings per petal, per frame.
|
| 1147 |
petalFill: `hsla(${hue},85%,64%,0.95)`,
|
| 1148 |
centerFill: `hsla(${(hue + 45) % 360},92%,78%,0.95)`
|
|
@@ -1155,7 +1194,7 @@ function buildScene() {
|
|
| 1155 |
}
|
| 1156 |
}
|
| 1157 |
|
| 1158 |
-
// v4: blurry background seaweed
|
| 1159 |
// Pre-rendered to offscreen canvases with blur at build time (no runtime cost).
|
| 1160 |
const nBackClusters = Math.max(4, Math.floor(W / 200));
|
| 1161 |
for (let i = 0; i < nBackClusters; i++) {
|
|
@@ -1164,7 +1203,7 @@ function buildScene() {
|
|
| 1164 |
const nStalks = 2 + ((Math.random() * 3) | 0); // 2..4 stalks per bundle
|
| 1165 |
for (let j = 0; j < nStalks; j++) {
|
| 1166 |
const hue = clusterHue + rand(-15, 15);
|
| 1167 |
-
// v4.3: depth split
|
| 1168 |
const near = Math.random() < 0.49;
|
| 1169 |
const w = near ? rand(2.5, 5) : rand(3, 6);
|
| 1170 |
const tw = near ? rand(1.2, 2.4) : rand(1.5, 3);
|
|
@@ -1264,7 +1303,7 @@ function buildScene() {
|
|
| 1264 |
}
|
| 1265 |
}
|
| 1266 |
|
| 1267 |
-
// v5: ultra-far pre-rendered blurry seaweed
|
| 1268 |
const nFarClusters = Math.max(6, Math.floor(W / 180)); // 50% more than previous far count
|
| 1269 |
for (let i = 0; i < nFarClusters; i++) {
|
| 1270 |
const cx = rand(20, W - 20);
|
|
@@ -1307,7 +1346,7 @@ function buildScene() {
|
|
| 1307 |
const nGrassTufts = Math.max(50, Math.floor(W / 9));
|
| 1308 |
for (let i = 0; i < nGrassTufts; i++) {
|
| 1309 |
const gx = rand(8, W - 8);
|
| 1310 |
-
const gy = sandAt(gx) + rand(2, 9); // moved DOWN so every blade roots at/below the sand line
|
| 1311 |
const baseHue = rand(95, 155);
|
| 1312 |
const blades = 2 + ((Math.random() * 4) | 0); // 2..5 blades per tuft
|
| 1313 |
for (let b = 0; b < blades; b++) {
|
|
@@ -1315,7 +1354,7 @@ function buildScene() {
|
|
| 1315 |
grass.push({
|
| 1316 |
x: gx + rand(-7, 7),
|
| 1317 |
y: gy,
|
| 1318 |
-
h: rand(12, 85), // v4: more height variability
|
| 1319 |
lean: rand(-0.35, 0.35), // base tilt from vertical
|
| 1320 |
phase: rand(0, Math.PI * 2),
|
| 1321 |
speed: rand(1.3, 2.8), // quicker, finer sway than seaweed
|
|
@@ -1331,14 +1370,15 @@ function buildScene() {
|
|
| 1331 |
sAmp: rand(1.5, 3.5),
|
| 1332 |
sFreq: rand(4.5, 8),
|
| 1333 |
sPh: rand(0, Math.PI * 2),
|
| 1334 |
-
// PERF: per-blade scratch points
|
| 1335 |
// allocating a fresh pts array + point objects every frame (GC churn)
|
| 1336 |
-
pts: Array.from({ length: bladeSegs + 1 }, () => ({ x: 0, y: 0 }))
|
| 1337 |
-
|
|
|
|
| 1338 |
}
|
| 1339 |
}
|
| 1340 |
|
| 1341 |
-
// v12: foreground grass
|
| 1342 |
// drawn last and foreshortened. It grows in the low band below the sand line, i.e. the
|
| 1343 |
// zone fish never reach, and sways with the water like the other flora but has no
|
| 1344 |
// fish-push physics (purely decorative). ALT-FG: all sizes halved vs the original fg.
|
|
@@ -1347,7 +1387,7 @@ function buildScene() {
|
|
| 1347 |
for (let i = 0; i < nFgClusters; i++) {
|
| 1348 |
const cx = rand(-10, W + 10);
|
| 1349 |
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
|
| 1350 |
-
const baseHue = rand(46, 76); // v12.1: yellowish-green
|
| 1351 |
const nBlades = 5 + ((Math.random() * 4) | 0); // 5..8 blades per clump (doubled density)
|
| 1352 |
for (let b = 0; b < nBlades; b++) {
|
| 1353 |
const fgSegs = 4 + ((Math.random() * 2) | 0); // 4-5 segments for tapered outline
|
|
@@ -1369,18 +1409,19 @@ function buildScene() {
|
|
| 1369 |
sAmp: rand(2, 4.5),
|
| 1370 |
sFreq: rand(3.8, 6.5),
|
| 1371 |
sPh: rand(0, Math.PI * 2),
|
| 1372 |
-
// PERF: per-blade scratch points
|
| 1373 |
pts: Array.from({ length: fgSegs + 1 }, () => ({ x: 0, y: 0 })),
|
| 1374 |
// v28: ~40% of blades get a gentle blur (pre-rendered sprite) for depth
|
| 1375 |
blurred: Math.random() < 0.4,
|
| 1376 |
sprite: null,
|
| 1377 |
-
spriteScale: 1
|
| 1378 |
-
|
|
|
|
| 1379 |
}
|
| 1380 |
}
|
| 1381 |
buildFgGrassSprites(); // v28: bake the defocused-blade sprites once (free at runtime)
|
| 1382 |
|
| 1383 |
-
// PERF: pool the plantPoints collision objects too
|
| 1384 |
// in place instead of allocating one per stalk/branch point every frame.
|
| 1385 |
let pcap = 8;
|
| 1386 |
for (const w of seaweeds) pcap += (w.segs + 1) + w.branches.length * 2;
|
|
@@ -1390,7 +1431,7 @@ function buildScene() {
|
|
| 1390 |
for (let i = 0; i < nFish; i++) fishes.push(new Fish());
|
| 1391 |
|
| 1392 |
const nAmbient = Math.round(26 * 0.75); // v10: ambient bubbles reduced 25% (fish production unchanged)
|
| 1393 |
-
for (let i = 0; i < nAmbient; i++)
|
| 1394 |
|
| 1395 |
for (let i = 0; i < 45; i++) {
|
| 1396 |
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) });
|
|
@@ -1403,7 +1444,7 @@ function buildScene() {
|
|
| 1403 |
rays.push({
|
| 1404 |
x: rand(0, W), w: rand(80, 210), tilt: rand(-0.35, 0.35),
|
| 1405 |
speed: rand(0.06, 0.14) * (Math.random() < 0.5 ? 1 : -1), alpha: rand(0.04, 0.09), blur,
|
| 1406 |
-
// undulation
|
| 1407 |
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),
|
| 1408 |
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)
|
| 1409 |
});
|
|
@@ -1412,7 +1453,7 @@ function buildScene() {
|
|
| 1412 |
const nRocks = Math.max(3, Math.floor(W / 320));
|
| 1413 |
for (let i = 0; i < nRocks; i++) {
|
| 1414 |
const r = rand(14, 34);
|
| 1415 |
-
// jagged outline
|
| 1416 |
const nPts = 8 + ((Math.random() * 5) | 0); // 8..12 vertices
|
| 1417 |
const pts = [];
|
| 1418 |
for (let k = 0; k < nPts; k++) {
|
|
@@ -1477,7 +1518,7 @@ function nightFactor(t) {
|
|
| 1477 |
|
| 1478 |
// Per-fish night curve (v10): shifts each fish's dusk/dawn fades by its own early/normal/late
|
| 1479 |
// personality so they don't all sleep or wake in unison. Full day and full night are shared
|
| 1480 |
-
// by everyone
|
| 1481 |
function personalNight(t, f) {
|
| 1482 |
const p = ((t / CYCLE) % 1 + 1) % 1;
|
| 1483 |
if (p < DUSK_START) return 0; // bright day: all awake
|
|
@@ -1509,16 +1550,16 @@ function drawBackground(light) {
|
|
| 1509 |
ctx.fillRect(0, 0, W, H);
|
| 1510 |
}
|
| 1511 |
|
| 1512 |
-
// ============ Light rays
|
| 1513 |
// The old version applied a canvas blur() filter to every beam on every frame;
|
| 1514 |
// canvas filters force slow CPU rasterization over large regions, which tanked FPS.
|
| 1515 |
// Now each beam's shape is rendered ONCE into its own offscreen sprite (blur + the
|
| 1516 |
// vertical fade baked in at build time), and per frame we just blit that sprite in
|
| 1517 |
-
// horizontal slices with the same undulation offsets as before
|
| 1518 |
// edges, zero filters at runtime.
|
| 1519 |
const RAY_SEGS = 18; // vertical resolution of each beam's spine (at build time)
|
| 1520 |
-
const RAY_SLICES = 10; // horizontal slices per beam used for the wobble (per frame)
|
| 1521 |
-
const RAY_SPRITE_SCALE = 0.5; // sprites stored at half resolution
|
| 1522 |
const RAY_PAD = 60; // room for the blur to spill past the beam edge
|
| 1523 |
let raySpritesBuiltFor = null;
|
| 1524 |
let rayTintRGB = null; // the light.ray color used when building tinted sprites (perf: rebuild only on big shifts)
|
|
@@ -1554,7 +1595,7 @@ function buildRaySprites() {
|
|
| 1554 |
const full = document.createElement("canvas");
|
| 1555 |
full.width = bw; full.height = bh;
|
| 1556 |
const fc = full.getContext("2d");
|
| 1557 |
-
fc.translate(-xMinL + RAY_PAD, -yTop0 + RAY_PAD); // local beam coords
|
| 1558 |
if (r.blur > 0) { try { fc.filter = `blur(${r.blur}px)`; } catch (e) {} }
|
| 1559 |
|
| 1560 |
const poly = (a, b) => {
|
|
@@ -1612,7 +1653,7 @@ function buildRaySprites() {
|
|
| 1612 |
}
|
| 1613 |
|
| 1614 |
// Cheaply re-tint the cached blurred sprites with the current light.ray color.
|
| 1615 |
-
// No blur / no shape rasterization
|
| 1616 |
// so this stays fast even during slow dusk/dawn transitions.
|
| 1617 |
function applyRayTint() {
|
| 1618 |
for (const r of rays) {
|
|
@@ -1641,7 +1682,7 @@ function drawRays(t, light) {
|
|
| 1641 |
const yTop0 = -10, ySpan = H * 0.95 + 10;
|
| 1642 |
|
| 1643 |
// PERF: re-tint the cached blurred sprites only when the light color shifts enough.
|
| 1644 |
-
// Re-tinting is cheap (a source-in fill)
|
| 1645 |
// smoothly even during slow dusk/dawn transitions instead of hitching on rebuilds.
|
| 1646 |
if (!rayTintRGB || Math.abs(rayTintRGB[0] - rr) + Math.abs(rayTintRGB[1] - rg) + Math.abs(rayTintRGB[2] - rb) > 35) {
|
| 1647 |
rayTintRGB = [rr, rg, rb];
|
|
@@ -1649,9 +1690,9 @@ function drawRays(t, light) {
|
|
| 1649 |
}
|
| 1650 |
|
| 1651 |
for (const r of rays) {
|
| 1652 |
-
// blit horizontal slices with the undulation offset at each depth
|
| 1653 |
// math as before, so the beams shimmer exactly like they always have
|
| 1654 |
-
const off =
|
| 1655 |
for (let i = 0; i < RAY_SLICES; i++) {
|
| 1656 |
const vT = i / RAY_SLICES, vB = (i + 1) / RAY_SLICES, vM = (vT + vB) / 2;
|
| 1657 |
const ya = yTop0 + vT * ySpan, yb = yTop0 + vB * ySpan;
|
|
@@ -1695,7 +1736,7 @@ function drawSand(c = ctx) {
|
|
| 1695 |
function drawRocks(c = ctx) {
|
| 1696 |
for (const rk of rocks) {
|
| 1697 |
const y = sandAt(rk.x) + rk.r * 0.35;
|
| 1698 |
-
// fully opaque
|
| 1699 |
const base = Math.round(28 + rk.tone * 46);
|
| 1700 |
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);
|
| 1701 |
g.addColorStop(0, `rgb(${base + 68},${base + 78},${base + 92})`);
|
|
@@ -1719,17 +1760,17 @@ function stalkCurveX(w, f) {
|
|
| 1719 |
|
| 1720 |
// PERF: shared sway function (was a per-stalk closure recreated every frame)
|
| 1721 |
function stalkSwayX(w, t, f) {
|
| 1722 |
-
return
|
| 1723 |
-
|
| 1724 |
-
|
| 1725 |
}
|
| 1726 |
|
| 1727 |
-
// v4.3: draw background seaweed procedurally
|
| 1728 |
// rigid sprite rotation. Far stalks get a soft low-alpha halo pass to keep their
|
| 1729 |
// "far away" look without any runtime blur filters (no slowdown).
|
| 1730 |
// v6: middle-distance seaweed using halo stroke pass (median depth between back and far)
|
| 1731 |
function drawMidSeaweeds(t) {
|
| 1732 |
-
// PERF: lineCap/lineJoin are constant
|
| 1733 |
ctx.lineCap = 'round';
|
| 1734 |
ctx.lineJoin = 'round';
|
| 1735 |
for (const w of midSeaweeds) {
|
|
@@ -1764,7 +1805,7 @@ function drawMidSeaweeds(t) {
|
|
| 1764 |
else ctx.lineTo(px + nx * hw, py + ny * hw);
|
| 1765 |
prevX = px; prevY = py;
|
| 1766 |
}
|
| 1767 |
-
// down the other side (cached points; i=n no-op point dropped
|
| 1768 |
for (let i = n - 1; i >= 0; i--) {
|
| 1769 |
const f = i / n;
|
| 1770 |
const px = pxs[i], py = pys[i];
|
|
@@ -1781,7 +1822,7 @@ function drawMidSeaweeds(t) {
|
|
| 1781 |
for (const b of w.branches) {
|
| 1782 |
const idx = Math.min(n, Math.max(1, Math.round(b.f * n)));
|
| 1783 |
const ax = pxs[idx], ay = pys[idx];
|
| 1784 |
-
const flutter =
|
| 1785 |
const bx = ax + b.side * b.len + flutter;
|
| 1786 |
const by = ay - b.up;
|
| 1787 |
ctx.beginPath();
|
|
@@ -1800,7 +1841,7 @@ function drawMidSeaweeds(t) {
|
|
| 1800 |
}
|
| 1801 |
|
| 1802 |
function drawBackSeaweeds(t) {
|
| 1803 |
-
// v6: no runtime canvas filter
|
| 1804 |
// and was the main FPS cost. Softness now comes from the halo pass below instead.
|
| 1805 |
ctx.lineCap = 'round';
|
| 1806 |
ctx.lineJoin = 'round';
|
|
@@ -1836,7 +1877,7 @@ function drawBackSeaweeds(t) {
|
|
| 1836 |
else ctx.lineTo(px + nx * hw, py + ny * hw);
|
| 1837 |
prevX = px; prevY = py;
|
| 1838 |
}
|
| 1839 |
-
// down the other side (cached points; i=n no-op point dropped
|
| 1840 |
for (let i = n - 1; i >= 0; i--) {
|
| 1841 |
const f = i / n;
|
| 1842 |
const px = pxs[i], py = pys[i];
|
|
@@ -1853,7 +1894,7 @@ function drawBackSeaweeds(t) {
|
|
| 1853 |
for (const b of w.branches) {
|
| 1854 |
const idx = Math.min(n, Math.max(1, Math.round(b.f * n)));
|
| 1855 |
const ax = pxs[idx], ay = pys[idx];
|
| 1856 |
-
const flutter =
|
| 1857 |
const bx = ax + b.side * b.len + flutter;
|
| 1858 |
const by = ay - b.up;
|
| 1859 |
ctx.beginPath();
|
|
@@ -1873,7 +1914,7 @@ function drawBackSeaweeds(t) {
|
|
| 1873 |
|
| 1874 |
// v5: pre-render ultra-far seaweed sprites with heavy blur baked in at build time
|
| 1875 |
function buildFarSeaweedSprites() {
|
| 1876 |
-
const BLUR = 7; // v5.1: reduced from 10 (33% less)
|
| 1877 |
const PAD = 40; // extra space for blur spill
|
| 1878 |
|
| 1879 |
for (const w of farSeaweeds) {
|
|
@@ -1965,7 +2006,7 @@ function buildFarSeaweedSprites() {
|
|
| 1965 |
|
| 1966 |
// v28: pre-render blurred foreground grass sprites for ~40% of blades (gentle depth effect)
|
| 1967 |
function buildFgGrassSprites() {
|
| 1968 |
-
const BLUR = 1.5; // very gentle blur
|
| 1969 |
const PAD = 8; // extra space for blur spill
|
| 1970 |
|
| 1971 |
for (const g of fgGrass) {
|
|
@@ -2044,7 +2085,7 @@ function drawFarSeaweeds(t) {
|
|
| 2044 |
|
| 2045 |
const baseY = sandAt(w.x) + 10;
|
| 2046 |
// Simple rotational sway - root stays planted, sprite rotates slightly
|
| 2047 |
-
const swayAngle =
|
| 2048 |
|
| 2049 |
ctx.save();
|
| 2050 |
// Translate to the base point (where the stalk meets the sand)
|
|
@@ -2070,7 +2111,7 @@ function updatePlants(t, dt) {
|
|
| 2070 |
const moveF = Math.min(1, fSpd / 1.2); // v9: how actively this fish is swimming
|
| 2071 |
for (let i = 1; i <= w.segs; i++) {
|
| 2072 |
const fl = i / w.segs;
|
| 2073 |
-
const px = w.x + w.off[i] + stalkCurveX(w, fl) +
|
| 2074 |
const py = sandAt(w.x) + 8 - w.h * fl;
|
| 2075 |
const dx = px - f.x, dy = py - f.y;
|
| 2076 |
const d2 = dx * dx + dy * dy;
|
|
@@ -2078,7 +2119,7 @@ function updatePlants(t, dt) {
|
|
| 2078 |
const d = Math.sqrt(d2);
|
| 2079 |
const weight = (R - d) / R * (0.35 + 0.65 * fl); // upper segments yield more
|
| 2080 |
// shove radially away from the fish + drag along its swim direction.
|
| 2081 |
-
// v9: a nearly-still fish only gently parts leaves
|
| 2082 |
w.offV[i] += ((dx / d) * 1500 * (0.25 + 0.75 * moveF) + fx * 9) * weight * dt;
|
| 2083 |
if (weight > 0.25 && moveF > 0.35) w.touch = Math.min(1, w.touch + dt * 5);
|
| 2084 |
}
|
|
@@ -2107,15 +2148,15 @@ function updatePlants(t, dt) {
|
|
| 2107 |
|
| 2108 |
if (w.touch > 0) w.touch = Math.max(0, w.touch - dt * 1.2);
|
| 2109 |
const baseY = sandAt(w.x) + 8;
|
| 2110 |
-
w.yMid = baseY - w.h * 0.5; // v6: stalk midpoint
|
| 2111 |
w.points.length = 0;
|
| 2112 |
for (let i = 0; i <= w.segs; i++) {
|
| 2113 |
const f = i / w.segs;
|
| 2114 |
// normal sway + a fast extra flinch while being pushed
|
| 2115 |
-
const sway =
|
| 2116 |
-
+
|
| 2117 |
// v17: secondary wobble - a finer inner movement for unique per-stalk motion
|
| 2118 |
-
const sWobble =
|
| 2119 |
const px = w.x + (w.off[i] || 0) + stalkCurveX(w, f) + sway + sWobble;
|
| 2120 |
const py = baseY - w.h * f;
|
| 2121 |
w.points.push({ x: px, y: py });
|
|
@@ -2123,7 +2164,7 @@ function updatePlants(t, dt) {
|
|
| 2123 |
}
|
| 2124 |
for (const b of w.branches) {
|
| 2125 |
const a = w.points[Math.min(w.segs, Math.max(1, Math.round(b.f * w.segs)))];
|
| 2126 |
-
const flutter =
|
| 2127 |
b.tx = a.x + b.side * b.len + flutter;
|
| 2128 |
b.ty = a.y - b.up;
|
| 2129 |
plantPoints.push({ x: b.tx, y: b.ty, r: 5, s: w });
|
|
@@ -2132,8 +2173,8 @@ function updatePlants(t, dt) {
|
|
| 2132 |
}
|
| 2133 |
}
|
| 2134 |
|
| 2135 |
-
// Grass blades ripple when fish swim close
|
| 2136 |
-
// v4: more reactive to fish contact
|
| 2137 |
// and slightly slower decay so the wave lingers a touch longer after a fish passes.
|
| 2138 |
function updateGrass(t, dt) {
|
| 2139 |
const night = nightFactor(t); // v6: after dark the fish drift toward the plants
|
|
@@ -2157,7 +2198,7 @@ function updateGrass(t, dt) {
|
|
| 2157 |
function drawSeaweed() {
|
| 2158 |
for (const w of seaweeds) {
|
| 2159 |
const pts = w.points, n = pts.length;
|
| 2160 |
-
// v15: main stipe as a tapered leaf-blade
|
| 2161 |
// points, wide at the root and thinning to a soft point up top. No straight-line
|
| 2162 |
// segments anywhere; the stalk's own curve comes from the sway + rest-curve baked
|
| 2163 |
// into those points in updatePlants.
|
|
@@ -2167,17 +2208,17 @@ function drawSeaweed() {
|
|
| 2167 |
const p = pts[i];
|
| 2168 |
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
|
| 2169 |
let dx = b.x - a.x, dy = b.y - a.y;
|
| 2170 |
-
const dl = Math.
|
| 2171 |
const nx = -dy / dl, ny = dx / dl; // perpendicular to the local stalk direction
|
| 2172 |
const f = i / (n - 1);
|
| 2173 |
-
const hw = (w.width * (1 - f) + w.tipW * f) / 2; // half-width: base
|
| 2174 |
if (i === 0) ctx.moveTo(p.x + nx * hw, p.y + ny * hw);
|
| 2175 |
else ctx.lineTo(p.x + nx * hw, p.y + ny * hw);
|
| 2176 |
}
|
| 2177 |
{ // rounded cap at the tip
|
| 2178 |
const tp = pts[n - 1], pp = pts[n - 2];
|
| 2179 |
let dx = tp.x - pp.x, dy = tp.y - pp.y;
|
| 2180 |
-
const dl = Math.
|
| 2181 |
const nx = -dy / dl, ny = dx / dl;
|
| 2182 |
const hwT = w.tipW / 2;
|
| 2183 |
ctx.quadraticCurveTo(tp.x + dx, tp.y + dy * 0.6, tp.x - nx * hwT, tp.y - ny * hwT);
|
|
@@ -2186,7 +2227,7 @@ function drawSeaweed() {
|
|
| 2186 |
const p = pts[i];
|
| 2187 |
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
|
| 2188 |
let dx = b.x - a.x, dy = b.y - a.y;
|
| 2189 |
-
const dl = Math.
|
| 2190 |
const nx = -dy / dl, ny = dx / dl;
|
| 2191 |
const f = i / (n - 1);
|
| 2192 |
const hw = (w.width * (1 - f) + w.tipW * f) / 2;
|
|
@@ -2207,7 +2248,7 @@ function drawSeaweed() {
|
|
| 2207 |
// side branches curling up and out from the stalk
|
| 2208 |
for (const b of w.branches) {
|
| 2209 |
const a = w.points[Math.min(w.segs, Math.max(1, Math.round(b.f * w.segs)))];
|
| 2210 |
-
// v15: S-curved frond
|
| 2211 |
// to the tip instead of one plain quadratic bow (same endpoints as before)
|
| 2212 |
const c1x = a.x + b.side * b.len * 0.3;
|
| 2213 |
const c1y = a.y - b.up * 0.75;
|
|
@@ -2220,7 +2261,7 @@ function drawSeaweed() {
|
|
| 2220 |
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, b.tx, b.ty);
|
| 2221 |
ctx.stroke();
|
| 2222 |
|
| 2223 |
-
// colourful flower heads blooming along the branch
|
| 2224 |
if (b.flowers.length) {
|
| 2225 |
for (const fl of b.flowers) {
|
| 2226 |
const u = fl.t, iu = 1 - u; // point on the branch's cubic curve (v15)
|
|
@@ -2253,7 +2294,7 @@ function drawFlowerHead(x, y, fl) {
|
|
| 2253 |
|
| 2254 |
function drawGrass(t) {
|
| 2255 |
for (const g of grass) {
|
| 2256 |
-
// v16: tapered multi-segment blade
|
| 2257 |
const amp = 3 + g.ripple * 13;
|
| 2258 |
const n = g.segs + 1; // number of points along the blade
|
| 2259 |
const pts = [];
|
|
@@ -2262,25 +2303,25 @@ function drawGrass(t) {
|
|
| 2262 |
// organic rest-curve: each blade has its own natural bend (like seaweed)
|
| 2263 |
const curveX = f * g.cA1 * Math.sin(f * g.cF1 + g.cP1);
|
| 2264 |
// constant sway, amplified while rippling from a passing fish
|
| 2265 |
-
const sway =
|
| 2266 |
// v17: secondary wobble - finer inner movement for unique per-blade motion
|
| 2267 |
-
const sWobble =
|
| 2268 |
pts.push({
|
| 2269 |
x: g.x + g.lean * g.h * f + curveX + sway + sWobble,
|
| 2270 |
y: g.y - g.h * f * (1 - g.ripple * 0.06)
|
| 2271 |
});
|
| 2272 |
}
|
| 2273 |
// draw tapered outline around the physics points
|
| 2274 |
-
ctx.fillStyle =
|
| 2275 |
ctx.beginPath();
|
| 2276 |
for (let i = 0; i < n; i++) {
|
| 2277 |
const p = pts[i];
|
| 2278 |
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
|
| 2279 |
let dx = b.x - a.x, dy = b.y - a.y;
|
| 2280 |
-
const dl = Math.
|
| 2281 |
const nx = -dy / dl, ny = dx / dl; // perpendicular to local blade direction
|
| 2282 |
const f = i / (n - 1);
|
| 2283 |
-
const hw = (g.width * (1 - f) + 0.8 * f) / 2; // half-width: base
|
| 2284 |
if (i === 0) ctx.moveTo(p.x + nx * hw, p.y + ny * hw);
|
| 2285 |
else ctx.lineTo(p.x + nx * hw, p.y + ny * hw);
|
| 2286 |
}
|
|
@@ -2288,7 +2329,7 @@ function drawGrass(t) {
|
|
| 2288 |
{
|
| 2289 |
const tp = pts[n - 1], pp = pts[n - 2];
|
| 2290 |
let dx = tp.x - pp.x, dy = tp.y - pp.y;
|
| 2291 |
-
const dl = Math.
|
| 2292 |
const nx = -dy / dl, ny = dx / dl;
|
| 2293 |
const hwT = 0.4; // tip half-width
|
| 2294 |
ctx.quadraticCurveTo(tp.x + dx, tp.y + dy * 0.6, tp.x - nx * hwT, tp.y - ny * hwT);
|
|
@@ -2297,7 +2338,7 @@ function drawGrass(t) {
|
|
| 2297 |
const p = pts[i];
|
| 2298 |
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
|
| 2299 |
let dx = b.x - a.x, dy = b.y - a.y;
|
| 2300 |
-
const dl = Math.
|
| 2301 |
const nx = -dy / dl, ny = dx / dl;
|
| 2302 |
const f = i / (n - 1);
|
| 2303 |
const hw = (g.width * (1 - f) + 0.8 * f) / 2;
|
|
@@ -2308,16 +2349,16 @@ function drawGrass(t) {
|
|
| 2308 |
}
|
| 2309 |
}
|
| 2310 |
|
| 2311 |
-
// v12: foreground grass sways with the water
|
| 2312 |
// Drawn on top of everything else (it is closest to the viewer). Each blade gets a
|
| 2313 |
// thin lighter inner stroke over a darker body for a lush, voluminous "closer" look.
|
| 2314 |
function drawFgGrass(t) {
|
| 2315 |
for (const g of fgGrass) {
|
| 2316 |
-
// v28: defocused blades
|
| 2317 |
// around its base (same trick as the ultra-far seaweed). The baked blur hides the
|
| 2318 |
// simplified motion, and this path is far cheaper than per-frame canvas filters.
|
| 2319 |
if (g.sprite) {
|
| 2320 |
-
const swayAngle =
|
| 2321 |
ctx.save();
|
| 2322 |
ctx.translate(g.x, g.y);
|
| 2323 |
ctx.rotate(swayAngle);
|
|
@@ -2334,25 +2375,25 @@ function drawFgGrass(t) {
|
|
| 2334 |
// organic rest-curve: each blade has its own natural bend
|
| 2335 |
const curveX = f * g.cA1 * Math.sin(f * g.cF1 + g.cP1);
|
| 2336 |
// constant sway, weighted toward the tip
|
| 2337 |
-
const sway =
|
| 2338 |
// v17: secondary wobble - finer inner movement for unique per-blade motion
|
| 2339 |
-
const sWobble =
|
| 2340 |
pts.push({
|
| 2341 |
x: g.x + g.lean * g.h * f + curveX + sway + sWobble,
|
| 2342 |
y: g.y - g.h * f
|
| 2343 |
});
|
| 2344 |
}
|
| 2345 |
// dark body of the blade (tapered outline)
|
| 2346 |
-
ctx.fillStyle =
|
| 2347 |
ctx.beginPath();
|
| 2348 |
for (let i = 0; i < n; i++) {
|
| 2349 |
const p = pts[i];
|
| 2350 |
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
|
| 2351 |
let dx = b.x - a.x, dy = b.y - a.y;
|
| 2352 |
-
const dl = Math.
|
| 2353 |
const nx = -dy / dl, ny = dx / dl;
|
| 2354 |
const f = i / (n - 1);
|
| 2355 |
-
const hw = (g.width * (1 - f) + 1.2 * f) / 2; // half-width: base
|
| 2356 |
if (i === 0) ctx.moveTo(p.x + nx * hw, p.y + ny * hw);
|
| 2357 |
else ctx.lineTo(p.x + nx * hw, p.y + ny * hw);
|
| 2358 |
}
|
|
@@ -2360,7 +2401,7 @@ function drawFgGrass(t) {
|
|
| 2360 |
{
|
| 2361 |
const tp = pts[n - 1], pp = pts[n - 2];
|
| 2362 |
let dx = tp.x - pp.x, dy = tp.y - pp.y;
|
| 2363 |
-
const dl = Math.
|
| 2364 |
const nx = -dy / dl, ny = dx / dl;
|
| 2365 |
const hwT = 0.6; // tip half-width
|
| 2366 |
ctx.quadraticCurveTo(tp.x + dx, tp.y + dy * 0.6, tp.x - nx * hwT, tp.y - ny * hwT);
|
|
@@ -2369,7 +2410,7 @@ function drawFgGrass(t) {
|
|
| 2369 |
const p = pts[i];
|
| 2370 |
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
|
| 2371 |
let dx = b.x - a.x, dy = b.y - a.y;
|
| 2372 |
-
const dl = Math.
|
| 2373 |
const nx = -dy / dl, ny = dx / dl;
|
| 2374 |
const f = i / (n - 1);
|
| 2375 |
const hw = (g.width * (1 - f) + 1.2 * f) / 2;
|
|
@@ -2379,7 +2420,7 @@ function drawFgGrass(t) {
|
|
| 2379 |
ctx.fill();
|
| 2380 |
|
| 2381 |
// lighter inner highlight for depth: a thin centerline stroke
|
| 2382 |
-
ctx.strokeStyle =
|
| 2383 |
ctx.lineWidth = g.width * 0.3;
|
| 2384 |
ctx.lineCap = "round";
|
| 2385 |
ctx.beginPath();
|
|
@@ -2433,7 +2474,7 @@ function buildGrainTile() {
|
|
| 2433 |
let grainOx = 0, grainOy = 0, lastGrainTime = 0;
|
| 2434 |
function drawGrain() {
|
| 2435 |
if (!grainPattern) return;
|
| 2436 |
-
// v17: half-speed grain
|
| 2437 |
const now = performance.now();
|
| 2438 |
if (now - lastGrainTime >= 33) { // ~30fps grain updates instead of per-frame (~60fps)
|
| 2439 |
grainOx = (Math.random() * GRAIN_TILE) | 0;
|
|
@@ -2454,14 +2495,14 @@ function drawGrain() {
|
|
| 2454 |
}
|
| 2455 |
let lastGrainDrawn = false;
|
| 2456 |
|
| 2457 |
-
// Precomputed static layers
|
| 2458 |
// rendered once to an offscreen canvas and blitted each frame (v6) instead of
|
| 2459 |
// re-drawing their gradients and paths every single frame.
|
| 2460 |
-
// PERF: half-resolution offscreen buffer for the light rays (see drawRays)
|
| 2461 |
// per-ray blur() filter is far cheaper rasterizing small areas than full-screen.
|
| 2462 |
|
| 2463 |
|
| 2464 |
-
// v24: baked current ripples
|
| 2465 |
function drawRipples(c) {
|
| 2466 |
c.save();
|
| 2467 |
c.lineCap = "round";
|
|
@@ -2486,7 +2527,7 @@ function drawRipples(c) {
|
|
| 2486 |
c.restore();
|
| 2487 |
}
|
| 2488 |
|
| 2489 |
-
// v24: baked pebble/speckle scatter
|
| 2490 |
function drawPebbles(c) {
|
| 2491 |
const n = Math.floor(W * H / 1800);
|
| 2492 |
for (let i = 0; i < n; i++) {
|
|
@@ -2516,7 +2557,7 @@ function drawPebbles(c) {
|
|
| 2516 |
}
|
| 2517 |
}
|
| 2518 |
|
| 2519 |
-
// v24: contact shadows
|
| 2520 |
function drawContactShadows(c) {
|
| 2521 |
// Faint darker band right at the sand surface (grounds the whole bed)
|
| 2522 |
// FIXED: follows the actual sand contour instead of a fixed horizontal band
|
|
@@ -2558,7 +2599,7 @@ function buildStaticLayer() {
|
|
| 2558 |
drawRipples(c2); // v24: baked current ripples (free at runtime)
|
| 2559 |
drawPebbles(c2); // v24: baked grainy pebble/speckle scatter
|
| 2560 |
drawContactShadows(c2); // v24: soft dark ellipses under rocks + surface band
|
| 2561 |
-
// NOTE: rocks are no longer baked here
|
| 2562 |
// drawn AFTER the mid-ground grass (rocks sit IN FRONT of the blades, not behind them).
|
| 2563 |
}
|
| 2564 |
// Rocks on their own layer: blitted after drawGrass() so stones read as sitting in
|
|
@@ -2577,7 +2618,7 @@ function buildRocksLayer() {
|
|
| 2577 |
let causticTile = null;
|
| 2578 |
let causticOffsetX = 0, causticOffsetY = 0;
|
| 2579 |
let lastCausticShift = 0;
|
| 2580 |
-
const CAUSTIC_TILE_SIZE = 160; // small tile
|
| 2581 |
|
| 2582 |
function buildCausticTile() {
|
| 2583 |
causticTile = document.createElement("canvas");
|
|
@@ -2676,9 +2717,9 @@ function drawLightPooling(t) {
|
|
| 2676 |
ctx.globalCompositeOperation = "screen"; // additive light
|
| 2677 |
|
| 2678 |
for (const r of rays) {
|
| 2679 |
-
// Compute where this ray hits the sand at y
|
| 2680 |
// Use the same wave math as the ray drawing to match positions
|
| 2681 |
-
const off =
|
| 2682 |
// At the sand depth (vM near the bottom), compute the x position
|
| 2683 |
const vM = 0.95; // approximate depth where sand starts
|
| 2684 |
const wave =
|
|
@@ -2744,13 +2785,13 @@ function frame(now) {
|
|
| 2744 |
drawRays(t, light);
|
| 2745 |
updateMotes(dt, t);
|
| 2746 |
|
| 2747 |
-
// v5: ultra-far pre-rendered blurry seaweed
|
| 2748 |
drawFarSeaweeds(t);
|
| 2749 |
|
| 2750 |
// v6: middle-distance seaweed with halo pass (between far and back)
|
| 2751 |
drawMidSeaweeds(t);
|
| 2752 |
|
| 2753 |
-
// v4: background seaweed
|
| 2754 |
drawBackSeaweeds(t); // v4.3: procedural multi-wave motion (no separate update pass)
|
| 2755 |
|
| 2756 |
updatePlants(t, dt);
|
|
@@ -2764,16 +2805,23 @@ function frame(now) {
|
|
| 2764 |
// Build spatial grid for collision detection
|
| 2765 |
buildGrid();
|
| 2766 |
|
| 2767 |
-
// v7: fish gently bump each other
|
| 2768 |
// center line (weighted by size), so they nudge instead of stacking on top of each other
|
| 2769 |
-
|
| 2770 |
-
|
| 2771 |
-
|
| 2772 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2773 |
let dx = b.x - a.x, dy = b.y - a.y;
|
| 2774 |
const minD = (a.size + b.size) * 0.62; // gentle: smaller than their visual length
|
| 2775 |
const d2 = dx * dx + dy * dy;
|
| 2776 |
-
if (d2 <= 0.01) { // exactly overlapping
|
| 2777 |
a.x -= rand(0.5, 1.5); a.y -= rand(0.5, 1.5);
|
| 2778 |
b.x += rand(0.5, 1.5); b.y += rand(0.5, 1.5);
|
| 2779 |
} else if (d2 < minD * minD) {
|
|
@@ -2787,8 +2835,6 @@ function frame(now) {
|
|
| 2787 |
}
|
| 2788 |
}
|
| 2789 |
}
|
| 2790 |
-
|
| 2791 |
-
// Sort fish by size for depth ordering - only re-sort every 100ms
|
| 2792 |
if (t - lastSortTime > 0.1) {
|
| 2793 |
// Sort fish by size for depth ordering - only re-sort every 100ms
|
| 2794 |
if (t - lastSortTime > 0.1) {
|
|
@@ -2826,7 +2872,7 @@ function frame(now) {
|
|
| 2826 |
const b = bubbles[i];
|
| 2827 |
b.update(dt, t);
|
| 2828 |
if (!b.dead) {
|
| 2829 |
-
// v10: a fish brushing a bubble pops it
|
| 2830 |
// Optimized: use spatial grid instead of checking all fish
|
| 2831 |
const nearby = getFishNear(b.x, b.y);
|
| 2832 |
for (const f of nearby) {
|
|
@@ -2836,11 +2882,11 @@ function frame(now) {
|
|
| 2836 |
if (dx * dx + dy * dy < rr * rr) { b.dead = true; break; }
|
| 2837 |
}
|
| 2838 |
}
|
| 2839 |
-
if (b.dead) { bubbles.splice(i, 1); continue; }
|
| 2840 |
b.draw(ctx);
|
| 2841 |
}
|
| 2842 |
|
| 2843 |
-
drawFgGrass(t); // v12: foreground grass
|
| 2844 |
|
| 2845 |
// ambient day/night wash over the whole scene
|
| 2846 |
if (light.washA > 0.005) {
|
|
@@ -2933,8 +2979,8 @@ function getPlayerInput() {
|
|
| 2933 |
const ly = Math.abs(pad.axes[1]) > GAMEPAD_DEADZONE ? pad.axes[1] : 0;
|
| 2934 |
dx += lx;
|
| 2935 |
dy += ly;
|
| 2936 |
-
if (pad.axes[3] > GAMEPAD_DEADZONE) boost = Math.max(boost, pad.axes[3]); // down
|
| 2937 |
-
if (pad.axes[3] < -GAMEPAD_DEADZONE) slow = Math.max(slow, -pad.axes[3]); // up
|
| 2938 |
break; // only the first connected pad
|
| 2939 |
}
|
| 2940 |
}
|
|
@@ -2951,12 +2997,7 @@ window.addEventListener("resize", doResize);
|
|
| 2951 |
doResize();
|
| 2952 |
requestAnimationFrame(frame);
|
| 2953 |
})();
|
| 2954 |
-
</script>
|
| 2955 |
-
|
| 2956 |
-
<div id="controls-help" style="position: fixed; bottom: 15px; left: 15px; color: rgba(255,255,255,0.7); font-family: monospace; font-size: 12px; pointer-events: none; z-index: 1000; line-height: 1.7; transition: opacity 1.5s ease;">
|
| 2957 |
-
<strong>Keyboard:</strong> F spawn · WASD steer · Shift speed up · Space slow down<br>
|
| 2958 |
-
<strong>Controller:</strong> Start spawn · Left stick steer · Right stick ↓ speed up · Right stick ↑ slow down
|
| 2959 |
-
</div>
|
| 2960 |
|
|
|
|
| 2961 |
</body>
|
| 2962 |
</html>
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Digital Aquarium (Optimized)</title>
|
| 7 |
<style>
|
| 8 |
html, body { margin:0; padding:0; height:100%; overflow:hidden; background:#02121f; }
|
| 9 |
#tank { display:block; width:100vw; height:100vh; }
|
|
|
|
| 20 |
<canvas id="tank"></canvas>
|
| 21 |
<div class="glass"></div>
|
| 22 |
<script>
|
| 23 |
+
|
| 24 |
(() => {
|
| 25 |
"use strict";
|
| 26 |
+
// ============ Aquarium v16 WHISKERS â alt-fg base + spots/stripes + whiskers (~10%) ==
|
| 27 |
+
// v22: Ecco-style rendering â fish show top/bottom view when moving vertically,
|
| 28 |
// side profile when moving horizontally, like Ecco the Dolphin's 3D perspective
|
| 29 |
// Based on v15 SPOTTY-3 (rolling hills, three tail styles, yellow-green foreground grass,
|
| 30 |
// ~20% spots / ~20% stripes / ~10% both), plus: an independent roll gives ~10% of fish
|
| 31 |
+
// whiskers (barbels) â a forward-curving pair from the snout in one of THREE lengths
|
| 32 |
// (short / medium / long), swaying gently as they swim. Whiskers are independent of the
|
| 33 |
// body markings, so a whiskered fish may also be spotted, striped, both, or plain.
|
| 34 |
// Iridescence + overhead streak unchanged.
|
|
|
|
| 48 |
|
| 49 |
const HUES = [8, 35, 46, 172, 195, 215];
|
| 50 |
const TAIL_TYPES = ['classic', 'fan', 'streamer']; // v11: three tail-fin styles, one per fish
|
| 51 |
+
// v18: koi colourations â large irregular patches on a white/ivory base. Three classic
|
| 52 |
// looks: kohaku (red/orange), sanke (red + black), golden (amber/gold). Patches are
|
| 53 |
// sampled in units of `size` like the spots, so they scale with each fish.
|
| 54 |
function genKoiPatches(type) {
|
|
|
|
| 88 |
}
|
| 89 |
let fishes = [], bubbles = [], seaweeds = [], backSeaweeds = [], midSeaweeds = [], farSeaweeds = [], motes = [], rays = [], rocks = [], grass = [], fgGrass = [];
|
| 90 |
let playerFish = null; // the fish controlled by gamepad (spawned with F key)
|
| 91 |
+
let bubblePool = [];
|
| 92 |
+
let bubblePoolSize = 0;
|
| 93 |
+
|
| 94 |
+
function getBubble() {
|
| 95 |
+
if (bubblePool.length > 0) {
|
| 96 |
+
return bubblePool.pop();
|
| 97 |
+
}
|
| 98 |
+
bubblePoolSize++;
|
| 99 |
+
return new Bubble(true);
|
| 100 |
+
}
|
| 101 |
+
function returnBubble(b) {
|
| 102 |
+
if (bubblePool.length < 50) {
|
| 103 |
+
bubblePool.push(b);
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
const GAMEPAD_DEADZONE = 0.15; // ignore small stick movements
|
| 107 |
let curNight = 0; // v7: shared night factor (dims the top-light + sheen)
|
| 108 |
+
let plantPoints = [], _ppUsed = 0; // collision points â a pooled set, reused in place
|
| 109 |
let sandHeights = [];
|
| 110 |
let sandParticles = []; // v22: grains of sand kicked up when a fast fish skims the sea floor
|
| 111 |
|
| 112 |
+
|
| 113 |
+
// v26: Trig lookup table for faster sin/cos (360 entries, 1 degree resolution)
|
| 114 |
+
const SIN_TABLE = new Float32Array(360);
|
| 115 |
+
const COS_TABLE = new Float32Array(360);
|
| 116 |
+
for (let i = 0; i < 360; i++) {
|
| 117 |
+
const rad = i * Math.PI / 180;
|
| 118 |
+
SIN_TABLE[i] = Math.sin(rad);
|
| 119 |
+
COS_TABLE[i] = Math.cos(rad);
|
| 120 |
+
}
|
| 121 |
+
function fastSin(x) {
|
| 122 |
+
// Convert radians to degrees, normalize to 0-360
|
| 123 |
+
let deg = (x * 180 / Math.PI) % 360;
|
| 124 |
+
if (deg < 0) deg += 360;
|
| 125 |
+
return SIN_TABLE[Math.floor(deg)];
|
| 126 |
+
}
|
| 127 |
+
function fastCos(x) {
|
| 128 |
+
let deg = (x * 180 / Math.PI) % 360;
|
| 129 |
+
if (deg < 0) deg += 360;
|
| 130 |
+
return COS_TABLE[Math.floor(deg)];
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
class Fish {
|
| 134 |
+
static nextId = 0;
|
| 135 |
constructor() {
|
| 136 |
+
this.id = Fish.nextId++;
|
| 137 |
+
this.size = rand(15, 42) * 1.15;
|
| 138 |
this.speed = rand(0.55, 1.7) * (this.size / 30 + 0.5) * 0.75; // v3: max speed reduced 25%
|
| 139 |
this.x = rand(60, W - 60);
|
| 140 |
this.y = rand(H * 0.15, H * 0.72);
|
|
|
|
| 169 |
this.spots = null;
|
| 170 |
this.whiskerLen = null;
|
| 171 |
}
|
| 172 |
+
// v18: color palette â silver/gray + three koi colourations (replaces dark blue & lime green)
|
| 173 |
const specialRoll = Math.random();
|
| 174 |
this.isKoi = false;
|
| 175 |
this.koiPatches = null;
|
| 176 |
if (specialRoll < 0.15) {
|
| 177 |
+
// Silver/gray fish â low saturation, medium-high lightness
|
| 178 |
this.hue = rand(200, 240); // slight blue-gray tint
|
| 179 |
this.saturation = rand(8, 18);
|
| 180 |
this.lightness = rand(60, 75);
|
| 181 |
} else if (specialRoll < 0.45) {
|
| 182 |
+
// Koi â a white/ivory base with large coloured patches; the three classic
|
| 183 |
// colourations are split roughly evenly across this 30% band.
|
| 184 |
this.isKoi = true;
|
| 185 |
const koiType = specialRoll < 0.22 ? 'kohaku' : specialRoll < 0.30 ? 'sanke' : specialRoll < 0.38 ? 'golden' : 'goldspot';
|
| 186 |
if (koiType === 'goldspot') {
|
| 187 |
// Golden orange fish with white spots
|
| 188 |
this.hue = rand(30, 42); // golden orange
|
| 189 |
+
this.saturation = rand(85, 95); // high saturation â vivid color
|
| 190 |
+
this.lightness = rand(50, 65); // medium lightness â rich gold
|
| 191 |
} else {
|
| 192 |
+
// Classic koi â white/ivory base
|
| 193 |
this.hue = rand(40, 50); // warm ivory tint for the base
|
| 194 |
+
this.saturation = rand(12, 22); // low saturation â white body
|
| 195 |
+
this.lightness = rand(88, 94); // high lightness â white/ivory base
|
| 196 |
}
|
| 197 |
this.koiPatches = genKoiPatches(koiType);
|
| 198 |
} else {
|
|
|
|
| 203 |
}
|
| 204 |
this.tailType = pick(TAIL_TYPES); // v11: random tail-fin style for variety
|
| 205 |
|
| 206 |
+
// v13/v14/v15: random body markings â one roll per fish decides the look:
|
| 207 |
// ~20% spots, ~20% tiger stripes, ~10% BOTH (spots over stripes), else plain.
|
| 208 |
// Sampled in units of `size` so they scale with the fish; stored as pre-built
|
| 209 |
+
// fill styles â zero per-frame string work.
|
| 210 |
const genSpots = () => {
|
| 211 |
const arr = [];
|
| 212 |
const nSpots = 7 + ((Math.random() * 9) | 0); // 7..15 spots
|
|
|
|
| 216 |
const rr = Math.sqrt(Math.random()) * 0.9; // uniform in ellipse, kept off the rim
|
| 217 |
let style;
|
| 218 |
if (this.saturation < 30) {
|
| 219 |
+
// v14: gray/silver fish â keep spots low-saturation to match
|
| 220 |
const spotLight = pale ? rand(75, 88) : rand(35, 50);
|
| 221 |
style = `hsla(${this.hue + rand(-24, 24)},${rand(5, 15)}%,${spotLight}%,${rand(0.35, 0.6).toFixed(2)})`;
|
| 222 |
} else if (pale) {
|
| 223 |
+
// cream/ivory spots â pearl-gourami / leopard pattern
|
| 224 |
style = `hsla(${this.hue + rand(-24, 24)},${rand(45, 70)}%,${rand(82, 94)}%,${rand(0.35, 0.6).toFixed(2)})`;
|
| 225 |
} else {
|
| 226 |
+
// dark spots â dace / leopard pattern
|
| 227 |
style = `hsla(${this.hue + rand(-18, 18)},${rand(60, 85)}%,${rand(24, 34)}%,${rand(0.45, 0.7).toFixed(2)})`;
|
| 228 |
}
|
| 229 |
arr.push({
|
|
|
|
| 236 |
return arr;
|
| 237 |
};
|
| 238 |
const genStripes = () => {
|
| 239 |
+
// v14/v14.1: tiger stripes â vertical bars across the body (betta / tiger-barb
|
| 240 |
// style), spread from just behind the head to near the tail base with organic
|
| 241 |
// jitter; each bar bends and tapers instead of being a straight line.
|
| 242 |
const arr = [];
|
| 243 |
const nStr = 5 + ((Math.random() * 4) | 0); // 5..8 bars
|
| 244 |
const darkBars = this.saturation >= 30 && Math.random() < 0.72; // v14: gray fish get light stripes
|
| 245 |
for (let i = 0; i < nStr; i++) {
|
| 246 |
+
const t = nStr > 1 ? i / (nStr - 1) : 0; // 0 behind head â 1 near tail base
|
| 247 |
let style;
|
| 248 |
if (this.saturation < 30) {
|
| 249 |
+
// v14: gray/silver fish â keep stripes low-saturation to match
|
| 250 |
const stripeLight = darkBars ? rand(35, 50) : rand(75, 88);
|
| 251 |
style = `hsla(${this.hue + rand(-14, 14)},${rand(5, 15)}%,${stripeLight}%,${rand(0.5, 0.75).toFixed(2)})`;
|
| 252 |
} else if (darkBars) {
|
| 253 |
// classic dark tiger bars
|
| 254 |
style = `hsla(${this.hue + rand(-14, 14)},${rand(65, 90)}%,${rand(18, 30)}%,${rand(0.5, 0.75).toFixed(2)})`;
|
| 255 |
} else {
|
| 256 |
+
// cream/ivory bars â pale tiger variant
|
| 257 |
style = `hsla(${this.hue + rand(-24, 24)},${rand(45, 70)}%,${rand(80, 93)}%,${rand(0.35, 0.6).toFixed(2)})`;
|
| 258 |
}
|
| 259 |
arr.push({
|
| 260 |
+
x: 0.72 - t * 1.62 + rand(-0.07, 0.07), // +0.72s â -0.90s along the body
|
| 261 |
w: rand(0.08, 0.2), // bar width, in units of s
|
| 262 |
style,
|
| 263 |
+
// v14.1: organic shape â each bar bends and tapers instead of a straight line
|
| 264 |
cAmp: rand(0.03, 0.09), // whole-bar bend amplitude (units of s)
|
| 265 |
cFreq: rand(0.8, 2.0), // waves of bend across the bar's height
|
| 266 |
cPh: rand(0, Math.PI * 2),
|
|
|
|
| 271 |
}
|
| 272 |
return arr;
|
| 273 |
};
|
| 274 |
+
// v18: koi already carry their own large patches â skip the random spots/stripes
|
| 275 |
const markRoll = this.isKoi ? 1 : Math.random();
|
| 276 |
if (markRoll < 0.2) {
|
| 277 |
this.spots = genSpots();
|
|
|
|
| 280 |
this.spots = null;
|
| 281 |
this.stripes = genStripes();
|
| 282 |
} else if (markRoll < 0.5) {
|
| 283 |
+
// v15: the mixed breed â stripes as the base pattern, spots scattered over them
|
| 284 |
this.spots = genSpots();
|
| 285 |
this.stripes = genStripes();
|
| 286 |
} else {
|
| 287 |
this.spots = null;
|
| 288 |
this.stripes = null;
|
| 289 |
}
|
| 290 |
+
// v16: whiskers (barbels) â an independent ~10% roll. A whiskered fish gets a
|
| 291 |
// forward-curving pair from the snout in one of three length tiers (~even split),
|
| 292 |
// so the tank shows short, medium and long whiskers. Independent of spots/stripes,
|
| 293 |
// so any marked fish can also wear them.
|
|
|
|
| 303 |
}
|
| 304 |
|
| 305 |
// PERF: precompute per-fish paint styles once. draw() used to rebuild these
|
| 306 |
+
// template strings every frame for every fish â now it's a property read.
|
| 307 |
this.tailFillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.92)`;
|
| 308 |
this.dorsalFillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.9)`;
|
| 309 |
this.pevFillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.8)`;
|
|
|
|
| 318 |
this.bodyGrad = bg;
|
| 319 |
|
| 320 |
this.phase = rand(0, Math.PI * 2);
|
| 321 |
+
// v9: after dark, most fish bed down low in the grass â only a small share still
|
| 322 |
// hovers by the seaweed, and each of those keeps to its own stalk (no pile-ups)
|
| 323 |
this.grassSleeper = Math.random() < 0.85;
|
| 324 |
this.seaweedPref = -1; // chosen lazily on the first night (needs built scenery)
|
|
|
|
| 344 |
this.wanderTheta = rand(0, Math.PI * 2);
|
| 345 |
this.wanderRadius = rand(35, 80);
|
| 346 |
this.wanderDist = rand(90, 170);
|
| 347 |
+
this.maxTurn = rand(1.4, 2.2); // max rad/s â organic turns, no orbiting
|
| 348 |
|
| 349 |
+
// Behavior states: fish don't swim non-stop â they cruise, hover, and rest.
|
| 350 |
this.behavior = 'swim'; // 'swim' | 'hover' | 'pause'
|
| 351 |
this.behaviorTimer = rand(4, 11); // seconds until it may change behavior
|
| 352 |
this.speedFactor = 1; // current speed multiplier (eased toward target)
|
|
|
|
| 357 |
// Occasionally puffs a tiny cluster of bubbles from its mouth
|
| 358 |
this.bubbleTimer = rand(4, 16); // seconds until the next bubble puff
|
| 359 |
|
| 360 |
+
// Body roll â banks/rolls through sharp turns like a real fish (spring-damper)
|
| 361 |
this.roll = 0; // current roll angle (rad); 0 = upright
|
| 362 |
this.rollVel = 0; // angular velocity of the roll
|
| 363 |
this.inHardTurn = false;// was it in a sharp turn last frame?
|
| 364 |
|
| 365 |
+
// v22: Ecco-style viewing angle â determines whether we see the fish from the side
|
| 366 |
// or from above/below based on its movement direction (0 = side, 1 = top/bottom)
|
| 367 |
this.viewAngle = 0; // current interpolated view angle
|
| 368 |
|
|
|
|
| 373 |
const night = personalNight(performance.now() / 1000, this); // v10: per-fish early/normal/late sleep & wake
|
| 374 |
|
| 375 |
// Night: cruise power eases down so even active fish drift lazily after dark,
|
| 376 |
+
// and back up again at dawn â smooth, never a sudden speed jump.
|
| 377 |
this.cruiseSpeed += ((1 - night * 0.55) - this.cruiseSpeed) * Math.min(1, dt * 0.3);
|
| 378 |
|
| 379 |
+
// Behavior state machine â occasionally hover or come to near-rest.
|
| 380 |
// At night the odds tilt toward hovering and long still rests (v6).
|
| 381 |
this.behaviorTimer -= dt;
|
| 382 |
if (this.behaviorTimer <= 0) {
|
|
|
|
| 402 |
// Occasionally release a small cluster of bubbles from the mouth
|
| 403 |
this.bubbleTimer -= dt;
|
| 404 |
if (this.bubbleTimer <= 0) {
|
| 405 |
+
const mx = this.x + fastCos(this.angle) * this.size * 0.62;
|
| 406 |
const my = this.y + Math.sin(this.angle) * this.size * 0.62;
|
| 407 |
+
const n = (2 + ((Math.random() * 3) | 0)) * 2; // 4..8 bubbles â doubled from the old 2..4
|
| 408 |
for (let i = 0; i < n; i++) {
|
| 409 |
+
const bb = getBubble(); bb.reset(false); bb.x = mx + rand(-3, 3); bb.y = my + rand(-3, 3); bb.r = rand(1.4, 3.2) * (this.size / 28); bb.speed = rand(45, 85) * (0.7 + 0.3 * (this.size / 28)); bb.p = rand(0, Math.PI * 2); bb.rgb = bubbleColor(); bb.isMouth = true;
|
| 410 |
bb.owner = this; // v10: a fish never pops its own bubbles
|
| 411 |
bubbles.push(bb);
|
| 412 |
}
|
|
|
|
| 418 |
|
| 419 |
// Target sits ahead of the fish (in heading direction), offset by wanderTheta.
|
| 420 |
// Steering toward it yields smooth meandering and genuine direction changes.
|
| 421 |
+
let wx = this.x + fastCos(this.angle) * this.wanderDist + Math.cos(this.wanderTheta) * this.wanderRadius;
|
| 422 |
let wy = this.y + Math.sin(this.angle) * this.wanderDist + Math.sin(this.wanderTheta) * this.wanderRadius;
|
| 423 |
|
| 424 |
// v7: after dark, most fish bed down low in the grass; a few still hover by the seaweed.
|
| 425 |
if (night > 0.01) {
|
| 426 |
if (this.grassSleeper) {
|
| 427 |
+
// v14: drift toward this fish's own sleep spot along the sand line â
|
| 428 |
// spreads sleeping fish across the tank instead of one big clump
|
| 429 |
wx += (this.sleepSpotX - wx) * night * 0.75;
|
| 430 |
+
const groundY = sandAt(this.x + fastCos(this.angle) * this.wanderDist) - this.size * 0.5;
|
| 431 |
wy += (groundY - wy) * night * 0.85;
|
| 432 |
} else if (seaweeds.length) {
|
| 433 |
// v9: each seaweed-hungry fish keeps to its own stalk instead of all crowding the nearest one
|
|
|
|
| 435 |
this.seaweedPref = (Math.random() * seaweeds.length) | 0;
|
| 436 |
}
|
| 437 |
const w = seaweeds[this.seaweedPref];
|
| 438 |
+
// v13: plinko fix â stalks near the sides pull harder so fish actually reach them
|
| 439 |
// instead of being deflected back toward the middle by the boundary steering.
|
| 440 |
+
const edgeT = Math.abs(w.x / W - 0.5) * 2; // 0 at center â 1 at glass
|
| 441 |
const k = night * 0.8 * (1 + edgeT * 0.9); // up to ~1.9x at the very edge
|
| 442 |
wx += (w.x - wx) * k;
|
| 443 |
wy += (w.yMid - wy) * k;
|
|
|
|
| 454 |
else if (this.x > W - mX) pull = Math.max(pull, 1 - (W - this.x) / mX);
|
| 455 |
if (this.y < mTop) pull = Math.max(pull, 1 - this.y / mTop);
|
| 456 |
else if (this.y > H - mBot) pull = Math.max(pull, 1 - (H - this.y) / mBot);
|
| 457 |
+
// v13: plinko fix â grass-sleepers settling at night are less deflected by the
|
| 458 |
// boundary, so they can reach side positions instead of being pushed to the middle.
|
| 459 |
if (pull > 0 && this.grassSleeper && night > 0.25) {
|
| 460 |
const settle = Math.min(1, (night - 0.25) / 0.75); // ramps up as they get sleepier
|
|
|
|
| 467 |
|
| 468 |
// Floor steering: as a fish nears the sand bed it is steered upward,
|
| 469 |
// so it skims above the ground line instead of diving under it
|
| 470 |
+
// (v4: starts earlier and pushes harder â grass yes, faceplants no)
|
| 471 |
const floorY = sandAt(this.x) - this.size * 0.35;
|
| 472 |
const fZone = H * 0.18;
|
| 473 |
if (this.y > floorY - fZone) {
|
|
|
|
| 475 |
desired = lerpAngle(desired, Math.atan2(H * 0.45 - this.y, W / 2 - this.x), fp * 1.6);
|
| 476 |
}
|
| 477 |
|
| 478 |
+
// v7: separation steering â bend around nearby fish instead of ploughing into them.
|
| 479 |
// The faster we swim, the earlier and harder we steer away.
|
| 480 |
const mySpeed = this.speed * this.speedFactor;
|
| 481 |
if (mySpeed > 0.3) {
|
|
|
|
| 487 |
const zone = (this.size + o.size) * 1.7; // notice radius scales with both fish
|
| 488 |
if (d2 < zone * zone && d2 > 0.01) {
|
| 489 |
const dd = Math.sqrt(d2);
|
| 490 |
+
const w = (1 - dd / zone) * Math.min(1, mySpeed); // close & fast â strong
|
| 491 |
ax -= (dx / dd) * w;
|
| 492 |
ay -= (dy / dd) * w;
|
| 493 |
wsum += w;
|
|
|
|
| 497 |
desired = lerpAngle(desired, Math.atan2(ay, ax), Math.min(0.85, wsum * 0.45));
|
| 498 |
}
|
| 499 |
} else if (night > 0.2) {
|
| 500 |
+
// v14: at rest after dark â a wider, stronger personal buffer so sleeping
|
| 501 |
// fish gently push apart instead of settling into one tight clump
|
| 502 |
let ax = 0, ay = 0, wsum = 0;
|
| 503 |
for (const o of fishes) {
|
|
|
|
| 538 |
if (this.roll > 1.15) { this.roll = 1.15; this.rollVel *= -0.25; }
|
| 539 |
else if (this.roll < -1.15) { this.roll = -1.15; this.rollVel *= -0.25; }
|
| 540 |
|
| 541 |
+
// v22: Ecco-style viewing angle â when moving vertically, we see the fish from above/below;
|
| 542 |
// when moving horizontally, we see its side profile. Smoothly interpolate between views.
|
| 543 |
+
// sin(angle) gives 0 for horizontal (0, Ï) and ±1 for vertical (±Ï/2)
|
| 544 |
const targetView = Math.abs(Math.sin(this.angle));
|
| 545 |
this.viewAngle += (targetView - this.viewAngle) * Math.min(1, dt * 4);
|
| 546 |
|
|
|
|
| 554 |
let d = targetAngle - this.angle;
|
| 555 |
while (d > Math.PI) d -= 2 * Math.PI;
|
| 556 |
while (d < -Math.PI) d += 2 * Math.PI;
|
| 557 |
+
const turnSpeed = 4.0; // rad/s â responsive turning
|
| 558 |
this.angle += Math.max(-turnSpeed * dt, Math.min(turnSpeed * dt, d));
|
| 559 |
}
|
| 560 |
// Speed: cruise baseline, boosted by Shift / R-stick down,
|
|
|
|
| 564 |
if (inp.slow > 0) speedMult -= inp.slow * 0.85; // slow down
|
| 565 |
speedMult = Math.max(0.15, Math.min(3.0, speedMult));
|
| 566 |
const playerSpeed = this.speed * this.cruiseSpeed * speedMult * 60 * dt;
|
| 567 |
+
this.x += fastCos(this.angle) * playerSpeed;
|
| 568 |
this.y += Math.sin(this.angle) * playerSpeed;
|
| 569 |
this.currentVelocity = this.speed * this.cruiseSpeed * speedMult * 60; // track actual velocity
|
| 570 |
} else {
|
| 571 |
// Normal AI fish movement.
|
| 572 |
const sp = this.speed * this.cruiseSpeed * this.speedFactor * 60 * dt;
|
| 573 |
+
this.x += fastCos(this.angle) * sp;
|
| 574 |
this.y += Math.sin(this.angle) * sp;
|
| 575 |
this.currentVelocity = this.speed * this.cruiseSpeed * this.speedFactor * 60; // track actual velocity
|
| 576 |
}
|
| 577 |
|
| 578 |
+
// NOTE: fish do NOT collide with plants â they plough straight through.
|
| 579 |
// The seaweed bends out of the way instead (see updatePlants).
|
| 580 |
|
| 581 |
+
// Hard safety clamp â fish may just about swim off-screen (they never
|
| 582 |
// despawn or leave), but never below the ground line (sand surface)
|
| 583 |
this.x = Math.max(-40, Math.min(W + 40, this.x));
|
| 584 |
this.y = Math.max(-50, Math.min(sandAt(this.x) - this.size * 0.35, this.y));
|
| 585 |
|
| 586 |
+
// Tail beat follows actual speed â a resting fish barely moves its tail
|
| 587 |
// Use the actual current velocity (which accounts for player boost/slow)
|
| 588 |
const effSpeed = this.currentVelocity / 60; // convert back to base units
|
| 589 |
this.phase += dt * (4 + effSpeed * 3);
|
|
|
|
| 597 |
// detailed body and foreshorten it as the fish turns to swim vertically.
|
| 598 |
// viewAngle 0 = pure side profile (one eye), 1 = seen from above/below (the back).
|
| 599 |
// Foreshortening along local up/down makes the dorsal fin slide toward center and
|
| 600 |
+
// the body slim down, reading as "seeing the back" â no snapping, nothing vanishes.
|
| 601 |
const v = Math.max(0, Math.min(1, this.viewAngle));
|
| 602 |
let scaleY = 1 - 0.34 * v; // foreshorten toward the back view
|
| 603 |
if (Math.abs(this.roll) > 0.01) { // banking roll still squashes a touch
|
| 604 |
+
scaleY *= Math.max(0.42, fastCos(this.roll));
|
| 605 |
c.rotate(this.roll * 0.1);
|
| 606 |
}
|
| 607 |
c.scale(1, scaleY);
|
|
|
|
| 616 |
// traveling body wave
|
| 617 |
const waveK = Math.PI / s;
|
| 618 |
const waveAmp = s * 0.12;
|
| 619 |
+
const sway = fastSin(-s * waveK + this.phase) * s * 0.34;
|
| 620 |
|
| 621 |
// Top/bottom view: body seen from above or below
|
| 622 |
// Dorsal fin visible as a line down the center when viewed from above
|
|
|
|
| 641 |
c.fillStyle = g;
|
| 642 |
c.fill(bodyPath);
|
| 643 |
|
| 644 |
+
// Dorsal fin â visible as a line/shape down the center when viewed from above
|
| 645 |
c.strokeStyle = this.tailFillStyle;
|
| 646 |
c.lineWidth = s * 0.15;
|
| 647 |
c.lineCap = "round";
|
|
|
|
| 651 |
c.quadraticCurveTo(-s * 0.2, -sway * 0.5, -s * 0.6, sway * 0.3);
|
| 652 |
c.stroke();
|
| 653 |
|
| 654 |
+
// Eyes â visible on both sides of the head when viewed from top/bottom
|
| 655 |
c.fillStyle = "#fff";
|
| 656 |
// Left eye
|
| 657 |
c.beginPath();
|
|
|
|
| 697 |
// v22: Draw fish from the side (original view)
|
| 698 |
drawSideView(c) {
|
| 699 |
const s = this.size;
|
| 700 |
+
// traveling body wave (v3): ripples headâtail so the fish undulates like a real one
|
| 701 |
const waveK = Math.PI / s; // ~one wavelength across the body length
|
| 702 |
const waveAmp = s * 0.12; // subtle flex amplitude
|
| 703 |
+
// tail fin follows the body wave at its base, amplified (same phase â no fighting)
|
| 704 |
+
const sway = fastSin(-s * waveK + this.phase) * s * 0.34;
|
| 705 |
|
| 706 |
// v24: Ecco-style blend factor (0 = side profile, 1 = seen from above/below).
|
| 707 |
// Flattens the tail lobes and spreads eyes/barbels as we see more of the back.
|
|
|
|
| 719 |
// with only a shallow dip between the two lobes.
|
| 720 |
c.moveTo(-s * 0.5, 0);
|
| 721 |
c.quadraticCurveTo(-s * 1.3, -s * 0.28 * tailFlat + sway, -s * 1.42, -s * 0.5 * tailFlat + sway); // up to upper tip
|
| 722 |
+
c.quadraticCurveTo(-s * 1.12, sway * 0.5, -s * 1.42, s * 0.5 * tailFlat + sway); // shallow notch â lower tip
|
| 723 |
c.quadraticCurveTo(-s * 1.3, s * 0.28 * tailFlat + sway, -s * 0.5, 0); // back to the base
|
| 724 |
} else if (this.tailType === 'streamer') {
|
| 725 |
// Long forked streamers (angelfish/fancy-guppy-style): two flowing lobes that
|
| 726 |
// reach further back with a deeper notch between them.
|
| 727 |
c.moveTo(-s * 0.5, 0);
|
| 728 |
c.quadraticCurveTo(-s * 1.35, -s * 0.24 * tailFlat + sway, -s * 1.68, -s * 0.56 * tailFlat + sway); // long upper lobe
|
| 729 |
+
c.quadraticCurveTo(-s * 0.98, sway * 0.35, -s * 1.68, s * 0.56 * tailFlat + sway); // deep fork â lower tip
|
| 730 |
c.quadraticCurveTo(-s * 1.35, s * 0.24 * tailFlat + sway, -s * 0.5, 0); // long lower lobe back to base
|
| 731 |
} else {
|
| 732 |
+
// classic (v7): rounded lobes with a soft notch â the original look
|
| 733 |
c.moveTo(-s * 0.5, 0);
|
| 734 |
c.quadraticCurveTo(-s * 1.04, -s * 0.36 * tailFlat + sway, -s * 1.22, -s * 0.44 * tailFlat + sway); // upper edge out toward the tip
|
| 735 |
+
c.quadraticCurveTo(-s * 1.34, -s * 0.52 * tailFlat + sway, -s * 0.86, sway * 0.5); // rounded upper lobe â notch
|
| 736 |
+
c.quadraticCurveTo(-s * 1.34, s * 0.52 * tailFlat + sway, -s * 1.22, s * 0.44 * tailFlat + sway); // notch â rounded lower lobe
|
| 737 |
c.quadraticCurveTo(-s * 1.04, s * 0.36 * tailFlat + sway, -s * 0.5, 0); // lower edge back to the base
|
| 738 |
}
|
| 739 |
c.fill();
|
| 740 |
|
| 741 |
+
// v21: far-side whisker â drawn BEFORE the body so it sits behind the fish and is
|
| 742 |
// partially obscured by the head (only the part that pokes out from behind the snout
|
| 743 |
// shows). The near-side whisker is drawn after the eye, below.
|
| 744 |
if (this.whiskerLen) {
|
|
|
|
| 752 |
{
|
| 753 |
const side = -1; // far side (behind the body)
|
| 754 |
const rootX = s * 0.6;
|
| 755 |
+
// v24: from above, barbels sit on either side of the mouth â spread the roots
|
| 756 |
// apart and flare each whisker further out as the fish "rotates" to swim vertically
|
| 757 |
const rootY = side * s * (0.09 + 0.07 * ev);
|
| 758 |
c.beginPath();
|
|
|
|
| 762 |
const bx = rootX - L * (0.25 + 0.65 * spd) * t;
|
| 763 |
const by = rootY + side * L * (0.42 + 0.3 * ev) * t * t;
|
| 764 |
const amp = s * 0.17 * t * (0.35 + 0.65 * spd);
|
| 765 |
+
const off = fastSin(t * 5.5 - this.phase * 1.4 + this.whiskPh) * amp;
|
| 766 |
c.lineTo(bx, by + off);
|
| 767 |
}
|
| 768 |
c.stroke();
|
|
|
|
| 772 |
|
| 773 |
// v26:
|
| 774 |
|
| 775 |
+
// body â undulates while swimming: the ellipse flexes along a traveling wave (v3)
|
| 776 |
const g = this.bodyGrad; // precomputed in constructor (static colors, local coords)
|
| 777 |
const NSEG = 26; // outline samples for a smooth curve
|
| 778 |
// wave offset at any x along the body: rigid at head, full flex at tail.
|
| 779 |
// Shared with the dorsal fin so it stays glued to the moving back (v4)
|
| 780 |
const wy = x => Math.sin(x * waveK + this.phase) * waveAmp * ((s - x) / (2 * s));
|
| 781 |
+
// pelvic fin drawn BEFORE the body so it sits behind the fish
|
| 782 |
if (this.hasPelvicFin) {
|
| 783 |
try {
|
| 784 |
c.fillStyle = this.pevFillStyle;
|
|
|
|
| 794 |
console.error('Pelvic fin error:', e);
|
| 795 |
}
|
| 796 |
}
|
| 797 |
+
// // Build the body outline once as a Path2D â reused for base fill + iridescence (v7)
|
| 798 |
const bodyPath = new Path2D();
|
| 799 |
for (let i = 0; i <= NSEG; i++) { // top edge: head (+s) -> tail (-s)
|
| 800 |
const x = s - (i / NSEG) * 2 * s;
|
|
|
|
| 811 |
c.fillStyle = g;
|
| 812 |
c.fill(bodyPath);
|
| 813 |
|
| 814 |
+
// v15: stripes first when both are present â bars form the base pattern,
|
| 815 |
// spots scatter on top. Each is clipped to the body so nothing spills, and both
|
| 816 |
// ride the traveling wave (wy) so they flex with the undulating body, drawn
|
| 817 |
// BEFORE the additive sheen so the iridescent shimmer still plays across them.
|
|
|
|
| 825 |
const H = s * 1.35; // vertical span (clipped by the body outline)
|
| 826 |
c.fillStyle = st.style;
|
| 827 |
c.beginPath();
|
| 828 |
+
// left edge, top â bottom: bend the centerline + taper the width with t
|
| 829 |
for (let j = 0; j <= N; j++) {
|
| 830 |
const t = j / N;
|
| 831 |
+
const cx = sx + fastSin(t * st.cFreq * Math.PI + st.cPh) * st.cAmp * s;
|
| 832 |
+
const hwL = hw0 * (1 + st.tAmp * fastSin(t * st.tFreq * Math.PI + st.tPh));
|
| 833 |
const xL = cx - hwL, yL = wy(xL) + (t - 0.5) * H;
|
| 834 |
if (j === 0) c.moveTo(xL, yL); else c.lineTo(xL, yL);
|
| 835 |
}
|
| 836 |
+
// right edge, bottom â top
|
| 837 |
for (let j = N; j >= 0; j--) {
|
| 838 |
const t = j / N;
|
| 839 |
+
const cx = sx + fastSin(t * st.cFreq * Math.PI + st.cPh) * st.cAmp * s;
|
| 840 |
+
const hwR = hw0 * (1 + st.tAmp * fastSin(t * st.tFreq * Math.PI + st.tPh));
|
| 841 |
const xR = cx + hwR, yR = wy(xR) + (t - 0.5) * H;
|
| 842 |
c.lineTo(xR, yR);
|
| 843 |
}
|
|
|
|
| 847 |
c.restore();
|
| 848 |
}
|
| 849 |
|
| 850 |
+
// v18: koi patches â large irregular blobs clipped to the body outline, riding the
|
| 851 |
// traveling wave (wy) so they flex with the undulating body, drawn BEFORE the
|
| 852 |
// additive sheen so the iridescent shimmer still plays across them.
|
| 853 |
if (this.koiPatches) {
|
|
|
|
| 863 |
c.restore();
|
| 864 |
}
|
| 865 |
|
| 866 |
+
// v13: random spots on ~20% of fish (or over the stripes for the 10% mixed) â
|
| 867 |
// clipped to the body so they never spill, riding the traveling wave (wy) so they
|
| 868 |
// flex with the undulating body, and drawn BEFORE the additive sheen so the
|
| 869 |
// iridescent shimmer still plays across them.
|
|
|
|
| 880 |
c.restore();
|
| 881 |
}
|
| 882 |
|
| 883 |
+
// v7: efficient iridescence â a sheen whose hue shimmers with the swim phase,
|
| 884 |
// so scales appear to catch the light while moving. One gradient + one fill.
|
| 885 |
const shimmer = Math.sin(this.phase * 1.6) * 0.5 + 0.5; // 0..1 as it swims
|
| 886 |
const iridHue = this.hue + 40 * Math.sin(this.phase * 2.3); // hue drifts with phase
|
| 887 |
// PERF: cache the sheen gradient. Its inputs move smoothly, so quantize them to a
|
| 888 |
+
// tiny grid (1/31 shimmer steps, 1° hue) and reuse until the cell changes â this
|
| 889 |
// skips one createLinearGradient + 3 addColorStop calls on most frames.
|
| 890 |
const igKey = Math.round(shimmer * 31) * 512 + (Math.round(iridHue) + 64);
|
| 891 |
if (igKey !== this._igKey || !this._igGrad) {
|
|
|
|
| 908 |
c.ellipse(0, s * 0.14, s * 0.82, s * 0.24, 0, 0, Math.PI * 2);
|
| 909 |
c.fill();
|
| 910 |
|
| 911 |
+
// dorsal fin â rides the body wave at its attachment points so it
|
| 912 |
// never disconnects as the back undulates (v4)
|
| 913 |
+
// v17: matches the fish's own color instead of a fixed blue â silver fish no longer
|
| 914 |
// sprout mismatched blue fins
|
| 915 |
+
// v25: as the fish turns to swim vertically (evâ1), the dorsal fin slides from
|
| 916 |
// "on top of the fish" to "in the middle" (centerline), matching the top-down view
|
| 917 |
c.fillStyle = this.dorsalFillStyle;
|
| 918 |
c.beginPath();
|
|
|
|
| 924 |
c.closePath();
|
| 925 |
c.fill();
|
| 926 |
|
| 927 |
+
// v24: both eyes â as the fish turns to swim vertically (evâ1) we see them from
|
| 928 |
// above: the pair spreads wider on either side of the head and each eye squashes
|
| 929 |
// into a flatter ellipse (perspective), instead of two overlapping circles.
|
| 930 |
{
|
|
|
|
| 940 |
c.ellipse(s * 0.61, yNear, s * 0.055, s * pupRY, 0, 0, Math.PI * 2);
|
| 941 |
c.fill();
|
| 942 |
|
| 943 |
+
// far-side eye fades in as we see more of the back (Ecco-style top view) â
|
| 944 |
// two eyes flanking the head when the fish turns to swim vertically.
|
| 945 |
if (ev > 0.03) {
|
| 946 |
+
const yFar = s * (0.16 + 0.08 * ev); // far eye slides outward too â pair widens
|
| 947 |
c.save();
|
| 948 |
// Ease in the far-side eye so it takes longer to appear (25% slower fade-in)
|
| 949 |
c.globalAlpha = Math.pow(ev, 1.25) * 0.9;
|
|
|
|
| 959 |
}
|
| 960 |
}
|
| 961 |
|
| 962 |
+
// v21: near-side whisker â drawn AFTER the body so it sits in front of the fish.
|
| 963 |
// (The far-side whisker is drawn before the body, above.) Together they form a
|
| 964 |
// pair from the snout on ~10% of fish, in one of three lengths, with loose ribbons
|
| 965 |
// flowing in the current and a traveling wave rippling down their length. Drawn at
|
|
|
|
| 975 |
{
|
| 976 |
const side = 1; // near side (in front of the body)
|
| 977 |
const rootX = s * 0.6;
|
| 978 |
+
// v24: same spread/flare as the far whisker â a pair flanking the snout from above
|
| 979 |
const rootY = side * s * (0.09 + 0.07 * ev);
|
| 980 |
c.beginPath();
|
| 981 |
c.moveTo(rootX, rootY);
|
|
|
|
| 984 |
const bx = rootX - L * (0.25 + 0.65 * spd) * t;
|
| 985 |
const by = rootY + side * L * (0.42 + 0.3 * ev) * t * t;
|
| 986 |
const amp = s * 0.17 * t * (0.35 + 0.65 * spd);
|
| 987 |
+
const off = fastSin(t * 5.5 - this.phase * 1.4 + this.whiskPh) * amp;
|
| 988 |
c.lineTo(bx, by + off);
|
| 989 |
}
|
| 990 |
c.stroke();
|
|
|
|
| 994 |
|
| 995 |
c.restore();
|
| 996 |
|
| 997 |
+
// v17/v23: overhead light â soft elliptical highlight on the fish's upper edge.
|
| 998 |
// Drawn in local coords (draw() already positioned + rotated us) so it rides with
|
| 999 |
// the body and foreshortens correctly instead of being double-offset.
|
| 1000 |
{
|
|
|
|
| 1015 |
}
|
| 1016 |
}
|
| 1017 |
|
| 1018 |
+
// v7: helper â stroke a smooth line through the given points
|
| 1019 |
function strokeArc(c, pts) {
|
| 1020 |
c.beginPath();
|
| 1021 |
c.moveTo(pts[0][0], pts[0][1]);
|
|
|
|
| 1023 |
c.stroke();
|
| 1024 |
}
|
| 1025 |
|
| 1026 |
+
// v19: each bubble gets a subtle random tint â pure white up to a soft grey-blue
|
| 1027 |
function bubbleColor() {
|
| 1028 |
const t = Math.random() * 0.85; // keep it subtle, most stay near-white
|
| 1029 |
const r = Math.round(255 - (255 - 196) * t);
|
|
|
|
| 1057 |
}
|
| 1058 |
update(dt, t) {
|
| 1059 |
this.y -= this.speed * dt;
|
| 1060 |
+
this.x += fastSin(t * 1.6 + this.p) * 22 * dt;
|
| 1061 |
if (this.y < -12) { if (this.isMouth) this.dead = true; else this.reset(false); }
|
| 1062 |
}
|
| 1063 |
draw(c) {
|
|
|
|
| 1094 |
rocks = [];
|
| 1095 |
sandHeights = [];
|
| 1096 |
sandParticles = []; // v22: fresh bed, no leftover grains
|
| 1097 |
+
raySpritesBuiltFor = null; // v23: beam sprites are sized to the old canvas â rebuild them
|
| 1098 |
grass = []; // v10.1: clear on rebuild so resize/F11 doesn't stack a second row
|
| 1099 |
fgGrass = []; // v12: same reset for the foreground layer (resize/F11 safety)
|
| 1100 |
|
| 1101 |
+
// ALT: rolling hills instead of a flat bed â three octaves of random sine waves
|
| 1102 |
// (broad swells + mid undulations + fine ripples), re-rolled on every refresh.
|
| 1103 |
const sandTop = H - Math.max(60, H * 0.12);
|
| 1104 |
const hillUp = rand(34, 58); // tallest crest rises this far above the baseline
|
|
|
|
| 1114 |
for (const wv of waves) v += Math.sin((x / wv.wl) * Math.PI * 2 + wv.ph) * wv.amp;
|
| 1115 |
raw.push(v);
|
| 1116 |
}
|
| 1117 |
+
// Rescale so the extremes land exactly at the chosen limits â full relief, no clamping
|
| 1118 |
let lo = Infinity, hi = -Infinity;
|
| 1119 |
for (const v of raw) { if (v < lo) lo = v; if (v > hi) hi = v; }
|
| 1120 |
const span = Math.max(0.001, hi - lo);
|
| 1121 |
sandHeights.length = 0;
|
| 1122 |
for (const v of raw) {
|
| 1123 |
+
const off = -hillUp + ((hi - v) / span) * (hillUp + hillDown); // crest â -hillUp, dip â +hillDown
|
| 1124 |
sandHeights.push(sandTop + off);
|
| 1125 |
}
|
| 1126 |
|
|
|
|
| 1141 |
hue,
|
| 1142 |
width: rand(4, 8),
|
| 1143 |
touch: 0,
|
| 1144 |
+
// v15: organic rest curve â each stalk leans and waves like real seaweed
|
| 1145 |
// instead of standing perfectly straight. Also fed into the physics (updatePlants)
|
| 1146 |
+
// so fish push the stalk where it actually looks. Weighted by f â root stays planted.
|
| 1147 |
cA1: rand(5, 12), cF1: rand(0.9, 1.8), cP1: rand(0, Math.PI * 2),
|
| 1148 |
cA2: rand(2, 6), cF2: rand(2.2, 3.6), cP2: rand(0, Math.PI * 2),
|
| 1149 |
// v17: secondary wobble - a finer inner shimmer for unique motion per stalk
|
| 1150 |
sAmp: rand(2, 5), // amplitude of the secondary wave
|
| 1151 |
sFreq: rand(3.5, 6.5), // higher frequency than primary sway
|
| 1152 |
sPh: rand(0, Math.PI * 2), // phase offset
|
| 1153 |
+
tipW: rand(1.4, 2.6), // v15: blade tapers from width â tipW up the stipe
|
| 1154 |
+
// PERF: stroke styles precomputed once (hue is fixed per stalk) â drawSeaweed
|
| 1155 |
// used to rebuild both template strings every frame for every stalk.
|
| 1156 |
stalkStyle: `hsla(${hue},55%,36%,0.92)`,
|
| 1157 |
branchStyle: `hsla(${hue + 8},60%,46%,0.9)`,
|
|
|
|
| 1159 |
off: new Float32Array(9), // lateral bend per segment (fish push leaves aside)
|
| 1160 |
offV: new Float32Array(9), // velocity of that bend
|
| 1161 |
branches: [],
|
| 1162 |
+
// PERF: stalk points preallocated once â updatePlants reuses these objects
|
| 1163 |
// in place instead of pushing fresh {x,y} pairs every frame (GC churn)
|
| 1164 |
points: Array.from({ length: 9 }, () => ({ x: 0, y: 0 })) // segs+1; can't read st inside its own initializer
|
| 1165 |
};
|
|
|
|
| 1175 |
flowers: Math.random() < 0.3 ? (() => { // ~30% of branches bloom with 1-3 heads (v3)
|
| 1176 |
const n = 1 + ((Math.random() * 3) | 0); // 1..3 flowers per branch
|
| 1177 |
return Array.from({ length: n }, () => {
|
| 1178 |
+
const hue = (Math.random() * 360) | 0; // colourful â full spectrum
|
| 1179 |
return {
|
| 1180 |
t: rand(0.45, 1), // where along the branch it sits (tip zone)
|
| 1181 |
hue,
|
| 1182 |
r: rand(4, 7), // head size
|
| 1183 |
petals: 5 + ((Math.random() * 3) | 0), // 5..7 petals
|
| 1184 |
+
// PERF: paint styles precomputed once â drawFlowerHead used to rebuild
|
| 1185 |
// these strings per petal, per frame.
|
| 1186 |
petalFill: `hsla(${hue},85%,64%,0.95)`,
|
| 1187 |
centerFill: `hsla(${(hue + 45) % 360},92%,78%,0.95)`
|
|
|
|
| 1194 |
}
|
| 1195 |
}
|
| 1196 |
|
| 1197 |
+
// v4: blurry background seaweed â taller, bluer, behind everything else.
|
| 1198 |
// Pre-rendered to offscreen canvases with blur at build time (no runtime cost).
|
| 1199 |
const nBackClusters = Math.max(4, Math.floor(W / 200));
|
| 1200 |
for (let i = 0; i < nBackClusters; i++) {
|
|
|
|
| 1203 |
const nStalks = 2 + ((Math.random() * 3) | 0); // 2..4 stalks per bundle
|
| 1204 |
for (let j = 0; j < nStalks; j++) {
|
| 1205 |
const hue = clusterHue + rand(-15, 15);
|
| 1206 |
+
// v4.3: depth split â ~51% "far" (heavy softness), ~49% "near" (sharper) for a layered look
|
| 1207 |
const near = Math.random() < 0.49;
|
| 1208 |
const w = near ? rand(2.5, 5) : rand(3, 6);
|
| 1209 |
const tw = near ? rand(1.2, 2.4) : rand(1.5, 3);
|
|
|
|
| 1303 |
}
|
| 1304 |
}
|
| 1305 |
|
| 1306 |
+
// v5: ultra-far pre-rendered blurry seaweed â deepest layer, heavily blurred sprites
|
| 1307 |
const nFarClusters = Math.max(6, Math.floor(W / 180)); // 50% more than previous far count
|
| 1308 |
for (let i = 0; i < nFarClusters; i++) {
|
| 1309 |
const cx = rand(20, W - 20);
|
|
|
|
| 1346 |
const nGrassTufts = Math.max(50, Math.floor(W / 9));
|
| 1347 |
for (let i = 0; i < nGrassTufts; i++) {
|
| 1348 |
const gx = rand(8, W - 8);
|
| 1349 |
+
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
|
| 1350 |
const baseHue = rand(95, 155);
|
| 1351 |
const blades = 2 + ((Math.random() * 4) | 0); // 2..5 blades per tuft
|
| 1352 |
for (let b = 0; b < blades; b++) {
|
|
|
|
| 1354 |
grass.push({
|
| 1355 |
x: gx + rand(-7, 7),
|
| 1356 |
y: gy,
|
| 1357 |
+
h: rand(12, 85), // v4: more height variability â some short tufts, some noticeably tall
|
| 1358 |
lean: rand(-0.35, 0.35), // base tilt from vertical
|
| 1359 |
phase: rand(0, Math.PI * 2),
|
| 1360 |
speed: rand(1.3, 2.8), // quicker, finer sway than seaweed
|
|
|
|
| 1370 |
sAmp: rand(1.5, 3.5),
|
| 1371 |
sFreq: rand(4.5, 8),
|
| 1372 |
sPh: rand(0, Math.PI * 2),
|
| 1373 |
+
// PERF: per-blade scratch points â drawGrass reuses them instead of
|
| 1374 |
// allocating a fresh pts array + point objects every frame (GC churn)
|
| 1375 |
+
pts: Array.from({ length: bladeSegs + 1 }, () => ({ x: 0, y: 0 })),
|
| 1376 |
+
fillStyle: `hsla(${baseHue + rand(-9, 9)},58%,40%,0.92)`,
|
| 1377 |
+
strokeStyle: `hsla(${baseHue + rand(-9, 9) + 6},64%,45%,0.5)` });
|
| 1378 |
}
|
| 1379 |
}
|
| 1380 |
|
| 1381 |
+
// v12: foreground grass â planted on the FRONT glass (closest to the viewer), so it is
|
| 1382 |
// drawn last and foreshortened. It grows in the low band below the sand line, i.e. the
|
| 1383 |
// zone fish never reach, and sways with the water like the other flora but has no
|
| 1384 |
// fish-push physics (purely decorative). ALT-FG: all sizes halved vs the original fg.
|
|
|
|
| 1387 |
for (let i = 0; i < nFgClusters; i++) {
|
| 1388 |
const cx = rand(-10, W + 10);
|
| 1389 |
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
|
| 1390 |
+
const baseHue = rand(46, 76); // v12.1: yellowish-green â pops against the mid-ground green grass
|
| 1391 |
const nBlades = 5 + ((Math.random() * 4) | 0); // 5..8 blades per clump (doubled density)
|
| 1392 |
for (let b = 0; b < nBlades; b++) {
|
| 1393 |
const fgSegs = 4 + ((Math.random() * 2) | 0); // 4-5 segments for tapered outline
|
|
|
|
| 1409 |
sAmp: rand(2, 4.5),
|
| 1410 |
sFreq: rand(3.8, 6.5),
|
| 1411 |
sPh: rand(0, Math.PI * 2),
|
| 1412 |
+
// PERF: per-blade scratch points â drawFgGrass reuses them in place
|
| 1413 |
pts: Array.from({ length: fgSegs + 1 }, () => ({ x: 0, y: 0 })),
|
| 1414 |
// v28: ~40% of blades get a gentle blur (pre-rendered sprite) for depth
|
| 1415 |
blurred: Math.random() < 0.4,
|
| 1416 |
sprite: null,
|
| 1417 |
+
spriteScale: 1,
|
| 1418 |
+
fillStyle: `hsla(${baseHue + rand(-10, 10)},60%,31%,0.96)`,
|
| 1419 |
+
strokeStyle: `hsla(${baseHue + rand(-10, 10) + 6},64%,45%,0.5)` });
|
| 1420 |
}
|
| 1421 |
}
|
| 1422 |
buildFgGrassSprites(); // v28: bake the defocused-blade sprites once (free at runtime)
|
| 1423 |
|
| 1424 |
+
// PERF: pool the plantPoints collision objects too â updatePlants reuses them
|
| 1425 |
// in place instead of allocating one per stalk/branch point every frame.
|
| 1426 |
let pcap = 8;
|
| 1427 |
for (const w of seaweeds) pcap += (w.segs + 1) + w.branches.length * 2;
|
|
|
|
| 1431 |
for (let i = 0; i < nFish; i++) fishes.push(new Fish());
|
| 1432 |
|
| 1433 |
const nAmbient = Math.round(26 * 0.75); // v10: ambient bubbles reduced 25% (fish production unchanged)
|
| 1434 |
+
for (let i = 0; i < nAmbient; i++) { const b = getBubble(); b.reset(true); bubbles.push(b); }
|
| 1435 |
|
| 1436 |
for (let i = 0; i < 45; i++) {
|
| 1437 |
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) });
|
|
|
|
| 1444 |
rays.push({
|
| 1445 |
x: rand(0, W), w: rand(80, 210), tilt: rand(-0.35, 0.35),
|
| 1446 |
speed: rand(0.06, 0.14) * (Math.random() < 0.5 ? 1 : -1), alpha: rand(0.04, 0.09), blur,
|
| 1447 |
+
// undulation â two traveling waves so each beam shimmers like light refracted through moving water
|
| 1448 |
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),
|
| 1449 |
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)
|
| 1450 |
});
|
|
|
|
| 1453 |
const nRocks = Math.max(3, Math.floor(W / 320));
|
| 1454 |
for (let i = 0; i < nRocks; i++) {
|
| 1455 |
const r = rand(14, 34);
|
| 1456 |
+
// jagged outline â a ring of uneven points so rocks read as craggy stones, not domes
|
| 1457 |
const nPts = 8 + ((Math.random() * 5) | 0); // 8..12 vertices
|
| 1458 |
const pts = [];
|
| 1459 |
for (let k = 0; k < nPts; k++) {
|
|
|
|
| 1518 |
|
| 1519 |
// Per-fish night curve (v10): shifts each fish's dusk/dawn fades by its own early/normal/late
|
| 1520 |
// personality so they don't all sleep or wake in unison. Full day and full night are shared
|
| 1521 |
+
// by everyone â only the transitions differ.
|
| 1522 |
function personalNight(t, f) {
|
| 1523 |
const p = ((t / CYCLE) % 1 + 1) % 1;
|
| 1524 |
if (p < DUSK_START) return 0; // bright day: all awake
|
|
|
|
| 1550 |
ctx.fillRect(0, 0, W, H);
|
| 1551 |
}
|
| 1552 |
|
| 1553 |
+
// ============ Light rays â pre-blurred sprites (v23 fast path) ============
|
| 1554 |
// The old version applied a canvas blur() filter to every beam on every frame;
|
| 1555 |
// canvas filters force slow CPU rasterization over large regions, which tanked FPS.
|
| 1556 |
// Now each beam's shape is rendered ONCE into its own offscreen sprite (blur + the
|
| 1557 |
// vertical fade baked in at build time), and per frame we just blit that sprite in
|
| 1558 |
+
// horizontal slices with the same undulation offsets as before â wobble + soft
|
| 1559 |
// edges, zero filters at runtime.
|
| 1560 |
const RAY_SEGS = 18; // vertical resolution of each beam's spine (at build time)
|
| 1561 |
+
const RAY_SLICES = 10; // horizontal slices per beam used for the wobble (per frame) â reduced from 24
|
| 1562 |
+
const RAY_SPRITE_SCALE = 0.5; // sprites stored at half resolution â softer + cheaper
|
| 1563 |
const RAY_PAD = 60; // room for the blur to spill past the beam edge
|
| 1564 |
let raySpritesBuiltFor = null;
|
| 1565 |
let rayTintRGB = null; // the light.ray color used when building tinted sprites (perf: rebuild only on big shifts)
|
|
|
|
| 1595 |
const full = document.createElement("canvas");
|
| 1596 |
full.width = bw; full.height = bh;
|
| 1597 |
const fc = full.getContext("2d");
|
| 1598 |
+
fc.translate(-xMinL + RAY_PAD, -yTop0 + RAY_PAD); // local beam coords â scratch px
|
| 1599 |
if (r.blur > 0) { try { fc.filter = `blur(${r.blur}px)`; } catch (e) {} }
|
| 1600 |
|
| 1601 |
const poly = (a, b) => {
|
|
|
|
| 1653 |
}
|
| 1654 |
|
| 1655 |
// Cheaply re-tint the cached blurred sprites with the current light.ray color.
|
| 1656 |
+
// No blur / no shape rasterization â just a source-in fill over the stored beam,
|
| 1657 |
// so this stays fast even during slow dusk/dawn transitions.
|
| 1658 |
function applyRayTint() {
|
| 1659 |
for (const r of rays) {
|
|
|
|
| 1682 |
const yTop0 = -10, ySpan = H * 0.95 + 10;
|
| 1683 |
|
| 1684 |
// PERF: re-tint the cached blurred sprites only when the light color shifts enough.
|
| 1685 |
+
// Re-tinting is cheap (a source-in fill) â no blur re-rasterization â so this runs
|
| 1686 |
// smoothly even during slow dusk/dawn transitions instead of hitching on rebuilds.
|
| 1687 |
if (!rayTintRGB || Math.abs(rayTintRGB[0] - rr) + Math.abs(rayTintRGB[1] - rg) + Math.abs(rayTintRGB[2] - rb) > 35) {
|
| 1688 |
rayTintRGB = [rr, rg, rb];
|
|
|
|
| 1690 |
}
|
| 1691 |
|
| 1692 |
for (const r of rays) {
|
| 1693 |
+
// blit horizontal slices with the undulation offset at each depth â same wave
|
| 1694 |
// math as before, so the beams shimmer exactly like they always have
|
| 1695 |
+
const off = fastSin(t * r.speed * 6 + r.x) * 55; // slow drift of the whole beam
|
| 1696 |
for (let i = 0; i < RAY_SLICES; i++) {
|
| 1697 |
const vT = i / RAY_SLICES, vB = (i + 1) / RAY_SLICES, vM = (vT + vB) / 2;
|
| 1698 |
const ya = yTop0 + vT * ySpan, yb = yTop0 + vB * ySpan;
|
|
|
|
| 1736 |
function drawRocks(c = ctx) {
|
| 1737 |
for (const rk of rocks) {
|
| 1738 |
const y = sandAt(rk.x) + rk.r * 0.35;
|
| 1739 |
+
// fully opaque â tone now varies lightness instead of alpha
|
| 1740 |
const base = Math.round(28 + rk.tone * 46);
|
| 1741 |
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);
|
| 1742 |
g.addColorStop(0, `rgb(${base + 68},${base + 78},${base + 92})`);
|
|
|
|
| 1760 |
|
| 1761 |
// PERF: shared sway function (was a per-stalk closure recreated every frame)
|
| 1762 |
function stalkSwayX(w, t, f) {
|
| 1763 |
+
return fastSin(t * w.waveF1 + w.wavePh1 + f * 2.4) * w.waveA1 * f * f +
|
| 1764 |
+
fastSin(t * w.waveF2 + w.wavePh2 + f * 3.8) * w.waveA2 * f * f +
|
| 1765 |
+
fastSin(t * w.waveF3 + w.wavePh3 + f * 5.4) * w.waveA3 * f;
|
| 1766 |
}
|
| 1767 |
|
| 1768 |
+
// v4.3: draw background seaweed procedurally â multi-wave organic motion instead of
|
| 1769 |
// rigid sprite rotation. Far stalks get a soft low-alpha halo pass to keep their
|
| 1770 |
// "far away" look without any runtime blur filters (no slowdown).
|
| 1771 |
// v6: middle-distance seaweed using halo stroke pass (median depth between back and far)
|
| 1772 |
function drawMidSeaweeds(t) {
|
| 1773 |
+
// PERF: lineCap/lineJoin are constant â set once instead of per stalk per pass
|
| 1774 |
ctx.lineCap = 'round';
|
| 1775 |
ctx.lineJoin = 'round';
|
| 1776 |
for (const w of midSeaweeds) {
|
|
|
|
| 1805 |
else ctx.lineTo(px + nx * hw, py + ny * hw);
|
| 1806 |
prevX = px; prevY = py;
|
| 1807 |
}
|
| 1808 |
+
// down the other side (cached points; i=n no-op point dropped â degenerate in original)
|
| 1809 |
for (let i = n - 1; i >= 0; i--) {
|
| 1810 |
const f = i / n;
|
| 1811 |
const px = pxs[i], py = pys[i];
|
|
|
|
| 1822 |
for (const b of w.branches) {
|
| 1823 |
const idx = Math.min(n, Math.max(1, Math.round(b.f * n)));
|
| 1824 |
const ax = pxs[idx], ay = pys[idx];
|
| 1825 |
+
const flutter = fastSin(t * w.waveF2 * 1.4 + b.ph) * (3 + w.waveA2 * 0.4);
|
| 1826 |
const bx = ax + b.side * b.len + flutter;
|
| 1827 |
const by = ay - b.up;
|
| 1828 |
ctx.beginPath();
|
|
|
|
| 1841 |
}
|
| 1842 |
|
| 1843 |
function drawBackSeaweeds(t) {
|
| 1844 |
+
// v6: no runtime canvas filter â blur() forces a CPU re-rasterize every frame
|
| 1845 |
// and was the main FPS cost. Softness now comes from the halo pass below instead.
|
| 1846 |
ctx.lineCap = 'round';
|
| 1847 |
ctx.lineJoin = 'round';
|
|
|
|
| 1877 |
else ctx.lineTo(px + nx * hw, py + ny * hw);
|
| 1878 |
prevX = px; prevY = py;
|
| 1879 |
}
|
| 1880 |
+
// down the other side (cached points; i=n no-op point dropped â degenerate in original)
|
| 1881 |
for (let i = n - 1; i >= 0; i--) {
|
| 1882 |
const f = i / n;
|
| 1883 |
const px = pxs[i], py = pys[i];
|
|
|
|
| 1894 |
for (const b of w.branches) {
|
| 1895 |
const idx = Math.min(n, Math.max(1, Math.round(b.f * n)));
|
| 1896 |
const ax = pxs[idx], ay = pys[idx];
|
| 1897 |
+
const flutter = fastSin(t * w.waveF2 * 1.4 + b.ph) * (3 + w.waveA2 * 0.4);
|
| 1898 |
const bx = ax + b.side * b.len + flutter;
|
| 1899 |
const by = ay - b.up;
|
| 1900 |
ctx.beginPath();
|
|
|
|
| 1914 |
|
| 1915 |
// v5: pre-render ultra-far seaweed sprites with heavy blur baked in at build time
|
| 1916 |
function buildFarSeaweedSprites() {
|
| 1917 |
+
const BLUR = 7; // v5.1: reduced from 10 (33% less) â slightly closer appearance
|
| 1918 |
const PAD = 40; // extra space for blur spill
|
| 1919 |
|
| 1920 |
for (const w of farSeaweeds) {
|
|
|
|
| 2006 |
|
| 2007 |
// v28: pre-render blurred foreground grass sprites for ~40% of blades (gentle depth effect)
|
| 2008 |
function buildFgGrassSprites() {
|
| 2009 |
+
const BLUR = 1.5; // very gentle blur â just enough to suggest "slightly out of focus"
|
| 2010 |
const PAD = 8; // extra space for blur spill
|
| 2011 |
|
| 2012 |
for (const g of fgGrass) {
|
|
|
|
| 2085 |
|
| 2086 |
const baseY = sandAt(w.x) + 10;
|
| 2087 |
// Simple rotational sway - root stays planted, sprite rotates slightly
|
| 2088 |
+
const swayAngle = fastSin(t * w.speed + w.phase) * 0.04; // very subtle rotation
|
| 2089 |
|
| 2090 |
ctx.save();
|
| 2091 |
// Translate to the base point (where the stalk meets the sand)
|
|
|
|
| 2111 |
const moveF = Math.min(1, fSpd / 1.2); // v9: how actively this fish is swimming
|
| 2112 |
for (let i = 1; i <= w.segs; i++) {
|
| 2113 |
const fl = i / w.segs;
|
| 2114 |
+
const px = w.x + w.off[i] + stalkCurveX(w, fl) + fastSin(t * w.speed + w.phase + fl * 2.6) * fl * fl * 24;
|
| 2115 |
const py = sandAt(w.x) + 8 - w.h * fl;
|
| 2116 |
const dx = px - f.x, dy = py - f.y;
|
| 2117 |
const d2 = dx * dx + dy * dy;
|
|
|
|
| 2119 |
const d = Math.sqrt(d2);
|
| 2120 |
const weight = (R - d) / R * (0.35 + 0.65 * fl); // upper segments yield more
|
| 2121 |
// shove radially away from the fish + drag along its swim direction.
|
| 2122 |
+
// v9: a nearly-still fish only gently parts leaves â no shoving, no flinch wobble
|
| 2123 |
w.offV[i] += ((dx / d) * 1500 * (0.25 + 0.75 * moveF) + fx * 9) * weight * dt;
|
| 2124 |
if (weight > 0.25 && moveF > 0.35) w.touch = Math.min(1, w.touch + dt * 5);
|
| 2125 |
}
|
|
|
|
| 2148 |
|
| 2149 |
if (w.touch > 0) w.touch = Math.max(0, w.touch - dt * 1.2);
|
| 2150 |
const baseY = sandAt(w.x) + 8;
|
| 2151 |
+
w.yMid = baseY - w.h * 0.5; // v6: stalk midpoint â the night-time gathering point
|
| 2152 |
w.points.length = 0;
|
| 2153 |
for (let i = 0; i <= w.segs; i++) {
|
| 2154 |
const f = i / w.segs;
|
| 2155 |
// normal sway + a fast extra flinch while being pushed
|
| 2156 |
+
const sway = fastSin(t * w.speed + w.phase + f * 2.6) * f * f * 24
|
| 2157 |
+
+ fastSin(t * 7 + w.phase) * w.touch * f * 16;
|
| 2158 |
// v17: secondary wobble - a finer inner movement for unique per-stalk motion
|
| 2159 |
+
const sWobble = fastSin(t * w.sFreq + w.sPh + f * 4.2) * w.sAmp * f;
|
| 2160 |
const px = w.x + (w.off[i] || 0) + stalkCurveX(w, f) + sway + sWobble;
|
| 2161 |
const py = baseY - w.h * f;
|
| 2162 |
w.points.push({ x: px, y: py });
|
|
|
|
| 2164 |
}
|
| 2165 |
for (const b of w.branches) {
|
| 2166 |
const a = w.points[Math.min(w.segs, Math.max(1, Math.round(b.f * w.segs)))];
|
| 2167 |
+
const flutter = fastSin(t * w.speed * 1.4 + b.ph) * (3 + w.touch * 9);
|
| 2168 |
b.tx = a.x + b.side * b.len + flutter;
|
| 2169 |
b.ty = a.y - b.up;
|
| 2170 |
plantPoints.push({ x: b.tx, y: b.ty, r: 5, s: w });
|
|
|
|
| 2173 |
}
|
| 2174 |
}
|
| 2175 |
|
| 2176 |
+
// Grass blades ripple when fish swim close â stronger the lower the fish is.
|
| 2177 |
+
// v4: more reactive to fish contact â wider influence radius, faster ripple build,
|
| 2178 |
// and slightly slower decay so the wave lingers a touch longer after a fish passes.
|
| 2179 |
function updateGrass(t, dt) {
|
| 2180 |
const night = nightFactor(t); // v6: after dark the fish drift toward the plants
|
|
|
|
| 2198 |
function drawSeaweed() {
|
| 2199 |
for (const w of seaweeds) {
|
| 2200 |
const pts = w.points, n = pts.length;
|
| 2201 |
+
// v15: main stipe as a tapered leaf-blade â an outline around the same physics
|
| 2202 |
// points, wide at the root and thinning to a soft point up top. No straight-line
|
| 2203 |
// segments anywhere; the stalk's own curve comes from the sway + rest-curve baked
|
| 2204 |
// into those points in updatePlants.
|
|
|
|
| 2208 |
const p = pts[i];
|
| 2209 |
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
|
| 2210 |
let dx = b.x - a.x, dy = b.y - a.y;
|
| 2211 |
+
const dl = Math.sqrt(dx*dx + dy*dy) || 1;
|
| 2212 |
const nx = -dy / dl, ny = dx / dl; // perpendicular to the local stalk direction
|
| 2213 |
const f = i / (n - 1);
|
| 2214 |
+
const hw = (w.width * (1 - f) + w.tipW * f) / 2; // half-width: base â tip taper
|
| 2215 |
if (i === 0) ctx.moveTo(p.x + nx * hw, p.y + ny * hw);
|
| 2216 |
else ctx.lineTo(p.x + nx * hw, p.y + ny * hw);
|
| 2217 |
}
|
| 2218 |
{ // rounded cap at the tip
|
| 2219 |
const tp = pts[n - 1], pp = pts[n - 2];
|
| 2220 |
let dx = tp.x - pp.x, dy = tp.y - pp.y;
|
| 2221 |
+
const dl = Math.sqrt(dx*dx + dy*dy) || 1;
|
| 2222 |
const nx = -dy / dl, ny = dx / dl;
|
| 2223 |
const hwT = w.tipW / 2;
|
| 2224 |
ctx.quadraticCurveTo(tp.x + dx, tp.y + dy * 0.6, tp.x - nx * hwT, tp.y - ny * hwT);
|
|
|
|
| 2227 |
const p = pts[i];
|
| 2228 |
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
|
| 2229 |
let dx = b.x - a.x, dy = b.y - a.y;
|
| 2230 |
+
const dl = Math.sqrt(dx*dx + dy*dy) || 1;
|
| 2231 |
const nx = -dy / dl, ny = dx / dl;
|
| 2232 |
const f = i / (n - 1);
|
| 2233 |
const hw = (w.width * (1 - f) + w.tipW * f) / 2;
|
|
|
|
| 2248 |
// side branches curling up and out from the stalk
|
| 2249 |
for (const b of w.branches) {
|
| 2250 |
const a = w.points[Math.min(w.segs, Math.max(1, Math.round(b.f * w.segs)))];
|
| 2251 |
+
// v15: S-curved frond â leaves the stalk heading upward, then arcs out and up
|
| 2252 |
// to the tip instead of one plain quadratic bow (same endpoints as before)
|
| 2253 |
const c1x = a.x + b.side * b.len * 0.3;
|
| 2254 |
const c1y = a.y - b.up * 0.75;
|
|
|
|
| 2261 |
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, b.tx, b.ty);
|
| 2262 |
ctx.stroke();
|
| 2263 |
|
| 2264 |
+
// colourful flower heads blooming along the branch â 1..3 per branch (v3)
|
| 2265 |
if (b.flowers.length) {
|
| 2266 |
for (const fl of b.flowers) {
|
| 2267 |
const u = fl.t, iu = 1 - u; // point on the branch's cubic curve (v15)
|
|
|
|
| 2294 |
|
| 2295 |
function drawGrass(t) {
|
| 2296 |
for (const g of grass) {
|
| 2297 |
+
// v16: tapered multi-segment blade â wide at root, thinning to a soft point
|
| 2298 |
const amp = 3 + g.ripple * 13;
|
| 2299 |
const n = g.segs + 1; // number of points along the blade
|
| 2300 |
const pts = [];
|
|
|
|
| 2303 |
// organic rest-curve: each blade has its own natural bend (like seaweed)
|
| 2304 |
const curveX = f * g.cA1 * Math.sin(f * g.cF1 + g.cP1);
|
| 2305 |
// constant sway, amplified while rippling from a passing fish
|
| 2306 |
+
const sway = fastSin(t * g.speed + g.phase + f * 2.4) * amp * f;
|
| 2307 |
// v17: secondary wobble - finer inner movement for unique per-blade motion
|
| 2308 |
+
const sWobble = fastSin(t * g.sFreq + g.sPh + f * 3.8) * g.sAmp * f;
|
| 2309 |
pts.push({
|
| 2310 |
x: g.x + g.lean * g.h * f + curveX + sway + sWobble,
|
| 2311 |
y: g.y - g.h * f * (1 - g.ripple * 0.06)
|
| 2312 |
});
|
| 2313 |
}
|
| 2314 |
// draw tapered outline around the physics points
|
| 2315 |
+
ctx.fillStyle = g.fillStyle;
|
| 2316 |
ctx.beginPath();
|
| 2317 |
for (let i = 0; i < n; i++) {
|
| 2318 |
const p = pts[i];
|
| 2319 |
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
|
| 2320 |
let dx = b.x - a.x, dy = b.y - a.y;
|
| 2321 |
+
const dl = Math.sqrt(dx*dx + dy*dy) || 1;
|
| 2322 |
const nx = -dy / dl, ny = dx / dl; // perpendicular to local blade direction
|
| 2323 |
const f = i / (n - 1);
|
| 2324 |
+
const hw = (g.width * (1 - f) + 0.8 * f) / 2; // half-width: base â tip taper
|
| 2325 |
if (i === 0) ctx.moveTo(p.x + nx * hw, p.y + ny * hw);
|
| 2326 |
else ctx.lineTo(p.x + nx * hw, p.y + ny * hw);
|
| 2327 |
}
|
|
|
|
| 2329 |
{
|
| 2330 |
const tp = pts[n - 1], pp = pts[n - 2];
|
| 2331 |
let dx = tp.x - pp.x, dy = tp.y - pp.y;
|
| 2332 |
+
const dl = Math.sqrt(dx*dx + dy*dy) || 1;
|
| 2333 |
const nx = -dy / dl, ny = dx / dl;
|
| 2334 |
const hwT = 0.4; // tip half-width
|
| 2335 |
ctx.quadraticCurveTo(tp.x + dx, tp.y + dy * 0.6, tp.x - nx * hwT, tp.y - ny * hwT);
|
|
|
|
| 2338 |
const p = pts[i];
|
| 2339 |
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
|
| 2340 |
let dx = b.x - a.x, dy = b.y - a.y;
|
| 2341 |
+
const dl = Math.sqrt(dx*dx + dy*dy) || 1;
|
| 2342 |
const nx = -dy / dl, ny = dx / dl;
|
| 2343 |
const f = i / (n - 1);
|
| 2344 |
const hw = (g.width * (1 - f) + 0.8 * f) / 2;
|
|
|
|
| 2349 |
}
|
| 2350 |
}
|
| 2351 |
|
| 2352 |
+
// v12: foreground grass sways with the water â decorative only, no physics.
|
| 2353 |
// Drawn on top of everything else (it is closest to the viewer). Each blade gets a
|
| 2354 |
// thin lighter inner stroke over a darker body for a lush, voluminous "closer" look.
|
| 2355 |
function drawFgGrass(t) {
|
| 2356 |
for (const g of fgGrass) {
|
| 2357 |
+
// v28: defocused blades â blit the pre-blurred sprite with a gentle rotational sway
|
| 2358 |
// around its base (same trick as the ultra-far seaweed). The baked blur hides the
|
| 2359 |
// simplified motion, and this path is far cheaper than per-frame canvas filters.
|
| 2360 |
if (g.sprite) {
|
| 2361 |
+
const swayAngle = fastSin(t * g.speed + g.phase) * 0.05; // subtle: like farSeaweeds
|
| 2362 |
ctx.save();
|
| 2363 |
ctx.translate(g.x, g.y);
|
| 2364 |
ctx.rotate(swayAngle);
|
|
|
|
| 2375 |
// organic rest-curve: each blade has its own natural bend
|
| 2376 |
const curveX = f * g.cA1 * Math.sin(f * g.cF1 + g.cP1);
|
| 2377 |
// constant sway, weighted toward the tip
|
| 2378 |
+
const sway = fastSin(t * g.speed + g.phase + f * 2.4) * amp * f;
|
| 2379 |
// v17: secondary wobble - finer inner movement for unique per-blade motion
|
| 2380 |
+
const sWobble = fastSin(t * g.sFreq + g.sPh + f * 4.5) * g.sAmp * f;
|
| 2381 |
pts.push({
|
| 2382 |
x: g.x + g.lean * g.h * f + curveX + sway + sWobble,
|
| 2383 |
y: g.y - g.h * f
|
| 2384 |
});
|
| 2385 |
}
|
| 2386 |
// dark body of the blade (tapered outline)
|
| 2387 |
+
ctx.fillStyle = g.fillStyle;
|
| 2388 |
ctx.beginPath();
|
| 2389 |
for (let i = 0; i < n; i++) {
|
| 2390 |
const p = pts[i];
|
| 2391 |
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
|
| 2392 |
let dx = b.x - a.x, dy = b.y - a.y;
|
| 2393 |
+
const dl = Math.sqrt(dx*dx + dy*dy) || 1;
|
| 2394 |
const nx = -dy / dl, ny = dx / dl;
|
| 2395 |
const f = i / (n - 1);
|
| 2396 |
+
const hw = (g.width * (1 - f) + 1.2 * f) / 2; // half-width: base â tip
|
| 2397 |
if (i === 0) ctx.moveTo(p.x + nx * hw, p.y + ny * hw);
|
| 2398 |
else ctx.lineTo(p.x + nx * hw, p.y + ny * hw);
|
| 2399 |
}
|
|
|
|
| 2401 |
{
|
| 2402 |
const tp = pts[n - 1], pp = pts[n - 2];
|
| 2403 |
let dx = tp.x - pp.x, dy = tp.y - pp.y;
|
| 2404 |
+
const dl = Math.sqrt(dx*dx + dy*dy) || 1;
|
| 2405 |
const nx = -dy / dl, ny = dx / dl;
|
| 2406 |
const hwT = 0.6; // tip half-width
|
| 2407 |
ctx.quadraticCurveTo(tp.x + dx, tp.y + dy * 0.6, tp.x - nx * hwT, tp.y - ny * hwT);
|
|
|
|
| 2410 |
const p = pts[i];
|
| 2411 |
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
|
| 2412 |
let dx = b.x - a.x, dy = b.y - a.y;
|
| 2413 |
+
const dl = Math.sqrt(dx*dx + dy*dy) || 1;
|
| 2414 |
const nx = -dy / dl, ny = dx / dl;
|
| 2415 |
const f = i / (n - 1);
|
| 2416 |
const hw = (g.width * (1 - f) + 1.2 * f) / 2;
|
|
|
|
| 2420 |
ctx.fill();
|
| 2421 |
|
| 2422 |
// lighter inner highlight for depth: a thin centerline stroke
|
| 2423 |
+
ctx.strokeStyle = g.strokeStyle;
|
| 2424 |
ctx.lineWidth = g.width * 0.3;
|
| 2425 |
ctx.lineCap = "round";
|
| 2426 |
ctx.beginPath();
|
|
|
|
| 2474 |
let grainOx = 0, grainOy = 0, lastGrainTime = 0;
|
| 2475 |
function drawGrain() {
|
| 2476 |
if (!grainPattern) return;
|
| 2477 |
+
// v17: half-speed grain â re-randomize the offset every other frame instead of every frame
|
| 2478 |
const now = performance.now();
|
| 2479 |
if (now - lastGrainTime >= 33) { // ~30fps grain updates instead of per-frame (~60fps)
|
| 2480 |
grainOx = (Math.random() * GRAIN_TILE) | 0;
|
|
|
|
| 2495 |
}
|
| 2496 |
let lastGrainDrawn = false;
|
| 2497 |
|
| 2498 |
+
// Precomputed static layers â the sand and rocks never move, so they are
|
| 2499 |
// rendered once to an offscreen canvas and blitted each frame (v6) instead of
|
| 2500 |
// re-drawing their gradients and paths every single frame.
|
| 2501 |
+
// PERF: half-resolution offscreen buffer for the light rays (see drawRays) â the
|
| 2502 |
// per-ray blur() filter is far cheaper rasterizing small areas than full-screen.
|
| 2503 |
|
| 2504 |
|
| 2505 |
+
// v24: baked current ripples â faint curved lines following the sand contour
|
| 2506 |
function drawRipples(c) {
|
| 2507 |
c.save();
|
| 2508 |
c.lineCap = "round";
|
|
|
|
| 2527 |
c.restore();
|
| 2528 |
}
|
| 2529 |
|
| 2530 |
+
// v24: baked pebble/speckle scatter â grainy cool-toned dots across the sand bed
|
| 2531 |
function drawPebbles(c) {
|
| 2532 |
const n = Math.floor(W * H / 1800);
|
| 2533 |
for (let i = 0; i < n; i++) {
|
|
|
|
| 2557 |
}
|
| 2558 |
}
|
| 2559 |
|
| 2560 |
+
// v24: contact shadows â soft dark ellipses under rocks + faint band at sand surface line
|
| 2561 |
function drawContactShadows(c) {
|
| 2562 |
// Faint darker band right at the sand surface (grounds the whole bed)
|
| 2563 |
// FIXED: follows the actual sand contour instead of a fixed horizontal band
|
|
|
|
| 2599 |
drawRipples(c2); // v24: baked current ripples (free at runtime)
|
| 2600 |
drawPebbles(c2); // v24: baked grainy pebble/speckle scatter
|
| 2601 |
drawContactShadows(c2); // v24: soft dark ellipses under rocks + surface band
|
| 2602 |
+
// NOTE: rocks are no longer baked here â they live in rocksLayer so they can be
|
| 2603 |
// drawn AFTER the mid-ground grass (rocks sit IN FRONT of the blades, not behind them).
|
| 2604 |
}
|
| 2605 |
// Rocks on their own layer: blitted after drawGrass() so stones read as sitting in
|
|
|
|
| 2618 |
let causticTile = null;
|
| 2619 |
let causticOffsetX = 0, causticOffsetY = 0;
|
| 2620 |
let lastCausticShift = 0;
|
| 2621 |
+
const CAUSTIC_TILE_SIZE = 160; // small tile â cheap to blit repeatedly
|
| 2622 |
|
| 2623 |
function buildCausticTile() {
|
| 2624 |
causticTile = document.createElement("canvas");
|
|
|
|
| 2717 |
ctx.globalCompositeOperation = "screen"; // additive light
|
| 2718 |
|
| 2719 |
for (const r of rays) {
|
| 2720 |
+
// Compute where this ray hits the sand at y â sandTop
|
| 2721 |
// Use the same wave math as the ray drawing to match positions
|
| 2722 |
+
const off = fastSin(t * r.speed * 6 + r.x) * 55;
|
| 2723 |
// At the sand depth (vM near the bottom), compute the x position
|
| 2724 |
const vM = 0.95; // approximate depth where sand starts
|
| 2725 |
const wave =
|
|
|
|
| 2785 |
drawRays(t, light);
|
| 2786 |
updateMotes(dt, t);
|
| 2787 |
|
| 2788 |
+
// v5: ultra-far pre-rendered blurry seaweed â deepest layer, behind everything else
|
| 2789 |
drawFarSeaweeds(t);
|
| 2790 |
|
| 2791 |
// v6: middle-distance seaweed with halo pass (between far and back)
|
| 2792 |
drawMidSeaweeds(t);
|
| 2793 |
|
| 2794 |
+
// v4: background seaweed â drawn behind sand/rocks for depth
|
| 2795 |
drawBackSeaweeds(t); // v4.3: procedural multi-wave motion (no separate update pass)
|
| 2796 |
|
| 2797 |
updatePlants(t, dt);
|
|
|
|
| 2805 |
// Build spatial grid for collision detection
|
| 2806 |
buildGrid();
|
| 2807 |
|
| 2808 |
+
// v7: fish gently bump each other - overlapping pairs are eased apart along their
|
| 2809 |
// center line (weighted by size), so they nudge instead of stacking on top of each other
|
| 2810 |
+
// Uses spatial grid for O(n) average instead of O(n²)
|
| 2811 |
+
// Track processed pairs by ID to avoid double-checking (which causes twitching when sleeping)
|
| 2812 |
+
const checked = new Set();
|
| 2813 |
+
for (const a of fishes) {
|
| 2814 |
+
const nearby = getFishNear(a.x, a.y);
|
| 2815 |
+
for (const b of nearby) {
|
| 2816 |
+
if (a === b) continue;
|
| 2817 |
+
// Only check each pair once using fish IDs
|
| 2818 |
+
const pairKey = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`;
|
| 2819 |
+
if (checked.has(pairKey)) continue;
|
| 2820 |
+
checked.add(pairKey);
|
| 2821 |
let dx = b.x - a.x, dy = b.y - a.y;
|
| 2822 |
const minD = (a.size + b.size) * 0.62; // gentle: smaller than their visual length
|
| 2823 |
const d2 = dx * dx + dy * dy;
|
| 2824 |
+
if (d2 <= 0.01) { // exactly overlapping - nudge apart randomly
|
| 2825 |
a.x -= rand(0.5, 1.5); a.y -= rand(0.5, 1.5);
|
| 2826 |
b.x += rand(0.5, 1.5); b.y += rand(0.5, 1.5);
|
| 2827 |
} else if (d2 < minD * minD) {
|
|
|
|
| 2835 |
}
|
| 2836 |
}
|
| 2837 |
}
|
|
|
|
|
|
|
| 2838 |
if (t - lastSortTime > 0.1) {
|
| 2839 |
// Sort fish by size for depth ordering - only re-sort every 100ms
|
| 2840 |
if (t - lastSortTime > 0.1) {
|
|
|
|
| 2872 |
const b = bubbles[i];
|
| 2873 |
b.update(dt, t);
|
| 2874 |
if (!b.dead) {
|
| 2875 |
+
// v10: a fish brushing a bubble pops it â unless it's that same fish's own bubble
|
| 2876 |
// Optimized: use spatial grid instead of checking all fish
|
| 2877 |
const nearby = getFishNear(b.x, b.y);
|
| 2878 |
for (const f of nearby) {
|
|
|
|
| 2882 |
if (dx * dx + dy * dy < rr * rr) { b.dead = true; break; }
|
| 2883 |
}
|
| 2884 |
}
|
| 2885 |
+
if (b.dead) { returnBubble(b); bubbles.splice(i, 1); continue; }
|
| 2886 |
b.draw(ctx);
|
| 2887 |
}
|
| 2888 |
|
| 2889 |
+
drawFgGrass(t); // v12: foreground grass â closest to the viewer, drawn on top
|
| 2890 |
|
| 2891 |
// ambient day/night wash over the whole scene
|
| 2892 |
if (light.washA > 0.005) {
|
|
|
|
| 2979 |
const ly = Math.abs(pad.axes[1]) > GAMEPAD_DEADZONE ? pad.axes[1] : 0;
|
| 2980 |
dx += lx;
|
| 2981 |
dy += ly;
|
| 2982 |
+
if (pad.axes[3] > GAMEPAD_DEADZONE) boost = Math.max(boost, pad.axes[3]); // down â faster
|
| 2983 |
+
if (pad.axes[3] < -GAMEPAD_DEADZONE) slow = Math.max(slow, -pad.axes[3]); // up â slower
|
| 2984 |
break; // only the first connected pad
|
| 2985 |
}
|
| 2986 |
}
|
|
|
|
| 2997 |
doResize();
|
| 2998 |
requestAnimationFrame(frame);
|
| 2999 |
})();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3000 |
|
| 3001 |
+
</script>
|
| 3002 |
</body>
|
| 3003 |
</html>
|