🎉 initial commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
19
CHANGELOG.md
Normal file
19
CHANGELOG.md
Normal 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
64
CLAUDE.md
Normal 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
16
index.html
Normal 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
908
package-lock.json
generated
Normal 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
19
package.json
Normal 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
19
src/NetworkAdapter.ts
Normal 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
193
src/StateManager.ts
Normal 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
50
src/config.ts
Normal 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
30
src/main.ts
Normal 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
365
src/scenes/BootScene.ts
Normal 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
130
src/scenes/GameScene.ts
Normal 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
283
src/scenes/UIScene.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
182
src/systems/BuildingSystem.ts
Normal file
182
src/systems/BuildingSystem.ts
Normal 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
105
src/systems/CameraSystem.ts
Normal 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) }
|
||||
}
|
||||
}
|
||||
205
src/systems/FarmingSystem.ts
Normal file
205
src/systems/FarmingSystem.ts
Normal 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
120
src/systems/PlayerSystem.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
90
src/systems/ResourceSystem.ts
Normal file
90
src/systems/ResourceSystem.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
393
src/systems/VillagerSystem.ts
Normal file
393
src/systems/VillagerSystem.ts
Normal 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
128
src/systems/WorldSystem.ts
Normal 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
125
src/types.ts
Normal 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
77
src/utils/noise.ts
Normal 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
60
src/utils/pathfinding.ts
Normal 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
26
tsconfig.json
Normal 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
12
vite.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 3000,
|
||||
host: true
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsInlineLimit: 0
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user