Upload index.html
Browse files- index.html +275 -84
index.html
CHANGED
|
@@ -43,7 +43,7 @@ function lerpAngle(a, b, t) {
|
|
| 43 |
return a + d * t;
|
| 44 |
}
|
| 45 |
|
| 46 |
-
const HUES = [8, 35, 46, 172, 195, 215, 262
|
| 47 |
const TAIL_TYPES = ['classic', 'fan', 'streamer']; // v11: three tail-fin styles, one per fish
|
| 48 |
// v18: koi colourations — large irregular patches on a white/ivory base. Three classic
|
| 49 |
// looks: kohaku (red/orange), sanke (red + black), golden (amber/gold). Patches are
|
|
@@ -62,17 +62,22 @@ function genKoiPatches(type) {
|
|
| 62 |
() => `hsla(${rand(38, 48)},${rand(85, 98)}%,${rand(55, 66)}%,0.92)`, // gold
|
| 63 |
() => `hsla(${rand(26, 40)},${rand(85, 98)}%,${rand(50, 62)}%,0.88)`, // amber
|
| 64 |
],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
};
|
| 66 |
const styles = palettes[type];
|
| 67 |
const arr = [];
|
| 68 |
-
const
|
|
|
|
| 69 |
for (let i = 0; i < nPatches; i++) {
|
| 70 |
const th = rand(0, Math.PI * 2);
|
| 71 |
const rr = Math.sqrt(Math.random()) * 0.85; // uniform in ellipse, kept off the rim
|
| 72 |
arr.push({
|
| 73 |
x: Math.cos(th) * rr, // along body axis (units of s)
|
| 74 |
y: Math.sin(th) * rr * 0.42, // across body (matches the 0.42 ellipse ratio)
|
| 75 |
-
r: rand(0.3, 0.52),
|
| 76 |
style: styles[Math.random() < 0.5 ? 0 : 1](),
|
| 77 |
});
|
| 78 |
}
|
|
@@ -103,10 +108,18 @@ class Fish {
|
|
| 103 |
// Koi — a white/ivory base with large coloured patches; the three classic
|
| 104 |
// colourations are split roughly evenly across this 30% band.
|
| 105 |
this.isKoi = true;
|
| 106 |
-
const koiType = specialRoll < 0.
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
this.koiPatches = genKoiPatches(koiType);
|
| 111 |
} else {
|
| 112 |
// Standard colored fish from the original palette
|
|
@@ -214,6 +227,22 @@ class Fish {
|
|
| 214 |
} else {
|
| 215 |
this.whiskerLen = null;
|
| 216 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
this.phase = rand(0, Math.PI * 2);
|
| 218 |
// v9: after dark, most fish bed down low in the grass — only a small share still
|
| 219 |
// hovers by the seaweed, and each of those keeps to its own stalk (no pile-ups)
|
|
@@ -459,7 +488,7 @@ class Fish {
|
|
| 459 |
// whose control point sits just past where the old spike was. The style is fixed
|
| 460 |
// per fish at spawn so the school has variety in its tails.
|
| 461 |
// v14: use per-fish saturation and lightness for expanded color palette
|
| 462 |
-
c.fillStyle =
|
| 463 |
c.beginPath();
|
| 464 |
if (this.tailType === 'fan') {
|
| 465 |
// Broad, rounded fan (betta/goldfish-style): wide spread, tips high and far out,
|
|
@@ -486,10 +515,7 @@ class Fish {
|
|
| 486 |
c.fill();
|
| 487 |
|
| 488 |
// body — undulates while swimming: the ellipse flexes along a traveling wave (v3)
|
| 489 |
-
const g =
|
| 490 |
-
// v14: use per-fish saturation and lightness for expanded color palette
|
| 491 |
-
g.addColorStop(0, `hsl(${this.hue},${Math.min(100, this.saturation * 1.08)}%,${this.lightness + 12}%)`);
|
| 492 |
-
g.addColorStop(1, `hsl(${this.hue},${this.saturation * 0.9}%,${this.lightness - 8}%)`);
|
| 493 |
const NSEG = 26; // outline samples for a smooth curve
|
| 494 |
// wave offset at any x along the body: rigid at head, full flex at tail.
|
| 495 |
// Shared with the dorsal fin so it stays glued to the moving back (v4)
|
|
@@ -584,10 +610,18 @@ class Fish {
|
|
| 584 |
// so scales appear to catch the light while moving. One gradient + one fill.
|
| 585 |
const shimmer = Math.sin(this.phase * 1.6) * 0.5 + 0.5; // 0..1 as it swims
|
| 586 |
const iridHue = this.hue + 40 * Math.sin(this.phase * 2.3); // hue drifts with phase
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 591 |
c.save();
|
| 592 |
c.globalCompositeOperation = "lighter"; // additive sheen: reads as light on scales
|
| 593 |
c.fillStyle = ig;
|
|
@@ -604,7 +638,7 @@ class Fish {
|
|
| 604 |
// never disconnects as the back undulates (v4)
|
| 605 |
// v17: matches the fish's own color instead of a fixed blue — silver fish no longer
|
| 606 |
// sprout mismatched blue fins
|
| 607 |
-
c.fillStyle =
|
| 608 |
c.beginPath();
|
| 609 |
c.moveTo(-s * 0.15, -s * 0.36 + wy(-s * 0.15));
|
| 610 |
c.quadraticCurveTo(s * 0.05, -s * 0.78 + wy(s * 0.05), s * 0.38, -s * 0.4 + wy(s * 0.38));
|
|
@@ -618,11 +652,11 @@ class Fish {
|
|
| 618 |
c.rotate(0.7 + flap);
|
| 619 |
// v17: pectoral fin matches the body color (no more blue arms on silver fish), but
|
| 620 |
// gets a thin darker outline so it stays visible sitting on top of the same-colored body
|
| 621 |
-
c.fillStyle =
|
| 622 |
c.beginPath();
|
| 623 |
c.ellipse(0, 0, s * 0.3, s * 0.14, 0, 0, Math.PI * 2);
|
| 624 |
c.fill();
|
| 625 |
-
c.strokeStyle =
|
| 626 |
c.lineWidth = Math.max(1, s * 0.03);
|
| 627 |
c.stroke();
|
| 628 |
c.restore();
|
|
@@ -649,7 +683,7 @@ class Fish {
|
|
| 649 |
const NPTS = 7; // sample points per whisker
|
| 650 |
c.save();
|
| 651 |
c.lineCap = "round";
|
| 652 |
-
c.strokeStyle =
|
| 653 |
c.lineWidth = Math.max(1, s * 0.045);
|
| 654 |
for (const side of [-1, 1]) { // one whisker each side
|
| 655 |
const rootX = s * 0.6;
|
|
@@ -808,15 +842,27 @@ function buildScene() {
|
|
| 808 |
const clusterHue = rand(95, 145);
|
| 809 |
const nStalks = 3 + ((Math.random() * 4) | 0); // 3..6 stalks per bundle
|
| 810 |
for (let j = 0; j < nStalks; j++) {
|
|
|
|
| 811 |
const st = {
|
| 812 |
x: cx + rand(-16, 16),
|
| 813 |
h: rand(H * 0.12, H * 0.34),
|
| 814 |
segs: 8,
|
| 815 |
phase: rand(0, Math.PI * 2),
|
| 816 |
speed: rand(0.9, 1.7),
|
| 817 |
-
hue
|
| 818 |
width: rand(4, 8),
|
| 819 |
touch: 0,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 820 |
off: new Float32Array(9), // lateral bend per segment (fish push leaves aside)
|
| 821 |
offV: new Float32Array(9), // velocity of that bend
|
| 822 |
branches: [],
|
|
@@ -833,12 +879,19 @@ function buildScene() {
|
|
| 833 |
ph: rand(0, Math.PI * 2),
|
| 834 |
flowers: Math.random() < 0.3 ? (() => { // ~30% of branches bloom with 1-3 heads (v3)
|
| 835 |
const n = 1 + ((Math.random() * 3) | 0); // 1..3 flowers per branch
|
| 836 |
-
return Array.from({ length: n }, () =>
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 842 |
})() : []
|
| 843 |
});
|
| 844 |
}
|
|
@@ -864,7 +917,12 @@ function buildScene() {
|
|
| 864 |
speed: rand(1.3, 2.8), // quicker, finer sway than seaweed
|
| 865 |
hue: baseHue + rand(-9, 9),
|
| 866 |
width: rand(1.4, 2.8),
|
| 867 |
-
ripple: 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 868 |
});
|
| 869 |
}
|
| 870 |
}
|
|
@@ -888,7 +946,12 @@ function buildScene() {
|
|
| 888 |
phase: rand(0, Math.PI * 2),
|
| 889 |
speed: rand(0.8, 1.6), // slower, heavier sway than the tiny mid-ground grass
|
| 890 |
hue: baseHue + rand(-10, 10),
|
| 891 |
-
width: rand(1.75, 3.5)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 892 |
});
|
| 893 |
}
|
| 894 |
}
|
|
@@ -1003,9 +1066,18 @@ function drawBackground(light) {
|
|
| 1003 |
ctx.fillRect(0, 0, W, H);
|
| 1004 |
}
|
| 1005 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1006 |
function drawRays(t, light) {
|
| 1007 |
const [rr, rg, rb] = light.ray;
|
| 1008 |
-
const segs =
|
| 1009 |
for (const r of rays) {
|
| 1010 |
// slow horizontal drift of the whole beam (kept from v1)
|
| 1011 |
const off = Math.sin(t * r.speed * 6 + r.x) * 55;
|
|
@@ -1016,7 +1088,7 @@ function drawRays(t, light) {
|
|
| 1016 |
// Undulating spine: the beam's centerline bends left/right as it descends,
|
| 1017 |
// driven by two traveling sine waves (different wavelength & speed) so it
|
| 1018 |
// shimmers like sunlight refracted through moving water, seen from inside.
|
| 1019 |
-
const L =
|
| 1020 |
for (let i = 0; i <= segs; i++) {
|
| 1021 |
const v = i / segs; // 0 at the surface, 1 near the floor
|
| 1022 |
const y = -10 + v * (H * 0.95 + 10);
|
|
@@ -1026,8 +1098,8 @@ function drawRays(t, light) {
|
|
| 1026 |
Math.sin(v * r.vLen - t * r.vSpd + r.vPh) * r.vAmp;
|
| 1027 |
const cx = x0 + shift * v + wave * grow; // undulating center x at this depth
|
| 1028 |
const halfW = topW + (botW - topW) * v; // beam widens as it falls
|
| 1029 |
-
L
|
| 1030 |
-
R
|
| 1031 |
}
|
| 1032 |
|
| 1033 |
// Fill a band between two edge polylines with a vertical fade (matches v1 look).
|
|
@@ -1049,13 +1121,13 @@ function drawRays(t, light) {
|
|
| 1049 |
band(L, R, r.alpha, 0.55, 0.45);
|
| 1050 |
|
| 1051 |
// brighter, narrower inner core riding the same undulation (v1 core profile)
|
| 1052 |
-
const LC =
|
| 1053 |
for (let i = 0; i <= segs; i++) {
|
| 1054 |
const [lx, ly] = L[i], [rx2, ry2] = R[i];
|
| 1055 |
const cxm = (lx + rx2) / 2;
|
| 1056 |
const coreHalf = ((rx2 - lx) / 2) * 0.42; // 42% of the local half-width, as in v1
|
| 1057 |
-
LC
|
| 1058 |
-
RC
|
| 1059 |
}
|
| 1060 |
band(LC, RC, r.alpha * 1.5, 0.65, 0);
|
| 1061 |
}
|
|
@@ -1106,6 +1178,11 @@ function drawRocks(c = ctx) {
|
|
| 1106 |
}
|
| 1107 |
}
|
| 1108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1109 |
function updatePlants(t, dt) {
|
| 1110 |
plantPoints.length = 0;
|
| 1111 |
for (const w of seaweeds) {
|
|
@@ -1117,7 +1194,7 @@ function updatePlants(t, dt) {
|
|
| 1117 |
const moveF = Math.min(1, fSpd / 1.2); // v9: how actively this fish is swimming
|
| 1118 |
for (let i = 1; i <= w.segs; i++) {
|
| 1119 |
const fl = i / w.segs;
|
| 1120 |
-
const px = w.x + w.off[i] + Math.sin(t * w.speed + w.phase + fl * 2.6) * fl * fl * 24;
|
| 1121 |
const py = sandAt(w.x) + 8 - w.h * fl;
|
| 1122 |
const dx = px - f.x, dy = py - f.y;
|
| 1123 |
const d2 = dx * dx + dy * dy;
|
|
@@ -1161,7 +1238,7 @@ function updatePlants(t, dt) {
|
|
| 1161 |
// normal sway + a fast extra flinch while being pushed
|
| 1162 |
const sway = Math.sin(t * w.speed + w.phase + f * 2.6) * f * f * 24
|
| 1163 |
+ Math.sin(t * 7 + w.phase) * w.touch * f * 16;
|
| 1164 |
-
const px = w.x + (w.off[i] || 0) + sway;
|
| 1165 |
const py = baseY - w.h * f;
|
| 1166 |
w.points.push({ x: px, y: py });
|
| 1167 |
plantPoints.push({ x: px, y: py, r: w.width * 0.55, s: w });
|
|
@@ -1199,33 +1276,77 @@ function updateGrass(t, dt) {
|
|
| 1199 |
|
| 1200 |
function drawSeaweed() {
|
| 1201 |
for (const w of seaweeds) {
|
| 1202 |
-
|
| 1203 |
-
|
| 1204 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1205 |
ctx.lineCap = "round";
|
| 1206 |
ctx.beginPath();
|
| 1207 |
-
ctx.moveTo(
|
| 1208 |
-
for (let i = 1; i <
|
| 1209 |
ctx.stroke();
|
| 1210 |
|
| 1211 |
// side branches curling up and out from the stalk
|
| 1212 |
for (const b of w.branches) {
|
| 1213 |
const a = w.points[Math.min(w.segs, Math.max(1, Math.round(b.f * w.segs)))];
|
| 1214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1215 |
ctx.lineWidth = b.w;
|
| 1216 |
ctx.beginPath();
|
| 1217 |
ctx.moveTo(a.x, a.y);
|
| 1218 |
-
ctx.
|
| 1219 |
ctx.stroke();
|
| 1220 |
|
| 1221 |
// colourful flower heads blooming along the branch — 1..3 per branch (v3)
|
| 1222 |
if (b.flowers.length) {
|
| 1223 |
-
const cpX = (a.x + b.tx) / 2 + b.side * 3;
|
| 1224 |
-
const cpY = (a.y + b.ty) / 2 - 5;
|
| 1225 |
for (const fl of b.flowers) {
|
| 1226 |
-
const u = fl.t, iu = 1 - u; // point on the branch's
|
| 1227 |
-
drawFlowerHead(
|
| 1228 |
-
|
|
|
|
| 1229 |
}
|
| 1230 |
}
|
| 1231 |
}
|
|
@@ -1238,12 +1359,12 @@ function drawFlowerHead(x, y, fl) {
|
|
| 1238 |
const ang = (i / fl.petals) * Math.PI * 2;
|
| 1239 |
const px = x + Math.cos(ang) * fl.r * 0.72;
|
| 1240 |
const py = y + Math.sin(ang) * fl.r * 0.72;
|
| 1241 |
-
ctx.fillStyle =
|
| 1242 |
ctx.beginPath();
|
| 1243 |
ctx.ellipse(px, py, fl.r * 0.55, fl.r * 0.36, ang, 0, Math.PI * 2);
|
| 1244 |
ctx.fill();
|
| 1245 |
}
|
| 1246 |
-
ctx.fillStyle =
|
| 1247 |
ctx.beginPath();
|
| 1248 |
ctx.arc(x, y, fl.r * 0.42, 0, Math.PI * 2);
|
| 1249 |
ctx.fill();
|
|
@@ -1252,22 +1373,56 @@ function drawFlowerHead(x, y, fl) {
|
|
| 1252 |
|
| 1253 |
function drawGrass(t) {
|
| 1254 |
for (const g of grass) {
|
| 1255 |
-
//
|
| 1256 |
const amp = 3 + g.ripple * 13;
|
| 1257 |
-
const
|
| 1258 |
-
const
|
| 1259 |
-
|
| 1260 |
-
|
| 1261 |
-
|
| 1262 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1263 |
ctx.beginPath();
|
| 1264 |
-
|
| 1265 |
-
|
| 1266 |
-
|
| 1267 |
-
|
| 1268 |
-
|
| 1269 |
-
|
| 1270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1271 |
}
|
| 1272 |
}
|
| 1273 |
|
|
@@ -1275,29 +1430,65 @@ function drawGrass(t) {
|
|
| 1275 |
// Drawn on top of everything else (it is closest to the viewer). Each blade gets a
|
| 1276 |
// thin lighter inner stroke over a darker body for a lush, voluminous "closer" look.
|
| 1277 |
function drawFgGrass(t) {
|
| 1278 |
-
ctx.lineCap = "round";
|
| 1279 |
for (const g of fgGrass) {
|
| 1280 |
-
|
| 1281 |
-
const
|
| 1282 |
-
const
|
| 1283 |
-
const
|
| 1284 |
-
|
| 1285 |
-
|
| 1286 |
-
|
| 1287 |
-
|
| 1288 |
-
|
| 1289 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1290 |
ctx.beginPath();
|
| 1291 |
-
|
| 1292 |
-
|
| 1293 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1294 |
|
| 1295 |
-
// lighter inner highlight for depth
|
| 1296 |
ctx.strokeStyle = `hsla(${g.hue + 6},64%,45%,0.5)`;
|
| 1297 |
-
ctx.lineWidth = g.width * 0.
|
|
|
|
| 1298 |
ctx.beginPath();
|
| 1299 |
-
ctx.moveTo(
|
| 1300 |
-
ctx.
|
| 1301 |
ctx.stroke();
|
| 1302 |
}
|
| 1303 |
}
|
|
|
|
| 43 |
return a + d * t;
|
| 44 |
}
|
| 45 |
|
| 46 |
+
const HUES = [8, 35, 46, 172, 195, 215, 262];
|
| 47 |
const TAIL_TYPES = ['classic', 'fan', 'streamer']; // v11: three tail-fin styles, one per fish
|
| 48 |
// v18: koi colourations — large irregular patches on a white/ivory base. Three classic
|
| 49 |
// looks: kohaku (red/orange), sanke (red + black), golden (amber/gold). Patches are
|
|
|
|
| 62 |
() => `hsla(${rand(38, 48)},${rand(85, 98)}%,${rand(55, 66)}%,0.92)`, // gold
|
| 63 |
() => `hsla(${rand(26, 40)},${rand(85, 98)}%,${rand(50, 62)}%,0.88)`, // amber
|
| 64 |
],
|
| 65 |
+
goldspot: [
|
| 66 |
+
() => `hsla(${rand(40, 50)},${rand(15, 30)}%,${rand(92, 98)}%,0.9)`, // white spots
|
| 67 |
+
() => `hsla(${rand(40, 50)},${rand(15, 30)}%,${rand(92, 98)}%,0.9)`, // white spots (duplicate for consistent selection)
|
| 68 |
+
],
|
| 69 |
};
|
| 70 |
const styles = palettes[type];
|
| 71 |
const arr = [];
|
| 72 |
+
const isGoldspot = type === 'goldspot';
|
| 73 |
+
const nPatches = isGoldspot ? (8 + ((Math.random() * 8) | 0)) : (4 + ((Math.random() * 4) | 0)); // goldspot: 8..15, else 4..7
|
| 74 |
for (let i = 0; i < nPatches; i++) {
|
| 75 |
const th = rand(0, Math.PI * 2);
|
| 76 |
const rr = Math.sqrt(Math.random()) * 0.85; // uniform in ellipse, kept off the rim
|
| 77 |
arr.push({
|
| 78 |
x: Math.cos(th) * rr, // along body axis (units of s)
|
| 79 |
y: Math.sin(th) * rr * 0.42, // across body (matches the 0.42 ellipse ratio)
|
| 80 |
+
r: isGoldspot ? rand(0.06, 0.14) : rand(0.3, 0.52), // goldspot: small dots, else large patches
|
| 81 |
style: styles[Math.random() < 0.5 ? 0 : 1](),
|
| 82 |
});
|
| 83 |
}
|
|
|
|
| 108 |
// Koi — a white/ivory base with large coloured patches; the three classic
|
| 109 |
// colourations are split roughly evenly across this 30% band.
|
| 110 |
this.isKoi = true;
|
| 111 |
+
const koiType = specialRoll < 0.22 ? 'kohaku' : specialRoll < 0.30 ? 'sanke' : specialRoll < 0.38 ? 'golden' : 'goldspot';
|
| 112 |
+
if (koiType === 'goldspot') {
|
| 113 |
+
// Golden orange fish with white spots
|
| 114 |
+
this.hue = rand(30, 42); // golden orange
|
| 115 |
+
this.saturation = rand(85, 95); // high saturation → vivid color
|
| 116 |
+
this.lightness = rand(50, 65); // medium lightness → rich gold
|
| 117 |
+
} else {
|
| 118 |
+
// Classic koi — white/ivory base
|
| 119 |
+
this.hue = rand(40, 50); // warm ivory tint for the base
|
| 120 |
+
this.saturation = rand(12, 22); // low saturation → white body
|
| 121 |
+
this.lightness = rand(88, 94); // high lightness → white/ivory base
|
| 122 |
+
}
|
| 123 |
this.koiPatches = genKoiPatches(koiType);
|
| 124 |
} else {
|
| 125 |
// Standard colored fish from the original palette
|
|
|
|
| 227 |
} else {
|
| 228 |
this.whiskerLen = null;
|
| 229 |
}
|
| 230 |
+
|
| 231 |
+
// PERF: precompute per-fish paint styles once. draw() used to rebuild these
|
| 232 |
+
// template strings every frame for every fish — now it's a property read.
|
| 233 |
+
this.tailFillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.92)`;
|
| 234 |
+
this.dorsalFillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.9)`;
|
| 235 |
+
this.pevFillStyle = `hsla(${this.hue},${this.saturation}%,${this.lightness}%,0.8)`;
|
| 236 |
+
this.pevStrokeStyle = `hsla(${this.hue},${Math.min(100, this.saturation * 1.15)}%,${Math.max(8, this.lightness - 20)}%,0.5)`;
|
| 237 |
+
this.whiskerStyle = `hsla(${this.hue},55%,82%,${this.whiskerAlpha || 0.5})`;
|
| 238 |
+
|
| 239 |
+
// PERF: the body gradient depends only on fixed per-fish colors and fish-local
|
| 240 |
+
// coordinates, so build it once instead of recreating it every frame.
|
| 241 |
+
const bg = ctx.createLinearGradient(0, -this.size * 0.5, 0, this.size * 0.5);
|
| 242 |
+
bg.addColorStop(0, `hsl(${this.hue},${Math.min(100, this.saturation * 1.08)}%,${this.lightness + 12}%)`);
|
| 243 |
+
bg.addColorStop(1, `hsl(${this.hue},${this.saturation * 0.9}%,${this.lightness - 8}%)`);
|
| 244 |
+
this.bodyGrad = bg;
|
| 245 |
+
|
| 246 |
this.phase = rand(0, Math.PI * 2);
|
| 247 |
// v9: after dark, most fish bed down low in the grass — only a small share still
|
| 248 |
// hovers by the seaweed, and each of those keeps to its own stalk (no pile-ups)
|
|
|
|
| 488 |
// whose control point sits just past where the old spike was. The style is fixed
|
| 489 |
// per fish at spawn so the school has variety in its tails.
|
| 490 |
// v14: use per-fish saturation and lightness for expanded color palette
|
| 491 |
+
c.fillStyle = this.tailFillStyle;
|
| 492 |
c.beginPath();
|
| 493 |
if (this.tailType === 'fan') {
|
| 494 |
// Broad, rounded fan (betta/goldfish-style): wide spread, tips high and far out,
|
|
|
|
| 515 |
c.fill();
|
| 516 |
|
| 517 |
// body — undulates while swimming: the ellipse flexes along a traveling wave (v3)
|
| 518 |
+
const g = this.bodyGrad; // precomputed in constructor (static colors, local coords)
|
|
|
|
|
|
|
|
|
|
| 519 |
const NSEG = 26; // outline samples for a smooth curve
|
| 520 |
// wave offset at any x along the body: rigid at head, full flex at tail.
|
| 521 |
// Shared with the dorsal fin so it stays glued to the moving back (v4)
|
|
|
|
| 610 |
// so scales appear to catch the light while moving. One gradient + one fill.
|
| 611 |
const shimmer = Math.sin(this.phase * 1.6) * 0.5 + 0.5; // 0..1 as it swims
|
| 612 |
const iridHue = this.hue + 40 * Math.sin(this.phase * 2.3); // hue drifts with phase
|
| 613 |
+
// PERF: cache the sheen gradient. Its inputs move smoothly, so quantize them to a
|
| 614 |
+
// tiny grid (1/31 shimmer steps, 1° hue) and reuse until the cell changes — this
|
| 615 |
+
// skips one createLinearGradient + 3 addColorStop calls on most frames.
|
| 616 |
+
const igKey = Math.round(shimmer * 31) * 512 + (Math.round(iridHue) + 64);
|
| 617 |
+
if (igKey !== this._igKey || !this._igGrad) {
|
| 618 |
+
this._igGrad = c.createLinearGradient(-s, -s*0.3, s, s*0.35);
|
| 619 |
+
this._igGrad.addColorStop(0.0, `hsla(${this.hue},95%,72%,${0.10 + 0.18*shimmer})`);
|
| 620 |
+
this._igGrad.addColorStop(0.5, `hsla(${iridHue},90%,70%,${0.04 + 0.10*shimmer})`);
|
| 621 |
+
this._igGrad.addColorStop(1.0, `hsla(${this.hue + 200},85%,68%,${0.03 + 0.08*shimmer})`);
|
| 622 |
+
this._igKey = igKey;
|
| 623 |
+
}
|
| 624 |
+
const ig = this._igGrad;
|
| 625 |
c.save();
|
| 626 |
c.globalCompositeOperation = "lighter"; // additive sheen: reads as light on scales
|
| 627 |
c.fillStyle = ig;
|
|
|
|
| 638 |
// never disconnects as the back undulates (v4)
|
| 639 |
// v17: matches the fish's own color instead of a fixed blue — silver fish no longer
|
| 640 |
// sprout mismatched blue fins
|
| 641 |
+
c.fillStyle = this.dorsalFillStyle;
|
| 642 |
c.beginPath();
|
| 643 |
c.moveTo(-s * 0.15, -s * 0.36 + wy(-s * 0.15));
|
| 644 |
c.quadraticCurveTo(s * 0.05, -s * 0.78 + wy(s * 0.05), s * 0.38, -s * 0.4 + wy(s * 0.38));
|
|
|
|
| 652 |
c.rotate(0.7 + flap);
|
| 653 |
// v17: pectoral fin matches the body color (no more blue arms on silver fish), but
|
| 654 |
// gets a thin darker outline so it stays visible sitting on top of the same-colored body
|
| 655 |
+
c.fillStyle = this.pevFillStyle;
|
| 656 |
c.beginPath();
|
| 657 |
c.ellipse(0, 0, s * 0.3, s * 0.14, 0, 0, Math.PI * 2);
|
| 658 |
c.fill();
|
| 659 |
+
c.strokeStyle = this.pevStrokeStyle;
|
| 660 |
c.lineWidth = Math.max(1, s * 0.03);
|
| 661 |
c.stroke();
|
| 662 |
c.restore();
|
|
|
|
| 683 |
const NPTS = 7; // sample points per whisker
|
| 684 |
c.save();
|
| 685 |
c.lineCap = "round";
|
| 686 |
+
c.strokeStyle = this.whiskerStyle;
|
| 687 |
c.lineWidth = Math.max(1, s * 0.045);
|
| 688 |
for (const side of [-1, 1]) { // one whisker each side
|
| 689 |
const rootX = s * 0.6;
|
|
|
|
| 842 |
const clusterHue = rand(95, 145);
|
| 843 |
const nStalks = 3 + ((Math.random() * 4) | 0); // 3..6 stalks per bundle
|
| 844 |
for (let j = 0; j < nStalks; j++) {
|
| 845 |
+
const hue = clusterHue + rand(-10, 10);
|
| 846 |
const st = {
|
| 847 |
x: cx + rand(-16, 16),
|
| 848 |
h: rand(H * 0.12, H * 0.34),
|
| 849 |
segs: 8,
|
| 850 |
phase: rand(0, Math.PI * 2),
|
| 851 |
speed: rand(0.9, 1.7),
|
| 852 |
+
hue,
|
| 853 |
width: rand(4, 8),
|
| 854 |
touch: 0,
|
| 855 |
+
// v15: organic rest curve — each stalk leans and waves like real seaweed
|
| 856 |
+
// instead of standing perfectly straight. Also fed into the physics (updatePlants)
|
| 857 |
+
// so fish push the stalk where it actually looks. Weighted by f → root stays planted.
|
| 858 |
+
cA1: rand(5, 12), cF1: rand(0.9, 1.8), cP1: rand(0, Math.PI * 2),
|
| 859 |
+
cA2: rand(2, 6), cF2: rand(2.2, 3.6), cP2: rand(0, Math.PI * 2),
|
| 860 |
+
tipW: rand(1.4, 2.6), // v15: blade tapers from width → tipW up the stipe
|
| 861 |
+
// PERF: stroke styles precomputed once (hue is fixed per stalk) — drawSeaweed
|
| 862 |
+
// used to rebuild both template strings every frame for every stalk.
|
| 863 |
+
stalkStyle: `hsla(${hue},55%,36%,0.92)`,
|
| 864 |
+
branchStyle: `hsla(${hue + 8},60%,46%,0.9)`,
|
| 865 |
+
midStyle: `hsla(${hue - 8},48%,27%,0.45)`, // v15: faint leaf vein down the blade
|
| 866 |
off: new Float32Array(9), // lateral bend per segment (fish push leaves aside)
|
| 867 |
offV: new Float32Array(9), // velocity of that bend
|
| 868 |
branches: [],
|
|
|
|
| 879 |
ph: rand(0, Math.PI * 2),
|
| 880 |
flowers: Math.random() < 0.3 ? (() => { // ~30% of branches bloom with 1-3 heads (v3)
|
| 881 |
const n = 1 + ((Math.random() * 3) | 0); // 1..3 flowers per branch
|
| 882 |
+
return Array.from({ length: n }, () => {
|
| 883 |
+
const hue = (Math.random() * 360) | 0; // colourful — full spectrum
|
| 884 |
+
return {
|
| 885 |
+
t: rand(0.45, 1), // where along the branch it sits (tip zone)
|
| 886 |
+
hue,
|
| 887 |
+
r: rand(4, 7), // head size
|
| 888 |
+
petals: 5 + ((Math.random() * 3) | 0), // 5..7 petals
|
| 889 |
+
// PERF: paint styles precomputed once — drawFlowerHead used to rebuild
|
| 890 |
+
// these strings per petal, per frame.
|
| 891 |
+
petalFill: `hsla(${hue},85%,64%,0.95)`,
|
| 892 |
+
centerFill: `hsla(${(hue + 45) % 360},92%,78%,0.95)`
|
| 893 |
+
};
|
| 894 |
+
});
|
| 895 |
})() : []
|
| 896 |
});
|
| 897 |
}
|
|
|
|
| 917 |
speed: rand(1.3, 2.8), // quicker, finer sway than seaweed
|
| 918 |
hue: baseHue + rand(-9, 9),
|
| 919 |
width: rand(1.4, 2.8),
|
| 920 |
+
ripple: 0, // agitation from nearby fish (0..1)
|
| 921 |
+
// v16: organic rest-curve + segmented blade outline (like seaweed)
|
| 922 |
+
cA1: rand(-5, 5), // primary bend amplitude
|
| 923 |
+
cF1: rand(2.2, 4.5), // frequency along blade length
|
| 924 |
+
cP1: rand(0, Math.PI * 2), // phase offset
|
| 925 |
+
segs: 3 + ((Math.random() * 2) | 0) // 3-4 segments for tapered outline
|
| 926 |
});
|
| 927 |
}
|
| 928 |
}
|
|
|
|
| 946 |
phase: rand(0, Math.PI * 2),
|
| 947 |
speed: rand(0.8, 1.6), // slower, heavier sway than the tiny mid-ground grass
|
| 948 |
hue: baseHue + rand(-10, 10),
|
| 949 |
+
width: rand(1.75, 3.5), // 50%: half thickness (was 3.5..7)
|
| 950 |
+
// v16: organic rest-curve + segmented blade outline (like seaweed)
|
| 951 |
+
cA1: rand(-8, 8), // primary bend amplitude (larger for foreground)
|
| 952 |
+
cF1: rand(2, 4), // frequency along blade length
|
| 953 |
+
cP1: rand(0, Math.PI * 2), // phase offset
|
| 954 |
+
segs: 4 + ((Math.random() * 2) | 0) // 4-5 segments for tapered outline
|
| 955 |
});
|
| 956 |
}
|
| 957 |
}
|
|
|
|
| 1066 |
ctx.fillRect(0, 0, W, H);
|
| 1067 |
}
|
| 1068 |
|
| 1069 |
+
// PERF: shared scratch arrays for drawRays. The beam spines used to allocate fresh
|
| 1070 |
+
// point arrays — plus ~72 tiny [x,y] pairs per ray, per frame. Rendering is sequential,
|
| 1071 |
+
// so one set of preallocated slots serves every ray with zero per-frame allocation.
|
| 1072 |
+
const RAY_SEGS = 18; // vertical resolution of each beam's undulating spine
|
| 1073 |
+
const _rayL = [], _rayR = [], _rayLC = [], _rayRC = [];
|
| 1074 |
+
for (let i = 0; i <= RAY_SEGS; i++) {
|
| 1075 |
+
_rayL.push([0, 0]); _rayR.push([0, 0]); _rayLC.push([0, 0]); _rayRC.push([0, 0]);
|
| 1076 |
+
}
|
| 1077 |
+
|
| 1078 |
function drawRays(t, light) {
|
| 1079 |
const [rr, rg, rb] = light.ray;
|
| 1080 |
+
const segs = RAY_SEGS;
|
| 1081 |
for (const r of rays) {
|
| 1082 |
// slow horizontal drift of the whole beam (kept from v1)
|
| 1083 |
const off = Math.sin(t * r.speed * 6 + r.x) * 55;
|
|
|
|
| 1088 |
// Undulating spine: the beam's centerline bends left/right as it descends,
|
| 1089 |
// driven by two traveling sine waves (different wavelength & speed) so it
|
| 1090 |
// shimmers like sunlight refracted through moving water, seen from inside.
|
| 1091 |
+
const L = _rayL, R = _rayR;
|
| 1092 |
for (let i = 0; i <= segs; i++) {
|
| 1093 |
const v = i / segs; // 0 at the surface, 1 near the floor
|
| 1094 |
const y = -10 + v * (H * 0.95 + 10);
|
|
|
|
| 1098 |
Math.sin(v * r.vLen - t * r.vSpd + r.vPh) * r.vAmp;
|
| 1099 |
const cx = x0 + shift * v + wave * grow; // undulating center x at this depth
|
| 1100 |
const halfW = topW + (botW - topW) * v; // beam widens as it falls
|
| 1101 |
+
L[i][0] = cx - halfW; L[i][1] = y;
|
| 1102 |
+
R[i][0] = cx + halfW; R[i][1] = y;
|
| 1103 |
}
|
| 1104 |
|
| 1105 |
// Fill a band between two edge polylines with a vertical fade (matches v1 look).
|
|
|
|
| 1121 |
band(L, R, r.alpha, 0.55, 0.45);
|
| 1122 |
|
| 1123 |
// brighter, narrower inner core riding the same undulation (v1 core profile)
|
| 1124 |
+
const LC = _rayLC, RC = _rayRC;
|
| 1125 |
for (let i = 0; i <= segs; i++) {
|
| 1126 |
const [lx, ly] = L[i], [rx2, ry2] = R[i];
|
| 1127 |
const cxm = (lx + rx2) / 2;
|
| 1128 |
const coreHalf = ((rx2 - lx) / 2) * 0.42; // 42% of the local half-width, as in v1
|
| 1129 |
+
LC[i][0] = cxm - coreHalf; LC[i][1] = ly;
|
| 1130 |
+
RC[i][0] = cxm + coreHalf; RC[i][1] = ry2;
|
| 1131 |
}
|
| 1132 |
band(LC, RC, r.alpha * 1.5, 0.65, 0);
|
| 1133 |
}
|
|
|
|
| 1178 |
}
|
| 1179 |
}
|
| 1180 |
|
| 1181 |
+
// v15: static organic lean of a stalk at fraction f up its length (root stays planted)
|
| 1182 |
+
function stalkCurveX(w, f) {
|
| 1183 |
+
return f * (w.cA1 * Math.sin(f * w.cF1 + w.cP1) + w.cA2 * Math.sin(f * w.cF2 + w.cP2));
|
| 1184 |
+
}
|
| 1185 |
+
|
| 1186 |
function updatePlants(t, dt) {
|
| 1187 |
plantPoints.length = 0;
|
| 1188 |
for (const w of seaweeds) {
|
|
|
|
| 1194 |
const moveF = Math.min(1, fSpd / 1.2); // v9: how actively this fish is swimming
|
| 1195 |
for (let i = 1; i <= w.segs; i++) {
|
| 1196 |
const fl = i / w.segs;
|
| 1197 |
+
const px = w.x + w.off[i] + stalkCurveX(w, fl) + Math.sin(t * w.speed + w.phase + fl * 2.6) * fl * fl * 24;
|
| 1198 |
const py = sandAt(w.x) + 8 - w.h * fl;
|
| 1199 |
const dx = px - f.x, dy = py - f.y;
|
| 1200 |
const d2 = dx * dx + dy * dy;
|
|
|
|
| 1238 |
// normal sway + a fast extra flinch while being pushed
|
| 1239 |
const sway = Math.sin(t * w.speed + w.phase + f * 2.6) * f * f * 24
|
| 1240 |
+ Math.sin(t * 7 + w.phase) * w.touch * f * 16;
|
| 1241 |
+
const px = w.x + (w.off[i] || 0) + stalkCurveX(w, f) + sway;
|
| 1242 |
const py = baseY - w.h * f;
|
| 1243 |
w.points.push({ x: px, y: py });
|
| 1244 |
plantPoints.push({ x: px, y: py, r: w.width * 0.55, s: w });
|
|
|
|
| 1276 |
|
| 1277 |
function drawSeaweed() {
|
| 1278 |
for (const w of seaweeds) {
|
| 1279 |
+
const pts = w.points, n = pts.length;
|
| 1280 |
+
// v15: main stipe as a tapered leaf-blade — an outline around the same physics
|
| 1281 |
+
// points, wide at the root and thinning to a soft point up top. No straight-line
|
| 1282 |
+
// segments anywhere; the stalk's own curve comes from the sway + rest-curve baked
|
| 1283 |
+
// into those points in updatePlants.
|
| 1284 |
+
ctx.fillStyle = w.stalkStyle;
|
| 1285 |
+
ctx.beginPath();
|
| 1286 |
+
for (let i = 0; i < n; i++) { // up one side of the blade
|
| 1287 |
+
const p = pts[i];
|
| 1288 |
+
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
|
| 1289 |
+
let dx = b.x - a.x, dy = b.y - a.y;
|
| 1290 |
+
const dl = Math.hypot(dx, dy) || 1;
|
| 1291 |
+
const nx = -dy / dl, ny = dx / dl; // perpendicular to the local stalk direction
|
| 1292 |
+
const f = i / (n - 1);
|
| 1293 |
+
const hw = (w.width * (1 - f) + w.tipW * f) / 2; // half-width: base → tip taper
|
| 1294 |
+
if (i === 0) ctx.moveTo(p.x + nx * hw, p.y + ny * hw);
|
| 1295 |
+
else ctx.lineTo(p.x + nx * hw, p.y + ny * hw);
|
| 1296 |
+
}
|
| 1297 |
+
{ // rounded cap at the tip
|
| 1298 |
+
const tp = pts[n - 1], pp = pts[n - 2];
|
| 1299 |
+
let dx = tp.x - pp.x, dy = tp.y - pp.y;
|
| 1300 |
+
const dl = Math.hypot(dx, dy) || 1;
|
| 1301 |
+
const nx = -dy / dl, ny = dx / dl;
|
| 1302 |
+
const hwT = w.tipW / 2;
|
| 1303 |
+
ctx.quadraticCurveTo(tp.x + dx, tp.y + dy * 0.6, tp.x - nx * hwT, tp.y - ny * hwT);
|
| 1304 |
+
}
|
| 1305 |
+
for (let i = n - 1; i >= 0; i--) { // down the other side
|
| 1306 |
+
const p = pts[i];
|
| 1307 |
+
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
|
| 1308 |
+
let dx = b.x - a.x, dy = b.y - a.y;
|
| 1309 |
+
const dl = Math.hypot(dx, dy) || 1;
|
| 1310 |
+
const nx = -dy / dl, ny = dx / dl;
|
| 1311 |
+
const f = i / (n - 1);
|
| 1312 |
+
const hw = (w.width * (1 - f) + w.tipW * f) / 2;
|
| 1313 |
+
ctx.lineTo(p.x - nx * hw, p.y - ny * hw);
|
| 1314 |
+
}
|
| 1315 |
+
ctx.closePath();
|
| 1316 |
+
ctx.fill();
|
| 1317 |
+
|
| 1318 |
+
// v15: faint midrib vein down the blade's centreline for a leafy look
|
| 1319 |
+
ctx.strokeStyle = w.midStyle;
|
| 1320 |
+
ctx.lineWidth = 1.4;
|
| 1321 |
ctx.lineCap = "round";
|
| 1322 |
ctx.beginPath();
|
| 1323 |
+
ctx.moveTo(pts[0].x, pts[0].y);
|
| 1324 |
+
for (let i = 1; i < n; i++) ctx.lineTo(pts[i].x, pts[i].y);
|
| 1325 |
ctx.stroke();
|
| 1326 |
|
| 1327 |
// side branches curling up and out from the stalk
|
| 1328 |
for (const b of w.branches) {
|
| 1329 |
const a = w.points[Math.min(w.segs, Math.max(1, Math.round(b.f * w.segs)))];
|
| 1330 |
+
// v15: S-curved frond — leaves the stalk heading upward, then arcs out and up
|
| 1331 |
+
// to the tip instead of one plain quadratic bow (same endpoints as before)
|
| 1332 |
+
const c1x = a.x + b.side * b.len * 0.3;
|
| 1333 |
+
const c1y = a.y - b.up * 0.75;
|
| 1334 |
+
const c2x = b.tx - b.side * b.len * 0.35;
|
| 1335 |
+
const c2y = b.ty + b.up * 0.6;
|
| 1336 |
+
ctx.strokeStyle = w.branchStyle;
|
| 1337 |
ctx.lineWidth = b.w;
|
| 1338 |
ctx.beginPath();
|
| 1339 |
ctx.moveTo(a.x, a.y);
|
| 1340 |
+
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, b.tx, b.ty);
|
| 1341 |
ctx.stroke();
|
| 1342 |
|
| 1343 |
// colourful flower heads blooming along the branch — 1..3 per branch (v3)
|
| 1344 |
if (b.flowers.length) {
|
|
|
|
|
|
|
| 1345 |
for (const fl of b.flowers) {
|
| 1346 |
+
const u = fl.t, iu = 1 - u; // point on the branch's cubic curve (v15)
|
| 1347 |
+
drawFlowerHead(
|
| 1348 |
+
iu * iu * iu * a.x + 3 * iu * iu * u * c1x + 3 * iu * u * u * c2x + u * u * u * b.tx,
|
| 1349 |
+
iu * iu * iu * a.y + 3 * iu * iu * u * c1y + 3 * iu * u * u * c2y + u * u * u * b.ty, fl);
|
| 1350 |
}
|
| 1351 |
}
|
| 1352 |
}
|
|
|
|
| 1359 |
const ang = (i / fl.petals) * Math.PI * 2;
|
| 1360 |
const px = x + Math.cos(ang) * fl.r * 0.72;
|
| 1361 |
const py = y + Math.sin(ang) * fl.r * 0.72;
|
| 1362 |
+
ctx.fillStyle = fl.petalFill;
|
| 1363 |
ctx.beginPath();
|
| 1364 |
ctx.ellipse(px, py, fl.r * 0.55, fl.r * 0.36, ang, 0, Math.PI * 2);
|
| 1365 |
ctx.fill();
|
| 1366 |
}
|
| 1367 |
+
ctx.fillStyle = fl.centerFill; // bright contrasting centre (precomputed at bloom time)
|
| 1368 |
ctx.beginPath();
|
| 1369 |
ctx.arc(x, y, fl.r * 0.42, 0, Math.PI * 2);
|
| 1370 |
ctx.fill();
|
|
|
|
| 1373 |
|
| 1374 |
function drawGrass(t) {
|
| 1375 |
for (const g of grass) {
|
| 1376 |
+
// v16: tapered multi-segment blade — wide at root, thinning to a soft point
|
| 1377 |
const amp = 3 + g.ripple * 13;
|
| 1378 |
+
const n = g.segs + 1; // number of points along the blade
|
| 1379 |
+
const pts = [];
|
| 1380 |
+
for (let i = 0; i < n; i++) {
|
| 1381 |
+
const f = i / g.segs; // 0 at base, 1 at tip
|
| 1382 |
+
// organic rest-curve: each blade has its own natural bend (like seaweed)
|
| 1383 |
+
const curveX = f * g.cA1 * Math.sin(f * g.cF1 + g.cP1);
|
| 1384 |
+
// constant sway, amplified while rippling from a passing fish
|
| 1385 |
+
const sway = Math.sin(t * g.speed + g.phase + f * 2.4) * amp * f;
|
| 1386 |
+
pts.push({
|
| 1387 |
+
x: g.x + g.lean * g.h * f + curveX + sway,
|
| 1388 |
+
y: g.y - g.h * f * (1 - g.ripple * 0.06)
|
| 1389 |
+
});
|
| 1390 |
+
}
|
| 1391 |
+
// draw tapered outline around the physics points
|
| 1392 |
+
ctx.fillStyle = `hsla(${g.hue},58%,40%,0.92)`;
|
| 1393 |
ctx.beginPath();
|
| 1394 |
+
for (let i = 0; i < n; i++) {
|
| 1395 |
+
const p = pts[i];
|
| 1396 |
+
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
|
| 1397 |
+
let dx = b.x - a.x, dy = b.y - a.y;
|
| 1398 |
+
const dl = Math.hypot(dx, dy) || 1;
|
| 1399 |
+
const nx = -dy / dl, ny = dx / dl; // perpendicular to local blade direction
|
| 1400 |
+
const f = i / (n - 1);
|
| 1401 |
+
const hw = (g.width * (1 - f) + 0.8 * f) / 2; // half-width: base → tip taper
|
| 1402 |
+
if (i === 0) ctx.moveTo(p.x + nx * hw, p.y + ny * hw);
|
| 1403 |
+
else ctx.lineTo(p.x + nx * hw, p.y + ny * hw);
|
| 1404 |
+
}
|
| 1405 |
+
// rounded cap at the tip
|
| 1406 |
+
{
|
| 1407 |
+
const tp = pts[n - 1], pp = pts[n - 2];
|
| 1408 |
+
let dx = tp.x - pp.x, dy = tp.y - pp.y;
|
| 1409 |
+
const dl = Math.hypot(dx, dy) || 1;
|
| 1410 |
+
const nx = -dy / dl, ny = dx / dl;
|
| 1411 |
+
const hwT = 0.4; // tip half-width
|
| 1412 |
+
ctx.quadraticCurveTo(tp.x + dx, tp.y + dy * 0.6, tp.x - nx * hwT, tp.y - ny * hwT);
|
| 1413 |
+
}
|
| 1414 |
+
for (let i = n - 1; i >= 0; i--) {
|
| 1415 |
+
const p = pts[i];
|
| 1416 |
+
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
|
| 1417 |
+
let dx = b.x - a.x, dy = b.y - a.y;
|
| 1418 |
+
const dl = Math.hypot(dx, dy) || 1;
|
| 1419 |
+
const nx = -dy / dl, ny = dx / dl;
|
| 1420 |
+
const f = i / (n - 1);
|
| 1421 |
+
const hw = (g.width * (1 - f) + 0.8 * f) / 2;
|
| 1422 |
+
ctx.lineTo(p.x - nx * hw, p.y - ny * hw);
|
| 1423 |
+
}
|
| 1424 |
+
ctx.closePath();
|
| 1425 |
+
ctx.fill();
|
| 1426 |
}
|
| 1427 |
}
|
| 1428 |
|
|
|
|
| 1430 |
// Drawn on top of everything else (it is closest to the viewer). Each blade gets a
|
| 1431 |
// thin lighter inner stroke over a darker body for a lush, voluminous "closer" look.
|
| 1432 |
function drawFgGrass(t) {
|
|
|
|
| 1433 |
for (const g of fgGrass) {
|
| 1434 |
+
// v16: tapered multi-segment blade with two-tone depth (like seaweed)
|
| 1435 |
+
const amp = 4 + g.h * 0.03;
|
| 1436 |
+
const n = g.segs + 1;
|
| 1437 |
+
const pts = [];
|
| 1438 |
+
for (let i = 0; i < n; i++) {
|
| 1439 |
+
const f = i / g.segs;
|
| 1440 |
+
// organic rest-curve: each blade has its own natural bend
|
| 1441 |
+
const curveX = f * g.cA1 * Math.sin(f * g.cF1 + g.cP1);
|
| 1442 |
+
// constant sway, weighted toward the tip
|
| 1443 |
+
const sway = Math.sin(t * g.speed + g.phase + f * 2.4) * amp * f;
|
| 1444 |
+
pts.push({
|
| 1445 |
+
x: g.x + g.lean * g.h * f + curveX + sway,
|
| 1446 |
+
y: g.y - g.h * f
|
| 1447 |
+
});
|
| 1448 |
+
}
|
| 1449 |
+
// dark body of the blade (tapered outline)
|
| 1450 |
+
ctx.fillStyle = `hsla(${g.hue},60%,31%,0.96)`;
|
| 1451 |
ctx.beginPath();
|
| 1452 |
+
for (let i = 0; i < n; i++) {
|
| 1453 |
+
const p = pts[i];
|
| 1454 |
+
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
|
| 1455 |
+
let dx = b.x - a.x, dy = b.y - a.y;
|
| 1456 |
+
const dl = Math.hypot(dx, dy) || 1;
|
| 1457 |
+
const nx = -dy / dl, ny = dx / dl;
|
| 1458 |
+
const f = i / (n - 1);
|
| 1459 |
+
const hw = (g.width * (1 - f) + 1.2 * f) / 2; // half-width: base → tip
|
| 1460 |
+
if (i === 0) ctx.moveTo(p.x + nx * hw, p.y + ny * hw);
|
| 1461 |
+
else ctx.lineTo(p.x + nx * hw, p.y + ny * hw);
|
| 1462 |
+
}
|
| 1463 |
+
// rounded cap at the tip
|
| 1464 |
+
{
|
| 1465 |
+
const tp = pts[n - 1], pp = pts[n - 2];
|
| 1466 |
+
let dx = tp.x - pp.x, dy = tp.y - pp.y;
|
| 1467 |
+
const dl = Math.hypot(dx, dy) || 1;
|
| 1468 |
+
const nx = -dy / dl, ny = dx / dl;
|
| 1469 |
+
const hwT = 0.6; // tip half-width
|
| 1470 |
+
ctx.quadraticCurveTo(tp.x + dx, tp.y + dy * 0.6, tp.x - nx * hwT, tp.y - ny * hwT);
|
| 1471 |
+
}
|
| 1472 |
+
for (let i = n - 1; i >= 0; i--) {
|
| 1473 |
+
const p = pts[i];
|
| 1474 |
+
const a = pts[Math.max(0, i - 1)], b = pts[Math.min(n - 1, i + 1)];
|
| 1475 |
+
let dx = b.x - a.x, dy = b.y - a.y;
|
| 1476 |
+
const dl = Math.hypot(dx, dy) || 1;
|
| 1477 |
+
const nx = -dy / dl, ny = dx / dl;
|
| 1478 |
+
const f = i / (n - 1);
|
| 1479 |
+
const hw = (g.width * (1 - f) + 1.2 * f) / 2;
|
| 1480 |
+
ctx.lineTo(p.x - nx * hw, p.y - ny * hw);
|
| 1481 |
+
}
|
| 1482 |
+
ctx.closePath();
|
| 1483 |
+
ctx.fill();
|
| 1484 |
|
| 1485 |
+
// lighter inner highlight for depth: a thin centerline stroke
|
| 1486 |
ctx.strokeStyle = `hsla(${g.hue + 6},64%,45%,0.5)`;
|
| 1487 |
+
ctx.lineWidth = g.width * 0.3;
|
| 1488 |
+
ctx.lineCap = "round";
|
| 1489 |
ctx.beginPath();
|
| 1490 |
+
ctx.moveTo(pts[0].x, pts[0].y);
|
| 1491 |
+
for (let i = 1; i < n; i++) ctx.lineTo(pts[i].x, pts[i].y);
|
| 1492 |
ctx.stroke();
|
| 1493 |
}
|
| 1494 |
}
|