import * as THREE from "three"; import { createTank } from "./scene/Tank"; import { createWater } from "./scene/Water"; import { setupLighting } from "./scene/Lighting"; import { createGLTFishSchool, type FishSpec } from "./scene/GLTFish"; import { createDecor, type DecorSpec } from "./scene/Decor"; import { createParallaxCamera } from "./scene/Camera"; const canvas = document.querySelector("#aquarium"); if (!canvas) { throw new Error('Canvas element with id "aquarium" not found'); } // Renderer const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); renderer.setPixelRatio(window.devicePixelRatio); // Scene const scene = new THREE.Scene(); scene.background = new THREE.Color(0x0a0a0f); // Camera — frames the tank, positioned slightly above its center const camera = new THREE.PerspectiveCamera( 50, window.innerWidth / window.innerHeight, 0.1, 100 ); camera.position.set(0, 1.5, 6); camera.lookAt(0, 1.2, 0); // Scene content 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. const fishSpecs: FishSpec[] = [ // Tropical (maya's-aquarium-3d) { url: "models/angelfish.glb", targetLength: 0.55, speed: [0.25, 0.45] }, { url: "models/angelfish.glb", targetLength: 0.5, speed: [0.25, 0.45] }, { url: "models/goldfish.glb", targetLength: 0.45, speed: [0.35, 0.6] }, { url: "models/goldfish.glb", targetLength: 0.4, speed: [0.35, 0.6] }, { url: "models/guppy.glb", targetLength: 0.4, speed: [0.4, 0.7] }, { url: "models/guppy.glb", targetLength: 0.35, speed: [0.4, 0.7] }, // Cute fish (Quaternius, CC0) { url: "models/quaternius/glb/Fish1.glb", targetLength: 0.5, speed: [0.4, 0.7], orient: { y: Math.PI / 2 } }, { url: "models/quaternius/glb/Fish1.glb", targetLength: 0.45, speed: [0.4, 0.7], orient: { y: Math.PI / 2 } }, { 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); // Decor — static plants and rocks on the gravel floor (y ~ 0.05). const decorSpecs: DecorSpec[] = [ { url: "models/plant0.glb", x: -1.4, z: -0.9, targetSize: 1.4, rotationY: 0.4 }, { url: "models/plant1.glb", x: 1.3, z: -0.8, targetSize: 1.1, rotationY: -0.3 }, { url: "models/rock0.glb", x: -0.7, z: 0.7, targetSize: 1.0, rotationY: 0.2 }, { url: "models/rock1.glb", x: 0.8, z: 0.8, targetSize: 1.1, rotationY: 1.1 }, { url: "models/rock2.glb", x: 0.1, z: -1.1, targetSize: 1.3, rotationY: 0.0 }, ]; createDecor(scene, decorSpecs); 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 { const width = window.innerWidth; const height = window.innerHeight; camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(width, height); } resize(); window.addEventListener("resize", resize); // Render loop const startTime = performance.now(); let lastFrameTime = performance.now(); function animate(): void { requestAnimationFrame(animate); 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();