Upload index.html
Browse files- index.html +301 -174
index.html
CHANGED
|
@@ -23,6 +23,8 @@
|
|
| 23 |
(() => {
|
| 24 |
"use strict";
|
| 25 |
// ============ Aquarium v16 WHISKERS — alt-fg base + spots/stripes + whiskers (~10%) ==
|
|
|
|
|
|
|
| 26 |
// Based on v15 SPOTTY-3 (rolling hills, three tail styles, yellow-green foreground grass,
|
| 27 |
// ~20% spots / ~20% stripes / ~10% both), plus: an independent roll gives ~10% of fish
|
| 28 |
// whiskers (barbels) — a forward-curving pair from the snout in one of THREE lengths
|
|
@@ -290,6 +292,10 @@ class Fish {
|
|
| 290 |
this.roll = 0; // current roll angle (rad); 0 = upright
|
| 291 |
this.rollVel = 0; // angular velocity of the roll
|
| 292 |
this.inHardTurn = false;// was it in a sharp turn last frame?
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
}
|
| 294 |
update(dt) {
|
| 295 |
const night = personalNight(performance.now() / 1000, this); // v10: per-fish early/normal/late sleep & wake
|
|
@@ -460,6 +466,12 @@ class Fish {
|
|
| 460 |
if (this.roll > 1.15) { this.roll = 1.15; this.rollVel *= -0.25; }
|
| 461 |
else if (this.roll < -1.15) { this.roll = -1.15; this.rollVel *= -0.25; }
|
| 462 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
// Player fish: input-driven movement (keyboard WASD + gamepad sticks),
|
| 464 |
// overrides the AI cruising so the player is fully in control.
|
| 465 |
if (this === playerFish) {
|
|
@@ -508,12 +520,110 @@ class Fish {
|
|
| 508 |
c.save();
|
| 509 |
c.translate(this.x, this.y);
|
| 510 |
c.rotate(this.angle);
|
| 511 |
-
|
| 512 |
-
//
|
| 513 |
-
|
| 514 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
c.rotate(this.roll * 0.1);
|
| 516 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 517 |
const s = this.size;
|
| 518 |
// traveling body wave (v3): ripples head→tail so the fish undulates like a real one
|
| 519 |
const waveK = Math.PI / s; // ~one wavelength across the body length
|
|
@@ -521,6 +631,11 @@ class Fish {
|
|
| 521 |
// tail fin follows the body wave at its base, amplified (same phase → no fighting)
|
| 522 |
const sway = Math.sin(-s * waveK + this.phase) * s * 0.34;
|
| 523 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 524 |
// tail fin (v7 rounded / v11 three styles): each tip is swept around by a curve
|
| 525 |
// whose control point sits just past where the old spike was. The style is fixed
|
| 526 |
// per fish at spawn so the school has variety in its tails.
|
|
@@ -531,23 +646,23 @@ class Fish {
|
|
| 531 |
// Broad, rounded fan (betta/goldfish-style): wide spread, tips high and far out,
|
| 532 |
// with only a shallow dip between the two lobes.
|
| 533 |
c.moveTo(-s * 0.5, 0);
|
| 534 |
-
c.quadraticCurveTo(-s * 1.3, -s * 0.28 + sway, -s * 1.42, -s * 0.5 + sway); // up to upper tip
|
| 535 |
-
c.quadraticCurveTo(-s * 1.12, sway * 0.5, -s * 1.42, s * 0.5 + sway);
|
| 536 |
-
c.quadraticCurveTo(-s * 1.3, s * 0.28 + sway, -s * 0.5, 0);
|
| 537 |
} else if (this.tailType === 'streamer') {
|
| 538 |
// Long forked streamers (angelfish/fancy-guppy-style): two flowing lobes that
|
| 539 |
// reach further back with a deeper notch between them.
|
| 540 |
c.moveTo(-s * 0.5, 0);
|
| 541 |
-
c.quadraticCurveTo(-s * 1.35, -s * 0.24 + sway, -s * 1.68, -s * 0.56 + sway); // long upper lobe
|
| 542 |
-
c.quadraticCurveTo(-s * 0.98, sway * 0.35, -s * 1.68, s * 0.56 + sway);
|
| 543 |
-
c.quadraticCurveTo(-s * 1.35, s * 0.24 + sway, -s * 0.5, 0);
|
| 544 |
} else {
|
| 545 |
// classic (v7): rounded lobes with a soft notch — the original look
|
| 546 |
c.moveTo(-s * 0.5, 0);
|
| 547 |
-
c.quadraticCurveTo(-s * 1.04, -s * 0.36 + sway, -s * 1.22, -s * 0.44 + sway); // upper edge out toward the tip
|
| 548 |
-
c.quadraticCurveTo(-s * 1.34, -s * 0.52 + sway, -s * 0.86, sway * 0.5);
|
| 549 |
-
c.quadraticCurveTo(-s * 1.34, s * 0.52 + sway, -s * 1.22, s * 0.44 + sway);
|
| 550 |
-
c.quadraticCurveTo(-s * 1.04, s * 0.36 + sway, -s * 0.5, 0);
|
| 551 |
}
|
| 552 |
c.fill();
|
| 553 |
|
|
@@ -565,13 +680,15 @@ class Fish {
|
|
| 565 |
{
|
| 566 |
const side = -1; // far side (behind the body)
|
| 567 |
const rootX = s * 0.6;
|
| 568 |
-
|
|
|
|
|
|
|
| 569 |
c.beginPath();
|
| 570 |
c.moveTo(rootX, rootY);
|
| 571 |
for (let i = 1; i <= NPTS; i++) {
|
| 572 |
const t = i / NPTS;
|
| 573 |
const bx = rootX - L * (0.25 + 0.65 * spd) * t;
|
| 574 |
-
const by = rootY + side * L * 0.42 * t * t;
|
| 575 |
const amp = s * 0.17 * t * (0.35 + 0.65 * spd);
|
| 576 |
const off = Math.sin(t * 5.5 - this.phase * 1.4 + this.whiskPh) * amp;
|
| 577 |
c.lineTo(bx, by + off);
|
|
@@ -712,15 +829,39 @@ class Fish {
|
|
| 712 |
c.closePath();
|
| 713 |
c.fill();
|
| 714 |
|
| 715 |
-
//
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 724 |
|
| 725 |
// v21: near-side whisker — drawn AFTER the body so it sits in front of the fish.
|
| 726 |
// (The far-side whisker is drawn before the body, above.) Together they form a
|
|
@@ -738,13 +879,14 @@ class Fish {
|
|
| 738 |
{
|
| 739 |
const side = 1; // near side (in front of the body)
|
| 740 |
const rootX = s * 0.6;
|
| 741 |
-
|
|
|
|
| 742 |
c.beginPath();
|
| 743 |
c.moveTo(rootX, rootY);
|
| 744 |
for (let i = 1; i <= NPTS; i++) {
|
| 745 |
const t = i / NPTS;
|
| 746 |
const bx = rootX - L * (0.25 + 0.65 * spd) * t;
|
| 747 |
-
const by = rootY + side * L * 0.42 * t * t;
|
| 748 |
const amp = s * 0.17 * t * (0.35 + 0.65 * spd);
|
| 749 |
const off = Math.sin(t * 5.5 - this.phase * 1.4 + this.whiskPh) * amp;
|
| 750 |
c.lineTo(bx, by + off);
|
|
@@ -756,51 +898,27 @@ class Fish {
|
|
| 756 |
|
| 757 |
c.restore();
|
| 758 |
|
| 759 |
-
// v17: overhead light —
|
| 760 |
-
//
|
|
|
|
| 761 |
{
|
| 762 |
-
const ca = Math.cos(this.angle), sa = Math.sin(this.angle);
|
| 763 |
-
// Find which side faces up (screen space)
|
| 764 |
-
const upY = -sa; // screen-space y-component of the fish's local "up" direction
|
| 765 |
-
// Smooth transition instead of hard switch to prevent flickering/snapping
|
| 766 |
-
const sideUp = -upY / (Math.abs(upY) + 0.15);
|
| 767 |
-
|
| 768 |
const a = 0.35 * (1 - curNight * 0.65); // dimmer at night, still present
|
| 769 |
if (a > 0.02) {
|
| 770 |
-
c.save();
|
| 771 |
-
// Position the ellipse on the upper edge of the fish body
|
| 772 |
-
const offsetX = sideUp * s * 0.18; // offset along the fish's local up axis
|
| 773 |
-
const lightX = this.x + offsetX * ca;
|
| 774 |
-
const lightY = this.y + offsetX * sa;
|
| 775 |
-
|
| 776 |
-
// Draw two concentric ellipses matching the fish body proportions
|
| 777 |
-
c.translate(lightX, lightY);
|
| 778 |
-
c.rotate(this.angle);
|
| 779 |
-
|
| 780 |
-
// Outer soft glow — larger and more transparent
|
| 781 |
c.fillStyle = `rgba(255,255,255,${(a * 0.3).toFixed(3)})`;
|
| 782 |
c.beginPath();
|
| 783 |
-
c.ellipse(0, 0, s * 0.
|
| 784 |
c.fill();
|
| 785 |
-
|
| 786 |
-
// Inner bright core — smaller and more opaque
|
| 787 |
c.fillStyle = `rgba(255,255,255,${a.toFixed(3)})`;
|
| 788 |
c.beginPath();
|
| 789 |
-
c.ellipse(0, 0, s * 0.
|
| 790 |
c.fill();
|
| 791 |
-
|
| 792 |
-
c.restore();
|
| 793 |
}
|
| 794 |
}
|
| 795 |
-
// v19: pectoral fins drawn OVER the reflections for correct layering
|
|
|
|
|
|
|
| 796 |
{
|
| 797 |
c.save();
|
| 798 |
-
c.translate(this.x, this.y);
|
| 799 |
-
c.rotate(this.angle);
|
| 800 |
-
if (Math.abs(this.roll) > 0.01) {
|
| 801 |
-
c.scale(1, Math.max(0.42, Math.cos(this.roll)));
|
| 802 |
-
c.rotate(this.roll * 0.1);
|
| 803 |
-
}
|
| 804 |
|
| 805 |
// v20: render the pectoral fins at 50% opacity
|
| 806 |
c.globalAlpha = 0.5;
|
|
@@ -1017,6 +1135,8 @@ function buildScene() {
|
|
| 1017 |
const hue = clusterHue + rand(-15, 15);
|
| 1018 |
// v4.3: depth split — ~51% "far" (heavy softness), ~49% "near" (sharper) for a layered look
|
| 1019 |
const near = Math.random() < 0.49;
|
|
|
|
|
|
|
| 1020 |
const st = {
|
| 1021 |
x: cx + rand(-20, 20),
|
| 1022 |
h: rand(H * 0.35, H * 0.65), // much taller than foreground seaweed
|
|
@@ -1025,8 +1145,8 @@ function buildScene() {
|
|
| 1025 |
speed: rand(0.7, 1.3), // slower, more gentle sway
|
| 1026 |
hue,
|
| 1027 |
near,
|
| 1028 |
-
width:
|
| 1029 |
-
tipW:
|
| 1030 |
branches: [],
|
| 1031 |
// organic curve parameters (same as foreground seaweed)
|
| 1032 |
cA1: rand(8, 15), cF1: rand(0.8, 1.6), cP1: rand(0, Math.PI * 2),
|
|
@@ -1034,17 +1154,24 @@ function buildScene() {
|
|
| 1034 |
// v4.3: multi-wave organic motion parameters
|
| 1035 |
waveA1: rand(6, 14), waveF1: rand(0.5, 1.2), wavePh1: rand(0, Math.PI * 2),
|
| 1036 |
waveA2: rand(3, 8), waveF2: rand(1.3, 2.1), wavePh2: rand(0, Math.PI * 2),
|
| 1037 |
-
waveA3: rand(2, 5), waveF3: rand(2.4, 3.6), wavePh3: rand(0, Math.PI * 2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1038 |
};
|
| 1039 |
// v4.1: add branches to background seaweed (like foreground)
|
| 1040 |
const nBr = 1 + ((Math.random() * 3) | 0); // 1..3 branches per stalk
|
| 1041 |
for (let k = 0; k < nBr; k++) {
|
|
|
|
| 1042 |
st.branches.push({
|
| 1043 |
f: rand(0.25, 0.85), // where along the stalk it sprouts
|
| 1044 |
side: Math.random() < 0.5 ? -1 : 1,
|
| 1045 |
len: rand(10, 24), // horizontal reach (slightly smaller than fg)
|
| 1046 |
up: rand(8, 20), // how far it curls upward
|
| 1047 |
-
w:
|
|
|
|
| 1048 |
ph: rand(0, Math.PI * 2)
|
| 1049 |
});
|
| 1050 |
}
|
|
@@ -1062,6 +1189,8 @@ function buildScene() {
|
|
| 1062 |
const nStalks = 2 + ((Math.random() * 3) | 0);
|
| 1063 |
for (let j = 0; j < nStalks; j++) {
|
| 1064 |
const hue = clusterHue + rand(-12, 12);
|
|
|
|
|
|
|
| 1065 |
// Median properties: height between backSeaweeds (0.35-0.65H) and farSeaweeds (0.4-0.7H)
|
| 1066 |
const st = {
|
| 1067 |
x: cx + rand(-22, 22),
|
|
@@ -1070,8 +1199,8 @@ function buildScene() {
|
|
| 1070 |
phase: rand(0, Math.PI * 2),
|
| 1071 |
speed: rand(0.6, 1.15), // median sway speed between back (0.7-1.3) and far (0.5-1.0)
|
| 1072 |
hue,
|
| 1073 |
-
width:
|
| 1074 |
-
tipW:
|
| 1075 |
branches: [],
|
| 1076 |
// Organic curve parameters (like backSeaweeds for halo pass technique)
|
| 1077 |
cA1: rand(9, 16), cF1: rand(0.7, 1.5), cP1: rand(0, Math.PI * 2),
|
|
@@ -1079,17 +1208,24 @@ function buildScene() {
|
|
| 1079 |
// Multi-wave organic motion parameters
|
| 1080 |
waveA1: rand(7, 15), waveF1: rand(0.45, 1.1), wavePh1: rand(0, Math.PI * 2),
|
| 1081 |
waveA2: rand(3.5, 9), waveF2: rand(1.2, 2.0), wavePh2: rand(0, Math.PI * 2),
|
| 1082 |
-
waveA3: rand(2.5, 6), waveF3: rand(2.2, 3.4), wavePh3: rand(0, Math.PI * 2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1083 |
};
|
| 1084 |
// Add branches (median count between back and far)
|
| 1085 |
const nBr = 1 + ((Math.random() * 3) | 0);
|
| 1086 |
for (let k = 0; k < nBr; k++) {
|
|
|
|
| 1087 |
st.branches.push({
|
| 1088 |
f: rand(0.28, 0.82),
|
| 1089 |
side: Math.random() < 0.5 ? -1 : 1,
|
| 1090 |
len: rand(11, 26),
|
| 1091 |
up: rand(9, 22),
|
| 1092 |
-
w:
|
|
|
|
| 1093 |
ph: rand(0, Math.PI * 2)
|
| 1094 |
});
|
| 1095 |
}
|
|
@@ -1324,12 +1460,21 @@ function personalNight(t, f) {
|
|
| 1324 |
return c(g + f.riseShift);
|
| 1325 |
}
|
| 1326 |
|
|
|
|
|
|
|
| 1327 |
function drawBackground(light) {
|
| 1328 |
-
|
| 1329 |
-
|
| 1330 |
-
|
| 1331 |
-
|
| 1332 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1333 |
ctx.fillRect(0, 0, W, H);
|
| 1334 |
}
|
| 1335 |
|
|
@@ -1541,87 +1686,76 @@ function stalkCurveX(w, f) {
|
|
| 1541 |
return f * (w.cA1 * Math.sin(f * w.cF1 + w.cP1) + w.cA2 * Math.sin(f * w.cF2 + w.cP2));
|
| 1542 |
}
|
| 1543 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1544 |
// v4.3: draw background seaweed procedurally — multi-wave organic motion instead of
|
| 1545 |
// rigid sprite rotation. Far stalks get a soft low-alpha halo pass to keep their
|
| 1546 |
// "far away" look without any runtime blur filters (no slowdown).
|
| 1547 |
// v6: middle-distance seaweed using halo stroke pass (median depth between back and far)
|
| 1548 |
function drawMidSeaweeds(t) {
|
|
|
|
|
|
|
|
|
|
| 1549 |
for (const w of midSeaweeds) {
|
| 1550 |
const baseY = sandAt(w.x) + 10;
|
| 1551 |
const n = w.segs;
|
| 1552 |
-
//
|
| 1553 |
-
const
|
| 1554 |
-
const
|
| 1555 |
-
|
| 1556 |
-
|
| 1557 |
-
|
| 1558 |
-
|
| 1559 |
-
|
| 1560 |
-
Math.sin(t * w.waveF3 + w.wavePh3 + f * 5.4) * w.waveA3 * f;
|
| 1561 |
|
| 1562 |
// Wider halo pass for more softness (between backSeaweeds and farSeaweeds)
|
| 1563 |
for (let pass = 0; pass < 2; pass++) {
|
| 1564 |
const isHalo = pass === 0;
|
| 1565 |
-
ctx.strokeStyle = isHalo ? haloStyle : coreStyle;
|
| 1566 |
-
ctx.
|
| 1567 |
-
ctx.lineJoin = 'round';
|
| 1568 |
-
ctx.lineWidth = (w.width + w.tipW) / 2 * (isHalo ? 2.2 : 1); // wider halo than backSeaweeds
|
| 1569 |
|
| 1570 |
-
// stalk outline: up one side,
|
| 1571 |
ctx.beginPath();
|
| 1572 |
-
let prevX = 0, prevY = baseY;
|
| 1573 |
-
let lastPx = 0, lastPy = baseY;
|
| 1574 |
for (let i = 0; i <= n; i++) {
|
| 1575 |
const f = i / n;
|
| 1576 |
-
const px =
|
| 1577 |
-
const py = baseY - w.h * f;
|
| 1578 |
const dx = px - prevX, dy = py - prevY;
|
| 1579 |
-
const dl = Math.
|
| 1580 |
const nx = -dy / dl, ny = dx / dl;
|
| 1581 |
-
const hw = (w.width * (1 - f) + w.tipW * f)
|
| 1582 |
if (i === 0) ctx.moveTo(px + nx * hw, py + ny * hw);
|
| 1583 |
else ctx.lineTo(px + nx * hw, py + ny * hw);
|
| 1584 |
prevX = px; prevY = py;
|
| 1585 |
-
lastPx = px; lastPy = py;
|
| 1586 |
}
|
| 1587 |
-
//
|
| 1588 |
-
|
| 1589 |
-
const dlT = Math.hypot(dxT, dyT) || 1;
|
| 1590 |
-
const nxT = -dyT / dlT, nyT = dxT / dlT;
|
| 1591 |
-
ctx.quadraticCurveTo(lastPx, lastPy, lastPx + dxT * 0.5 + nxT * (w.tipW / 2), lastPy + dyT * 0.5 + nyT * (w.tipW / 2));
|
| 1592 |
-
// down the other side
|
| 1593 |
-
for (let i = n; i >= 0; i--) {
|
| 1594 |
const f = i / n;
|
| 1595 |
-
const px =
|
| 1596 |
-
const
|
| 1597 |
-
|
| 1598 |
-
if (i === n) { dx = prevX - px; dy = prevY - py; }
|
| 1599 |
-
else {
|
| 1600 |
-
const f2 = (i + 1) / n;
|
| 1601 |
-
const px2 = w.x + stalkCurveX(w, f2) + swayAt(f2);
|
| 1602 |
-
const py2 = baseY - w.h * f2;
|
| 1603 |
-
dx = px2 - px; dy = py2 - py;
|
| 1604 |
-
}
|
| 1605 |
-
const dl = Math.hypot(dx, dy) || 1;
|
| 1606 |
const nx = -dy / dl, ny = dx / dl;
|
| 1607 |
-
const hw = (w.width * (1 - f) + w.tipW * f)
|
| 1608 |
ctx.lineTo(px - nx * hw, py - ny * hw);
|
| 1609 |
}
|
| 1610 |
ctx.closePath();
|
| 1611 |
ctx.stroke();
|
| 1612 |
|
| 1613 |
-
// branches flutter with the stalk
|
| 1614 |
for (const b of w.branches) {
|
| 1615 |
const idx = Math.min(n, Math.max(1, Math.round(b.f * n)));
|
| 1616 |
-
const
|
| 1617 |
-
const ax = w.x + stalkCurveX(w, f) + swayAt(f);
|
| 1618 |
-
const ay = baseY - w.h * f;
|
| 1619 |
const flutter = Math.sin(t * w.waveF2 * 1.4 + b.ph) * (3 + w.waveA2 * 0.4);
|
| 1620 |
const bx = ax + b.side * b.len + flutter;
|
| 1621 |
const by = ay - b.up;
|
| 1622 |
ctx.beginPath();
|
| 1623 |
-
ctx.lineWidth = isHalo ? b.
|
| 1624 |
-
ctx.strokeStyle = isHalo ? haloStyle :
|
| 1625 |
ctx.moveTo(ax, ay);
|
| 1626 |
ctx.bezierCurveTo(
|
| 1627 |
ax + b.side * b.len * 0.3, ay - b.up * 0.75,
|
|
@@ -1637,82 +1771,63 @@ function drawMidSeaweeds(t) {
|
|
| 1637 |
function drawBackSeaweeds(t) {
|
| 1638 |
// v6: no runtime canvas filter — blur() forces a CPU re-rasterize every frame
|
| 1639 |
// and was the main FPS cost. Softness now comes from the halo pass below instead.
|
|
|
|
|
|
|
| 1640 |
for (const w of backSeaweeds) {
|
| 1641 |
const baseY = sandAt(w.x) + 10;
|
| 1642 |
const n = w.segs;
|
| 1643 |
-
|
| 1644 |
-
const
|
| 1645 |
-
|
| 1646 |
-
|
| 1647 |
-
|
| 1648 |
-
|
| 1649 |
-
|
| 1650 |
-
|
| 1651 |
|
| 1652 |
// v6: near stalks also get a halo pass (replaces the old blur(2px) filter)
|
| 1653 |
for (let pass = 0; pass < 2; pass++) {
|
| 1654 |
-
const isHalo = pass === 0;
|
| 1655 |
-
ctx.strokeStyle = isHalo ? haloStyle : coreStyle;
|
| 1656 |
-
ctx.
|
| 1657 |
-
ctx.lineJoin = 'round';
|
| 1658 |
-
ctx.lineWidth = (w.width + w.tipW) / 2 * (isHalo ? 1.9 : 1);
|
| 1659 |
|
| 1660 |
-
// stalk outline: up one side,
|
| 1661 |
ctx.beginPath();
|
| 1662 |
-
let prevX = 0, prevY = baseY;
|
| 1663 |
-
let lastPx = 0, lastPy = baseY; // track final position for tip cap
|
| 1664 |
for (let i = 0; i <= n; i++) {
|
| 1665 |
const f = i / n;
|
| 1666 |
-
const px =
|
| 1667 |
-
const py = baseY - w.h * f;
|
| 1668 |
const dx = px - prevX, dy = py - prevY;
|
| 1669 |
-
const dl = Math.
|
| 1670 |
const nx = -dy / dl, ny = dx / dl;
|
| 1671 |
-
const hw = (w.width * (1 - f) + w.tipW * f)
|
| 1672 |
if (i === 0) ctx.moveTo(px + nx * hw, py + ny * hw);
|
| 1673 |
else ctx.lineTo(px + nx * hw, py + ny * hw);
|
| 1674 |
prevX = px; prevY = py;
|
| 1675 |
-
lastPx = px; lastPy = py;
|
| 1676 |
}
|
| 1677 |
-
//
|
| 1678 |
-
|
| 1679 |
-
const dlT = Math.hypot(dxT, dyT) || 1;
|
| 1680 |
-
const nxT = -dyT / dlT, nyT = dxT / dlT;
|
| 1681 |
-
ctx.quadraticCurveTo(lastPx, lastPy, lastPx + dxT * 0.5 + nxT * (w.tipW / 2), lastPy + dyT * 0.5 + nyT * (w.tipW / 2));
|
| 1682 |
-
// down the other side
|
| 1683 |
-
for (let i = n; i >= 0; i--) {
|
| 1684 |
const f = i / n;
|
| 1685 |
-
const px =
|
| 1686 |
-
const
|
| 1687 |
-
|
| 1688 |
-
let dx, dy;
|
| 1689 |
-
if (i === n) { dx = prevX - px; dy = prevY - py; }
|
| 1690 |
-
else {
|
| 1691 |
-
const f2 = (i + 1) / n;
|
| 1692 |
-
const px2 = w.x + stalkCurveX(w, f2) + swayAt(f2);
|
| 1693 |
-
const py2 = baseY - w.h * f2;
|
| 1694 |
-
dx = px2 - px; dy = py2 - py;
|
| 1695 |
-
}
|
| 1696 |
-
const dl = Math.hypot(dx, dy) || 1;
|
| 1697 |
const nx = -dy / dl, ny = dx / dl;
|
| 1698 |
-
const hw = (w.width * (1 - f) + w.tipW * f)
|
| 1699 |
ctx.lineTo(px - nx * hw, py - ny * hw);
|
| 1700 |
}
|
| 1701 |
ctx.closePath();
|
| 1702 |
ctx.stroke();
|
| 1703 |
|
| 1704 |
-
// branches flutter with the stalk
|
| 1705 |
for (const b of w.branches) {
|
| 1706 |
const idx = Math.min(n, Math.max(1, Math.round(b.f * n)));
|
| 1707 |
-
const
|
| 1708 |
-
const ax = w.x + stalkCurveX(w, f) + swayAt(f);
|
| 1709 |
-
const ay = baseY - w.h * f;
|
| 1710 |
const flutter = Math.sin(t * w.waveF2 * 1.4 + b.ph) * (3 + w.waveA2 * 0.4);
|
| 1711 |
const bx = ax + b.side * b.len + flutter;
|
| 1712 |
const by = ay - b.up;
|
| 1713 |
ctx.beginPath();
|
| 1714 |
-
ctx.lineWidth = isHalo ? b.
|
| 1715 |
-
ctx.strokeStyle = isHalo ? haloStyle :
|
| 1716 |
ctx.moveTo(ax, ay);
|
| 1717 |
ctx.bezierCurveTo(
|
| 1718 |
ax + b.side * b.len * 0.3, ay - b.up * 0.75,
|
|
@@ -2503,7 +2618,25 @@ function drawCaustics(t) {
|
|
| 2503 |
ctx.restore();
|
| 2504 |
}
|
| 2505 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2506 |
function drawLightPooling(t) {
|
|
|
|
| 2507 |
// Draw faint bright patches where each god-ray hits the sand
|
| 2508 |
const sandTop = H * 0.85;
|
| 2509 |
ctx.save();
|
|
@@ -2520,16 +2653,10 @@ function drawLightPooling(t) {
|
|
| 2520 |
Math.sin(vM * r.vLen - t * r.vSpd + r.vPh) * r.vAmp;
|
| 2521 |
const dx = r.x + off + wave * (0.35 + 0.65 * vM);
|
| 2522 |
|
| 2523 |
-
// Draw a soft bright ellipse at this position on the sand
|
| 2524 |
const poolW = r.w * 0.7, poolH = 18;
|
| 2525 |
-
|
| 2526 |
-
|
| 2527 |
-
g.addColorStop(0, `rgba(255,255,255,${r.alpha * 1.75})`); // v26: +25% brighter
|
| 2528 |
-
g.addColorStop(1, "rgba(255,255,255,0)");
|
| 2529 |
-
ctx.fillStyle = g;
|
| 2530 |
-
ctx.beginPath();
|
| 2531 |
-
ctx.ellipse(dx, sandTop + 12, poolW, poolH, 0, 0, Math.PI * 2);
|
| 2532 |
-
ctx.fill();
|
| 2533 |
}
|
| 2534 |
|
| 2535 |
ctx.restore();
|
|
|
|
| 23 |
(() => {
|
| 24 |
"use strict";
|
| 25 |
// ============ Aquarium v16 WHISKERS — alt-fg base + spots/stripes + whiskers (~10%) ==
|
| 26 |
+
// v22: Ecco-style rendering — fish show top/bottom view when moving vertically,
|
| 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) — a forward-curving pair from the snout in one of THREE lengths
|
|
|
|
| 292 |
this.roll = 0; // current roll angle (rad); 0 = upright
|
| 293 |
this.rollVel = 0; // angular velocity of the roll
|
| 294 |
this.inHardTurn = false;// was it in a sharp turn last frame?
|
| 295 |
+
|
| 296 |
+
// v22: Ecco-style viewing angle — determines whether we see the fish from the side
|
| 297 |
+
// or from above/below based on its movement direction (0 = side, 1 = top/bottom)
|
| 298 |
+
this.viewAngle = 0; // current interpolated view angle
|
| 299 |
}
|
| 300 |
update(dt) {
|
| 301 |
const night = personalNight(performance.now() / 1000, this); // v10: per-fish early/normal/late sleep & wake
|
|
|
|
| 466 |
if (this.roll > 1.15) { this.roll = 1.15; this.rollVel *= -0.25; }
|
| 467 |
else if (this.roll < -1.15) { this.roll = -1.15; this.rollVel *= -0.25; }
|
| 468 |
|
| 469 |
+
// v22: Ecco-style viewing angle — when moving vertically, we see the fish from above/below;
|
| 470 |
+
// when moving horizontally, we see its side profile. Smoothly interpolate between views.
|
| 471 |
+
// sin(angle) gives 0 for horizontal (0, π) and ±1 for vertical (±π/2)
|
| 472 |
+
const targetView = Math.abs(Math.sin(this.angle));
|
| 473 |
+
this.viewAngle += (targetView - this.viewAngle) * Math.min(1, dt * 4);
|
| 474 |
+
|
| 475 |
// Player fish: input-driven movement (keyboard WASD + gamepad sticks),
|
| 476 |
// overrides the AI cruising so the player is fully in control.
|
| 477 |
if (this === playerFish) {
|
|
|
|
| 520 |
c.save();
|
| 521 |
c.translate(this.x, this.y);
|
| 522 |
c.rotate(this.angle);
|
| 523 |
+
|
| 524 |
+
// v23: Ecco-style perspective WITHOUT a second picture. We keep the one fully-
|
| 525 |
+
// detailed body and foreshorten it as the fish turns to swim vertically.
|
| 526 |
+
// viewAngle 0 = pure side profile (one eye), 1 = seen from above/below (the back).
|
| 527 |
+
// Foreshortening along local up/down makes the dorsal fin slide toward center and
|
| 528 |
+
// the body slim down, reading as "seeing the back" — no snapping, nothing vanishes.
|
| 529 |
+
const v = Math.max(0, Math.min(1, this.viewAngle));
|
| 530 |
+
let scaleY = 1 - 0.34 * v; // foreshorten toward the back view
|
| 531 |
+
if (Math.abs(this.roll) > 0.01) { // banking roll still squashes a touch
|
| 532 |
+
scaleY *= Math.max(0.42, Math.cos(this.roll));
|
| 533 |
c.rotate(this.roll * 0.1);
|
| 534 |
}
|
| 535 |
+
c.scale(1, scaleY);
|
| 536 |
+
|
| 537 |
+
this.drawSideView(c); // one body: wave + patterns + fins always on
|
| 538 |
+
c.restore();
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
// v22: Draw fish from above or below (Ecco-style top/bottom view)
|
| 542 |
+
drawTopBottomView(c) {
|
| 543 |
+
const s = this.size;
|
| 544 |
+
// traveling body wave
|
| 545 |
+
const waveK = Math.PI / s;
|
| 546 |
+
const waveAmp = s * 0.12;
|
| 547 |
+
const sway = Math.sin(-s * waveK + this.phase) * s * 0.34;
|
| 548 |
+
|
| 549 |
+
// Top/bottom view: body seen from above or below
|
| 550 |
+
// Dorsal fin visible as a line down the center when viewed from above
|
| 551 |
+
const g = this.bodyGrad;
|
| 552 |
+
const NSEG = 26;
|
| 553 |
+
|
| 554 |
+
// Build body outline (same as side view but we're looking at it from top/bottom)
|
| 555 |
+
const bodyPath = new Path2D();
|
| 556 |
+
for (let i = 0; i <= NSEG; i++) {
|
| 557 |
+
const x = s - (i / NSEG) * 2 * s;
|
| 558 |
+
// Wider body when viewed from top/bottom (we see the full width)
|
| 559 |
+
const hh = s * 0.55 * Math.sqrt(Math.max(0, 1 - (x / s) ** 2));
|
| 560 |
+
if (i === 0) bodyPath.moveTo(x, -hh); else bodyPath.lineTo(x, -hh);
|
| 561 |
+
}
|
| 562 |
+
for (let i = NSEG; i >= 0; i--) {
|
| 563 |
+
const x = s - (i / NSEG) * 2 * s;
|
| 564 |
+
const hh = s * 0.55 * Math.sqrt(Math.max(0, 1 - (x / s) ** 2));
|
| 565 |
+
bodyPath.lineTo(x, hh);
|
| 566 |
+
}
|
| 567 |
+
bodyPath.closePath();
|
| 568 |
+
|
| 569 |
+
c.fillStyle = g;
|
| 570 |
+
c.fill(bodyPath);
|
| 571 |
+
|
| 572 |
+
// Dorsal fin — visible as a line/shape down the center when viewed from above
|
| 573 |
+
c.strokeStyle = this.tailFillStyle;
|
| 574 |
+
c.lineWidth = s * 0.15;
|
| 575 |
+
c.lineCap = "round";
|
| 576 |
+
c.beginPath();
|
| 577 |
+
c.moveTo(s * 0.3, 0);
|
| 578 |
+
// Curved dorsal fin running down the center
|
| 579 |
+
c.quadraticCurveTo(-s * 0.2, -sway * 0.5, -s * 0.6, sway * 0.3);
|
| 580 |
+
c.stroke();
|
| 581 |
+
|
| 582 |
+
// Eyes — visible on both sides of the head when viewed from top/bottom
|
| 583 |
+
c.fillStyle = "#fff";
|
| 584 |
+
// Left eye
|
| 585 |
+
c.beginPath();
|
| 586 |
+
c.arc(s * 0.55, -s * 0.25, s * 0.1, 0, Math.PI * 2);
|
| 587 |
+
c.fill();
|
| 588 |
+
c.fillStyle = "#0d1420";
|
| 589 |
+
c.beginPath();
|
| 590 |
+
c.arc(s * 0.57, -s * 0.25, s * 0.05, 0, Math.PI * 2);
|
| 591 |
+
c.fill();
|
| 592 |
+
// Right eye
|
| 593 |
+
c.fillStyle = "#fff";
|
| 594 |
+
c.beginPath();
|
| 595 |
+
c.arc(s * 0.55, s * 0.25, s * 0.1, 0, Math.PI * 2);
|
| 596 |
+
c.fill();
|
| 597 |
+
c.fillStyle = "#0d1420";
|
| 598 |
+
c.beginPath();
|
| 599 |
+
c.arc(s * 0.57, s * 0.25, s * 0.05, 0, Math.PI * 2);
|
| 600 |
+
c.fill();
|
| 601 |
+
|
| 602 |
+
// Tail fin (simplified for top/bottom view)
|
| 603 |
+
c.fillStyle = this.tailFillStyle;
|
| 604 |
+
c.beginPath();
|
| 605 |
+
if (this.tailType === 'fan') {
|
| 606 |
+
c.moveTo(-s * 0.5, 0);
|
| 607 |
+
c.quadraticCurveTo(-s * 1.3, -s * 0.28 + sway, -s * 1.42, -s * 0.5 + sway);
|
| 608 |
+
c.quadraticCurveTo(-s * 1.12, sway * 0.5, -s * 1.42, s * 0.5 + sway);
|
| 609 |
+
c.quadraticCurveTo(-s * 1.3, s * 0.28 + sway, -s * 0.5, 0);
|
| 610 |
+
} else if (this.tailType === 'streamer') {
|
| 611 |
+
c.moveTo(-s * 0.5, 0);
|
| 612 |
+
c.quadraticCurveTo(-s * 1.35, -s * 0.24 + sway, -s * 1.68, -s * 0.56 + sway);
|
| 613 |
+
c.quadraticCurveTo(-s * 0.98, sway * 0.35, -s * 1.68, s * 0.56 + sway);
|
| 614 |
+
c.quadraticCurveTo(-s * 1.35, s * 0.24 + sway, -s * 0.5, 0);
|
| 615 |
+
} else {
|
| 616 |
+
c.moveTo(-s * 0.5, 0);
|
| 617 |
+
c.quadraticCurveTo(-s * 1.04, -s * 0.36 + sway, -s * 1.22, -s * 0.44 + sway);
|
| 618 |
+
c.quadraticCurveTo(-s * 1.34, -s * 0.52 + sway, -s * 0.86, sway * 0.5);
|
| 619 |
+
c.quadraticCurveTo(-s * 1.34, s * 0.52 + sway, -s * 1.22, s * 0.44 + sway);
|
| 620 |
+
c.quadraticCurveTo(-s * 1.04, s * 0.36 + sway, -s * 0.5, 0);
|
| 621 |
+
}
|
| 622 |
+
c.fill();
|
| 623 |
+
}
|
| 624 |
+
|
| 625 |
+
// v22: Draw fish from the side (original view)
|
| 626 |
+
drawSideView(c) {
|
| 627 |
const s = this.size;
|
| 628 |
// traveling body wave (v3): ripples head→tail so the fish undulates like a real one
|
| 629 |
const waveK = Math.PI / s; // ~one wavelength across the body length
|
|
|
|
| 631 |
// tail fin follows the body wave at its base, amplified (same phase → no fighting)
|
| 632 |
const sway = Math.sin(-s * waveK + this.phase) * s * 0.34;
|
| 633 |
|
| 634 |
+
// v24: Ecco-style blend factor (0 = side profile, 1 = seen from above/below).
|
| 635 |
+
// Flattens the tail lobes and spreads eyes/barbels as we see more of the back.
|
| 636 |
+
const ev = Math.max(0, Math.min(1, this.viewAngle));
|
| 637 |
+
const tailFlat = 1 - 0.4 * ev; // extra foreshortening for the caudal lobes only
|
| 638 |
+
|
| 639 |
// tail fin (v7 rounded / v11 three styles): each tip is swept around by a curve
|
| 640 |
// whose control point sits just past where the old spike was. The style is fixed
|
| 641 |
// per fish at spawn so the school has variety in its tails.
|
|
|
|
| 646 |
// Broad, rounded fan (betta/goldfish-style): wide spread, tips high and far out,
|
| 647 |
// with only a shallow dip between the two lobes.
|
| 648 |
c.moveTo(-s * 0.5, 0);
|
| 649 |
+
c.quadraticCurveTo(-s * 1.3, -s * 0.28 * tailFlat + sway, -s * 1.42, -s * 0.5 * tailFlat + sway); // up to upper tip
|
| 650 |
+
c.quadraticCurveTo(-s * 1.12, sway * 0.5, -s * 1.42, s * 0.5 * tailFlat + sway); // shallow notch → lower tip
|
| 651 |
+
c.quadraticCurveTo(-s * 1.3, s * 0.28 * tailFlat + sway, -s * 0.5, 0); // back to the base
|
| 652 |
} else if (this.tailType === 'streamer') {
|
| 653 |
// Long forked streamers (angelfish/fancy-guppy-style): two flowing lobes that
|
| 654 |
// reach further back with a deeper notch between them.
|
| 655 |
c.moveTo(-s * 0.5, 0);
|
| 656 |
+
c.quadraticCurveTo(-s * 1.35, -s * 0.24 * tailFlat + sway, -s * 1.68, -s * 0.56 * tailFlat + sway); // long upper lobe
|
| 657 |
+
c.quadraticCurveTo(-s * 0.98, sway * 0.35, -s * 1.68, s * 0.56 * tailFlat + sway); // deep fork → lower tip
|
| 658 |
+
c.quadraticCurveTo(-s * 1.35, s * 0.24 * tailFlat + sway, -s * 0.5, 0); // long lower lobe back to base
|
| 659 |
} else {
|
| 660 |
// classic (v7): rounded lobes with a soft notch — the original look
|
| 661 |
c.moveTo(-s * 0.5, 0);
|
| 662 |
+
c.quadraticCurveTo(-s * 1.04, -s * 0.36 * tailFlat + sway, -s * 1.22, -s * 0.44 * tailFlat + sway); // upper edge out toward the tip
|
| 663 |
+
c.quadraticCurveTo(-s * 1.34, -s * 0.52 * tailFlat + sway, -s * 0.86, sway * 0.5); // rounded upper lobe → notch
|
| 664 |
+
c.quadraticCurveTo(-s * 1.34, s * 0.52 * tailFlat + sway, -s * 1.22, s * 0.44 * tailFlat + sway); // notch → rounded lower lobe
|
| 665 |
+
c.quadraticCurveTo(-s * 1.04, s * 0.36 * tailFlat + sway, -s * 0.5, 0); // lower edge back to the base
|
| 666 |
}
|
| 667 |
c.fill();
|
| 668 |
|
|
|
|
| 680 |
{
|
| 681 |
const side = -1; // far side (behind the body)
|
| 682 |
const rootX = s * 0.6;
|
| 683 |
+
// v24: from above, barbels sit on either side of the mouth — spread the roots
|
| 684 |
+
// apart and flare each whisker further out as the fish "rotates" to swim vertically
|
| 685 |
+
const rootY = side * s * (0.09 + 0.07 * ev);
|
| 686 |
c.beginPath();
|
| 687 |
c.moveTo(rootX, rootY);
|
| 688 |
for (let i = 1; i <= NPTS; i++) {
|
| 689 |
const t = i / NPTS;
|
| 690 |
const bx = rootX - L * (0.25 + 0.65 * spd) * t;
|
| 691 |
+
const by = rootY + side * L * (0.42 + 0.3 * ev) * t * t;
|
| 692 |
const amp = s * 0.17 * t * (0.35 + 0.65 * spd);
|
| 693 |
const off = Math.sin(t * 5.5 - this.phase * 1.4 + this.whiskPh) * amp;
|
| 694 |
c.lineTo(bx, by + off);
|
|
|
|
| 829 |
c.closePath();
|
| 830 |
c.fill();
|
| 831 |
|
| 832 |
+
// v24: both eyes — as the fish turns to swim vertically (ev→1) we see them from
|
| 833 |
+
// above: the pair spreads wider on either side of the head and each eye squashes
|
| 834 |
+
// into a flatter ellipse (perspective), instead of two overlapping circles.
|
| 835 |
+
{
|
| 836 |
+
const eyeRY = 0.11 * (1 - 0.35 * ev); // vertical squash of the white
|
| 837 |
+
const pupRY = 0.055 * (1 - 0.35 * ev); // pupil squashes with it
|
| 838 |
+
const yNear = -s * (0.1 + 0.1 * ev); // near eye slides outward as we see more of the back
|
| 839 |
+
c.fillStyle = "#fff";
|
| 840 |
+
c.beginPath();
|
| 841 |
+
c.ellipse(s * 0.58, yNear, s * 0.11, s * eyeRY, 0, 0, Math.PI * 2);
|
| 842 |
+
c.fill();
|
| 843 |
+
c.fillStyle = "#0d1420";
|
| 844 |
+
c.beginPath();
|
| 845 |
+
c.ellipse(s * 0.61, yNear, s * 0.055, s * pupRY, 0, 0, Math.PI * 2);
|
| 846 |
+
c.fill();
|
| 847 |
+
|
| 848 |
+
// far-side eye fades in as we see more of the back (Ecco-style top view) —
|
| 849 |
+
// two eyes flanking the head when the fish turns to swim vertically.
|
| 850 |
+
if (ev > 0.03) {
|
| 851 |
+
const yFar = s * (0.14 + 0.08 * ev); // far eye slides outward too — pair widens
|
| 852 |
+
c.save();
|
| 853 |
+
c.globalAlpha = ev * 0.9;
|
| 854 |
+
c.fillStyle = "#fff";
|
| 855 |
+
c.beginPath();
|
| 856 |
+
c.ellipse(s * 0.58, yFar, s * 0.11, s * eyeRY, 0, 0, Math.PI * 2);
|
| 857 |
+
c.fill();
|
| 858 |
+
c.fillStyle = "#0d1420";
|
| 859 |
+
c.beginPath();
|
| 860 |
+
c.ellipse(s * 0.61, yFar, s * 0.055, s * pupRY, 0, 0, Math.PI * 2);
|
| 861 |
+
c.fill();
|
| 862 |
+
c.restore();
|
| 863 |
+
}
|
| 864 |
+
}
|
| 865 |
|
| 866 |
// v21: near-side whisker — drawn AFTER the body so it sits in front of the fish.
|
| 867 |
// (The far-side whisker is drawn before the body, above.) Together they form a
|
|
|
|
| 879 |
{
|
| 880 |
const side = 1; // near side (in front of the body)
|
| 881 |
const rootX = s * 0.6;
|
| 882 |
+
// v24: same spread/flare as the far whisker — a pair flanking the snout from above
|
| 883 |
+
const rootY = side * s * (0.09 + 0.07 * ev);
|
| 884 |
c.beginPath();
|
| 885 |
c.moveTo(rootX, rootY);
|
| 886 |
for (let i = 1; i <= NPTS; i++) {
|
| 887 |
const t = i / NPTS;
|
| 888 |
const bx = rootX - L * (0.25 + 0.65 * spd) * t;
|
| 889 |
+
const by = rootY + side * L * (0.42 + 0.3 * ev) * t * t;
|
| 890 |
const amp = s * 0.17 * t * (0.35 + 0.65 * spd);
|
| 891 |
const off = Math.sin(t * 5.5 - this.phase * 1.4 + this.whiskPh) * amp;
|
| 892 |
c.lineTo(bx, by + off);
|
|
|
|
| 898 |
|
| 899 |
c.restore();
|
| 900 |
|
| 901 |
+
// v17/v23: overhead light — soft elliptical highlight on the fish's upper edge.
|
| 902 |
+
// Drawn in local coords (draw() already positioned + rotated us) so it rides with
|
| 903 |
+
// the body and foreshortens correctly instead of being double-offset.
|
| 904 |
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 905 |
const a = 0.35 * (1 - curNight * 0.65); // dimmer at night, still present
|
| 906 |
if (a > 0.02) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 907 |
c.fillStyle = `rgba(255,255,255,${(a * 0.3).toFixed(3)})`;
|
| 908 |
c.beginPath();
|
| 909 |
+
c.ellipse(s * 0.05, -s * 0.26, s * 0.6, s * 0.2, 0, 0, Math.PI * 2);
|
| 910 |
c.fill();
|
|
|
|
|
|
|
| 911 |
c.fillStyle = `rgba(255,255,255,${a.toFixed(3)})`;
|
| 912 |
c.beginPath();
|
| 913 |
+
c.ellipse(s * 0.05, -s * 0.26, s * 0.34, s * 0.1, 0, 0, Math.PI * 2);
|
| 914 |
c.fill();
|
|
|
|
|
|
|
| 915 |
}
|
| 916 |
}
|
| 917 |
+
// v19/v23: pectoral fins drawn OVER the reflections for correct layering.
|
| 918 |
+
// draw() already applied translate+rotate+foreshortening, so we draw in local
|
| 919 |
+
// coords (the old duplicate translate/rotate misplaced and hid these fins).
|
| 920 |
{
|
| 921 |
c.save();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 922 |
|
| 923 |
// v20: render the pectoral fins at 50% opacity
|
| 924 |
c.globalAlpha = 0.5;
|
|
|
|
| 1135 |
const hue = clusterHue + rand(-15, 15);
|
| 1136 |
// v4.3: depth split — ~51% "far" (heavy softness), ~49% "near" (sharper) for a layered look
|
| 1137 |
const near = Math.random() < 0.49;
|
| 1138 |
+
const w = near ? rand(2.5, 5) : rand(3, 6);
|
| 1139 |
+
const tw = near ? rand(1.2, 2.4) : rand(1.5, 3);
|
| 1140 |
const st = {
|
| 1141 |
x: cx + rand(-20, 20),
|
| 1142 |
h: rand(H * 0.35, H * 0.65), // much taller than foreground seaweed
|
|
|
|
| 1145 |
speed: rand(0.7, 1.3), // slower, more gentle sway
|
| 1146 |
hue,
|
| 1147 |
near,
|
| 1148 |
+
width: w,
|
| 1149 |
+
tipW: tw,
|
| 1150 |
branches: [],
|
| 1151 |
// organic curve parameters (same as foreground seaweed)
|
| 1152 |
cA1: rand(8, 15), cF1: rand(0.8, 1.6), cP1: rand(0, Math.PI * 2),
|
|
|
|
| 1154 |
// v4.3: multi-wave organic motion parameters
|
| 1155 |
waveA1: rand(6, 14), waveF1: rand(0.5, 1.2), wavePh1: rand(0, Math.PI * 2),
|
| 1156 |
waveA2: rand(3, 8), waveF2: rand(1.3, 2.1), wavePh2: rand(0, Math.PI * 2),
|
| 1157 |
+
waveA3: rand(2, 5), waveF3: rand(2.4, 3.6), wavePh3: rand(0, Math.PI * 2),
|
| 1158 |
+
// PERF: precomputed styles/widths (avoids per-frame string alloc + arithmetic)
|
| 1159 |
+
coreStyle: `hsla(${hue},50%,32%,${near ? 0.8 : 0.7})`,
|
| 1160 |
+
haloStyle: `hsla(${hue},45%,36%,0.18)`,
|
| 1161 |
+
branchCoreStyle: `hsla(${hue + 8},55%,42%,0.75)`,
|
| 1162 |
+
backLineW: (w + tw) / 2
|
| 1163 |
};
|
| 1164 |
// v4.1: add branches to background seaweed (like foreground)
|
| 1165 |
const nBr = 1 + ((Math.random() * 3) | 0); // 1..3 branches per stalk
|
| 1166 |
for (let k = 0; k < nBr; k++) {
|
| 1167 |
+
const bw = rand(1.5, 3); // PERF: computed once, reused for halo width
|
| 1168 |
st.branches.push({
|
| 1169 |
f: rand(0.25, 0.85), // where along the stalk it sprouts
|
| 1170 |
side: Math.random() < 0.5 ? -1 : 1,
|
| 1171 |
len: rand(10, 24), // horizontal reach (slightly smaller than fg)
|
| 1172 |
up: rand(8, 20), // how far it curls upward
|
| 1173 |
+
w: bw,
|
| 1174 |
+
haloW: bw * 1.9, // PERF: precomputed halo width
|
| 1175 |
ph: rand(0, Math.PI * 2)
|
| 1176 |
});
|
| 1177 |
}
|
|
|
|
| 1189 |
const nStalks = 2 + ((Math.random() * 3) | 0);
|
| 1190 |
for (let j = 0; j < nStalks; j++) {
|
| 1191 |
const hue = clusterHue + rand(-12, 12);
|
| 1192 |
+
const mw = rand(3, 6.5); // PERF: captured once for precomputed widths
|
| 1193 |
+
const mtw = rand(1.8, 3.2);
|
| 1194 |
// Median properties: height between backSeaweeds (0.35-0.65H) and farSeaweeds (0.4-0.7H)
|
| 1195 |
const st = {
|
| 1196 |
x: cx + rand(-22, 22),
|
|
|
|
| 1199 |
phase: rand(0, Math.PI * 2),
|
| 1200 |
speed: rand(0.6, 1.15), // median sway speed between back (0.7-1.3) and far (0.5-1.0)
|
| 1201 |
hue,
|
| 1202 |
+
width: mw, // median width
|
| 1203 |
+
tipW: mtw,
|
| 1204 |
branches: [],
|
| 1205 |
// Organic curve parameters (like backSeaweeds for halo pass technique)
|
| 1206 |
cA1: rand(9, 16), cF1: rand(0.7, 1.5), cP1: rand(0, Math.PI * 2),
|
|
|
|
| 1208 |
// Multi-wave organic motion parameters
|
| 1209 |
waveA1: rand(7, 15), waveF1: rand(0.45, 1.1), wavePh1: rand(0, Math.PI * 2),
|
| 1210 |
waveA2: rand(3.5, 9), waveF2: rand(1.2, 2.0), wavePh2: rand(0, Math.PI * 2),
|
| 1211 |
+
waveA3: rand(2.5, 6), waveF3: rand(2.2, 3.4), wavePh3: rand(0, Math.PI * 2),
|
| 1212 |
+
// PERF: precomputed styles/widths (avoids per-frame string alloc + arithmetic)
|
| 1213 |
+
coreStyle: `hsla(${hue},48%,30%,0.65)`,
|
| 1214 |
+
haloStyle: `hsla(${hue},42%,34%,0.16)`,
|
| 1215 |
+
branchCoreStyle: `hsla(${hue + 6},50%,40%,0.7)`,
|
| 1216 |
+
midLineW: (mw + mtw) / 2
|
| 1217 |
};
|
| 1218 |
// Add branches (median count between back and far)
|
| 1219 |
const nBr = 1 + ((Math.random() * 3) | 0);
|
| 1220 |
for (let k = 0; k < nBr; k++) {
|
| 1221 |
+
const bw = rand(1.8, 3.5); // PERF: computed once, reused for halo width
|
| 1222 |
st.branches.push({
|
| 1223 |
f: rand(0.28, 0.82),
|
| 1224 |
side: Math.random() < 0.5 ? -1 : 1,
|
| 1225 |
len: rand(11, 26),
|
| 1226 |
up: rand(9, 22),
|
| 1227 |
+
w: bw,
|
| 1228 |
+
haloW: bw * 2.2, // PERF: precomputed halo width
|
| 1229 |
ph: rand(0, Math.PI * 2)
|
| 1230 |
});
|
| 1231 |
}
|
|
|
|
| 1460 |
return c(g + f.riseShift);
|
| 1461 |
}
|
| 1462 |
|
| 1463 |
+
// PERF: cache background gradient - only recreate when colors change significantly
|
| 1464 |
+
let _bgGrad = null, _bgGradKey = '';
|
| 1465 |
function drawBackground(light) {
|
| 1466 |
+
// PERF: quantize colors so the gradient only rebuilds on visible color shifts
|
| 1467 |
+
const key = [light.top[0] | 0, light.top[1] | 0, light.top[2] | 0,
|
| 1468 |
+
light.mid[0] | 0, light.mid[1] | 0, light.mid[2] | 0,
|
| 1469 |
+
light.bot[0] | 0, light.bot[1] | 0, light.bot[2] | 0].join(',');
|
| 1470 |
+
if (!_bgGrad || _bgGradKey !== key) {
|
| 1471 |
+
_bgGrad = ctx.createLinearGradient(0, 0, 0, H);
|
| 1472 |
+
_bgGrad.addColorStop(0, rgbStr(light.top));
|
| 1473 |
+
_bgGrad.addColorStop(0.5, rgbStr(light.mid));
|
| 1474 |
+
_bgGrad.addColorStop(1, rgbStr(light.bot));
|
| 1475 |
+
_bgGradKey = key;
|
| 1476 |
+
}
|
| 1477 |
+
ctx.fillStyle = _bgGrad;
|
| 1478 |
ctx.fillRect(0, 0, W, H);
|
| 1479 |
}
|
| 1480 |
|
|
|
|
| 1686 |
return f * (w.cA1 * Math.sin(f * w.cF1 + w.cP1) + w.cA2 * Math.sin(f * w.cF2 + w.cP2));
|
| 1687 |
}
|
| 1688 |
|
| 1689 |
+
// PERF: shared sway function (was a per-stalk closure recreated every frame)
|
| 1690 |
+
function stalkSwayX(w, t, f) {
|
| 1691 |
+
return Math.sin(t * w.waveF1 + w.wavePh1 + f * 2.4) * w.waveA1 * f * f +
|
| 1692 |
+
Math.sin(t * w.waveF2 + w.wavePh2 + f * 3.8) * w.waveA2 * f * f +
|
| 1693 |
+
Math.sin(t * w.waveF3 + w.wavePh3 + f * 5.4) * w.waveA3 * f;
|
| 1694 |
+
}
|
| 1695 |
+
|
| 1696 |
// v4.3: draw background seaweed procedurally — multi-wave organic motion instead of
|
| 1697 |
// rigid sprite rotation. Far stalks get a soft low-alpha halo pass to keep their
|
| 1698 |
// "far away" look without any runtime blur filters (no slowdown).
|
| 1699 |
// v6: middle-distance seaweed using halo stroke pass (median depth between back and far)
|
| 1700 |
function drawMidSeaweeds(t) {
|
| 1701 |
+
// PERF: lineCap/lineJoin are constant — set once instead of per stalk per pass
|
| 1702 |
+
ctx.lineCap = 'round';
|
| 1703 |
+
ctx.lineJoin = 'round';
|
| 1704 |
for (const w of midSeaweeds) {
|
| 1705 |
const baseY = sandAt(w.x) + 10;
|
| 1706 |
const n = w.segs;
|
| 1707 |
+
// PERF: compute sway positions ONCE per frame; both passes and branches reuse them
|
| 1708 |
+
const pxs = w._px || (w._px = new Float64Array(n + 1));
|
| 1709 |
+
const pys = w._py || (w._py = new Float64Array(n + 1));
|
| 1710 |
+
for (let i = 0; i <= n; i++) {
|
| 1711 |
+
const f = i / n;
|
| 1712 |
+
pxs[i] = w.x + stalkCurveX(w, f) + stalkSwayX(w, t, f);
|
| 1713 |
+
pys[i] = baseY - w.h * f;
|
| 1714 |
+
}
|
|
|
|
| 1715 |
|
| 1716 |
// Wider halo pass for more softness (between backSeaweeds and farSeaweeds)
|
| 1717 |
for (let pass = 0; pass < 2; pass++) {
|
| 1718 |
const isHalo = pass === 0;
|
| 1719 |
+
ctx.strokeStyle = isHalo ? w.haloStyle : w.coreStyle; // PERF: precomputed at build
|
| 1720 |
+
ctx.lineWidth = w.midLineW * (isHalo ? 2.2 : 1);
|
|
|
|
|
|
|
| 1721 |
|
| 1722 |
+
// stalk outline: up one side, down the other
|
| 1723 |
ctx.beginPath();
|
| 1724 |
+
let prevX = 0, prevY = baseY; // original quirk: first normal measured from origin
|
|
|
|
| 1725 |
for (let i = 0; i <= n; i++) {
|
| 1726 |
const f = i / n;
|
| 1727 |
+
const px = pxs[i], py = pys[i];
|
|
|
|
| 1728 |
const dx = px - prevX, dy = py - prevY;
|
| 1729 |
+
const dl = Math.sqrt(dx * dx + dy * dy) || 1; // PERF: hypot is slower, not needed here
|
| 1730 |
const nx = -dy / dl, ny = dx / dl;
|
| 1731 |
+
const hw = (w.width * (1 - f) + w.tipW * f) * 0.5;
|
| 1732 |
if (i === 0) ctx.moveTo(px + nx * hw, py + ny * hw);
|
| 1733 |
else ctx.lineTo(px + nx * hw, py + ny * hw);
|
| 1734 |
prevX = px; prevY = py;
|
|
|
|
| 1735 |
}
|
| 1736 |
+
// down the other side (cached points; i=n no-op point dropped — degenerate in original)
|
| 1737 |
+
for (let i = n - 1; i >= 0; i--) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1738 |
const f = i / n;
|
| 1739 |
+
const px = pxs[i], py = pys[i];
|
| 1740 |
+
const dx = pxs[i + 1] - px, dy = pys[i + 1] - py;
|
| 1741 |
+
const dl = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1742 |
const nx = -dy / dl, ny = dx / dl;
|
| 1743 |
+
const hw = (w.width * (1 - f) + w.tipW * f) * 0.5;
|
| 1744 |
ctx.lineTo(px - nx * hw, py - ny * hw);
|
| 1745 |
}
|
| 1746 |
ctx.closePath();
|
| 1747 |
ctx.stroke();
|
| 1748 |
|
| 1749 |
+
// branches flutter with the stalk (anchors read from cached positions)
|
| 1750 |
for (const b of w.branches) {
|
| 1751 |
const idx = Math.min(n, Math.max(1, Math.round(b.f * n)));
|
| 1752 |
+
const ax = pxs[idx], ay = pys[idx];
|
|
|
|
|
|
|
| 1753 |
const flutter = Math.sin(t * w.waveF2 * 1.4 + b.ph) * (3 + w.waveA2 * 0.4);
|
| 1754 |
const bx = ax + b.side * b.len + flutter;
|
| 1755 |
const by = ay - b.up;
|
| 1756 |
ctx.beginPath();
|
| 1757 |
+
ctx.lineWidth = isHalo ? b.haloW : b.w; // PERF: halo width precomputed
|
| 1758 |
+
ctx.strokeStyle = isHalo ? w.haloStyle : w.branchCoreStyle;
|
| 1759 |
ctx.moveTo(ax, ay);
|
| 1760 |
ctx.bezierCurveTo(
|
| 1761 |
ax + b.side * b.len * 0.3, ay - b.up * 0.75,
|
|
|
|
| 1771 |
function drawBackSeaweeds(t) {
|
| 1772 |
// v6: no runtime canvas filter — blur() forces a CPU re-rasterize every frame
|
| 1773 |
// and was the main FPS cost. Softness now comes from the halo pass below instead.
|
| 1774 |
+
ctx.lineCap = 'round';
|
| 1775 |
+
ctx.lineJoin = 'round';
|
| 1776 |
for (const w of backSeaweeds) {
|
| 1777 |
const baseY = sandAt(w.x) + 10;
|
| 1778 |
const n = w.segs;
|
| 1779 |
+
// PERF: compute sway positions ONCE per frame; both passes and branches reuse them
|
| 1780 |
+
const pxs = w._px || (w._px = new Float64Array(n + 1));
|
| 1781 |
+
const pys = w._py || (w._py = new Float64Array(n + 1));
|
| 1782 |
+
for (let i = 0; i <= n; i++) {
|
| 1783 |
+
const f = i / n;
|
| 1784 |
+
pxs[i] = w.x + stalkCurveX(w, f) + stalkSwayX(w, t, f);
|
| 1785 |
+
pys[i] = baseY - w.h * f;
|
| 1786 |
+
}
|
| 1787 |
|
| 1788 |
// v6: near stalks also get a halo pass (replaces the old blur(2px) filter)
|
| 1789 |
for (let pass = 0; pass < 2; pass++) {
|
| 1790 |
+
const isHalo = pass === 0;
|
| 1791 |
+
ctx.strokeStyle = isHalo ? w.haloStyle : w.coreStyle; // PERF: precomputed at build
|
| 1792 |
+
ctx.lineWidth = w.backLineW * (isHalo ? 1.9 : 1);
|
|
|
|
|
|
|
| 1793 |
|
| 1794 |
+
// stalk outline: up one side, down the other
|
| 1795 |
ctx.beginPath();
|
| 1796 |
+
let prevX = 0, prevY = baseY; // original quirk: first normal measured from origin
|
|
|
|
| 1797 |
for (let i = 0; i <= n; i++) {
|
| 1798 |
const f = i / n;
|
| 1799 |
+
const px = pxs[i], py = pys[i];
|
|
|
|
| 1800 |
const dx = px - prevX, dy = py - prevY;
|
| 1801 |
+
const dl = Math.sqrt(dx * dx + dy * dy) || 1; // PERF: hypot is slower, not needed here
|
| 1802 |
const nx = -dy / dl, ny = dx / dl;
|
| 1803 |
+
const hw = (w.width * (1 - f) + w.tipW * f) * 0.5;
|
| 1804 |
if (i === 0) ctx.moveTo(px + nx * hw, py + ny * hw);
|
| 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];
|
| 1812 |
+
const dx = pxs[i + 1] - px, dy = pys[i + 1] - py;
|
| 1813 |
+
const dl = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1814 |
const nx = -dy / dl, ny = dx / dl;
|
| 1815 |
+
const hw = (w.width * (1 - f) + w.tipW * f) * 0.5;
|
| 1816 |
ctx.lineTo(px - nx * hw, py - ny * hw);
|
| 1817 |
}
|
| 1818 |
ctx.closePath();
|
| 1819 |
ctx.stroke();
|
| 1820 |
|
| 1821 |
+
// branches flutter with the stalk (anchors read from cached positions)
|
| 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 = Math.sin(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();
|
| 1829 |
+
ctx.lineWidth = isHalo ? b.haloW : b.w; // PERF: halo width precomputed
|
| 1830 |
+
ctx.strokeStyle = isHalo ? w.haloStyle : w.branchCoreStyle;
|
| 1831 |
ctx.moveTo(ax, ay);
|
| 1832 |
ctx.bezierCurveTo(
|
| 1833 |
ax + b.side * b.len * 0.3, ay - b.up * 0.75,
|
|
|
|
| 2618 |
ctx.restore();
|
| 2619 |
}
|
| 2620 |
|
| 2621 |
+
// PERF: pre-render a soft radial gradient sprite for light pooling - draw scaled instead of creating gradients each frame
|
| 2622 |
+
let _poolSprite = null, _poolSpriteSize = 0;
|
| 2623 |
+
function buildPoolSprite() {
|
| 2624 |
+
const size = 256; // fixed size, will be scaled per ray
|
| 2625 |
+
if (_poolSprite && _poolSpriteSize === size) return;
|
| 2626 |
+
_poolSprite = document.createElement('canvas');
|
| 2627 |
+
_poolSprite.width = size;
|
| 2628 |
+
_poolSprite.height = size;
|
| 2629 |
+
const pc = _poolSprite.getContext('2d');
|
| 2630 |
+
const g = pc.createRadialGradient(size/2, size/2, 0, size/2, size/2, size/2);
|
| 2631 |
+
g.addColorStop(0, 'rgba(255,255,255,1)'); // full white, alpha controlled via globalAlpha
|
| 2632 |
+
g.addColorStop(1, 'rgba(255,255,255,0)');
|
| 2633 |
+
pc.fillStyle = g;
|
| 2634 |
+
pc.fillRect(0, 0, size, size);
|
| 2635 |
+
_poolSpriteSize = size;
|
| 2636 |
+
}
|
| 2637 |
+
|
| 2638 |
function drawLightPooling(t) {
|
| 2639 |
+
if (!_poolSprite) buildPoolSprite(); // lazy init (was never called before)
|
| 2640 |
// Draw faint bright patches where each god-ray hits the sand
|
| 2641 |
const sandTop = H * 0.85;
|
| 2642 |
ctx.save();
|
|
|
|
| 2653 |
Math.sin(vM * r.vLen - t * r.vSpd + r.vPh) * r.vAmp;
|
| 2654 |
const dx = r.x + off + wave * (0.35 + 0.65 * vM);
|
| 2655 |
|
| 2656 |
+
// Draw a soft bright ellipse at this position on the sand using pre-rendered sprite
|
| 2657 |
const poolW = r.w * 0.7, poolH = 18;
|
| 2658 |
+
ctx.globalAlpha = r.alpha * 1.75; // v26: +25% brighter
|
| 2659 |
+
ctx.drawImage(_poolSprite, dx - poolW, sandTop + 12 - poolH, poolW * 2, poolH * 2);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2660 |
}
|
| 2661 |
|
| 2662 |
ctx.restore();
|