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 }; }