feat: bubbles, food pellets & fish feeding behavior
- Bubbles.ts: rising bubble particles from 2 air stones (near plants), wobble + growth + fade near surface, 50-mesh pool - Food.ts: auto-dropping food pellets (every 6-14s, 3-6 pieces) that float down and settle on the floor; manual drop via click - GLTFish.ts: fish now notice food within 1.2 units, seek at 1.4x speed with faster turn rate, eat pellets within 0.15 units; faster tail beat while chasing - main.ts: wires food + bubbles into render loop; click drops 4 pellets
This commit is contained in:
+17
-5
@@ -5,6 +5,8 @@ import { setupLighting } from "./scene/Lighting";
|
||||
import { createGLTFishSchool, type FishSpec } from "./scene/GLTFish";
|
||||
import { createDecor, type DecorSpec } from "./scene/Decor";
|
||||
import { createParallaxCamera } from "./scene/Camera";
|
||||
import { createBubbles } from "./scene/Bubbles";
|
||||
import { createFood } from "./scene/Food";
|
||||
|
||||
const canvas = document.querySelector<HTMLCanvasElement>("#aquarium");
|
||||
if (!canvas) {
|
||||
@@ -34,10 +36,10 @@ setupLighting(scene, renderer);
|
||||
createTank(scene);
|
||||
const water = createWater(scene);
|
||||
|
||||
// Fish — GLB models with swim animations + wandering AI.
|
||||
// Maya's tropical fish already face local +X (no orient fix). The Quaternius
|
||||
// pack is built with its long axis on +Z (nose at +Z), so it is rotated +90°
|
||||
// about Y to bring the nose onto +X.
|
||||
// Food — pellets that drop from above; fish seek and eat them.
|
||||
const food = createFood(scene);
|
||||
|
||||
// Fish — GLB models with swim animations + wandering AI + food seeking.
|
||||
const fishSpecs: FishSpec[] = [
|
||||
// Tropical (maya's-aquarium-3d)
|
||||
{ url: "models/angelfish.glb", targetLength: 0.55, speed: [0.25, 0.45] },
|
||||
@@ -52,7 +54,7 @@ const fishSpecs: FishSpec[] = [
|
||||
{ url: "models/quaternius/glb/Fish2.glb", targetLength: 0.6, speed: [0.3, 0.55], orient: { y: Math.PI / 2 } },
|
||||
{ url: "models/quaternius/glb/Fish3.glb", targetLength: 0.55, speed: [0.35, 0.6], orient: { y: Math.PI / 2 } },
|
||||
];
|
||||
const fishSchool = createGLTFishSchool(scene, fishSpecs);
|
||||
const fishSchool = createGLTFishSchool(scene, fishSpecs, food);
|
||||
|
||||
// Decor — static plants and rocks on the gravel floor (y ~ 0.05).
|
||||
const decorSpecs: DecorSpec[] = [
|
||||
@@ -64,6 +66,9 @@ const decorSpecs: DecorSpec[] = [
|
||||
];
|
||||
createDecor(scene, decorSpecs);
|
||||
|
||||
// Bubbles — rising from air stones near the plants.
|
||||
const bubbles = createBubbles(scene);
|
||||
|
||||
const parallax = createParallaxCamera(camera);
|
||||
|
||||
// Mouse tracking (normalized to -1..1)
|
||||
@@ -73,6 +78,11 @@ window.addEventListener("mousemove", (e) => {
|
||||
mouseY = (e.clientY / window.innerHeight) * 2 - 1;
|
||||
});
|
||||
|
||||
// Click to drop food.
|
||||
window.addEventListener("click", () => {
|
||||
food.dropFood(4);
|
||||
});
|
||||
|
||||
// Resize handling
|
||||
function resize(): void {
|
||||
const width = window.innerWidth;
|
||||
@@ -95,7 +105,9 @@ function animate(): void {
|
||||
const dt = Math.min((now - lastFrameTime) / 1000, 0.1);
|
||||
lastFrameTime = now;
|
||||
water.update(time);
|
||||
food.update(dt, time);
|
||||
fishSchool.update(dt, time);
|
||||
bubbles.update(dt, time);
|
||||
parallax.update(mouseX, mouseY, time, dt);
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
// ---- Config ----
|
||||
|
||||
interface AirStone {
|
||||
x: number;
|
||||
z: number;
|
||||
/** Bubbles per second emitted. */
|
||||
rate: number;
|
||||
}
|
||||
|
||||
const STONES: AirStone[] = [
|
||||
{ x: -1.3, z: -0.8, rate: 2.5 },
|
||||
{ x: 1.2, z: -0.7, rate: 1.8 },
|
||||
];
|
||||
|
||||
const RISE_SPEED: [number, number] = [0.25, 0.5];
|
||||
const WOBBLE_AMP = 0.03;
|
||||
const WOBBLE_FREQ: [number, number] = [2.0, 4.0];
|
||||
const SURFACE_Y = 2.1;
|
||||
const FLOOR_Y = 0.05;
|
||||
const POOL_SIZE = 50;
|
||||
const BASE_RADIUS: [number, number] = [0.012, 0.028];
|
||||
|
||||
// ---- Per-bubble state ----
|
||||
|
||||
interface Bubble {
|
||||
mesh: THREE.Mesh;
|
||||
active: boolean;
|
||||
pos: THREE.Vector3;
|
||||
vel: number;
|
||||
wobblePhase: number;
|
||||
wobbleFreq: number;
|
||||
wobbleAxis: number; // 0 = x, 1 = z
|
||||
baseRadius: number;
|
||||
age: number;
|
||||
}
|
||||
|
||||
// ---- Scratch ----
|
||||
const _rand = (lo: number, hi: number): number => lo + Math.random() * (hi - lo);
|
||||
|
||||
// ---- Module ----
|
||||
|
||||
export interface BubbleSystem {
|
||||
update: (dt: number, time: number) => void;
|
||||
group: THREE.Group;
|
||||
}
|
||||
|
||||
export function createBubbles(scene: THREE.Scene): BubbleSystem {
|
||||
const group = new THREE.Group();
|
||||
scene.add(group);
|
||||
|
||||
// Shared geometry + material for the pool (cheap to clone per instance).
|
||||
const geo = new THREE.SphereGeometry(1, 8, 6);
|
||||
const mat = new THREE.MeshPhongMaterial({
|
||||
color: 0xaaddff,
|
||||
transparent: true,
|
||||
opacity: 0.35,
|
||||
shininess: 100,
|
||||
specular: 0xffffff,
|
||||
});
|
||||
|
||||
// Pool of bubble meshes.
|
||||
const bubbles: Bubble[] = [];
|
||||
for (let i = 0; i < POOL_SIZE; i++) {
|
||||
const mesh = new THREE.Mesh(geo, mat.clone());
|
||||
mesh.visible = false;
|
||||
const pos = new THREE.Vector3();
|
||||
const bubble: Bubble = {
|
||||
mesh,
|
||||
active: false,
|
||||
pos,
|
||||
vel: 0,
|
||||
wobblePhase: Math.random() * Math.PI * 2,
|
||||
wobbleFreq: _rand(WOBBLE_FREQ[0], WOBBLE_FREQ[1]),
|
||||
wobbleAxis: Math.random() < 0.5 ? 0 : 1,
|
||||
baseRadius: _rand(BASE_RADIUS[0], BASE_RADIUS[1]),
|
||||
age: 0,
|
||||
};
|
||||
bubbles.push(bubble);
|
||||
group.add(mesh);
|
||||
}
|
||||
|
||||
// Spawn accumulators per stone.
|
||||
const accumulators = STONES.map(() => 0);
|
||||
|
||||
function spawn(stoneIdx: number): void {
|
||||
// Find an inactive bubble.
|
||||
let b: Bubble | null = null;
|
||||
for (let i = 0; i < bubbles.length; i++) {
|
||||
if (!bubbles[i].active) {
|
||||
b = bubbles[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!b) return; // Pool exhausted.
|
||||
|
||||
const stone = STONES[stoneIdx];
|
||||
const jitter = 0.06;
|
||||
b.pos.set(
|
||||
stone.x + _rand(-jitter, jitter),
|
||||
FLOOR_Y,
|
||||
stone.z + _rand(-jitter, jitter)
|
||||
);
|
||||
b.vel = _rand(RISE_SPEED[0], RISE_SPEED[1]);
|
||||
b.wobblePhase = Math.random() * Math.PI * 2;
|
||||
b.wobbleFreq = _rand(WOBBLE_FREQ[0], WOBBLE_FREQ[1]);
|
||||
b.wobbleAxis = Math.random() < 0.5 ? 0 : 1;
|
||||
b.baseRadius = _rand(BASE_RADIUS[0], BASE_RADIUS[1]);
|
||||
b.age = 0;
|
||||
b.active = true;
|
||||
b.mesh.visible = true;
|
||||
}
|
||||
|
||||
function update(dt: number, time: number): void {
|
||||
// Spawn new bubbles.
|
||||
for (let s = 0; s < STONES.length; s++) {
|
||||
accumulators[s] += STONES[s].rate * dt;
|
||||
while (accumulators[s] >= 1) {
|
||||
accumulators[s] -= 1;
|
||||
spawn(s);
|
||||
}
|
||||
}
|
||||
|
||||
// Update active bubbles.
|
||||
for (let i = 0; i < bubbles.length; i++) {
|
||||
const b = bubbles[i];
|
||||
if (!b.active) continue;
|
||||
|
||||
b.age += dt;
|
||||
b.pos.y += b.vel * dt;
|
||||
|
||||
// Wobble.
|
||||
const wobble = Math.sin(time * b.wobbleFreq + b.wobblePhase) * WOBBLE_AMP;
|
||||
if (b.wobbleAxis === 0) {
|
||||
b.pos.x += wobble * dt;
|
||||
} else {
|
||||
b.pos.z += wobble * dt;
|
||||
}
|
||||
|
||||
// Grown slightly as it rises (pressure drop).
|
||||
const scale = b.baseRadius * (1 + b.age * 0.08);
|
||||
|
||||
// Fade out near surface.
|
||||
const lifeT = (b.pos.y - FLOOR_Y) / (SURFACE_Y - FLOOR_Y);
|
||||
const opacity = 0.35 * (1 - lifeT * 0.6);
|
||||
(b.mesh.material as THREE.MeshPhongMaterial).opacity = opacity;
|
||||
|
||||
// Kill at surface.
|
||||
if (b.pos.y >= SURFACE_Y) {
|
||||
b.active = false;
|
||||
b.mesh.visible = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
b.mesh.position.copy(b.pos);
|
||||
b.mesh.scale.setScalar(scale);
|
||||
}
|
||||
}
|
||||
|
||||
return { update, group };
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
// ---- Config ----
|
||||
|
||||
const DROP_Y = 2.2;
|
||||
const FLOOR_Y = 0.08;
|
||||
const FALL_SPEED: [number, number] = [0.12, 0.22];
|
||||
const DRIFT_SPEED: [number, number] = [0.02, 0.08];
|
||||
const RADIUS: [number, number] = [0.02, 0.04];
|
||||
const MAX_PELLETS = 15;
|
||||
const AUTO_DROP_INTERVAL: [number, number] = [6, 14]; // seconds between auto drops
|
||||
const AUTO_DROP_COUNT: [number, number] = [3, 6];
|
||||
|
||||
// Tank interior bounds for x/z placement.
|
||||
const BOUNDS_X: [number, number] = [-1.4, 1.4];
|
||||
const BOUNDS_Z: [number, number] = [-0.9, 0.9];
|
||||
|
||||
// ---- Per-pellet state ----
|
||||
|
||||
interface Pellet {
|
||||
mesh: THREE.Mesh;
|
||||
active: boolean;
|
||||
pos: THREE.Vector3;
|
||||
velY: number;
|
||||
driftX: number;
|
||||
driftZ: number;
|
||||
wobblePhase: number;
|
||||
}
|
||||
|
||||
// ---- Public interface ----
|
||||
|
||||
export interface FoodSystem {
|
||||
/** Call each frame. */
|
||||
update: (dt: number, time: number) => void;
|
||||
/**
|
||||
* Returns positions of all active pellets. Index into this array matches
|
||||
* the index passed to `eat()`.
|
||||
*/
|
||||
getActivePositions: () => THREE.Vector3[];
|
||||
/**
|
||||
* Consume the pellet at the given index (from getActivePositions).
|
||||
* Returns true if a pellet was eaten.
|
||||
*/
|
||||
eat: (index: number) => boolean;
|
||||
/** Manually drop a batch of pellets (e.g., from a click). */
|
||||
dropFood: (count?: number) => void;
|
||||
group: THREE.Group;
|
||||
}
|
||||
|
||||
// ---- Scratch ----
|
||||
const _rand = (lo: number, hi: number): number => lo + Math.random() * (hi - lo);
|
||||
|
||||
// ---- Implementation ----
|
||||
|
||||
export function createFood(scene: THREE.Scene): FoodSystem {
|
||||
const group = new THREE.Group();
|
||||
scene.add(group);
|
||||
|
||||
const geo = new THREE.SphereGeometry(1, 6, 5);
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: 0xc88830,
|
||||
roughness: 0.8,
|
||||
metalness: 0.0,
|
||||
});
|
||||
|
||||
// Pool of pellet meshes.
|
||||
const pellets: Pellet[] = [];
|
||||
for (let i = 0; i < MAX_PELLETS; i++) {
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.visible = false;
|
||||
const p: Pellet = {
|
||||
mesh,
|
||||
active: false,
|
||||
pos: new THREE.Vector3(),
|
||||
velY: 0,
|
||||
driftX: 0,
|
||||
driftZ: 0,
|
||||
wobblePhase: Math.random() * Math.PI * 2,
|
||||
};
|
||||
pellets.push(p);
|
||||
group.add(mesh);
|
||||
}
|
||||
|
||||
// Auto-drop timer.
|
||||
let nextDrop = _rand(AUTO_DROP_INTERVAL[0], AUTO_DROP_INTERVAL[1]);
|
||||
let elapsed = 0;
|
||||
|
||||
function findInactive(): Pellet | null {
|
||||
for (let i = 0; i < pellets.length; i++) {
|
||||
if (!pellets[i].active) return pellets[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function spawnPellet(): void {
|
||||
const p = findInactive();
|
||||
if (!p) return;
|
||||
p.pos.set(_rand(BOUNDS_X[0], BOUNDS_X[1]), DROP_Y, _rand(BOUNDS_Z[0], BOUNDS_Z[1]));
|
||||
p.velY = _rand(FALL_SPEED[0], FALL_SPEED[1]);
|
||||
p.driftX = _rand(-DRIFT_SPEED[1], DRIFT_SPEED[1]);
|
||||
p.driftZ = _rand(-DRIFT_SPEED[1], DRIFT_SPEED[1]);
|
||||
p.wobblePhase = Math.random() * Math.PI * 2;
|
||||
p.active = true;
|
||||
p.mesh.visible = true;
|
||||
const r = _rand(RADIUS[0], RADIUS[1]);
|
||||
p.mesh.scale.setScalar(r);
|
||||
}
|
||||
|
||||
function dropFood(count?: number): void {
|
||||
const n = count ?? Math.round(_rand(AUTO_DROP_COUNT[0], AUTO_DROP_COUNT[1]));
|
||||
for (let i = 0; i < n; i++) {
|
||||
// Stagger the drop slightly so they don't all appear at the exact same spot.
|
||||
spawnPellet();
|
||||
}
|
||||
nextDrop = _rand(AUTO_DROP_INTERVAL[0], AUTO_DROP_INTERVAL[1]);
|
||||
elapsed = 0;
|
||||
}
|
||||
|
||||
function getActivePositions(): THREE.Vector3[] {
|
||||
const out: THREE.Vector3[] = [];
|
||||
for (let i = 0; i < pellets.length; i++) {
|
||||
if (pellets[i].active) out.push(pellets[i].pos);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function eat(index: number): boolean {
|
||||
// Map the index from getActivePositions() to the actual pellet.
|
||||
let activeIdx = -1;
|
||||
for (let i = 0; i < pellets.length; i++) {
|
||||
if (pellets[i].active) {
|
||||
activeIdx++;
|
||||
if (activeIdx === index) {
|
||||
pellets[i].active = false;
|
||||
pellets[i].mesh.visible = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function update(dt: number, time: number): void {
|
||||
// Auto-drop timer.
|
||||
elapsed += dt;
|
||||
if (elapsed >= nextDrop) {
|
||||
dropFood();
|
||||
}
|
||||
|
||||
// Update pellets.
|
||||
for (let i = 0; i < pellets.length; i++) {
|
||||
const p = pellets[i];
|
||||
if (!p.active) continue;
|
||||
|
||||
p.pos.y -= p.velY * dt;
|
||||
p.pos.x += p.driftX * dt + Math.sin(time * 1.5 + p.wobblePhase) * 0.003;
|
||||
p.pos.z += p.driftZ * dt;
|
||||
|
||||
// Settle on the floor — stays there until eaten.
|
||||
if (p.pos.y <= FLOOR_Y) {
|
||||
p.pos.y = FLOOR_Y;
|
||||
p.velY = 0;
|
||||
p.driftX = 0;
|
||||
p.driftZ = 0;
|
||||
}
|
||||
|
||||
p.mesh.position.copy(p.pos);
|
||||
}
|
||||
}
|
||||
|
||||
return { update, getActivePositions, eat, dropFood, group };
|
||||
}
|
||||
+114
-14
@@ -1,5 +1,6 @@
|
||||
import * as THREE from "three";
|
||||
import { GLTFLoader, type GLTF } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import type { FoodSystem } from "./Food";
|
||||
|
||||
// ---- Tank bounds (slightly inside the 4 x 2.5 x 3 tank) ----
|
||||
|
||||
@@ -19,6 +20,10 @@ const CLAMP: Bounds = { x: [-1.8, 1.8], y: [0.2, 2.2], z: [-1.3, 1.3] };
|
||||
// so a single forward axis works for the whole school.
|
||||
const FORWARD = new THREE.Vector3(1, 0, 0);
|
||||
|
||||
// Food interaction radii.
|
||||
const FOOD_NOTICE = 1.2; // units — fish start seeking food within this distance
|
||||
const FOOD_EAT = 0.15; // units — fish eats the pellet within this distance
|
||||
|
||||
// ---- Per-model configuration ----
|
||||
|
||||
export interface FishSpec {
|
||||
@@ -130,10 +135,14 @@ interface Fish {
|
||||
position: THREE.Vector3;
|
||||
target: THREE.Vector3;
|
||||
speed: number;
|
||||
/** Speed used while actively seeking food (slightly faster). */
|
||||
seekSpeed: number;
|
||||
phase: number;
|
||||
turnSpeed: number;
|
||||
quaternion: THREE.Quaternion;
|
||||
mixer: THREE.AnimationMixer | null;
|
||||
/** Index into food.getActivePositions() that this fish is currently chasing, or -1. */
|
||||
chasingFood: number;
|
||||
}
|
||||
|
||||
// Builds one wandering fish from a loaded GLB: wraps the model in an
|
||||
@@ -172,6 +181,7 @@ function buildFish(gltf: GLTF, spec: FishSpec): Fish {
|
||||
|
||||
// Wandering parameters.
|
||||
const speed = spec.speed ? rand(spec.speed[0], spec.speed[1]) : rand(0.3, 0.8);
|
||||
const seekSpeed = speed * 1.4;
|
||||
const phase = Math.random() * Math.PI * 2;
|
||||
const turnSpeed = rand(2, 4);
|
||||
|
||||
@@ -191,7 +201,7 @@ function buildFish(gltf: GLTF, spec: FishSpec): Fish {
|
||||
group.position.copy(position);
|
||||
group.quaternion.copy(quaternion);
|
||||
|
||||
return { group, position, target, speed, phase, turnSpeed, quaternion, mixer };
|
||||
return { group, position, target, speed, seekSpeed, phase, turnSpeed, quaternion, mixer, chasingFood: -1 };
|
||||
}
|
||||
|
||||
// ---- School ----
|
||||
@@ -205,26 +215,86 @@ export interface GLTFishSchool {
|
||||
const _dir = new THREE.Vector3();
|
||||
const _targetQuat = new THREE.Quaternion();
|
||||
|
||||
// One frame of wandering for a single fish: seek the current target, retarget
|
||||
// on arrival, smoothly turn (frame-rate independent slerp), move, bob, clamp,
|
||||
// and advance the swim animation.
|
||||
function updateFish(f: Fish, dt: number, time: number): void {
|
||||
// 1. Direction toward the current target.
|
||||
_dir.subVectors(f.target, f.position);
|
||||
// One frame of behavior for a single fish: either seek food (if nearby) or
|
||||
// wander. Handles seeking, retargeting, smooth turning, movement, bobbing,
|
||||
// clamping, and animation.
|
||||
function updateFish(
|
||||
f: Fish,
|
||||
dt: number,
|
||||
time: number,
|
||||
food: FoodSystem | null,
|
||||
foodPositions: THREE.Vector3[],
|
||||
foodIndex: number[] // maps active-position index → pellet index for eat()
|
||||
): void {
|
||||
let speed = f.speed;
|
||||
let target = f.target;
|
||||
|
||||
// 2. Reached the target — pick a new one.
|
||||
if (_dir.length() < 0.3) {
|
||||
// --- Food seeking ---
|
||||
if (food && foodPositions.length > 0) {
|
||||
// If this fish is already chasing a specific pellet, keep chasing it.
|
||||
if (f.chasingFood >= 0 && f.chasingFood < foodPositions.length) {
|
||||
target = foodPositions[f.chasingFood];
|
||||
speed = f.seekSpeed;
|
||||
|
||||
// Check if we've reached it.
|
||||
const dist = f.position.distanceTo(target);
|
||||
if (dist < FOOD_EAT) {
|
||||
food.eat(foodIndex[f.chasingFood]);
|
||||
f.chasingFood = -1;
|
||||
// Reset target to a new wander point.
|
||||
f.target.copy(randomWanderTarget());
|
||||
target = f.target;
|
||||
speed = f.speed;
|
||||
}
|
||||
} else {
|
||||
// Not chasing — look for the nearest food within notice radius.
|
||||
let nearestIdx = -1;
|
||||
let nearestDist = FOOD_NOTICE;
|
||||
for (let i = 0; i < foodPositions.length; i++) {
|
||||
const d = f.position.distanceTo(foodPositions[i]);
|
||||
if (d < nearestDist) {
|
||||
nearestDist = d;
|
||||
nearestIdx = i;
|
||||
}
|
||||
}
|
||||
if (nearestIdx >= 0) {
|
||||
f.chasingFood = nearestIdx;
|
||||
target = foodPositions[nearestIdx];
|
||||
speed = f.seekSpeed;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No food around — reset chasing state.
|
||||
f.chasingFood = -1;
|
||||
}
|
||||
|
||||
// --- Movement (shared for wander + seek) ---
|
||||
|
||||
// 1. Direction toward the current target.
|
||||
_dir.subVectors(target, f.position);
|
||||
|
||||
// 2. If wandering and reached the target — pick a new one.
|
||||
if (f.chasingFood < 0 && _dir.length() < 0.3) {
|
||||
f.target.copy(randomWanderTarget());
|
||||
_dir.subVectors(f.target, f.position);
|
||||
}
|
||||
|
||||
if (_dir.length() < 0.001) {
|
||||
// Already at target (or degenerate) — skip movement this frame.
|
||||
f.group.position.copy(f.position);
|
||||
f.group.quaternion.copy(f.quaternion);
|
||||
if (f.mixer) f.mixer.update(dt);
|
||||
return;
|
||||
}
|
||||
_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));
|
||||
const turnRate = f.chasingFood >= 0 ? f.turnSpeed * 1.3 : f.turnSpeed;
|
||||
f.quaternion.slerp(_targetQuat, 1 - Math.exp(-turnRate * dt));
|
||||
|
||||
// 4. Move forward.
|
||||
f.position.addScaledVector(_dir, f.speed * dt);
|
||||
f.position.addScaledVector(_dir, speed * dt);
|
||||
|
||||
// 5. Gentle vertical bob.
|
||||
f.position.y += Math.sin(time * 1.5 + f.phase) * 0.002;
|
||||
@@ -237,13 +307,25 @@ function updateFish(f: Fish, dt: number, time: number): void {
|
||||
f.group.quaternion.copy(f.quaternion);
|
||||
|
||||
// 8. Advance the swim animation.
|
||||
if (f.mixer) f.mixer.update(dt);
|
||||
if (f.mixer) {
|
||||
// Slightly faster tail beat while seeking food.
|
||||
const baseTimeScale = Math.min(1.6, 0.6 + f.speed * 1.2);
|
||||
const seekBoost = f.chasingFood >= 0 ? 1.3 : 1.0;
|
||||
f.mixer.timeScale = baseTimeScale * seekBoost;
|
||||
f.mixer.update(dt);
|
||||
}
|
||||
}
|
||||
|
||||
// Loads the given GLB fish specs and creates a single school under one parent
|
||||
// group. Models load asynchronously and join the school as they arrive, so the
|
||||
// aquarium populates progressively. Returns an updater to call each frame.
|
||||
export function createGLTFishSchool(scene: THREE.Scene, specs: FishSpec[]): GLTFishSchool {
|
||||
//
|
||||
// If `food` is provided, fish will notice and seek food pellets within range.
|
||||
export function createGLTFishSchool(
|
||||
scene: THREE.Scene,
|
||||
specs: FishSpec[],
|
||||
food?: FoodSystem
|
||||
): GLTFishSchool {
|
||||
const group = new THREE.Group();
|
||||
scene.add(group);
|
||||
|
||||
@@ -263,9 +345,27 @@ export function createGLTFishSchool(scene: THREE.Scene, specs: FishSpec[]): GLTF
|
||||
);
|
||||
}
|
||||
|
||||
// Scratch arrays for food positions (reused each frame to avoid GC pressure).
|
||||
const foodPositions: THREE.Vector3[] = [];
|
||||
const foodIndex: number[] = [];
|
||||
|
||||
function update(dt: number, time: number): void {
|
||||
// Refresh food positions if a food system is present.
|
||||
if (food) {
|
||||
foodPositions.length = 0;
|
||||
foodIndex.length = 0;
|
||||
const active = food.getActivePositions();
|
||||
// We need to map active-position-index → pellet-index for eat().
|
||||
// getActivePositions returns in pellet order, so index i in the array
|
||||
// corresponds to the i-th active pellet.
|
||||
for (let i = 0; i < active.length; i++) {
|
||||
foodPositions.push(active[i]);
|
||||
foodIndex.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < fish.length; i++) {
|
||||
updateFish(fish[i], dt, time);
|
||||
updateFish(fish[i], dt, time, food || null, foodPositions, foodIndex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user