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:
+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