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
+17 -5
View File
@@ -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);
}
+162
View File
@@ -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 };
}
+172
View File
@@ -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
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);
}
}