Echo / src /scene /Fish.js
mlmihjaz
Fix sprite facing via UV-mirror; crane wades left-right with long pauses
fec2039
Raw
History Blame Contribute Delete
8.85 kB
/**
* 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);
}
}