+18
-1
@@ -2,6 +2,8 @@ import * as THREE from "three";
|
||||
import { createTank } from "./scene/Tank";
|
||||
import { createWater } from "./scene/Water";
|
||||
import { setupLighting } from "./scene/Lighting";
|
||||
import { createFishSchool } from "./scene/Fish";
|
||||
import { createParallaxCamera } from "./scene/Camera";
|
||||
|
||||
const canvas = document.querySelector<HTMLCanvasElement>("#aquarium");
|
||||
if (!canvas) {
|
||||
@@ -30,6 +32,15 @@ camera.lookAt(0, 1.2, 0);
|
||||
setupLighting(scene, renderer);
|
||||
createTank(scene);
|
||||
const water = createWater(scene);
|
||||
const fishSchool = createFishSchool(scene, 6);
|
||||
const parallax = createParallaxCamera(camera);
|
||||
|
||||
// Mouse tracking (normalized to -1..1)
|
||||
let mouseX = 0, mouseY = 0;
|
||||
window.addEventListener("mousemove", (e) => {
|
||||
mouseX = (e.clientX / window.innerWidth) * 2 - 1;
|
||||
mouseY = (e.clientY / window.innerHeight) * 2 - 1;
|
||||
});
|
||||
|
||||
// Resize handling
|
||||
function resize(): void {
|
||||
@@ -45,10 +56,16 @@ window.addEventListener("resize", resize);
|
||||
|
||||
// Render loop
|
||||
const startTime = performance.now();
|
||||
let lastFrameTime = performance.now();
|
||||
function animate(): void {
|
||||
requestAnimationFrame(animate);
|
||||
const time = (performance.now() - startTime) / 1000;
|
||||
const now = performance.now();
|
||||
const time = (now - startTime) / 1000;
|
||||
const dt = Math.min((now - lastFrameTime) / 1000, 0.1);
|
||||
lastFrameTime = now;
|
||||
water.update(time);
|
||||
fishSchool.update(dt, time);
|
||||
parallax.update(mouseX, mouseY, time, dt);
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
animate();
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
export interface ParallaxCamera {
|
||||
update: (mouseX: number, mouseY: number, time: number, dt: number) => void;
|
||||
}
|
||||
|
||||
// Creates a parallax camera controller: the camera sways gently toward the
|
||||
// mouse and drifts slowly on its own while the mouse is idle.
|
||||
export function createParallaxCamera(camera: THREE.PerspectiveCamera): ParallaxCamera {
|
||||
// Baseline captured at creation time. `basePosition` is the full snapshot;
|
||||
// `baseZ` is the starting depth used to offset the camera along its axis.
|
||||
const basePosition = camera.position.clone();
|
||||
const baseZ = camera.position.z;
|
||||
|
||||
// Smoothed state (radians for rotations, world units for the z offset).
|
||||
let currentY = 0;
|
||||
let currentX = 0;
|
||||
let currentZ = 0;
|
||||
|
||||
// Last known mouse position and when it most recently changed (idle detect).
|
||||
let lastMouseX = 0;
|
||||
let lastMouseY = 0;
|
||||
let lastChangeTime = -Infinity;
|
||||
|
||||
// Easing rate: how fast values chase their target (frame-rate independent).
|
||||
const EASE = 5;
|
||||
|
||||
function update(mouseX: number, mouseY: number, time: number, dt: number): void {
|
||||
// 1. Mouse moved? Refresh the "last change" timestamp.
|
||||
if (mouseX !== lastMouseX || mouseY !== lastMouseY) {
|
||||
lastChangeTime = time;
|
||||
lastMouseX = mouseX;
|
||||
lastMouseY = mouseY;
|
||||
}
|
||||
|
||||
// 2. Target rotation about Y (≈ ±5°).
|
||||
let targetY = mouseX * 0.087;
|
||||
|
||||
// 3. Target rotation about X (≈ ±1°).
|
||||
const targetX = mouseY * 0.02;
|
||||
|
||||
// 4. Target z offset (parallax push away/toward the tank).
|
||||
const targetZ = mouseX * 0.1;
|
||||
|
||||
// 5. Idle sway — a slow drift once the mouse has been still for >2s.
|
||||
if (time - lastChangeTime > 2) {
|
||||
targetY += Math.sin(time * 0.4) * 0.03;
|
||||
}
|
||||
|
||||
// 6. Ease each value toward its target.
|
||||
const ease = 1 - Math.exp(-EASE * dt);
|
||||
currentY += (targetY - currentY) * ease;
|
||||
currentX += (targetX - currentX) * ease;
|
||||
currentZ += (targetZ - currentZ) * ease;
|
||||
|
||||
// 7. Apply to the camera (position offset from the original baseline).
|
||||
camera.rotation.y = currentY;
|
||||
camera.rotation.x = currentX;
|
||||
camera.position.z = baseZ + currentZ;
|
||||
}
|
||||
|
||||
// basePosition is the full baseline snapshot kept for future use (e.g.
|
||||
// restoring x/y or clamping); the current parallax only offsets z.
|
||||
void basePosition;
|
||||
|
||||
return { update };
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
// ---- Configuration ----
|
||||
|
||||
// Roaming / clamping bounds, kept inside the tank (tank spans x:-2..2, y:0..2.5, z:-1.5..1.5)
|
||||
type Bounds = { x: [number, number]; y: [number, number]; z: [number, number] };
|
||||
const BOUNDS: { wander: Bounds; clamp: Bounds } = {
|
||||
wander: { x: [-1.5, 1.5], y: [0.3, 2.0], z: [-1.0, 1.0] },
|
||||
clamp: { x: [-1.8, 1.8], y: [0.2, 2.3], z: [-1.3, 1.3] },
|
||||
};
|
||||
|
||||
// The fish body is elongated along local +X, so that is its forward axis.
|
||||
const FORWARD = new THREE.Vector3(1, 0, 0);
|
||||
|
||||
// Color palette: orange / blue / yellow hue ranges (HSL).
|
||||
const HUE_RANGES: [number, number][] = [
|
||||
[0.05, 0.12], // orange
|
||||
[0.55, 0.62], // blue
|
||||
[0.12, 0.18], // yellow
|
||||
];
|
||||
|
||||
// Scratch objects reused by update() to avoid per-frame allocation.
|
||||
const _dir = new THREE.Vector3();
|
||||
const _targetQuat = new THREE.Quaternion();
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
function rand(min: number, max: number): number {
|
||||
return min + Math.random() * (max - min);
|
||||
}
|
||||
|
||||
function randIn(range: [number, number]): number {
|
||||
return rand(range[0], range[1]);
|
||||
}
|
||||
|
||||
function randomWanderTarget(): THREE.Vector3 {
|
||||
return new THREE.Vector3(
|
||||
randIn(BOUNDS.wander.x),
|
||||
randIn(BOUNDS.wander.y),
|
||||
randIn(BOUNDS.wander.z)
|
||||
);
|
||||
}
|
||||
|
||||
function clampToBounds(v: THREE.Vector3): void {
|
||||
v.x = THREE.MathUtils.clamp(v.x, BOUNDS.clamp.x[0], BOUNDS.clamp.x[1]);
|
||||
v.y = THREE.MathUtils.clamp(v.y, BOUNDS.clamp.y[0], BOUNDS.clamp.y[1]);
|
||||
v.z = THREE.MathUtils.clamp(v.z, BOUNDS.clamp.z[0], BOUNDS.clamp.z[1]);
|
||||
}
|
||||
|
||||
function randomFishColor(): THREE.Color {
|
||||
const [h0, h1] = HUE_RANGES[Math.floor(Math.random() * HUE_RANGES.length)];
|
||||
return new THREE.Color().setHSL(rand(h0, h1), rand(0.7, 0.9), rand(0.5, 0.65));
|
||||
}
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
interface FishState {
|
||||
group: THREE.Group;
|
||||
tail: THREE.Mesh;
|
||||
body: THREE.Mesh;
|
||||
position: THREE.Vector3;
|
||||
target: THREE.Vector3;
|
||||
speed: number;
|
||||
phase: number;
|
||||
turnSpeed: number;
|
||||
quaternion: THREE.Quaternion;
|
||||
}
|
||||
|
||||
// ---- Mesh construction ----
|
||||
|
||||
// Builds a low-poly procedural fish from primitives, all sharing one material.
|
||||
// The body is an elongated sphere (long axis = local +X = forward).
|
||||
function createFishMesh(color: THREE.Color): {
|
||||
group: THREE.Group;
|
||||
tail: THREE.Mesh;
|
||||
body: THREE.Mesh;
|
||||
} {
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
color,
|
||||
roughness: 0.4,
|
||||
metalness: 0.1,
|
||||
// DoubleSide so the flat pectoral fins are visible from either side.
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
|
||||
const group = new THREE.Group();
|
||||
|
||||
// Body
|
||||
const body = new THREE.Mesh(new THREE.SphereGeometry(1, 16, 12), material);
|
||||
body.scale.set(1.0, 0.4, 0.25);
|
||||
body.castShadow = true;
|
||||
|
||||
// Tail — thin cone at the rear.
|
||||
// NOTE: the spec says "rotated Z by PI", but that orients the cone's axis
|
||||
// along Y, which makes the per-frame rotation.y wag invisible (a line aligned
|
||||
// with its rotation axis doesn't move). Rotating -PI/2 instead points the tip
|
||||
// forward (+X, toward the body) as intended, and lets the y-wag sweep side-to-side.
|
||||
const tail = new THREE.Mesh(new THREE.ConeGeometry(0.3, 0.5, 4), material);
|
||||
tail.rotation.z = -Math.PI / 2;
|
||||
tail.scale.set(0.1, 1, 1);
|
||||
tail.position.set(-1.0, 0, 0);
|
||||
tail.castShadow = true;
|
||||
|
||||
// Dorsal fin — sits on top of the body, points up.
|
||||
const dorsal = new THREE.Mesh(new THREE.ConeGeometry(0.15, 0.2, 4), material);
|
||||
dorsal.scale.set(0.1, 1, 1);
|
||||
dorsal.position.set(0, 0.4, 0);
|
||||
dorsal.castShadow = true;
|
||||
|
||||
// Pectoral fins — small flat paddles on either side, angled out and back.
|
||||
// Nudged just past the body's z-extent (0.25) so they read as fins rather
|
||||
// than being buried inside the opaque body.
|
||||
const pectoralGeo = new THREE.PlaneGeometry(0.2, 0.1);
|
||||
|
||||
const leftFin = new THREE.Mesh(pectoralGeo, material);
|
||||
leftFin.position.set(0, -0.05, 0.26);
|
||||
leftFin.rotation.set(-0.3, 0, 0.3); // face +Z (outward), droop down, sweep back
|
||||
leftFin.castShadow = true;
|
||||
|
||||
const rightFin = new THREE.Mesh(pectoralGeo, material);
|
||||
rightFin.position.set(0, -0.05, -0.26);
|
||||
rightFin.rotation.set(-0.3, Math.PI, -0.3); // face -Z (outward)
|
||||
rightFin.castShadow = true;
|
||||
|
||||
group.add(body, tail, dorsal, leftFin, rightFin);
|
||||
|
||||
return { group, tail, body };
|
||||
}
|
||||
|
||||
// ---- School ----
|
||||
|
||||
export interface FishSchool {
|
||||
update: (dt: number, time: number) => void;
|
||||
group: THREE.Group;
|
||||
}
|
||||
|
||||
// Creates `count` independently-wandering fish under a single parent group.
|
||||
export function createFishSchool(scene: THREE.Scene, count: number = 6): FishSchool {
|
||||
const group = new THREE.Group();
|
||||
const fish: FishState[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const { group: fishGroup, tail, body } = createFishMesh(randomFishColor());
|
||||
|
||||
const position = randomWanderTarget();
|
||||
const target = randomWanderTarget();
|
||||
const speed = rand(0.3, 0.8);
|
||||
const phase = Math.random() * Math.PI * 2;
|
||||
const turnSpeed = rand(2, 4);
|
||||
|
||||
// Start already facing the first target to avoid an initial swing.
|
||||
const startDir = target.clone().sub(position).normalize();
|
||||
const quaternion = new THREE.Quaternion().setFromUnitVectors(FORWARD, startDir);
|
||||
|
||||
fishGroup.position.copy(position);
|
||||
fishGroup.quaternion.copy(quaternion);
|
||||
fishGroup.scale.setScalar(rand(0.3, 0.5));
|
||||
|
||||
group.add(fishGroup);
|
||||
|
||||
fish.push({ group: fishGroup, tail, body, position, target, speed, phase, turnSpeed, quaternion });
|
||||
}
|
||||
|
||||
scene.add(group);
|
||||
|
||||
function update(dt: number, time: number): void {
|
||||
for (const f of fish) {
|
||||
// 1. Direction toward current target.
|
||||
_dir.subVectors(f.target, f.position);
|
||||
|
||||
// 2. Reached the target — pick a new one.
|
||||
if (_dir.length() < 0.3) {
|
||||
f.target.copy(randomWanderTarget());
|
||||
_dir.subVectors(f.target, f.position);
|
||||
}
|
||||
_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));
|
||||
|
||||
// 4. Move forward.
|
||||
f.position.addScaledVector(_dir, f.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. Tail wag.
|
||||
f.tail.rotation.y = Math.sin(time * (2 + f.speed * 3) + f.phase) * 0.4;
|
||||
|
||||
// 9. Subtle body flex (length pulses around 1.0).
|
||||
f.body.scale.x = 1.0 + Math.sin(time * (2 + f.speed * 3) + f.phase) * 0.03;
|
||||
}
|
||||
}
|
||||
|
||||
return { update, group };
|
||||
}
|
||||
Reference in New Issue
Block a user