import "./style.css"; 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"; import { createBubbles } from "./scene/Bubbles"; import { createFood } from "./scene/Food"; import { createDefaultState, saveGame, loadGame, resetGame } from "./game/state"; import type { GameState, FishData, FishSpecies, Genetics } from "./game/types"; import { syncFromGameState, pushToast, getUIState } from "./game/store"; import { setGameActions, startAlpine } from "./ui"; import { FISH_PRICES, TANK_TIER_COSTS, TANK_TIER_DIMS, getFishSellValue } from "./game/economy"; import { generateGenetics, generateFishName, breedGenetics } from "./game/genetics"; // --- Game State --- let gameState: GameState = loadGame() || createDefaultState(); saveGame(gameState); // --- Canvas & Renderer --- const canvas = document.querySelector("#aquarium"); if (!canvas) throw new Error('Canvas "aquarium" not found'); 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 --- const tankDim = TANK_TIER_DIMS[gameState.tankTier]; const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 100); camera.position.set(0, tankDim.h / 2, tankDim.camZ); camera.lookAt(0, tankDim.h * 0.5, 0); // --- Scene Content --- setupLighting(scene, renderer); createTank(scene); const water = createWater(scene); const food = createFood(scene); // --- Fish helpers --- function isQuaternius(sp: FishSpecies): boolean { return sp === "betta" || sp === "blue_tang" || sp === "flower_horn"; } function getSpeciesUrl(sp: FishSpecies): string { const urls: Record = { angelfish: "models/blendswap/blueface_angelfish.glb", goldfish: "models/goldfish.glb", guppy: "models/guppy.glb", betta: "models/poly-pizza/betta.glb", blue_tang: "models/poly-pizza/blue_tang.glb", flower_horn: "models/poly-pizza/flower_horn.glb", neon_tetra: "models/blendswap/neon_tetra.glb", oscar: "models/blendswap/oscar_cichlid.glb", knifefish: "models/blendswap/ghost_knifefish.glb", }; return urls[sp]; } function fishToSpec(f: FishData): FishSpec { return { url: getSpeciesUrl(f.species), targetLength: f.genetics.sizeMult * 0.5, speed: [0.3 * f.genetics.speedMult, 0.7 * f.genetics.speedMult], orient: isQuaternius(f.species) ? { y: Math.PI / 2 } : undefined, genetics: f.genetics, }; } // --- Fish School --- const fishSchool = createGLTFishSchool( scene, gameState.fish.map(fishToSpec), food ); // --- Decor --- 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 bubbles = createBubbles(scene); const parallax = createParallaxCamera(camera); // --- Mouse / Raycaster --- let mouseX = 0, mouseY = 0; const raycaster = new THREE.Raycaster(); const mouseNDC = new THREE.Vector2(); window.addEventListener("mousemove", (e) => { mouseX = (e.clientX / window.innerWidth) * 2 - 1; mouseY = (e.clientY / window.innerHeight) * 2 - 1; }); window.addEventListener("click", (e) => { if (e.target !== canvas) return; mouseNDC.x = (e.clientX / window.innerWidth) * 2 - 1; mouseNDC.y = -(e.clientY / window.innerHeight) * 2 + 1; raycaster.setFromCamera(mouseNDC, camera); const fishGroup = fishSchool.group; const intersects = raycaster.intersectObjects(fishGroup.children, true); if (intersects.length > 0) { let obj: THREE.Object3D | null = intersects[0].object; while (obj && obj.parent !== fishGroup) obj = obj.parent; if (obj) { const idx = fishGroup.children.indexOf(obj); if (idx >= 0 && idx < gameState.fish.length) { getUIState().selectedFish = gameState.fish[idx]; return; } } } food.dropFood(4); }); // --- Cooldowns --- const cooldowns: Record = {}; // --- Game Actions --- setGameActions({ getState: () => gameState, getFishById: (id) => gameState.fish.find((f) => f.id === id) || null, isAdult: (f) => f.age >= 72, inCooldown: (id) => cooldowns[id] !== undefined && Date.now() < cooldowns[id], onBuyFish: (species: FishSpecies, genetics: Genetics) => { const price = FISH_PRICES[species]; const cap = TANK_TIER_DIMS[gameState.tankTier].cap; if (gameState.money < price) return; if (gameState.fish.length >= cap) return; gameState.money -= price; const name = generateFishName(species, genetics); const fish: FishData = { id: `fish_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, species, name, genetics, age: 0, x: 0, y: 1, z: 0, }; gameState.fish.push(fish); fishSchool.addFish(fishToSpec(fish)); saveGame(gameState); syncFromGameState(gameState); if (genetics.rarity === "epic" || genetics.rarity === "legendary") { pushToast(`🌟 ${genetics.rarity.toUpperCase()} ${name}!`, "rare"); } else { pushToast(`+${name} (${genetics.rarity})`, "success"); } }, onSellFish: (fishId: string) => { const idx = gameState.fish.findIndex((f) => f.id === fishId); if (idx < 0) return; if (gameState.fish.length <= 1) { pushToast("Can't sell your last fish!", "warning"); return; } const fish = gameState.fish[idx]; const value = getFishSellValue(fish); gameState.money += value; gameState.fish.splice(idx, 1); fishSchool.removeFish(idx); delete cooldowns[fishId]; saveGame(gameState); syncFromGameState(gameState); pushToast(`+$${value} — Sold ${fish.name}`, "success"); }, onBreed: (fishA: FishData, fishB: FishData) => { if (fishA.species !== fishB.species) return; if (fishA.genetics.gender === fishB.genetics.gender) { pushToast("Need male + female!", "warning"); return; } if (fishA.age < 72 || fishB.age < 72) { pushToast("Fish must be adults!", "warning"); return; } cooldowns[fishA.id] = Date.now() + 60000; cooldowns[fishB.id] = Date.now() + 60000; const count = 1 + Math.floor(Math.random() * 3); for (let i = 0; i < count; i++) { const genetics = breedGenetics(fishA.genetics, fishB.genetics); const name = generateFishName(fishA.species, genetics); const baby: FishData = { id: `fish_${Date.now()}_${i}_${Math.random().toString(36).slice(2, 8)}`, species: fishA.species, name, genetics, age: 0, x: 0, y: 1, z: 0, }; gameState.fish.push(baby); fishSchool.addFish(fishToSpec(baby)); } const bonus = Math.round((getFishSellValue(fishA) + getFishSellValue(fishB)) * 0.1); gameState.money += bonus; saveGame(gameState); syncFromGameState(gameState); pushToast(`🐣 ${count} baby ${fishA.species} born! (+$${bonus})`, "success"); }, onUpgradeTank: () => { const nextTier = gameState.tankTier + 1; if (nextTier > 3) return; const cost = TANK_TIER_COSTS[nextTier]; if (gameState.money < cost) return; gameState.money -= cost; gameState.tankTier = nextTier; saveGame(gameState); syncFromGameState(gameState); // Update camera const newDim = TANK_TIER_DIMS[nextTier]; camera.position.set(0, newDim.h / 2, newDim.camZ); camera.lookAt(0, newDim.h * 0.5, 0); // Update fish bounds const scale = newDim.w / 4; fishSchool.updateBounds( { x: [-1.5 * scale, 1.5 * scale], y: [0.3, newDim.h - 0.3], z: [-1.0 * scale, 1.0 * scale] }, { x: [-1.8 * scale, 1.8 * scale], y: [0.2, newDim.h - 0.2], z: [-1.3 * scale, 1.3 * scale] } ); const names = ["Small", "Medium", "Large", "XL"]; pushToast(`🔬 Tank upgraded to ${names[nextTier]}!`, "success"); }, onReset: () => { resetGame(); window.location.reload(); }, }); // --- Init --- startAlpine(); syncFromGameState(gameState); // --- Resize --- function resize(): void { const w = window.innerWidth, h = window.innerHeight; camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(w, h); } resize(); window.addEventListener("resize", resize); // --- Auto-save --- setInterval(() => saveGame(gameState), 30000); // --- 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); food.update(dt, time); fishSchool.update(dt, time); bubbles.update(dt, time); parallax.update(mouseX, mouseY, time, dt); renderer.render(scene, camera); } animate();