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 ---- type Range = [number, number]; interface Bounds { x: Range; y: Range; z: Range; } 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 FORWARD = new THREE.Vector3(1, 0, 0); const FOOD_NOTICE = 1.2; const FOOD_EAT = 0.15; // ---- FishSpec ---- export interface FishSpec { url: string; targetLength: number; orient?: { x?: number; y?: number; z?: number }; speed?: Range; genetics?: Genetics; /** * Correction factor between the extent reported by `Box3.setFromObject` * and the fish's true rendered extent. `Box3.setFromObject` measures * skinned geometry in skeleton space (and may use a stale cached * bounding box), which does not match what the GPU actually renders * (`mesh.matrixWorld * skinning * position`). baseScale = rendered / box3, * so the final scale becomes `targetLength / (box3 * baseScale) = * targetLength / rendered`, normalizing every fish to its real size. */ baseScale?: number; } // ---- 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]); } function pickSwimClip(clips: THREE.AnimationClip[]): THREE.AnimationClip | null { if (clips.length === 0) return null; for (const c of clips) { const last = (c.name.split("|").pop() ?? c.name).trim().toLowerCase(); if (last === "swim") return c; } for (const c of clips) { if (c.name.toLowerCase().includes("swim")) return c; } return clips[0]; } function createSwimMixer(gltf: GLTF): THREE.AnimationMixer | null { const model = gltf.scene ?? gltf.scenes[0]; const clip = pickSwimClip(gltf.animations); if (!clip) return null; const mixer = new THREE.AnimationMixer(model); const action = mixer.clipAction(clip); action.setLoop(THREE.LoopRepeat, Infinity); action.play(); return mixer; } // ---- 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 }, }; function applyGeneticsToModel(model: THREE.Object3D, genetics: Genetics): void { const emissive = RARITY_EMISSIVE[genetics.rarity]; 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; // 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 { group: THREE.Group; position: THREE.Vector3; target: THREE.Vector3; speed: number; seekSpeed: number; phase: number; turnSpeed: number; quaternion: THREE.Quaternion; mixer: THREE.AnimationMixer | null; chasingFood: number; // For fade-out animation fading: boolean; fadeAlpha: number; } function buildFish(gltf: GLTF, spec: FishSpec): Fish { const model = gltf.scene ?? gltf.scenes[0]; const orient = new THREE.Group(); if (spec.orient) { orient.rotation.set(spec.orient.x ?? 0, spec.orient.y ?? 0, spec.orient.z ?? 0); } model.updateMatrixWorld(true); const box = new THREE.Box3().setFromObject(model); const size = box.getSize(new THREE.Vector3()); const longest = Math.max(size.x, size.y, size.z); const baseScale = spec.baseScale ?? 1.0; const scale = (spec.targetLength / (longest * baseScale)) * rand(0.92, 1.08); orient.scale.setScalar(scale); orient.add(model); const group = new THREE.Group(); group.add(orient); // 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; } }); } 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); const mixer = createSwimMixer(gltf); if (mixer) { mixer.timeScale = Math.min(1.6, 0.6 + speed * 1.2) * rand(0.9, 1.1); } const position = randomWanderTarget(); const target = randomWanderTarget(); const startDir = target.clone().sub(position).normalize(); const quaternion = new THREE.Quaternion().setFromUnitVectors(FORWARD, startDir); group.position.copy(position); group.quaternion.copy(quaternion); return { group, position, target, speed, seekSpeed, phase, turnSpeed, quaternion, mixer, chasingFood: -1, fading: false, fadeAlpha: 1 }; } // ---- Update logic ---- const _dir = new THREE.Vector3(); const _targetQuat = new THREE.Quaternion(); function updateFish( f: Fish, dt: number, time: number, food: FoodSystem | null, foodPositions: THREE.Vector3[], 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; if (food && foodPositions.length > 0) { if (f.chasingFood >= 0 && f.chasingFood < foodPositions.length) { target = foodPositions[f.chasingFood]; speed = f.seekSpeed; const dist = f.position.distanceTo(target); if (dist < FOOD_EAT) { food.eat(foodIndex[f.chasingFood]); f.chasingFood = -1; f.target.copy(randomWanderTarget()); target = f.target; speed = f.speed; } } else { let nearestIdx = -1; let nearestDist = FOOD_NOTICE; for (let i = 0; i < foodPositions.length; i++) { const d = f.position.distanceTo(foodPositions[i]); if (d < nearestDist) { nearestDist = d; nearestIdx = i; } } if (nearestIdx >= 0) { f.chasingFood = nearestIdx; target = foodPositions[nearestIdx]; speed = f.seekSpeed; } } } else { f.chasingFood = -1; } _dir.subVectors(target, f.position); if (f.chasingFood < 0 && _dir.length() < 0.3) { f.target.copy(randomWanderTarget()); _dir.subVectors(f.target, f.position); } if (_dir.length() < 0.001) { f.group.position.copy(f.position); f.group.quaternion.copy(f.quaternion); if (f.mixer) f.mixer.update(dt); return; } _dir.normalize(); _targetQuat.setFromUnitVectors(FORWARD, _dir); const turnRate = f.chasingFood >= 0 ? f.turnSpeed * 1.3 : f.turnSpeed; f.quaternion.slerp(_targetQuat, 1 - Math.exp(-turnRate * dt)); f.position.addScaledVector(_dir, speed * dt); f.position.y += Math.sin(time * 1.5 + f.phase) * 0.002; clampToBounds(f.position); f.group.position.copy(f.position); f.group.quaternion.copy(f.quaternion); if (f.mixer) { const baseTimeScale = Math.min(1.6, 0.6 + f.speed * 1.2); const seekBoost = f.chasingFood >= 0 ? 1.3 : 1.0; f.mixer.timeScale = baseTimeScale * seekBoost; f.mixer.update(dt); } } // ---- 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[], food?: FoodSystem ): GLTFishSchool { const group = new THREE.Group(); scene.add(group); const fish: Fish[] = []; 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) { loadAndAdd(spec); } const foodPositions: THREE.Vector3[] = []; const foodIndex: number[] = []; function update(dt: number, time: number): void { if (food) { foodPositions.length = 0; foodIndex.length = 0; const active = food.getActivePositions(); for (let i = 0; i < active.length; i++) { foodPositions.push(active[i]); foodIndex.push(i); } } 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, 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; }, }; }