- Replaced hardcoded SPECIES_LENGTH with runtime Box3 measurement (model added to parent group BEFORE measuring, so full GLB hierarchy transforms are included in world matrices) - All 9 species now render at correct target size (0.13-0.32u) - Added optional chaining (?.) to all Alpine template expressions referencing store.selectedFish to prevent null-reference errors - Guarded getFishValue/canBreed calls with null checks
279 lines
9.2 KiB
TypeScript
279 lines
9.2 KiB
TypeScript
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<HTMLCanvasElement>("#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<FishSpecies, string> = {
|
|
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<string, number> = {};
|
|
|
|
// --- 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();
|