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:
+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