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:
+41
-41
@@ -10,10 +10,9 @@ import { createBubbles } from "./scene/Bubbles";
|
|||||||
import { createFood } from "./scene/Food";
|
import { createFood } from "./scene/Food";
|
||||||
import { createDefaultState, saveGame, loadGame, resetGame } from "./game/state";
|
import { createDefaultState, saveGame, loadGame, resetGame } from "./game/state";
|
||||||
import type { GameState, FishData, FishSpecies, Genetics } from "./game/types";
|
import type { GameState, FishData, FishSpecies, Genetics } from "./game/types";
|
||||||
import { RARITY_MULT } from "./game/types";
|
|
||||||
import { syncFromGameState, pushToast, getUIState } from "./game/store";
|
import { syncFromGameState, pushToast, getUIState } from "./game/store";
|
||||||
import { setGameActions, startAlpine } from "./ui";
|
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";
|
import { generateGenetics, generateFishName, breedGenetics } from "./game/genetics";
|
||||||
|
|
||||||
// --- Game State ---
|
// --- Game State ---
|
||||||
@@ -42,11 +41,10 @@ createTank(scene);
|
|||||||
const water = createWater(scene);
|
const water = createWater(scene);
|
||||||
const food = createFood(scene);
|
const food = createFood(scene);
|
||||||
|
|
||||||
// --- Fish specs from game state ---
|
// --- Fish helpers ---
|
||||||
function isQuaternius(sp: FishSpecies): boolean {
|
function isQuaternius(sp: FishSpecies): boolean {
|
||||||
return sp === "fish1" || sp === "fish2" || sp === "fish3";
|
return sp === "fish1" || sp === "fish2" || sp === "fish3";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSpeciesUrl(sp: FishSpecies): string {
|
function getSpeciesUrl(sp: FishSpecies): string {
|
||||||
const urls: Record<FishSpecies, string> = {
|
const urls: Record<FishSpecies, string> = {
|
||||||
angelfish: "models/angelfish.glb",
|
angelfish: "models/angelfish.glb",
|
||||||
@@ -58,17 +56,22 @@ function getSpeciesUrl(sp: FishSpecies): string {
|
|||||||
};
|
};
|
||||||
return urls[sp];
|
return urls[sp];
|
||||||
}
|
}
|
||||||
|
function fishToSpec(f: FishData): FishSpec {
|
||||||
function getFishSpecs(): FishSpec[] {
|
return {
|
||||||
return gameState.fish.map((f) => ({
|
|
||||||
url: getSpeciesUrl(f.species),
|
url: getSpeciesUrl(f.species),
|
||||||
targetLength: f.genetics.sizeMult * 0.5,
|
targetLength: f.genetics.sizeMult * 0.5,
|
||||||
speed: [0.3 * f.genetics.speedMult, 0.7 * f.genetics.speedMult],
|
speed: [0.3 * f.genetics.speedMult, 0.7 * f.genetics.speedMult],
|
||||||
orient: isQuaternius(f.species) ? { y: Math.PI / 2 } : undefined,
|
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 ---
|
// --- Decor ---
|
||||||
const decorSpecs: DecorSpec[] = [
|
const decorSpecs: DecorSpec[] = [
|
||||||
@@ -94,9 +97,7 @@ window.addEventListener("mousemove", (e) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("click", (e) => {
|
window.addEventListener("click", (e) => {
|
||||||
// Don't process clicks on UI elements
|
|
||||||
if (e.target !== canvas) return;
|
if (e.target !== canvas) return;
|
||||||
|
|
||||||
mouseNDC.x = (e.clientX / window.innerWidth) * 2 - 1;
|
mouseNDC.x = (e.clientX / window.innerWidth) * 2 - 1;
|
||||||
mouseNDC.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
mouseNDC.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
||||||
raycaster.setFromCamera(mouseNDC, camera);
|
raycaster.setFromCamera(mouseNDC, camera);
|
||||||
@@ -106,9 +107,7 @@ window.addEventListener("click", (e) => {
|
|||||||
|
|
||||||
if (intersects.length > 0) {
|
if (intersects.length > 0) {
|
||||||
let obj: THREE.Object3D | null = intersects[0].object;
|
let obj: THREE.Object3D | null = intersects[0].object;
|
||||||
while (obj && obj.parent !== fishGroup) {
|
while (obj && obj.parent !== fishGroup) obj = obj.parent;
|
||||||
obj = obj.parent;
|
|
||||||
}
|
|
||||||
if (obj) {
|
if (obj) {
|
||||||
const idx = fishGroup.children.indexOf(obj);
|
const idx = fishGroup.children.indexOf(obj);
|
||||||
if (idx >= 0 && idx < gameState.fish.length) {
|
if (idx >= 0 && idx < gameState.fish.length) {
|
||||||
@@ -117,11 +116,10 @@ window.addEventListener("click", (e) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// No fish clicked — drop food
|
|
||||||
food.dropFood(4);
|
food.dropFood(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Cooldown tracking ---
|
// --- Cooldowns ---
|
||||||
const cooldowns: Record<string, number> = {};
|
const cooldowns: Record<string, number> = {};
|
||||||
|
|
||||||
// --- Game Actions ---
|
// --- Game Actions ---
|
||||||
@@ -133,27 +131,27 @@ setGameActions({
|
|||||||
|
|
||||||
onBuyFish: (species: FishSpecies, genetics: Genetics) => {
|
onBuyFish: (species: FishSpecies, genetics: Genetics) => {
|
||||||
const price = FISH_PRICES[species];
|
const price = FISH_PRICES[species];
|
||||||
|
const cap = TANK_TIER_DIMS[gameState.tankTier].cap;
|
||||||
if (gameState.money < price) return;
|
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;
|
gameState.money -= price;
|
||||||
const name = generateFishName(species, genetics);
|
const name = generateFishName(species, genetics);
|
||||||
const fish: FishData = {
|
const fish: FishData = {
|
||||||
id: `fish_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
id: `fish_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
species,
|
species, name, genetics, age: 0,
|
||||||
name,
|
|
||||||
genetics,
|
|
||||||
age: 0,
|
|
||||||
x: 0, y: 1, z: 0,
|
x: 0, y: 1, z: 0,
|
||||||
};
|
};
|
||||||
gameState.fish.push(fish);
|
gameState.fish.push(fish);
|
||||||
|
fishSchool.addFish(fishToSpec(fish));
|
||||||
saveGame(gameState);
|
saveGame(gameState);
|
||||||
syncFromGameState(gameState);
|
syncFromGameState(gameState);
|
||||||
|
|
||||||
if (genetics.rarity === "epic" || genetics.rarity === "legendary") {
|
if (genetics.rarity === "epic" || genetics.rarity === "legendary") {
|
||||||
pushToast(`🌟 ${genetics.rarity.toUpperCase()} ${name}!`, "rare");
|
pushToast(`🌟 ${genetics.rarity.toUpperCase()} ${name}!`, "rare");
|
||||||
} else {
|
} else {
|
||||||
pushToast(`+${name} (${genetics.rarity})`, "success");
|
pushToast(`+${name} (${genetics.rarity})`, "success");
|
||||||
}
|
}
|
||||||
console.log("[GAME] Bought:", name, "Rarity:", genetics.rarity);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onSellFish: (fishId: string) => {
|
onSellFish: (fishId: string) => {
|
||||||
@@ -167,52 +165,45 @@ setGameActions({
|
|||||||
const value = getFishSellValue(fish);
|
const value = getFishSellValue(fish);
|
||||||
gameState.money += value;
|
gameState.money += value;
|
||||||
gameState.fish.splice(idx, 1);
|
gameState.fish.splice(idx, 1);
|
||||||
|
fishSchool.removeFish(idx);
|
||||||
delete cooldowns[fishId];
|
delete cooldowns[fishId];
|
||||||
saveGame(gameState);
|
saveGame(gameState);
|
||||||
syncFromGameState(gameState);
|
syncFromGameState(gameState);
|
||||||
pushToast(`+$${value} — Sold ${fish.name}`, "success");
|
pushToast(`+$${value} — Sold ${fish.name}`, "success");
|
||||||
console.log("[GAME] Sold:", fish.name, "for", value);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onBreed: (fishA: FishData, fishB: FishData) => {
|
onBreed: (fishA: FishData, fishB: FishData) => {
|
||||||
if (fishA.species !== fishB.species) return;
|
if (fishA.species !== fishB.species) return;
|
||||||
if (fishA.genetics.gender === fishB.genetics.gender) {
|
if (fishA.genetics.gender === fishB.genetics.gender) {
|
||||||
pushToast("Need male + female to breed!", "warning");
|
pushToast("Need male + female!", "warning");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (fishA.age < 72 || fishB.age < 72) {
|
if (fishA.age < 72 || fishB.age < 72) {
|
||||||
pushToast("Fish must be adults to breed!", "warning");
|
pushToast("Fish must be adults!", "warning");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set cooldowns
|
|
||||||
cooldowns[fishA.id] = Date.now() + 60000;
|
cooldowns[fishA.id] = Date.now() + 60000;
|
||||||
cooldowns[fishB.id] = Date.now() + 60000;
|
cooldowns[fishB.id] = Date.now() + 60000;
|
||||||
|
|
||||||
// Breed 1-3 offspring
|
|
||||||
const count = 1 + Math.floor(Math.random() * 3);
|
const count = 1 + Math.floor(Math.random() * 3);
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const genetics = breedGenetics(fishA.genetics, fishB.genetics);
|
const genetics = breedGenetics(fishA.genetics, fishB.genetics);
|
||||||
const name = generateFishName(fishA.species, genetics);
|
const name = generateFishName(fishA.species, genetics);
|
||||||
const baby: FishData = {
|
const baby: FishData = {
|
||||||
id: `fish_${Date.now()}_${i}_${Math.random().toString(36).slice(2, 8)}`,
|
id: `fish_${Date.now()}_${i}_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
species: fishA.species,
|
species: fishA.species, name, genetics, age: 0,
|
||||||
name,
|
|
||||||
genetics,
|
|
||||||
age: 0,
|
|
||||||
x: 0, y: 1, z: 0,
|
x: 0, y: 1, z: 0,
|
||||||
};
|
};
|
||||||
gameState.fish.push(baby);
|
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);
|
const bonus = Math.round((getFishSellValue(fishA) + getFishSellValue(fishB)) * 0.1);
|
||||||
gameState.money += bonus;
|
gameState.money += bonus;
|
||||||
|
|
||||||
saveGame(gameState);
|
saveGame(gameState);
|
||||||
syncFromGameState(gameState);
|
syncFromGameState(gameState);
|
||||||
pushToast(`🐣 ${count} baby ${fishA.species} born! (+$${bonus})`, "success");
|
pushToast(`🐣 ${count} baby ${fishA.species} born! (+$${bonus})`, "success");
|
||||||
console.log("[GAME] Breeding:", fishA.name, "x", fishB.name, "→", count, "babies");
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onUpgradeTank: () => {
|
onUpgradeTank: () => {
|
||||||
@@ -224,9 +215,21 @@ setGameActions({
|
|||||||
gameState.tankTier = nextTier;
|
gameState.tankTier = nextTier;
|
||||||
saveGame(gameState);
|
saveGame(gameState);
|
||||||
syncFromGameState(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"];
|
const names = ["Small", "Medium", "Large", "XL"];
|
||||||
pushToast(`🔬 Tank upgraded to ${names[nextTier]}!`, "success");
|
pushToast(`🔬 Tank upgraded to ${names[nextTier]}!`, "success");
|
||||||
console.log("[GAME] Tank upgraded to tier", nextTier);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onReset: () => {
|
onReset: () => {
|
||||||
@@ -235,14 +238,13 @@ setGameActions({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Start Alpine & sync ---
|
// --- Init ---
|
||||||
startAlpine();
|
startAlpine();
|
||||||
syncFromGameState(gameState);
|
syncFromGameState(gameState);
|
||||||
|
|
||||||
// --- Resize ---
|
// --- Resize ---
|
||||||
function resize(): void {
|
function resize(): void {
|
||||||
const w = window.innerWidth;
|
const w = window.innerWidth, h = window.innerHeight;
|
||||||
const h = window.innerHeight;
|
|
||||||
camera.aspect = w / h;
|
camera.aspect = w / h;
|
||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
renderer.setPixelRatio(window.devicePixelRatio);
|
renderer.setPixelRatio(window.devicePixelRatio);
|
||||||
@@ -252,9 +254,7 @@ resize();
|
|||||||
window.addEventListener("resize", resize);
|
window.addEventListener("resize", resize);
|
||||||
|
|
||||||
// --- Auto-save ---
|
// --- Auto-save ---
|
||||||
setInterval(() => {
|
setInterval(() => saveGame(gameState), 30000);
|
||||||
saveGame(gameState);
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
// --- Render Loop ---
|
// --- Render Loop ---
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
|
|||||||
+161
-152
@@ -1,70 +1,43 @@
|
|||||||
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";
|
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];
|
type Range = [number, number];
|
||||||
interface Bounds {
|
interface Bounds { x: Range; y: Range; z: Range; }
|
||||||
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 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] };
|
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 FORWARD = new THREE.Vector3(1, 0, 0);
|
||||||
|
const FOOD_NOTICE = 1.2;
|
||||||
|
const FOOD_EAT = 0.15;
|
||||||
|
|
||||||
// Food interaction radii.
|
// ---- FishSpec ----
|
||||||
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 {
|
export interface FishSpec {
|
||||||
/** Path to the GLB, relative to the page root (served from /public). */
|
|
||||||
url: string;
|
url: string;
|
||||||
/** Long-axis length, in world units, the model is scaled to. */
|
|
||||||
targetLength: number;
|
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 };
|
orient?: { x?: number; y?: number; z?: number };
|
||||||
/** Wandering speed range in world-units/second. */
|
|
||||||
speed?: Range;
|
speed?: Range;
|
||||||
|
genetics?: Genetics;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Helpers ----
|
// ---- Helpers ----
|
||||||
|
|
||||||
function rand(min: number, max: number): number {
|
function rand(min: number, max: number): number {
|
||||||
return min + Math.random() * (max - min);
|
return min + Math.random() * (max - min);
|
||||||
}
|
}
|
||||||
|
|
||||||
function randIn([lo, hi]: Range): number {
|
function randIn([lo, hi]: Range): number {
|
||||||
return rand(lo, hi);
|
return rand(lo, hi);
|
||||||
}
|
}
|
||||||
|
|
||||||
function randomWanderTarget(): THREE.Vector3 {
|
function randomWanderTarget(): THREE.Vector3 {
|
||||||
return new THREE.Vector3(randIn(WANDER.x), randIn(WANDER.y), randIn(WANDER.z));
|
return new THREE.Vector3(randIn(WANDER.x), randIn(WANDER.y), randIn(WANDER.z));
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampToBounds(v: THREE.Vector3): void {
|
function clampToBounds(v: THREE.Vector3): void {
|
||||||
v.x = THREE.MathUtils.clamp(v.x, CLAMP.x[0], CLAMP.x[1]);
|
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.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]);
|
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 {
|
function pickSwimClip(clips: THREE.AnimationClip[]): THREE.AnimationClip | null {
|
||||||
if (clips.length === 0) return null;
|
if (clips.length === 0) return null;
|
||||||
for (const c of clips) {
|
for (const c of clips) {
|
||||||
@@ -77,8 +50,6 @@ function pickSwimClip(clips: THREE.AnimationClip[]): THREE.AnimationClip | null
|
|||||||
return clips[0];
|
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 {
|
function createSwimMixer(gltf: GLTF): THREE.AnimationMixer | null {
|
||||||
const model = gltf.scene ?? gltf.scenes[0];
|
const model = gltf.scene ?? gltf.scenes[0];
|
||||||
const clip = pickSwimClip(gltf.animations);
|
const clip = pickSwimClip(gltf.animations);
|
||||||
@@ -90,69 +61,68 @@ function createSwimMixer(gltf: GLTF): THREE.AnimationMixer | null {
|
|||||||
return mixer;
|
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
|
function applyGeneticsToModel(model: THREE.Object3D, genetics: Genetics): void {
|
||||||
// any) already playing. `scale` multiplies the model's size (default 1). This
|
const emissive = RARITY_EMISSIVE[genetics.rarity];
|
||||||
// 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);
|
|
||||||
|
|
||||||
model.traverse((o) => {
|
model.traverse((obj) => {
|
||||||
const mesh = o as THREE.Mesh;
|
const mesh = obj as THREE.Mesh;
|
||||||
if (mesh.isMesh) {
|
if (!mesh.isMesh) return;
|
||||||
mesh.castShadow = true;
|
const mat = mesh.material as THREE.MeshStandardMaterial;
|
||||||
mesh.receiveShadow = false;
|
if (!mat || !mat.isMaterial) return;
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
scene.add(group);
|
// Color shift: modify hue and saturation
|
||||||
resolve({ mesh: group, mixer: createSwimMixer(gltf) });
|
if (mat.color) {
|
||||||
},
|
const col = mat.color.clone();
|
||||||
undefined,
|
const hsl = { h: 0, s: 0, l: 0 };
|
||||||
(err) => reject(err)
|
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 ----
|
// ---- Per-fish state ----
|
||||||
|
|
||||||
interface Fish {
|
interface Fish {
|
||||||
/** Outer group moved by the AI (position + quaternion). */
|
|
||||||
group: THREE.Group;
|
group: THREE.Group;
|
||||||
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;
|
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;
|
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 {
|
function buildFish(gltf: GLTF, spec: FishSpec): Fish {
|
||||||
const model = gltf.scene ?? gltf.scenes[0];
|
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();
|
const orient = new THREE.Group();
|
||||||
if (spec.orient) {
|
if (spec.orient) {
|
||||||
orient.rotation.set(spec.orient.x ?? 0, spec.orient.y ?? 0, spec.orient.z ?? 0);
|
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 box = new THREE.Box3().setFromObject(model);
|
||||||
const size = box.getSize(new THREE.Vector3());
|
const size = box.getSize(new THREE.Vector3());
|
||||||
const longest = Math.max(size.x, size.y, size.z);
|
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);
|
const scale = (spec.targetLength / longest) * rand(0.92, 1.08);
|
||||||
orient.scale.setScalar(scale);
|
orient.scale.setScalar(scale);
|
||||||
orient.add(model);
|
orient.add(model);
|
||||||
@@ -170,29 +139,29 @@ function buildFish(gltf: GLTF, spec: FishSpec): Fish {
|
|||||||
const group = new THREE.Group();
|
const group = new THREE.Group();
|
||||||
group.add(orient);
|
group.add(orient);
|
||||||
|
|
||||||
// Fish cast shadows onto the gravel; they don't need to receive them.
|
// Apply genetics (color shift, rarity glow)
|
||||||
model.traverse((o) => {
|
if (spec.genetics) {
|
||||||
const mesh = o as THREE.Mesh;
|
applyGeneticsToModel(model, spec.genetics);
|
||||||
if (mesh.isMesh) {
|
} else {
|
||||||
mesh.castShadow = true;
|
model.traverse((o) => {
|
||||||
mesh.receiveShadow = false;
|
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 speed = spec.speed ? rand(spec.speed[0], spec.speed[1]) : rand(0.3, 0.8);
|
||||||
const seekSpeed = speed * 1.4;
|
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);
|
||||||
|
|
||||||
// Swim animation (if the model ships one).
|
|
||||||
const mixer = createSwimMixer(gltf);
|
const mixer = createSwimMixer(gltf);
|
||||||
if (mixer) {
|
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);
|
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 position = randomWanderTarget();
|
||||||
const target = randomWanderTarget();
|
const target = randomWanderTarget();
|
||||||
const startDir = target.clone().sub(position).normalize();
|
const startDir = target.clone().sub(position).normalize();
|
||||||
@@ -201,53 +170,44 @@ 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, seekSpeed, phase, turnSpeed, quaternion, mixer, chasingFood: -1 };
|
return { group, position, target, speed, seekSpeed, phase, turnSpeed, quaternion, mixer, chasingFood: -1, fading: false, fadeAlpha: 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- School ----
|
// ---- Update logic ----
|
||||||
|
|
||||||
export interface GLTFishSchool {
|
|
||||||
update: (dt: number, time: number) => void;
|
|
||||||
group: THREE.Group;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scratch objects reused by updateFish() to avoid per-frame allocation.
|
|
||||||
const _dir = new THREE.Vector3();
|
const _dir = new THREE.Vector3();
|
||||||
const _targetQuat = new THREE.Quaternion();
|
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(
|
function updateFish(
|
||||||
f: Fish,
|
f: Fish, dt: number, time: number,
|
||||||
dt: number,
|
|
||||||
time: number,
|
|
||||||
food: FoodSystem | null,
|
food: FoodSystem | null,
|
||||||
foodPositions: THREE.Vector3[],
|
foodPositions: THREE.Vector3[],
|
||||||
foodIndex: number[] // maps active-position index → pellet index for eat()
|
foodIndex: number[]
|
||||||
): void {
|
): 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 speed = f.speed;
|
||||||
let target = f.target;
|
let target = f.target;
|
||||||
|
|
||||||
// --- Food seeking ---
|
|
||||||
if (food && foodPositions.length > 0) {
|
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) {
|
if (f.chasingFood >= 0 && f.chasingFood < foodPositions.length) {
|
||||||
target = foodPositions[f.chasingFood];
|
target = foodPositions[f.chasingFood];
|
||||||
speed = f.seekSpeed;
|
speed = f.seekSpeed;
|
||||||
|
|
||||||
// Check if we've reached it.
|
|
||||||
const dist = f.position.distanceTo(target);
|
const dist = f.position.distanceTo(target);
|
||||||
if (dist < FOOD_EAT) {
|
if (dist < FOOD_EAT) {
|
||||||
food.eat(foodIndex[f.chasingFood]);
|
food.eat(foodIndex[f.chasingFood]);
|
||||||
f.chasingFood = -1;
|
f.chasingFood = -1;
|
||||||
// Reset target to a new wander point.
|
|
||||||
f.target.copy(randomWanderTarget());
|
f.target.copy(randomWanderTarget());
|
||||||
target = f.target;
|
target = f.target;
|
||||||
speed = f.speed;
|
speed = f.speed;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Not chasing — look for the nearest food within notice radius.
|
|
||||||
let nearestIdx = -1;
|
let nearestIdx = -1;
|
||||||
let nearestDist = FOOD_NOTICE;
|
let nearestDist = FOOD_NOTICE;
|
||||||
for (let i = 0; i < foodPositions.length; i++) {
|
for (let i = 0; i < foodPositions.length; i++) {
|
||||||
@@ -264,23 +224,16 @@ function updateFish(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No food around — reset chasing state.
|
|
||||||
f.chasingFood = -1;
|
f.chasingFood = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Movement (shared for wander + seek) ---
|
|
||||||
|
|
||||||
// 1. Direction toward the current target.
|
|
||||||
_dir.subVectors(target, f.position);
|
_dir.subVectors(target, f.position);
|
||||||
|
|
||||||
// 2. If wandering and reached the target — pick a new one.
|
|
||||||
if (f.chasingFood < 0 && _dir.length() < 0.3) {
|
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) {
|
if (_dir.length() < 0.001) {
|
||||||
// Already at target (or degenerate) — skip movement this frame.
|
|
||||||
f.group.position.copy(f.position);
|
f.group.position.copy(f.position);
|
||||||
f.group.quaternion.copy(f.quaternion);
|
f.group.quaternion.copy(f.quaternion);
|
||||||
if (f.mixer) f.mixer.update(dt);
|
if (f.mixer) f.mixer.update(dt);
|
||||||
@@ -288,27 +241,18 @@ function updateFish(
|
|||||||
}
|
}
|
||||||
_dir.normalize();
|
_dir.normalize();
|
||||||
|
|
||||||
// 3. Turn toward the target (frame-rate independent slerp).
|
|
||||||
_targetQuat.setFromUnitVectors(FORWARD, _dir);
|
_targetQuat.setFromUnitVectors(FORWARD, _dir);
|
||||||
const turnRate = f.chasingFood >= 0 ? f.turnSpeed * 1.3 : f.turnSpeed;
|
const turnRate = f.chasingFood >= 0 ? f.turnSpeed * 1.3 : f.turnSpeed;
|
||||||
f.quaternion.slerp(_targetQuat, 1 - Math.exp(-turnRate * dt));
|
f.quaternion.slerp(_targetQuat, 1 - Math.exp(-turnRate * dt));
|
||||||
|
|
||||||
// 4. Move forward.
|
|
||||||
f.position.addScaledVector(_dir, speed * dt);
|
f.position.addScaledVector(_dir, speed * dt);
|
||||||
|
|
||||||
// 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;
|
||||||
|
|
||||||
// 6. Keep inside the tank.
|
|
||||||
clampToBounds(f.position);
|
clampToBounds(f.position);
|
||||||
|
|
||||||
// 7. Apply transform.
|
|
||||||
f.group.position.copy(f.position);
|
f.group.position.copy(f.position);
|
||||||
f.group.quaternion.copy(f.quaternion);
|
f.group.quaternion.copy(f.quaternion);
|
||||||
|
|
||||||
// 8. Advance the swim animation.
|
|
||||||
if (f.mixer) {
|
if (f.mixer) {
|
||||||
// Slightly faster tail beat while seeking food.
|
|
||||||
const baseTimeScale = Math.min(1.6, 0.6 + f.speed * 1.2);
|
const baseTimeScale = Math.min(1.6, 0.6 + f.speed * 1.2);
|
||||||
const seekBoost = f.chasingFood >= 0 ? 1.3 : 1.0;
|
const seekBoost = f.chasingFood >= 0 ? 1.3 : 1.0;
|
||||||
f.mixer.timeScale = baseTimeScale * seekBoost;
|
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
|
// ---- School (dynamic) ----
|
||||||
// group. Models load asynchronously and join the school as they arrive, so the
|
export interface GLTFishSchool {
|
||||||
// aquarium populates progressively. Returns an updater to call each frame.
|
update: (dt: number, time: number) => void;
|
||||||
//
|
group: THREE.Group;
|
||||||
// If `food` is provided, fish will notice and seek food pellets within range.
|
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(
|
export function createGLTFishSchool(
|
||||||
scene: THREE.Scene,
|
scene: THREE.Scene,
|
||||||
specs: FishSpec[],
|
specs: FishSpec[],
|
||||||
@@ -328,46 +292,91 @@ export function createGLTFishSchool(
|
|||||||
): GLTFishSchool {
|
): GLTFishSchool {
|
||||||
const group = new THREE.Group();
|
const group = new THREE.Group();
|
||||||
scene.add(group);
|
scene.add(group);
|
||||||
|
|
||||||
const fish: Fish[] = [];
|
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) {
|
for (const spec of specs) {
|
||||||
loader.load(
|
loadAndAdd(spec);
|
||||||
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)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scratch arrays for food positions (reused each frame to avoid GC pressure).
|
|
||||||
const foodPositions: THREE.Vector3[] = [];
|
const foodPositions: THREE.Vector3[] = [];
|
||||||
const foodIndex: number[] = [];
|
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) {
|
if (food) {
|
||||||
foodPositions.length = 0;
|
foodPositions.length = 0;
|
||||||
foodIndex.length = 0;
|
foodIndex.length = 0;
|
||||||
const active = food.getActivePositions();
|
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++) {
|
for (let i = 0; i < active.length; i++) {
|
||||||
foodPositions.push(active[i]);
|
foodPositions.push(active[i]);
|
||||||
foodIndex.push(i);
|
foodIndex.push(i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < fish.length; i++) {
|
for (let i = fish.length - 1; i >= 0; i--) {
|
||||||
updateFish(fish[i], dt, time, food || null, foodPositions, foodIndex);
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user