@@ -0,0 +1,204 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user