/** * Fish — small colourful sprites swimming inside the foreground aquarium pond. * Each fish wanders inside its pond rectangle with simple sine-driven motion * plus tail flick frame switching. * * Placement bounds match the pond defined in Water.js: * pond covers x ∈ [-30..30], z ∈ [-11..19], surface y=0 * fish swim slightly below y=0 (y ≈ -0.05..-0.4) */ import { CanvasTexture, RepeatWrapping, Sprite, SpriteMaterial } from 'three'; const FISH_PALETTES = [ { body: '#ff7a3a', stripe: '#fff', edge: '#a64412' }, // clownfish { body: '#ffd34d', stripe: '#3a2b00', edge: '#a87a0a' }, // yellow tang { body: '#3e8be9', stripe: '#103a72', edge: '#15497b' }, // blue tang { body: '#ff5d8f', stripe: '#fff', edge: '#9a2748' }, // pink { body: '#67e1c0', stripe: '#0a3a32', edge: '#1b6d59' }, // teal { body: '#c98bff', stripe: '#3a1466', edge: '#5b2298' }, // purple ]; /** Lighten (amt>0) or darken (amt<0) a #rrggbb colour. */ function shade(hex, amt) { const n = parseInt(hex.slice(1), 16); let r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; if (amt >= 0) { r += (255 - r) * amt; g += (255 - g) * amt; b += (255 - b) * amt; } else { r *= (1 + amt); g *= (1 + amt); b *= (1 + amt); } return `rgb(${r | 0},${g | 0},${b | 0})`; } /** Side-view fish, head on the RIGHT, drawn with shading, scales, stripes, * finned rays and a glossy eye so it reads as a real fish, not a flat blob. * tail = 0 centred; 1 flicked up; 2 flicked down. */ function makeFishTexture(palette, tail) { const W = 256, H = 128; const c = document.createElement('canvas'); c.width = W; c.height = H; const ctx = c.getContext('2d'); ctx.clearRect(0, 0, W, H); const cx = 142, cy = 64, bw = 60, bh = 30; // body centre + half-size const bodyDark = shade(palette.body, -0.4); const bodyLite = shade(palette.body, 0.4); const finCol = palette.edge; // ---- Caudal (tail) fin behind the body, with rays ---- let tipX = 40, topY = 30, botY = 98; if (tail === 1) { tipX = 36; topY = 22; botY = 86; } else if (tail === 2) { tipX = 44; topY = 42; botY = 106; } const tg = ctx.createLinearGradient(tipX, 0, 96, 0); tg.addColorStop(0, shade(finCol, -0.15)); tg.addColorStop(1, finCol); ctx.fillStyle = tg; ctx.beginPath(); ctx.moveTo(96, cy); ctx.lineTo(tipX, topY); ctx.quadraticCurveTo(tipX + 12, cy, tipX, botY); ctx.closePath(); ctx.fill(); ctx.strokeStyle = shade(finCol, -0.3); ctx.lineWidth = 1.3; for (let i = 0; i <= 4; i++) { const yy = topY + (botY - topY) * (i / 4); ctx.beginPath(); ctx.moveTo(92, cy); ctx.lineTo(tipX + 4, yy); ctx.stroke(); } // ---- Dorsal + pelvic fins (drawn before body so they tuck under) ---- ctx.fillStyle = shade(finCol, 0.05); ctx.beginPath(); ctx.moveTo(cx - 34, cy - bh + 6); ctx.quadraticCurveTo(cx - 4, cy - bh - 18, cx + 30, cy - bh + 4); ctx.closePath(); ctx.fill(); ctx.beginPath(); ctx.moveTo(cx - 24, cy + bh - 6); ctx.quadraticCurveTo(cx, cy + bh + 16, cx + 26, cy + bh - 4); ctx.closePath(); ctx.fill(); // ---- Body silhouette (smooth teardrop, snout on the right) ---- ctx.beginPath(); ctx.moveTo(cx - bw, cy); ctx.bezierCurveTo(cx - bw, cy - bh, cx + bw - 10, cy - bh, cx + bw + 22, cy - 4); ctx.bezierCurveTo(cx + bw + 30, cy, cx + bw + 22, cy + 4, cx + bw - 10, cy + bh); ctx.bezierCurveTo(cx + bw - 10, cy + bh, cx - bw, cy + bh, cx - bw, cy); ctx.closePath(); const bg = ctx.createLinearGradient(0, cy - bh, 0, cy + bh); bg.addColorStop(0, bodyDark); bg.addColorStop(0.45, palette.body); bg.addColorStop(1, bodyLite); ctx.fillStyle = bg; ctx.fill(); // Pattern + shading clipped to the body ctx.save(); ctx.clip(); // scales — overlapping arcs ctx.strokeStyle = 'rgba(255,255,255,0.16)'; ctx.lineWidth = 1; for (let row = 0; row < 6; row++) { for (let col = 0; col < 11; col++) { const sx = cx - bw + 8 + col * 14 + (row % 2) * 7; const sy = cy - bh + 8 + row * 11; ctx.beginPath(); ctx.arc(sx, sy, 7, Math.PI * 0.15, Math.PI * 0.85); ctx.stroke(); } } // vertical body stripes (pattern) ctx.fillStyle = palette.stripe; ctx.globalAlpha = 0.5; for (const sx of [cx - 22, cx + 6, cx + 32]) { ctx.beginPath(); ctx.ellipse(sx, cy, 5, bh, 0, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; // top shadow for volume const sg = ctx.createLinearGradient(0, cy - bh, 0, cy); sg.addColorStop(0, 'rgba(0,0,0,0.28)'); sg.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = sg; ctx.fillRect(cx - bw, cy - bh, bw * 2 + 34, bh); // belly highlight const hg = ctx.createLinearGradient(0, cy, 0, cy + bh); hg.addColorStop(0, 'rgba(255,255,255,0)'); hg.addColorStop(1, 'rgba(255,255,255,0.28)'); ctx.fillStyle = hg; ctx.fillRect(cx - bw, cy, bw * 2 + 34, bh); ctx.restore(); // ---- Gill arc ---- ctx.strokeStyle = shade(finCol, -0.1); ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(cx + bw - 6, cy, bh - 5, -Math.PI * 0.4, Math.PI * 0.4); ctx.stroke(); // ---- Pectoral fin on the side ---- ctx.fillStyle = shade(palette.body, -0.18); ctx.beginPath(); ctx.moveTo(cx + bw - 14, cy + 2); ctx.quadraticCurveTo(cx + bw - 30, cy + 18, cx + bw - 6, cy + 16); ctx.closePath(); ctx.fill(); // ---- Eye (glossy) ---- const ex = cx + bw + 6, ey = cy - 6; ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(ex, ey, 5.5, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#10121a'; ctx.beginPath(); ctx.arc(ex + 0.5, ey, 3, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(255,255,255,0.95)'; ctx.beginPath(); ctx.arc(ex - 1.4, ey - 1.4, 1.2, 0, Math.PI * 2); ctx.fill(); const t = new CanvasTexture(c); t.needsUpdate = true; return t; } class FishSprite { constructor(scene, bounds) { this.scene = scene; this.bounds = bounds; this.palette = FISH_PALETTES[Math.floor(Math.random() * FISH_PALETTES.length)]; this.frames = [makeFishTexture(this.palette, 0), makeFishTexture(this.palette, 1), makeFishTexture(this.palette, 2)]; // Allow horizontal UV mirroring so the fish can face its travel direction // (a THREE.Sprite ignores negative scale.x, so we flip the texture instead). for (const f of this.frames) f.wrapS = RepeatWrapping; this.mat = new SpriteMaterial({ map: this.frames[0], transparent: true, opacity: 0.95, depthWrite: false }); this.sprite = new Sprite(this.mat); // Smaller fish with a wide, natural spread — power curve biases toward // lots of little ones with the occasional bigger fish, so the school reads // as varied sizes instead of one uniform size. const s = 0.18 + Math.pow(Math.random(), 1.8) * 0.55; // ~0.18 (tiny) .. ~0.73 (big) this.sprite.scale.set(s, s * 0.5, 1); scene.add(this.sprite); this.x = (Math.random() * 2 - 1) * bounds.x; this.y = bounds.yTop - Math.random() * (bounds.yTop - bounds.yBot); this.z = bounds.zMin + Math.random() * (bounds.zMax - bounds.zMin); this.dir = Math.random() < 0.5 ? 1 : -1; this.speed = 0.6 + Math.random() * 1.1; this.bobSeed = Math.random() * 100; this.flickPhase = Math.random() * Math.PI * 2; this.size = s; this.targetZ = this.z; this.targetY = this.y; this._retargetIn = 0; } update(dt, elapsed) { const b = this.bounds; // horizontal travel, bounce at side walls this.x += this.speed * this.dir * dt; if (this.x > b.x) this.dir = -1; else if (this.x < -b.x) this.dir = 1; // drift toward a slow-changing depth + Y target this._retargetIn -= dt; if (this._retargetIn <= 0) { this.targetY = b.yTop - Math.random() * (b.yTop - b.yBot); this.targetZ = b.zMin + Math.random() * (b.zMax - b.zMin); this._retargetIn = 3 + Math.random() * 5; } this.y += (this.targetY - this.y) * Math.min(1, dt * 0.6); this.z += (this.targetZ - this.z) * Math.min(1, dt * 0.4); this.sprite.position.set(this.x, this.y + Math.sin(elapsed * 2 + this.bobSeed) * 0.05, this.z); this.sprite.scale.x = this.size; // always positive; sprites ignore negative scale // tail flick this.flickPhase += dt * 6; const s = Math.sin(this.flickPhase); const frame = s > 0.3 ? 1 : (s < -0.3 ? 2 : 0); const map = this.frames[frame]; // Texture is drawn head-right; mirror its UVs when swimming left so the head leads. if (this.dir < 0) { map.repeat.x = -1; map.offset.x = 1; } else { map.repeat.x = 1; map.offset.x = 0; } this.mat.map = map; this.mat.needsUpdate = true; } } export class Fish { constructor(scene, getBounds) { this.scene = scene; this.getBounds = getBounds; this.fish = []; const bounds = getBounds(); for (let i = 0; i < 14; i++) this.fish.push(new FishSprite(scene, bounds)); } update(dt, elapsed) { for (const f of this.fish) f.update(dt, elapsed); } }