7 Commits

6 changed files with 158 additions and 22 deletions

View File

@@ -7,7 +7,20 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Fixed
- Nisse now clear the FOREST/ROCK tile after harvesting, opening paths to deeper resources
- Nisse no longer get stuck idle after depositing items at the stockpile
- Working Nisse now reset to idle on game load (like walking ones), preventing stale AI state
- Stale jobs with empty carry are now cleared after work completes, avoiding a false "haul to stockpile" loop
- UI elements (stockpile panel, controls hint) now reposition correctly after window resize
- Centered overlay panels (build menu, villager panel) close on resize so they reopen at the correct position
- Mouse world coordinates now use `ptr.worldX`/`ptr.worldY` in BuildingSystem and FarmingSystem, fixing misalignment after resize or zoom
### Changed
- Villagers are now called **Nisse** throughout the UI (panel, controls hint, stockpile display, context menu, spawn message)
### Added ### Added
- Right-click context menu: suppresses browser default, shows Build and Nisse actions in the game world
- Initial project setup: Phaser 3 + TypeScript + Vite - Initial project setup: Phaser 3 + TypeScript + Vite
- Core scenes: `BootScene`, `GameScene`, `UIScene` - Core scenes: `BootScene`, `GameScene`, `UIScene`
- Systems: `BuildingSystem`, `CameraSystem`, `FarmingSystem`, `PlayerSystem`, - Systems: `BuildingSystem`, `CameraSystem`, `FarmingSystem`, `PlayerSystem`,

View File

@@ -176,9 +176,9 @@ class StateManager {
if (!p.world.crops) p.world.crops = {} if (!p.world.crops) p.world.crops = {}
if (!p.world.villagers) p.world.villagers = {} if (!p.world.villagers) p.world.villagers = {}
if (!p.world.stockpile) p.world.stockpile = {} if (!p.world.stockpile) p.world.stockpile = {}
// Reset walking villagers to idle on load // Reset in-flight AI states to idle on load so runtime timers start fresh
for (const v of Object.values(p.world.villagers)) { for (const v of Object.values(p.world.villagers)) {
if (v.aiState === 'walking') v.aiState = 'idle' if (v.aiState === 'walking' || v.aiState === 'working') v.aiState = 'idle'
} }
return p return p
} catch (_) { return null } } catch (_) { return null }

View File

