diff --git a/index.html b/index.html
index 00dcfeb..f17522d 100644
--- a/index.html
+++ b/index.html
@@ -1,24 +1,192 @@
-
-
-
- Aquarium
-
-
-
-
-
-
+
+
+
+ Aquarium
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Gender
+
+
Size
+
+
Speed
+
+
Pattern
+
+
Age
+
+
+
+
+ Sell value:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🐟 Fish Shop
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
⚙️ Settings
+
+
+
+
+
+ Tank is at MAX size 🎉
+
+
+
+
+
+
+
+
+
+
⚠️ Reset Game
+
This will permanently delete ALL your fish, money, and progress.
+
Type DELETE to confirm:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index 439fb09..8e73896 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index d11d8fd..0a958a2 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
diff --git a/src/game/economy.ts b/src/game/economy.ts
new file mode 100644
index 0000000..cdf7a2e
--- /dev/null
+++ b/src/game/economy.ts
@@ -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 = {
+ 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 = {
+ basic: 10,
+ premium: 30,
+ special: 75,
+};
+
+/** Decor prices */
+export const DECOR_PRICES: Record = {
+ 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> = {
+ 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 = {
+ angelfish: "Angelfish",
+ goldfish: "Goldfish",
+ guppy: "Guppy",
+ fish1: "Cute Fish I",
+ fish2: "Cute Fish II",
+ fish3: "Cute Fish III",
+};
diff --git a/src/game/genetics.ts b/src/game/genetics.ts
new file mode 100644
index 0000000..1b30d25
--- /dev/null
+++ b/src/game/genetics.ts
@@ -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(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
+): 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 {
+ 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";
+}
diff --git a/src/game/state.ts b/src/game/state.ts
new file mode 100644
index 0000000..68f3346
--- /dev/null
+++ b/src/game/state.ts
@@ -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;
+}
diff --git a/src/game/store.ts b/src/game/store.ts
new file mode 100644
index 0000000..f6c1a3c
--- /dev/null
+++ b/src/game/store.ts
@@ -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();
+}
diff --git a/src/game/types.ts b/src/game/types.ts
new file mode 100644
index 0000000..22d1159
--- /dev/null
+++ b/src/game/types.ts
@@ -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 = {
+ 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 = {
+ 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 = {
+ common: "bg-gray-700",
+ uncommon: "bg-green-800",
+ rare: "bg-blue-800",
+ epic: "bg-purple-800",
+ legendary: "bg-yellow-800",
+};
diff --git a/src/main.ts b/src/main.ts
index 5fb9e4e..d5024c0 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -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("#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 = {
+ 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 = {};
+
+// --- 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 {
diff --git a/src/style.css b/src/style.css
new file mode 100644
index 0000000..8080a63
--- /dev/null
+++ b/src/style.css
@@ -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;
+}
diff --git a/src/types/alpinejs.d.ts b/src/types/alpinejs.d.ts
new file mode 100644
index 0000000..b431ca1
--- /dev/null
+++ b/src/types/alpinejs.d.ts
@@ -0,0 +1,15 @@
+declare module "alpinejs" {
+ interface AlpineData {
+ data(key: string, value: () => any): void;
+ store(name: string, value: Record): 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) => void;
+ start: () => void;
+ };
+ export default Alpine;
+}
diff --git a/src/ui.ts b/src/ui.ts
new file mode 100644
index 0000000..881a0d5
--- /dev/null
+++ b/src/ui.ts
@@ -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 = { common: 1, uncommon: 1.5, rare: 2, epic: 3, legendary: 5 };
+ return (mults[r] || 1) + "x";
+ },
+
+ rarityBg(r: string): string {
+ const bgs: Record = {
+ 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}% ${r}`);
+ }
+ 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();
+}
diff --git a/vite.config.ts b/vite.config.ts
index eec3e21..3992793 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,6 +1,8 @@
import { defineConfig } from "vite";
+import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
+ plugins: [tailwindcss()],
build: {
outDir: "dist"
}