🎉 initial commit

This commit is contained in:
2026-03-20 08:11:31 +00:00
commit fe389a9856
25 changed files with 3621 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

19
CHANGELOG.md Normal file
View File

@@ -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
---

64
CLAUDE.md Normal file
View File

@@ -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

16
index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Topdown Game</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #000; overflow: hidden; display: flex; justify-content: center; align-items: center; height: 100vh; }
canvas { display: block; }
</style>
</head>
<body>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

908
package-lock.json generated Normal file
View File

@@ -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
}
}
}
}
}

19
package.json Normal file
View File

@@ -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"
}
}

19
src/NetworkAdapter.ts Normal file
View File

@@ -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)
}
}

193
src/StateManager.ts Normal file
View File

@@ -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<GameStateData> { 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()

50
src/config.ts Normal file
View File

@@ -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<BuildingType, Record<string, number>> = {
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<Record<ItemId, number>>
}
export const CROP_CONFIGS: Record<CropKind, CropConfig> = {
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<string, number> = {
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

30
src/main.ts Normal file
View File

@@ -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)

365
src/scenes/BootScene.ts Normal file
View File

@@ -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
}
}
}

130
src/scenes/GameScene.ts Normal file
View File

@@ -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()
}
}

283
src/scenes/UIScene.ts Normal file
View File

@@ -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<string, string> = {
wood: '🪵', stone: '🪨', wheat_seed: '🌱', carrot_seed: '🥕',
wheat: '🌾', carrot: '🧡',
}
export class UIScene extends Phaser.Scene {
private stockpileTexts: Map<string, Phaser.GameObjects.Text> = 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)
}
}

View File

@@ -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<Record<BuildingType, TileType>> = {
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()
}
}

105
src/systems/CameraSystem.ts Normal file
View File

@@ -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) }
}
}

View File

@@ -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<FarmingTool, string> = {
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<string, Phaser.GameObjects.Image>()
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)
}
}

120
src/systems/PlayerSystem.ts Normal file
View File

@@ -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),
}
}
}

View File

@@ -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<string, ResourceSprite>()
// 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)
}
}

View File

@@ -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<string, VillagerRuntime>()
private claimed = new Set<string>() // 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<string, string> = { 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()
}
}

128
src/systems/WorldSystem.ts Normal file
View File

@@ -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<number, string> = {
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()
}
}

125
src/types.ts Normal file
View File

@@ -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<number>([
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<Record<ItemId, number>>
}
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<Record<ItemId, number>>
}
export interface WorldState {
seed: number
tiles: number[]
resources: Record<string, ResourceNodeState>
buildings: Record<string, BuildingState>
crops: Record<string, CropState>
villagers: Record<string, VillagerState>
stockpile: Partial<Record<ItemId, number>>
}
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<Record<ItemId, number>> }
| { type: 'CHANGE_TILE'; tileX: number; tileY: number; tile: TileType }
| { type: 'PLACE_BUILDING'; building: BuildingState; costs: Partial<Record<ItemId, number>> }
| { type: 'REMOVE_BUILDING'; buildingId: string }
| { type: 'ADD_ITEMS'; items: Partial<Record<ItemId, number>> }
| { type: 'PLANT_CROP'; crop: CropState; seedItem: ItemId }
| { type: 'WATER_CROP'; cropId: string }
| { type: 'HARVEST_CROP'; cropId: string; rewards: Partial<Record<ItemId, number>> }
| { 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 }

77
src/utils/noise.ts Normal file
View File

@@ -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<number>(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 }
}

60
src/utils/pathfinding.ts Normal file
View File

@@ -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<number, PFNode>()
const closed = new Set<number>()
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
}

26
tsconfig.json Normal file
View File

@@ -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"]
}

12
vite.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
export default defineConfig({
server: {
port: 3000,
host: true
},
build: {
outDir: 'dist',
assetsInlineLimit: 0
}
})