Box3.setFromObject measures skinned geometry in skeleton space (and may use a stale cached bounding box), which does not match the GPU's actual rendered extent (mesh.matrixWorld * skinning * position). Add a per-species baseScale correction factor (rendered / box3) to FishSpec and fold it into buildFish's scale calc so each GLB normalizes to its true size. Measured per GLB with three.js's exact skinning formula: most models are 1.0. The skinned blendswap oscar (1.35) and knifefish (2.58) deviate and are corrected. The clownfish GLB is a full scene (water 'Surface' 45.3, 'TopLight' 15, 'Bubbles' 3.4) plus a small skinned body (~8.3); box3 is dominated by the water plane, so a baseScale can't fix it without blowing up the scene geometry - left at 1.0 pending a clean re-export.
394 lines
12 KiB
TypeScript
394 lines
12 KiB
TypeScript
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<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 },
|
|
};
|
|
|
|
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<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[],
|
|
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<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) {
|
|
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;
|
|
},
|
|
};
|
|
}
|