Files
Aquarium/src/scene/Fish.ts
T

205 lines
6.4 KiB
TypeScript

import * as THREE from "three";
// ---- Configuration ----
// Roaming / clamping bounds, kept inside the tank (tank spans x:-2..2, y:0..2.5, z:-1.5..1.5)
type Bounds = { x: [number, number]; y: [number, number]; z: [number, number] };
const BOUNDS: { wander: Bounds; clamp: Bounds } = {
wander: { x: [-1.5, 1.5], y: [0.3, 2.0], z: [-1.0, 1.0] },
clamp: { x: [-1.8, 1.8], y: [0.2, 2.3], z: [-1.3, 1.3] },
};
// The fish body is elongated along local +X, so that is its forward axis.
const FORWARD = new THREE.Vector3(1, 0, 0);
// Color palette: orange / blue / yellow hue ranges (HSL).
const HUE_RANGES: [number, number][] = [
[0.05, 0.12], // orange
[0.55, 0.62], // blue
[0.12, 0.18], // yellow
];
// Scratch objects reused by update() to avoid per-frame allocation.
const _dir = new THREE.Vector3();
const _targetQuat = new THREE.Quaternion();
// ---- Helpers ----
function rand(min: number, max: number): number {
return min + Math.random() * (max - min);
}
function randIn(range: [number, number]): number {
return rand(range[0], range[1]);
}
function randomWanderTarget(): THREE.Vector3 {
return new THREE.Vector3(
randIn(BOUNDS.wander.x),
randIn(BOUNDS.wander.y),
randIn(BOUNDS.wander.z)
);
}
function clampToBounds(v: THREE.Vector3): void {
v.x = THREE.MathUtils.clamp(v.x, BOUNDS.clamp.x[0], BOUNDS.clamp.x[1]);
v.y = THREE.MathUtils.clamp(v.y, BOUNDS.clamp.y[0], BOUNDS.clamp.y[1]);
v.z = THREE.MathUtils.clamp(v.z, BOUNDS.clamp.z[0], BOUNDS.clamp.z[1]);
}
function randomFishColor(): THREE.Color {
const [h0, h1] = HUE_RANGES[Math.floor(Math.random() * HUE_RANGES.length)];
return new THREE.Color().setHSL(rand(h0, h1), rand(0.7, 0.9), rand(0.5, 0.65));
}
// ---- Types ----
interface FishState {
group: THREE.Group;
tail: THREE.Mesh;
body: THREE.Mesh;
position: THREE.Vector3;
target: THREE.Vector3;
speed: number;
phase: number;
turnSpeed: number;
quaternion: THREE.Quaternion;
}
// ---- Mesh construction ----
// Builds a low-poly procedural fish from primitives, all sharing one material.
// The body is an elongated sphere (long axis = local +X = forward).
function createFishMesh(color: THREE.Color): {
group: THREE.Group;
tail: THREE.Mesh;
body: THREE.Mesh;
} {
const material = new THREE.MeshStandardMaterial({
color,
roughness: 0.4,
metalness: 0.1,
// DoubleSide so the flat pectoral fins are visible from either side.
side: THREE.DoubleSide,
});
const group = new THREE.Group();
// Body
const body = new THREE.Mesh(new THREE.SphereGeometry(1, 16, 12), material);
body.scale.set(1.0, 0.4, 0.25);
body.castShadow = true;
// Tail — thin cone at the rear.
// NOTE: the spec says "rotated Z by PI", but that orients the cone's axis
// along Y, which makes the per-frame rotation.y wag invisible (a line aligned
// with its rotation axis doesn't move). Rotating -PI/2 instead points the tip
// forward (+X, toward the body) as intended, and lets the y-wag sweep side-to-side.
const tail = new THREE.Mesh(new THREE.ConeGeometry(0.3, 0.5, 4), material);
tail.rotation.z = -Math.PI / 2;
tail.scale.set(0.1, 1, 1);
tail.position.set(-1.0, 0, 0);
tail.castShadow = true;
// Dorsal fin — sits on top of the body, points up.
const dorsal = new THREE.Mesh(new THREE.ConeGeometry(0.15, 0.2, 4), material);
dorsal.scale.set(0.1, 1, 1);
dorsal.position.set(0, 0.4, 0);
dorsal.castShadow = true;
// Pectoral fins — small flat paddles on either side, angled out and back.
// Nudged just past the body's z-extent (0.25) so they read as fins rather
// than being buried inside the opaque body.
const pectoralGeo = new THREE.PlaneGeometry(0.2, 0.1);
const leftFin = new THREE.Mesh(pectoralGeo, material);
leftFin.position.set(0, -0.05, 0.26);
leftFin.rotation.set(-0.3, 0, 0.3); // face +Z (outward), droop down, sweep back
leftFin.castShadow = true;
const rightFin = new THREE.Mesh(pectoralGeo, material);
rightFin.position.set(0, -0.05, -0.26);
rightFin.rotation.set(-0.3, Math.PI, -0.3); // face -Z (outward)
rightFin.castShadow = true;
group.add(body, tail, dorsal, leftFin, rightFin);
return { group, tail, body };
}
// ---- School ----
export interface FishSchool {
update: (dt: number, time: number) => void;
group: THREE.Group;
}
// Creates `count` independently-wandering fish under a single parent group.
export function createFishSchool(scene: THREE.Scene, count: number = 6): FishSchool {
const group = new THREE.Group();
const fish: FishState[] = [];
for (let i = 0; i < count; i++) {
const { group: fishGroup, tail, body } = createFishMesh(randomFishColor());
const position = randomWanderTarget();
const target = randomWanderTarget();
const speed = rand(0.3, 0.8);
const phase = Math.random() * Math.PI * 2;
const turnSpeed = rand(2, 4);
// Start already facing the first target to avoid an initial swing.
const startDir = target.clone().sub(position).normalize();
const quaternion = new THREE.Quaternion().setFromUnitVectors(FORWARD, startDir);
fishGroup.position.copy(position);
fishGroup.quaternion.copy(quaternion);
fishGroup.scale.setScalar(rand(0.3, 0.5));
group.add(fishGroup);
fish.push({ group: fishGroup, tail, body, position, target, speed, phase, turnSpeed, quaternion });
}
scene.add(group);
function update(dt: number, time: number): void {
for (const f of fish) {
// 1. Direction toward current target.
_dir.subVectors(f.target, f.position);
// 2. Reached the target — pick a new one.
if (_dir.length() < 0.3) {
f.target.copy(randomWanderTarget());
_dir.subVectors(f.target, f.position);
}
_dir.normalize();
// 3. Turn toward the target (frame-rate independent slerp).
_targetQuat.setFromUnitVectors(FORWARD, _dir);
f.quaternion.slerp(_targetQuat, 1 - Math.exp(-f.turnSpeed * dt));
// 4. Move forward.
f.position.addScaledVector(_dir, f.speed * dt);
// 5. Gentle vertical bob.
f.position.y += Math.sin(time * 1.5 + f.phase) * 0.002;
// 6. Keep inside the tank.
clampToBounds(f.position);
// 7. Apply transform.
f.group.position.copy(f.position);
f.group.quaternion.copy(f.quaternion);
// 8. Tail wag.
f.tail.rotation.y = Math.sin(time * (2 + f.speed * 3) + f.phase) * 0.4;
// 9. Subtle body flex (length pulses around 1.0).
f.body.scale.x = 1.0 + Math.sin(time * (2 + f.speed * 3) + f.phase) * 0.03;
}
}
return { update, group };
}