From eb50f3b56b6f888554aec359bac058ad5e6e690a Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Wed, 7 Oct 2026 12:38:18 +0000 Subject: [PATCH] feat: game state, persistence, UI framework (Alpine+Tailwind), economy system (#10, #11, #12) - Game state with localStorage save/load + reset (type DELETE to confirm) - Alpine.js + Tailwind CSS + Lucide UI overlay (HUD bar, shop modal, settings, toasts) - Economy: fish prices, tank tier costs/capacities, rarity multipliers, sell value formula - Genetic system: hue/saturation/size/speed/pattern/rarity/gender + breeding inheritance - Fish selection via raycaster (click fish = select, click empty = drop food) - Buy/Sell/Breed/Upgrade actions wired to game state --- index.html | 210 +++++++++++-- package-lock.json | 682 ++++++++++++++++++++++++++++++++++++++++ package.json | 3 + src/game/economy.ts | 97 ++++++ src/game/genetics.ts | 99 ++++++ src/game/state.ts | 87 +++++ src/game/store.ts | 85 +++++ src/game/types.ts | 70 +++++ src/main.ts | 253 ++++++++++++--- src/style.css | 28 ++ src/types/alpinejs.d.ts | 15 + src/ui.ts | 171 ++++++++++ vite.config.ts | 2 + 13 files changed, 1735 insertions(+), 67 deletions(-) create mode 100644 src/game/economy.ts create mode 100644 src/game/genetics.ts create mode 100644 src/game/state.ts create mode 100644 src/game/store.ts create mode 100644 src/game/types.ts create mode 100644 src/style.css create mode 100644 src/types/alpinejs.d.ts create mode 100644 src/ui.ts diff --git a/index.html b/index.html index 00dcfeb..f17522d 100644 --- a/index.html +++ b/index.html @@ -1,24 +1,192 @@ - - - - Aquarium - - - - - - + + + + Aquarium + + + + + +
+ + +
+ +
+
+ $ + +
+
+ + +
+
+ +
+ + + +
+
+ + + + + + + + + + + + + + +
+ +
+ +
+ + + 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" }