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:
2026-10-07 11:49:10 +00:00
parent 4893ee2f8c
commit c7427f9c61
4 changed files with 465 additions and 19 deletions
+114 -14
View File
@@ -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);
}
}