12 Commits

Author SHA1 Message Date
d02ed33435 fix GC ruckler — Object.values() einmal pro pickJob-Aufruf
Fixes #34. Alle Object.values()-Aufrufe werden einmal am Anfang von
pickJob() extrahiert und in allen Branches wiederverwendet. Der
Forester-Loop rief zuvor fuer jedes Zone-Tile 4x Object.values() auf.
JOB_ICONS als Modul-Konstante, Math.min-spread durch Schleife ersetzt.
2026-03-23 20:18:00 +00:00
c7cf971e54 📝 update CHANGELOG for depth sorting (PR #32) 2026-03-23 20:08:12 +00:00
08dffa135f Merge pull request 'Fix Y-based depth sorting for world objects' (#32) from feature/depth-sorting into master
Reviewed-on: #32
Reviewed-by: tekki <tekki.mariani@googlemail.com>
2026-03-23 20:07:28 +00:00
4f2e9f73b6 ♻️ remove silhouette — Nisse always render above world objects
Depth fixed at 900; isOccluded() and outlineSprite removed.
WorldSystem.hasResourceAt() stays as a useful utility.
2026-03-23 20:05:53 +00:00
84b6e51746 🐛 improve occlusion detection with wider tile check
Expands isOccluded() from same-column-only to a 3x4 tile window
(tileX+-1, tileY+1..4) to catch trees whose canopy extends sideways
and well above the trunk tile. Outline scale bumped to 1.15.
2026-03-23 20:02:40 +00:00
5f646d54ca 🐛 fix Nisse outline only shown when actually occluded
Silhouette now hidden by default and toggled on per frame only when
isOccluded() detects a tree, rock or building 1–3 tiles below the Nisse.
Adds WorldSystem.hasResourceAt() for O(1) tile lookup. Outline colour
changed to light blue (0xaaddff) at scale 1.1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 19:52:37 +00:00
94b2f7f457 add Nisse silhouette outline for occlusion visibility
Fixes #33. Each Nisse now has a white filled outline sprite at depth 900
that is always visible above trees and buildings. The main sprite uses
Y-based depth (floor(y/TILE_SIZE)+5) so Nisse sort correctly with world
objects. Name label, energy bar and job icon moved to depth 901/902 so
they remain readable regardless of occlusion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 19:47:51 +00:00
cd171c859c fix depth sorting for world objects by tileY
Fixes #31. All trees, rocks, seedlings and buildings now use
tileY+5 as depth instead of a fixed value, so objects further
down the screen always render in front of objects above them
regardless of spawn order. Build ghost moved to depth 1000/1001.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 19:40:27 +00:00
d9ef57c6b0 Merge pull request 'Add bottom action bar with Build and Nisse buttons' (#30) from feature/action-bar into master
Reviewed-on: #30
Reviewed-by: tekki <tekki.mariani@googlemail.com>
2026-03-23 19:24:46 +00:00
87f69b4774 add bottom action bar with Build and Nisse category buttons (fixes #29)
- Persistent action bar at bottom of screen (48px high, full width)
- Build button: toggles a horizontal building tray above the bar
- Nisse button: opens the existing Nisse management panel
- Active category button is highlighted; ESC closes the tray
- hintText (farm tool indicator) repositioned above the action bar
- Bar and tray reposition correctly on canvas resize

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 17:10:41 +00:00
8d2c58cb5f Merge pull request 'Remove bottom HUD text, move keys to ESC menu' (#28) from fix/remove-bottom-hud-text into master 2026-03-23 16:35:59 +00:00
986c2ea9eb 🔥 remove bottom HUD text, move keys to ESC menu (fixes #27)
- Removed controls hint text and tile coordinate display from the screen
- Removed coordsText / controlsHintText fields and createCoordsDisplay / onCameraMoved methods
- Added keyboard shortcut reference block at the bottom of the ESC menu

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 16:32:45 +00:00
8 changed files with 253 additions and 55 deletions

View File

@@ -7,6 +7,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Fixed
- **Y-based depth sorting** (Issue #31): trees, rocks, seedlings and buildings now use `tileY + 5` as depth instead of fixed values — objects lower on screen always render in front of objects above them, regardless of spawn order; build ghost moved to depth 1000
- **Nisse always visible** (Issue #33): Nisse sprites fixed at depth 900, always rendered above world objects
### Added
- **Försterkreislauf** (Issue #25):
- **Setzlinge beim Fällen**: Jeder gefällte Baum gibt 12 `tree_seed` in den Stockpile

View File

@@ -179,18 +179,19 @@ export class GameScene extends Phaser.Scene {
const name = `bobj_${building.id}`
if (this.children.getByName(name)) continue
const worldDepth = building.tileY + 5
if (building.kind === 'chest') {
const g = this.add.graphics().setName(name).setDepth(8)
const g = this.add.graphics().setName(name).setDepth(worldDepth)
g.fillStyle(0x8B4513); g.fillRect(wx - 10, wy - 7, 20, 14)
g.fillStyle(0xCD853F); g.fillRect(wx - 9, wy - 6, 18, 6)
g.lineStyle(1, 0x5C3317); g.strokeRect(wx - 10, wy - 7, 20, 14)
} else if (building.kind === 'bed') {
this.add.image(wx, wy, 'bed_obj').setName(name).setDepth(8)
this.add.image(wx, wy, 'bed_obj').setName(name).setDepth(worldDepth)
} 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)
const g = this.add.graphics().setName(name).setDepth(worldDepth)
// Body
g.fillStyle(0x6B3F16); g.fillRect(wx - 12, wy - 9, 24, 18)
// Roof

View File

@@ -22,8 +22,6 @@ export class UIScene extends Phaser.Scene {
private villagerPanelVisible = false
private buildModeText!: Phaser.GameObjects.Text
private farmToolText!: Phaser.GameObjects.Text
private coordsText!: Phaser.GameObjects.Text
private controlsHintText!: Phaser.GameObjects.Text
private popText!: Phaser.GameObjects.Text
private stockpileTitleText!: Phaser.GameObjects.Text
private contextMenuGroup!: Phaser.GameObjects.Group
@@ -61,6 +59,18 @@ export class UIScene extends Phaser.Scene {
/** True while the zone-edit tool is active (shown in ESC priority stack). */
private inForesterZoneEdit = false
// ── Action Bar ────────────────────────────────────────────────────────────
private static readonly BAR_H = 48
private static readonly TRAY_H = 68
private actionBarBg!: Phaser.GameObjects.Rectangle
private actionBuildBtn!: Phaser.GameObjects.Rectangle
private actionBuildLabel!: Phaser.GameObjects.Text
private actionNisseBtn!: Phaser.GameObjects.Rectangle
private actionNisseLabel!: Phaser.GameObjects.Text
private actionTrayGroup!: Phaser.GameObjects.Group
private actionTrayVisible = false
private activeCategory: 'build' | 'nisse' | null = null
constructor() { super({ key: 'UI' }) }
/**
@@ -75,15 +85,14 @@ export class UIScene extends Phaser.Scene {
this.createBuildMenu()
this.createBuildModeIndicator()
this.createFarmToolIndicator()
this.createCoordsDisplay()
this.createDebugPanel()
this.createActionBar()
const gameScene = this.scene.get('Game')
gameScene.events.on('buildModeChanged', (a: boolean, b: BuildingType) => this.onBuildModeChanged(a, b))
gameScene.events.on('farmToolChanged', (t: FarmingTool, l: string) => this.onFarmToolChanged(t, l))
gameScene.events.on('toast', (m: string) => this.showToast(m))
gameScene.events.on('openBuildMenu', () => this.toggleBuildMenu())
gameScene.events.on('cameraMoved', (pos: { tileX: number; tileY: number }) => this.onCameraMoved(pos))
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.B)
.on('down', () => gameScene.events.emit('uiRequestBuildMenu'))
@@ -103,6 +112,7 @@ export class UIScene extends Phaser.Scene {
this.nisseInfoGroup = this.add.group()
this.settingsGroup = this.add.group()
this.foresterPanelGroup = this.add.group()
this.actionTrayGroup = this.add.group()
gameScene.events.on('foresterHutClicked', (id: string) => this.openForesterPanel(id))
gameScene.events.on('foresterZoneEditEnded', () => this.onForesterEditEnded())
@@ -175,7 +185,7 @@ export class UIScene extends Phaser.Scene {
/** Creates the centered hint text element near the bottom of the screen. */
private createHintText(): void {
this.hintText = this.add.text(this.scale.width / 2, this.scale.height - 40, '', {
this.hintText = this.add.text(this.scale.width / 2, this.scale.height - UIScene.BAR_H - 24, '', {
fontSize: '14px', color: '#ffff88', fontFamily: 'monospace',
backgroundColor: '#00000099', padding: { x: 10, y: 5 },
}).setOrigin(0.5).setScrollFactor(0).setDepth(100).setVisible(false)
@@ -270,6 +280,10 @@ export class UIScene extends Phaser.Scene {
this.villagerPanelVisible = false
this.villagerPanelGroup?.destroy(true)
this.scene.get('Game').events.emit('uiMenuClose')
if (this.activeCategory === 'nisse') {
this.activeCategory = null
this.updateCategoryHighlights()
}
}
/**
@@ -386,24 +400,6 @@ export class UIScene extends Phaser.Scene {
this.farmToolText.setText(tool === 'none' ? '' : `[F] Farm: ${label} [RMB cancel]`).setVisible(tool !== 'none')
}
// ─── Coords + controls ────────────────────────────────────────────────────
/** Creates the tile-coordinate display and controls hint at the bottom-left. */
private createCoordsDisplay(): void {
this.coordsText = this.add.text(10, this.scale.height - 24, '', { fontSize: '11px', color: '#666666', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(100)
this.controlsHintText = this.add.text(10, this.scale.height - 42, '[WASD] Pan [Scroll] Zoom [F] Farm [B] Build [V] Nisse [F3] Debug', {
fontSize: '10px', color: '#444444', fontFamily: 'monospace', backgroundColor: '#00000066', padding: { x: 4, y: 2 }
}).setScrollFactor(0).setDepth(100)
}
/**
* Updates the tile-coordinate display when the camera moves.
* @param pos - Tile position of the camera center
*/
private onCameraMoved(pos: { tileX: number; tileY: number }): void {
this.coordsText.setText(`Tile: ${pos.tileX}, ${pos.tileY}`)
}
// ─── Debug Panel (F3) ─────────────────────────────────────────────────────
/** Creates the debug panel text object (initially hidden). */
@@ -547,6 +543,7 @@ export class UIScene extends Phaser.Scene {
if (this.foresterPanelVisible) { this.closeForesterPanel(); return }
if (this.contextMenuVisible) { this.hideContextMenu(); return }
if (this.buildMenuVisible) { this.closeBuildMenu(); return }
if (this.actionTrayVisible) { this.closeActionTray(); return }
if (this.villagerPanelVisible) { this.closeVillagerPanel(); return }
if (this.nisseInfoVisible) { this.closeNisseInfoPanel(); return }
if (this.settingsVisible) { this.closeSettings(); return }
@@ -589,8 +586,9 @@ export class UIScene extends Phaser.Scene {
{ label: '⚙️ Settings', action: () => this.doSettings() },
{ label: '🆕 New Game', action: () => this.doNewGame() },
]
// 32px header + entries × (btnH + 8px gap) + 8px bottom padding
const menuH = 32 + entries.length * (btnH + 8) + 8
const keysBlock = '[WASD] Pan [Scroll] Zoom\n[F] Farm [B] Build [V] Nisse\n[F3] Debug [ESC] Menu'
// 32px header + entries × (btnH + 8px gap) + 8px sep + 46px keys block + 12px bottom padding
const menuH = 32 + entries.length * (btnH + 8) + 8 + 46 + 12
const mx = this.scale.width / 2 - menuW / 2
const my = this.scale.height / 2 - menuH / 2
@@ -617,6 +615,14 @@ export class UIScene extends Phaser.Scene {
}).setOrigin(0, 0.5).setScrollFactor(0).setDepth(402)
)
})
// Keyboard shortcuts reference at the bottom of the menu
const keysY = my + 32 + entries.length * (btnH + 8) + 8
this.escMenuGroup.add(
this.add.text(mx + menuW / 2, keysY, keysBlock, {
fontSize: '10px', color: '#555555', fontFamily: 'monospace', align: 'center',
}).setOrigin(0.5, 0).setScrollFactor(0).setDepth(401)
)
}
/** Saves the game and shows a toast confirmation. */
@@ -1150,6 +1156,152 @@ export class UIScene extends Phaser.Scene {
}
}
// ─── Action Bar ───────────────────────────────────────────────────────────
/**
* Creates the persistent bottom action bar with Build and Nisse category buttons.
* The bar is always visible; individual button highlights change with the active category.
*/
private createActionBar(): void {
const { width, height } = this.scale
const barY = height - UIScene.BAR_H
this.actionBarBg = this.add.rectangle(0, barY, width, UIScene.BAR_H, 0x080808, 0.92)
.setOrigin(0, 0).setScrollFactor(0).setDepth(300)
this.actionBuildBtn = this.add.rectangle(8, barY + 8, 88, 32, 0x1a3a1a, 0.9)
.setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive()
this.actionBuildBtn.on('pointerover', () => {
if (this.activeCategory !== 'build') this.actionBuildBtn.setFillStyle(0x2a5a2a, 0.9)
})
this.actionBuildBtn.on('pointerout', () => {
if (this.activeCategory !== 'build') this.actionBuildBtn.setFillStyle(0x1a3a1a, 0.9)
})
this.actionBuildBtn.on('pointerdown', () => this.toggleCategory('build'))
this.actionBuildLabel = this.add.text(52, barY + UIScene.BAR_H / 2, '🔨 Build', {
fontSize: '12px', color: '#cccccc', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(302)
this.actionNisseBtn = this.add.rectangle(104, barY + 8, 88, 32, 0x1a1a3a, 0.9)
.setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive()
this.actionNisseBtn.on('pointerover', () => {
if (this.activeCategory !== 'nisse') this.actionNisseBtn.setFillStyle(0x2a2a5a, 0.9)
})
this.actionNisseBtn.on('pointerout', () => {
if (this.activeCategory !== 'nisse') this.actionNisseBtn.setFillStyle(0x1a1a3a, 0.9)
})
this.actionNisseBtn.on('pointerdown', () => this.toggleCategory('nisse'))
this.actionNisseLabel = this.add.text(148, barY + UIScene.BAR_H / 2, '👥 Nisse', {
fontSize: '12px', color: '#cccccc', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(302)
}
/**
* Toggles the given action bar category on or off.
* Selecting the active category deselects it; selecting a new one closes the previous.
* @param cat - The category to toggle ('build' or 'nisse')
*/
private toggleCategory(cat: 'build' | 'nisse'): void {
if (this.activeCategory === cat) {
this.deactivateCategory()
return
}
// Close whatever was open before
if (this.activeCategory === 'build') this.closeActionTray()
if (this.activeCategory === 'nisse' && this.villagerPanelVisible) this.closeVillagerPanel()
this.activeCategory = cat
this.updateCategoryHighlights()
if (cat === 'build') {
this.openActionTray()
} else {
this.openVillagerPanel()
}
}
/**
* Deactivates the currently active category, closing its associated panel or tray.
*/
private deactivateCategory(): void {
if (this.activeCategory === 'build') this.closeActionTray()
if (this.activeCategory === 'nisse' && this.villagerPanelVisible) this.closeVillagerPanel()
this.activeCategory = null
this.updateCategoryHighlights()
}
/**
* Updates the visual highlight of the Build and Nisse buttons
* to reflect the current active category.
*/
private updateCategoryHighlights(): void {
this.actionBuildBtn.setFillStyle(this.activeCategory === 'build' ? 0x3d7a3d : 0x1a3a1a, 0.9)
this.actionNisseBtn.setFillStyle(this.activeCategory === 'nisse' ? 0x3d3d7a : 0x1a1a3a, 0.9)
}
/**
* Builds and shows the building tool tray above the action bar.
* Each building is shown as a clickable tile with emoji and name.
*/
private openActionTray(): void {
if (this.actionTrayVisible) return
this.actionTrayVisible = true
this.actionTrayGroup.destroy(true)
this.actionTrayGroup = this.add.group()
const { width, height } = this.scale
const trayY = height - UIScene.BAR_H - UIScene.TRAY_H
const bg = this.add.rectangle(0, trayY, width, UIScene.TRAY_H, 0x0d0d0d, 0.88)
.setOrigin(0, 0).setScrollFactor(0).setDepth(300)
this.actionTrayGroup.add(bg)
const buildings: { kind: BuildingType; emoji: string; label: string }[] = [
{ kind: 'floor', emoji: '🪵', label: 'Floor' },
{ kind: 'wall', emoji: '🧱', label: 'Wall' },
{ kind: 'chest', emoji: '📦', label: 'Chest' },
{ kind: 'bed', emoji: '🛏', label: 'Bed' },
{ kind: 'stockpile_zone', emoji: '📦', label: 'Stockpile' },
{ kind: 'forester_hut', emoji: '🌲', label: 'Forester' },
]
const itemW = 84
buildings.forEach((b, i) => {
const bx = 8 + i * (itemW + 4)
const btn = this.add.rectangle(bx, trayY + 4, itemW, UIScene.TRAY_H - 8, 0x1a2a1a, 0.9)
.setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive()
btn.on('pointerover', () => btn.setFillStyle(0x2d4a2d, 0.9))
btn.on('pointerout', () => btn.setFillStyle(0x1a2a1a, 0.9))
btn.on('pointerdown', () => {
this.closeActionTray()
this.deactivateCategory()
this.scene.get('Game').events.emit('selectBuilding', b.kind)
})
this.actionTrayGroup.add(btn)
this.actionTrayGroup.add(
this.add.text(bx + itemW / 2, trayY + 18, b.emoji, { fontSize: '18px' })
.setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(302)
)
this.actionTrayGroup.add(
this.add.text(bx + itemW / 2, trayY + 44, b.label, {
fontSize: '10px', color: '#cccccc', fontFamily: 'monospace',
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(302)
)
})
}
/**
* Hides and destroys the building tool tray.
*/
private closeActionTray(): void {
if (!this.actionTrayVisible) return
this.actionTrayVisible = false
this.actionTrayGroup.destroy(true)
this.actionTrayGroup = this.add.group()
}
// ─── Resize ───────────────────────────────────────────────────────────────
/**
@@ -1170,11 +1322,16 @@ export class UIScene extends Phaser.Scene {
}
// Bottom elements
this.hintText.setPosition(width / 2, height - 40)
this.hintText.setPosition(width / 2, height - UIScene.BAR_H - 24)
this.toastText.setPosition(width / 2, 60)
this.coordsText.setPosition(10, height - 24)
this.controlsHintText.setPosition(10, height - 42)
// Action bar — reposition persistent elements
this.actionBarBg.setPosition(0, height - UIScene.BAR_H).setSize(width, UIScene.BAR_H)
this.actionBuildBtn.setPosition(8, height - UIScene.BAR_H + 8)
this.actionBuildLabel.setPosition(48, height - UIScene.BAR_H + UIScene.BAR_H / 2)
this.actionNisseBtn.setPosition(104, height - UIScene.BAR_H + 8)
this.actionNisseLabel.setPosition(144, height - UIScene.BAR_H + UIScene.BAR_H / 2)
if (this.actionTrayVisible) this.closeActionTray()
// Close centered panels — their position is calculated on open, so they
// would be off-center if left open during a resize
if (this.buildMenuVisible) this.closeBuildMenu()

View File

@@ -32,7 +32,7 @@ export class BuildingSystem {
create(): void {
this.ghost = this.scene.add.rectangle(0, 0, TILE_SIZE, TILE_SIZE, 0x00FF00, 0.35)
this.ghost.setDepth(20)
this.ghost.setDepth(1000)
this.ghost.setVisible(false)
this.ghost.setStrokeStyle(2, 0x00FF00, 0.8)
@@ -40,7 +40,7 @@ export class BuildingSystem {
fontSize: '10px', color: '#ffffff', fontFamily: 'monospace',
backgroundColor: '#000000aa', padding: { x: 3, y: 2 }
})
this.ghostLabel.setDepth(21)
this.ghostLabel.setDepth(1001)
this.ghostLabel.setVisible(false)
this.ghostLabel.setOrigin(0.5, 1)

View File

@@ -47,10 +47,10 @@ export class ResourceSystem {
sprite.setOrigin(0.5, 0.75)
}
sprite.setDepth(5)
sprite.setDepth(node.tileY + 5)
const healthBar = this.scene.add.graphics()
healthBar.setDepth(6)
healthBar.setDepth(node.tileY + 6)
healthBar.setVisible(false)
this.sprites.set(node.id, { sprite, node, healthBar })

View File

@@ -110,7 +110,7 @@ export class TreeSeedlingSystem {
const key = `seedling_${Math.min(s.stage, 2)}`
const sprite = this.scene.add.image(x, y, key)
.setOrigin(0.5, 0.85)
.setDepth(5)
.setDepth(s.tileY + 5)
this.sprites.set(s.id, sprite)
}

View File

@@ -13,6 +13,9 @@ const ARRIVAL_PX = 3
const WORK_LOG_MAX = 20
/** Job-type → display icon mapping; defined once at module level to avoid per-frame allocation. */
const JOB_ICONS: Record<string, string> = { chop: '🪓', mine: '⛏', farm: '🌾', forester: '🌲', '': '' }
interface VillagerRuntime {
sprite: Phaser.GameObjects.Image
nameLabel: Phaser.GameObjects.Text
@@ -118,15 +121,15 @@ export class VillagerSystem {
case 'sleeping':this.tickSleeping(v, rt, delta); break
}
// Sync sprite to state position
// Nisse always render above world objects
rt.sprite.setPosition(v.x, v.y)
rt.nameLabel.setPosition(v.x, v.y - 22)
rt.energyBar.setPosition(0, 0)
this.drawEnergyBar(rt.energyBar, v.x, v.y, v.energy)
// Job icon
const icons: Record<string, string> = { chop: '🪓', mine: '⛏', farm: '🌾', forester: '🌲', '': '' }
const jobType = v.aiState === 'sleeping' ? '💤' : (v.job ? (icons[v.job.type] ?? '') : '')
const jobType = v.aiState === 'sleeping' ? '💤' : (v.job ? (JOB_ICONS[v.job.type] ?? '') : '')
rt.jobIcon.setText(jobType).setPosition(v.x + 10, v.y - 18)
}
@@ -376,20 +379,27 @@ export class VillagerSystem {
const vTY = Math.floor(v.y / TILE_SIZE)
const dist = (tx: number, ty: number) => Math.abs(tx - vTX) + Math.abs(ty - vTY)
// Extract state collections once — avoids repeated Object.values() allocation per branch/loop.
const resources = Object.values(state.world.resources)
const buildings = Object.values(state.world.buildings)
const crops = Object.values(state.world.crops)
const seedlings = Object.values(state.world.treeSeedlings)
const zones = Object.values(state.world.foresterZones)
type C = { type: JobType; targetId: string; tileX: number; tileY: number; dist: number; pri: number }
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 zone of zones) {
for (const key of zone.tiles) zoneTiles.add(key)
}
const zoneChop: C[] = []
const naturalChop: C[] = []
for (const res of Object.values(state.world.resources)) {
for (const res of resources) {
if (res.kind !== 'tree' || this.claimed.has(res.id)) continue
// Skip trees with no reachable neighbour — A* cannot reach them.
if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue
@@ -405,7 +415,7 @@ export class VillagerSystem {
}
if (p.mine > 0) {
for (const res of Object.values(state.world.resources)) {
for (const res of resources) {
if (res.kind !== 'rock' || this.claimed.has(res.id)) continue
// Same reachability guard for rock tiles.
if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue
@@ -414,7 +424,7 @@ export class VillagerSystem {
}
if (p.farm > 0) {
for (const crop of Object.values(state.world.crops)) {
for (const crop of crops) {
if (crop.stage < crop.maxStage || this.claimed.has(crop.id)) continue
candidates.push({ type: 'farm', targetId: crop.id, tileX: crop.tileX, tileY: crop.tileY, dist: dist(crop.tileX, crop.tileY), pri: p.farm })
}
@@ -422,7 +432,7 @@ 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 zone of zones) {
for (const key of zone.tiles) {
const [tx, ty] = key.split(',').map(Number)
const targetId = `forester_tile_${tx}_${ty}`
@@ -430,12 +440,12 @@ export class VillagerSystem {
// 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
// Skip if something occupies this tile — reuse already-extracted arrays
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)
resources.some(r => r.tileX === tx && r.tileY === ty) ||
buildings.some(b => b.tileX === tx && b.tileY === ty) ||
crops.some(c => c.tileX === tx && c.tileY === ty) ||
seedlings.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 })
}
@@ -444,8 +454,9 @@ export class VillagerSystem {
if (candidates.length === 0) return null
// Lowest priority number wins; ties broken by distance
const bestPri = Math.min(...candidates.map(c => c.pri))
// Lowest priority number wins; ties broken by distance — avoid spread+map allocation
let bestPri = candidates[0].pri
for (let i = 1; i < candidates.length; i++) if (candidates[i].pri < bestPri) bestPri = candidates[i].pri
return candidates
.filter(c => c.pri === bestPri)
.sort((a, b) => a.dist - b.dist)[0] ?? null
@@ -569,16 +580,23 @@ export class VillagerSystem {
* for a newly added Nisse.
* @param v - Villager state to create sprites for
*/
/**
* Creates and registers all runtime objects (sprite, outline, label, energy bar, icon)
* for a newly added Nisse.
* @param v - Villager state to create sprites for
*/
private spawnSprite(v: VillagerState): void {
const sprite = this.scene.add.image(v.x, v.y, 'villager').setDepth(11)
// Nisse always render above trees, buildings and other world objects.
const sprite = this.scene.add.image(v.x, v.y, 'villager')
.setDepth(900)
const nameLabel = this.scene.add.text(v.x, v.y - 22, v.name, {
fontSize: '8px', color: '#ffffff', fontFamily: 'monospace',
backgroundColor: '#00000088', padding: { x: 2, y: 1 },
}).setOrigin(0.5, 1).setDepth(12)
}).setOrigin(0.5, 1).setDepth(901)
const energyBar = this.scene.add.graphics().setDepth(12)
const jobIcon = this.scene.add.text(v.x, v.y - 18, '', { fontSize: '10px' }).setDepth(13)
const energyBar = this.scene.add.graphics().setDepth(901)
const jobIcon = this.scene.add.text(v.x, v.y - 18, '', { fontSize: '10px' }).setDepth(902)
sprite.setInteractive()
sprite.on('pointerdown', () => this.onNisseClick?.(v.id))
@@ -667,6 +685,14 @@ export class VillagerSystem {
return result
}
/**
* Destroys all Nisse sprites and clears the runtime map.
* Should be called when the scene shuts down.
*/
/**
* Destroys all Nisse sprites and clears the runtime map.
* Should be called when the scene shuts down.
*/
/**
* Destroys all Nisse sprites and clears the runtime map.
* Should be called when the scene shuts down.

View File

@@ -172,6 +172,16 @@ export class WorldSystem {
this.resourceTiles.delete(tileY * WORLD_TILES + tileX)
}
/**
* Returns true if a resource (tree or rock) occupies the given tile.
* Uses the O(1) resourceTiles index.
* @param tileX - Tile column
* @param tileY - Tile row
*/
hasResourceAt(tileX: number, tileY: number): boolean {
return this.resourceTiles.has(tileY * WORLD_TILES + tileX)
}
/**
* Converts world pixel coordinates to tile coordinates.
* @param worldX - World X in pixels