feat: game state, persistence, UI framework (Alpine+Tailwind), economy system (#10, #11, #12)

- Game state with localStorage save/load + reset (type DELETE to confirm)
- Alpine.js + Tailwind CSS + Lucide UI overlay (HUD bar, shop modal, settings, toasts)
- Economy: fish prices, tank tier costs/capacities, rarity multipliers, sell value formula
- Genetic system: hue/saturation/size/speed/pattern/rarity/gender + breeding inheritance
- Fish selection via raycaster (click fish = select, click empty = drop food)
- Buy/Sell/Breed/Upgrade actions wired to game state
This commit is contained in:
2026-10-07 12:38:18 +00:00
parent 6ef28b6dbd
commit eb50f3b56b
13 changed files with 1735 additions and 67 deletions
+182 -14
View File
@@ -4,21 +4,189 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Aquarium</title> <title>Aquarium</title>
<style>
html,
body {
margin: 0;
padding: 0;
overflow: hidden;
background: #0a0a0f;
}
canvas {
display: block;
}
</style>
</head> </head>
<body> <body class="bg-aqua-900">
<canvas id="aquarium"></canvas> <canvas id="aquarium" class="fixed inset-0 w-full h-full"></canvas>
<!-- UI Overlay -->
<div id="game-ui" class="fixed inset-0 pointer-events-none" x-data="gameUI()" x-init="init()">
<!-- Top HUD Bar -->
<div class="absolute top-0 left-0 right-0 h-12 flex items-center justify-between px-4 pointer-events-auto bg-aqua-900/70 backdrop-blur-sm border-b border-aqua-700/50">
<!-- Left: Money + Fish count -->
<div class="flex items-center gap-4">
<div class="flex items-center gap-1.5 text-yellow-400 font-semibold">
<span class="text-lg">$</span>
<span x-text="formatMoney(store.money)"></span>
</div>
<div class="flex items-center gap-1.5 text-aqua-200">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6.5 12c.94-3.46 4.94-6 8.5-6 3.56 0 7.56 2.54 8.5 6-.94 3.47-4.94 6-8.5 6-3.56 0-7.56-2.53-8.5-6Z"/><path d="M6 12H2"/><path d="M6 12l3-3"/><path d="M6 12l3 3"/></svg>
<span x-text="store.fishCount + ' / ' + store.fishCapacity"></span>
</div>
</div>
<!-- Right: Tank tier + buttons -->
<div class="flex items-center gap-2">
<span class="text-xs text-aqua-300 px-2 py-0.5 rounded bg-aqua-800/80 border border-aqua-700" x-text="store.tankName + ' Tank'"></span>
<button @click="store.showShop = true"
class="text-xs px-3 py-1.5 rounded-lg bg-aqua-700 hover:bg-aqua-600 text-white transition-colors cursor-pointer">
🐟 Shop
</button>
<button @click="store.showSettings = true"
class="text-xs px-2 py-1.5 rounded-lg bg-aqua-800 hover:bg-aqua-700 text-aqua-300 transition-colors cursor-pointer">
⚙️
</button>
</div>
</div>
<!-- Fish Info Panel (right side) -->
<div x-show="store.selectedFish" x-transition.opacity.duration.200ms
class="absolute right-4 top-16 w-72 pointer-events-auto"
x-cloak style="display:none">
<div class="bg-aqua-900/90 backdrop-blur-md rounded-xl border border-aqua-700/60 p-4 shadow-xl">
<!-- Header -->
<div class="flex items-start justify-between mb-3">
<div>
<h3 class="text-white font-semibold text-sm" x-text="store.selectedFish.name"></h3>
<div class="flex items-center gap-1.5 mt-1">
<span class="text-[10px] px-1.5 py-0.5 rounded font-medium"
:class="rarityBg(store.selectedFish.genetics.rarity)"
x-text="store.selectedFish.genetics.rarity.toUpperCase()"></span>
<span class="text-[10px] text-aqua-300" x-text="'(' + rarityMult(store.selectedFish.genetics.rarity) + 'x value)'"></span>
</div>
</div>
<button @click="store.selectedFish = null" class="text-aqua-400 hover:text-white cursor-pointer">✕</button>
</div>
<!-- Genetics -->
<div class="grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs mb-3">
<div class="text-aqua-300">Gender</div>
<div class="text-white" x-text="store.selectedFish.genetics.gender === 'male' ? '♂ Male' : '♀ Female'"></div>
<div class="text-aqua-300">Size</div>
<div class="text-white" x-text="store.selectedFish.genetics.sizeMult.toFixed(2) + 'x'"></div>
<div class="text-aqua-300">Speed</div>
<div class="text-white" x-text="store.selectedFish.genetics.speedMult.toFixed(2) + 'x'"></div>
<div class="text-aqua-300">Pattern</div>
<div class="text-white capitalize" x-text="store.selectedFish.genetics.pattern"></div>
<div class="text-aqua-300">Age</div>
<div class="text-white" x-text="formatAge(store.selectedFish.age)"></div>
</div>
<!-- Value -->
<div class="text-sm mb-3">
<span class="text-aqua-300">Sell value: </span>
<span class="text-yellow-400 font-bold" x-text="formatMoney(getFishValue(store.selectedFish))"></span>
</div>
<!-- Actions -->
<div class="flex gap-2">
<button @click="requestSell()"
class="flex-1 text-xs py-2 rounded-lg bg-red-900/60 hover:bg-red-800 text-red-200 border border-red-800 transition-colors cursor-pointer">
Sell
</button>
<button @click="requestBreed()"
class="flex-1 text-xs py-2 rounded-lg bg-aqua-700 hover:bg-aqua-600 text-white border border-aqua-600 transition-colors cursor-pointer"
x-disabled="!canBreed(store.selectedFish)">
Breed →
</button>
</div>
</div>
</div>
<!-- Shop Modal -->
<div x-show="store.showShop" x-transition.opacity.duration.200ms
class="absolute inset-0 flex items-center justify-center pointer-events-auto bg-black/60 backdrop-blur-sm"
x-cloak style="display:none">
<div class="bg-aqua-900/95 border border-aqua-700 rounded-2xl w-[500px] max-h-[80vh] overflow-y-auto p-6 shadow-2xl">
<div class="flex items-center justify-between mb-4">
<h2 class="text-white font-bold text-lg">🐟 Fish Shop</h2>
<button @click="store.showShop = false" class="text-aqua-400 hover:text-white cursor-pointer text-xl">✕</button>
</div>
<!-- Fish grid -->
<div class="grid grid-cols-2 gap-3 mb-4">
<template x-for="sp in shopSpecies" :key="sp">
<div class="bg-aqua-800/60 rounded-xl border border-aqua-700/50 p-3">
<div class="text-white font-medium text-sm mb-1" x-text="speciesNames[sp]"></div>
<div class="text-yellow-400 text-xs mb-2" x-text="formatMoney(fishPrices[sp])"></div>
<div class="text-[10px] text-aqua-400 mb-2 leading-tight" x-html="rarityOdds(sp)"></div>
<button @click="buyFish(sp)"
:disabled="store.money < fishPrices[sp] || store.fishCount >= store.fishCapacity"
class="w-full text-xs py-1.5 rounded-lg bg-aqua-600 hover:bg-aqua-500 text-white transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed">
<span x-text="store.fishCount >= store.fishCapacity ? 'Tank Full' : 'Buy'"></span>
</button>
</div>
</template>
</div>
<!-- Footer -->
<div class="flex justify-between text-xs text-aqua-400">
<span x-text="'Fish: ' + store.fishCount + ' / ' + store.fishCapacity"></span>
<span x-text="'Balance: ' + formatMoney(store.money)"></span>
</div>
</div>
</div>
<!-- Settings Modal -->
<div x-show="store.showSettings" x-transition.opacity.duration.200ms
class="absolute inset-0 flex items-center justify-center pointer-events-auto bg-black/60 backdrop-blur-sm"
x-cloak style="display:none">
<div class="bg-aqua-900/95 border border-aqua-700 rounded-2xl w-80 p-6 shadow-2xl">
<div class="flex items-center justify-between mb-4">
<h2 class="text-white font-bold">⚙️ Settings</h2>
<button @click="store.showSettings = false" class="text-aqua-400 hover:text-white cursor-pointer">✕</button>
</div>
<!-- Tank upgrade -->
<div class="mb-4" x-show="store.nextTierCost !== null">
<div class="text-xs text-aqua-300 mb-2">Tank: <span x-text="store.tankName"></span></div>
<button @click="upgradeTank()"
:disabled="store.money < store.nextTierCost"
class="w-full text-xs py-2 rounded-lg bg-aqua-600 hover:bg-aqua-500 text-white transition-colors cursor-pointer disabled:opacity-40">
<span x-text="'Upgrade to ' + nextTierName() + ' — $' + store.nextTierCost"></span>
</button>
</div>
<div class="text-xs text-aqua-400 mb-4" x-show="store.nextTierCost === null">
Tank is at MAX size 🎉
</div>
<!-- Reset -->
<button @click="store.showResetConfirm = true"
class="w-full text-xs py-2 rounded-lg bg-red-900/40 hover:bg-red-900/60 text-red-300 border border-red-900 transition-colors cursor-pointer">
Reset Game
</button>
</div>
</div>
<!-- Reset Confirmation -->
<div x-show="store.showResetConfirm" x-transition.opacity.duration.150ms
class="absolute inset-0 flex items-center justify-center pointer-events-auto bg-black/70 z-50"
x-cloak style="display:none">
<div class="bg-aqua-900 border border-red-900 rounded-2xl w-80 p-6 shadow-2xl">
<h3 class="text-white font-bold mb-2">⚠️ Reset Game</h3>
<p class="text-sm text-aqua-300 mb-4">This will permanently delete ALL your fish, money, and progress.</p>
<p class="text-xs text-red-400 mb-4">Type <code class="bg-red-900/50 px-1 rounded">DELETE</code> to confirm:</p>
<input type="text" x-model="store.resetConfirmText"
class="w-full mb-4 px-3 py-2 rounded-lg bg-aqua-800 border border-aqua-700 text-white text-sm"
placeholder="DELETE" @keyup.enter="confirmReset()" />
<div class="flex gap-2">
<button @click="store.showResetConfirm = false; store.resetConfirmText = ''"
class="flex-1 text-xs py-2 rounded-lg bg-aqua-800 hover:bg-aqua-700 text-white cursor-pointer">Cancel</button>
<button @click="confirmReset()" :disabled="store.resetConfirmText !== 'DELETE'"
class="flex-1 text-xs py-2 rounded-lg bg-red-800 hover:bg-red-700 text-white cursor-pointer disabled:opacity-40">Reset</button>
</div>
</div>
</div>
<!-- Toasts -->
<div class="absolute top-14 right-4 flex flex-col gap-2 items-end">
<template x-for="toast in store.toasts" :key="toast.id">
<div class="px-3 py-2 rounded-lg text-xs font-medium shadow-lg backdrop-blur-sm border transition-all duration-300"
:class="{
'bg-green-900/80 border-green-700 text-green-200': toast.type === 'success',
'bg-aqua-800/80 border-aqua-600 text-aqua-200': toast.type === 'info',
'bg-yellow-900/80 border-yellow-700 text-yellow-200': toast.type === 'warning',
'bg-purple-900/80 border-purple-700 text-purple-200': toast.type === 'rare',
}"
x-text="toast.text">
</div>
</template>
</div>
</div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>
+682
View File
@@ -8,10 +8,13 @@
"name": "aquarium", "name": "aquarium",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"alpinejs": "^3.17.4",
"three": "^0.182.0" "three": "^0.182.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/three": "^0.182.0", "@types/three": "^0.182.0",
"tailwindcss": "^4.3.3",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^7.0.0" "vite": "^7.0.0"
} }
@@ -465,6 +468,56 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/lzma-linux-x64-gnu": { "node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -832,6 +885,278 @@
"win32" "win32"
] ]
}, },
"node_modules/@tailwindcss/node": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
"integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.24.1",
"jiti": "^2.7.0",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.3.3"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
"integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.3.3",
"@tailwindcss/oxide-darwin-arm64": "4.3.3",
"@tailwindcss/oxide-darwin-x64": "4.3.3",
"@tailwindcss/oxide-freebsd-x64": "4.3.3",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
"@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
"@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
"@tailwindcss/oxide-linux-x64-musl": "4.3.3",
"@tailwindcss/oxide-wasm32-wasi": "4.3.3",
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
"@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
"integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
"integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
"integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
"integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
"integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
"integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
"integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
"integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
"integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
"integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
"@emnapi/runtime",
"@tybys/wasm-util",
"@emnapi/wasi-threads",
"tslib"
],
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.11.1",
"@emnapi/runtime": "^1.11.1",
"@emnapi/wasi-threads": "^1.2.2",
"@napi-rs/wasm-runtime": "^1.1.4",
"@tybys/wasm-util": "^0.10.2",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
"integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
"integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
"integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tailwindcss/node": "4.3.3",
"@tailwindcss/oxide": "4.3.3",
"tailwindcss": "4.3.3"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tweenjs/tween.js": { "node_modules/@tweenjs/tween.js": {
"version": "23.1.3", "version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
@@ -876,6 +1201,21 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vue/reactivity": {
"version": "3.5.43",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.43.tgz",
"integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.43"
}
},
"node_modules/@vue/shared": {
"version": "3.5.43",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.43.tgz",
"integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==",
"license": "MIT"
},
"node_modules/@webgpu/types": { "node_modules/@webgpu/types": {
"version": "0.1.74", "version": "0.1.74",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz", "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz",
@@ -883,6 +1223,39 @@
"dev": true, "dev": true,
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/alpinejs": {
"version": "3.17.4",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.17.4.tgz",
"integrity": "sha512-N2gsr+58XVUt7OAPKnUGiKPDtkd+xbSGTae8C8IJxeRdkkhBJPIautkJ+qcwb7lcjJ/QK3/CPVIbN20JPvtK7A==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "~3.5.40"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/enhanced-resolve": {
"version": "5.26.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz",
"integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.3.3"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.28.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
@@ -965,6 +1338,294 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/meshoptimizer": { "node_modules/meshoptimizer": {
"version": "0.22.0", "version": "0.22.0",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz", "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
@@ -1096,6 +1757,27 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/tailwindcss": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
"dev": true,
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/three": { "node_modules/three": {
"version": "0.182.0", "version": "0.182.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz", "resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
+3
View File
@@ -9,10 +9,13 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"alpinejs": "^3.17.4",
"three": "^0.182.0" "three": "^0.182.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/three": "^0.182.0", "@types/three": "^0.182.0",
"tailwindcss": "^4.3.3",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^7.0.0" "vite": "^7.0.0"
} }
+97
View File
@@ -0,0 +1,97 @@
import type { FishData, FishSpecies, Rarity } from "./types";
import { RARITY_MULT } from "./types";
/** Base prices for buying fish (shop) */
export const FISH_PRICES: Record<FishSpecies, number> = {
angelfish: 100,
goldfish: 80,
guppy: 60,
fish1: 50,
fish2: 70,
fish3: 65,
};
/** Cost to upgrade TO tank tier (index = target tier) */
export const TANK_TIER_COSTS: number[] = [0, 500, 1500, 4000];
/** Tank tier capacities */
export const TANK_CAPACITY: number[] = [12, 20, 30, 45];
/** Tank tier names */
export const TANK_TIER_NAMES: string[] = ["Small", "Medium", "Large", "XL"];
/** Tank tier dimensions */
export const TANK_TIER_DIMS: { w: number; h: number; d: number; camZ: number; cap: number }[] = [
{ w: 4, h: 2.5, d: 3, camZ: 6, cap: 12 },
{ w: 6, h: 3.5, d: 4, camZ: 8, cap: 20 },
{ w: 8, h: 4.5, d: 5, camZ: 10, cap: 30 },
{ w: 10, h: 5, d: 6, camZ: 12, cap: 45 },
];
/** Food prices */
export const FOOD_PRICES: Record<string, number> = {
basic: 10,
premium: 30,
special: 75,
};
/** Decor prices */
export const DECOR_PRICES: Record<string, number> = {
plant0: 25,
plant1: 40,
rock0: 15,
rock1: 20,
rock2: 20,
};
/**
* Calculate sell value of a fish.
* Formula: basePrice * 0.6 * sizeMult * rarityMult * ageMult
*/
export function getFishSellValue(fish: FishData): number {
const base = FISH_PRICES[fish.species] || 50;
const ageMult = getAgeMultiplier(fish.age);
const value = base * 0.6 * fish.genetics.sizeMult * RARITY_MULT[fish.genetics.rarity] * ageMult;
return Math.round(value);
}
/** Age-based value multiplier */
function getAgeMultiplier(ageHours: number): number {
if (ageHours < 24) return 0.5;
if (ageHours < 72) return 0.8;
return 1.0;
}
/** Get the display value breakdown for UI */
export function getFishValueBreakdown(fish: FishData): { label: string; value: number }[] {
const base = FISH_PRICES[fish.species] || 50;
return [
{ label: `Base (${fish.species})`, value: base },
{ label: `Size (${fish.genetics.sizeMult.toFixed(2)}x)`, value: Math.round(base * 0.6 * fish.genetics.sizeMult) },
{ label: `Rarity (${fish.genetics.rarity}, ${RARITY_MULT[fish.genetics.rarity]}x)`, value: Math.round(base * 0.6 * fish.genetics.sizeMult * RARITY_MULT[fish.genetics.rarity]) },
{ label: "Total", value: getFishSellValue(fish) },
];
}
/** Species rarity distributions for shop rolls */
export const SPECIES_RARITY_DIST: Record<FishSpecies, Record<Rarity, number>> = {
angelfish: { common: 60, uncommon: 25, rare: 10, epic: 4, legendary: 1 },
goldfish: { common: 65, uncommon: 20, rare: 9, epic: 5, legendary: 1 },
guppy: { common: 70, uncommon: 18, rare: 7, epic: 4, legendary: 1 },
fish1: { common: 75, uncommon: 15, rare: 6, epic: 3, legendary: 1 },
fish2: { common: 65, uncommon: 20, rare: 10, epic: 4, legendary: 1 },
fish3: { common: 68, uncommon: 18, rare: 9, epic: 4, legendary: 1 },
};
/** Available species in shop */
export const SHOP_SPECIES: FishSpecies[] = ["angelfish", "goldfish", "guppy", "fish1", "fish2", "fish3"];
/** Species display names */
export const SPECIES_NAMES: Record<FishSpecies, string> = {
angelfish: "Angelfish",
goldfish: "Goldfish",
guppy: "Guppy",
fish1: "Cute Fish I",
fish2: "Cute Fish II",
fish3: "Cute Fish III",
};
+99
View File
@@ -0,0 +1,99 @@
import type { Genetics, Rarity, Pattern, Gender } from "./types";
import { RARITY_ORDER } from "./types";
const PATTERNS: Pattern[] = ["solid", "striped", "spotted", "gradient"];
function rand(min: number, max: number): number {
return min + Math.random() * (max - min);
}
function randInt(min: number, max: number): number {
return Math.floor(rand(min, max + 1));
}
function pick<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
/** Generate random genetics for a newly purchased fish (weighted by species rarity distribution) */
export function generateGenetics(
rarityDist: Record<Rarity, number>
): Genetics {
const rarity = rollRarity(rarityDist);
return {
hueShift: rand(-30, 30),
saturation: rand(0.6, 1.2),
sizeMult: rand(0.8, 1.3),
speedMult: rand(0.7, 1.3),
pattern: pick(PATTERNS),
rarity,
gender: Math.random() < 0.5 ? "male" : "female",
};
}
/** Roll rarity from a distribution object (values are percentages) */
function rollRarity(dist: Record<Rarity, number>): Rarity {
const total = Object.values(dist).reduce((a, b) => a + b, 0);
let roll = Math.random() * total;
for (const r of RARITY_ORDER) {
roll -= dist[r] || 0;
if (roll <= 0) return r;
}
return "common";
}
/** Breed two parents: blend genetics + mutation */
export function breedGenetics(a: Genetics, b: Genetics): Genetics {
const mutate = () => (Math.random() - 0.5) * 0.1;
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));
// Blend continuous traits
const hueShift = clamp((a.hueShift + b.hueShift) / 2 + mutate() * 30, -30, 30);
const saturation = clamp((a.saturation + b.saturation) / 2 + mutate(), 0.6, 1.2);
const sizeMult = clamp((a.sizeMult + b.sizeMult) / 2 + mutate(), 0.8, 1.3);
const speedMult = clamp((a.speedMult + b.speedMult) / 2 + mutate(), 0.7, 1.3);
// Pattern: 50/50 from parents, 5% mutation
let pattern: Pattern;
if (Math.random() < 0.05) {
pattern = pick(PATTERNS.filter((p) => p !== a.pattern));
} else {
pattern = Math.random() < 0.5 ? a.pattern : b.pattern;
}
// Rarity: max of parents, 10% chance to bump up 1 tier, 5% chance to drop 1
const maxIdx = Math.max(
RARITY_ORDER.indexOf(a.rarity),
RARITY_ORDER.indexOf(b.rarity)
);
let rarityIdx = maxIdx;
const roll = Math.random();
if (roll < 0.10 && maxIdx < 4) {
rarityIdx = maxIdx + 1; // bump up
} else if (roll < 0.15 && maxIdx > 0) {
rarityIdx = maxIdx - 1; // drop down
}
const rarity = RARITY_ORDER[rarityIdx];
const gender: Gender = Math.random() < 0.5 ? "male" : "female";
return { hueShift, saturation, sizeMult, speedMult, pattern, rarity, gender };
}
/** Generate a display name from genetics */
export function generateFishName(species: string, g: Genetics): string {
const colorName = hueToColorName(g.hueShift);
const patternLabel = g.pattern.charAt(0).toUpperCase() + g.pattern.slice(1);
const speciesLabel = species.charAt(0).toUpperCase() + species.slice(1);
return `${patternLabel} ${colorName} ${speciesLabel}`;
}
function hueToColorName(hueShift: number): string {
// Base hue for each species is 0 (we use hueShift as offset from a base)
// For simplicity, map shift ranges to color names
if (hueShift < -20) return "Cool";
if (hueShift < -8) return "Blue";
if (hueShift < 8) return "Golden";
if (hueShift < 20) return "Warm";
return "Fiery";
}
+87
View File
@@ -0,0 +1,87 @@
import type { GameState, FishData, FishSpecies } from "./types";
const STORAGE_KEY = "aquarium-game-state";
const VERSION = 1;
/** Default starting state: 200 credits, 6 starter fish, small tank */
export function createDefaultState(): GameState {
const now = Date.now();
const starters: FishData[] = [
makeFish("angelfish", 0.55),
makeFish("angelfish", 0.50),
makeFish("goldfish", 0.45),
makeFish("goldfish", 0.40),
makeFish("guppy", 0.40),
makeFish("guppy", 0.35),
];
return {
version: VERSION,
money: 200,
fish: starters,
tankTier: 0,
decor: [],
createdAt: now,
lastSaved: now,
};
}
function makeFish(species: FishSpecies, sizeMult: number): FishData {
// All starters are common, with random other traits
const hueShift = Math.random() * 40 - 20;
const saturation = 0.7 + Math.random() * 0.4;
const speedMult = 0.8 + Math.random() * 0.4;
const patterns = ["solid", "striped", "spotted", "gradient"] as const;
const pattern = patterns[Math.floor(Math.random() * patterns.length)];
const gender = Math.random() < 0.5 ? "male" as const : "female" as const;
const colorName = hueShift < -10 ? "Cool" : hueShift < 5 ? "Golden" : "Warm";
const pLabel = pattern.charAt(0).toUpperCase() + pattern.slice(1);
const sLabel = species.charAt(0).toUpperCase() + species.slice(1);
return {
id: `fish_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
species,
name: `${pLabel} ${colorName} ${sLabel}`,
genetics: {
hueShift,
saturation,
sizeMult,
speedMult,
pattern,
rarity: "common",
gender,
},
age: 200, // start as adults
x: 0,
y: 1,
z: 0,
};
}
export function saveGame(state: GameState): void {
state.lastSaved = Date.now();
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
export function loadGame(): GameState | null {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as GameState;
if (parsed.version !== VERSION) {
// Migration needed — for now just return default
return null;
}
return parsed;
} catch {
return null;
}
}
export function resetGame(): void {
localStorage.removeItem(STORAGE_KEY);
}
export function hasSavedGame(): boolean {
return localStorage.getItem(STORAGE_KEY) !== null;
}
+85
View File
@@ -0,0 +1,85 @@
import type { GameState, FishData, Rarity } from "./types";
import { RARITY_MULT } from "./types";
import { getFishSellValue, TANK_TIER_NAMES, TANK_CAPACITY, TANK_TIER_COSTS } from "./economy";
/**
* Reactive store shared between game logic and Alpine UI.
* Alpine reads from this; game code writes to it.
*/
export interface UIState {
// Economy
money: number;
// Fish
fishCount: number;
fishCapacity: number;
selectedFish: FishData | null;
// Tank
tankTier: number;
tankName: string;
nextTierCost: number | null;
// Modals
showSettings: boolean;
showShop: boolean;
showBreedingSelect: boolean;
showResetConfirm: boolean;
showSellConfirm: boolean;
resetConfirmText: string;
// Notifications
toasts: { id: number; text: string; type: "info" | "success" | "warning" | "rare" }[];
// Breeding
breedingFrom: FishData | null;
}
let _state: UIState;
let _toasts: { id: number; text: string; type: "info" | "success" | "warning" | "rare" }[] = [];
let _toastId = 0;
export function initUIState(): UIState {
_state = {
money: 0,
fishCount: 0,
fishCapacity: 12,
selectedFish: null,
tankTier: 0,
tankName: "Small",
nextTierCost: 500,
showSettings: false,
showShop: false,
showBreedingSelect: false,
showResetConfirm: false,
showSellConfirm: false,
resetConfirmText: "",
toasts: [],
breedingFrom: null,
};
return _state;
}
export function getUIState(): UIState {
return _state;
}
/** Update the store from game state */
export function syncFromGameState(gs: GameState): void {
_state.money = gs.money;
_state.fishCount = gs.fish.length;
_state.tankTier = gs.tankTier;
_state.tankName = TANK_TIER_NAMES[gs.tankTier];
_state.fishCapacity = TANK_CAPACITY[gs.tankTier];
_state.nextTierCost = gs.tankTier < 3 ? TANK_TIER_COSTS[gs.tankTier + 1] : null;
}
/** Add a toast notification */
export function pushToast(text: string, type: "info" | "success" | "warning" | "rare" = "info"): void {
const id = ++_toastId;
_state.toasts.push({ id, text, type });
// Auto-remove after 3s
setTimeout(() => {
_state.toasts = _state.toasts.filter((t) => t.id !== id);
}, 3000);
}
/** Format money for display */
export function formatMoney(n: number): string {
return "$" + n.toLocaleString();
}
+70
View File
@@ -0,0 +1,70 @@
export type Rarity = "common" | "uncommon" | "rare" | "epic" | "legendary";
export type Pattern = "solid" | "striped" | "spotted" | "gradient";
export type Gender = "male" | "female";
export type FishSpecies = "angelfish" | "goldfish" | "guppy" | "fish1" | "fish2" | "fish3";
export interface Genetics {
hueShift: number; // -30 to +30
saturation: number; // 0.6 to 1.2
sizeMult: number; // 0.8 to 1.3
speedMult: number; // 0.7 to 1.3
pattern: Pattern;
rarity: Rarity;
gender: Gender;
}
export interface FishData {
id: string;
species: FishSpecies;
name: string;
genetics: Genetics;
age: number; // in game hours
x: number;
y: number;
z: number;
}
export interface DecorItem {
id: string;
url: string;
x: number;
z: number;
size: number;
rotationY: number;
}
export interface GameState {
version: number;
money: number;
fish: FishData[];
tankTier: number; // 0=small, 1=medium, 2=large, 3=xl
decor: DecorItem[];
createdAt: number;
lastSaved: number;
}
export const RARITY_MULT: Record<Rarity, number> = {
common: 1.0,
uncommon: 1.5,
rare: 2.0,
epic: 3.0,
legendary: 5.0,
};
export const RARITY_ORDER: Rarity[] = ["common", "uncommon", "rare", "epic", "legendary"];
export const RARITY_COLOR: Record<Rarity, string> = {
common: "text-gray-400",
uncommon: "text-green-400",
rare: "text-blue-400",
epic: "text-purple-400",
legendary: "text-yellow-400",
};
export const RARITY_BG: Record<Rarity, string> = {
common: "bg-gray-700",
uncommon: "bg-green-800",
rare: "bg-blue-800",
epic: "bg-purple-800",
legendary: "bg-yellow-800",
};
+207 -46
View File
@@ -1,3 +1,4 @@
import "./style.css";
import * as THREE from "three"; import * as THREE from "three";
import { createTank } from "./scene/Tank"; import { createTank } from "./scene/Tank";
import { createWater } from "./scene/Water"; import { createWater } from "./scene/Water";
@@ -7,56 +8,69 @@ import { createDecor, type DecorSpec } from "./scene/Decor";
import { createParallaxCamera } from "./scene/Camera"; import { createParallaxCamera } from "./scene/Camera";
import { createBubbles } from "./scene/Bubbles"; import { createBubbles } from "./scene/Bubbles";
import { createFood } from "./scene/Food"; 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 { generateGenetics, generateFishName, breedGenetics } from "./game/genetics";
// --- Game State ---
let gameState: GameState = loadGame() || createDefaultState();
saveGame(gameState);
// --- Canvas & Renderer ---
const canvas = document.querySelector<HTMLCanvasElement>("#aquarium"); const canvas = document.querySelector<HTMLCanvasElement>("#aquarium");
if (!canvas) { if (!canvas) throw new Error('Canvas "aquarium" not found');
throw new Error('Canvas element with id "aquarium" not found');
}
// Renderer
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(window.devicePixelRatio); renderer.setPixelRatio(window.devicePixelRatio);
// Scene // --- Scene ---
const scene = new THREE.Scene(); const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a0f); scene.background = new THREE.Color(0x0a0a0f);
// Camera — frames the tank, positioned slightly above its center // --- Camera ---
const camera = new THREE.PerspectiveCamera( const tankDim = TANK_TIER_DIMS[gameState.tankTier];
50, const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 100);
window.innerWidth / window.innerHeight, camera.position.set(0, tankDim.h / 2, tankDim.camZ);
0.1, camera.lookAt(0, tankDim.h * 0.5, 0);
100
);
camera.position.set(0, 1.5, 6);
camera.lookAt(0, 1.2, 0);
// Scene content // --- Scene Content ---
setupLighting(scene, renderer); setupLighting(scene, renderer);
createTank(scene); createTank(scene);
const water = createWater(scene); const water = createWater(scene);
// Food — pellets that drop from above; fish seek and eat them.
const food = createFood(scene); const food = createFood(scene);
// Fish — GLB models with swim animations + wandering AI + food seeking. // --- Fish specs from game state ---
const fishSpecs: FishSpec[] = [ function isQuaternius(sp: FishSpecies): boolean {
// Tropical (maya's-aquarium-3d) return sp === "fish1" || sp === "fish2" || sp === "fish3";
{ 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, food);
// Decor — static plants and rocks on the gravel floor (y ~ 0.05). function getSpeciesUrl(sp: FishSpecies): string {
const urls: Record<FishSpecies, string> = {
angelfish: "models/angelfish.glb",
goldfish: "models/goldfish.glb",
guppy: "models/guppy.glb",
fish1: "models/quaternius/glb/Fish1.glb",
fish2: "models/quaternius/glb/Fish2.glb",
fish3: "models/quaternius/glb/Fish3.glb",
};
return urls[sp];
}
function getFishSpecs(): FishSpec[] {
return gameState.fish.map((f) => ({
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,
}));
}
const fishSchool = createGLTFishSchool(scene, getFishSpecs(), food);
// --- Decor ---
const decorSpecs: DecorSpec[] = [ const decorSpecs: DecorSpec[] = [
{ url: "models/plant0.glb", x: -1.4, z: -0.9, targetSize: 1.4, rotationY: 0.4 }, { 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/plant1.glb", x: 1.3, z: -0.8, targetSize: 1.1, rotationY: -0.3 },
@@ -66,36 +80,183 @@ const decorSpecs: DecorSpec[] = [
]; ];
createDecor(scene, decorSpecs); createDecor(scene, decorSpecs);
// Bubbles — rising from air stones near the plants.
const bubbles = createBubbles(scene); const bubbles = createBubbles(scene);
const parallax = createParallaxCamera(camera); const parallax = createParallaxCamera(camera);
// Mouse tracking (normalized to -1..1) // --- Mouse / Raycaster ---
let mouseX = 0, mouseY = 0; let mouseX = 0, mouseY = 0;
const raycaster = new THREE.Raycaster();
const mouseNDC = new THREE.Vector2();
window.addEventListener("mousemove", (e) => { window.addEventListener("mousemove", (e) => {
mouseX = (e.clientX / window.innerWidth) * 2 - 1; mouseX = (e.clientX / window.innerWidth) * 2 - 1;
mouseY = (e.clientY / window.innerHeight) * 2 - 1; mouseY = (e.clientY / window.innerHeight) * 2 - 1;
}); });
// Click to drop food. window.addEventListener("click", (e) => {
window.addEventListener("click", () => { // 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);
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;
}
}
}
// No fish clicked — drop food
food.dropFood(4); food.dropFood(4);
}); });
// Resize handling // --- Cooldown tracking ---
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];
if (gameState.money < price) return;
if (gameState.fish.length >= TANK_TIER_DIMS[gameState.tankTier].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);
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) => {
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);
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");
return;
}
if (fishA.age < 72 || fishB.age < 72) {
pushToast("Fish must be adults to breed!", "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,
x: 0, y: 1, z: 0,
};
gameState.fish.push(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: () => {
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);
const names = ["Small", "Medium", "Large", "XL"];
pushToast(`🔬 Tank upgraded to ${names[nextTier]}!`, "success");
console.log("[GAME] Tank upgraded to tier", nextTier);
},
onReset: () => {
resetGame();
window.location.reload();
},
});
// --- Start Alpine & sync ---
startAlpine();
syncFromGameState(gameState);
// --- Resize ---
function resize(): void { function resize(): void {
const width = window.innerWidth; const w = window.innerWidth;
const height = window.innerHeight; const h = window.innerHeight;
camera.aspect = width / height; camera.aspect = w / h;
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
renderer.setPixelRatio(window.devicePixelRatio); renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(width, height); renderer.setSize(w, h);
} }
resize(); resize();
window.addEventListener("resize", resize); window.addEventListener("resize", resize);
// Render loop // --- Auto-save ---
setInterval(() => {
saveGame(gameState);
}, 30000);
// --- Render Loop ---
const startTime = performance.now(); const startTime = performance.now();
let lastFrameTime = performance.now(); let lastFrameTime = performance.now();
function animate(): void { function animate(): void {
+28
View File
@@ -0,0 +1,28 @@
@import "tailwindcss";
@theme {
--color-aqua-900: #0a1628;
--color-aqua-800: #0f2035;
--color-aqua-700: #14304a;
--color-aqua-600: #1a4060;
--color-aqua-500: #2060a0;
--color-aqua-400: #3090d0;
--color-aqua-300: #50b0e0;
--color-aqua-200: #80d0f0;
--color-aqua-100: #b0e8ff;
--color-glow-gold: #ffd700;
--color-glow-purple: #bb44ff;
--color-glow-blue: #4488ff;
--color-glow-green: #44dd88;
--color-glow-grey: #888899;
}
body {
margin: 0;
overflow: hidden;
background: #0a0a0f;
}
[x-cloak] {
display: none !important;
}
+15
View File
@@ -0,0 +1,15 @@
declare module "alpinejs" {
interface AlpineData {
data(key: string, value: () => any): void;
store(name: string, value: Record<string, any>): void;
start(): void;
addDirective(name: string, handler: (...args: any[]) => void): void;
magic(name: string, handler: (...args: any[]) => any): void;
}
const Alpine: AlpineData & {
data: (key: string, value: () => any) => void;
store: (name: string, value: Record<string, any>) => void;
start: () => void;
};
export default Alpine;
}
+171
View File
@@ -0,0 +1,171 @@
import Alpine from "alpinejs";
import type { GameState, FishData, FishSpecies } from "./game/types";
import {
initUIState, getUIState, syncFromGameState, pushToast, formatMoney,
} from "./game/store";
import {
FISH_PRICES, SPECIES_NAMES, SPECIES_RARITY_DIST, SHOP_SPECIES,
TANK_TIER_NAMES, TANK_TIER_COSTS, TANK_CAPACITY,
getFishSellValue,
} from "./game/economy";
import { generateGenetics, generateFishName, breedGenetics } from "./game/genetics";
// --- Game action callbacks (set by main.ts) ---
let _onBuyFish: (species: FishSpecies, genetics: import("./game/types").Genetics) => void = () => {};
let _onSellFish: (fishId: string) => void = () => {};
let _onBreed: (fishA: FishData, fishB: FishData) => void = () => {};
let _onUpgradeTank: () => void = () => {};
let _onReset: () => void = () => {};
let _getState: () => GameState | null = () => null;
let _getFishById: (id: string) => FishData | null = () => null;
let _isAdult: (fish: FishData) => boolean = () => false;
let _inCooldown: (fishId: string) => boolean = () => false;
export function setGameActions(actions: {
onBuyFish?: typeof _onBuyFish;
onSellFish?: typeof _onSellFish;
onBreed?: typeof _onBreed;
onUpgradeTank?: typeof _onUpgradeTank;
onReset?: typeof _onReset;
getState?: typeof _getState;
getFishById?: typeof _getFishById;
isAdult?: typeof _isAdult;
inCooldown?: typeof _inCooldown;
}): void {
if (actions.onBuyFish) _onBuyFish = actions.onBuyFish;
if (actions.onSellFish) _onSellFish = actions.onSellFish;
if (actions.onBreed) _onBreed = actions.onBreed;
if (actions.onUpgradeTank) _onUpgradeTank = actions.onUpgradeTank;
if (actions.onReset) _onReset = actions.onReset;
if (actions.getState) _getState = actions.getState;
if (actions.getFishById) _getFishById = actions.getFishById;
if (actions.isAdult) _isAdult = actions.isAdult;
if (actions.inCooldown) _inCooldown = actions.inCooldown;
}
// --- Alpine Component ---
function gameUI() {
return {
store: initUIState(),
init() {
// Periodic sync from game state
setInterval(() => {
const gs = _getState();
if (gs) syncFromGameState(gs);
}, 1000);
},
// Helpers
formatMoney,
fishPrices: FISH_PRICES,
speciesNames: SPECIES_NAMES,
shopSpecies: SHOP_SPECIES,
rarityMult(r: string): string {
const mults: Record<string, number> = { common: 1, uncommon: 1.5, rare: 2, epic: 3, legendary: 5 };
return (mults[r] || 1) + "x";
},
rarityBg(r: string): string {
const bgs: Record<string, string> = {
common: "bg-gray-700 text-gray-300",
uncommon: "bg-green-900 text-green-300",
rare: "bg-blue-900 text-blue-300",
epic: "bg-purple-900 text-purple-300",
legendary: "bg-yellow-900 text-yellow-300",
};
return bgs[r] || bgs.common;
},
rarityOdds(sp: FishSpecies): string {
const dist = SPECIES_RARITY_DIST[sp];
const parts: string[] = [];
for (const [r, pct] of Object.entries(dist)) {
if (pct > 0) parts.push(`${pct}% <span class="capitalize">${r}</span>`);
}
return parts.join(" / ");
},
formatAge(hours: number): string {
if (hours < 1) return "Newborn";
if (hours < 24) return `${Math.floor(hours)}h (Baby)`;
if (hours < 72) return `${Math.floor(hours / 24)}d (Juvenile)`;
return `${Math.floor(hours / 24)}d (Adult)`;
},
getFishValue(fish: FishData): number {
return getFishSellValue(fish);
},
nextTierName(): string {
const tier = this.store.tankTier;
return tier < 3 ? TANK_TIER_NAMES[tier + 1] : "MAX";
},
canBreed(fish: FishData | null): boolean {
if (!fish) return false;
if (!_isAdult(fish)) return false;
if (_inCooldown(fish.id)) return false;
return true;
},
// Actions
requestSell() {
const fish = this.store.selectedFish;
if (!fish) return;
this.store.showSellConfirm = true;
},
confirmSell() {
const fish = this.store.selectedFish;
if (!fish) return;
this.store.showSellConfirm = false;
_onSellFish(fish.id);
this.store.selectedFish = null;
},
requestBreed() {
const fish = this.store.selectedFish;
if (!fish) return;
this.store.breedingFrom = fish;
this.store.showBreedingSelect = true;
},
confirmBreed(partner: FishData) {
const from = this.store.breedingFrom;
if (!from) return;
this.store.showBreedingSelect = false;
this.store.breedingFrom = null;
_onBreed(from, partner);
},
buyFish(species: FishSpecies) {
const price = FISH_PRICES[species];
if (this.store.money < price) return;
if (this.store.fishCount >= this.store.fishCapacity) return;
const genetics = generateGenetics(SPECIES_RARITY_DIST[species]);
_onBuyFish(species, genetics);
},
upgradeTank() {
_onUpgradeTank();
},
confirmReset() {
if (this.store.resetConfirmText !== "DELETE") return;
this.store.showResetConfirm = false;
this.store.resetConfirmText = "";
_onReset();
},
};
}
// Register Alpine
document.addEventListener("alpine:init", () => {
Alpine.data("gameUI", gameUI);
});
export function startAlpine() {
Alpine.start();
}
+2
View File
@@ -1,6 +1,8 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss()],
build: { build: {
outDir: "dist" outDir: "dist"
} }