@@ -21,7 +21,13 @@ export class UIScene extends Phaser.Scene {
private buildModeText!: Phaser.GameObjects.Text private buildModeText!: Phaser.GameObjects.Text
private farmToolText!: Phaser.GameObjects.Text private farmToolText!: Phaser.GameObjects.Text
private coordsText!: Phaser.GameObjects.Text private coordsText!: Phaser.GameObjects.Text
private controlsHintText!: Phaser.GameObjects.Text
private popText!: Phaser.GameObjects.Text private popText!: Phaser.GameObjects.Text
private stockpileTitleText!: Phaser.GameObjects.Text
private contextMenuGroup!: Phaser.GameObjects.Group
private contextMenuVisible = false
private inBuildMode = false
private inFarmMode = false
constructor() { super({ key: 'UI' }) } constructor() { super({ key: 'UI' }) }
@@ -47,6 +53,22 @@ export class UIScene extends Phaser.Scene {
.on('down', () => this.toggleVillagerPanel()) .on('down', () => this.toggleVillagerPanel())
this.scale.on('resize', () => this.repositionUI()) this.scale.on('resize', () => this.repositionUI())
this.input.mouse!.disableContextMenu()
this.contextMenuGroup = this.add.group()
this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
if (ptr.rightButtonDown()) {
if (!this.inBuildMode && !this.inFarmMode && !this.buildMenuVisible && !this.villagerPanelVisible) {
this.showContextMenu(ptr.x, ptr.y)
}
} else if (this.contextMenuVisible) {
this.hideContextMenu()
}
})
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.ESC)
.on('down', () => this.hideContextMenu())
} }
update(_t: number, delta: number): void { update(_t: number, delta: number): void {
@@ -60,13 +82,13 @@ export class UIScene extends Phaser.Scene {
private createStockpilePanel(): void { private createStockpilePanel(): void {
const x = this.scale.width - 178, y = 10 const x = this.scale.width - 178, y = 10
this.stockpilePanel = this.add.rectangle(x, y, 168, 165, 0x000000, 0.72).setOrigin(0, 0).setScrollFactor(0).setDepth(100) this.stockpilePanel = this.add.rectangle(x, y, 168, 165, 0x000000, 0.72).setOrigin(0, 0).setScrollFactor(0).setDepth(100)
this.add.text(x + 10, y + 7, '⚡ STOCKPILE', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101) this.stockpileTitleText = this.add.text(x + 10, y + 7, '⚡ STOCKPILE', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
const items = ['wood','stone','wheat_seed','carrot_seed','wheat','carrot'] as const const items = ['wood','stone','wheat_seed','carrot_seed','wheat','carrot'] as const
items.forEach((item, i) => { items.forEach((item, i) => {
const t = this.add.text(x + 10, y + 26 + i * 22, `${ITEM_ICONS[item]} ${item}: 0`, { fontSize: '13px', color: '#88dd88', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101) const t = this.add.text(x + 10, y + 26 + i * 22, `${ITEM_ICONS[item]} ${item}: 0`, { fontSize: '13px', color: '#88dd88', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
this.stockpileTexts.set(item, t) this.stockpileTexts.set(item, t)
}) })
this.popText = this.add.text(x + 10, y + 145, '👥 Pop: 0 / 0', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101) this.popText = this.add.text(x + 10, y + 145, '👥 Nisse: 0 / 0', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
} }
private updateStockpile(): void { private updateStockpile(): void {
@@ -82,7 +104,7 @@ export class UIScene extends Phaser.Scene {
const state = stateManager.getState() const state = stateManager.getState()
const beds = Object.values(state.world.buildings).filter(b => b.kind === 'bed').length const beds = Object.values(state.world.buildings).filter(b => b.kind === 'bed').length
const current = Object.keys(state.world.villagers).length const current = Object.keys(state.world.villagers).length
this.popText?.setText(`👥 Pop: ${current} / ${beds} [V] manage`) this.popText?.setText(`👥 Nisse: ${current} / ${beds} [V]`)
} }
// ─── Hint ───────────────────────────────────────────────────────────────── // ─── Hint ─────────────────────────────────────────────────────────────────
@@ -182,13 +204,13 @@ export class UIScene extends Phaser.Scene {
this.villagerPanelGroup.add(bg) this.villagerPanelGroup.add(bg)
this.villagerPanelGroup.add( this.villagerPanelGroup.add(
this.add.text(px + panelW/2, py + 12, '👥 VILLAGERS [V] close', { fontSize: '12px', color: '#aaaaaa', fontFamily: 'monospace' }) this.add.text(px + panelW/2, py + 12, '👥 NISSE [V] close', { fontSize: '12px', color: '#aaaaaa', fontFamily: 'monospace' })
.setOrigin(0.5, 0).setScrollFactor(0).setDepth(211) .setOrigin(0.5, 0).setScrollFactor(0).setDepth(211)
) )
if (villagers.length === 0) { if (villagers.length === 0) {
this.villagerPanelGroup.add( this.villagerPanelGroup.add(
this.add.text(px + panelW/2, py + panelH/2, 'No villagers yet.\nBuild a 🛏 Bed first!', { this.add.text(px + panelW/2, py + panelH/2, 'No Nisse yet.\nBuild a 🛏 Bed first!', {
fontSize: '13px', color: '#666666', fontFamily: 'monospace', align: 'center' fontSize: '13px', color: '#666666', fontFamily: 'monospace', align: 'center'
}).setOrigin(0.5).setScrollFactor(0).setDepth(211) }).setOrigin(0.5).setScrollFactor(0).setDepth(211)
) )
@@ -248,6 +270,7 @@ export class UIScene extends Phaser.Scene {
this.buildModeText = this.add.text(10, 10, '', { fontSize: '13px', color: '#ffff00', fontFamily: 'monospace', backgroundColor: '#00000099', padding: { x: 8, y: 4 } }).setScrollFactor(0).setDepth(100).setVisible(false) this.buildModeText = this.add.text(10, 10, '', { fontSize: '13px', color: '#ffff00', fontFamily: 'monospace', backgroundColor: '#00000099', padding: { x: 8, y: 4 } }).setScrollFactor(0).setDepth(100).setVisible(false)
} }
private onBuildModeChanged(active: boolean, building: BuildingType): void { private onBuildModeChanged(active: boolean, building: BuildingType): void {
this.inBuildMode = active
this.buildModeText.setText(active ? `🏗 BUILD: ${building.toUpperCase()} [RMB/ESC cancel]` : '').setVisible(active) this.buildModeText.setText(active ? `🏗 BUILD: ${building.toUpperCase()} [RMB/ESC cancel]` : '').setVisible(active)
} }
@@ -257,6 +280,7 @@ export class UIScene extends Phaser.Scene {
this.farmToolText = this.add.text(10, 44, '', { fontSize: '13px', color: '#aaffaa', fontFamily: 'monospace', backgroundColor: '#00000099', padding: { x: 8, y: 4 } }).setScrollFactor(0).setDepth(100).setVisible(false) this.farmToolText = this.add.text(10, 44, '', { fontSize: '13px', color: '#aaffaa', fontFamily: 'monospace', backgroundColor: '#00000099', padding: { x: 8, y: 4 } }).setScrollFactor(0).setDepth(100).setVisible(false)
} }
private onFarmToolChanged(tool: FarmingTool, label: string): void { private onFarmToolChanged(tool: FarmingTool, label: string): void {
this.inFarmMode = tool !== 'none'
this.farmToolText.setText(tool === 'none' ? '' : `[F] Farm: ${label} [RMB cancel]`).setVisible(tool !== 'none') this.farmToolText.setText(tool === 'none' ? '' : `[F] Farm: ${label} [RMB cancel]`).setVisible(tool !== 'none')
} }
@@ -264,7 +288,7 @@ export class UIScene extends Phaser.Scene {
private createCoordsDisplay(): void { private createCoordsDisplay(): void {
this.coordsText = this.add.text(10, this.scale.height - 24, '', { fontSize: '11px', color: '#666666', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(100) this.coordsText = this.add.text(10, this.scale.height - 24, '', { fontSize: '11px', color: '#666666', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(100)
this.add.text(10, this.scale.height - 42, '[WASD] Pan [Scroll] Zoom [F] Farm [B] Build [V] Villagers', { this.controlsHintText = this.add.text(10, this.scale.height - 42, '[WASD] Pan [Scroll] Zoom [F] Farm [B] Build [V] Nisse', {
fontSize: '10px', color: '#444444', fontFamily: 'monospace', backgroundColor: '#00000066', padding: { x: 4, y: 2 } fontSize: '10px', color: '#444444', fontFamily: 'monospace', backgroundColor: '#00000066', padding: { x: 4, y: 2 }
}).setScrollFactor(0).setDepth(100) }).setScrollFactor(0).setDepth(100)
} }
@@ -272,12 +296,98 @@ export class UIScene extends Phaser.Scene {
this.coordsText.setText(`Tile: ${pos.tileX}, ${pos.tileY}`) this.coordsText.setText(`Tile: ${pos.tileX}, ${pos.tileY}`)
} }
// ─── Context Menu ─────────────────────────────────────────────────────────
/**
* Shows the right-click context menu at the given screen coordinates.
* Any previously open context menu is closed first.
* @param x - Screen x position of the pointer
* @param y - Screen y position of the pointer
*/
private showContextMenu(x: number, y: number): void {
this.hideContextMenu()
const menuW = 150
const btnH = 32
const menuH = 8 + 2 * (btnH + 6) - 6 + 8
const mx = Math.min(x, this.scale.width - menuW - 4)
const my = Math.min(y, this.scale.height - menuH - 4)
const bg = this.add.rectangle(mx, my, menuW, menuH, 0x000000, 0.88)
.setOrigin(0, 0).setScrollFactor(0).setDepth(300)
this.contextMenuGroup.add(bg)
const entries: { label: string; action: () => void }[] = [
{
label: '🏗 Build',
action: () => { this.hideContextMenu(); this.scene.get('Game').events.emit('uiRequestBuildMenu') },
},
{
label: '👥 Nisse',
action: () => { this.hideContextMenu(); this.toggleVillagerPanel() },
},
]
entries.forEach((entry, i) => {
const by = my + 8 + i * (btnH + 6)
const btn = this.add.rectangle(mx + 8, by, menuW - 16, btnH, 0x1a3a1a, 0.9)
.setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive()
btn.on('pointerover', () => btn.setFillStyle(0x2d6a4f, 0.9))
btn.on('pointerout', () => btn.setFillStyle(0x1a3a1a, 0.9))
btn.on('pointerdown', entry.action)
this.contextMenuGroup.add(btn)
this.contextMenuGroup.add(
this.add.text(mx + 16, by + btnH / 2, entry.label, {
fontSize: '13px', color: '#ffffff', fontFamily: 'monospace',
}).setOrigin(0, 0.5).setScrollFactor(0).setDepth(302)
)
})
this.contextMenuVisible = true
this.scene.get('Game').events.emit('uiMenuOpen')
}
/**
* Closes and destroys the context menu if it is currently visible.
*/
private hideContextMenu(): void {
if (!this.contextMenuVisible) return
this.contextMenuGroup.destroy(true)
this.contextMenuGroup = this.add.group()
this.contextMenuVisible = false
this.scene.get('Game').events.emit('uiMenuClose')
}
// ─── Resize ─────────────────────────────────────────────────────────────── // ─── Resize ───────────────────────────────────────────────────────────────
/**
* Repositions all fixed UI elements after a canvas resize.
* Open overlay panels are closed so they reopen correctly centered.
*/
private repositionUI(): void { private repositionUI(): void {
const { width, height } = this.scale const { width, height } = this.scale
// Stockpile panel — anchored to top-right; move all elements by the delta
const newPanelX = width - 178
const deltaX = newPanelX - this.stockpilePanel.x
if (deltaX !== 0) {
this.stockpilePanel.setX(newPanelX)
this.stockpileTitleText.setX(this.stockpileTitleText.x + deltaX)
this.stockpileTexts.forEach(t => t.setX(t.x + deltaX))
this.popText.setX(this.popText.x + deltaX)
}
// Bottom elements
this.hintText.setPosition(width / 2, height - 40) this.hintText.setPosition(width / 2, height - 40)
this.toastText.setPosition(width / 2, 60) this.toastText.setPosition(width / 2, 60)
this.coordsText.setPosition(10, height - 24) this.coordsText.setPosition(10, height - 24)
this.controlsHintText.setPosition(10, height - 42)
// 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()
if (this.villagerPanelVisible) this.closeVillagerPanel()
if (this.contextMenuVisible) this.hideContextMenu()
} }
} }

View File

@@ -93,8 +93,8 @@ export class BuildingSystem {
// Update ghost to follow mouse (snapped to tile grid) // Update ghost to follow mouse (snapped to tile grid)
const ptr = this.scene.input.activePointer const ptr = this.scene.input.activePointer
const worldX = this.scene.cameras.main.scrollX + ptr.x const worldX = ptr.worldX
const worldY = this.scene.cameras.main.scrollY + ptr.y const worldY = ptr.worldY
const tileX = Math.floor(worldX / TILE_SIZE) const tileX = Math.floor(worldX / TILE_SIZE)
const tileY = Math.floor(worldY / TILE_SIZE) const tileY = Math.floor(worldY / TILE_SIZE)
const snapX = tileX * TILE_SIZE + TILE_SIZE / 2 const snapX = tileX * TILE_SIZE + TILE_SIZE / 2
@@ -142,8 +142,8 @@ export class BuildingSystem {
} }
private tryPlace(ptr: Phaser.Input.Pointer): void { private tryPlace(ptr: Phaser.Input.Pointer): void {
const worldX = this.scene.cameras.main.scrollX + ptr.x const worldX = ptr.worldX
const worldY = this.scene.cameras.main.scrollY + ptr.y const worldY = ptr.worldY
const tileX = Math.floor(worldX / TILE_SIZE) const tileX = Math.floor(worldX / TILE_SIZE)
const tileY = Math.floor(worldY / TILE_SIZE) const tileY = Math.floor(worldY / TILE_SIZE)

View File

@@ -80,9 +80,8 @@ export class FarmingSystem {
// ─── Tool actions ───────────────────────────────────────────────────────── // ─── Tool actions ─────────────────────────────────────────────────────────
private useToolAt(ptr: Phaser.Input.Pointer): void { private useToolAt(ptr: Phaser.Input.Pointer): void {
const cam = this.scene.cameras.main const worldX = ptr.worldX
const worldX = cam.scrollX + ptr.x const worldY = ptr.worldY
const worldY = cam.scrollY + ptr.y
const tileX = Math.floor(worldX / TILE_SIZE) const tileX = Math.floor(worldX / TILE_SIZE)
const tileY = Math.floor(worldY / TILE_SIZE) const tileY = Math.floor(worldY / TILE_SIZE)
const state = stateManager.getState() const state = stateManager.getState()

View File

@@ -1,5 +1,6 @@
import Phaser from 'phaser' import Phaser from 'phaser'
import { TILE_SIZE, VILLAGER_SPEED, VILLAGER_SPAWN_INTERVAL, VILLAGER_WORK_TIMES, VILLAGER_NAMES } from '../config' import { TILE_SIZE, VILLAGER_SPEED, VILLAGER_SPAWN_INTERVAL, VILLAGER_WORK_TIMES, VILLAGER_NAMES } from '../config'
import { TileType } from '../types'
import type { VillagerState, VillagerJob, JobType, AIState, ItemId } from '../types' import type { VillagerState, VillagerJob, JobType, AIState, ItemId } from '../types'
import { stateManager } from '../StateManager' import { stateManager } from '../StateManager'
import { findPath } from '../utils/pathfinding' import { findPath } from '../utils/pathfinding'
@@ -170,6 +171,7 @@ export class VillagerSystem {
case 'stockpile': case 'stockpile':
this.adapter.send({ type: 'VILLAGER_DEPOSIT', villagerId: v.id }) this.adapter.send({ type: 'VILLAGER_DEPOSIT', villagerId: v.id })
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' }) this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
rt.idleScanTimer = 0 // scan for a new job immediately after deposit
break break
case 'bed': case 'bed':
@@ -199,13 +201,19 @@ export class VillagerSystem {
const state = stateManager.getState() const state = stateManager.getState()
if (job.type === 'chop') { if (job.type === 'chop') {
if (state.world.resources[job.targetId]) { const res = state.world.resources[job.targetId]
if (res) {
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId }) this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
// Clear the FOREST tile so the area becomes passable for future pathfinding
this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.DARK_GRASS })
this.resourceSystem.removeResource(job.targetId) this.resourceSystem.removeResource(job.targetId)
} }
} else if (job.type === 'mine') { } else if (job.type === 'mine') {
if (state.world.resources[job.targetId]) { const res = state.world.resources[job.targetId]
if (res) {
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId }) this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
// Clear the ROCK tile so the area becomes passable for future pathfinding
this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.GRASS })
this.resourceSystem.removeResource(job.targetId) this.resourceSystem.removeResource(job.targetId)
} }
} else if (job.type === 'farm') { } else if (job.type === 'farm') {
@@ -217,7 +225,13 @@ export class VillagerSystem {
} }
} }
// Back to idle so decideAction handles depositing // If the harvest produced nothing (resource already gone), clear the stale job
// so tickIdle does not try to walk to a stockpile with nothing to deposit.
if (!v.job?.carrying || !Object.values(v.job.carrying).some(n => (n ?? 0) > 0)) {
this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: null })
}
// Back to idle — tickIdle will handle hauling to stockpile if carrying items
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' }) this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
} }
@@ -342,7 +356,7 @@ export class VillagerSystem {
this.adapter.send({ type: 'SPAWN_VILLAGER', villager }) this.adapter.send({ type: 'SPAWN_VILLAGER', villager })
this.spawnSprite(villager) this.spawnSprite(villager)
this.onMessage?.(`${name} has joined the settlement! 🏘`) this.onMessage?.(`${name} the Nisse has arrived! 🏘`)
} }
// ─── Sprite management ──────────────────────────────────────────────────── // ─── Sprite management ────────────────────────────────────────────────────