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 { createGLTFishSchool, type FishSpec } from "./scene/GLTFish";
|
||||||
import { createDecor, type DecorSpec } from "./scene/Decor";
|
import { createDecor, type DecorSpec } from "./scene/Decor";
|
||||||
import { createParallaxCamera } from "./scene/Camera";
|
import { createParallaxCamera } from "./scene/Camera";
|
||||||
|
import { createBubbles } from "./scene/Bubbles";
|
||||||
|
import { createFood } from "./scene/Food";
|
||||||
|
|
||||||
const canvas = document.querySelector<HTMLCanvasElement>("#aquarium");
|
const canvas = document.querySelector<HTMLCanvasElement>("#aquarium");
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
@@ -34,10 +36,10 @@ setupLighting(scene, renderer);
|
|||||||
createTank(scene);
|
createTank(scene);
|
||||||
const water = createWater(scene);
|
const water = createWater(scene);
|
||||||
|
|
||||||
// Fish — GLB models with swim animations + wandering AI.
|
// Food — pellets that drop from above; fish seek and eat them.
|
||||||
// Maya's tropical fish already face local +X (no orient fix). The Quaternius
|
const food = createFood(scene);
|
||||||
// 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.
|
// Fish — GLB models with swim animations + wandering AI + food seeking.
|
||||||
const fishSpecs: FishSpec[] = [
|
const fishSpecs: FishSpec[] = [
|
||||||
// Tropical (maya's-aquarium-3d)
|
// Tropical (maya's-aquarium-3d)
|
||||||
{ url: "models/angelfish.glb", targetLength: 0.55, speed: [0.25, 0.45] },
|
{ 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/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 } },
|
{ 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).
|
// Decor — static plants and rocks on the gravel floor (y ~ 0.05).
|
||||||
const decorSpecs: DecorSpec[] = [
|
const decorSpecs: DecorSpec[] = [
|
||||||
@@ -64,6 +66,9 @@ const decorSpecs: DecorSpec[] = [
|
|||||||
];
|
];
|
||||||
createDecor(scene, decorSpecs);
|
createDecor(scene, decorSpecs);
|
||||||
|
|
||||||
|
// Bubbles — rising from air stones near the plants.
|
||||||
|
const bubbles = createBubbles(scene);
|
||||||
|
|
||||||
const parallax = createParallaxCamera(camera);
|
const parallax = createParallaxCamera(camera);
|
||||||
|
|
||||||
// Mouse tracking (normalized to -1..1)
|
// Mouse tracking (normalized to -1..1)
|
||||||
@@ -73,6 +78,11 @@ window.addEventListener("mousemove", (e) => {
|
|||||||
mouseY = (e.clientY / window.innerHeight) * 2 - 1;
|
mouseY = (e.clientY / window.innerHeight) * 2 - 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Click to drop food.
|
||||||
|
window.addEventListener("click", () => {
|
||||||
|
food.dropFood(4);
|
||||||
|
});
|
||||||
|
|
||||||
// Resize handling
|
// Resize handling
|
||||||
function resize(): void {
|
function resize(): void {
|
||||||
const width = window.innerWidth;
|
const width = window.innerWidth;
|
||||||
@@ -95,7 +105,9 @@ function animate(): void {
|
|||||||
const dt = Math.min((now - lastFrameTime) / 1000, 0.1);
|
const dt = Math.min((now - lastFrameTime) / 1000, 0.1);
|
||||||
lastFrameTime = now;
|
lastFrameTime = now;
|
||||||
water.update(time);
|
water.update(time);
|
||||||
|
food.update(dt, time);
|
||||||
fishSchool.update(dt, time);
|
fishSchool.update(dt, time);
|
||||||
|
bubbles.update(dt, time);
|
||||||
parallax.update(mouseX, mouseY, time, dt);
|
parallax.update(mouseX, mouseY, time, dt);
|
||||||
renderer.render(scene, camera);
|
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 * as THREE from "three";
|
||||||
import { GLTFLoader, type GLTF } from "three/examples/jsm/loaders/GLTFLoader.js";
|
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) ----
|
// ---- 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.
|
// so a single forward axis works for the whole school.
|
||||||
const FORWARD = new THREE.Vector3(1, 0, 0);
|
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 ----
|
// ---- Per-model configuration ----
|
||||||
|
|
||||||
export interface FishSpec {
|
export interface FishSpec {
|
||||||
@@ -130,10 +135,14 @@ interface Fish {
|
|||||||
position: THREE.Vector3;
|
position: THREE.Vector3;
|
||||||
target: THREE.Vector3;
|
target: THREE.Vector3;
|
||||||
speed: number;
|
speed: number;
|
||||||
|
/** Speed used while actively seeking food (slightly faster). */
|
||||||
|
seekSpeed: number;
|
||||||
phase: number;
|
phase: number;
|
||||||
turnSpeed: number;
|
turnSpeed: number;
|
||||||
quaternion: THREE.Quaternion;
|
quaternion: THREE.Quaternion;
|
||||||
mixer: THREE.AnimationMixer | null;
|
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
|
// 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.
|
// Wandering parameters.
|
||||||
const speed = spec.speed ? rand(spec.speed[0], spec.speed[1]) : rand(0.3, 0.8);
|
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 phase = Math.random() * Math.PI * 2;
|
||||||
const turnSpeed = rand(2, 4);
|
const turnSpeed = rand(2, 4);
|
||||||
|
|
||||||
@@ -191,7 +201,7 @@ function buildFish(gltf: GLTF, spec: FishSpec): Fish {
|
|||||||
group.position.copy(position);
|
group.position.copy(position);
|
||||||
group.quaternion.copy(quaternion);
|
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 ----
|
// ---- School ----
|
||||||
@@ -205,26 +215,86 @@ export interface GLTFishSchool {
|
|||||||
const _dir = new THREE.Vector3();
|
const _dir = new THREE.Vector3();
|
||||||
const _targetQuat = new THREE.Quaternion();
|
const _targetQuat = new THREE.Quaternion();
|
||||||
|
|
||||||
// One frame of wandering for a single fish: seek the current target, retarget
|
// One frame of behavior for a single fish: either seek food (if nearby) or
|
||||||
// on arrival, smoothly turn (frame-rate independent slerp), move, bob, clamp,
|
// wander. Handles seeking, retargeting, smooth turning, movement, bobbing,
|
||||||
// and advance the swim animation.
|
// clamping, and animation.
|
||||||
function updateFish(f: Fish, dt: number, time: number): void {
|
function updateFish(
|
||||||
// 1. Direction toward the current target.
|
f: Fish,
|
||||||
_dir.subVectors(f.target, f.position);
|
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.
|
// --- Food seeking ---
|
||||||
if (_dir.length() < 0.3) {
|
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());
|
f.target.copy(randomWanderTarget());
|
||||||
_dir.subVectors(f.target, f.position);
|
_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();
|
_dir.normalize();
|
||||||
|
|
||||||
// 3. Turn toward the target (frame-rate independent slerp).
|
// 3. Turn toward the target (frame-rate independent slerp).
|
||||||
_targetQuat.setFromUnitVectors(FORWARD, _dir);
|
_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.
|
// 4. Move forward.
|
||||||
f.position.addScaledVector(_dir, f.speed * dt);
|
f.position.addScaledVector(_dir, speed * dt);
|
||||||
|
|
||||||
// 5. Gentle vertical bob.
|
// 5. Gentle vertical bob.
|
||||||
f.position.y += Math.sin(time * 1.5 + f.phase) * 0.002;
|
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);
|
f.group.quaternion.copy(f.quaternion);
|
||||||
|
|
||||||
// 8. Advance the swim animation.
|
// 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
|
// 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
|
// group. Models load asynchronously and join the school as they arrive, so the
|
||||||
// aquarium populates progressively. Returns an updater to call each frame.
|
// 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();
|
const group = new THREE.Group();
|
||||||
scene.add(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 {
|
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++) {
|
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