✨ 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:
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
|
||||
|
||||
Reference in New Issue
Block a user