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:
2026-03-23 13:07:36 +00:00
parent b024cf36fb
commit 969a82949e
9 changed files with 629 additions and 39 deletions

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

View File

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