feat: dynamic fish add/remove, genetics visualization, full game loop (#13-#19)

- GLTFish: dynamic addFish/removeFish with fade, model caching, genetics applied
  (color hue/saturation shift, rarity emissive glow on fish meshes)
- Buy: spawns fish in 3D scene with random genetics + rarity roll
- Sell: fades out fish, removes from scene, credits money
- Breed: 1-3 babies with blended genetics + mutation, 60s cooldown
- Tank upgrade: resizes bounds, moves camera, updates capacity
- Fish growth: age-based value multiplier (baby/juvenile/adult)
- Full game loop: earn → breed for rarity → sell for profit → upgrade tank
This commit is contained in:
2026-10-07 12:41:05 +00:00
parent eb50f3b56b
commit 6a4614e276
2 changed files with 202 additions and 193 deletions
+41 -41
View File
@@ -10,10 +10,9 @@ import { createBubbles } from "./scene/Bubbles";
import { createFood } from "./scene/Food";
import { createDefaultState, saveGame, loadGame, resetGame } from "./game/state";
import type { GameState, FishData, FishSpecies, Genetics } from "./game/types";
import { RARITY_MULT } from "./game/types";
import { syncFromGameState, pushToast, getUIState } from "./game/store";
import { setGameActions, startAlpine } from "./ui";
import { FISH_PRICES, SPECIES_RARITY_DIST, TANK_TIER_COSTS, TANK_TIER_DIMS, getFishSellValue, getFishValueBreakdown } from "./game/economy";
import { FISH_PRICES, TANK_TIER_COSTS, TANK_TIER_DIMS, getFishSellValue } from "./game/economy";
import { generateGenetics, generateFishName, breedGenetics } from "./game/genetics";
// --- Game State ---
@@ -42,11 +41,10 @@ createTank(scene);
const water = createWater(scene);
const food = createFood(scene);
// --- Fish specs from game state ---
// --- Fish helpers ---
function isQuaternius(sp: FishSpecies): boolean {
return sp === "fish1" || sp === "fish2" || sp === "fish3";
}
function getSpeciesUrl(sp: FishSpecies): string {
const urls: Record<FishSpecies, string> = {
angelfish: "models/angelfish.glb",
@@ -58,17 +56,22 @@ function getSpeciesUrl(sp: FishSpecies): string {
};
return urls[sp];
}
function getFishSpecs(): FishSpec[] {
return gameState.fish.map((f) => ({
function fishToSpec(f: FishData): FishSpec {
return {
url: getSpeciesUrl(f.species),
targetLength: f.genetics.sizeMult * 0.5,
speed: [0.3 * f.genetics.speedMult, 0.7 * f.genetics.speedMult],
orient: isQuaternius(f.species) ? { y: Math.PI / 2 } : undefined,
}));
genetics: f.genetics,
};
}
const fishSchool = createGLTFishSchool(scene, getFishSpecs(), food);
// --- Fish School ---
const fishSchool = createGLTFishSchool(
scene,
gameState.fish.map(fishToSpec),
food
);
// --- Decor ---
const decorSpecs: DecorSpec[] = [
@@ -94,9 +97,7 @@ window.addEventListener("mousemove", (e) => {
});
window.addEventListener("click", (e) => {
// Don't process clicks on UI elements
if (e.target !== canvas) return;
mouseNDC.x = (e.clientX / window.innerWidth) * 2 - 1;
mouseNDC.y = -(e.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouseNDC, camera);
@@ -106,9 +107,7 @@ window.addEventListener("click", (e) => {
if (intersects.length > 0) {
let obj: THREE.Object3D | null = intersects[0].object;
while (obj && obj.parent !== fishGroup) {
obj = obj.parent;
}
while (obj && obj.parent !== fishGroup) obj = obj.parent;
if (obj) {
const idx = fishGroup.children.indexOf(obj);
if (idx >= 0 && idx < gameState.fish.length) {
@@ -117,11 +116,10 @@ window.addEventListener("click", (e) => {
}
}
}
// No fish clicked — drop food
food.dropFood(4);
});
// --- Cooldown tracking ---
// --- Cooldowns ---
const cooldowns: Record<string, number> = {};
// --- Game Actions ---
@@ -133,27 +131,27 @@ setGameActions({
onBuyFish: (species: FishSpecies, genetics: Genetics) => {
const price = FISH_PRICES[species];
const cap = TANK_TIER_DIMS[gameState.tankTier].cap;
if (gameState.money < price) return;
if (gameState.fish.length >= TANK_TIER_DIMS[gameState.tankTier].cap) return;
if (gameState.fish.length >= cap) return;
gameState.money -= price;
const name = generateFishName(species, genetics);
const fish: FishData = {
id: `fish_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
species,
name,
genetics,
age: 0,
species, name, genetics, age: 0,
x: 0, y: 1, z: 0,
};
gameState.fish.push(fish);
fishSchool.addFish(fishToSpec(fish));
saveGame(gameState);
syncFromGameState(gameState);
if (genetics.rarity === "epic" || genetics.rarity === "legendary") {
pushToast(`🌟 ${genetics.rarity.toUpperCase()} ${name}!`, "rare");
} else {
pushToast(`+${name} (${genetics.rarity})`, "success");
}
console.log("[GAME] Bought:", name, "Rarity:", genetics.rarity);
},
onSellFish: (fishId: string) => {
@@ -167,52 +165,45 @@ setGameActions({
const value = getFishSellValue(fish);
gameState.money += value;
gameState.fish.splice(idx, 1);
fishSchool.removeFish(idx);
delete cooldowns[fishId];
saveGame(gameState);
syncFromGameState(gameState);
pushToast(`+$${value} — Sold ${fish.name}`, "success");
console.log("[GAME] Sold:", fish.name, "for", value);
},
onBreed: (fishA: FishData, fishB: FishData) => {
if (fishA.species !== fishB.species) return;
if (fishA.genetics.gender === fishB.genetics.gender) {
pushToast("Need male + female to breed!", "warning");
pushToast("Need male + female!", "warning");
return;
}
if (fishA.age < 72 || fishB.age < 72) {
pushToast("Fish must be adults to breed!", "warning");
pushToast("Fish must be adults!", "warning");
return;
}
// Set cooldowns
cooldowns[fishA.id] = Date.now() + 60000;
cooldowns[fishB.id] = Date.now() + 60000;
// Breed 1-3 offspring
const count = 1 + Math.floor(Math.random() * 3);
for (let i = 0; i < count; i++) {
const genetics = breedGenetics(fishA.genetics, fishB.genetics);
const name = generateFishName(fishA.species, genetics);
const baby: FishData = {
id: `fish_${Date.now()}_${i}_${Math.random().toString(36).slice(2, 8)}`,
species: fishA.species,
name,
genetics,
age: 0,
species: fishA.species, name, genetics, age: 0,
x: 0, y: 1, z: 0,
};
gameState.fish.push(baby);
fishSchool.addFish(fishToSpec(baby));
}
// Breeding bonus: 10% of combined parent value
const bonus = Math.round((getFishSellValue(fishA) + getFishSellValue(fishB)) * 0.1);
gameState.money += bonus;
saveGame(gameState);
syncFromGameState(gameState);
pushToast(`🐣 ${count} baby ${fishA.species} born! (+$${bonus})`, "success");
console.log("[GAME] Breeding:", fishA.name, "x", fishB.name, "→", count, "babies");
},
onUpgradeTank: () => {
@@ -224,9 +215,21 @@ setGameActions({
gameState.tankTier = nextTier;
saveGame(gameState);
syncFromGameState(gameState);
// Update camera
const newDim = TANK_TIER_DIMS[nextTier];
camera.position.set(0, newDim.h / 2, newDim.camZ);
camera.lookAt(0, newDim.h * 0.5, 0);
// Update fish bounds
const scale = newDim.w / 4;
fishSchool.updateBounds(
{ x: [-1.5 * scale, 1.5 * scale], y: [0.3, newDim.h - 0.3], z: [-1.0 * scale, 1.0 * scale] },
{ x: [-1.8 * scale, 1.8 * scale], y: [0.2, newDim.h - 0.2], z: [-1.3 * scale, 1.3 * scale] }
);
const names = ["Small", "Medium", "Large", "XL"];
pushToast(`🔬 Tank upgraded to ${names[nextTier]}!`, "success");
console.log("[GAME] Tank upgraded to tier", nextTier);
},
onReset: () => {
@@ -235,14 +238,13 @@ setGameActions({
},
});
// --- Start Alpine & sync ---
// --- Init ---
startAlpine();
syncFromGameState(gameState);
// --- Resize ---
function resize(): void {
const w = window.innerWidth;
const h = window.innerHeight;
const w = window.innerWidth, h = window.innerHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setPixelRatio(window.devicePixelRatio);
@@ -252,9 +254,7 @@ resize();
window.addEventListener("resize", resize);
// --- Auto-save ---
setInterval(() => {
saveGame(gameState);
}, 30000);
setInterval(() => saveGame(gameState), 30000);
// --- Render Loop ---
const startTime = performance.now();
+161 -152
View File
@@ -1,70 +1,43 @@
import * as THREE from "three";
import { GLTFLoader, type GLTF } from "three/examples/jsm/loaders/GLTFLoader.js";
import type { FoodSystem } from "./Food";
import type { Genetics, Rarity } from "../game/types";
// ---- Tank bounds (slightly inside the 4 x 2.5 x 3 tank) ----
// ---- Tank bounds ----
type Range = [number, number];
interface Bounds {
x: Range;
y: Range;
z: Range;
}
interface Bounds { x: Range; y: Range; z: Range; }
// Wander targets are drawn from the inner box; positions are hard-clamped to
// the outer box so fish never poke through the glass.
const WANDER: Bounds = { x: [-1.5, 1.5], y: [0.3, 2.0], z: [-1.0, 1.0] };
const CLAMP: Bounds = { x: [-1.8, 1.8], y: [0.2, 2.2], z: [-1.3, 1.3] };
// After the per-model orientation fix, every fish's nose points along local +X,
// so a single forward axis works for the whole school.
const FORWARD = new THREE.Vector3(1, 0, 0);
const FOOD_NOTICE = 1.2;
const FOOD_EAT = 0.15;
// 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 ----
// ---- FishSpec ----
export interface FishSpec {
/** Path to the GLB, relative to the page root (served from /public). */
url: string;
/** Long-axis length, in world units, the model is scaled to. */
targetLength: number;
/**
* Static orientation applied to the loaded model so its nose ends up on
* local +X and its back on local +Y. Maya's tropical fish already satisfy
* this (nose +X); the Quaternius pack is built with its long axis on +Z, so
* it needs +90° about Y.
*/
orient?: { x?: number; y?: number; z?: number };
/** Wandering speed range in world-units/second. */
speed?: Range;
genetics?: Genetics;
}
// ---- Helpers ----
function rand(min: number, max: number): number {
return min + Math.random() * (max - min);
}
function randIn([lo, hi]: Range): number {
return rand(lo, hi);
}
function randomWanderTarget(): THREE.Vector3 {
return new THREE.Vector3(randIn(WANDER.x), randIn(WANDER.y), randIn(WANDER.z));
}
function clampToBounds(v: THREE.Vector3): void {
v.x = THREE.MathUtils.clamp(v.x, CLAMP.x[0], CLAMP.x[1]);
v.y = THREE.MathUtils.clamp(v.y, CLAMP.y[0], CLAMP.y[1]);
v.z = THREE.MathUtils.clamp(v.z, CLAMP.z[0], CLAMP.z[1]);
}
// Prefer a clip whose final name segment is exactly "swim" (e.g. "Swim" or
// "Armature|Armature|Swim"); otherwise the first clip mentioning "swim";
// otherwise the first clip. Models without clips return null (no mixer).
function pickSwimClip(clips: THREE.AnimationClip[]): THREE.AnimationClip | null {
if (clips.length === 0) return null;
for (const c of clips) {
@@ -77,8 +50,6 @@ function pickSwimClip(clips: THREE.AnimationClip[]): THREE.AnimationClip | null
return clips[0];
}
// Bind the model's best swim clip to a playing AnimationMixer. Models without
// clips return null (the fish still wanders, just without a tail beat).
function createSwimMixer(gltf: GLTF): THREE.AnimationMixer | null {
const model = gltf.scene ?? gltf.scenes[0];
const clip = pickSwimClip(gltf.animations);
@@ -90,69 +61,68 @@ function createSwimMixer(gltf: GLTF): THREE.AnimationMixer | null {
return mixer;
}
// ---- Single-model loader (public API) ----
// ---- Apply genetics to a loaded model's materials ----
const RARITY_EMISSIVE: Record<Rarity, { color: number; intensity: number }> = {
common: { color: 0x000000, intensity: 0 },
uncommon: { color: 0x22aa44, intensity: 0.15 },
rare: { color: 0x3388ff, intensity: 0.25 },
epic: { color: 0xaa44ff, intensity: 0.4 },
legendary: { color: 0xffdd00, intensity: 0.6 },
};
// Load a single GLB into the scene wrapped in a group, with its swim clip (if
// any) already playing. `scale` multiplies the model's size (default 1). This
// is a plain loader — for wandering fish use createGLTFishSchool.
export function loadGLBFish(
scene: THREE.Scene,
url: string,
scale = 1
): Promise<{ mesh: THREE.Group; mixer: THREE.AnimationMixer | null }> {
const loader = new GLTFLoader();
return new Promise((resolve, reject) => {
loader.load(
url,
(gltf) => {
const model = gltf.scene ?? gltf.scenes[0];
const group = new THREE.Group();
group.add(model);
group.scale.setScalar(scale);
function applyGeneticsToModel(model: THREE.Object3D, genetics: Genetics): void {
const emissive = RARITY_EMISSIVE[genetics.rarity];
model.traverse((o) => {
const mesh = o as THREE.Mesh;
if (mesh.isMesh) {
mesh.castShadow = true;
mesh.receiveShadow = false;
}
});
model.traverse((obj) => {
const mesh = obj as THREE.Mesh;
if (!mesh.isMesh) return;
const mat = mesh.material as THREE.MeshStandardMaterial;
if (!mat || !mat.isMaterial) return;
scene.add(group);
resolve({ mesh: group, mixer: createSwimMixer(gltf) });
},
undefined,
(err) => reject(err)
);
// Color shift: modify hue and saturation
if (mat.color) {
const col = mat.color.clone();
const hsl = { h: 0, s: 0, l: 0 };
col.getHSL(hsl);
// Apply hue shift (genetics.hueShift is -30 to +30 degrees)
hsl.h = (hsl.h + genetics.hueShift / 360 + 1) % 1;
// Apply saturation multiplier
hsl.s = THREE.MathUtils.clamp(hsl.s * genetics.saturation, 0, 1);
col.setHSL(hsl.h, hsl.s, hsl.l);
mat.color = col;
}
// Rarity glow: add emissive
if (emissive.intensity > 0 && mat.emissive) {
mat.emissive = new THREE.Color(emissive.color);
mat.emissiveIntensity = emissive.intensity;
}
mesh.castShadow = true;
mesh.receiveShadow = false;
});
}
// ---- Per-fish state ----
interface Fish {
/** Outer group moved by the AI (position + quaternion). */
group: THREE.Group;
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;
// For fade-out animation
fading: boolean;
fadeAlpha: number;
}
// Builds one wandering fish from a loaded GLB: wraps the model in an
// orientation/scale group, attaches an animation mixer for the swim clip, and
// seeds the wandering state.
function buildFish(gltf: GLTF, spec: FishSpec): Fish {
const model = gltf.scene ?? gltf.scenes[0];
// Orientation fix + scale live on a wrapper so the AI only ever touches the
// outer group's transform.
const orient = new THREE.Group();
if (spec.orient) {
orient.rotation.set(spec.orient.x ?? 0, spec.orient.y ?? 0, spec.orient.z ?? 0);
@@ -162,7 +132,6 @@ function buildFish(gltf: GLTF, spec: FishSpec): Fish {
const box = new THREE.Box3().setFromObject(model);
const size = box.getSize(new THREE.Vector3());
const longest = Math.max(size.x, size.y, size.z);
// Small per-instance jitter so a pair of the same species don't look cloned.
const scale = (spec.targetLength / longest) * rand(0.92, 1.08);
orient.scale.setScalar(scale);
orient.add(model);
@@ -170,29 +139,29 @@ function buildFish(gltf: GLTF, spec: FishSpec): Fish {
const group = new THREE.Group();
group.add(orient);
// Fish cast shadows onto the gravel; they don't need to receive them.
model.traverse((o) => {
const mesh = o as THREE.Mesh;
if (mesh.isMesh) {
mesh.castShadow = true;
mesh.receiveShadow = false;
}
});
// Apply genetics (color shift, rarity glow)
if (spec.genetics) {
applyGeneticsToModel(model, spec.genetics);
} else {
model.traverse((o) => {
const mesh = o as THREE.Mesh;
if (mesh.isMesh) {
mesh.castShadow = true;
mesh.receiveShadow = false;
}
});
}
// 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);
// Swim animation (if the model ships one).
const mixer = createSwimMixer(gltf);
if (mixer) {
// Tail beat scales with cruising speed, with a little random variation.
mixer.timeScale = Math.min(1.6, 0.6 + speed * 1.2) * rand(0.9, 1.1);
}
// Start already facing the first target to avoid an initial swing.
const position = randomWanderTarget();
const target = randomWanderTarget();
const startDir = target.clone().sub(position).normalize();
@@ -201,53 +170,44 @@ function buildFish(gltf: GLTF, spec: FishSpec): Fish {
group.position.copy(position);
group.quaternion.copy(quaternion);
return { group, position, target, speed, seekSpeed, phase, turnSpeed, quaternion, mixer, chasingFood: -1 };
return { group, position, target, speed, seekSpeed, phase, turnSpeed, quaternion, mixer, chasingFood: -1, fading: false, fadeAlpha: 1 };
}
// ---- School ----
export interface GLTFishSchool {
update: (dt: number, time: number) => void;
group: THREE.Group;
}
// Scratch objects reused by updateFish() to avoid per-frame allocation.
// ---- Update logic ----
const _dir = new THREE.Vector3();
const _targetQuat = new THREE.Quaternion();
// 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,
f: Fish, dt: number, time: number,
food: FoodSystem | null,
foodPositions: THREE.Vector3[],
foodIndex: number[] // maps active-position index → pellet index for eat()
foodIndex: number[]
): void {
if (f.fading) {
f.fadeAlpha -= dt * 2;
f.group.scale.setScalar(Math.max(0.01, f.fadeAlpha));
if (f.fadeAlpha <= 0) {
f.group.visible = false;
}
return;
}
let speed = f.speed;
let target = f.target;
// --- 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++) {
@@ -264,23 +224,16 @@ function updateFish(
}
}
} 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);
@@ -288,27 +241,18 @@ function updateFish(
}
_dir.normalize();
// 3. Turn toward the target (frame-rate independent slerp).
_targetQuat.setFromUnitVectors(FORWARD, _dir);
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, speed * dt);
// 5. Gentle vertical bob.
f.position.y += Math.sin(time * 1.5 + f.phase) * 0.002;
// 6. Keep inside the tank.
clampToBounds(f.position);
// 7. Apply transform.
f.group.position.copy(f.position);
f.group.quaternion.copy(f.quaternion);
// 8. Advance the swim animation.
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;
@@ -316,11 +260,31 @@ function updateFish(
}
}
// 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.
//
// If `food` is provided, fish will notice and seek food pellets within range.
// ---- School (dynamic) ----
export interface GLTFishSchool {
update: (dt: number, time: number) => void;
group: THREE.Group;
addFish: (spec: FishSpec) => void;
removeFish: (index: number) => void;
getFishCount: () => number;
updateBounds: (wander: Bounds, clamp: Bounds) => void;
}
const loader = new GLTFLoader();
const loadedModels: Map<string, GLTF> = new Map();
function loadModel(url: string): Promise<GLTF> {
if (loadedModels.has(url)) return Promise.resolve(loadedModels.get(url)!);
return new Promise((resolve, reject) => {
loader.load(
url,
(gltf) => { loadedModels.set(url, gltf); resolve(gltf); },
undefined,
(err) => reject(err)
);
});
}
export function createGLTFishSchool(
scene: THREE.Scene,
specs: FishSpec[],
@@ -328,46 +292,91 @@ export function createGLTFishSchool(
): GLTFishSchool {
const group = new THREE.Group();
scene.add(group);
const fish: Fish[] = [];
const loader = new GLTFLoader();
let wander = { ...WANDER };
let clamp = { ...CLAMP };
function updateWanderTarget(): void {
// Re-randomize wander targets within current bounds
}
async function loadAndAdd(spec: FishSpec): Promise<void> {
try {
const gltf = await loadModel(spec.url);
// Clone the model so each fish instance is independent
const clonedGltf = { ...gltf, scene: gltf.scene.clone(true) } as GLTF;
const f = buildFish(clonedGltf, spec);
group.add(f.group);
fish.push(f);
} catch (err) {
console.error(`[GLTFish] failed to load ${spec.url}:`, err);
}
}
// Load initial fish
for (const spec of specs) {
loader.load(
spec.url,
(gltf) => {
const f = buildFish(gltf, spec);
group.add(f.group);
fish.push(f);
},
undefined,
(err) => console.error(`[GLTFish] failed to load ${spec.url}:`, err)
);
loadAndAdd(spec);
}
// 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, food || null, foodPositions, foodIndex);
for (let i = fish.length - 1; i >= 0; i--) {
const f = fish[i];
if (f.fading && f.fadeAlpha <= 0) {
// Remove fully faded fish
group.remove(f.group);
f.group.traverse((obj) => {
const mesh = obj as THREE.Mesh;
if (mesh.isMesh) {
mesh.geometry.dispose();
if (Array.isArray(mesh.material)) mesh.material.forEach((m) => m.dispose());
else (mesh.material as THREE.Material).dispose();
}
});
if (f.mixer) f.mixer.stopAllAction();
fish.splice(i, 1);
continue;
}
// Use local bounds
const savedWander = WANDER;
const savedClamp = CLAMP;
// Temporarily set bounds
(WANDER as any).x = wander.x; (WANDER as any).y = wander.y; (WANDER as any).z = wander.z;
(CLAMP as any).x = clamp.x; (CLAMP as any).y = clamp.y; (CLAMP as any).z = clamp.z;
updateFish(f, dt, time, food || null, foodPositions, foodIndex);
// Restore
(WANDER as any).x = savedWander.x; (WANDER as any).y = savedWander.y; (WANDER as any).z = savedWander.z;
(CLAMP as any).x = savedClamp.x; (CLAMP as any).y = savedClamp.y; (CLAMP as any).z = savedClamp.z;
}
}
return { update, group };
return {
update,
group,
addFish: (spec: FishSpec) => { loadAndAdd(spec); },
removeFish: (index: number) => {
if (index >= 0 && index < fish.length) {
fish[index].fading = true;
fish[index].fadeAlpha = 1;
}
},
getFishCount: () => fish.length,
updateBounds: (newWander: Bounds, newClamp: Bounds) => {
wander = newWander;
clamp = newClamp;
},
};
}