diff --git a/CHANGELOG.md b/CHANGELOG.md index bb5be00..c672039 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,19 @@ 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 +- **Terrain canvas not updating** (Issue #22): `CHANGE_TILE` now calls `refreshTerrainTile()` centrally via the adapter handler, fixing the visual glitch where chopped trees left a dark FOREST-coloured pixel instead of DARK_GRASS - **Stockpile panel** (Issue #20): panel background now uses `uiOpacity` and updates live when Settings opacity changes; panel height increased so the Nisse count row no longer overlaps the carrot row - **ESC menu** (Issue #20): internal bottom padding corrected — last button now has 16px gap to panel edge instead of 0px diff --git a/CLAUDE.md b/CLAUDE.md index a2c24cc..d51a2cd 100644 --- a/CLAUDE.md +++ b/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 ` — never `cd ~/game && git ` (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 + +# Merge PR — ONLY after explicit user says "merge it" +tea pr merge --login zally --style merge + +# Close issue +tea issue close --login zally --repo tekki/nissefolk + +# 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" +tea issue edit --login zally --repo tekki/nissefolk --remove-labels "status: in discussion" + + +# Both flags can be combined; --add-labels takes precedence over --remove-labels +tea issue edit --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 \ No newline at end of file diff --git a/src/StateManager.ts b/src/StateManager.ts index 3c854d6..263d263 100644 --- a/src/StateManager.ts +++ b/src/StateManager.ts @@ -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 } diff --git a/src/config.ts b/src/config.ts index 34f56ed..463c80e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -19,8 +19,12 @@ export const BUILDING_COSTS: Record> = { 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 = { 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 = { - 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', diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index 98e933a..530018c 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -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() } } diff --git a/src/scenes/UIScene.ts b/src/scenes/UIScene.ts index 19bbed7..9aefb48 100644 --- a/src/scenes/UIScene.ts +++ b/src/scenes/UIScene.ts @@ -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() } } diff --git a/src/systems/ForesterZoneSystem.ts b/src/systems/ForesterZoneSystem.ts new file mode 100644 index 0000000..f09621e --- /dev/null +++ b/src/systems/ForesterZoneSystem.ts @@ -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) + } +} diff --git a/src/systems/VillagerSystem.ts b/src/systems/VillagerSystem.ts index 906b14b..388331d 100644 --- a/src/systems/VillagerSystem.ts +++ b/src/systems/VillagerSystem.ts @@ -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 = { chop: '🪓', mine: '⛏', farm: '🌾', '': '' } + const icons: Record = { 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() + 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 diff --git a/src/types.ts b/src/types.ts index 7e37beb..bb6cc69 100644 --- a/src/types.ts +++ b/src/types.ts @@ -32,18 +32,19 @@ export const PLANTABLE_TILES = new Set([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 + /** Forester zone definitions, keyed by forester_hut building ID. */ + foresterZones: Record } 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[] }