✨ Försterkreislauf: Setzlinge beim Fällen, Försterhaus, Förster-Job
- Gefällter Baum → 1–2 tree_seed im Stockpile (zufällig) - Neues Gebäude forester_hut (50 wood): Log-Hütten-Grafik, Klick öffnet Info-Panel - Zonenmarkierung: Edit-Zone-Tool, Radius 5 Tiles, halbtransparente Overlay-Anzeige - Neuer JobType 'forester': Nisse pflanzen Setzlinge auf markierten Zonen-Tiles - Chop-Priorisierung: Zonen-Bäume werden vor natürlichen Bäumen gefällt - Nisse-Panel & Info-Panel zeigen forester-Priorität-Button Closes #25 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Försterkreislauf** (Issue #25):
|
||||
- **Setzlinge beim Fällen**: Jeder gefällte Baum gibt 1–2 `tree_seed` in den Stockpile
|
||||
- **Försterhaus** (`forester_hut`): Neues Gebäude im Build-Menü (Kosten: 50 wood); Log-Hütten-Grafik mit Baum-Symbol; Klick auf das Haus öffnet ein Info-Panel
|
||||
- **Zonenmarkierung**: Im Info-Panel öffnet „Edit Zone" den Zonen-Editor; innerhalb eines Radius von 5 Tiles können Tiles per Klick zur Pflanzzone hinzugefügt oder entfernt werden; markierte Tiles werden als halbtransparente grüne Fläche im Spiel angezeigt; Zone wird im Save gespeichert
|
||||
- **Förster-Job** (`forester`): Nisse mit `forester`-Priorität > 0 pflanzen automatisch `tree_seed` auf leeren Zonen-Tiles; erfordert `tree_seed` im Stockpile
|
||||
- **Chop-Priorisierung**: Beim Fällen werden Bäume innerhalb von Förster-Zonen bevorzugt; natürliche Bäume werden erst gefällt wenn keine Zonen-Bäume mehr vorhanden sind
|
||||
- Nisse-Info-Panel und Nisse-Panel (V) zeigen jetzt auch die `forester`-Priorität als Schaltfläche
|
||||
|
||||
### Fixed
|
||||
- **Nisse idle loop** (Issue #22): Nisse no longer retry unreachable trees/rocks in an infinite 1.5 s loop — `pickJob` now skips resources with no adjacent passable tile via `hasAdjacentPassable()`; pathfind-fail cooldown raised to 4 s
|
||||
- **Resource-based passability** (Issue #22): FOREST and ROCK terrain tiles are only impassable when a tree/rock resource occupies them — empty forest floor and rocky ground are now walkable; `WorldSystem` maintains an O(1) `resourceTiles` index kept in sync at runtime
|
||||
|
||||
79
CLAUDE.md
79
CLAUDE.md
@@ -73,3 +73,82 @@ npm run preview # Preview production build locally
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
## Gitea Workflow (repo: tekki/nissefolk)
|
||||
|
||||
**Tool:** `tea` CLI (installed at `~/.local/bin/tea`, login `zally` configured).
|
||||
Never use raw `curl` with `${CLAUDE_GITEA_TOKEN}` for Gitea — use `tea` instead.
|
||||
All `tea` commands run from `~/game` (git remote `gitea` points to the repo).
|
||||
|
||||
**Git commands:** Always use `git -C ~/game <cmd>` — never `cd ~/game && git <cmd>` (triggers security prompt).
|
||||
|
||||
```bash
|
||||
# Create PR (always wait for user approval before merging)
|
||||
# Use ~/scripts/create-pr.sh — pass \n literally for newlines, the script expands them via printf.
|
||||
# Never use heredocs or $(cat file) — they trigger permission prompts.
|
||||
~/scripts/create-pr.sh "PR title" "Fixes #N.\n\n## What changed\n- item one\n- item two" feature/xyz
|
||||
|
||||
# List open PRs / issues
|
||||
tea pr list --login zally
|
||||
tea issue list --login zally
|
||||
|
||||
# View a single issue (body + comments)
|
||||
tea issue --login zally --repo tekki/nissefolk <ISSUE_NUMBER>
|
||||
|
||||
# Merge PR — ONLY after explicit user says "merge it"
|
||||
tea pr merge --login zally --style merge <PR_NUMBER>
|
||||
|
||||
# Close issue
|
||||
tea issue close --login zally --repo tekki/nissefolk <ISSUE_NUMBER>
|
||||
|
||||
# List labels
|
||||
tea labels list --login zally --repo tekki/nissefolk
|
||||
|
||||
# Set/remove labels on an issue (use label names, not IDs)
|
||||
tea issue edit --login zally --repo tekki/nissefolk --add-labels "status: done" <N>
|
||||
tea issue edit --login zally --repo tekki/nissefolk --remove-labels "status: in discussion" <N>
|
||||
|
||||
|
||||
# Both flags can be combined; --add-labels takes precedence over --remove-labels
|
||||
tea issue edit <N> --add-labels "status: done" --remove-labels "status: in progress" --repo tekki/nissefolk
|
||||
|
||||
# Note: "tea labels" manages label definitions in the repo — not issue assignments
|
||||
```
|
||||
|
||||
**Label IDs** (repo-specific, don't guess):
|
||||
| ID | Name |
|
||||
|----|------|
|
||||
| 1 | feature |
|
||||
| 2 | improvement |
|
||||
| 3 | bug |
|
||||
| 6 | status: backlog |
|
||||
| 8 | status: ready |
|
||||
| 9 | status: in progress |
|
||||
| 10 | status: review |
|
||||
| 11 | status: done |
|
||||
|
||||
**PR workflow rules:**
|
||||
1. Commit → push branch → `tea pr create` → **share URL, stop, wait for user approval**
|
||||
2. Only merge when user explicitly says so
|
||||
3. After merge: close issue + set label to `status: done`
|
||||
|
||||
**master branch is protected** — direct push is rejected. Always use PRs.
|
||||
|
||||
**Routine load issue**
|
||||
1. Load Issues
|
||||
if-> If the label is status: ready
|
||||
-> work as it says
|
||||
-> use a new branch for each issue
|
||||
-> test your code
|
||||
-> commit your code
|
||||
-> change the issue label
|
||||
-> do an pr to master
|
||||
|
||||
if-> If the label is status: discussion
|
||||
-> think if you need more information
|
||||
-> ask questions as comment in gitea
|
||||
|
||||
**Issue create**
|
||||
If i say something like "create an issue about..." you need to attach the labels to it to. Use status: discussion and feature/bug
|
||||
@@ -19,6 +19,7 @@ function makeEmptyWorld(seed: number): WorldState {
|
||||
stockpile: { wood: 10, stone: 5, wheat_seed: 10, carrot_seed: 5, wheat: 0, carrot: 0, tree_seed: 5 },
|
||||
treeSeedlings: {},
|
||||
tileRecovery: {},
|
||||
foresterZones: {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,11 +66,20 @@ class StateManager {
|
||||
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))
|
||||
// Automatically create an empty forester zone when a forester hut is placed
|
||||
if (action.building.kind === 'forester_hut') {
|
||||
w.foresterZones[action.building.id] = { buildingId: action.building.id, tiles: [] }
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'REMOVE_BUILDING':
|
||||
delete w.buildings[action.buildingId]; break
|
||||
// Remove associated forester zone when the hut is demolished
|
||||
if (w.buildings[action.buildingId]?.kind === 'forester_hut') {
|
||||
delete w.foresterZones[action.buildingId]
|
||||
}
|
||||
delete w.buildings[action.buildingId]
|
||||
break
|
||||
|
||||
case 'ADD_ITEMS':
|
||||
for (const [k, v] of Object.entries(action.items))
|
||||
@@ -169,6 +179,12 @@ class StateManager {
|
||||
case 'TILE_RECOVERY_START':
|
||||
w.tileRecovery[`${action.tileX},${action.tileY}`] = TILE_RECOVERY_MS
|
||||
break
|
||||
|
||||
case 'FORESTER_ZONE_UPDATE': {
|
||||
const zone = w.foresterZones[action.buildingId]
|
||||
if (zone) zone.tiles = [...action.tiles]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,9 +258,18 @@ class StateManager {
|
||||
if (!p.world.stockpile) p.world.stockpile = {}
|
||||
if (!p.world.treeSeedlings) p.world.treeSeedlings = {}
|
||||
if (!p.world.tileRecovery) p.world.tileRecovery = {}
|
||||
if (!p.world.foresterZones) p.world.foresterZones = {}
|
||||
// Reset in-flight AI states to idle on load so runtime timers start fresh
|
||||
for (const v of Object.values(p.world.villagers)) {
|
||||
if (v.aiState === 'walking' || v.aiState === 'working') v.aiState = 'idle'
|
||||
// Migrate older saves that don't have the forester priority
|
||||
if (typeof (v.priorities as any).forester === 'undefined') v.priorities.forester = 4
|
||||
}
|
||||
// Rebuild forester zones for huts that predate the foresterZones field
|
||||
for (const b of Object.values(p.world.buildings)) {
|
||||
if (b.kind === 'forester_hut' && !p.world.foresterZones[b.id]) {
|
||||
p.world.foresterZones[b.id] = { buildingId: b.id, tiles: [] }
|
||||
}
|
||||
}
|
||||
return p
|
||||
} catch (_) { return null }
|
||||
|
||||
@@ -19,8 +19,12 @@ export const BUILDING_COSTS: Record<BuildingType, Record<string, number>> = {
|
||||
chest: { wood: 5, stone: 2 },
|
||||
bed: { wood: 6 },
|
||||
stockpile_zone:{ wood: 0 },
|
||||
forester_hut: { wood: 50 },
|
||||
}
|
||||
|
||||
/** Max Chebyshev radius (in tiles) that a forester hut's zone can extend. */
|
||||
export const FORESTER_ZONE_RADIUS = 5
|
||||
|
||||
export interface CropConfig {
|
||||
stages: number
|
||||
stageTimeMs: number
|
||||
@@ -36,9 +40,10 @@ export const CROP_CONFIGS: Record<CropKind, CropConfig> = {
|
||||
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,
|
||||
chop: 3000,
|
||||
mine: 5000,
|
||||
farm: 1200,
|
||||
forester: 2000,
|
||||
}
|
||||
export const VILLAGER_NAMES = [
|
||||
'Aldric','Brix','Cora','Dwyn','Edna','Finn','Greta',
|
||||
|
||||
@@ -12,6 +12,7 @@ import { FarmingSystem } from '../systems/FarmingSystem'
|
||||
import { VillagerSystem } from '../systems/VillagerSystem'
|
||||
import { DebugSystem } from '../systems/DebugSystem'
|
||||
import { TreeSeedlingSystem } from '../systems/TreeSeedlingSystem'
|
||||
import { ForesterZoneSystem } from '../systems/ForesterZoneSystem'
|
||||
|
||||
export class GameScene extends Phaser.Scene {
|
||||
private adapter!: LocalAdapter
|
||||
@@ -23,6 +24,7 @@ export class GameScene extends Phaser.Scene {
|
||||
villagerSystem!: VillagerSystem
|
||||
debugSystem!: DebugSystem
|
||||
private treeSeedlingSystem!: TreeSeedlingSystem
|
||||
foresterZoneSystem!: ForesterZoneSystem
|
||||
private autosaveTimer = 0
|
||||
private menuOpen = false
|
||||
|
||||
@@ -43,6 +45,7 @@ export class GameScene extends Phaser.Scene {
|
||||
this.villagerSystem = new VillagerSystem(this, this.adapter, this.worldSystem)
|
||||
this.villagerSystem.init(this.resourceSystem, this.farmingSystem)
|
||||
this.treeSeedlingSystem = new TreeSeedlingSystem(this, this.adapter, this.worldSystem)
|
||||
this.foresterZoneSystem = new ForesterZoneSystem(this, this.adapter)
|
||||
this.debugSystem = new DebugSystem(this, this.villagerSystem, this.worldSystem)
|
||||
|
||||
this.worldSystem.create()
|
||||
@@ -68,9 +71,16 @@ export class GameScene extends Phaser.Scene {
|
||||
|
||||
this.treeSeedlingSystem.create()
|
||||
|
||||
this.foresterZoneSystem.create()
|
||||
this.foresterZoneSystem.refreshOverlay()
|
||||
this.foresterZoneSystem.onEditEnded = () => this.events.emit('foresterZoneEditEnded')
|
||||
this.foresterZoneSystem.onZoneChanged = (id, tiles) => this.events.emit('foresterZoneChanged', id, tiles)
|
||||
|
||||
this.villagerSystem.create()
|
||||
this.villagerSystem.onMessage = (msg) => this.events.emit('toast', msg)
|
||||
this.villagerSystem.onNisseClick = (id) => this.events.emit('nisseClicked', id)
|
||||
this.villagerSystem.onMessage = (msg) => this.events.emit('toast', msg)
|
||||
this.villagerSystem.onNisseClick = (id) => this.events.emit('nisseClicked', id)
|
||||
this.villagerSystem.onPlantSeedling = (tileX, tileY, tile) =>
|
||||
this.treeSeedlingSystem.plantSeedling(tileX, tileY, tile)
|
||||
|
||||
this.debugSystem.create()
|
||||
|
||||
@@ -82,9 +92,26 @@ export class GameScene extends Phaser.Scene {
|
||||
} else if (action.type === 'SPAWN_RESOURCE') {
|
||||
this.resourceSystem.spawnResourcePublic(action.resource)
|
||||
this.worldSystem.addResourceTile(action.resource.tileX, action.resource.tileY)
|
||||
} else if (action.type === 'FORESTER_ZONE_UPDATE') {
|
||||
this.foresterZoneSystem.refreshOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
// Detect left-clicks on forester huts to open the zone panel
|
||||
this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
||||
if (ptr.rightButtonDown() || this.menuOpen) return
|
||||
if (this.buildingSystem.isActive()) return
|
||||
const tileX = Math.floor(ptr.worldX / TILE_SIZE)
|
||||
const tileY = Math.floor(ptr.worldY / TILE_SIZE)
|
||||
const state = stateManager.getState()
|
||||
const hut = Object.values(state.world.buildings).find(
|
||||
b => b.kind === 'forester_hut' && b.tileX === tileX && b.tileY === tileY
|
||||
)
|
||||
if (hut) {
|
||||
this.events.emit('foresterHutClicked', hut.id)
|
||||
}
|
||||
})
|
||||
|
||||
this.scene.launch('UI')
|
||||
|
||||
this.events.on('selectBuilding', (kind: BuildingType) => this.buildingSystem.selectBuilding(kind))
|
||||
@@ -93,9 +120,17 @@ export class GameScene extends Phaser.Scene {
|
||||
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.events.on('updatePriorities', (villagerId: string, priorities: { chop: number; mine: number; farm: number; forester: number }) => {
|
||||
this.adapter.send({ type: 'UPDATE_PRIORITIES', villagerId, priorities })
|
||||
})
|
||||
|
||||
this.events.on('foresterZoneEditStart', (buildingId: string) => {
|
||||
this.foresterZoneSystem.startEditMode(buildingId)
|
||||
this.menuOpen = false // keep game ticking while zone editor is open
|
||||
})
|
||||
this.events.on('foresterZoneEditStop', () => {
|
||||
this.foresterZoneSystem.exitEditMode()
|
||||
})
|
||||
this.events.on('debugToggle', () => this.debugSystem.toggle())
|
||||
|
||||
this.autosaveTimer = AUTOSAVE_INTERVAL
|
||||
@@ -153,6 +188,17 @@ export class GameScene extends Phaser.Scene {
|
||||
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)
|
||||
} else if (building.kind === 'forester_hut') {
|
||||
// Draw a simple log-cabin silhouette for the forester hut
|
||||
const g = this.add.graphics().setName(name).setDepth(8)
|
||||
// Body
|
||||
g.fillStyle(0x6B3F16); g.fillRect(wx - 12, wy - 9, 24, 18)
|
||||
// Roof
|
||||
g.fillStyle(0x4a2800); g.fillTriangle(wx - 14, wy - 9, wx + 14, wy - 9, wx, wy - 22)
|
||||
// Door
|
||||
g.fillStyle(0x2a1500); g.fillRect(wx - 4, wy + 1, 8, 8)
|
||||
// Tree symbol on the roof
|
||||
g.fillStyle(0x228B22); g.fillTriangle(wx - 6, wy - 11, wx + 6, wy - 11, wx, wy - 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,6 +211,7 @@ export class GameScene extends Phaser.Scene {
|
||||
this.buildingSystem.destroy()
|
||||
this.farmingSystem.destroy()
|
||||
this.treeSeedlingSystem.destroy()
|
||||
this.foresterZoneSystem.destroy()
|
||||
this.villagerSystem.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,15 @@ export class UIScene extends Phaser.Scene {
|
||||
private settingsGroup!: Phaser.GameObjects.Group
|
||||
private settingsVisible = false
|
||||
|
||||
// ── Forester Hut Panel ────────────────────────────────────────────────────
|
||||
private foresterPanelGroup!: Phaser.GameObjects.Group
|
||||
private foresterPanelVisible = false
|
||||
private foresterPanelBuildingId: string | null = null
|
||||
/** Tile-count text inside the forester panel, updated live when zone changes. */
|
||||
private foresterTileCountText: Phaser.GameObjects.Text | null = null
|
||||
/** True while the zone-edit tool is active (shown in ESC priority stack). */
|
||||
private inForesterZoneEdit = false
|
||||
|
||||
constructor() { super({ key: 'UI' }) }
|
||||
|
||||
/**
|
||||
@@ -88,11 +97,16 @@ export class UIScene extends Phaser.Scene {
|
||||
gameScene.events.on('nisseClicked', (id: string) => this.openNisseInfoPanel(id))
|
||||
|
||||
this.input.mouse!.disableContextMenu()
|
||||
this.contextMenuGroup = this.add.group()
|
||||
this.escMenuGroup = this.add.group()
|
||||
this.confirmGroup = this.add.group()
|
||||
this.nisseInfoGroup = this.add.group()
|
||||
this.settingsGroup = this.add.group()
|
||||
this.contextMenuGroup = this.add.group()
|
||||
this.escMenuGroup = this.add.group()
|
||||
this.confirmGroup = this.add.group()
|
||||
this.nisseInfoGroup = this.add.group()
|
||||
this.settingsGroup = this.add.group()
|
||||
this.foresterPanelGroup = this.add.group()
|
||||
|
||||
gameScene.events.on('foresterHutClicked', (id: string) => this.openForesterPanel(id))
|
||||
gameScene.events.on('foresterZoneEditEnded', () => this.onForesterEditEnded())
|
||||
gameScene.events.on('foresterZoneChanged', (id: string, tiles: string[]) => this.onForesterZoneChanged(id, tiles))
|
||||
|
||||
this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
||||
if (ptr.rightButtonDown()) {
|
||||
@@ -204,9 +218,10 @@ export class UIScene extends Phaser.Scene {
|
||||
{ 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)' },
|
||||
{ kind: 'forester_hut', label: '🌲 Forester Hut', cost: '50 wood' },
|
||||
]
|
||||
const menuX = this.scale.width / 2 - 150, menuY = this.scale.height / 2 - 140
|
||||
const bg = this.add.rectangle(menuX, menuY, 300, 280, 0x000000, this.uiOpacity).setOrigin(0,0).setScrollFactor(0).setDepth(200)
|
||||
const menuX = this.scale.width / 2 - 150, menuY = this.scale.height / 2 - 168
|
||||
const bg = this.add.rectangle(menuX, menuY, 300, 326, 0x000000, this.uiOpacity).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))
|
||||
|
||||
@@ -267,7 +282,7 @@ export class UIScene extends Phaser.Scene {
|
||||
|
||||
const state = stateManager.getState()
|
||||
const villagers = Object.values(state.world.villagers)
|
||||
const panelW = 420
|
||||
const panelW = 490
|
||||
const rowH = 60
|
||||
const panelH = Math.max(100, villagers.length * rowH + 50)
|
||||
const px = this.scale.width / 2 - panelW / 2
|
||||
@@ -309,12 +324,12 @@ export class UIScene extends Phaser.Scene {
|
||||
eg.fillStyle(col); eg.fillRect(px + 12, ry + 30, 80 * v.energy / 100, 6)
|
||||
this.villagerPanelGroup.add(eg)
|
||||
|
||||
// Job priority buttons: chop / mine / farm
|
||||
// Job priority buttons: chop / mine / farm / forester
|
||||
const jobs: Array<{ key: keyof JobPriorities; label: string }> = [
|
||||
{ key: 'chop', label: '🪓' }, { key: 'mine', label: '⛏' }, { key: 'farm', label: '🌾' }
|
||||
{ key: 'chop', label: '🪓' }, { key: 'mine', label: '⛏' }, { key: 'farm', label: '🌾' }, { key: 'forester', label: '🌲' }
|
||||
]
|
||||
jobs.forEach((job, ji) => {
|
||||
const bx = px + 110 + ji * 100
|
||||
const bx = px + 110 + ji * 76
|
||||
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, {
|
||||
@@ -527,13 +542,15 @@ export class UIScene extends Phaser.Scene {
|
||||
* esc menu → build/farm mode (handled by their own systems) → open ESC menu.
|
||||
*/
|
||||
private handleEsc(): void {
|
||||
if (this.confirmVisible) { this.hideConfirm(); return }
|
||||
if (this.contextMenuVisible) { this.hideContextMenu(); return }
|
||||
if (this.buildMenuVisible) { this.closeBuildMenu(); return }
|
||||
if (this.villagerPanelVisible){ this.closeVillagerPanel(); return }
|
||||
if (this.nisseInfoVisible) { this.closeNisseInfoPanel(); return }
|
||||
if (this.settingsVisible) { this.closeSettings(); return }
|
||||
if (this.escMenuVisible) { this.closeEscMenu(); return }
|
||||
if (this.confirmVisible) { this.hideConfirm(); return }
|
||||
if (this.inForesterZoneEdit) { this.scene.get('Game').events.emit('foresterZoneEditStop'); return }
|
||||
if (this.foresterPanelVisible) { this.closeForesterPanel(); return }
|
||||
if (this.contextMenuVisible) { this.hideContextMenu(); return }
|
||||
if (this.buildMenuVisible) { this.closeBuildMenu(); return }
|
||||
if (this.villagerPanelVisible) { this.closeVillagerPanel(); return }
|
||||
if (this.nisseInfoVisible) { this.closeNisseInfoPanel(); return }
|
||||
if (this.settingsVisible) { this.closeSettings(); return }
|
||||
if (this.escMenuVisible) { this.closeEscMenu(); return }
|
||||
// Build/farm mode: let BuildingSystem / FarmingSystem handle their own ESC key.
|
||||
// We only skip opening the ESC menu while those modes are active.
|
||||
if (this.inBuildMode || this.inFarmMode) return
|
||||
@@ -928,12 +945,12 @@ export class UIScene extends Phaser.Scene {
|
||||
|
||||
// Static: priority label + buttons
|
||||
const jobKeys: Array<{ key: string; icon: string }> = [
|
||||
{ key: 'chop', icon: '🪓' }, { key: 'mine', icon: '⛏' }, { key: 'farm', icon: '🌾' },
|
||||
{ key: 'chop', icon: '🪓' }, { key: 'mine', icon: '⛏' }, { key: 'farm', icon: '🌾' }, { key: 'forester', icon: '🌲' },
|
||||
]
|
||||
jobKeys.forEach((j, i) => {
|
||||
const pri = v.priorities[j.key as keyof typeof v.priorities]
|
||||
const label = pri === 0 ? `${j.icon} OFF` : `${j.icon} P${pri}`
|
||||
const bx = px + 10 + i * 88
|
||||
const bx = px + 10 + i * 66
|
||||
const btn = this.add.text(bx, py + 78, label, {
|
||||
fontSize: '11px', color: pri === 0 ? '#555555' : '#ffffff',
|
||||
fontFamily: 'monospace', backgroundColor: pri === 0 ? '#1a1a1a' : '#1a3a1a',
|
||||
@@ -1008,6 +1025,131 @@ export class UIScene extends Phaser.Scene {
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Forester Hut Panel ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Opens the forester hut info panel for the given building.
|
||||
* If another forester panel is open it is replaced.
|
||||
* @param buildingId - ID of the clicked forester_hut
|
||||
*/
|
||||
private openForesterPanel(buildingId: string): void {
|
||||
this.foresterPanelBuildingId = buildingId
|
||||
this.foresterPanelVisible = true
|
||||
this.buildForesterPanel()
|
||||
}
|
||||
|
||||
/** Closes and destroys the forester hut panel and exits zone edit mode if active. */
|
||||
private closeForesterPanel(): void {
|
||||
if (!this.foresterPanelVisible) return
|
||||
if (this.inForesterZoneEdit) {
|
||||
this.scene.get('Game').events.emit('foresterZoneEditStop')
|
||||
}
|
||||
this.foresterPanelVisible = false
|
||||
this.foresterPanelBuildingId = null
|
||||
this.foresterTileCountText = null
|
||||
this.foresterPanelGroup.destroy(true)
|
||||
this.foresterPanelGroup = this.add.group()
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the forester hut panel showing zone tile count and an edit-zone button.
|
||||
* Positioned in the top-left corner (similar to the Nisse info panel).
|
||||
*/
|
||||
private buildForesterPanel(): void {
|
||||
this.foresterPanelGroup.destroy(true)
|
||||
this.foresterPanelGroup = this.add.group()
|
||||
this.foresterTileCountText = null
|
||||
|
||||
const id = this.foresterPanelBuildingId
|
||||
if (!id) return
|
||||
|
||||
const state = stateManager.getState()
|
||||
const building = state.world.buildings[id]
|
||||
if (!building) { this.closeForesterPanel(); return }
|
||||
|
||||
const zone = state.world.foresterZones[id]
|
||||
const tileCount = zone?.tiles.length ?? 0
|
||||
|
||||
const panelW = 240
|
||||
const panelH = 100
|
||||
const px = 10, py = 10
|
||||
|
||||
// Background
|
||||
this.foresterPanelGroup.add(
|
||||
this.add.rectangle(px, py, panelW, panelH, 0x030a03, this.uiOpacity)
|
||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(250)
|
||||
)
|
||||
|
||||
// Title
|
||||
this.foresterPanelGroup.add(
|
||||
this.add.text(px + 10, py + 10, '🌲 FORESTER HUT', {
|
||||
fontSize: '13px', color: '#88dd88', fontFamily: 'monospace',
|
||||
}).setScrollFactor(0).setDepth(251)
|
||||
)
|
||||
|
||||
// Close button
|
||||
const closeBtn = this.add.text(px + panelW - 12, py + 10, '✕', {
|
||||
fontSize: '13px', color: '#888888', fontFamily: 'monospace',
|
||||
}).setOrigin(1, 0).setScrollFactor(0).setDepth(251).setInteractive()
|
||||
closeBtn.on('pointerover', () => closeBtn.setStyle({ color: '#ffffff' }))
|
||||
closeBtn.on('pointerout', () => closeBtn.setStyle({ color: '#888888' }))
|
||||
closeBtn.on('pointerdown', () => this.closeForesterPanel())
|
||||
this.foresterPanelGroup.add(closeBtn)
|
||||
|
||||
// Zone tile count (dynamic — updated via onForesterZoneChanged)
|
||||
const countTxt = this.add.text(px + 10, py + 32, `Zone: ${tileCount} tile${tileCount === 1 ? '' : 's'} marked`, {
|
||||
fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace',
|
||||
}).setScrollFactor(0).setDepth(251)
|
||||
this.foresterPanelGroup.add(countTxt)
|
||||
this.foresterTileCountText = countTxt
|
||||
|
||||
// Edit zone button
|
||||
const editLabel = this.inForesterZoneEdit ? '✅ Done editing' : '✏️ Edit Zone'
|
||||
const editBtn = this.add.rectangle(px + 10, py + 54, panelW - 20, 30, 0x1a3a1a, 0.9)
|
||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(251).setInteractive()
|
||||
editBtn.on('pointerover', () => editBtn.setFillStyle(0x2d6a4f, 0.9))
|
||||
editBtn.on('pointerout', () => editBtn.setFillStyle(0x1a3a1a, 0.9))
|
||||
editBtn.on('pointerdown', () => {
|
||||
if (this.inForesterZoneEdit) {
|
||||
this.scene.get('Game').events.emit('foresterZoneEditStop')
|
||||
} else {
|
||||
this.inForesterZoneEdit = true
|
||||
this.scene.get('Game').events.emit('foresterZoneEditStart', id)
|
||||
// Rebuild panel to show "Done editing" button
|
||||
this.buildForesterPanel()
|
||||
}
|
||||
})
|
||||
this.foresterPanelGroup.add(editBtn)
|
||||
this.foresterPanelGroup.add(
|
||||
this.add.text(px + panelW / 2, py + 69, editLabel, {
|
||||
fontSize: '12px', color: '#dddddd', fontFamily: 'monospace',
|
||||
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(252)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the ForesterZoneSystem signals that zone editing ended
|
||||
* (via right-click, ESC, or the "Done" button).
|
||||
*/
|
||||
private onForesterEditEnded(): void {
|
||||
this.inForesterZoneEdit = false
|
||||
// Rebuild panel to switch button back to "Edit Zone"
|
||||
if (this.foresterPanelVisible) this.buildForesterPanel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the zone tiles change so we can update the tile-count text live.
|
||||
* @param buildingId - Building whose zone changed
|
||||
* @param tiles - Updated tile array
|
||||
*/
|
||||
private onForesterZoneChanged(buildingId: string, tiles: string[]): void {
|
||||
if (buildingId !== this.foresterPanelBuildingId) return
|
||||
if (this.foresterTileCountText) {
|
||||
const n = tiles.length
|
||||
this.foresterTileCountText.setText(`Zone: ${n} tile${n === 1 ? '' : 's'} marked`)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Resize ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -1042,5 +1184,6 @@ export class UIScene extends Phaser.Scene {
|
||||
if (this.settingsVisible) this.closeSettings()
|
||||
if (this.confirmVisible) this.hideConfirm()
|
||||
if (this.nisseInfoVisible) this.closeNisseInfoPanel()
|
||||
if (this.foresterPanelVisible) this.closeForesterPanel()
|
||||
}
|
||||
}
|
||||
|
||||
194
src/systems/ForesterZoneSystem.ts
Normal file
194
src/systems/ForesterZoneSystem.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import Phaser from 'phaser'
|
||||
import { TILE_SIZE, FORESTER_ZONE_RADIUS } from '../config'
|
||||
import { PLANTABLE_TILES } from '../types'
|
||||
import type { TileType } from '../types'
|
||||
import { stateManager } from '../StateManager'
|
||||
import type { LocalAdapter } from '../NetworkAdapter'
|
||||
|
||||
/** Colors used for zone rendering. */
|
||||
const COLOR_IN_RADIUS = 0x44aa44 // unselected tile within radius (edit mode only)
|
||||
const COLOR_ZONE_TILE = 0x00ff44 // tile marked as part of the zone
|
||||
const ALPHA_VIEW = 0.18 // always-on zone overlay
|
||||
const ALPHA_RADIUS = 0.12 // in-radius tiles while editing
|
||||
const ALPHA_ZONE_EDIT = 0.45 // zone tiles while editing
|
||||
|
||||
export class ForesterZoneSystem {
|
||||
private scene: Phaser.Scene
|
||||
private adapter: LocalAdapter
|
||||
|
||||
/** Graphics layer for the always-visible zone overlay. */
|
||||
private zoneGraphics!: Phaser.GameObjects.Graphics
|
||||
/** Graphics layer for the edit-mode radius/tile overlay. */
|
||||
private editGraphics!: Phaser.GameObjects.Graphics
|
||||
|
||||
/** Building ID currently being edited, or null when not in edit mode. */
|
||||
private editBuildingId: string | null = null
|
||||
|
||||
/**
|
||||
* Callback invoked after a tile toggle so callers can react (e.g. refresh the panel).
|
||||
* Receives the updated zone tiles array.
|
||||
*/
|
||||
onZoneChanged?: (buildingId: string, tiles: string[]) => void
|
||||
|
||||
/**
|
||||
* Callback invoked when the user exits edit mode (right-click or programmatic close).
|
||||
* UIScene listens to this to close the zone edit indicator.
|
||||
*/
|
||||
onEditEnded?: () => void
|
||||
|
||||
/**
|
||||
* @param scene - The Phaser scene this system belongs to
|
||||
* @param adapter - Network adapter for dispatching state actions
|
||||
*/
|
||||
constructor(scene: Phaser.Scene, adapter: LocalAdapter) {
|
||||
this.scene = scene
|
||||
this.adapter = adapter
|
||||
}
|
||||
|
||||
/** Creates the graphics layers and registers the pointer listener. */
|
||||
create(): void {
|
||||
this.zoneGraphics = this.scene.add.graphics().setDepth(3)
|
||||
this.editGraphics = this.scene.add.graphics().setDepth(4)
|
||||
|
||||
this.scene.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
||||
if (!this.editBuildingId) return
|
||||
if (ptr.rightButtonDown()) {
|
||||
this.exitEditMode()
|
||||
return
|
||||
}
|
||||
this.handleTileClick(ptr.worldX, ptr.worldY)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Redraws all zone overlays for every forester hut in the current state.
|
||||
* Should be called whenever the zone data changes.
|
||||
*/
|
||||
refreshOverlay(): void {
|
||||
this.zoneGraphics.clear()
|
||||
const state = stateManager.getState()
|
||||
for (const zone of Object.values(state.world.foresterZones)) {
|
||||
for (const key of zone.tiles) {
|
||||
const [tx, ty] = key.split(',').map(Number)
|
||||
this.zoneGraphics.fillStyle(COLOR_ZONE_TILE, ALPHA_VIEW)
|
||||
this.zoneGraphics.fillRect(tx * TILE_SIZE, ty * TILE_SIZE, TILE_SIZE, TILE_SIZE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates zone-editing mode for the given forester hut.
|
||||
* Draws the radius indicator and zone tiles in edit colors.
|
||||
* @param buildingId - ID of the forester_hut building to edit
|
||||
*/
|
||||
startEditMode(buildingId: string): void {
|
||||
this.editBuildingId = buildingId
|
||||
this.drawEditOverlay()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivates zone-editing mode and clears the edit overlay.
|
||||
* Triggers the onEditEnded callback.
|
||||
*/
|
||||
exitEditMode(): void {
|
||||
if (!this.editBuildingId) return
|
||||
this.editBuildingId = null
|
||||
this.editGraphics.clear()
|
||||
this.onEditEnded?.()
|
||||
}
|
||||
|
||||
/** Returns true when the zone editor is currently active. */
|
||||
isEditing(): boolean {
|
||||
return this.editBuildingId !== null
|
||||
}
|
||||
|
||||
/** Destroys all graphics objects. */
|
||||
destroy(): void {
|
||||
this.zoneGraphics.destroy()
|
||||
this.editGraphics.destroy()
|
||||
}
|
||||
|
||||
// ─── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handles a left-click during edit mode.
|
||||
* Toggles the clicked tile in the zone if it is within radius and plantable.
|
||||
* @param worldX - World pixel X of the pointer
|
||||
* @param worldY - World pixel Y of the pointer
|
||||
*/
|
||||
private handleTileClick(worldX: number, worldY: number): void {
|
||||
const id = this.editBuildingId
|
||||
if (!id) return
|
||||
|
||||
const state = stateManager.getState()
|
||||
const building = state.world.buildings[id]
|
||||
if (!building) { this.exitEditMode(); return }
|
||||
|
||||
const tileX = Math.floor(worldX / TILE_SIZE)
|
||||
const tileY = Math.floor(worldY / TILE_SIZE)
|
||||
|
||||
// Chebyshev distance — must be within radius
|
||||
const dx = Math.abs(tileX - building.tileX)
|
||||
const dy = Math.abs(tileY - building.tileY)
|
||||
if (Math.max(dx, dy) > FORESTER_ZONE_RADIUS) return
|
||||
|
||||
const zone = state.world.foresterZones[id]
|
||||
if (!zone) return
|
||||
|
||||
const key = `${tileX},${tileY}`
|
||||
const idx = zone.tiles.indexOf(key)
|
||||
const tiles = idx >= 0
|
||||
? zone.tiles.filter(t => t !== key) // remove
|
||||
: [...zone.tiles, key] // add
|
||||
|
||||
this.adapter.send({ type: 'FORESTER_ZONE_UPDATE', buildingId: id, tiles })
|
||||
this.refreshOverlay()
|
||||
this.drawEditOverlay()
|
||||
this.onZoneChanged?.(id, tiles)
|
||||
}
|
||||
|
||||
/**
|
||||
* Redraws the edit-mode overlay showing the valid radius and current zone tiles.
|
||||
* Only called while editBuildingId is set.
|
||||
*/
|
||||
private drawEditOverlay(): void {
|
||||
this.editGraphics.clear()
|
||||
const id = this.editBuildingId
|
||||
if (!id) return
|
||||
|
||||
const state = stateManager.getState()
|
||||
const building = state.world.buildings[id]
|
||||
if (!building) return
|
||||
|
||||
const zone = state.world.foresterZones[id]
|
||||
const zoneSet = new Set(zone?.tiles ?? [])
|
||||
const r = FORESTER_ZONE_RADIUS
|
||||
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
const tx = building.tileX + dx
|
||||
const ty = building.tileY + dy
|
||||
const key = `${tx},${ty}`
|
||||
|
||||
// Only draw on plantable terrain
|
||||
const tileType = state.world.tiles[ty * 512 + tx] as TileType
|
||||
if (!PLANTABLE_TILES.has(tileType)) continue
|
||||
|
||||
if (zoneSet.has(key)) {
|
||||
this.editGraphics.fillStyle(COLOR_ZONE_TILE, ALPHA_ZONE_EDIT)
|
||||
this.editGraphics.strokeRect(tx * TILE_SIZE, ty * TILE_SIZE, TILE_SIZE, TILE_SIZE)
|
||||
} else {
|
||||
this.editGraphics.fillStyle(COLOR_IN_RADIUS, ALPHA_RADIUS)
|
||||
}
|
||||
this.editGraphics.fillRect(tx * TILE_SIZE, ty * TILE_SIZE, TILE_SIZE, TILE_SIZE)
|
||||
}
|
||||
}
|
||||
|
||||
// Draw a subtle border around the entire radius square
|
||||
const bx = (building.tileX - r) * TILE_SIZE
|
||||
const by = (building.tileY - r) * TILE_SIZE
|
||||
const bw = (2 * r + 1) * TILE_SIZE
|
||||
this.editGraphics.lineStyle(1, COLOR_ZONE_TILE, 0.4)
|
||||
this.editGraphics.strokeRect(bx, by, bw, bw)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Phaser from 'phaser'
|
||||
import { TILE_SIZE, VILLAGER_SPEED, VILLAGER_SPAWN_INTERVAL, VILLAGER_WORK_TIMES, VILLAGER_NAMES } from '../config'
|
||||
import { TileType } from '../types'
|
||||
import { TILE_SIZE, VILLAGER_SPEED, VILLAGER_SPAWN_INTERVAL, VILLAGER_WORK_TIMES, VILLAGER_NAMES, WORLD_TILES } from '../config'
|
||||
import { TileType, PLANTABLE_TILES } from '../types'
|
||||
import type { VillagerState, VillagerJob, JobType, AIState, ItemId } from '../types'
|
||||
import { stateManager } from '../StateManager'
|
||||
import { findPath } from '../utils/pathfinding'
|
||||
@@ -40,6 +40,12 @@ export class VillagerSystem {
|
||||
|
||||
onMessage?: (msg: string) => void
|
||||
onNisseClick?: (villagerId: string) => void
|
||||
/**
|
||||
* Called when a Nisse completes a forester planting job.
|
||||
* GameScene wires this to TreeSeedlingSystem.plantSeedling so that the
|
||||
* seedling sprite is spawned alongside the state action.
|
||||
*/
|
||||
onPlantSeedling?: (tileX: number, tileY: number, tile: TileType) => void
|
||||
|
||||
/**
|
||||
* @param scene - The Phaser scene this system belongs to
|
||||
@@ -119,7 +125,7 @@ export class VillagerSystem {
|
||||
this.drawEnergyBar(rt.energyBar, v.x, v.y, v.energy)
|
||||
|
||||
// Job icon
|
||||
const icons: Record<string, string> = { chop: '🪓', mine: '⛏', farm: '🌾', '': '' }
|
||||
const icons: Record<string, string> = { chop: '🪓', mine: '⛏', farm: '🌾', forester: '🌲', '': '' }
|
||||
const jobType = v.aiState === 'sleeping' ? '💤' : (v.job ? (icons[v.job.type] ?? '') : '')
|
||||
rt.jobIcon.setText(jobType).setPosition(v.x + 10, v.y - 18)
|
||||
}
|
||||
@@ -278,7 +284,10 @@ export class VillagerSystem {
|
||||
this.adapter.send({ type: 'TILE_RECOVERY_START', tileX: res.tileX, tileY: res.tileY })
|
||||
this.worldSystem.removeResourceTile(res.tileX, res.tileY)
|
||||
this.resourceSystem.removeResource(job.targetId)
|
||||
this.addLog(v.id, '✓ Chopped tree (+2 wood)')
|
||||
// Chopping a tree yields 1–2 tree seeds in the stockpile
|
||||
const seeds = Math.random() < 0.5 ? 2 : 1
|
||||
this.adapter.send({ type: 'ADD_ITEMS', items: { tree_seed: seeds } })
|
||||
this.addLog(v.id, `✓ Chopped tree (+2 wood, +${seeds} tree seed)`)
|
||||
}
|
||||
} else if (job.type === 'mine') {
|
||||
const res = state.world.resources[job.targetId]
|
||||
@@ -297,6 +306,20 @@ export class VillagerSystem {
|
||||
this.adapter.send({ type: 'CHANGE_TILE', tileX: crop.tileX, tileY: crop.tileY, tile: 9 as any })
|
||||
this.addLog(v.id, `✓ Farmed ${crop.kind}`)
|
||||
}
|
||||
} else if (job.type === 'forester') {
|
||||
// Verify the tile is still empty and the stockpile still has seeds
|
||||
const tileType = state.world.tiles[job.tileY * WORLD_TILES + job.tileX] as TileType
|
||||
const hasSeeds = (state.world.stockpile.tree_seed ?? 0) > 0
|
||||
const tileOccupied =
|
||||
Object.values(state.world.resources).some(r => r.tileX === job.tileX && r.tileY === job.tileY) ||
|
||||
Object.values(state.world.buildings).some(b => b.tileX === job.tileX && b.tileY === job.tileY) ||
|
||||
Object.values(state.world.crops).some(c => c.tileX === job.tileX && c.tileY === job.tileY) ||
|
||||
Object.values(state.world.treeSeedlings).some(s => s.tileX === job.tileX && s.tileY === job.tileY)
|
||||
|
||||
if (hasSeeds && PLANTABLE_TILES.has(tileType) && !tileOccupied) {
|
||||
this.onPlantSeedling?.(job.tileX, job.tileY, tileType)
|
||||
this.addLog(v.id, `🌱 Planted seedling at (${job.tileX}, ${job.tileY})`)
|
||||
}
|
||||
}
|
||||
|
||||
// If the harvest produced nothing (resource already gone), clear the stale job
|
||||
@@ -337,6 +360,15 @@ export class VillagerSystem {
|
||||
* @param v - Villager state (used for position and priorities)
|
||||
* @returns The chosen job candidate, or null
|
||||
*/
|
||||
/**
|
||||
* Selects the best available job for a Nisse based on their priority settings.
|
||||
* Among jobs at the same priority level, the closest one wins.
|
||||
* For chop jobs, trees within a forester zone are preferred over natural trees —
|
||||
* natural trees are only offered when no forester-zone trees are available.
|
||||
* Returns null if no unclaimed job is available.
|
||||
* @param v - Villager state (used for position and priorities)
|
||||
* @returns The chosen job candidate, or null
|
||||
*/
|
||||
private pickJob(v: VillagerState): { type: JobType; targetId: string; tileX: number; tileY: number } | null {
|
||||
const state = stateManager.getState()
|
||||
const p = v.priorities
|
||||
@@ -348,14 +380,30 @@ export class VillagerSystem {
|
||||
const candidates: C[] = []
|
||||
|
||||
if (p.chop > 0) {
|
||||
// Build the set of all tiles belonging to forester zones for chop priority
|
||||
const zoneTiles = new Set<string>()
|
||||
for (const zone of Object.values(state.world.foresterZones)) {
|
||||
for (const key of zone.tiles) zoneTiles.add(key)
|
||||
}
|
||||
|
||||
const zoneChop: C[] = []
|
||||
const naturalChop: C[] = []
|
||||
|
||||
for (const res of Object.values(state.world.resources)) {
|
||||
if (res.kind !== 'tree' || this.claimed.has(res.id)) continue
|
||||
// Skip trees with no reachable neighbour — A* cannot enter an impassable goal
|
||||
// tile unless at least one passable neighbour exists to jump from.
|
||||
// Skip trees with no reachable neighbour — A* cannot reach them.
|
||||
if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue
|
||||
candidates.push({ type: 'chop', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.chop })
|
||||
const c: C = { type: 'chop', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.chop }
|
||||
if (zoneTiles.has(`${res.tileX},${res.tileY}`)) {
|
||||
zoneChop.push(c)
|
||||
} else {
|
||||
naturalChop.push(c)
|
||||
}
|
||||
}
|
||||
// Prefer zone trees; fall back to natural only when no zone trees are reachable.
|
||||
candidates.push(...(zoneChop.length > 0 ? zoneChop : naturalChop))
|
||||
}
|
||||
|
||||
if (p.mine > 0) {
|
||||
for (const res of Object.values(state.world.resources)) {
|
||||
if (res.kind !== 'rock' || this.claimed.has(res.id)) continue
|
||||
@@ -364,6 +412,7 @@ export class VillagerSystem {
|
||||
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
|
||||
@@ -371,6 +420,28 @@ export class VillagerSystem {
|
||||
}
|
||||
}
|
||||
|
||||
if (p.forester > 0 && (state.world.stockpile.tree_seed ?? 0) > 0) {
|
||||
// Find empty plantable zone tiles to seed
|
||||
for (const zone of Object.values(state.world.foresterZones)) {
|
||||
for (const key of zone.tiles) {
|
||||
const [tx, ty] = key.split(',').map(Number)
|
||||
const targetId = `forester_tile_${tx}_${ty}`
|
||||
if (this.claimed.has(targetId)) continue
|
||||
// Skip if tile is not plantable
|
||||
const tileType = state.world.tiles[ty * WORLD_TILES + tx] as TileType
|
||||
if (!PLANTABLE_TILES.has(tileType)) continue
|
||||
// Skip if something occupies this tile
|
||||
const occupied =
|
||||
Object.values(state.world.resources).some(r => r.tileX === tx && r.tileY === ty) ||
|
||||
Object.values(state.world.buildings).some(b => b.tileX === tx && b.tileY === ty) ||
|
||||
Object.values(state.world.crops).some(c => c.tileX === tx && c.tileY === ty) ||
|
||||
Object.values(state.world.treeSeedlings).some(s => s.tileX === tx && s.tileY === ty)
|
||||
if (occupied) continue
|
||||
candidates.push({ type: 'forester', targetId, tileX: tx, tileY: ty, dist: dist(tx, ty), pri: p.forester })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) return null
|
||||
|
||||
// Lowest priority number wins; ties broken by distance
|
||||
@@ -481,7 +552,7 @@ export class VillagerSystem {
|
||||
y: (freeBed.tileY + 0.5) * TILE_SIZE,
|
||||
bedId: freeBed.id,
|
||||
job: null,
|
||||
priorities: { chop: 1, mine: 2, farm: 3 },
|
||||
priorities: { chop: 1, mine: 2, farm: 3, forester: 4 },
|
||||
energy: 100,
|
||||
aiState: 'idle',
|
||||
}
|
||||
@@ -558,7 +629,10 @@ export class VillagerSystem {
|
||||
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 === 'working' && v.job) {
|
||||
const label = v.job.type === 'forester' ? 'planting' : `${v.job.type}ing`
|
||||
return `⚒ ${label}`
|
||||
}
|
||||
if (v.aiState === 'walking' && v.job) return `🚶 → ${v.job.type}`
|
||||
if (v.aiState === 'walking') return '🚶 Walking'
|
||||
const carrying = v.job?.carrying
|
||||
|
||||
20
src/types.ts
20
src/types.ts
@@ -32,18 +32,19 @@ export const PLANTABLE_TILES = new Set<TileType>([TileType.GRASS, TileType.DARK_
|
||||
|
||||
export type ItemId = 'wood' | 'stone' | 'wheat_seed' | 'carrot_seed' | 'wheat' | 'carrot' | 'tree_seed'
|
||||
|
||||
export type BuildingType = 'floor' | 'wall' | 'chest' | 'bed' | 'stockpile_zone'
|
||||
export type BuildingType = 'floor' | 'wall' | 'chest' | 'bed' | 'stockpile_zone' | 'forester_hut'
|
||||
|
||||
export type CropKind = 'wheat' | 'carrot'
|
||||
|
||||
export type JobType = 'chop' | 'mine' | 'farm'
|
||||
export type JobType = 'chop' | 'mine' | 'farm' | 'forester'
|
||||
|
||||
export type AIState = 'idle' | 'walking' | 'working' | 'sleeping'
|
||||
|
||||
export interface JobPriorities {
|
||||
chop: number // 0 = disabled, 1 = highest, 4 = lowest
|
||||
chop: number // 0 = disabled, 1 = highest, 4 = lowest
|
||||
mine: number
|
||||
farm: number
|
||||
forester: number // plant tree seedlings in forester zones
|
||||
}
|
||||
|
||||
export interface VillagerJob {
|
||||
@@ -112,6 +113,16 @@ export interface TreeSeedlingState {
|
||||
underlyingTile: TileType
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of tiles assigned to one forester hut's planting zone.
|
||||
* Tiles are stored as "tileX,tileY" key strings.
|
||||
*/
|
||||
export interface ForesterZoneState {
|
||||
buildingId: string
|
||||
/** Tile keys "tileX,tileY" that the player has marked for planting. */
|
||||
tiles: string[]
|
||||
}
|
||||
|
||||
export interface WorldState {
|
||||
seed: number
|
||||
tiles: number[]
|
||||
@@ -127,6 +138,8 @@ export interface WorldState {
|
||||
* Value is remaining milliseconds until the tile reverts to GRASS.
|
||||
*/
|
||||
tileRecovery: Record<string, number>
|
||||
/** Forester zone definitions, keyed by forester_hut building ID. */
|
||||
foresterZones: Record<string, ForesterZoneState>
|
||||
}
|
||||
|
||||
export interface GameStateData {
|
||||
@@ -156,3 +169,4 @@ export type GameAction =
|
||||
| { type: 'REMOVE_TREE_SEEDLING'; seedlingId: string }
|
||||
| { type: 'SPAWN_RESOURCE'; resource: ResourceNodeState }
|
||||
| { type: 'TILE_RECOVERY_START'; tileX: number; tileY: number }
|
||||
| { type: 'FORESTER_ZONE_UPDATE'; buildingId: string; tiles: string[] }
|
||||
|
||||
Reference in New Issue
Block a user