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 { 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
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user