feat: dynamic fish add/remove, genetics visualization, full game loop (#13-#19)

- GLTFish: dynamic addFish/removeFish with fade, model caching, genetics applied
  (color hue/saturation shift, rarity emissive glow on fish meshes)
- Buy: spawns fish in 3D scene with random genetics + rarity roll
- Sell: fades out fish, removes from scene, credits money
- Breed: 1-3 babies with blended genetics + mutation, 60s cooldown
- Tank upgrade: resizes bounds, moves camera, updates capacity
- Fish growth: age-based value multiplier (baby/juvenile/adult)
- Full game loop: earn → breed for rarity → sell for profit → upgrade tank
This commit is contained in:
2026-10-07 12:41:05 +00:00
parent eb50f3b56b
commit 6a4614e276
2 changed files with 202 additions and 193 deletions
+41 -41
View File
@@ -10,10 +10,9 @@ 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 { RARITY_MULT } from "./game/types";
import { syncFromGameState, pushToast, getUIState } from "./game/store";
import { setGameActions, startAlpine } from "./ui";
import { FISH_PRICES, SPECIES_RARITY_DIST, TANK_TIER_COSTS, TANK_TIER_DIMS, getFishSellValue, getFishValueBreakdown } from "./game/economy";
import { FISH_PRICES, TANK_TIER_COSTS, TANK_TIER_DIMS, getFishSellValue } from "./game/economy";
import { generateGenetics, generateFishName, breedGenetics } from "./game/genetics";
// --- Game State ---
@@ -42,11 +41,10 @@ createTank(scene);
const water = createWater(scene);
const food = createFood(scene);
// --- Fish specs from game state ---
// --- Fish helpers ---
function isQuaternius(sp: FishSpecies): boolean {
return sp === "fish1" || sp === "fish2" || sp === "fish3";
}
function getSpeciesUrl(sp: FishSpecies): string {
const urls: Record<FishSpecies, string> = {
angelfish: "models/angelfish.glb",
@@ -58,17 +56,22 @@ function getSpeciesUrl(sp: FishSpecies): string {
};
return urls[sp];
}
function getFishSpecs(): FishSpec[] {
return gameState.fish.map((f) => ({
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,
};
}
const fishSchool = createGLTFishSchool(scene, getFishSpecs(), food);
// --- Fish School ---
const fishSchool = createGLTFishSchool(
scene,
gameState.fish.map(fishToSpec),
food
);
// --- Decor ---
const decorSpecs: DecorSpec[] = [
@@ -94,9 +97,7 @@ window.addEventListener("mousemove", (e) => {
});
window.addEventListener("click", (e) => {
// Don't process clicks on UI elements
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);
@@ -106,9 +107,7 @@ window.addEventListener("click", (e) => {
if (intersects.length > 0) {
let obj: THREE.Object3D | null = intersects[0].object;
while (obj && obj.parent !== fishGroup) {
obj = obj.parent;
}
while (obj && obj.parent !== fishGroup) obj = obj.parent;
if (obj) {
const idx = fishGroup.children.indexOf(obj);
if (idx >= 0 && idx < gameState.fish.length) {
@@ -117,11 +116,10 @@ window.addEventListener("click", (e) => {
}
}
}
// No fish clicked — drop food
food.dropFood(4);
});
// --- Cooldown tracking ---
// --- Cooldowns ---
const cooldowns: Record<string, number> = {};
// --- Game Actions ---
@@ -133,27 +131,27 @@ setGameActions({
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 >= TANK_TIER_DIMS[gameState.tankTier].cap) 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,
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");
}
console.log("[GAME] Bought:", name, "Rarity:", genetics.rarity);
},
onSellFish: (fishId: string) => {
@@ -167,52 +165,45 @@ setGameActions({
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");
console.log("[GAME] Sold:", fish.name, "for", value);
},
onBreed: (fishA: FishData, fishB: FishData) => {
if (fishA.species !== fishB.species) return;
if (fishA.genetics.gender === fishB.genetics.gender) {
pushToast("Need male + female to breed!", "warning");
pushToast("Need male + female!", "warning");
return;
}
if (fishA.age < 72 || fishB.age < 72) {
pushToast("Fish must be adults to breed!", "warning");
pushToast("Fish must be adults!", "warning");
return;
}
// Set cooldowns
cooldowns[fishA.id] = Date.now() + 60000;
cooldowns[fishB.id] = Date.now() + 60000;
// Breed 1-3 offspring
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,
species: fishA.species, name, genetics, age: 0,
x: 0, y: 1, z: 0,
};
gameState.fish.push(baby);
fishSchool.addFish(fishToSpec(baby));
}
// Breeding bonus: 10% of combined parent value
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");
console.log("[GAME] Breeding:", fishA.name, "x", fishB.name, "→", count, "babies");
},
onUpgradeTank: () => {
@@ -224,9 +215,21 @@ setGameActions({
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");
console.log("[GAME] Tank upgraded to tier", nextTier);
},
onReset: () => {
@@ -235,14 +238,13 @@ setGameActions({
},
});
// --- Start Alpine & sync ---
// --- Init ---
startAlpine();
syncFromGameState(gameState);
// --- Resize ---
function resize(): void {
const w = window.innerWidth;
const h = window.innerHeight;
const w = window.innerWidth, h = window.innerHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setPixelRatio(window.devicePixelRatio);
@@ -252,9 +254,7 @@ resize();
window.addEventListener("resize", resize);
// --- Auto-save ---
setInterval(() => {
saveGame(gameState);
}, 30000);
setInterval(() => saveGame(gameState), 30000);
// --- Render Loop ---
const startTime = performance.now();