diff --git a/src/main.ts b/src/main.ts index bda677c..5fb9e4e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,6 +5,8 @@ import { setupLighting } from "./scene/Lighting"; import { createGLTFishSchool, type FishSpec } from "./scene/GLTFish"; import { createDecor, type DecorSpec } from "./scene/Decor"; import { createParallaxCamera } from "./scene/Camera"; +import { createBubbles } from "./scene/Bubbles"; +import { createFood } from "./scene/Food"; const canvas = document.querySelector("#aquarium"); if (!canvas) { @@ -34,10 +36,10 @@ setupLighting(scene, renderer); createTank(scene); const water = createWater(scene); -// Fish — GLB models with swim animations + wandering AI. -// Maya's tropical fish already face local +X (no orient fix). The Quaternius -// pack is built with its long axis on +Z (nose at +Z), so it is rotated +90° -// about Y to bring the nose onto +X. +// Food — pellets that drop from above; fish seek and eat them. +const food = createFood(scene); + +// Fish — GLB models with swim animations + wandering AI + food seeking. const fishSpecs: FishSpec[] = [ // Tropical (maya's-aquarium-3d) { url: "models/angelfish.glb", targetLength: 0.55, speed: [0.25, 0.45] }, @@ -52,7 +54,7 @@ const fishSpecs: FishSpec[] = [ { url: "models/quaternius/glb/Fish2.glb", targetLength: 0.6, speed: [0.3, 0.55], orient: { y: Math.PI / 2 } }, { url: "models/quaternius/glb/Fish3.glb", targetLength: 0.55, speed: [0.35, 0.6], orient: { y: Math.PI / 2 } }, ]; -const fishSchool = createGLTFishSchool(scene, fishSpecs); +const fishSchool = createGLTFishSchool(scene, fishSpecs, food); // Decor — static plants and rocks on the gravel floor (y ~ 0.05). const decorSpecs: DecorSpec[] = [ @@ -64,6 +66,9 @@ const decorSpecs: DecorSpec[] = [ ]; createDecor(scene, decorSpecs); +// Bubbles — rising from air stones near the plants. +const bubbles = createBubbles(scene); + const parallax = createParallaxCamera(camera); // Mouse tracking (normalized to -1..1) @@ -73,6 +78,11 @@ window.addEventListener("mousemove", (e) => { mouseY = (e.clientY / window.innerHeight) * 2 - 1; }); +// Click to drop food. +window.addEventListener("click", () => { + food.dropFood(4); +}); + // Resize handling function resize(): void { const width = window.innerWidth; @@ -95,7 +105,9 @@ function animate(): void { const dt = Math.min((now - lastFrameTime) / 1000, 0.1); lastFrameTime = now; water.update(time); + food.update(dt, time); fishSchool.update(dt, time); + bubbles.update(dt, time); parallax.update(mouseX, mouseY, time, dt); renderer.render(scene, camera); } diff --git a/src/scene/Bubbles.ts b/src/scene/Bubbles.ts new file mode 100644 index 0000000..92463f2 --- /dev/null +++ b/src/scene/Bubbles.ts @@ -0,0 +1,162 @@ +import * as THREE from "three"; + +// ---- Config ---- + +interface AirStone { + x: number; + z: number; + /** Bubbles per second emitted. */ + rate: number; +} + +const STONES: AirStone[] = [ + { x: -1.3, z: -0.8, rate: 2.5 }, + { x: 1.2, z: -0.7, rate: 1.8 }, +]; + +const RISE_SPEED: [number, number] = [0.25, 0.5]; +const WOBBLE_AMP = 0.03; +const WOBBLE_FREQ: [number, number] = [2.0, 4.0]; +const SURFACE_Y = 2.1; +const FLOOR_Y = 0.05; +const POOL_SIZE = 50; +const BASE_RADIUS: [number, number] = [0.012, 0.028]; + +// ---- Per-bubble state ---- + +interface Bubble { + mesh: THREE.Mesh; + active: boolean; + pos: THREE.Vector3; + vel: number; + wobblePhase: number; + wobbleFreq: number; + wobbleAxis: number; // 0 = x, 1 = z + baseRadius: number; + age: number; +} + +// ---- Scratch ---- +const _rand = (lo: number, hi: number): number => lo + Math.random() * (hi - lo); + +// ---- Module ---- + +export interface BubbleSystem { + update: (dt: number, time: number) => void; + group: THREE.Group; +} + +export function createBubbles(scene: THREE.Scene): BubbleSystem { + const group = new THREE.Group(); + scene.add(group); + + // Shared geometry + material for the pool (cheap to clone per instance). + const geo = new THREE.SphereGeometry(1, 8, 6); + const mat = new THREE.MeshPhongMaterial({ + color: 0xaaddff, + transparent: true, + opacity: 0.35, + shininess: 100, + specular: 0xffffff, + }); + + // Pool of bubble meshes. + const bubbles: Bubble[] = []; + for (let i = 0; i < POOL_SIZE; i++) { + const mesh = new THREE.Mesh(geo, mat.clone()); + mesh.visible = false; + const pos = new THREE.Vector3(); + const bubble: Bubble = { + mesh, + active: false, + pos, + vel: 0, + wobblePhase: Math.random() * Math.PI * 2, + wobbleFreq: _rand(WOBBLE_FREQ[0], WOBBLE_FREQ[1]), + wobbleAxis: Math.random() < 0.5 ? 0 : 1, + baseRadius: _rand(BASE_RADIUS[0], BASE_RADIUS[1]), + age: 0, + }; + bubbles.push(bubble); + group.add(mesh); + } + + // Spawn accumulators per stone. + const accumulators = STONES.map(() => 0); + + function spawn(stoneIdx: number): void { + // Find an inactive bubble. + let b: Bubble | null = null; + for (let i = 0; i < bubbles.length; i++) { + if (!bubbles[i].active) { + b = bubbles[i]; + break; + } + } + if (!b) return; // Pool exhausted. + + const stone = STONES[stoneIdx]; + const jitter = 0.06; + b.pos.set( + stone.x + _rand(-jitter, jitter), + FLOOR_Y, + stone.z + _rand(-jitter, jitter) + ); + b.vel = _rand(RISE_SPEED[0], RISE_SPEED[1]); + b.wobblePhase = Math.random() * Math.PI * 2; + b.wobbleFreq = _rand(WOBBLE_FREQ[0], WOBBLE_FREQ[1]); + b.wobbleAxis = Math.random() < 0.5 ? 0 : 1; + b.baseRadius = _rand(BASE_RADIUS[0], BASE_RADIUS[1]); + b.age = 0; + b.active = true; + b.mesh.visible = true; + } + + function update(dt: number, time: number): void { + // Spawn new bubbles. + for (let s = 0; s < STONES.length; s++) { + accumulators[s] += STONES[s].rate * dt; + while (accumulators[s] >= 1) { + accumulators[s] -= 1; + spawn(s); + } + } + + // Update active bubbles. + for (let i = 0; i < bubbles.length; i++) { + const b = bubbles[i]; + if (!b.active) continue; + + b.age += dt; + b.pos.y += b.vel * dt; + + // Wobble. + const wobble = Math.sin(time * b.wobbleFreq + b.wobblePhase) * WOBBLE_AMP; + if (b.wobbleAxis === 0) { + b.pos.x += wobble * dt; + } else { + b.pos.z += wobble * dt; + } + + // Grown slightly as it rises (pressure drop). + const scale = b.baseRadius * (1 + b.age * 0.08); + + // Fade out near surface. + const lifeT = (b.pos.y - FLOOR_Y) / (SURFACE_Y - FLOOR_Y); + const opacity = 0.35 * (1 - lifeT * 0.6); + (b.mesh.material as THREE.MeshPhongMaterial).opacity = opacity; + + // Kill at surface. + if (b.pos.y >= SURFACE_Y) { + b.active = false; + b.mesh.visible = false; + continue; + } + + b.mesh.position.copy(b.pos); + b.mesh.scale.setScalar(scale); + } + } + + return { update, group }; +} diff --git a/src/scene/Food.ts b/src/scene/Food.ts new file mode 100644 index 0000000..ab13593 --- /dev/null +++ b/src/scene/Food.ts @@ -0,0 +1,172 @@ +import * as THREE from "three"; + +// ---- Config ---- + +const DROP_Y = 2.2; +const FLOOR_Y = 0.08; +const FALL_SPEED: [number, number] = [0.12, 0.22]; +const DRIFT_SPEED: [number, number] = [0.02, 0.08]; +const RADIUS: [number, number] = [0.02, 0.04]; +const MAX_PELLETS = 15; +const AUTO_DROP_INTERVAL: [number, number] = [6, 14]; // seconds between auto drops +const AUTO_DROP_COUNT: [number, number] = [3, 6]; + +// Tank interior bounds for x/z placement. +const BOUNDS_X: [number, number] = [-1.4, 1.4]; +const BOUNDS_Z: [number, number] = [-0.9, 0.9]; + +// ---- Per-pellet state ---- + +interface Pellet { + mesh: THREE.Mesh; + active: boolean; + pos: THREE.Vector3; + velY: number; + driftX: number; + driftZ: number; + wobblePhase: number; +} + +// ---- Public interface ---- + +export interface FoodSystem { + /** Call each frame. */ + update: (dt: number, time: number) => void; + /** + * Returns positions of all active pellets. Index into this array matches + * the index passed to `eat()`. + */ + getActivePositions: () => THREE.Vector3[]; + /** + * Consume the pellet at the given index (from getActivePositions). + * Returns true if a pellet was eaten. + */ + eat: (index: number) => boolean; + /** Manually drop a batch of pellets (e.g., from a click). */ + dropFood: (count?: number) => void; + group: THREE.Group; +} + +// ---- Scratch ---- +const _rand = (lo: number, hi: number): number => lo + Math.random() * (hi - lo); + +// ---- Implementation ---- + +export function createFood(scene: THREE.Scene): FoodSystem { + const group = new THREE.Group(); + scene.add(group); + + const geo = new THREE.SphereGeometry(1, 6, 5); + const mat = new THREE.MeshStandardMaterial({ + color: 0xc88830, + roughness: 0.8, + metalness: 0.0, + }); + + // Pool of pellet meshes. + const pellets: Pellet[] = []; + for (let i = 0; i < MAX_PELLETS; i++) { + const mesh = new THREE.Mesh(geo, mat); + mesh.visible = false; + const p: Pellet = { + mesh, + active: false, + pos: new THREE.Vector3(), + velY: 0, + driftX: 0, + driftZ: 0, + wobblePhase: Math.random() * Math.PI * 2, + }; + pellets.push(p); + group.add(mesh); + } + + // Auto-drop timer. + let nextDrop = _rand(AUTO_DROP_INTERVAL[0], AUTO_DROP_INTERVAL[1]); + let elapsed = 0; + + function findInactive(): Pellet | null { + for (let i = 0; i < pellets.length; i++) { + if (!pellets[i].active) return pellets[i]; + } + return null; + } + + function spawnPellet(): void { + const p = findInactive(); + if (!p) return; + p.pos.set(_rand(BOUNDS_X[0], BOUNDS_X[1]), DROP_Y, _rand(BOUNDS_Z[0], BOUNDS_Z[1])); + p.velY = _rand(FALL_SPEED[0], FALL_SPEED[1]); + p.driftX = _rand(-DRIFT_SPEED[1], DRIFT_SPEED[1]); + p.driftZ = _rand(-DRIFT_SPEED[1], DRIFT_SPEED[1]); + p.wobblePhase = Math.random() * Math.PI * 2; + p.active = true; + p.mesh.visible = true; + const r = _rand(RADIUS[0], RADIUS[1]); + p.mesh.scale.setScalar(r); + } + + function dropFood(count?: number): void { + const n = count ?? Math.round(_rand(AUTO_DROP_COUNT[0], AUTO_DROP_COUNT[1])); + for (let i = 0; i < n; i++) { + // Stagger the drop slightly so they don't all appear at the exact same spot. + spawnPellet(); + } + nextDrop = _rand(AUTO_DROP_INTERVAL[0], AUTO_DROP_INTERVAL[1]); + elapsed = 0; + } + + function getActivePositions(): THREE.Vector3[] { + const out: THREE.Vector3[] = []; + for (let i = 0; i < pellets.length; i++) { + if (pellets[i].active) out.push(pellets[i].pos); + } + return out; + } + + function eat(index: number): boolean { + // Map the index from getActivePositions() to the actual pellet. + let activeIdx = -1; + for (let i = 0; i < pellets.length; i++) { + if (pellets[i].active) { + activeIdx++; + if (activeIdx === index) { + pellets[i].active = false; + pellets[i].mesh.visible = false; + return true; + } + } + } + return false; + } + + function update(dt: number, time: number): void { + // Auto-drop timer. + elapsed += dt; + if (elapsed >= nextDrop) { + dropFood(); + } + + // Update pellets. + for (let i = 0; i < pellets.length; i++) { + const p = pellets[i]; + if (!p.active) continue; + + p.pos.y -= p.velY * dt; + p.pos.x += p.driftX * dt + Math.sin(time * 1.5 + p.wobblePhase) * 0.003; + p.pos.z += p.driftZ * dt; + + // Settle on the floor — stays there until eaten. + if (p.pos.y <= FLOOR_Y) { + p.pos.y = FLOOR_Y; + p.velY = 0; + p.driftX = 0; + p.driftZ = 0; + } + + p.mesh.position.copy(p.pos); + } + } + + return { update, getActivePositions, eat, dropFood, group }; +} diff --git a/src/scene/GLTFish.ts b/src/scene/GLTFish.ts index e751e0a..7d324aa 100644 --- a/src/scene/GLTFish.ts +++ b/src/scene/GLTFish.ts @@ -1,5 +1,6 @@ import * as THREE from "three"; import { GLTFLoader, type GLTF } from "three/examples/jsm/loaders/GLTFLoader.js"; +import type { FoodSystem } from "./Food"; // ---- Tank bounds (slightly inside the 4 x 2.5 x 3 tank) ---- @@ -19,6 +20,10 @@ const CLAMP: Bounds = { x: [-1.8, 1.8], y: [0.2, 2.2], z: [-1.3, 1.3] }; // so a single forward axis works for the whole school. const FORWARD = new THREE.Vector3(1, 0, 0); +// 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 ---- export interface FishSpec { @@ -130,10 +135,14 @@ interface Fish { 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; } // Builds one wandering fish from a loaded GLB: wraps the model in an @@ -172,6 +181,7 @@ function buildFish(gltf: GLTF, spec: FishSpec): Fish { // 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); @@ -191,7 +201,7 @@ function buildFish(gltf: GLTF, spec: FishSpec): Fish { group.position.copy(position); group.quaternion.copy(quaternion); - return { group, position, target, speed, phase, turnSpeed, quaternion, mixer }; + return { group, position, target, speed, seekSpeed, phase, turnSpeed, quaternion, mixer, chasingFood: -1 }; } // ---- School ---- @@ -205,26 +215,86 @@ export interface GLTFishSchool { const _dir = new THREE.Vector3(); const _targetQuat = new THREE.Quaternion(); -// One frame of wandering for a single fish: seek the current target, retarget -// on arrival, smoothly turn (frame-rate independent slerp), move, bob, clamp, -// and advance the swim animation. -function updateFish(f: Fish, dt: number, time: number): void { - // 1. Direction toward the current target. - _dir.subVectors(f.target, f.position); +// 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, + food: FoodSystem | null, + foodPositions: THREE.Vector3[], + foodIndex: number[] // maps active-position index → pellet index for eat() +): void { + let speed = f.speed; + let target = f.target; - // 2. Reached the target — pick a new one. - if (_dir.length() < 0.3) { + // --- 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++) { + 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 { + // 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); + return; + } _dir.normalize(); // 3. Turn toward the target (frame-rate independent slerp). _targetQuat.setFromUnitVectors(FORWARD, _dir); - f.quaternion.slerp(_targetQuat, 1 - Math.exp(-f.turnSpeed * dt)); + 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, f.speed * dt); + f.position.addScaledVector(_dir, speed * dt); // 5. Gentle vertical bob. f.position.y += Math.sin(time * 1.5 + f.phase) * 0.002; @@ -237,13 +307,25 @@ function updateFish(f: Fish, dt: number, time: number): void { f.group.quaternion.copy(f.quaternion); // 8. Advance the swim animation. - if (f.mixer) f.mixer.update(dt); + 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; + f.mixer.update(dt); + } } // 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. -export function createGLTFishSchool(scene: THREE.Scene, specs: FishSpec[]): GLTFishSchool { +// +// If `food` is provided, fish will notice and seek food pellets within range. +export function createGLTFishSchool( + scene: THREE.Scene, + specs: FishSpec[], + food?: FoodSystem +): GLTFishSchool { const group = new THREE.Group(); scene.add(group); @@ -263,9 +345,27 @@ export function createGLTFishSchool(scene: THREE.Scene, specs: FishSpec[]): GLTF ); } + // 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); + updateFish(fish[i], dt, time, food || null, foodPositions, foodIndex); } }