diff --git a/src/main.ts b/src/main.ts index d5024c0..9e76065 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 = { 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 = {}; // --- 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(); diff --git a/src/scene/GLTFish.ts b/src/scene/GLTFish.ts index 7d324aa..2b4481d 100644 --- a/src/scene/GLTFish.ts +++ b/src/scene/GLTFish.ts @@ -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 = { + 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 = new Map(); + +function loadModel(url: string): Promise { + 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 { + 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; + }, + }; }