Upload index.html
Browse files- index.html +721 -19
index.html
CHANGED
|
@@ -1,19 +1,721 @@
|
|
| 1 |
-
<!
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 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; }
|
| 10 |
+
.glass {
|
| 11 |
+
position:fixed; inset:0; pointer-events:none; z-index:2;
|
| 12 |
+
background:
|
| 13 |
+
linear-gradient(115deg, rgba(255,255,255,0.07) 0%, rgba(255,255,255,0) 30%),
|
| 14 |
+
linear-gradient(295deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0) 25%);
|
| 15 |
+
mix-blend-mode: screen;
|
| 16 |
+
}
|
| 17 |
+
</style>
|
| 18 |
+
</head>
|
| 19 |
+
<body>
|
| 20 |
+
<canvas id="tank"></canvas>
|
| 21 |
+
<div class="glass"></div>
|
| 22 |
+
<script>
|
| 23 |
+
(() => {
|
| 24 |
+
"use strict";
|
| 25 |
+
const canvas = document.getElementById("tank");
|
| 26 |
+
const ctx = canvas.getContext("2d");
|
| 27 |
+
let W = 0, H = 0;
|
| 28 |
+
|
| 29 |
+
const rand = (a, b) => a + Math.random() * (b - a);
|
| 30 |
+
const pick = arr => arr[(Math.random() * arr.length) | 0];
|
| 31 |
+
|
| 32 |
+
function lerpAngle(a, b, t) {
|
| 33 |
+
let d = b - a;
|
| 34 |
+
while (d > Math.PI) d -= 2 * Math.PI;
|
| 35 |
+
while (d < -Math.PI) d += 2 * Math.PI;
|
| 36 |
+
return a + d * t;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
const HUES = [8, 35, 46, 172, 195, 215, 262, 320];
|
| 40 |
+
let fishes = [], bubbles = [], seaweeds = [], motes = [], rays = [], rocks = [], grass = [];
|
| 41 |
+
let plantPoints = []; // collision points along every stalk/branch, rebuilt each frame
|
| 42 |
+
let sandHeights = [], starfish = null;
|
| 43 |
+
|
| 44 |
+
class Fish {
|
| 45 |
+
constructor() {
|
| 46 |
+
this.size = rand(15, 42);
|
| 47 |
+
this.speed = rand(0.55, 1.7) * (this.size / 30 + 0.5);
|
| 48 |
+
this.x = rand(60, W - 60);
|
| 49 |
+
this.y = rand(H * 0.15, H * 0.72);
|
| 50 |
+
this.angle = Math.random() < 0.5 ? rand(-0.4, 0.4) : Math.PI + rand(-0.4, 0.4);
|
| 51 |
+
this.hue = pick(HUES);
|
| 52 |
+
this.phase = rand(0, Math.PI * 2);
|
| 53 |
+
|
| 54 |
+
// Wander steering (Reynolds-style): a circle ahead of the fish whose target point jitters
|
| 55 |
+
this.wanderTheta = rand(0, Math.PI * 2);
|
| 56 |
+
this.wanderRadius = rand(35, 80);
|
| 57 |
+
this.wanderDist = rand(90, 170);
|
| 58 |
+
this.maxTurn = rand(1.4, 2.2); // max rad/s — organic turns, no orbiting
|
| 59 |
+
|
| 60 |
+
// Behavior states: fish don't swim non-stop — they cruise, hover, and rest.
|
| 61 |
+
this.behavior = 'swim'; // 'swim' | 'hover' | 'pause'
|
| 62 |
+
this.behaviorTimer = rand(4, 11); // seconds until it may change behavior
|
| 63 |
+
this.speedFactor = 1; // current speed multiplier (eased toward target)
|
| 64 |
+
this.targetSpeedFactor = 1; // where speedFactor is easing toward
|
| 65 |
+
|
| 66 |
+
// Occasionally puffs a tiny cluster of bubbles from its mouth
|
| 67 |
+
this.bubbleTimer = rand(4, 16); // seconds until the next bubble puff
|
| 68 |
+
}
|
| 69 |
+
update(dt) {
|
| 70 |
+
// Behavior state machine — occasionally hover or come to near-rest
|
| 71 |
+
this.behaviorTimer -= dt;
|
| 72 |
+
if (this.behaviorTimer <= 0) {
|
| 73 |
+
const r = Math.random();
|
| 74 |
+
if (r < 0.45) {
|
| 75 |
+
this.behavior = 'swim';
|
| 76 |
+
this.targetSpeedFactor = 1;
|
| 77 |
+
this.behaviorTimer = rand(4, 11);
|
| 78 |
+
} else if (r < 0.74) {
|
| 79 |
+
this.behavior = 'hover';
|
| 80 |
+
this.targetSpeedFactor = rand(0.25, 0.45); // drift slowly in place
|
| 81 |
+
this.behaviorTimer = rand(2, 6);
|
| 82 |
+
} else {
|
| 83 |
+
this.behavior = 'pause';
|
| 84 |
+
this.targetSpeedFactor = rand(0.03, 0.10); // nearly stopped, just drifting
|
| 85 |
+
this.behaviorTimer = rand(1.5, 4);
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
// Ease speed toward the target so speed-ups/slow-downs are smooth, not abrupt
|
| 89 |
+
this.speedFactor += (this.targetSpeedFactor - this.speedFactor) * Math.min(1, dt * 2.2);
|
| 90 |
+
|
| 91 |
+
// Occasionally release a small cluster of bubbles from the mouth
|
| 92 |
+
this.bubbleTimer -= dt;
|
| 93 |
+
if (this.bubbleTimer <= 0) {
|
| 94 |
+
const mx = this.x + Math.cos(this.angle) * this.size * 0.62;
|
| 95 |
+
const my = this.y + Math.sin(this.angle) * this.size * 0.62;
|
| 96 |
+
const n = 2 + ((Math.random() * 3) | 0); // 2..4 bubbles, a tiny cluster
|
| 97 |
+
for (let i = 0; i < n; i++) bubbles.push(new Bubble(false, mx + rand(-3, 3), my + rand(-3, 3)));
|
| 98 |
+
this.bubbleTimer = rand(8, 22); // wait a while before the next puff
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
// Jitter the wander target point around its circle
|
| 102 |
+
this.wanderTheta += rand(-1, 1) * dt * 3.2;
|
| 103 |
+
|
| 104 |
+
// Target sits ahead of the fish (in heading direction), offset by wanderTheta.
|
| 105 |
+
// Steering toward it yields smooth meandering and genuine direction changes.
|
| 106 |
+
const wx = this.x + Math.cos(this.angle) * this.wanderDist + Math.cos(this.wanderTheta) * this.wanderRadius;
|
| 107 |
+
const wy = this.y + Math.sin(this.angle) * this.wanderDist + Math.sin(this.wanderTheta) * this.wanderRadius;
|
| 108 |
+
|
| 109 |
+
let desired = Math.atan2(wy - this.y, wx - this.x);
|
| 110 |
+
|
| 111 |
+
// Soft boundary steering: only kicks in near the actual glass, so fish roam
|
| 112 |
+
// freely across almost the whole tank (including far left and right)
|
| 113 |
+
let pull = 0;
|
| 114 |
+
const mX = W * 0.13, mTop = H * 0.10, mBot = H * 0.16;
|
| 115 |
+
if (this.x < mX) pull = Math.max(pull, 1 - this.x / mX);
|
| 116 |
+
else if (this.x > W - mX) pull = Math.max(pull, 1 - (W - this.x) / mX);
|
| 117 |
+
if (this.y < mTop) pull = Math.max(pull, 1 - this.y / mTop);
|
| 118 |
+
else if (this.y > H - mBot) pull = Math.max(pull, 1 - (H - this.y) / mBot);
|
| 119 |
+
if (pull > 0) {
|
| 120 |
+
const cx = W / 2, cy = H * 0.45;
|
| 121 |
+
desired = lerpAngle(desired, Math.atan2(cy - this.y, cx - this.x), Math.min(1, pull * 1.5));
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// Floor steering: as a fish nears the sand bed it is steered upward,
|
| 125 |
+
// so it skims above the ground line instead of diving under it
|
| 126 |
+
const floorY = sandAt(this.x) - this.size * 0.35;
|
| 127 |
+
const fZone = H * 0.14;
|
| 128 |
+
if (this.y > floorY - fZone) {
|
| 129 |
+
const fp = Math.min(1, (this.y - (floorY - fZone)) / fZone);
|
| 130 |
+
desired = lerpAngle(desired, Math.atan2(H * 0.45 - this.y, W / 2 - this.x), fp * 1.2);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
// Limit turn rate so direction changes are smooth and fish-like
|
| 134 |
+
let d = desired - this.angle;
|
| 135 |
+
while (d > Math.PI) d -= 2 * Math.PI;
|
| 136 |
+
while (d < -Math.PI) d += 2 * Math.PI;
|
| 137 |
+
const maxStep = this.maxTurn * dt;
|
| 138 |
+
this.angle += Math.max(-maxStep, Math.min(maxStep, d));
|
| 139 |
+
|
| 140 |
+
const sp = this.speed * this.speedFactor * 60 * dt;
|
| 141 |
+
this.x += Math.cos(this.angle) * sp;
|
| 142 |
+
this.y += Math.sin(this.angle) * sp;
|
| 143 |
+
|
| 144 |
+
// NOTE: fish do NOT collide with plants — they plough straight through.
|
| 145 |
+
// The seaweed bends out of the way instead (see updatePlants).
|
| 146 |
+
|
| 147 |
+
// Hard safety clamp — never below the ground line (sand surface)
|
| 148 |
+
this.x = Math.max(20, Math.min(W - 20, this.x));
|
| 149 |
+
this.y = Math.max(20, Math.min(sandAt(this.x) - this.size * 0.35, this.y));
|
| 150 |
+
|
| 151 |
+
// Tail beat follows actual speed — a resting fish barely moves its tail
|
| 152 |
+
const effSpeed = this.speed * this.speedFactor;
|
| 153 |
+
this.phase += dt * (4 + effSpeed * 3);
|
| 154 |
+
}
|
| 155 |
+
draw(c) {
|
| 156 |
+
c.save();
|
| 157 |
+
c.translate(this.x, this.y);
|
| 158 |
+
c.rotate(this.angle);
|
| 159 |
+
const s = this.size;
|
| 160 |
+
const sway = Math.sin(this.phase) * s * 0.28;
|
| 161 |
+
|
| 162 |
+
// tail fin
|
| 163 |
+
c.fillStyle = `hsla(${this.hue},75%,52%,0.92)`;
|
| 164 |
+
c.beginPath();
|
| 165 |
+
c.moveTo(-s * 0.5, 0);
|
| 166 |
+
c.quadraticCurveTo(-s * 1.0, -s * 0.35 + sway, -s * 1.28, -s * 0.48 + sway);
|
| 167 |
+
c.lineTo(-s * 0.82, sway * 0.5);
|
| 168 |
+
c.lineTo(-s * 1.28, s * 0.48 + sway);
|
| 169 |
+
c.quadraticCurveTo(-s * 1.0, s * 0.35 + sway, -s * 0.5, 0);
|
| 170 |
+
c.fill();
|
| 171 |
+
|
| 172 |
+
// body
|
| 173 |
+
const g = c.createLinearGradient(0, -s * 0.5, 0, s * 0.5);
|
| 174 |
+
g.addColorStop(0, `hsl(${this.hue},82%,64%)`);
|
| 175 |
+
g.addColorStop(1, `hsl(${this.hue},70%,44%)`);
|
| 176 |
+
c.fillStyle = g;
|
| 177 |
+
c.beginPath();
|
| 178 |
+
c.ellipse(0, 0, s, s * 0.42, 0, 0, Math.PI * 2);
|
| 179 |
+
c.fill();
|
| 180 |
+
|
| 181 |
+
// belly highlight
|
| 182 |
+
c.fillStyle = "rgba(255,255,255,0.16)";
|
| 183 |
+
c.beginPath();
|
| 184 |
+
c.ellipse(0, s * 0.14, s * 0.82, s * 0.24, 0, 0, Math.PI * 2);
|
| 185 |
+
c.fill();
|
| 186 |
+
|
| 187 |
+
// dorsal fin
|
| 188 |
+
c.fillStyle = `hsla(${this.hue},80%,50%,0.9)`;
|
| 189 |
+
c.beginPath();
|
| 190 |
+
c.moveTo(-s * 0.15, -s * 0.36);
|
| 191 |
+
c.quadraticCurveTo(s * 0.05, -s * 0.78, s * 0.38, -s * 0.4);
|
| 192 |
+
c.closePath();
|
| 193 |
+
c.fill();
|
| 194 |
+
|
| 195 |
+
// pectoral fin (flapping)
|
| 196 |
+
const flap = Math.sin(this.phase * 1.3) * 0.45;
|
| 197 |
+
c.save();
|
| 198 |
+
c.translate(s * 0.05, s * 0.12);
|
| 199 |
+
c.rotate(0.7 + flap);
|
| 200 |
+
c.fillStyle = `hsla(${this.hue},85%,66%,0.8)`;
|
| 201 |
+
c.beginPath();
|
| 202 |
+
c.ellipse(0, 0, s * 0.3, s * 0.14, 0, 0, Math.PI * 2);
|
| 203 |
+
c.fill();
|
| 204 |
+
c.restore();
|
| 205 |
+
|
| 206 |
+
// eye
|
| 207 |
+
c.fillStyle = "#fff";
|
| 208 |
+
c.beginPath();
|
| 209 |
+
c.arc(s * 0.58, -s * 0.1, s * 0.11, 0, Math.PI * 2);
|
| 210 |
+
c.fill();
|
| 211 |
+
c.fillStyle = "#0d1420";
|
| 212 |
+
c.beginPath();
|
| 213 |
+
c.arc(s * 0.61, -s * 0.1, s * 0.055, 0, Math.PI * 2);
|
| 214 |
+
c.fill();
|
| 215 |
+
|
| 216 |
+
c.restore();
|
| 217 |
+
}
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
class Bubble {
|
| 221 |
+
constructor(init, x, y) {
|
| 222 |
+
if (x !== undefined) { // spawned from a fish's mouth: tiny, one-shot
|
| 223 |
+
this.x = x; this.y = y;
|
| 224 |
+
this.r = rand(1.4, 3.2);
|
| 225 |
+
this.speed = rand(45, 85);
|
| 226 |
+
this.p = rand(0, Math.PI * 2);
|
| 227 |
+
this.isMouth = true;
|
| 228 |
+
} else {
|
| 229 |
+
this.reset(init);
|
| 230 |
+
}
|
| 231 |
+
}
|
| 232 |
+
reset(init) {
|
| 233 |
+
this.x = rand(0, W);
|
| 234 |
+
this.y = init ? rand(H * 0.3, H) : H + rand(10, 80);
|
| 235 |
+
this.r = rand(2, 7);
|
| 236 |
+
this.speed = rand(35, 90);
|
| 237 |
+
this.p = rand(0, Math.PI * 2);
|
| 238 |
+
this.isMouth = false;
|
| 239 |
+
}
|
| 240 |
+
update(dt, t) {
|
| 241 |
+
this.y -= this.speed * dt;
|
| 242 |
+
this.x += Math.sin(t * 1.6 + this.p) * 22 * dt;
|
| 243 |
+
if (this.y < -12) { if (this.isMouth) this.dead = true; else this.reset(false); }
|
| 244 |
+
}
|
| 245 |
+
draw(c) {
|
| 246 |
+
c.lineWidth = 1;
|
| 247 |
+
c.strokeStyle = "rgba(255,255,255,0.35)";
|
| 248 |
+
c.fillStyle = "rgba(255,255,255,0.08)";
|
| 249 |
+
c.beginPath();
|
| 250 |
+
c.arc(this.x, this.y, this.r, 0, Math.PI * 2);
|
| 251 |
+
c.fill();
|
| 252 |
+
c.stroke();
|
| 253 |
+
c.fillStyle = "rgba(255,255,255,0.45)";
|
| 254 |
+
c.beginPath();
|
| 255 |
+
c.arc(this.x - this.r * 0.3, this.y - this.r * 0.3, this.r * 0.25, 0, Math.PI * 2);
|
| 256 |
+
c.fill();
|
| 257 |
+
}
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
function sandAt(x) {
|
| 261 |
+
const i = Math.max(0, Math.min(sandHeights.length - 2, x / 40));
|
| 262 |
+
const f = (x / 40) - Math.floor(i);
|
| 263 |
+
return sandHeights[Math.floor(i)] * (1 - f) + sandHeights[Math.floor(i) + 1] * f;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
function buildScene() {
|
| 267 |
+
fishes = [];
|
| 268 |
+
bubbles = [];
|
| 269 |
+
seaweeds = [];
|
| 270 |
+
motes = [];
|
| 271 |
+
rays = [];
|
| 272 |
+
rocks = [];
|
| 273 |
+
sandHeights = [];
|
| 274 |
+
|
| 275 |
+
const sandTop = H - Math.max(60, H * 0.12);
|
| 276 |
+
for (let x = 0; x <= W + 40; x += 40) sandHeights.push(sandTop + rand(-8, 14));
|
| 277 |
+
|
| 278 |
+
// plants grow in bundles of stalks sharing a base patch; each stalk sprouts side branches
|
| 279 |
+
const nClusters = Math.max(6, Math.floor(W / 150));
|
| 280 |
+
for (let i = 0; i < nClusters; i++) {
|
| 281 |
+
const cx = rand(45, W - 45);
|
| 282 |
+
const clusterHue = rand(95, 145);
|
| 283 |
+
const nStalks = 3 + ((Math.random() * 4) | 0); // 3..6 stalks per bundle
|
| 284 |
+
for (let j = 0; j < nStalks; j++) {
|
| 285 |
+
const st = {
|
| 286 |
+
x: cx + rand(-16, 16),
|
| 287 |
+
h: rand(H * 0.12, H * 0.34),
|
| 288 |
+
segs: 8,
|
| 289 |
+
phase: rand(0, Math.PI * 2),
|
| 290 |
+
speed: rand(0.9, 1.7),
|
| 291 |
+
hue: clusterHue + rand(-10, 10),
|
| 292 |
+
width: rand(4, 8),
|
| 293 |
+
touch: 0,
|
| 294 |
+
off: new Float32Array(9), // lateral bend per segment (fish push leaves aside)
|
| 295 |
+
offV: new Float32Array(9), // velocity of that bend
|
| 296 |
+
branches: [],
|
| 297 |
+
points: []
|
| 298 |
+
};
|
| 299 |
+
const nBr = 1 + ((Math.random() * 3) | 0); // 1..3 branches per stalk
|
| 300 |
+
for (let k = 0; k < nBr; k++) {
|
| 301 |
+
st.branches.push({
|
| 302 |
+
f: rand(0.25, 0.85), // where along the stalk it sprouts
|
| 303 |
+
side: Math.random() < 0.5 ? -1 : 1,
|
| 304 |
+
len: rand(12, 30), // horizontal reach
|
| 305 |
+
up: rand(10, 26), // how far it curls upward
|
| 306 |
+
w: rand(2, 4),
|
| 307 |
+
ph: rand(0, Math.PI * 2),
|
| 308 |
+
flower: Math.random() < 0.3 ? { // ~30% of branch tips bloom
|
| 309 |
+
hue: (Math.random() * 360) | 0, // colourful — full spectrum
|
| 310 |
+
r: rand(4.5, 8), // head size
|
| 311 |
+
petals: 5 + ((Math.random() * 3) | 0) // 5..7 petals
|
| 312 |
+
} : null
|
| 313 |
+
});
|
| 314 |
+
}
|
| 315 |
+
seaweeds.push(st);
|
| 316 |
+
}
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
// Short, dense grass-like ground cover along the sand bed.
|
| 320 |
+
// Each tuft is a cluster of thin blades that ripple when fish swim nearby.
|
| 321 |
+
const nGrassTufts = Math.max(50, Math.floor(W / 9));
|
| 322 |
+
for (let i = 0; i < nGrassTufts; i++) {
|
| 323 |
+
const gx = rand(8, W - 8);
|
| 324 |
+
const gy = sandAt(gx) + rand(-3, 7);
|
| 325 |
+
const baseHue = rand(95, 155);
|
| 326 |
+
const blades = 2 + ((Math.random() * 4) | 0); // 2..5 blades per tuft
|
| 327 |
+
for (let b = 0; b < blades; b++) {
|
| 328 |
+
grass.push({
|
| 329 |
+
x: gx + rand(-7, 7),
|
| 330 |
+
y: gy,
|
| 331 |
+
h: rand(14, 44),
|
| 332 |
+
lean: rand(-0.35, 0.35), // base tilt from vertical
|
| 333 |
+
phase: rand(0, Math.PI * 2),
|
| 334 |
+
speed: rand(1.3, 2.8), // quicker, finer sway than seaweed
|
| 335 |
+
hue: baseHue + rand(-9, 9),
|
| 336 |
+
width: rand(1.4, 2.8),
|
| 337 |
+
ripple: 0 // agitation from nearby fish (0..1)
|
| 338 |
+
});
|
| 339 |
+
}
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
const nFish = Math.max(7, Math.floor(W / 130));
|
| 343 |
+
for (let i = 0; i < nFish; i++) fishes.push(new Fish());
|
| 344 |
+
|
| 345 |
+
for (let i = 0; i < 52; i++) bubbles.push(new Bubble(true)); // doubled for a livelier stream
|
| 346 |
+
|
| 347 |
+
for (let i = 0; i < 45; i++) {
|
| 348 |
+
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) });
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
for (let i = 0; i < 5; i++) {
|
| 352 |
+
rays.push({
|
| 353 |
+
x: rand(0, W), w: rand(80, 210), tilt: rand(-0.35, 0.35),
|
| 354 |
+
speed: rand(0.06, 0.14) * (Math.random() < 0.5 ? 1 : -1), alpha: rand(0.04, 0.09)
|
| 355 |
+
});
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
const nRocks = Math.max(3, Math.floor(W / 320));
|
| 359 |
+
for (let i = 0; i < nRocks; i++) {
|
| 360 |
+
rocks.push({ x: rand(40, W - 40), r: rand(14, 34), tone: rand(0.75, 1) });
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
starfish = { x: rand(W * 0.2, W * 0.8), r: rand(12, 18), rot: rand(0, Math.PI * 2) };
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
function resize() {
|
| 367 |
+
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
| 368 |
+
W = window.innerWidth;
|
| 369 |
+
H = window.innerHeight;
|
| 370 |
+
canvas.width = W * dpr;
|
| 371 |
+
canvas.height = H * dpr;
|
| 372 |
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
| 373 |
+
buildScene();
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
// --- slow light cycle: dark -> blue -> orange -> dark over CYCLE seconds ---
|
| 377 |
+
const CYCLE = 240; // seconds per full day/night cycle (night now lasts ~2x as long)
|
| 378 |
+
// [t, waterTop, waterMid, waterBot, rayTint, ambientWashRGB, ambientWashAlpha]
|
| 379 |
+
const LIGHT_KEYS = [
|
| 380 |
+
[0.00, [13,88,118], [8,58,82], [3,25,43], [190,235,255], [190,235,255], 0.00],
|
| 381 |
+
[0.35, [224,122,52], [128,60,36], [34,22,26], [255,176,96], [255,150,70], 0.14],
|
| 382 |
+
[0.62, [7,15,32], [4,10,22], [2,6,14], [96,146,206], [3,8,20], 0.40],
|
| 383 |
+
[0.90, [7,15,32], [4,10,22], [2,6,14], [96,146,206], [3,8,20], 0.40],
|
| 384 |
+
[1.00, [13,88,118], [8,58,82], [3,25,43], [190,235,255], [190,235,255], 0.00]
|
| 385 |
+
];
|
| 386 |
+
|
| 387 |
+
function sampleLight(t) {
|
| 388 |
+
const p = ((t / CYCLE) % 1 + 1) % 1;
|
| 389 |
+
let i = 0;
|
| 390 |
+
while (i < LIGHT_KEYS.length - 2 && LIGHT_KEYS[i + 1][0] <= p) i++;
|
| 391 |
+
const k0 = LIGHT_KEYS[i], k1 = LIGHT_KEYS[i + 1];
|
| 392 |
+
const f = Math.max(0, Math.min(1, (p - k0[0]) / (k1[0] - k0[0])));
|
| 393 |
+
// each key row: [t, waterTop[r,g,b], waterMid, waterBot, rayTint, washRGB, washA]
|
| 394 |
+
const mixC = (a, b) => [0, 1, 2].map(c => Math.round(a[c] + (b[c] - a[c]) * f));
|
| 395 |
+
return { top: mixC(k0[1], k1[1]), mid: mixC(k0[2], k1[2]), bot: mixC(k0[3], k1[3]), ray: mixC(k0[4], k1[4]), washRGB: mixC(k0[5], k1[5]), washA: k0[6] + (k1[6] - k0[6]) * f };
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
function rgbStr(c) { return `rgb(${c[0]},${c[1]},${c[2]})`; }
|
| 399 |
+
|
| 400 |
+
function drawBackground(light) {
|
| 401 |
+
const g = ctx.createLinearGradient(0, 0, 0, H);
|
| 402 |
+
g.addColorStop(0, rgbStr(light.top));
|
| 403 |
+
g.addColorStop(0.5, rgbStr(light.mid));
|
| 404 |
+
g.addColorStop(1, rgbStr(light.bot));
|
| 405 |
+
ctx.fillStyle = g;
|
| 406 |
+
ctx.fillRect(0, 0, W, H);
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
function drawRays(t, light) {
|
| 410 |
+
const [rr, rg, rb] = light.ray;
|
| 411 |
+
for (const r of rays) {
|
| 412 |
+
const off = Math.sin(t * r.speed * 6 + r.x) * 55;
|
| 413 |
+
const x = r.x + off;
|
| 414 |
+
const topW = r.w * 0.45, botW = r.w * 1.9;
|
| 415 |
+
const shift = r.tilt * H;
|
| 416 |
+
|
| 417 |
+
// soft outer beam: bright at the surface, fading down through the water
|
| 418 |
+
let grad = ctx.createLinearGradient(0, 0, 0, H);
|
| 419 |
+
grad.addColorStop(0, `rgba(${rr},${rg},${rb},${r.alpha})`);
|
| 420 |
+
grad.addColorStop(0.55, `rgba(${rr},${rg},${rb},${r.alpha * 0.45})`);
|
| 421 |
+
grad.addColorStop(1, `rgba(${rr},${rg},${rb},0)`);
|
| 422 |
+
ctx.fillStyle = grad;
|
| 423 |
+
ctx.beginPath();
|
| 424 |
+
ctx.moveTo(x - topW, -10);
|
| 425 |
+
ctx.lineTo(x + topW, -10);
|
| 426 |
+
ctx.lineTo(x + shift + botW, H * 0.95);
|
| 427 |
+
ctx.lineTo(x + shift - botW, H * 0.95);
|
| 428 |
+
ctx.closePath();
|
| 429 |
+
ctx.fill();
|
| 430 |
+
|
| 431 |
+
// brighter inner core so each beam has a gentle gradient across it
|
| 432 |
+
const cTop = topW * 0.42, cBot = botW * 0.42;
|
| 433 |
+
grad = ctx.createLinearGradient(0, 0, 0, H);
|
| 434 |
+
grad.addColorStop(0, `rgba(${rr},${rg},${rb},${r.alpha * 1.5})`);
|
| 435 |
+
grad.addColorStop(0.65, `rgba(${rr},${rg},${rb},0)`);
|
| 436 |
+
ctx.fillStyle = grad;
|
| 437 |
+
ctx.beginPath();
|
| 438 |
+
ctx.moveTo(x - cTop, -10);
|
| 439 |
+
ctx.lineTo(x + cTop, -10);
|
| 440 |
+
ctx.lineTo(x + shift + cBot, H * 0.95);
|
| 441 |
+
ctx.lineTo(x + shift - cBot, H * 0.95);
|
| 442 |
+
ctx.closePath();
|
| 443 |
+
ctx.fill();
|
| 444 |
+
}
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
function updateMotes(dt, t) {
|
| 448 |
+
ctx.fillStyle = "rgba(220,240,255,0.3)";
|
| 449 |
+
for (const m of motes) {
|
| 450 |
+
m.y -= m.s * dt;
|
| 451 |
+
m.x += Math.sin(t + m.p) * 6 * dt;
|
| 452 |
+
if (m.y < -4) { m.y = H + 4; m.x = rand(0, W); }
|
| 453 |
+
ctx.beginPath();
|
| 454 |
+
ctx.arc(m.x, m.y, m.r, 0, Math.PI * 2);
|
| 455 |
+
ctx.fill();
|
| 456 |
+
}
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
function drawSand() {
|
| 460 |
+
const g = ctx.createLinearGradient(0, H * 0.85, 0, H);
|
| 461 |
+
g.addColorStop(0, "#d9b97c");
|
| 462 |
+
g.addColorStop(1, "#7e653d");
|
| 463 |
+
ctx.fillStyle = g;
|
| 464 |
+
ctx.beginPath();
|
| 465 |
+
ctx.moveTo(-10, H + 10);
|
| 466 |
+
for (let i = 0; i < sandHeights.length; i++) ctx.lineTo(i * 40, sandHeights[i]);
|
| 467 |
+
ctx.lineTo(W + 10, H + 10);
|
| 468 |
+
ctx.closePath();
|
| 469 |
+
ctx.fill();
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
function drawRocks() {
|
| 473 |
+
for (const rk of rocks) {
|
| 474 |
+
const y = sandAt(rk.x) + rk.r * 0.35;
|
| 475 |
+
// fully opaque — tone now varies lightness instead of alpha
|
| 476 |
+
const base = Math.round(28 + rk.tone * 46);
|
| 477 |
+
const g = ctx.createRadialGradient(rk.x - rk.r * 0.3, y - rk.r * 0.4, rk.r * 0.1, rk.x, y, rk.r);
|
| 478 |
+
g.addColorStop(0, `rgb(${base + 68},${base + 78},${base + 92})`);
|
| 479 |
+
g.addColorStop(1, "rgb(38,48,62)");
|
| 480 |
+
ctx.fillStyle = g;
|
| 481 |
+
ctx.beginPath();
|
| 482 |
+
ctx.ellipse(rk.x, y, rk.r, rk.r * 0.72, 0, Math.PI, Math.PI * 2);
|
| 483 |
+
ctx.fill();
|
| 484 |
+
}
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
function drawStarfish(t) {
|
| 488 |
+
const sf = starfish;
|
| 489 |
+
const y = sandAt(sf.x) - sf.r * 0.15;
|
| 490 |
+
const rot = sf.rot + Math.sin(t * 0.4 + sf.x) * 0.08;
|
| 491 |
+
ctx.save();
|
| 492 |
+
ctx.translate(sf.x, y);
|
| 493 |
+
ctx.rotate(rot);
|
| 494 |
+
ctx.fillStyle = "#ff7a59";
|
| 495 |
+
ctx.beginPath();
|
| 496 |
+
for (let i = 0; i < 10; i++) {
|
| 497 |
+
const ang = i * Math.PI / 5 - Math.PI / 2;
|
| 498 |
+
const rr = i % 2 === 0 ? sf.r : sf.r * 0.45;
|
| 499 |
+
const px = Math.cos(ang) * rr, py = Math.sin(ang) * rr;
|
| 500 |
+
i ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
|
| 501 |
+
}
|
| 502 |
+
ctx.closePath();
|
| 503 |
+
ctx.fill();
|
| 504 |
+
ctx.fillStyle = "rgba(255,255,255,0.35)";
|
| 505 |
+
for (let i = 0; i < 5; i++) {
|
| 506 |
+
const ang = i * Math.PI * 2 / 5 - Math.PI / 2;
|
| 507 |
+
ctx.beginPath();
|
| 508 |
+
ctx.arc(Math.cos(ang) * sf.r * 0.5, Math.sin(ang) * sf.r * 0.5, 1.4, 0, Math.PI * 2);
|
| 509 |
+
ctx.fill();
|
| 510 |
+
}
|
| 511 |
+
ctx.restore();
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
function updatePlants(t, dt) {
|
| 515 |
+
plantPoints.length = 0;
|
| 516 |
+
for (const w of seaweeds) {
|
| 517 |
+
// ---- fish push leaves aside: fish overpower the flora ----
|
| 518 |
+
for (const f of fishes) {
|
| 519 |
+
const R = 80, R2 = R * R; // influence radius around fish
|
| 520 |
+
const fx = Math.cos(f.angle) * f.speed * 60; // fish velocity x (px/s)
|
| 521 |
+
for (let i = 1; i <= w.segs; i++) {
|
| 522 |
+
const fl = i / w.segs;
|
| 523 |
+
const px = w.x + w.off[i] + Math.sin(t * w.speed + w.phase + fl * 2.6) * fl * fl * 24;
|
| 524 |
+
const py = sandAt(w.x) + 8 - w.h * fl;
|
| 525 |
+
const dx = px - f.x, dy = py - f.y;
|
| 526 |
+
const d2 = dx * dx + dy * dy;
|
| 527 |
+
if (d2 < R2 && d2 > 0.01) {
|
| 528 |
+
const d = Math.sqrt(d2);
|
| 529 |
+
const weight = (R - d) / R * (0.35 + 0.65 * fl); // upper segments yield more
|
| 530 |
+
// shove radially away from the fish + drag along its swim direction
|
| 531 |
+
w.offV[i] += ((dx / d) * 1500 + fx * 9) * weight * dt;
|
| 532 |
+
if (weight > 0.25) w.touch = Math.min(1, w.touch + dt * 5);
|
| 533 |
+
}
|
| 534 |
+
}
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
// ---- spring back toward rest, with damping (leaves flow back after fish pass) ----
|
| 538 |
+
for (let i = 1; i <= w.segs; i++) {
|
| 539 |
+
w.offV[i] += -w.off[i] * 20 * dt;
|
| 540 |
+
w.offV[i] *= Math.max(0, 1 - 4 * dt);
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
// ---- neighbour coupling: bend as one flexible stalk, not loose points ----
|
| 544 |
+
for (let i = 1; i < w.segs; i++) {
|
| 545 |
+
const F = (w.off[i + 1] - w.off[i]) * 30 * dt;
|
| 546 |
+
w.offV[i] += F;
|
| 547 |
+
w.offV[i + 1] -= F;
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
// ---- integrate bend (root stays planted) ----
|
| 551 |
+
for (let i = 1; i <= w.segs; i++) {
|
| 552 |
+
w.off[i] += w.offV[i] * dt;
|
| 553 |
+
if (w.off[i] > 70) { w.off[i] = 70; w.offV[i] *= -0.3; }
|
| 554 |
+
else if (w.off[i] < -70) { w.off[i] = -70; w.offV[i] *= -0.3; }
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
if (w.touch > 0) w.touch = Math.max(0, w.touch - dt * 1.2);
|
| 558 |
+
const baseY = sandAt(w.x) + 8;
|
| 559 |
+
w.points.length = 0;
|
| 560 |
+
for (let i = 0; i <= w.segs; i++) {
|
| 561 |
+
const f = i / w.segs;
|
| 562 |
+
// normal sway + a fast extra flinch while being pushed
|
| 563 |
+
const sway = Math.sin(t * w.speed + w.phase + f * 2.6) * f * f * 24
|
| 564 |
+
+ Math.sin(t * 7 + w.phase) * w.touch * f * 16;
|
| 565 |
+
const px = w.x + (w.off[i] || 0) + sway;
|
| 566 |
+
const py = baseY - w.h * f;
|
| 567 |
+
w.points.push({ x: px, y: py });
|
| 568 |
+
plantPoints.push({ x: px, y: py, r: w.width * 0.55, s: w });
|
| 569 |
+
}
|
| 570 |
+
for (const b of w.branches) {
|
| 571 |
+
const a = w.points[Math.min(w.segs, Math.max(1, Math.round(b.f * w.segs)))];
|
| 572 |
+
const flutter = Math.sin(t * w.speed * 1.4 + b.ph) * (3 + w.touch * 9);
|
| 573 |
+
b.tx = a.x + b.side * b.len + flutter;
|
| 574 |
+
b.ty = a.y - b.up;
|
| 575 |
+
plantPoints.push({ x: b.tx, y: b.ty, r: 5, s: w });
|
| 576 |
+
plantPoints.push({ x: (a.x + b.tx) / 2, y: (a.y + b.ty) / 2, r: 4, s: w });
|
| 577 |
+
}
|
| 578 |
+
}
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
// Grass blades ripple when fish swim close — stronger the lower the fish is.
|
| 582 |
+
function updateGrass(t, dt) {
|
| 583 |
+
for (const g of grass) {
|
| 584 |
+
if (g.ripple > 0) g.ripple = Math.max(0, g.ripple - dt * 1.6);
|
| 585 |
+
for (const f of fishes) {
|
| 586 |
+
const dx = g.x - f.x;
|
| 587 |
+
const dy = g.y - f.y;
|
| 588 |
+
const d2 = dx * dx + dy * dy;
|
| 589 |
+
const R = 95, R2 = R * R;
|
| 590 |
+
if (d2 < R2 && d2 > 1) {
|
| 591 |
+
const d = Math.sqrt(d2);
|
| 592 |
+
// fish low near the ground stir the grass far more than high up
|
| 593 |
+
const nearGround = f.y > H * 0.58 ? 1 : 0.35;
|
| 594 |
+
g.ripple = Math.min(1, g.ripple + ((R - d) / R) * dt * 9 * nearGround);
|
| 595 |
+
}
|
| 596 |
+
}
|
| 597 |
+
}
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
function drawSeaweed() {
|
| 601 |
+
for (const w of seaweeds) {
|
| 602 |
+
// main stalk through its precomputed (collision-accurate) points
|
| 603 |
+
ctx.strokeStyle = `hsla(${w.hue},55%,36%,0.92)`;
|
| 604 |
+
ctx.lineWidth = w.width;
|
| 605 |
+
ctx.lineCap = "round";
|
| 606 |
+
ctx.beginPath();
|
| 607 |
+
ctx.moveTo(w.points[0].x, w.points[0].y);
|
| 608 |
+
for (let i = 1; i < w.points.length; i++) ctx.lineTo(w.points[i].x, w.points[i].y);
|
| 609 |
+
ctx.stroke();
|
| 610 |
+
|
| 611 |
+
// side branches curling up and out from the stalk
|
| 612 |
+
for (const b of w.branches) {
|
| 613 |
+
const a = w.points[Math.min(w.segs, Math.max(1, Math.round(b.f * w.segs)))];
|
| 614 |
+
ctx.strokeStyle = `hsla(${w.hue + 8},60%,46%,0.9)`;
|
| 615 |
+
ctx.lineWidth = b.w;
|
| 616 |
+
ctx.beginPath();
|
| 617 |
+
ctx.moveTo(a.x, a.y);
|
| 618 |
+
ctx.quadraticCurveTo((a.x + b.tx) / 2 + b.side * 3, (a.y + b.ty) / 2 - 5, b.tx, b.ty);
|
| 619 |
+
ctx.stroke();
|
| 620 |
+
|
| 621 |
+
// colourful flower head blooming at the branch tip
|
| 622 |
+
if (b.flower) drawFlowerHead(b.tx, b.ty, b.flower);
|
| 623 |
+
}
|
| 624 |
+
}
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
function drawFlowerHead(x, y, fl) {
|
| 628 |
+
ctx.save();
|
| 629 |
+
for (let i = 0; i < fl.petals; i++) { // petals radiating from the centre
|
| 630 |
+
const ang = (i / fl.petals) * Math.PI * 2;
|
| 631 |
+
const px = x + Math.cos(ang) * fl.r * 0.72;
|
| 632 |
+
const py = y + Math.sin(ang) * fl.r * 0.72;
|
| 633 |
+
ctx.fillStyle = `hsla(${fl.hue},85%,64%,0.95)`;
|
| 634 |
+
ctx.beginPath();
|
| 635 |
+
ctx.ellipse(px, py, fl.r * 0.55, fl.r * 0.36, ang, 0, Math.PI * 2);
|
| 636 |
+
ctx.fill();
|
| 637 |
+
}
|
| 638 |
+
ctx.fillStyle = `hsla(${(fl.hue + 45) % 360},92%,78%,0.95)`; // bright contrasting centre
|
| 639 |
+
ctx.beginPath();
|
| 640 |
+
ctx.arc(x, y, fl.r * 0.42, 0, Math.PI * 2);
|
| 641 |
+
ctx.fill();
|
| 642 |
+
ctx.restore();
|
| 643 |
+
}
|
| 644 |
+
|
| 645 |
+
function drawGrass(t) {
|
| 646 |
+
for (const g of grass) {
|
| 647 |
+
// gentle constant sway, amplified while rippling from a passing fish
|
| 648 |
+
const amp = 3 + g.ripple * 13;
|
| 649 |
+
const sway = Math.sin(t * g.speed + g.phase) * amp;
|
| 650 |
+
const tipX = g.x + g.lean * g.h + sway;
|
| 651 |
+
const tipY = g.y - g.h * (1 - g.ripple * 0.06);
|
| 652 |
+
ctx.strokeStyle = `hsla(${g.hue},58%,40%,0.92)`;
|
| 653 |
+
ctx.lineWidth = g.width;
|
| 654 |
+
ctx.lineCap = "round";
|
| 655 |
+
ctx.beginPath();
|
| 656 |
+
ctx.moveTo(g.x, g.y);
|
| 657 |
+
ctx.quadraticCurveTo(
|
| 658 |
+
(g.x + tipX) / 2 + sway * 0.4,
|
| 659 |
+
(g.y + tipY) / 2,
|
| 660 |
+
tipX, tipY
|
| 661 |
+
);
|
| 662 |
+
ctx.stroke();
|
| 663 |
+
}
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
function drawVignette() {
|
| 667 |
+
const g = ctx.createRadialGradient(W / 2, H * 0.45, Math.min(W, H) * 0.35, W / 2, H * 0.55, Math.max(W, H) * 0.78);
|
| 668 |
+
g.addColorStop(0, "rgba(0,0,0,0)");
|
| 669 |
+
g.addColorStop(1, "rgba(0,10,20,0.45)");
|
| 670 |
+
ctx.fillStyle = g;
|
| 671 |
+
ctx.fillRect(0, 0, W, H);
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
let last = performance.now();
|
| 675 |
+
function frame(now) {
|
| 676 |
+
const dt = Math.min(0.05, (now - last) / 1000);
|
| 677 |
+
last = now;
|
| 678 |
+
const t = now / 1000;
|
| 679 |
+
|
| 680 |
+
const light = sampleLight(t);
|
| 681 |
+
drawBackground(light);
|
| 682 |
+
drawRays(t, light);
|
| 683 |
+
updateMotes(dt, t);
|
| 684 |
+
|
| 685 |
+
updatePlants(t, dt);
|
| 686 |
+
updateGrass(t, dt);
|
| 687 |
+
|
| 688 |
+
for (const f of fishes) f.update(dt);
|
| 689 |
+
fishes.sort((a, b) => a.size - b.size); // small fish swim behind
|
| 690 |
+
for (const f of fishes) f.draw(ctx);
|
| 691 |
+
|
| 692 |
+
drawSand();
|
| 693 |
+
drawRocks();
|
| 694 |
+
drawStarfish(t);
|
| 695 |
+
drawSeaweed();
|
| 696 |
+
drawGrass(t);
|
| 697 |
+
|
| 698 |
+
for (let i = bubbles.length - 1; i >= 0; i--) {
|
| 699 |
+
const b = bubbles[i];
|
| 700 |
+
b.update(dt, t);
|
| 701 |
+
if (b.dead) { bubbles.splice(i, 1); continue; }
|
| 702 |
+
b.draw(ctx);
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
// ambient day/night wash over the whole scene
|
| 706 |
+
if (light.washA > 0.005) {
|
| 707 |
+
ctx.fillStyle = `rgba(${light.washRGB[0]},${light.washRGB[1]},${light.washRGB[2]},${light.washA})`;
|
| 708 |
+
ctx.fillRect(0, 0, W, H);
|
| 709 |
+
}
|
| 710 |
+
|
| 711 |
+
drawVignette();
|
| 712 |
+
requestAnimationFrame(frame);
|
| 713 |
+
}
|
| 714 |
+
|
| 715 |
+
window.addEventListener("resize", resize);
|
| 716 |
+
resize();
|
| 717 |
+
requestAnimationFrame(frame);
|
| 718 |
+
})();
|
| 719 |
+
</script>
|
| 720 |
+
</body>
|
| 721 |
+
</html>
|