From fe389a9856ebb8137088b5f9760517093cd5f5f2 Mon Sep 17 00:00:00 2001 From: tekki mariani Date: Fri, 20 Mar 2026 08:11:31 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20initial=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + CHANGELOG.md | 19 + CLAUDE.md | 64 +++ index.html | 16 + package-lock.json | 908 ++++++++++++++++++++++++++++++++++ package.json | 19 + src/NetworkAdapter.ts | 19 + src/StateManager.ts | 193 ++++++++ src/config.ts | 50 ++ src/main.ts | 30 ++ src/scenes/BootScene.ts | 365 ++++++++++++++ src/scenes/GameScene.ts | 130 +++++ src/scenes/UIScene.ts | 283 +++++++++++ src/systems/BuildingSystem.ts | 182 +++++++ src/systems/CameraSystem.ts | 105 ++++ src/systems/FarmingSystem.ts | 205 ++++++++ src/systems/PlayerSystem.ts | 120 +++++ src/systems/ResourceSystem.ts | 90 ++++ src/systems/VillagerSystem.ts | 393 +++++++++++++++ src/systems/WorldSystem.ts | 128 +++++ src/types.ts | 125 +++++ src/utils/noise.ts | 77 +++ src/utils/pathfinding.ts | 60 +++ tsconfig.json | 26 + vite.config.ts | 12 + 25 files changed, 3621 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/NetworkAdapter.ts create mode 100644 src/StateManager.ts create mode 100644 src/config.ts create mode 100644 src/main.ts create mode 100644 src/scenes/BootScene.ts create mode 100644 src/scenes/GameScene.ts create mode 100644 src/scenes/UIScene.ts create mode 100644 src/systems/BuildingSystem.ts create mode 100644 src/systems/CameraSystem.ts create mode 100644 src/systems/FarmingSystem.ts create mode 100644 src/systems/PlayerSystem.ts create mode 100644 src/systems/ResourceSystem.ts create mode 100644 src/systems/VillagerSystem.ts create mode 100644 src/systems/WorldSystem.ts create mode 100644 src/types.ts create mode 100644 src/utils/noise.ts create mode 100644 src/utils/pathfinding.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f736a8b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +All notable changes to this project will be documented here. +Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +--- + +## [Unreleased] + +### Added +- Initial project setup: Phaser 3 + TypeScript + Vite +- Core scenes: `BootScene`, `GameScene`, `UIScene` +- Systems: `BuildingSystem`, `CameraSystem`, `FarmingSystem`, `PlayerSystem`, + `ResourceSystem`, `VillagerSystem`, `WorldSystem` +- Utilities: simplex-noise wrapper, pathfinding +- `StateManager` for central game state +- `NetworkAdapter` for multiplayer/sync layer + +--- diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e09a9e8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,64 @@ +# CLAUDE.md — Game Project + +## Project Overview + +A browser-based top-down game built with **Phaser 3** and **TypeScript**, bundled via **Vite**. +Genre: settlement/farming with villager AI, resource management, and procedural world generation. + +## Tech Stack + +| Tool | Version | Purpose | +|------|---------|---------| +| Phaser | ^3.90.0 | Game framework (scenes, rendering, input) | +| TypeScript | ~5.9.3 | Type safety | +| Vite | ^8.0.1 | Dev server & bundler | +| simplex-noise | ^4.0.3 | Procedural terrain generation | + +## Project Structure + +``` +src/ + main.ts # Entry point, Phaser game config + config.ts # Global game constants & config + types.ts # Shared TypeScript types/interfaces + StateManager.ts # Central game state + NetworkAdapter.ts # (Multiplayer/sync layer) + scenes/ + BootScene.ts # Asset preloading + GameScene.ts # Main game loop + UIScene.ts # HUD overlay + systems/ + BuildingSystem.ts + CameraSystem.ts + FarmingSystem.ts + PlayerSystem.ts + ResourceSystem.ts + VillagerSystem.ts + WorldSystem.ts + utils/ + noise.ts # Simplex noise helpers + pathfinding.ts # A* or similar pathfinding +``` + +## Dev Commands + +```bash +npm run dev # Start Vite dev server +npm run build # TypeScript check + production build +npm run preview # Preview production build locally +``` + +## Code Conventions + +- Every method/function gets a JSDoc comment (what it does, params, return) +- Comments in English +- Meaningful names — no abbreviations outside of obvious loop vars +- Systems are self-contained classes registered in GameScene +- Shared types live in `types.ts`; avoid inline type literals in system files + +## Architecture Notes + +- **StateManager** is the single source of truth for game state +- **Systems** read/write state and are updated each game tick via Phaser's `update()` +- **Scenes** are thin orchestrators — logic belongs in systems, not scenes +- **NetworkAdapter** wraps any multiplayer/sync concerns; systems should not call network directly diff --git a/index.html b/index.html new file mode 100644 index 0000000..7d81920 --- /dev/null +++ b/index.html @@ -0,0 +1,16 @@ + + + + + + Topdown Game + + + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c8dfa53 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,908 @@ +{ + "name": "game", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "game", + "version": "0.0.0", + "dependencies": { + "phaser": "^3.90.0", + "simplex-noise": "^4.0.3" + }, + "devDependencies": { + "typescript": "~5.9.3", + "vite": "^8.0.1" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", + "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", + "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz", + "integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz", + "integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz", + "integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz", + "integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz", + "integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz", + "integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", + "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", + "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/phaser": { + "version": "3.90.0", + "resolved": "https://registry.npmjs.org/phaser/-/phaser-3.90.0.tgz", + "integrity": "sha512-/cziz/5ZIn02uDkC9RzN8VF9x3Gs3XdFFf9nkiMEQT3p7hQlWuyjy4QWosU802qqno2YSLn2BfqwOKLv/sSVfQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz", + "integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.120.0", + "@rolldown/pluginutils": "1.0.0-rc.10" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.10", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", + "@rolldown/binding-darwin-x64": "1.0.0-rc.10", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" + } + }, + "node_modules/simplex-noise": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/simplex-noise/-/simplex-noise-4.0.3.tgz", + "integrity": "sha512-qSE2I4AngLQG7BXqoZj51jokT4WUXe8mOBrvfOXpci8+6Yu44+/dD5zqDpOx3Ux792eamTd2lLcI8jqFntk/lg==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", + "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.10", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f39ea6a --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "game", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "~5.9.3", + "vite": "^8.0.1" + }, + "dependencies": { + "phaser": "^3.90.0", + "simplex-noise": "^4.0.3" + } +} diff --git a/src/NetworkAdapter.ts b/src/NetworkAdapter.ts new file mode 100644 index 0000000..e1f376e --- /dev/null +++ b/src/NetworkAdapter.ts @@ -0,0 +1,19 @@ +import type { GameAction } from './types' +import { stateManager } from './StateManager' + +/** All state mutations go through an adapter. + * Swap LocalAdapter for WebSocketAdapter to enable multiplayer. */ +export interface NetworkAdapter { + send(action: GameAction): void + onAction?: (action: GameAction) => void +} + +/** Singleplayer: apply actions immediately and synchronously */ +export class LocalAdapter implements NetworkAdapter { + onAction?: (action: GameAction) => void + + send(action: GameAction): void { + stateManager.apply(action) + this.onAction?.(action) + } +} diff --git a/src/StateManager.ts b/src/StateManager.ts new file mode 100644 index 0000000..35211c3 --- /dev/null +++ b/src/StateManager.ts @@ -0,0 +1,193 @@ +import { SAVE_KEY, WORLD_TILES, CROP_CONFIGS } from './config' +import type { GameStateData, GameAction, PlayerState, WorldState, ItemId } from './types' + +const DEFAULT_PLAYER: PlayerState = { + id: 'player1', + x: 8192, y: 8192, + inventory: {}, // empty — seeds now in stockpile +} + +function makeEmptyWorld(seed: number): WorldState { + return { + seed, + tiles: new Array(WORLD_TILES * WORLD_TILES).fill(3), + resources: {}, + buildings: {}, + crops: {}, + villagers: {}, + stockpile: { wood: 10, stone: 5, wheat_seed: 10, carrot_seed: 5, wheat: 0, carrot: 0 }, + } +} + +function makeDefaultState(): GameStateData { + return { + version: 4, + world: makeEmptyWorld(Math.floor(Math.random() * 999999)), + player: { ...DEFAULT_PLAYER, inventory: { ...DEFAULT_PLAYER.inventory } }, + } +} + +class StateManager { + private state: GameStateData + + constructor() { + this.state = this.load() ?? makeDefaultState() + } + + getState(): Readonly { return this.state } + + apply(action: GameAction): void { + const s = this.state + const w = s.world + + switch (action.type) { + + case 'PLAYER_MOVE': + s.player.x = action.x; s.player.y = action.y; break + + case 'HARVEST_RESOURCE': { + const res = w.resources[action.resourceId] + if (!res) break + res.hp -= 1 + if (res.hp <= 0) delete w.resources[action.resourceId] + for (const [k, v] of Object.entries(action.rewards)) + w.stockpile[k as ItemId] = (w.stockpile[k as ItemId] ?? 0) + (v ?? 0) + break + } + + case 'CHANGE_TILE': + w.tiles[action.tileY * WORLD_TILES + action.tileX] = action.tile; break + + case 'PLACE_BUILDING': { + w.buildings[action.building.id] = action.building + for (const [k, v] of Object.entries(action.costs)) + w.stockpile[k as ItemId] = Math.max(0, (w.stockpile[k as ItemId] ?? 0) - (v ?? 0)) + break + } + + case 'REMOVE_BUILDING': + delete w.buildings[action.buildingId]; break + + case 'ADD_ITEMS': + for (const [k, v] of Object.entries(action.items)) + w.stockpile[k as ItemId] = (w.stockpile[k as ItemId] ?? 0) + (v ?? 0) + break + + case 'PLANT_CROP': { + w.crops[action.crop.id] = { ...action.crop } + const have = w.stockpile[action.seedItem] ?? 0 + w.stockpile[action.seedItem] = Math.max(0, have - 1) + break + } + + case 'WATER_CROP': { + const c = w.crops[action.cropId]; if (c) c.watered = true; break + } + + case 'HARVEST_CROP': { + delete w.crops[action.cropId] + for (const [k, v] of Object.entries(action.rewards)) + w.stockpile[k as ItemId] = (w.stockpile[k as ItemId] ?? 0) + (v ?? 0) + break + } + + // ── Villager actions ────────────────────────────────────────────────── + + case 'SPAWN_VILLAGER': + w.villagers[action.villager.id] = { ...action.villager }; break + + case 'VILLAGER_SET_JOB': { + const v = w.villagers[action.villagerId]; if (v) v.job = action.job; break + } + + case 'VILLAGER_SET_AI': { + const v = w.villagers[action.villagerId]; if (v) v.aiState = action.aiState; break + } + + case 'VILLAGER_HARVEST_RESOURCE': { + const v = w.villagers[action.villagerId] + const res = w.resources[action.resourceId] + if (!v || !res) break + delete w.resources[action.resourceId] + const reward = res.kind === 'tree' ? { wood: 2 } : { stone: 2 } + if (!v.job) break + for (const [k, qty] of Object.entries(reward)) { + v.job.carrying[k as ItemId] = (v.job.carrying[k as ItemId] ?? 0) + qty + } + break + } + + case 'VILLAGER_HARVEST_CROP': { + const v = w.villagers[action.villagerId] + const crop = w.crops[action.cropId] + if (!v || !crop) break + delete w.crops[action.cropId] + const cfg = CROP_CONFIGS[crop.kind] + if (!v.job) break + for (const [k, qty] of Object.entries(cfg.rewards)) { + v.job.carrying[k as ItemId] = (v.job.carrying[k as ItemId] ?? 0) + (qty ?? 0) + } + break + } + + case 'VILLAGER_DEPOSIT': { + const v = w.villagers[action.villagerId] + if (!v?.job?.carrying) break + for (const [k, qty] of Object.entries(v.job.carrying)) { + w.stockpile[k as ItemId] = (w.stockpile[k as ItemId] ?? 0) + (qty ?? 0) + } + v.job.carrying = {} + v.job = null + break + } + + case 'UPDATE_PRIORITIES': { + const v = w.villagers[action.villagerId] + if (v) v.priorities = { ...action.priorities } + break + } + } + } + + tickCrops(delta: number): string[] { + const advanced: string[] = [] + for (const crop of Object.values(this.state.world.crops)) { + if (crop.stage >= crop.maxStage) continue + crop.stageTimerMs -= delta * (crop.watered ? 2 : 1) + if (crop.stageTimerMs <= 0) { + crop.stage = Math.min(crop.stage + 1, crop.maxStage) + crop.stageTimerMs = CROP_CONFIGS[crop.kind].stageTimeMs + advanced.push(crop.id) + } + } + return advanced + } + + save(): void { + try { localStorage.setItem(SAVE_KEY, JSON.stringify(this.state)) } catch (_) {} + } + + private load(): GameStateData | null { + try { + const raw = localStorage.getItem(SAVE_KEY) + if (!raw) return null + const p = JSON.parse(raw) as GameStateData + if (p.version !== 4) return null + if (!p.world.crops) p.world.crops = {} + if (!p.world.villagers) p.world.villagers = {} + if (!p.world.stockpile) p.world.stockpile = {} + // Reset walking villagers to idle on load + for (const v of Object.values(p.world.villagers)) { + if (v.aiState === 'walking') v.aiState = 'idle' + } + return p + } catch (_) { return null } + } + + reset(): void { + localStorage.removeItem(SAVE_KEY) + this.state = makeDefaultState() + } +} + +export const stateManager = new StateManager() diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..d37c30d --- /dev/null +++ b/src/config.ts @@ -0,0 +1,50 @@ +import type { CropKind, ItemId, BuildingType } from './types' + +export const TILE_SIZE = 32 +export const CHUNK_SIZE = 16 +export const WORLD_CHUNKS = 32 +export const WORLD_TILES = WORLD_CHUNKS * CHUNK_SIZE +export const WORLD_PX = WORLD_TILES * TILE_SIZE + +export const PLAYER_SPEED = 180 +export const INTERACTION_RANGE = 60 +export const CAMERA_LERP = 0.09 + +export const TREE_HEALTH = 3 +export const ROCK_HEALTH = 5 + +export const BUILDING_COSTS: Record> = { + floor: { wood: 2 }, + wall: { wood: 3, stone: 1 }, + chest: { wood: 5, stone: 2 }, + bed: { wood: 6 }, + stockpile_zone:{ wood: 0 }, +} + +export interface CropConfig { + stages: number + stageTimeMs: number + rewards: Partial> +} + +export const CROP_CONFIGS: Record = { + wheat: { stages: 3, stageTimeMs: 20_000, rewards: { wheat: 3, wheat_seed: 2 } }, + carrot: { stages: 3, stageTimeMs: 25_000, rewards: { carrot: 4, carrot_seed: 1 } }, +} + +// Villager config +export const VILLAGER_SPEED = 75 // px/s — slow and visible +export const VILLAGER_SPAWN_INTERVAL = 8_000 // ms between spawn checks +export const VILLAGER_WORK_TIMES: Record = { + chop: 3000, + mine: 5000, + farm: 1200, +} +export const VILLAGER_NAMES = [ + 'Aldric','Brix','Cora','Dwyn','Edna','Finn','Greta', + 'Holt','Iris','Jorn','Kira','Lars','Mira','Nox', + 'Orla','Pike','Quinn','Rook','Sera','Tull','Uma','Vex', +] + +export const SAVE_KEY = 'tg_save_v4' +export const AUTOSAVE_INTERVAL = 30_000 diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..ecd3ec2 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,30 @@ +import Phaser from 'phaser' +import { BootScene } from './scenes/BootScene' +import { GameScene } from './scenes/GameScene' +import { UIScene } from './scenes/UIScene' + +const config: Phaser.Types.Core.GameConfig = { + type: Phaser.AUTO, + width: window.innerWidth, + height: window.innerHeight, + backgroundColor: '#1a1a2e', + physics: { + default: 'arcade', + arcade: { + gravity: { x: 0, y: 0 }, + debug: false, + }, + }, + scene: [BootScene, GameScene, UIScene], + scale: { + mode: Phaser.Scale.RESIZE, + autoCenter: Phaser.Scale.CENTER_BOTH, + }, + render: { + pixelArt: false, + antialias: true, + roundPixels: true, + }, +} + +new Phaser.Game(config) diff --git a/src/scenes/BootScene.ts b/src/scenes/BootScene.ts new file mode 100644 index 0000000..f3496a6 --- /dev/null +++ b/src/scenes/BootScene.ts @@ -0,0 +1,365 @@ +import Phaser from 'phaser' +import { TILE_SIZE, WORLD_TILES } from '../config' +import { TileType } from '../types' +import { generateTerrain, findSpawn } from '../utils/noise' +import { stateManager } from '../StateManager' + +const TOTAL_TILES = 11 // 0-10 in the tileset strip + +export class BootScene extends Phaser.Scene { + constructor() { + super({ key: 'Boot' }) + } + + preload(): void { + this.createLoadingBar() + } + + create(): void { + this.buildTileset() + this.buildResourceTextures() + this.buildPlayerTexture() + this.buildCropTextures() + this.buildUITextures() + this.buildVillagerAndBuildingTextures() + this.generateWorldIfNeeded() + this.scene.start('Game') + } + + // ─── Loading bar ────────────────────────────────────────────────────────── + + private createLoadingBar(): void { + const { width, height } = this.scale + const barW = 400, barH = 20 + const x = width / 2 - barW / 2 + const y = height / 2 + + const border = this.add.graphics() + border.lineStyle(2, 0xffffff) + border.strokeRect(x - 2, y - 2, barW + 4, barH + 4) + + const bar = this.add.graphics() + this.load.on('progress', (v: number) => { + bar.clear() + bar.fillStyle(0x4CAF50) + bar.fillRect(x, y, barW * v, barH) + }) + + this.add.text(width / 2, y - 40, 'Loading...', { + fontSize: '20px', color: '#ffffff', fontFamily: 'monospace' + }).setOrigin(0.5) + } + + // ─── Tileset (TOTAL_TILES × TILE_SIZE wide, TILE_SIZE tall) ────────────── + + private buildTileset(): void { + const T = TILE_SIZE + const g = this.add.graphics() + + const drawTile = (idx: number, cb: (g: Phaser.GameObjects.Graphics) => void) => { + g.save(); g.translateCanvas(idx * T, 0); cb(g); g.restore() + } + + // 0 – Deep water + drawTile(TileType.DEEP_WATER, g => { + g.fillStyle(0x1565C0); g.fillRect(0, 0, T, T) + g.fillStyle(0x1976D2, 0.6) + for (let i = 0; i < 3; i++) g.fillRect(4 + i * 10, 8 + i * 8, 14, 3) + }) + + // 1 – Shallow water + drawTile(TileType.SHALLOW_WATER, g => { + g.fillStyle(0x42A5F5); g.fillRect(0, 0, T, T) + g.fillStyle(0x64B5F6, 0.7) + g.fillRect(5, 12, 22, 3); g.fillRect(8, 20, 16, 3) + }) + + // 2 – Sand + drawTile(TileType.SAND, g => { + g.fillStyle(0xF5DEB3); g.fillRect(0, 0, T, T) + g.fillStyle(0xDEB887, 0.5) + g.fillRect(4, 4, 6, 6); g.fillRect(18, 14, 8, 8); g.fillRect(10, 22, 5, 5) + }) + + // 3 – Grass + drawTile(TileType.GRASS, g => { + g.fillStyle(0x66BB6A); g.fillRect(0, 0, T, T) + g.fillStyle(0x4CAF50, 0.6) + g.fillRect(3, 8, 4, 6); g.fillRect(14, 4, 4, 8); g.fillRect(24, 16, 3, 7) + }) + + // 4 – Dark grass + drawTile(TileType.DARK_GRASS, g => { + g.fillStyle(0x43A047); g.fillRect(0, 0, T, T) + g.fillStyle(0x388E3C, 0.6) + g.fillRect(2, 6, 5, 8); g.fillRect(16, 3, 5, 10); g.fillRect(22, 18, 4, 8) + }) + + // 5 – Forest floor (under trees) + drawTile(TileType.FOREST, g => { + g.fillStyle(0x33691E); g.fillRect(0, 0, T, T) + g.fillStyle(0x2E7D32, 0.5); g.fillRect(0, 0, T, T) + }) + + // 6 – Rock ground + drawTile(TileType.ROCK, g => { + g.fillStyle(0x616161); g.fillRect(0, 0, T, T) + g.fillStyle(0x757575, 0.6) + g.fillRect(4, 4, 10, 10); g.fillRect(18, 16, 8, 8) + }) + + // 7 – Built floor (wood plank) + drawTile(TileType.FLOOR, g => { + g.fillStyle(0xD2A679); g.fillRect(0, 0, T, T) + g.lineStyle(1, 0xB8895A) + g.strokeRect(1, 1, T - 2, T - 2) + g.strokeRect(1, T / 2, T - 2, 1) + }) + + // 8 – Built wall + drawTile(TileType.WALL, g => { + g.fillStyle(0x9E9E9E); g.fillRect(0, 0, T, T) + g.fillStyle(0x757575) + for (let row = 0; row < 4; row++) { + for (let col = 0; col < 2; col++) { + const ox = col * 16 + (row % 2 === 0 ? 0 : 8) + g.fillRect(ox + 1, row * 8 + 1, 14, 6) + } + } + }) + + // 9 – Tilled soil + drawTile(TileType.TILLED_SOIL, g => { + g.fillStyle(0x5D3A1A); g.fillRect(0, 0, T, T) + g.fillStyle(0x4A2E0E) + for (let row = 0; row < 4; row++) { + g.fillRect(2, 4 + row * 8, T - 4, 2) + } + g.fillStyle(0x7B4F2A, 0.5) + g.fillRect(3, 5, T - 6, 1) + g.fillRect(3, 13, T - 6, 1) + }) + + // 10 – Watered soil + drawTile(TileType.WATERED_SOIL, g => { + g.fillStyle(0x3E2208); g.fillRect(0, 0, T, T) + g.fillStyle(0x2C5F8A, 0.25); g.fillRect(0, 0, T, T) + g.fillStyle(0x2A1505) + for (let row = 0; row < 4; row++) { + g.fillRect(2, 4 + row * 8, T - 4, 2) + } + g.fillStyle(0x4A90D9, 0.3) + g.fillRect(3, 5, T - 6, 1) + g.fillRect(3, 13, T - 6, 1) + }) + + g.generateTexture('tiles', TOTAL_TILES * T, T) + g.destroy() + } + + // ─── Tree and rock textures ─────────────────────────────────────────────── + + private buildResourceTextures(): void { + // Tree (32 × 52) + const tg = this.add.graphics() + tg.fillStyle(0x000000, 0.18); tg.fillEllipse(16, 44, 22, 8) + tg.fillStyle(0x6D4C41); tg.fillRect(11, 28, 10, 18) + tg.fillStyle(0x2E7D32); tg.fillCircle(16, 20, 15) + tg.fillStyle(0x388E3C); tg.fillCircle(10, 25, 11); tg.fillCircle(22, 25, 11) + tg.fillStyle(0x43A047); tg.fillCircle(16, 14, 10) + tg.generateTexture('tree', 32, 52) + tg.destroy() + + // Rock (40 × 34) + const rg = this.add.graphics() + rg.fillStyle(0x000000, 0.18); rg.fillEllipse(20, 30, 36, 8) + rg.fillStyle(0x78909C); rg.fillEllipse(20, 20, 36, 26) + rg.fillStyle(0x90A4AE); rg.fillEllipse(14, 14, 20, 16) + rg.fillStyle(0xB0BEC5, 0.6); rg.fillEllipse(12, 10, 10, 8) + rg.generateTexture('rock', 40, 34) + rg.destroy() + } + + // ─── Player texture (placeholder – no player body in game) ─────────────── + + private buildPlayerTexture(): void { + const T = TILE_SIZE + const dirs = ['down', 'up', 'left', 'right'] as const + dirs.forEach(dir => { + const g = this.add.graphics() + g.fillStyle(0x000000, 0.2); g.fillEllipse(T / 2, T - 4, 20, 8) + g.fillStyle(0xE53935); g.fillCircle(T / 2, T / 2 - 2, 11) + g.fillStyle(0x3949AB) + g.fillRect(T / 2 - 8, T / 2 + 5, 7, 10) + g.fillRect(T / 2 + 1, T / 2 + 5, 7, 10) + g.fillStyle(0xFFFFFF) + if (dir === 'down') g.fillTriangle(T/2, T/2+2, T/2-5, T/2-4, T/2+5, T/2-4) + if (dir === 'up') g.fillTriangle(T/2, T/2-5, T/2-5, T/2+3, T/2+5, T/2+3) + if (dir === 'left') g.fillTriangle(T/2-5, T/2, T/2+3, T/2-5, T/2+3, T/2+5) + if (dir === 'right') g.fillTriangle(T/2+5, T/2, T/2-3, T/2-5, T/2-3, T/2+5) + g.generateTexture(`player_${dir}`, T, T) + g.destroy() + }) + } + + // ─── Crop textures ──────────────────────────────────────────────────────── + + private buildCropTextures(): void { + this.buildWheatTextures() + this.buildCarrotTextures() + } + + private buildWheatTextures(): void { + const W = 32, H = 40 + + const g0 = this.add.graphics() + g0.fillStyle(0x8BC34A); g0.fillRect(14, 26, 4, 12) + g0.fillStyle(0x9CCC65); g0.fillEllipse(16, 24, 10, 8) + g0.generateTexture('crop_wheat_0', W, H); g0.destroy() + + const g1 = this.add.graphics() + g1.fillStyle(0x7CB342) + for (let i = 0; i < 3; i++) { + g1.fillRect(8 + i * 8, 16 + i * 2, 3, 22 - i * 2) + } + g1.fillStyle(0x9CCC65) + g1.fillEllipse(10, 14, 8, 6); g1.fillEllipse(18, 12, 8, 6); g1.fillEllipse(26, 16, 8, 6) + g1.generateTexture('crop_wheat_1', W, H); g1.destroy() + + const g2 = this.add.graphics() + g2.fillStyle(0x558B2F) + for (let i = 0; i < 4; i++) { + g2.fillRect(5 + i * 7, 8 + (i % 2) * 4, 3, 30 - (i % 2) * 4) + } + g2.fillStyle(0x689F38) + g2.fillEllipse(7, 6, 7, 5); g2.fillEllipse(14, 4, 7, 5) + g2.fillEllipse(21, 7, 7, 5); g2.fillEllipse(28, 5, 7, 5) + g2.generateTexture('crop_wheat_2', W, H); g2.destroy() + + const g3 = this.add.graphics() + g3.fillStyle(0x795548) + for (let i = 0; i < 5; i++) { + g3.fillRect(3 + i * 6, 14 + (i % 2) * 2, 2, 24) + } + g3.fillStyle(0xFDD835) + for (let i = 0; i < 5; i++) { + g3.fillEllipse(4 + i * 6, 10 + (i % 2) * 2, 6, 12) + } + g3.fillStyle(0xF9A825, 0.7) + for (let i = 0; i < 5; i++) { + g3.fillRect(3 + i * 6, 4 + (i % 2) * 2, 2, 8) + } + g3.generateTexture('crop_wheat_3', W, H); g3.destroy() + } + + private buildCarrotTextures(): void { + const W = 32, H = 40 + + const g0 = this.add.graphics() + g0.fillStyle(0x4CAF50); g0.fillRect(14, 26, 4, 12) + g0.fillStyle(0x66BB6A); g0.fillEllipse(16, 24, 10, 8) + g0.generateTexture('crop_carrot_0', W, H); g0.destroy() + + const g1 = this.add.graphics() + g1.fillStyle(0x388E3C) + g1.fillRect(14, 22, 4, 16) + g1.fillEllipse(11, 18, 10, 8); g1.fillEllipse(21, 18, 10, 8) + g1.fillEllipse(16, 14, 8, 10) + g1.generateTexture('crop_carrot_1', W, H); g1.destroy() + + const g2 = this.add.graphics() + g2.fillStyle(0x2E7D32) + g2.fillRect(14, 18, 4, 20) + g2.fillEllipse(9, 14, 12, 10); g2.fillEllipse(23, 14, 12, 10) + g2.fillEllipse(16, 8, 10, 12); g2.fillEllipse(13, 10, 8, 8); g2.fillEllipse(19, 10, 8, 8) + g2.fillStyle(0xE65100, 0.6); g2.fillEllipse(16, 36, 8, 6) + g2.generateTexture('crop_carrot_2', W, H); g2.destroy() + + const g3 = this.add.graphics() + g3.fillStyle(0x1B5E20) + g3.fillRect(14, 14, 4, 16) + g3.fillEllipse(8, 10, 12, 10); g3.fillEllipse(24, 10, 12, 10) + g3.fillEllipse(16, 4, 10, 12); g3.fillEllipse(13, 6, 8, 8); g3.fillEllipse(19, 6, 8, 8) + g3.fillStyle(0xFF6F00); g3.fillEllipse(16, 30, 12, 10) + g3.fillStyle(0xFF8F00) + g3.fillTriangle(10, 28, 22, 28, 16, 40) + g3.fillStyle(0xFFCC02, 0.4); g3.fillEllipse(13, 27, 5, 4) + g3.generateTexture('crop_carrot_3', W, H); g3.destroy() + } + + // ─── UI panel texture ───────────────────────────────────────────────────── + + private buildUITextures(): void { + const pg = this.add.graphics() + pg.fillStyle(0x000000, 0.65) + pg.fillRoundedRect(0, 0, 200, 100, 8) + pg.generateTexture('panel', 200, 100) + pg.destroy() + } + + // ─── Villager + building object textures ────────────────────────────────── + + private buildVillagerAndBuildingTextures(): void { + // ── Villager gnome (24 × 28) ────────────────────────────────────────── + const vg = this.add.graphics() + vg.fillStyle(0x000000, 0.15); vg.fillEllipse(12, 26, 18, 6) + vg.fillStyle(0x1A237E); vg.fillRect(7, 16, 5, 9) + vg.fillRect(12, 16, 5, 9) + vg.fillStyle(0x3949AB); vg.fillRect(5, 9, 14, 10) + vg.fillStyle(0xFFCC80); vg.fillCircle(12, 7, 6) + vg.fillStyle(0x4E342E); vg.fillCircle(10, 6, 1); vg.fillCircle(14, 6, 1) + vg.generateTexture('villager', 24, 28) + vg.destroy() + + // ── Bed (32 × 32) ───────────────────────────────────────────────────── + const bg = this.add.graphics() + bg.fillStyle(0x6D4C41); bg.fillRect(1, 1, 30, 30) + bg.fillStyle(0xEFEBE9); bg.fillRect(3, 3, 26, 10) + bg.fillStyle(0x5C6BC0); bg.fillRect(3, 15, 26, 14) + bg.lineStyle(1, 0x4E342E, 0.8); bg.strokeRect(1, 1, 30, 30) + bg.fillStyle(0xBCAAA4); bg.fillRect(3, 13, 26, 3) + bg.generateTexture('bed_obj', 32, 32) + bg.destroy() + + // ── Stockpile zone (32 × 32) ────────────────────────────────────────── + const sg = this.add.graphics() + sg.fillStyle(0xFFF9C4, 0.5); sg.fillRect(0, 0, 32, 32) + sg.lineStyle(2, 0xF9A825, 0.9) + for (let i = 0; i < 32; i += 6) { sg.strokeRect(i, 0, 6, 32) } + sg.fillStyle(0x8D6E63); sg.fillRect(6, 10, 9, 9); sg.fillRect(17, 10, 9, 9) + sg.fillRect(6, 20, 9, 9); sg.fillRect(17, 20, 9, 9) + sg.lineStyle(1, 0x5D4037) + sg.strokeRect(6, 10, 9, 9); sg.strokeRect(17, 10, 9, 9) + sg.strokeRect(6, 20, 9, 9); sg.strokeRect(17, 20, 9, 9) + sg.generateTexture('stockpile_obj', 32, 32) + sg.destroy() + } + + // ─── Terrain generation ─────────────────────────────────────────────────── + + private generateWorldIfNeeded(): void { + const state = stateManager.getState() + if (Object.keys(state.world.resources).length === 0) { + const tiles = generateTerrain(state.world.seed) + const mutableTiles = state.world.tiles as number[] + for (let i = 0; i < tiles.length; i++) mutableTiles[i] = tiles[i] + + for (let y = 0; y < WORLD_TILES; y++) { + for (let x = 0; x < WORLD_TILES; x++) { + const tile = tiles[y * WORLD_TILES + x] + if (tile === TileType.FOREST && Math.random() < 0.7) { + const id = `tree_${x}_${y}` + state.world.resources[id] = { id, tileX: x, tileY: y, kind: 'tree', hp: 3 } + } else if (tile === TileType.ROCK && Math.random() < 0.5) { + const id = `rock_${x}_${y}` + state.world.resources[id] = { id, tileX: x, tileY: y, kind: 'rock', hp: 5 } + } + } + } + + const spawn = findSpawn(tiles) + ;(state.player as { x: number; y: number }).x = (spawn.tileX + 0.5) * 32 + ;(state.player as { x: number; y: number }).y = (spawn.tileY + 0.5) * 32 + } + } +} diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts new file mode 100644 index 0000000..c9a221d --- /dev/null +++ b/src/scenes/GameScene.ts @@ -0,0 +1,130 @@ +import Phaser from 'phaser' +import { AUTOSAVE_INTERVAL, TILE_SIZE } from '../config' +import type { BuildingType } from '../types' +import { stateManager } from '../StateManager' +import { LocalAdapter } from '../NetworkAdapter' +import { WorldSystem } from '../systems/WorldSystem' +import { CameraSystem } from '../systems/CameraSystem' +import { ResourceSystem } from '../systems/ResourceSystem' +import { BuildingSystem } from '../systems/BuildingSystem' +import { FarmingSystem } from '../systems/FarmingSystem' +import { VillagerSystem } from '../systems/VillagerSystem' + +export class GameScene extends Phaser.Scene { + private adapter!: LocalAdapter + private worldSystem!: WorldSystem + private cameraSystem!: CameraSystem + private resourceSystem!: ResourceSystem + private buildingSystem!: BuildingSystem + private farmingSystem!: FarmingSystem + villagerSystem!: VillagerSystem + private autosaveTimer = 0 + private menuOpen = false + + constructor() { super({ key: 'Game' }) } + + create(): void { + this.adapter = new LocalAdapter() + + this.worldSystem = new WorldSystem(this) + this.cameraSystem = new CameraSystem(this, this.adapter) + this.resourceSystem = new ResourceSystem(this, this.adapter) + this.buildingSystem = new BuildingSystem(this, this.adapter) + this.farmingSystem = new FarmingSystem(this, this.adapter) + this.villagerSystem = new VillagerSystem(this, this.adapter, this.worldSystem) + this.villagerSystem.init(this.resourceSystem, this.farmingSystem) + + this.worldSystem.create() + this.renderPersistentObjects() + + this.cameraSystem.create() + + this.resourceSystem.create() + this.resourceSystem.onHarvest = (msg) => this.events.emit('toast', msg) + + this.buildingSystem.create() + this.buildingSystem.onModeChange = (active, building) => this.events.emit('buildModeChanged', active, building) + this.buildingSystem.onPlaced = (msg) => { + this.events.emit('toast', msg) + this.renderPersistentObjects() + } + + this.farmingSystem.create() + this.farmingSystem.onMessage = (msg) => this.events.emit('toast', msg) + this.farmingSystem.onToolChange = (tool, label) => this.events.emit('farmToolChanged', tool, label) + + this.villagerSystem.create() + this.villagerSystem.onMessage = (msg) => this.events.emit('toast', msg) + + // Sync tile changes and building visuals through adapter + this.adapter.onAction = (action) => { + if (action.type === 'CHANGE_TILE') { + this.worldSystem.setTile(action.tileX, action.tileY, action.tile) + } + } + + this.scene.launch('UI') + + this.events.on('selectBuilding', (kind: BuildingType) => this.buildingSystem.selectBuilding(kind)) + this.events.on('uiMenuOpen', () => { this.menuOpen = true }) + this.events.on('uiMenuClose', () => { this.menuOpen = false }) + this.events.on('uiRequestBuildMenu', () => { + if (!this.buildingSystem.isActive()) this.events.emit('openBuildMenu') + }) + this.events.on('updatePriorities', (villagerId: string, priorities: { chop: number; mine: number; farm: number }) => { + this.adapter.send({ type: 'UPDATE_PRIORITIES', villagerId, priorities }) + }) + + this.autosaveTimer = AUTOSAVE_INTERVAL + } + + update(_time: number, delta: number): void { + if (this.menuOpen) return + + this.cameraSystem.update(delta) + + this.resourceSystem.update(delta) + this.farmingSystem.update(delta) + this.villagerSystem.update(delta) + + this.events.emit('cameraMoved', this.cameraSystem.getCenterTile()) + this.buildingSystem.update() + + this.autosaveTimer -= delta + if (this.autosaveTimer <= 0) { + this.autosaveTimer = AUTOSAVE_INTERVAL + stateManager.save() + } + } + + /** Render game objects that persist across sessions (buildings + crop sprites etc.) */ + private renderPersistentObjects(): void { + const state = stateManager.getState() + for (const building of Object.values(state.world.buildings)) { + const wx = building.tileX * TILE_SIZE + TILE_SIZE / 2 + const wy = building.tileY * TILE_SIZE + TILE_SIZE / 2 + const name = `bobj_${building.id}` + if (this.children.getByName(name)) continue + + if (building.kind === 'chest') { + const g = this.add.graphics().setName(name).setDepth(8) + g.fillStyle(0x8B4513); g.fillRect(wx - 10, wy - 7, 20, 14) + g.fillStyle(0xCD853F); g.fillRect(wx - 9, wy - 6, 18, 6) + g.lineStyle(1, 0x5C3317); g.strokeRect(wx - 10, wy - 7, 20, 14) + } else if (building.kind === 'bed') { + this.add.image(wx, wy, 'bed_obj').setName(name).setDepth(8) + } else if (building.kind === 'stockpile_zone') { + this.add.image(wx, wy, 'stockpile_obj').setName(name).setDepth(4).setAlpha(0.8) + } + } + } + + shutdown(): void { + stateManager.save() + this.worldSystem.destroy() + this.resourceSystem.destroy() + this.buildingSystem.destroy() + this.farmingSystem.destroy() + this.villagerSystem.destroy() + } +} diff --git a/src/scenes/UIScene.ts b/src/scenes/UIScene.ts new file mode 100644 index 0000000..bcd8fb5 --- /dev/null +++ b/src/scenes/UIScene.ts @@ -0,0 +1,283 @@ +import Phaser from 'phaser' +import type { BuildingType, JobPriorities } from '../types' +import type { FarmingTool } from '../systems/FarmingSystem' +import { stateManager } from '../StateManager' + +const ITEM_ICONS: Record = { + wood: '🪵', stone: '🪨', wheat_seed: '🌱', carrot_seed: '🥕', + wheat: '🌾', carrot: '🧡', +} + +export class UIScene extends Phaser.Scene { + private stockpileTexts: Map = new Map() + private stockpilePanel!: Phaser.GameObjects.Rectangle + private hintText!: Phaser.GameObjects.Text + private toastText!: Phaser.GameObjects.Text + private toastTimer = 0 + private buildMenuGroup!: Phaser.GameObjects.Group + private buildMenuVisible = false + private villagerPanelGroup!: Phaser.GameObjects.Group + private villagerPanelVisible = false + private buildModeText!: Phaser.GameObjects.Text + private farmToolText!: Phaser.GameObjects.Text + private coordsText!: Phaser.GameObjects.Text + private popText!: Phaser.GameObjects.Text + + constructor() { super({ key: 'UI' }) } + + create(): void { + this.createStockpilePanel() + this.createHintText() + this.createToast() + this.createBuildMenu() + this.createBuildModeIndicator() + this.createFarmToolIndicator() + this.createCoordsDisplay() + + const gameScene = this.scene.get('Game') + gameScene.events.on('buildModeChanged', (a: boolean, b: BuildingType) => this.onBuildModeChanged(a, b)) + gameScene.events.on('farmToolChanged', (t: FarmingTool, l: string) => this.onFarmToolChanged(t, l)) + gameScene.events.on('toast', (m: string) => this.showToast(m)) + gameScene.events.on('openBuildMenu', () => this.toggleBuildMenu()) + gameScene.events.on('cameraMoved', (pos: { tileX: number; tileY: number }) => this.onCameraMoved(pos)) + + this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.B) + .on('down', () => gameScene.events.emit('uiRequestBuildMenu')) + this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.V) + .on('down', () => this.toggleVillagerPanel()) + + this.scale.on('resize', () => this.repositionUI()) + } + + update(_t: number, delta: number): void { + this.updateStockpile() + this.updateToast(delta) + this.updatePopText() + } + + // ─── Stockpile ──────────────────────────────────────────────────────────── + + private createStockpilePanel(): void { + const x = this.scale.width - 178, y = 10 + this.stockpilePanel = this.add.rectangle(x, y, 168, 165, 0x000000, 0.72).setOrigin(0, 0).setScrollFactor(0).setDepth(100) + this.add.text(x + 10, y + 7, '⚡ STOCKPILE', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101) + const items = ['wood','stone','wheat_seed','carrot_seed','wheat','carrot'] as const + items.forEach((item, i) => { + const t = this.add.text(x + 10, y + 26 + i * 22, `${ITEM_ICONS[item]} ${item}: 0`, { fontSize: '13px', color: '#88dd88', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101) + this.stockpileTexts.set(item, t) + }) + this.popText = this.add.text(x + 10, y + 145, '👥 Pop: 0 / 0', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101) + } + + private updateStockpile(): void { + const sp = stateManager.getState().world.stockpile + for (const [item, t] of this.stockpileTexts) { + const qty = sp[item as keyof typeof sp] ?? 0 + t.setStyle({ color: qty > 0 ? '#88dd88' : '#444444' }) + t.setText(`${ITEM_ICONS[item]} ${item}: ${qty}`) + } + } + + private updatePopText(): void { + const state = stateManager.getState() + const beds = Object.values(state.world.buildings).filter(b => b.kind === 'bed').length + const current = Object.keys(state.world.villagers).length + this.popText?.setText(`👥 Pop: ${current} / ${beds} [V] manage`) + } + + // ─── Hint ───────────────────────────────────────────────────────────────── + + private createHintText(): void { + this.hintText = this.add.text(this.scale.width / 2, this.scale.height - 40, '', { + fontSize: '14px', color: '#ffff88', fontFamily: 'monospace', + backgroundColor: '#00000099', padding: { x: 10, y: 5 }, + }).setOrigin(0.5).setScrollFactor(0).setDepth(100).setVisible(false) + } + + // ─── Toast ──────────────────────────────────────────────────────────────── + + private createToast(): void { + this.toastText = this.add.text(this.scale.width / 2, 60, '', { + fontSize: '15px', color: '#88ff88', fontFamily: 'monospace', + backgroundColor: '#00000099', padding: { x: 12, y: 6 }, + }).setOrigin(0.5, 0).setScrollFactor(0).setDepth(102).setAlpha(0) + } + + showToast(msg: string): void { this.toastText.setText(msg).setAlpha(1); this.toastTimer = 2200 } + + private updateToast(delta: number): void { + if (this.toastTimer <= 0) return + this.toastTimer -= delta + if (this.toastTimer <= 0) this.tweens.add({ targets: this.toastText, alpha: 0, duration: 400 }) + } + + // ─── Build Menu ─────────────────────────────────────────────────────────── + + private createBuildMenu(): void { + this.buildMenuGroup = this.add.group() + const buildings: { kind: BuildingType; label: string; cost: string }[] = [ + { kind: 'floor', label: 'Floor', cost: '2 wood' }, + { kind: 'wall', label: 'Wall', cost: '3 wood + 1 stone' }, + { kind: 'chest', label: 'Chest', cost: '5 wood + 2 stone' }, + { kind: 'bed', label: '🛏 Bed', cost: '6 wood (+1 villager)' }, + { kind: 'stockpile_zone', label: '📦 Stockpile', cost: 'free (workers deliver here)' }, + ] + const menuX = this.scale.width / 2 - 150, menuY = this.scale.height / 2 - 140 + const bg = this.add.rectangle(menuX, menuY, 300, 280, 0x000000, 0.88).setOrigin(0,0).setScrollFactor(0).setDepth(200) + this.buildMenuGroup.add(bg) + this.buildMenuGroup.add(this.add.text(menuX + 150, menuY + 14, 'BUILD MENU [B/ESC]', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setOrigin(0.5,0).setScrollFactor(0).setDepth(201)) + + buildings.forEach((b, i) => { + const btnY = menuY + 38 + i * 46 + const btn = this.add.rectangle(menuX + 14, btnY, 272, 38, 0x1a3a1a, 0.9).setOrigin(0,0).setScrollFactor(0).setDepth(201).setInteractive() + btn.on('pointerover', () => btn.setFillStyle(0x2d6a4f, 0.9)) + btn.on('pointerout', () => btn.setFillStyle(0x1a3a1a, 0.9)) + btn.on('pointerdown', () => { this.closeBuildMenu(); this.scene.get('Game').events.emit('selectBuilding', b.kind) }) + this.buildMenuGroup.add(btn) + this.buildMenuGroup.add(this.add.text(menuX + 24, btnY + 5, b.label, { fontSize: '13px', color: '#ffffff', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(202)) + this.buildMenuGroup.add(this.add.text(menuX + 24, btnY + 22, `Cost: ${b.cost}`, { fontSize: '10px', color: '#888888', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(202)) + }) + this.buildMenuGroup.setVisible(false) + } + + private toggleBuildMenu(): void { this.buildMenuVisible ? this.closeBuildMenu() : this.openBuildMenu() } + private openBuildMenu(): void { this.buildMenuVisible = true; this.buildMenuGroup.setVisible(true); this.scene.get('Game').events.emit('uiMenuOpen') } + private closeBuildMenu(): void { this.buildMenuVisible = false; this.buildMenuGroup.setVisible(false); this.scene.get('Game').events.emit('uiMenuClose') } + + // ─── Villager Panel (V key) ─────────────────────────────────────────────── + + private toggleVillagerPanel(): void { + if (this.villagerPanelVisible) { + this.closeVillagerPanel() + } else { + this.openVillagerPanel() + } + } + + private openVillagerPanel(): void { + this.villagerPanelVisible = true + this.buildVillagerPanel() + this.scene.get('Game').events.emit('uiMenuOpen') + } + + private closeVillagerPanel(): void { + this.villagerPanelVisible = false + this.villagerPanelGroup?.destroy(true) + this.scene.get('Game').events.emit('uiMenuClose') + } + + private buildVillagerPanel(): void { + if (this.villagerPanelGroup) this.villagerPanelGroup.destroy(true) + this.villagerPanelGroup = this.add.group() + + const state = stateManager.getState() + const villagers = Object.values(state.world.villagers) + const panelW = 420 + const rowH = 60 + const panelH = Math.max(100, villagers.length * rowH + 50) + const px = this.scale.width / 2 - panelW / 2 + const py = this.scale.height / 2 - panelH / 2 + + const bg = this.add.rectangle(px, py, panelW, panelH, 0x0a0a0a, 0.92).setOrigin(0,0).setScrollFactor(0).setDepth(210) + this.villagerPanelGroup.add(bg) + + this.villagerPanelGroup.add( + this.add.text(px + panelW/2, py + 12, '👥 VILLAGERS [V] close', { fontSize: '12px', color: '#aaaaaa', fontFamily: 'monospace' }) + .setOrigin(0.5, 0).setScrollFactor(0).setDepth(211) + ) + + if (villagers.length === 0) { + this.villagerPanelGroup.add( + this.add.text(px + panelW/2, py + panelH/2, 'No villagers yet.\nBuild a 🛏 Bed first!', { + fontSize: '13px', color: '#666666', fontFamily: 'monospace', align: 'center' + }).setOrigin(0.5).setScrollFactor(0).setDepth(211) + ) + } + + villagers.forEach((v, i) => { + const ry = py + 38 + i * rowH + const gameScene = this.scene.get('Game') as any + + // Name + status + const statusText = gameScene.villagerSystem?.getStatusText(v.id) ?? '—' + this.villagerPanelGroup.add( + this.add.text(px + 12, ry, `${v.name}`, { fontSize: '13px', color: '#ffffff', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(211) + ) + this.villagerPanelGroup.add( + this.add.text(px + 12, ry + 16, statusText, { fontSize: '10px', color: '#888888', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(211) + ) + + // Energy bar + const eg = this.add.graphics().setScrollFactor(0).setDepth(211) + eg.fillStyle(0x333333); eg.fillRect(px + 12, ry + 30, 80, 6) + const col = v.energy > 60 ? 0x4CAF50 : v.energy > 30 ? 0xFF9800 : 0xF44336 + eg.fillStyle(col); eg.fillRect(px + 12, ry + 30, 80 * v.energy / 100, 6) + this.villagerPanelGroup.add(eg) + + // Job priority buttons: chop / mine / farm + const jobs: Array<{ key: keyof JobPriorities; label: string }> = [ + { key: 'chop', label: '🪓' }, { key: 'mine', label: '⛏' }, { key: 'farm', label: '🌾' } + ] + jobs.forEach((job, ji) => { + const bx = px + 110 + ji * 100 + const pri = v.priorities[job.key] + const label = pri === 0 ? `${job.label} OFF` : `${job.label} P${pri}` + const btn = this.add.text(bx, ry + 6, label, { + fontSize: '11px', color: pri === 0 ? '#555555' : '#ffffff', + fontFamily: 'monospace', backgroundColor: pri === 0 ? '#1a1a1a' : '#1a4a1a', + padding: { x: 6, y: 4 } + }).setScrollFactor(0).setDepth(212).setInteractive() + + btn.on('pointerover', () => btn.setStyle({ backgroundColor: '#2d6a4f' })) + btn.on('pointerout', () => btn.setStyle({ backgroundColor: pri === 0 ? '#1a1a1a' : '#1a4a1a' })) + btn.on('pointerdown', () => { + const newPri = ((v.priorities[job.key] + 1) % 5) // 0→1→2→3→4→0 + const newPriorities: JobPriorities = { ...v.priorities, [job.key]: newPri } + this.scene.get('Game').events.emit('updatePriorities', v.id, newPriorities) + this.closeVillagerPanel() + this.openVillagerPanel() // Rebuild to reflect change + }) + this.villagerPanelGroup.add(btn) + }) + }) + } + + // ─── Build mode indicator ───────────────────────────────────────────────── + + private createBuildModeIndicator(): void { + this.buildModeText = this.add.text(10, 10, '', { fontSize: '13px', color: '#ffff00', fontFamily: 'monospace', backgroundColor: '#00000099', padding: { x: 8, y: 4 } }).setScrollFactor(0).setDepth(100).setVisible(false) + } + private onBuildModeChanged(active: boolean, building: BuildingType): void { + this.buildModeText.setText(active ? `🏗 BUILD: ${building.toUpperCase()} [RMB/ESC cancel]` : '').setVisible(active) + } + + // ─── Farm tool indicator ────────────────────────────────────────────────── + + private createFarmToolIndicator(): void { + this.farmToolText = this.add.text(10, 44, '', { fontSize: '13px', color: '#aaffaa', fontFamily: 'monospace', backgroundColor: '#00000099', padding: { x: 8, y: 4 } }).setScrollFactor(0).setDepth(100).setVisible(false) + } + private onFarmToolChanged(tool: FarmingTool, label: string): void { + this.farmToolText.setText(tool === 'none' ? '' : `[F] Farm: ${label} [RMB cancel]`).setVisible(tool !== 'none') + } + + // ─── Coords + controls ──────────────────────────────────────────────────── + + private createCoordsDisplay(): void { + this.coordsText = this.add.text(10, this.scale.height - 24, '', { fontSize: '11px', color: '#666666', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(100) + this.add.text(10, this.scale.height - 42, '[WASD] Pan [Scroll] Zoom [F] Farm [B] Build [V] Villagers', { + fontSize: '10px', color: '#444444', fontFamily: 'monospace', backgroundColor: '#00000066', padding: { x: 4, y: 2 } + }).setScrollFactor(0).setDepth(100) + } + private onCameraMoved(pos: { tileX: number; tileY: number }): void { + this.coordsText.setText(`Tile: ${pos.tileX}, ${pos.tileY}`) + } + + // ─── Resize ─────────────────────────────────────────────────────────────── + + private repositionUI(): void { + const { width, height } = this.scale + this.hintText.setPosition(width/2, height - 40) + this.toastText.setPosition(width/2, 60) + this.coordsText.setPosition(10, height - 24) + } +} diff --git a/src/systems/BuildingSystem.ts b/src/systems/BuildingSystem.ts new file mode 100644 index 0000000..df1b25e --- /dev/null +++ b/src/systems/BuildingSystem.ts @@ -0,0 +1,182 @@ +import Phaser from 'phaser' +import { TILE_SIZE, BUILDING_COSTS } from '../config' +import { TileType, IMPASSABLE } from '../types' +import type { BuildingType } from '../types' +import { stateManager } from '../StateManager' +import type { LocalAdapter } from '../NetworkAdapter' + +const BUILDING_TILE: Partial> = { + floor: TileType.FLOOR, + wall: TileType.WALL, + chest: TileType.FLOOR, // chest placed on floor tile + // bed and stockpile_zone do NOT change the underlying tile +} + +export class BuildingSystem { + private scene: Phaser.Scene + private adapter: LocalAdapter + private active = false + private selectedBuilding: BuildingType = 'floor' + private ghost!: Phaser.GameObjects.Rectangle + private ghostLabel!: Phaser.GameObjects.Text + private buildKey!: Phaser.Input.Keyboard.Key + private cancelKey!: Phaser.Input.Keyboard.Key + + onModeChange?: (active: boolean, building: BuildingType) => void + onPlaced?: (msg: string) => void + + constructor(scene: Phaser.Scene, adapter: LocalAdapter) { + this.scene = scene + this.adapter = adapter + } + + create(): void { + this.ghost = this.scene.add.rectangle(0, 0, TILE_SIZE, TILE_SIZE, 0x00FF00, 0.35) + this.ghost.setDepth(20) + this.ghost.setVisible(false) + this.ghost.setStrokeStyle(2, 0x00FF00, 0.8) + + this.ghostLabel = this.scene.add.text(0, 0, '', { + fontSize: '10px', color: '#ffffff', fontFamily: 'monospace', + backgroundColor: '#000000aa', padding: { x: 3, y: 2 } + }) + this.ghostLabel.setDepth(21) + this.ghostLabel.setVisible(false) + this.ghostLabel.setOrigin(0.5, 1) + + this.buildKey = this.scene.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.B) + this.cancelKey = this.scene.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.ESC) + + // Click to place + this.scene.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => { + if (!this.active) return + if (ptr.rightButtonDown()) { + this.deactivate() + return + } + this.tryPlace(ptr) + }) + } + + /** Select a building type and activate build mode */ + selectBuilding(kind: BuildingType): void { + this.selectedBuilding = kind + this.activate() + } + + private activate(): void { + this.active = true + this.ghost.setVisible(true) + this.ghostLabel.setVisible(true) + this.onModeChange?.(true, this.selectedBuilding) + } + + deactivate(): void { + this.active = false + this.ghost.setVisible(false) + this.ghostLabel.setVisible(false) + this.onModeChange?.(false, this.selectedBuilding) + } + + isActive(): boolean { return this.active } + + update(): void { + if (Phaser.Input.Keyboard.JustDown(this.buildKey)) { + if (this.active) this.deactivate() + // If not active, UIScene opens build menu + } + if (Phaser.Input.Keyboard.JustDown(this.cancelKey)) { + this.deactivate() + } + + if (!this.active) return + + // Update ghost to follow mouse (snapped to tile grid) + const ptr = this.scene.input.activePointer + const worldX = this.scene.cameras.main.scrollX + ptr.x + const worldY = this.scene.cameras.main.scrollY + ptr.y + const tileX = Math.floor(worldX / TILE_SIZE) + const tileY = Math.floor(worldY / TILE_SIZE) + const snapX = tileX * TILE_SIZE + TILE_SIZE / 2 + const snapY = tileY * TILE_SIZE + TILE_SIZE / 2 + + this.ghost.setPosition(snapX, snapY) + this.ghostLabel.setPosition(snapX, snapY - TILE_SIZE / 2 - 2) + + // Color ghost based on can-build + const canBuild = this.canBuildAt(tileX, tileY) + const color = canBuild ? 0x00FF00 : 0xFF4444 + this.ghost.setFillStyle(color, 0.35) + this.ghost.setStrokeStyle(2, color, 0.9) + + const costs = BUILDING_COSTS[this.selectedBuilding] ?? {} + const costStr = Object.entries(costs).map(([k, v]) => `${v}${k[0].toUpperCase()}`).join(' ') + this.ghostLabel.setText(`${this.selectedBuilding} [${costStr}]`) + } + + private canBuildAt(tileX: number, tileY: number): boolean { + const state = stateManager.getState() + const tile = state.world.tiles[tileY * 512 + tileX] as TileType // 512 = WORLD_TILES + + // Can only build on passable ground tiles (not water, not existing buildings) + if (IMPASSABLE.has(tile)) return false + + // Check no resource node on this tile + for (const res of Object.values(state.world.resources)) { + if (res.tileX === tileX && res.tileY === tileY) return false + } + + // Check no existing building of any kind on this tile + for (const b of Object.values(state.world.buildings)) { + if (b.tileX === tileX && b.tileY === tileY) return false + } + + // Check have enough resources + const costs = BUILDING_COSTS[this.selectedBuilding] ?? {} + for (const [item, qty] of Object.entries(costs)) { + const have = state.world.stockpile[item as keyof typeof state.world.stockpile] ?? 0 + if (have < qty) return false + } + + return true + } + + private tryPlace(ptr: Phaser.Input.Pointer): void { + const worldX = this.scene.cameras.main.scrollX + ptr.x + const worldY = this.scene.cameras.main.scrollY + ptr.y + const tileX = Math.floor(worldX / TILE_SIZE) + const tileY = Math.floor(worldY / TILE_SIZE) + + if (!this.canBuildAt(tileX, tileY)) { + this.onPlaced?.('Cannot build here!') + return + } + + const costs = BUILDING_COSTS[this.selectedBuilding] ?? {} + const building = { + id: `building_${tileX}_${tileY}_${Date.now()}`, + tileX, + tileY, + kind: this.selectedBuilding, + ownerId: stateManager.getState().player.id, + } + + this.adapter.send({ type: 'PLACE_BUILDING', building, costs }) + // Only change the tile type for buildings that have a floor/wall tile mapping + const tileMapped = BUILDING_TILE[this.selectedBuilding] + if (tileMapped !== undefined) { + this.adapter.send({ + type: 'CHANGE_TILE', + tileX, + tileY, + tile: tileMapped, + }) + } + this.onPlaced?.(`Placed ${this.selectedBuilding}!`) + } + + destroy(): void { + this.ghost.destroy() + this.ghostLabel.destroy() + } +} diff --git a/src/systems/CameraSystem.ts b/src/systems/CameraSystem.ts new file mode 100644 index 0000000..3eeea17 --- /dev/null +++ b/src/systems/CameraSystem.ts @@ -0,0 +1,105 @@ +import Phaser from 'phaser' +import { WORLD_TILES } from '../config' +import { stateManager } from '../StateManager' +import type { LocalAdapter } from '../NetworkAdapter' + +const CAMERA_SPEED = 400 // px/s +const MIN_ZOOM = 0.25 +const MAX_ZOOM = 2.0 +const ZOOM_STEP = 0.1 + +export class CameraSystem { + private scene: Phaser.Scene + private adapter: LocalAdapter + private keys!: { + up: Phaser.Input.Keyboard.Key + down: Phaser.Input.Keyboard.Key + left: Phaser.Input.Keyboard.Key + right: Phaser.Input.Keyboard.Key + w: Phaser.Input.Keyboard.Key + s: Phaser.Input.Keyboard.Key + a: Phaser.Input.Keyboard.Key + d: Phaser.Input.Keyboard.Key + } + private saveTimer = 0 + private readonly SAVE_TICK = 2000 + + constructor(scene: Phaser.Scene, adapter: LocalAdapter) { + this.scene = scene + this.adapter = adapter + } + + create(): void { + const state = stateManager.getState() + const cam = this.scene.cameras.main + + // Start at saved player position (reused as camera anchor) + cam.scrollX = state.player.x - cam.width / 2 + cam.scrollY = state.player.y - cam.height / 2 + + const kb = this.scene.input.keyboard! + this.keys = { + up: kb.addKey(Phaser.Input.Keyboard.KeyCodes.UP), + down: kb.addKey(Phaser.Input.Keyboard.KeyCodes.DOWN), + left: kb.addKey(Phaser.Input.Keyboard.KeyCodes.LEFT), + right: kb.addKey(Phaser.Input.Keyboard.KeyCodes.RIGHT), + w: kb.addKey(Phaser.Input.Keyboard.KeyCodes.W), + s: kb.addKey(Phaser.Input.Keyboard.KeyCodes.S), + a: kb.addKey(Phaser.Input.Keyboard.KeyCodes.A), + d: kb.addKey(Phaser.Input.Keyboard.KeyCodes.D), + } + + // Scroll wheel zoom + this.scene.input.on('wheel', (_ptr: Phaser.Input.Pointer, _objs: unknown, _dx: number, dy: number) => { + const zoom = Phaser.Math.Clamp(cam.zoom - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM) + cam.setZoom(zoom) + }) + } + + update(delta: number): void { + const cam = this.scene.cameras.main + const speed = CAMERA_SPEED * (delta / 1000) / cam.zoom + + const up = this.keys.up.isDown || this.keys.w.isDown + const down = this.keys.down.isDown || this.keys.s.isDown + const left = this.keys.left.isDown || this.keys.a.isDown + const right = this.keys.right.isDown || this.keys.d.isDown + + let dx = 0, dy = 0 + if (left) dx -= speed + if (right) dx += speed + if (up) dy -= speed + if (down) dy += speed + + if (dx !== 0 && dy !== 0) { dx *= 0.707; dy *= 0.707 } + + const worldW = WORLD_TILES * 32 // TILE_SIZE hardcoded since WORLD_PX may not exist + const worldH = WORLD_TILES * 32 + cam.scrollX = Phaser.Math.Clamp(cam.scrollX + dx, 0, worldW - cam.width / cam.zoom) + cam.scrollY = Phaser.Math.Clamp(cam.scrollY + dy, 0, worldH - cam.height / cam.zoom) + + // Periodically save camera center as "player position" + this.saveTimer += delta + if (this.saveTimer >= this.SAVE_TICK) { + this.saveTimer = 0 + this.adapter.send({ + type: 'PLAYER_MOVE', + x: cam.scrollX + cam.width / 2, + y: cam.scrollY + cam.height / 2, + }) + } + } + + getCenterWorld(): { x: number; y: number } { + const cam = this.scene.cameras.main + return { + x: cam.scrollX + cam.width / (2 * cam.zoom), + y: cam.scrollY + cam.height / (2 * cam.zoom), + } + } + + getCenterTile(): { tileX: number; tileY: number } { + const { x, y } = this.getCenterWorld() + return { tileX: Math.floor(x / 32), tileY: Math.floor(y / 32) } + } +} diff --git a/src/systems/FarmingSystem.ts b/src/systems/FarmingSystem.ts new file mode 100644 index 0000000..79ece7b --- /dev/null +++ b/src/systems/FarmingSystem.ts @@ -0,0 +1,205 @@ +import Phaser from 'phaser' +import { TILE_SIZE, CROP_CONFIGS } from '../config' +import { TileType } from '../types' +import type { CropKind, CropState, ItemId } from '../types' +import { stateManager } from '../StateManager' +import type { LocalAdapter } from '../NetworkAdapter' + +export type FarmingTool = 'none' | 'hoe' | 'wheat_seed' | 'carrot_seed' | 'water' + +const TOOL_CYCLE: FarmingTool[] = ['none', 'hoe', 'wheat_seed', 'carrot_seed', 'water'] + +const TOOL_LABELS: Record = { + none: '— None', + hoe: '⛏ Hoe (till grass)', + wheat_seed: '🌾 Wheat Seeds', + carrot_seed: '🥕 Carrot Seeds', + water: '💧 Watering Can', +} + +export class FarmingSystem { + private scene: Phaser.Scene + private adapter: LocalAdapter + private currentTool: FarmingTool = 'none' + private cropSprites = new Map() + private toolKey!: Phaser.Input.Keyboard.Key + private clickCooldown = 0 + private readonly COOLDOWN = 300 + + /** Emitted when the tool changes — pass (tool, label) */ + onToolChange?: (tool: FarmingTool, label: string) => void + /** Emitted for toast notifications */ + onMessage?: (msg: string) => void + + constructor(scene: Phaser.Scene, adapter: LocalAdapter) { + this.scene = scene + this.adapter = adapter + } + + create(): void { + // Restore crop sprites for any saved crops + const state = stateManager.getState() + for (const crop of Object.values(state.world.crops)) { + this.spawnCropSprite(crop) + } + + this.toolKey = this.scene.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.F) + + // Left-click to use current tool + this.scene.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => { + if (this.currentTool === 'none') return + if (ptr.rightButtonDown()) { this.setTool('none'); return } + if (this.clickCooldown > 0) return + this.useToolAt(ptr) + this.clickCooldown = this.COOLDOWN + }) + } + + /** Called every frame. */ + update(delta: number): void { + if (this.clickCooldown > 0) this.clickCooldown -= delta + + // F key cycles through tools + if (Phaser.Input.Keyboard.JustDown(this.toolKey)) { + const idx = TOOL_CYCLE.indexOf(this.currentTool) + this.setTool(TOOL_CYCLE[(idx + 1) % TOOL_CYCLE.length]) + } + + // Tick crop growth + const leveled = stateManager.tickCrops(delta) + for (const id of leveled) this.refreshCropSprite(id) + } + + getCurrentTool(): FarmingTool { return this.currentTool } + + private setTool(tool: FarmingTool): void { + this.currentTool = tool + this.onToolChange?.(tool, TOOL_LABELS[tool]) + } + + // ─── Tool actions ───────────────────────────────────────────────────────── + + private useToolAt(ptr: Phaser.Input.Pointer): void { + const cam = this.scene.cameras.main + const worldX = cam.scrollX + ptr.x + const worldY = cam.scrollY + ptr.y + const tileX = Math.floor(worldX / TILE_SIZE) + const tileY = Math.floor(worldY / TILE_SIZE) + const state = stateManager.getState() + const tile = state.world.tiles[tileY * 512 + tileX] as TileType + + if (this.currentTool === 'hoe') this.tillSoil(tileX, tileY, tile) + else if (this.currentTool === 'water') this.waterTile(tileX, tileY, tile) + else this.plantCrop(tileX, tileY, tile, this.currentTool.replace('_seed', '') as CropKind) + } + + private tillSoil(tileX: number, tileY: number, tile: TileType): void { + if (tile !== TileType.GRASS && tile !== TileType.DARK_GRASS) { + this.onMessage?.('Hoe only works on grass!') + return + } + const state = stateManager.getState() + const blocked = + Object.values(state.world.resources).some(r => r.tileX === tileX && r.tileY === tileY) || + Object.values(state.world.buildings).some(b => b.tileX === tileX && b.tileY === tileY) || + Object.values(state.world.crops).some(c => c.tileX === tileX && c.tileY === tileY) + if (blocked) { this.onMessage?.('Something is in the way!'); return } + + this.adapter.send({ type: 'CHANGE_TILE', tileX, tileY, tile: TileType.TILLED_SOIL }) + this.onMessage?.('Soil tilled ✓') + } + + private plantCrop(tileX: number, tileY: number, tile: TileType, kind: CropKind): void { + if (tile !== TileType.TILLED_SOIL && tile !== TileType.WATERED_SOIL) { + this.onMessage?.('Plant on tilled soil!') + return + } + const state = stateManager.getState() + const seedItem: ItemId = `${kind}_seed` as ItemId + const have = state.world.stockpile[seedItem] ?? 0 + if (have <= 0) { this.onMessage?.(`No ${kind} seeds left!`); return } + + if (Object.values(state.world.crops).some(c => c.tileX === tileX && c.tileY === tileY)) { + this.onMessage?.('Already planted here!') + return + } + + const cfg = CROP_CONFIGS[kind] + const crop: CropState = { + id: `crop_${tileX}_${tileY}_${Date.now()}`, + tileX, tileY, kind, + stage: 0, maxStage: cfg.stages, + stageTimerMs: cfg.stageTimeMs, + watered: tile === TileType.WATERED_SOIL, + } + this.adapter.send({ type: 'PLANT_CROP', crop, seedItem }) + this.spawnCropSprite(crop) + this.onMessage?.(`${kind} seed planted! 🌱`) + } + + private waterTile(tileX: number, tileY: number, tile: TileType): void { + if (tile !== TileType.TILLED_SOIL && tile !== TileType.WATERED_SOIL) { + this.onMessage?.('Water tilled soil!') + return + } + this.adapter.send({ type: 'CHANGE_TILE', tileX, tileY, tile: TileType.WATERED_SOIL }) + const state = stateManager.getState() + const crop = Object.values(state.world.crops).find(c => c.tileX === tileX && c.tileY === tileY) + if (crop) this.adapter.send({ type: 'WATER_CROP', cropId: crop.id }) + this.onMessage?.('Watered! (2× growth speed)') + } + + harvestCrop(id: string): void { + const state = stateManager.getState() + const crop = state.world.crops[id] + if (!crop) return + const cfg = CROP_CONFIGS[crop.kind] + this.adapter.send({ type: 'HARVEST_CROP', cropId: id, rewards: cfg.rewards }) + this.adapter.send({ type: 'CHANGE_TILE', tileX: crop.tileX, tileY: crop.tileY, tile: TileType.TILLED_SOIL }) + this.removeCropSprite(id) + const rewardStr = Object.entries(cfg.rewards).map(([k, v]) => `+${v} ${k}`).join(', ') + this.onMessage?.(`${crop.kind} harvested! ${rewardStr}`) + } + + // ─── Sprite management ──────────────────────────────────────────────────── + + private spawnCropSprite(crop: CropState): void { + const x = (crop.tileX + 0.5) * TILE_SIZE + const y = (crop.tileY + 0.5) * TILE_SIZE + const key = this.spriteKey(crop.kind, crop.stage, crop.maxStage) + const sprite = this.scene.add.image(x, y, key) + sprite.setOrigin(0.5, 0.85).setDepth(7) + this.cropSprites.set(crop.id, sprite) + } + + private refreshCropSprite(cropId: string): void { + const sprite = this.cropSprites.get(cropId) + if (!sprite) return + const crop = stateManager.getState().world.crops[cropId] + if (!crop) return + sprite.setTexture(this.spriteKey(crop.kind, crop.stage, crop.maxStage)) + // Subtle pop animation on growth + this.scene.tweens.add({ + targets: sprite, scaleX: 1.25, scaleY: 1.25, duration: 80, + yoyo: true, ease: 'Back.easeOut', + }) + } + + /** Called by VillagerSystem when a villager harvests a crop */ + public removeCropSpritePublic(id: string): void { + this.removeCropSprite(id) + } + + private removeCropSprite(id: string): void { + const s = this.cropSprites.get(id) + if (s) { s.destroy(); this.cropSprites.delete(id) } + } + + private spriteKey(kind: CropKind, stage: number, maxStage: number): string { + return `crop_${kind}_${Math.min(stage, maxStage)}` + } + + destroy(): void { + for (const id of [...this.cropSprites.keys()]) this.removeCropSprite(id) + } +} diff --git a/src/systems/PlayerSystem.ts b/src/systems/PlayerSystem.ts new file mode 100644 index 0000000..9cb39d5 --- /dev/null +++ b/src/systems/PlayerSystem.ts @@ -0,0 +1,120 @@ +import Phaser from 'phaser' +import { PLAYER_SPEED, TILE_SIZE, CAMERA_LERP } from '../config' +import { stateManager } from '../StateManager' +import type { LocalAdapter } from '../NetworkAdapter' + +export class PlayerSystem { + private scene: Phaser.Scene + private adapter: LocalAdapter + sprite!: Phaser.Physics.Arcade.Sprite + private keys!: { + up: Phaser.Input.Keyboard.Key + down: Phaser.Input.Keyboard.Key + left: Phaser.Input.Keyboard.Key + right: Phaser.Input.Keyboard.Key + w: Phaser.Input.Keyboard.Key + s: Phaser.Input.Keyboard.Key + a: Phaser.Input.Keyboard.Key + d: Phaser.Input.Keyboard.Key + } + private facing: 'up' | 'down' | 'left' | 'right' = 'down' + private moveTickTimer = 0 + private readonly SAVE_TICK = 500 // save position every 500ms + + constructor(scene: Phaser.Scene, adapter: LocalAdapter) { + this.scene = scene + this.adapter = adapter + } + + create(groundLayer: Phaser.Tilemaps.TilemapLayer): void { + const state = stateManager.getState() + + this.sprite = this.scene.physics.add.sprite( + state.player.x, + state.player.y, + 'player_down' + ) + + this.sprite.setCollideWorldBounds(true) + this.sprite.setDepth(10) + // Make the physics body smaller than the sprite for better feel + this.sprite.setBodySize(TILE_SIZE - 10, TILE_SIZE - 10) + + // Collide with tilemap + this.scene.physics.add.collider(this.sprite, groundLayer) + + // Camera follows player + this.scene.cameras.main.startFollow(this.sprite, true, CAMERA_LERP, CAMERA_LERP) + + // Input + const kb = this.scene.input.keyboard! + this.keys = { + up: kb.addKey(Phaser.Input.Keyboard.KeyCodes.UP), + down: kb.addKey(Phaser.Input.Keyboard.KeyCodes.DOWN), + left: kb.addKey(Phaser.Input.Keyboard.KeyCodes.LEFT), + right: kb.addKey(Phaser.Input.Keyboard.KeyCodes.RIGHT), + w: kb.addKey(Phaser.Input.Keyboard.KeyCodes.W), + s: kb.addKey(Phaser.Input.Keyboard.KeyCodes.S), + a: kb.addKey(Phaser.Input.Keyboard.KeyCodes.A), + d: kb.addKey(Phaser.Input.Keyboard.KeyCodes.D), + } + } + + update(delta: number): void { + const up = this.keys.up.isDown || this.keys.w.isDown + const down = this.keys.down.isDown || this.keys.s.isDown + const left = this.keys.left.isDown || this.keys.a.isDown + const right = this.keys.right.isDown || this.keys.d.isDown + + let vx = 0 + let vy = 0 + + if (left) vx -= PLAYER_SPEED + if (right) vx += PLAYER_SPEED + if (up) vy -= PLAYER_SPEED + if (down) vy += PLAYER_SPEED + + // Normalize diagonal movement + if (vx !== 0 && vy !== 0) { + vx *= 0.707 + vy *= 0.707 + } + + this.sprite.setVelocity(vx, vy) + + // Update facing direction + if (vx < 0) this.setFacing('left') + else if (vx > 0) this.setFacing('right') + else if (vy < 0) this.setFacing('up') + else if (vy > 0) this.setFacing('down') + + // Periodically save position to state + this.moveTickTimer += delta + if (this.moveTickTimer >= this.SAVE_TICK) { + this.moveTickTimer = 0 + this.adapter.send({ + type: 'PLAYER_MOVE', + x: this.sprite.x, + y: this.sprite.y, + }) + } + } + + private setFacing(dir: 'up' | 'down' | 'left' | 'right'): void { + if (this.facing === dir) return + this.facing = dir + this.sprite.setTexture(`player_${dir}`) + } + + getPosition(): { x: number; y: number } { + return { x: this.sprite.x, y: this.sprite.y } + } + + getTilePosition(): { tileX: number; tileY: number } { + return { + tileX: Math.floor(this.sprite.x / TILE_SIZE), + tileY: Math.floor(this.sprite.y / TILE_SIZE), + } + } +} + diff --git a/src/systems/ResourceSystem.ts b/src/systems/ResourceSystem.ts new file mode 100644 index 0000000..067c9d6 --- /dev/null +++ b/src/systems/ResourceSystem.ts @@ -0,0 +1,90 @@ +import Phaser from 'phaser' +import { TILE_SIZE } from '../config' +import { TileType } from '../types' +import { stateManager } from '../StateManager' +import type { LocalAdapter } from '../NetworkAdapter' +import type { ResourceNodeState } from '../types' + +interface ResourceSprite { + sprite: Phaser.GameObjects.Image + node: ResourceNodeState + healthBar: Phaser.GameObjects.Graphics +} + +export class ResourceSystem { + private scene: Phaser.Scene + private adapter: LocalAdapter + private sprites = new Map() + + // Emits after each successful harvest: (message: string) + onHarvest?: (message: string) => void + + constructor(scene: Phaser.Scene, adapter: LocalAdapter) { + this.scene = scene + this.adapter = adapter + } + + create(): void { + const state = stateManager.getState() + + // Spawn sprites for all resources in state + for (const node of Object.values(state.world.resources)) { + this.spawnSprite(node) + } + } + + private spawnSprite(node: ResourceNodeState): void { + const x = (node.tileX + 0.5) * TILE_SIZE + const y = (node.tileY + 0.5) * TILE_SIZE + + const key = node.kind === 'tree' ? 'tree' : 'rock' + const sprite = this.scene.add.image(x, y, key) + + // Trees are taller; offset them upward so trunk sits on tile + if (node.kind === 'tree') { + sprite.setOrigin(0.5, 0.85) + } else { + sprite.setOrigin(0.5, 0.75) + } + + sprite.setDepth(5) + + const healthBar = this.scene.add.graphics() + healthBar.setDepth(6) + healthBar.setVisible(false) + + this.sprites.set(node.id, { sprite, node, healthBar }) + } + + update(delta: number): void { + // Hide all health bars each frame (no player proximity detection) + for (const entry of this.sprites.values()) { + entry.healthBar.setVisible(false) + } + } + + private removeSprite(id: string): void { + const entry = this.sprites.get(id) + if (!entry) return + entry.sprite.destroy() + entry.healthBar.destroy() + this.sprites.delete(id) + } + + /** Called by VillagerSystem when a villager finishes chopping/mining */ + public removeResource(id: string): void { + this.removeSprite(id) + } + + /** Called when WorldSystem changes a tile (e.g. after tree removed) */ + syncTileChange(tileX: number, tileY: number, worldSystem: { setTile: (x: number, y: number, type: TileType) => void }): void { + const state = stateManager.getState() + const idx = tileY * 512 + tileX // WORLD_TILES = 512 + const tile = state.world.tiles[idx] as TileType + worldSystem.setTile(tileX, tileY, tile) + } + + destroy(): void { + for (const [id] of this.sprites) this.removeSprite(id) + } +} diff --git a/src/systems/VillagerSystem.ts b/src/systems/VillagerSystem.ts new file mode 100644 index 0000000..4c3ebfd --- /dev/null +++ b/src/systems/VillagerSystem.ts @@ -0,0 +1,393 @@ +import Phaser from 'phaser' +import { TILE_SIZE, VILLAGER_SPEED, VILLAGER_SPAWN_INTERVAL, VILLAGER_WORK_TIMES, VILLAGER_NAMES } from '../config' +import type { VillagerState, VillagerJob, JobType, AIState, ItemId } from '../types' +import { stateManager } from '../StateManager' +import { findPath } from '../utils/pathfinding' +import type { LocalAdapter } from '../NetworkAdapter' +import type { WorldSystem } from './WorldSystem' +import type { ResourceSystem } from './ResourceSystem' +import type { FarmingSystem } from './FarmingSystem' + +const ARRIVAL_PX = 3 + +interface VillagerRuntime { + sprite: Phaser.GameObjects.Image + nameLabel: Phaser.GameObjects.Text + energyBar: Phaser.GameObjects.Graphics + jobIcon: Phaser.GameObjects.Text + path: Array<{ tileX: number; tileY: number }> + destination: 'job' | 'stockpile' | 'bed' | null + workTimer: number + idleScanTimer: number +} + +export class VillagerSystem { + private scene: Phaser.Scene + private adapter: LocalAdapter + private worldSystem: WorldSystem + private resourceSystem!: ResourceSystem + private farmingSystem!: FarmingSystem + + private runtime = new Map() + private claimed = new Set() // target IDs currently claimed by a villager + private spawnTimer = 0 + private nameIndex = 0 + + onMessage?: (msg: string) => void + + constructor(scene: Phaser.Scene, adapter: LocalAdapter, worldSystem: WorldSystem) { + this.scene = scene + this.adapter = adapter + this.worldSystem = worldSystem + } + + /** Wire in sibling systems after construction */ + init(resourceSystem: ResourceSystem, farmingSystem: FarmingSystem): void { + this.resourceSystem = resourceSystem + this.farmingSystem = farmingSystem + } + + create(): void { + const state = stateManager.getState() + for (const v of Object.values(state.world.villagers)) { + this.spawnSprite(v) + // Re-claim any active job targets + if (v.job) this.claimed.add(v.job.targetId) + } + } + + update(delta: number): void { + this.spawnTimer += delta + if (this.spawnTimer >= VILLAGER_SPAWN_INTERVAL) { + this.spawnTimer = 0 + this.trySpawn() + } + + const state = stateManager.getState() + for (const v of Object.values(state.world.villagers)) { + this.tickVillager(v, delta) + } + } + + // ─── Per-villager tick ──────────────────────────────────────────────────── + + private tickVillager(v: VillagerState, delta: number): void { + const rt = this.runtime.get(v.id) + if (!rt) return + + switch (v.aiState as AIState) { + case 'idle': this.tickIdle(v, rt, delta); break + case 'walking': this.tickWalking(v, rt, delta); break + case 'working': this.tickWorking(v, rt, delta); break + case 'sleeping':this.tickSleeping(v, rt, delta); break + } + + // Sync sprite to state position + rt.sprite.setPosition(v.x, v.y) + rt.nameLabel.setPosition(v.x, v.y - 22) + rt.energyBar.setPosition(0, 0) + this.drawEnergyBar(rt.energyBar, v.x, v.y, v.energy) + + // Job icon + const icons: Record = { chop: '🪓', mine: '⛏', farm: '🌾', '': '' } + const jobType = v.aiState === 'sleeping' ? '💤' : (v.job ? (icons[v.job.type] ?? '') : '') + rt.jobIcon.setText(jobType).setPosition(v.x + 10, v.y - 18) + } + + // ─── IDLE ───────────────────────────────────────────────────────────────── + + private tickIdle(v: VillagerState, rt: VillagerRuntime, delta: number): void { + // Decrement scan timer if cooling down + if (rt.idleScanTimer > 0) { + rt.idleScanTimer -= delta + return + } + + // Carrying items? → find stockpile + if (v.job?.carrying && Object.values(v.job.carrying).some(n => (n ?? 0) > 0)) { + const sp = this.nearestBuilding(v, 'stockpile_zone') + if (sp) { this.beginWalk(v, rt, sp.tileX, sp.tileY, 'stockpile'); return } + } + + // Low energy → find bed + if (v.energy < 25) { + const bed = this.findBed(v) + if (bed) { this.beginWalk(v, rt, bed.tileX, bed.tileY, 'bed'); return } + } + + // Find a job + const job = this.pickJob(v) + if (job) { + this.claimed.add(job.targetId) + this.adapter.send({ + type: 'VILLAGER_SET_JOB', villagerId: v.id, + job: { type: job.type, targetId: job.targetId, tileX: job.tileX, tileY: job.tileY, carrying: {} }, + }) + this.beginWalk(v, rt, job.tileX, job.tileY, 'job') + } else { + // No job available — wait before scanning again + rt.idleScanTimer = 800 + Math.random() * 600 + } + } + + // ─── WALKING ────────────────────────────────────────────────────────────── + + private tickWalking(v: VillagerState, rt: VillagerRuntime, delta: number): void { + if (rt.path.length === 0) { + this.onArrived(v, rt) + return + } + + const next = rt.path[0] + const tx = (next.tileX + 0.5) * TILE_SIZE + const ty = (next.tileY + 0.5) * TILE_SIZE + const dx = tx - v.x + const dy = ty - v.y + const dist = Math.hypot(dx, dy) + + if (dist < ARRIVAL_PX) { + ;(v as { x: number; y: number }).x = tx + ;(v as { x: number; y: number }).y = ty + rt.path.shift() + } else { + const step = Math.min(VILLAGER_SPEED * delta / 1000, dist) + ;(v as { x: number; y: number }).x += (dx / dist) * step + ;(v as { x: number; y: number }).y += (dy / dist) * step + rt.sprite.setFlipX(dx < 0) + } + + // Slowly drain energy while active + ;(v as { energy: number }).energy = Math.max(0, v.energy - delta * 0.0015) + } + + private onArrived(v: VillagerState, rt: VillagerRuntime): void { + switch (rt.destination) { + case 'job': + rt.workTimer = VILLAGER_WORK_TIMES[v.job?.type ?? 'chop'] ?? 3000 + this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'working' }) + break + + case 'stockpile': + this.adapter.send({ type: 'VILLAGER_DEPOSIT', villagerId: v.id }) + this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' }) + break + + case 'bed': + this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'sleeping' }) + break + + default: + this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' }) + } + rt.destination = null + } + + // ─── WORKING ────────────────────────────────────────────────────────────── + + private tickWorking(v: VillagerState, rt: VillagerRuntime, delta: number): void { + rt.workTimer -= delta + // Wobble while working + rt.sprite.setAngle(Math.sin(Date.now() / 120) * 8) + + if (rt.workTimer > 0) return + rt.sprite.setAngle(0) + + const job = v.job + if (!job) { this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' }); return } + + this.claimed.delete(job.targetId) + const state = stateManager.getState() + + if (job.type === 'chop') { + if (state.world.resources[job.targetId]) { + this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId }) + this.resourceSystem.removeResource(job.targetId) + } + } else if (job.type === 'mine') { + if (state.world.resources[job.targetId]) { + this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId }) + this.resourceSystem.removeResource(job.targetId) + } + } else if (job.type === 'farm') { + const crop = state.world.crops[job.targetId] + if (crop) { + this.adapter.send({ type: 'VILLAGER_HARVEST_CROP', villagerId: v.id, cropId: job.targetId }) + this.farmingSystem.removeCropSpritePublic(job.targetId) + this.adapter.send({ type: 'CHANGE_TILE', tileX: crop.tileX, tileY: crop.tileY, tile: 9 as any }) + } + } + + // Back to idle so decideAction handles depositing + this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' }) + } + + // ─── SLEEPING ───────────────────────────────────────────────────────────── + + private tickSleeping(v: VillagerState, rt: VillagerRuntime, delta: number): void { + ;(v as { energy: number }).energy = Math.min(100, v.energy + delta * 0.04) + // Gentle bob while sleeping + rt.sprite.setAngle(Math.sin(Date.now() / 600) * 5) + if (v.energy >= 100) { + rt.sprite.setAngle(0) + this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' }) + } + } + + // ─── Job picking (RimWorld-style priority) ──────────────────────────────── + + private pickJob(v: VillagerState): { type: JobType; targetId: string; tileX: number; tileY: number } | null { + const state = stateManager.getState() + const p = v.priorities + const vTX = Math.floor(v.x / TILE_SIZE) + const vTY = Math.floor(v.y / TILE_SIZE) + const dist = (tx: number, ty: number) => Math.abs(tx - vTX) + Math.abs(ty - vTY) + + type C = { type: JobType; targetId: string; tileX: number; tileY: number; dist: number; pri: number } + const candidates: C[] = [] + + if (p.chop > 0) { + for (const res of Object.values(state.world.resources)) { + if (res.kind !== 'tree' || this.claimed.has(res.id)) continue + candidates.push({ type: 'chop', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.chop }) + } + } + if (p.mine > 0) { + for (const res of Object.values(state.world.resources)) { + if (res.kind !== 'rock' || this.claimed.has(res.id)) continue + candidates.push({ type: 'mine', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.mine }) + } + } + if (p.farm > 0) { + for (const crop of Object.values(state.world.crops)) { + if (crop.stage < crop.maxStage || this.claimed.has(crop.id)) continue + candidates.push({ type: 'farm', targetId: crop.id, tileX: crop.tileX, tileY: crop.tileY, dist: dist(crop.tileX, crop.tileY), pri: p.farm }) + } + } + + if (candidates.length === 0) return null + + // Lowest priority number wins; ties broken by distance + const bestPri = Math.min(...candidates.map(c => c.pri)) + return candidates + .filter(c => c.pri === bestPri) + .sort((a, b) => a.dist - b.dist)[0] ?? null + } + + // ─── Pathfinding ────────────────────────────────────────────────────────── + + private beginWalk(v: VillagerState, rt: VillagerRuntime, tileX: number, tileY: number, dest: VillagerRuntime['destination']): void { + const sx = Math.floor(v.x / TILE_SIZE) + const sy = Math.floor(v.y / TILE_SIZE) + const path = findPath(sx, sy, tileX, tileY, (x, y) => this.worldSystem.isPassable(x, y), 700) + + if (!path) { + if (v.job) { + this.claimed.delete(v.job.targetId) + this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: null }) + } + rt.idleScanTimer = 1500 // longer delay after failed pathfind + return + } + + rt.path = path + rt.destination = dest + this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'walking' }) + } + + // ─── Building finders ───────────────────────────────────────────────────── + + private nearestBuilding(v: VillagerState, kind: string): { tileX: number; tileY: number } | null { + const state = stateManager.getState() + const hits = Object.values(state.world.buildings).filter(b => b.kind === kind) + if (hits.length === 0) return null + const vx = v.x / TILE_SIZE + const vy = v.y / TILE_SIZE + return hits.sort((a, b) => Math.hypot(a.tileX - vx, a.tileY - vy) - Math.hypot(b.tileX - vx, b.tileY - vy))[0] + } + + private findBed(v: VillagerState): { id: string; tileX: number; tileY: number } | null { + const state = stateManager.getState() + // Prefer assigned bed + if (v.bedId && state.world.buildings[v.bedId]) return state.world.buildings[v.bedId] as any + return this.nearestBuilding(v, 'bed') as any + } + + // ─── Spawning ───────────────────────────────────────────────────────────── + + private trySpawn(): void { + const state = stateManager.getState() + const beds = Object.values(state.world.buildings).filter(b => b.kind === 'bed') + const current = Object.keys(state.world.villagers).length + if (current >= beds.length || beds.length === 0) return + + // Find a free bed (not assigned to any existing villager) + const taken = new Set(Object.values(state.world.villagers).map(v => v.bedId)) + const freeBed = beds.find(b => !taken.has(b.id)) + if (!freeBed) return + + const name = VILLAGER_NAMES[this.nameIndex % VILLAGER_NAMES.length] + this.nameIndex++ + + const villager: VillagerState = { + id: `villager_${Date.now()}`, + name, + x: (freeBed.tileX + 0.5) * TILE_SIZE, + y: (freeBed.tileY + 0.5) * TILE_SIZE, + bedId: freeBed.id, + job: null, + priorities: { chop: 1, mine: 2, farm: 3 }, + energy: 100, + aiState: 'idle', + } + + this.adapter.send({ type: 'SPAWN_VILLAGER', villager }) + this.spawnSprite(villager) + this.onMessage?.(`${name} has joined the settlement! 🏘`) + } + + // ─── Sprite management ──────────────────────────────────────────────────── + + private spawnSprite(v: VillagerState): void { + const sprite = this.scene.add.image(v.x, v.y, 'villager').setDepth(11) + + const nameLabel = this.scene.add.text(v.x, v.y - 22, v.name, { + fontSize: '8px', color: '#ffffff', fontFamily: 'monospace', + backgroundColor: '#00000088', padding: { x: 2, y: 1 }, + }).setOrigin(0.5, 1).setDepth(12) + + const energyBar = this.scene.add.graphics().setDepth(12) + const jobIcon = this.scene.add.text(v.x, v.y - 18, '', { fontSize: '10px' }).setDepth(13) + + this.runtime.set(v.id, { sprite, nameLabel, energyBar, jobIcon, path: [], destination: null, workTimer: 0, idleScanTimer: 0 }) + } + + private drawEnergyBar(g: Phaser.GameObjects.Graphics, x: number, y: number, energy: number): void { + const W = 20, H = 3 + g.clear() + g.fillStyle(0x222222); g.fillRect(x - W/2, y - 28, W, H) + const col = energy > 60 ? 0x4CAF50 : energy > 30 ? 0xFF9800 : 0xF44336 + g.fillStyle(col); g.fillRect(x - W/2, y - 28, W * (energy / 100), H) + } + + // ─── Public API ─────────────────────────────────────────────────────────── + + getStatusText(villagerId: string): string { + const v = stateManager.getState().world.villagers[villagerId] + if (!v) return '—' + if (v.aiState === 'sleeping') return '💤 Sleeping' + if (v.aiState === 'working' && v.job) return `⚒ ${v.job.type}ing` + if (v.aiState === 'walking' && v.job) return `🚶 → ${v.job.type}` + if (v.aiState === 'walking') return '🚶 Walking' + const carrying = v.job?.carrying + if (carrying && Object.values(carrying).some(n => (n ?? 0) > 0)) return '📦 Hauling' + return '💭 Idle' + } + + destroy(): void { + for (const rt of this.runtime.values()) { + rt.sprite.destroy(); rt.nameLabel.destroy() + rt.energyBar.destroy(); rt.jobIcon.destroy() + } + this.runtime.clear() + } +} diff --git a/src/systems/WorldSystem.ts b/src/systems/WorldSystem.ts new file mode 100644 index 0000000..26ea7b7 --- /dev/null +++ b/src/systems/WorldSystem.ts @@ -0,0 +1,128 @@ +import Phaser from 'phaser' +import { TILE_SIZE, WORLD_TILES } from '../config' +import { TileType, IMPASSABLE } from '../types' +import { stateManager } from '../StateManager' + +const BIOME_COLORS: Record = { + 0: '#1565C0', // DEEP_WATER + 1: '#42A5F5', // SHALLOW_WATER + 2: '#F5DEB3', // SAND + 3: '#66BB6A', // GRASS + 4: '#43A047', // DARK_GRASS + 5: '#33691E', // FOREST + 6: '#616161', // ROCK + // Built types: show grass below + 7: '#66BB6A', 8: '#66BB6A', 9: '#66BB6A', 10: '#66BB6A', +} + +export class WorldSystem { + private scene: Phaser.Scene + private map!: Phaser.Tilemaps.Tilemap + private tileset!: Phaser.Tilemaps.Tileset + private bgImage!: Phaser.GameObjects.Image + private builtLayer!: Phaser.Tilemaps.TilemapLayer + + constructor(scene: Phaser.Scene) { + this.scene = scene + } + + create(): void { + const state = stateManager.getState() + + // --- Canvas background (1px per tile, scaled up, LINEAR filtered) --- + const canvas = document.createElement('canvas') + canvas.width = WORLD_TILES + canvas.height = WORLD_TILES + const ctx = canvas.getContext('2d')! + + for (let y = 0; y < WORLD_TILES; y++) { + for (let x = 0; x < WORLD_TILES; x++) { + const tile = state.world.tiles[y * WORLD_TILES + x] + ctx.fillStyle = BIOME_COLORS[tile] ?? '#0a2210' + ctx.fillRect(x, y, 1, 1) + } + } + + this.scene.textures.addCanvas('terrain_bg', canvas) + this.bgImage = this.scene.add.image(0, 0, 'terrain_bg') + .setOrigin(0, 0) + .setScale(TILE_SIZE) + .setDepth(0) + this.scene.textures.get('terrain_bg').setFilter(Phaser.Textures.FilterMode.LINEAR) + + // --- Built tile layer (sparse — only FLOOR, WALL, TILLED_SOIL, WATERED_SOIL) --- + this.map = this.scene.make.tilemap({ + tileWidth: TILE_SIZE, + tileHeight: TILE_SIZE, + width: WORLD_TILES, + height: WORLD_TILES, + }) + + const ts = this.map.addTilesetImage('tiles', 'tiles', TILE_SIZE, TILE_SIZE, 0, 0, 0) + if (!ts) throw new Error('Failed to add tileset') + this.tileset = ts + + const layer = this.map.createBlankLayer('built', this.tileset, 0, 0) + if (!layer) throw new Error('Failed to create built layer') + this.builtLayer = layer + this.builtLayer.setDepth(1) + + const BUILT_TILES = new Set([7, 8, 9, 10]) // FLOOR, WALL, TILLED_SOIL, WATERED_SOIL + for (let y = 0; y < WORLD_TILES; y++) { + for (let x = 0; x < WORLD_TILES; x++) { + const t = state.world.tiles[y * WORLD_TILES + x] + if (BUILT_TILES.has(t)) { + this.builtLayer.putTileAt(t, x, y) + } + } + } + + // Camera bounds + this.scene.cameras.main.setBounds(0, 0, WORLD_TILES * TILE_SIZE, WORLD_TILES * TILE_SIZE) + } + + getLayer(): Phaser.Tilemaps.TilemapLayer { + return this.builtLayer + } + + setTile(tileX: number, tileY: number, type: TileType): void { + const BUILT_TILES = new Set([TileType.FLOOR, TileType.WALL, TileType.TILLED_SOIL, TileType.WATERED_SOIL]) + if (BUILT_TILES.has(type)) { + this.builtLayer.putTileAt(type, tileX, tileY) + } else { + // Reverting to natural: remove from built layer + this.builtLayer.removeTileAt(tileX, tileY) + } + } + + isPassable(tileX: number, tileY: number): boolean { + if (tileX < 0 || tileY < 0 || tileX >= WORLD_TILES || tileY >= WORLD_TILES) return false + const state = stateManager.getState() + const tile = state.world.tiles[tileY * WORLD_TILES + tileX] + return !IMPASSABLE.has(tile) + } + + worldToTile(worldX: number, worldY: number): { tileX: number; tileY: number } { + return { + tileX: Math.floor(worldX / TILE_SIZE), + tileY: Math.floor(worldY / TILE_SIZE), + } + } + + tileToWorld(tileX: number, tileY: number): { x: number; y: number } { + return { + x: tileX * TILE_SIZE + TILE_SIZE / 2, + y: tileY * TILE_SIZE + TILE_SIZE / 2, + } + } + + getTileType(tileX: number, tileY: number): TileType { + const state = stateManager.getState() + return state.world.tiles[tileY * WORLD_TILES + tileX] as TileType + } + + destroy(): void { + this.map.destroy() + this.bgImage.destroy() + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..b80f6bf --- /dev/null +++ b/src/types.ts @@ -0,0 +1,125 @@ +export enum TileType { + DEEP_WATER = 0, + SHALLOW_WATER = 1, + SAND = 2, + GRASS = 3, + DARK_GRASS = 4, + FOREST = 5, + ROCK = 6, + FLOOR = 7, + WALL = 8, + TILLED_SOIL = 9, + WATERED_SOIL = 10, +} + +export const IMPASSABLE = new Set([ + TileType.DEEP_WATER, + TileType.SHALLOW_WATER, + TileType.FOREST, + TileType.ROCK, + TileType.WALL, +]) + +export type ItemId = 'wood' | 'stone' | 'wheat_seed' | 'carrot_seed' | 'wheat' | 'carrot' + +export type BuildingType = 'floor' | 'wall' | 'chest' | 'bed' | 'stockpile_zone' + +export type CropKind = 'wheat' | 'carrot' + +export type JobType = 'chop' | 'mine' | 'farm' + +export type AIState = 'idle' | 'walking' | 'working' | 'sleeping' + +export interface JobPriorities { + chop: number // 0 = disabled, 1 = highest, 4 = lowest + mine: number + farm: number +} + +export interface VillagerJob { + type: JobType + targetId: string + tileX: number + tileY: number + carrying: Partial> +} + +export interface VillagerState { + id: string + name: string + x: number + y: number + bedId: string | null + job: VillagerJob | null + priorities: JobPriorities + energy: number + aiState: AIState +} + +export interface ResourceNodeState { + id: string + tileX: number + tileY: number + kind: 'tree' | 'rock' + hp: number +} + +export interface BuildingState { + id: string + tileX: number + tileY: number + kind: BuildingType + ownerId: string +} + +export interface CropState { + id: string + tileX: number + tileY: number + kind: CropKind + stage: number + maxStage: number + stageTimerMs: number + watered: boolean +} + +export interface PlayerState { + id: string + x: number + y: number + inventory: Partial> +} + +export interface WorldState { + seed: number + tiles: number[] + resources: Record + buildings: Record + crops: Record + villagers: Record + stockpile: Partial> +} + +export interface GameStateData { + version: number + world: WorldState + player: PlayerState +} + +export type GameAction = + | { type: 'PLAYER_MOVE'; x: number; y: number } + | { type: 'HARVEST_RESOURCE'; resourceId: string; rewards: Partial> } + | { type: 'CHANGE_TILE'; tileX: number; tileY: number; tile: TileType } + | { type: 'PLACE_BUILDING'; building: BuildingState; costs: Partial> } + | { type: 'REMOVE_BUILDING'; buildingId: string } + | { type: 'ADD_ITEMS'; items: Partial> } + | { type: 'PLANT_CROP'; crop: CropState; seedItem: ItemId } + | { type: 'WATER_CROP'; cropId: string } + | { type: 'HARVEST_CROP'; cropId: string; rewards: Partial> } + | { type: 'SPAWN_VILLAGER'; villager: VillagerState } + | { type: 'VILLAGER_SET_JOB'; villagerId: string; job: VillagerJob | null } + | { type: 'VILLAGER_SET_AI'; villagerId: string; aiState: AIState } + | { type: 'VILLAGER_HARVEST_RESOURCE'; villagerId: string; resourceId: string } + | { type: 'VILLAGER_HARVEST_CROP'; villagerId: string; cropId: string } + | { type: 'VILLAGER_DEPOSIT'; villagerId: string } + | { type: 'UPDATE_PRIORITIES'; villagerId: string; priorities: JobPriorities } diff --git a/src/utils/noise.ts b/src/utils/noise.ts new file mode 100644 index 0000000..dc78503 --- /dev/null +++ b/src/utils/noise.ts @@ -0,0 +1,77 @@ +import { createNoise2D } from 'simplex-noise' +import { WORLD_TILES } from '../config' +import { TileType } from '../types' + +/** Simple seeded PRNG (mulberry32) */ +function mulberry32(seed: number): () => number { + return () => { + seed |= 0 + seed = (seed + 0x6D2B79F5) | 0 + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +function classify(e: number, m: number): TileType { + if (e < 0.22) return TileType.DEEP_WATER + if (e < 0.30) return TileType.SHALLOW_WATER + if (e < 0.38) return TileType.SAND + if (e > 0.82) return TileType.ROCK + // land – split by moisture + if (m > 0.62 && e > 0.48) return TileType.FOREST + if (m > 0.38) return TileType.DARK_GRASS + return TileType.GRASS +} + +export function generateTerrain(seed: number): number[] { + const prng1 = mulberry32(seed) + const prng2 = mulberry32(seed ^ 0xDEADBEEF) + + const elevNoise = createNoise2D(prng1) + const moistNoise = createNoise2D(prng2) + + const size = WORLD_TILES + const tiles = new Array(size * size) + + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + // Multi-octave elevation + const nx = x / size + const ny = y / size + const e = + (elevNoise(nx * 4, ny * 4) * 1.0 + + elevNoise(nx * 8, ny * 8) * 0.5 + + elevNoise(nx * 16, ny * 16) * 0.25) / 1.75 + const eNorm = (e + 1) / 2 // -1..1 → 0..1 + + const m = moistNoise(nx * 6 + 10, ny * 6 + 10) + const mNorm = (m + 1) / 2 + + tiles[y * size + x] = classify(eNorm, mNorm) + } + } + + return tiles +} + +/** Find a walkable spawn tile near the world center */ +export function findSpawn(tiles: number[]): { tileX: number; tileY: number } { + const center = Math.floor(WORLD_TILES / 2) + const walkable = new Set([TileType.GRASS, TileType.DARK_GRASS, TileType.SAND]) + + for (let r = 0; r < center; r++) { + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue + const tx = center + dx + const ty = center + dy + if (tx < 0 || ty < 0 || tx >= WORLD_TILES || ty >= WORLD_TILES) continue + if (walkable.has(tiles[ty * WORLD_TILES + tx])) { + return { tileX: tx, tileY: ty } + } + } + } + } + return { tileX: center, tileY: center } +} diff --git a/src/utils/pathfinding.ts b/src/utils/pathfinding.ts new file mode 100644 index 0000000..f03df6d --- /dev/null +++ b/src/utils/pathfinding.ts @@ -0,0 +1,60 @@ +interface PFNode { + x: number; y: number + g: number; h: number; f: number + parent: PFNode | null +} + +/** A* pathfinding on a tile grid. Returns array of tile steps (excluding start). + * Returns null if no path found within maxIterations. */ +export function findPath( + startX: number, startY: number, + goalX: number, goalY: number, + isPassable: (x: number, y: number) => boolean, + maxIterations = 600, +): Array<{ tileX: number; tileY: number }> | null { + + if (startX === goalX && startY === goalY) return [] + + const key = (x: number, y: number) => x * 10_000 + y + const heur = (x: number, y: number) => Math.abs(x - goalX) + Math.abs(y - goalY) + + const open = new Map() + const closed = new Set() + + open.set(key(startX, startY), { x: startX, y: startY, g: 0, h: heur(startX, startY), f: heur(startX, startY), parent: null }) + + const DIRS = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]] as const + + for (let iter = 0; iter < maxIterations && open.size > 0; iter++) { + // Cheapest open node + let current!: PFNode + for (const n of open.values()) if (!current || n.f < current.f) current = n + + if (current.x === goalX && current.y === goalY) { + const path: Array<{ tileX: number; tileY: number }> = [] + let n: PFNode | null = current + while (n) { path.unshift({ tileX: n.x, tileY: n.y }); n = n.parent } + return path.slice(1) // exclude start tile + } + + open.delete(key(current.x, current.y)) + closed.add(key(current.x, current.y)) + + for (const [dx, dy] of DIRS) { + const nx = current.x + dx + const ny = current.y + dy + const nk = key(nx, ny) + if (closed.has(nk)) continue + + const isGoal = nx === goalX && ny === goalY + if (!isGoal && !isPassable(nx, ny)) continue + + const g = current.g + (dx !== 0 && dy !== 0 ? 1.414 : 1) + const ex = open.get(nk) + if (!ex || g < ex.g) { + open.set(nk, { x: nx, y: ny, g, h: heur(nx, ny), f: g + heur(nx, ny), parent: current }) + } + } + } + return null +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3af070c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2023", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "erasableSyntaxOnly": false, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..b6ab531 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + server: { + port: 3000, + host: true + }, + build: { + outDir: 'dist', + assetsInlineLimit: 0 + } +})