- 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:
+182
-14
@@ -4,21 +4,189 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Aquarium</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: #0a0a0f;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="aquarium"></canvas>
|
||||
<body class="bg-aqua-900">
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+682
@@ -8,10 +8,13 @@
|
||||
"name": "aquarium",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"alpinejs": "^3.17.4",
|
||||
"three": "^0.182.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/three": "^0.182.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
@@ -465,6 +468,56 @@
|
||||
"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": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||
@@ -832,6 +885,278 @@
|
||||
"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": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
@@ -876,6 +1201,21 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.1.74",
|
||||
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz",
|
||||
@@ -883,6 +1223,39 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.28.2",
|
||||
"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_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": {
|
||||
"version": "0.22.0",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
|
||||
@@ -1096,6 +1757,27 @@
|
||||
"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": {
|
||||
"version": "0.182.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
|
||||
|
||||
@@ -9,10 +9,13 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"alpinejs": "^3.17.4",
|
||||
"three": "^0.182.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/three": "^0.182.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -1,3 +1,4 @@
|
||||
import "./style.css";
|
||||
import * as THREE from "three";
|
||||
import { createTank } from "./scene/Tank";
|
||||
import { createWater } from "./scene/Water";
|
||||
@@ -7,56 +8,69 @@ 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 { 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");
|
||||
if (!canvas) {
|
||||
throw new Error('Canvas element with id "aquarium" not found');
|
||||
}
|
||||
|
||||
// Renderer
|
||||
if (!canvas) throw new Error('Canvas "aquarium" not found');
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
|
||||
// Scene
|
||||
// --- 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);
|
||||
// --- 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
|
||||
// --- Scene Content ---
|
||||
setupLighting(scene, renderer);
|
||||
createTank(scene);
|
||||
const water = createWater(scene);
|
||||
|
||||
// Food — pellets that drop from above; fish seek and eat them.
|
||||
const food = createFood(scene);
|
||||
|
||||
// Fish — GLB models with swim animations + wandering AI + food seeking.
|
||||
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, food);
|
||||
// --- Fish specs from game state ---
|
||||
function isQuaternius(sp: FishSpecies): boolean {
|
||||
return sp === "fish1" || sp === "fish2" || sp === "fish3";
|
||||
}
|
||||
|
||||
// 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[] = [
|
||||
{ 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 },
|
||||
@@ -66,36 +80,183 @@ const decorSpecs: DecorSpec[] = [
|
||||
];
|
||||
createDecor(scene, decorSpecs);
|
||||
|
||||
// Bubbles — rising from air stones near the plants.
|
||||
const bubbles = createBubbles(scene);
|
||||
|
||||
const parallax = createParallaxCamera(camera);
|
||||
|
||||
// Mouse tracking (normalized to -1..1)
|
||||
// --- 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;
|
||||
});
|
||||
|
||||
// Click to drop food.
|
||||
window.addEventListener("click", () => {
|
||||
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);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// 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 {
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
camera.aspect = width / height;
|
||||
const w = window.innerWidth;
|
||||
const h = window.innerHeight;
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
renderer.setSize(width, height);
|
||||
renderer.setSize(w, h);
|
||||
}
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
// Render loop
|
||||
// --- Auto-save ---
|
||||
setInterval(() => {
|
||||
saveGame(gameState);
|
||||
}, 30000);
|
||||
|
||||
// --- Render Loop ---
|
||||
const startTime = performance.now();
|
||||
let lastFrameTime = performance.now();
|
||||
function animate(): void {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+15
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { defineConfig } from "vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss()],
|
||||
build: {
|
||||
outDir: "dist"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user