Compare commits
15 Commits
16852c42e7
...
feature/ni
| Author | SHA1 | Date | |
|---|---|---|---|
| 155a40f963 | |||
| 41097b4765 | |||
| 0c636ed5ec | |||
| 4c41dc9205 | |||
| 01e57df6a6 | |||
| 1feeff215d | |||
| 1ba38cc23e | |||
| 793ab430e4 | |||
| 6f0d8a866f | |||
| 71aee058b5 | |||
| 3fdf621966 | |||
| 7f0ef0554e | |||
| d83b97a447 | |||
| a93e8a2c5d | |||
| 7c130763b5 |
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(curl:)",
|
||||
"Bash(curl -s \"https://git.zally.dev/api/v1/repos/tekki/nissefolk/issues/1\" -H \"Authorization: token de54ccf9eadd5950a6ea5fa264b6404acdecc732\")",
|
||||
"Bash(python3 -m json.tool)",
|
||||
"Bash(curl -s \"https://git.zally.dev/api/v1/repos/tekki/nissefolk/issues/1/timeline\" -H \"Authorization: token de54ccf9eadd5950a6ea5fa264b6404acdecc732\")",
|
||||
"Bash(curl:*)",
|
||||
"Bash(python3 -c \":*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(npm run:*)",
|
||||
"Bash(/usr/local/bin/npm run:*)",
|
||||
"Bash(/home/tekki/.nvm/versions/node/v24.14.0/bin/npm run:*)",
|
||||
"Bash(export PATH=\"/home/tekki/.nvm/versions/node/v24.14.0/bin:$PATH\")",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\(''''html_url'''', d.get\\(''''message'''',''''''''\\)\\)\\)\")",
|
||||
"Bash(git pull:*)",
|
||||
"Bash(for id:*)",
|
||||
"Bash(do echo:*)",
|
||||
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\(''''html_url'''',''''''''\\)\\)\")",
|
||||
"Bash(TOKEN=\"de54ccf9eadd5950a6ea5fa264b6404acdecc732\" BASE=\"https://git.zally.dev/api/v1/repos/tekki/nissefolk\" __NEW_LINE_2bc8ebfb809e4939__ for id in 5 6 7 9)",
|
||||
"Bash(TOKEN=\"de54ccf9eadd5950a6ea5fa264b6404acdecc732\")",
|
||||
"Bash(BASE=\"https://git.zally.dev/api/v1/repos/tekki/nissefolk\")",
|
||||
"Bash(__NEW_LINE_5d5fe245d6f316dc__ for:*)",
|
||||
"Bash(do)",
|
||||
"Bash(done)",
|
||||
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\(''''html_url'''',''''''''\\), d.get\\(''''number'''',''''''''\\), d.get\\(''''message'''',''''''''\\)\\)\")",
|
||||
"Bash(git remote:*)",
|
||||
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\(''''login'''',''''''''\\), d.get\\(''''message'''',''''''''\\)\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
game-test.log
|
||||
.claude/
|
||||
|
||||
@@ -7,6 +7,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Nisse Info Panel** (Issue #9): clicking a Nisse opens a top-left panel with name, AI status, energy bar, active job, job priority buttons, and a live work log (last 10 of 20 runtime entries); closes with ESC, ✕ button, or by clicking another Nisse
|
||||
- Work log tracks: walking to job, hauling to stockpile, going to sleep, waking up, chopped/mined/farmed results, deposited at stockpile
|
||||
- **ESC Menu** (Issue #7): pressing ESC when no overlay is open shows a pause menu with Save Game, Load Game, Settings (placeholder), and New Game; New Game requires confirmation before wiping the save
|
||||
- ESC key now follows a priority stack: confirmation dialog → context menu → build menu → villager panel → Nisse info panel → ESC menu → (build/farm mode handled by their systems) → open ESC menu
|
||||
|
||||
### Added
|
||||
- **F3 Debug View**: toggleable overlay showing FPS, tile type and contents under the cursor, Nisse count by AI state, active jobs by type, and pathfinding visualization (cyan lines in world space)
|
||||
|
||||
|
||||
11
CLAUDE.md
11
CLAUDE.md
@@ -1,5 +1,16 @@
|
||||
# CLAUDE.md — Game Project
|
||||
|
||||
## ⚠️ Important: Session Start Location
|
||||
|
||||
**Claude Code must be started from `~` (home directory), NOT from `~/game`.**
|
||||
|
||||
If you are reading this and the working directory is `/home/tekki/game`, please let the user know:
|
||||
> "Heads up: you've started me from inside `~/game`. Please exit and restart from your home directory (`~`) so that `.claude/` settings and memory stay outside the repo."
|
||||
|
||||
`.claude/` directories inside `~/game` are gitignored and must stay that way — no settings, tokens, or memory files belong in the project repo.
|
||||
|
||||
---
|
||||
|
||||
## Project Overview
|
||||
|
||||
A browser-based top-down game built with **Phaser 3** and **TypeScript**, bundled via **Vite**.
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{"t":1774091984264,"event":"snapshot","zoom":1,"scrollX":321,"scrollY":562,"vpScreen":{"w":958,"h":475},"vpWorld":{"w":958,"h":475},"vpTiles":{"w":29.938,"h":14.844},"centerWorld":{"x":800,"y":799.5},"mouse":{"screen":{"x":0,"y":0},"world":{"x":0,"y":0}}}
|
||||
{"t":1774091986280,"event":"snapshot","zoom":1,"scrollX":321,"scrollY":562,"vpScreen":{"w":958,"h":475},"vpWorld":{"w":958,"h":475},"vpTiles":{"w":29.938,"h":14.844},"centerWorld":{"x":800,"y":799.5},"mouse":{"screen":{"x":288,"y":400},"world":{"x":609,"y":961.5}}}
|
||||
{"t":1774091988280,"event":"snapshot","zoom":1,"scrollX":321,"scrollY":562,"vpScreen":{"w":958,"h":475},"vpWorld":{"w":958,"h":475},"vpTiles":{"w":29.938,"h":14.844},"centerWorld":{"x":800,"y":799.5},"mouse":{"screen":{"x":288,"y":400},"world":{"x":609,"y":961.5}}}
|
||||
{"t":1774091990281,"event":"snapshot","zoom":1,"scrollX":321,"scrollY":562,"vpScreen":{"w":958,"h":475},"vpWorld":{"w":958,"h":475},"vpTiles":{"w":29.938,"h":14.844},"centerWorld":{"x":800,"y":799.5},"mouse":{"screen":{"x":288,"y":400},"world":{"x":609,"y":961.5}}}
|
||||
{"t":1774091992281,"event":"snapshot","zoom":1,"scrollX":321,"scrollY":562,"vpScreen":{"w":958,"h":475},"vpWorld":{"w":958,"h":475},"vpTiles":{"w":29.938,"h":14.844},"centerWorld":{"x":800,"y":799.5},"mouse":{"screen":{"x":288,"y":400},"world":{"x":609,"y":961.5}}}
|
||||
@@ -62,6 +62,7 @@ export class GameScene extends Phaser.Scene {
|
||||
|
||||
this.villagerSystem.create()
|
||||
this.villagerSystem.onMessage = (msg) => this.events.emit('toast', msg)
|
||||
this.villagerSystem.onNisseClick = (id) => this.events.emit('nisseClicked', id)
|
||||
|
||||
this.debugSystem.create()
|
||||
|
||||
|
||||
@@ -31,6 +31,20 @@ export class UIScene extends Phaser.Scene {
|
||||
private inFarmMode = false
|
||||
private debugPanelText!: Phaser.GameObjects.Text
|
||||
private debugActive = false
|
||||
private escMenuGroup!: Phaser.GameObjects.Group
|
||||
private escMenuVisible = false
|
||||
private confirmGroup!: Phaser.GameObjects.Group
|
||||
private confirmVisible = false
|
||||
private nisseInfoGroup!: Phaser.GameObjects.Group
|
||||
private nisseInfoVisible = false
|
||||
private nisseInfoId: string | null = null
|
||||
private nisseInfoDynamic: {
|
||||
statusText: Phaser.GameObjects.Text
|
||||
energyBar: Phaser.GameObjects.Graphics
|
||||
energyPct: Phaser.GameObjects.Text
|
||||
jobText: Phaser.GameObjects.Text
|
||||
logTexts: Phaser.GameObjects.Text[]
|
||||
} | null = null
|
||||
|
||||
constructor() { super({ key: 'UI' }) }
|
||||
|
||||
@@ -64,8 +78,13 @@ export class UIScene extends Phaser.Scene {
|
||||
|
||||
this.scale.on('resize', () => this.repositionUI())
|
||||
|
||||
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.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
||||
if (ptr.rightButtonDown()) {
|
||||
@@ -78,7 +97,7 @@ export class UIScene extends Phaser.Scene {
|
||||
})
|
||||
|
||||
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.ESC)
|
||||
.on('down', () => this.hideContextMenu())
|
||||
.on('down', () => this.handleEsc())
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,6 +111,7 @@ export class UIScene extends Phaser.Scene {
|
||||
this.updateToast(delta)
|
||||
this.updatePopText()
|
||||
if (this.debugActive) this.updateDebugPanel()
|
||||
if (this.nisseInfoVisible) this.refreshNisseInfoPanel()
|
||||
}
|
||||
|
||||
// ─── Stockpile ────────────────────────────────────────────────────────────
|
||||
@@ -488,6 +508,350 @@ export class UIScene extends Phaser.Scene {
|
||||
this.scene.get('Game').events.emit('uiMenuClose')
|
||||
}
|
||||
|
||||
// ─── ESC key handler ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handles ESC key presses with a priority stack:
|
||||
* confirm dialog → context menu → build menu → villager panel →
|
||||
* 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.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
|
||||
this.openEscMenu()
|
||||
}
|
||||
|
||||
// ─── ESC Menu ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Opens the ESC pause menu (New Game / Save / Load / Settings). */
|
||||
private openEscMenu(): void {
|
||||
if (this.escMenuVisible) return
|
||||
this.escMenuVisible = true
|
||||
this.scene.get('Game').events.emit('uiMenuOpen')
|
||||
this.buildEscMenu()
|
||||
}
|
||||
|
||||
/** Closes and destroys the ESC menu. */
|
||||
private closeEscMenu(): void {
|
||||
if (!this.escMenuVisible) return
|
||||
this.escMenuVisible = false
|
||||
this.escMenuGroup.destroy(true)
|
||||
this.escMenuGroup = this.add.group()
|
||||
this.scene.get('Game').events.emit('uiMenuClose')
|
||||
}
|
||||
|
||||
/** Builds the ESC menu UI elements. */
|
||||
private buildEscMenu(): void {
|
||||
if (this.escMenuGroup) this.escMenuGroup.destroy(true)
|
||||
this.escMenuGroup = this.add.group()
|
||||
|
||||
const menuW = 240
|
||||
const btnH = 40
|
||||
const entries: { label: string; action: () => void }[] = [
|
||||
{ label: '💾 Save Game', action: () => this.doSaveGame() },
|
||||
{ label: '📂 Load Game', action: () => this.doLoadGame() },
|
||||
{ label: '⚙️ Settings', action: () => this.doSettings() },
|
||||
{ label: '🆕 New Game', action: () => this.doNewGame() },
|
||||
]
|
||||
const menuH = 16 + entries.length * (btnH + 8) + 8
|
||||
const mx = this.scale.width / 2 - menuW / 2
|
||||
const my = this.scale.height / 2 - menuH / 2
|
||||
|
||||
const bg = this.add.rectangle(mx, my, menuW, menuH, 0x0a0a0a, 0.95)
|
||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(400)
|
||||
this.escMenuGroup.add(bg)
|
||||
this.escMenuGroup.add(
|
||||
this.add.text(mx + menuW / 2, my + 12, 'MENU [ESC] close', {
|
||||
fontSize: '11px', color: '#666666', fontFamily: 'monospace',
|
||||
}).setOrigin(0.5, 0).setScrollFactor(0).setDepth(401)
|
||||
)
|
||||
|
||||
entries.forEach((entry, i) => {
|
||||
const by = my + 32 + i * (btnH + 8)
|
||||
const btn = this.add.rectangle(mx + 12, by, menuW - 24, btnH, 0x1a1a2e, 0.9)
|
||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(401).setInteractive()
|
||||
btn.on('pointerover', () => btn.setFillStyle(0x2a2a4e, 0.9))
|
||||
btn.on('pointerout', () => btn.setFillStyle(0x1a1a2e, 0.9))
|
||||
btn.on('pointerdown', entry.action)
|
||||
this.escMenuGroup.add(btn)
|
||||
this.escMenuGroup.add(
|
||||
this.add.text(mx + 24, by + btnH / 2, entry.label, {
|
||||
fontSize: '14px', color: '#dddddd', fontFamily: 'monospace',
|
||||
}).setOrigin(0, 0.5).setScrollFactor(0).setDepth(402)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** Saves the game and shows a toast confirmation. */
|
||||
private doSaveGame(): void {
|
||||
stateManager.save()
|
||||
this.closeEscMenu()
|
||||
this.showToast('Game saved!')
|
||||
}
|
||||
|
||||
/** Reloads the page to load the last save from localStorage. */
|
||||
private doLoadGame(): void {
|
||||
this.closeEscMenu()
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
/** Opens an empty Settings panel (placeholder). */
|
||||
private doSettings(): void {
|
||||
this.closeEscMenu()
|
||||
this.showToast('Settings — coming soon')
|
||||
}
|
||||
|
||||
/** Shows a confirmation dialog before starting a new game. */
|
||||
private doNewGame(): void {
|
||||
this.closeEscMenu()
|
||||
this.showConfirm(
|
||||
'Start a new game?\nAll progress will be lost.',
|
||||
() => { stateManager.reset(); window.location.reload() },
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Confirm dialog ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Shows a modal confirmation dialog with OK and Cancel buttons.
|
||||
* @param message - Message to display (newlines supported)
|
||||
* @param onConfirm - Callback invoked when the user confirms
|
||||
*/
|
||||
private showConfirm(message: string, onConfirm: () => void): void {
|
||||
this.hideConfirm()
|
||||
this.confirmVisible = true
|
||||
this.scene.get('Game').events.emit('uiMenuOpen')
|
||||
|
||||
const dialogW = 280
|
||||
const dialogH = 130
|
||||
const dx = this.scale.width / 2 - dialogW / 2
|
||||
const dy = this.scale.height / 2 - dialogH / 2
|
||||
|
||||
const bg = this.add.rectangle(dx, dy, dialogW, dialogH, 0x0a0a0a, 0.97)
|
||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(500)
|
||||
this.confirmGroup.add(bg)
|
||||
|
||||
this.confirmGroup.add(
|
||||
this.add.text(dx + dialogW / 2, dy + 20, message, {
|
||||
fontSize: '13px', color: '#cccccc', fontFamily: 'monospace',
|
||||
align: 'center', wordWrap: { width: dialogW - 32 },
|
||||
}).setOrigin(0.5, 0).setScrollFactor(0).setDepth(501)
|
||||
)
|
||||
|
||||
const btnY = dy + dialogH - 44
|
||||
// Cancel button
|
||||
const cancelBtn = this.add.rectangle(dx + 16, btnY, 110, 30, 0x333333, 0.9)
|
||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(501).setInteractive()
|
||||
cancelBtn.on('pointerover', () => cancelBtn.setFillStyle(0x555555, 0.9))
|
||||
cancelBtn.on('pointerout', () => cancelBtn.setFillStyle(0x333333, 0.9))
|
||||
cancelBtn.on('pointerdown', () => this.hideConfirm())
|
||||
this.confirmGroup.add(cancelBtn)
|
||||
this.confirmGroup.add(
|
||||
this.add.text(dx + 71, btnY + 15, 'Cancel', {
|
||||
fontSize: '13px', color: '#aaaaaa', fontFamily: 'monospace',
|
||||
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(502)
|
||||
)
|
||||
|
||||
// OK button
|
||||
const okBtn = this.add.rectangle(dx + dialogW - 126, btnY, 110, 30, 0x4a1a1a, 0.9)
|
||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(501).setInteractive()
|
||||
okBtn.on('pointerover', () => okBtn.setFillStyle(0x8a2a2a, 0.9))
|
||||
okBtn.on('pointerout', () => okBtn.setFillStyle(0x4a1a1a, 0.9))
|
||||
okBtn.on('pointerdown', () => { this.hideConfirm(); onConfirm() })
|
||||
this.confirmGroup.add(okBtn)
|
||||
this.confirmGroup.add(
|
||||
this.add.text(dx + dialogW - 71, btnY + 15, 'OK', {
|
||||
fontSize: '13px', color: '#ff8888', fontFamily: 'monospace',
|
||||
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(502)
|
||||
)
|
||||
}
|
||||
|
||||
/** Closes and destroys the confirmation dialog. */
|
||||
private hideConfirm(): void {
|
||||
if (!this.confirmVisible) return
|
||||
this.confirmVisible = false
|
||||
this.confirmGroup.destroy(true)
|
||||
this.confirmGroup = this.add.group()
|
||||
this.scene.get('Game').events.emit('uiMenuClose')
|
||||
}
|
||||
|
||||
// ─── Nisse Info Panel ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Opens (or switches to) the Nisse info panel for the given Nisse ID.
|
||||
* If another Nisse's panel is already open, it is replaced.
|
||||
* @param villagerId - ID of the Nisse to display
|
||||
*/
|
||||
private openNisseInfoPanel(villagerId: string): void {
|
||||
this.nisseInfoId = villagerId
|
||||
this.nisseInfoVisible = true
|
||||
this.scene.get('Game').events.emit('uiMenuOpen')
|
||||
this.buildNisseInfoPanel()
|
||||
}
|
||||
|
||||
/** Closes and destroys the Nisse info panel. */
|
||||
private closeNisseInfoPanel(): void {
|
||||
if (!this.nisseInfoVisible) return
|
||||
this.nisseInfoVisible = false
|
||||
this.nisseInfoId = null
|
||||
this.nisseInfoGroup.destroy(true)
|
||||
this.nisseInfoGroup = this.add.group()
|
||||
this.scene.get('Game').events.emit('uiMenuClose')
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the static skeleton of the Nisse info panel (background, name, close
|
||||
* button, labels, priority buttons) and stores references to the dynamic parts
|
||||
* (status text, energy bar, job text, work log texts).
|
||||
*/
|
||||
private buildNisseInfoPanel(): void {
|
||||
this.nisseInfoGroup.destroy(true)
|
||||
this.nisseInfoGroup = this.add.group()
|
||||
this.nisseInfoDynamic = null
|
||||
|
||||
const id = this.nisseInfoId
|
||||
if (!id) return
|
||||
|
||||
const state = stateManager.getState()
|
||||
const v = state.world.villagers[id]
|
||||
if (!v) { this.closeNisseInfoPanel(); return }
|
||||
|
||||
const LOG_ROWS = 10
|
||||
const panelW = 280
|
||||
const panelH = 120 + LOG_ROWS * 14 + 16
|
||||
const px = 10, py = 10
|
||||
|
||||
// Background
|
||||
this.nisseInfoGroup.add(
|
||||
this.add.rectangle(px, py, panelW, panelH, 0x050510, 0.93)
|
||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(250)
|
||||
)
|
||||
|
||||
// Name
|
||||
this.nisseInfoGroup.add(
|
||||
this.add.text(px + 10, py + 10, v.name, {
|
||||
fontSize: '14px', color: '#ffffff', 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.closeNisseInfoPanel())
|
||||
this.nisseInfoGroup.add(closeBtn)
|
||||
|
||||
// Dynamic: status text
|
||||
const statusTxt = this.add.text(px + 10, py + 28, '', {
|
||||
fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace',
|
||||
}).setScrollFactor(0).setDepth(251)
|
||||
this.nisseInfoGroup.add(statusTxt)
|
||||
|
||||
// Dynamic: energy bar + pct
|
||||
const energyBar = this.add.graphics().setScrollFactor(0).setDepth(251)
|
||||
this.nisseInfoGroup.add(energyBar)
|
||||
const energyPct = this.add.text(px + 136, py + 46, '', {
|
||||
fontSize: '10px', color: '#888888', fontFamily: 'monospace',
|
||||
}).setScrollFactor(0).setDepth(251)
|
||||
this.nisseInfoGroup.add(energyPct)
|
||||
|
||||
// Dynamic: job text
|
||||
const jobTxt = this.add.text(px + 10, py + 60, '', {
|
||||
fontSize: '11px', color: '#cccccc', fontFamily: 'monospace',
|
||||
}).setScrollFactor(0).setDepth(251)
|
||||
this.nisseInfoGroup.add(jobTxt)
|
||||
|
||||
// Static: priority label + buttons
|
||||
const jobKeys: Array<{ key: string; icon: string }> = [
|
||||
{ key: 'chop', icon: '🪓' }, { key: 'mine', icon: '⛏' }, { key: 'farm', 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 btn = this.add.text(bx, py + 78, label, {
|
||||
fontSize: '11px', color: pri === 0 ? '#555555' : '#ffffff',
|
||||
fontFamily: 'monospace', backgroundColor: pri === 0 ? '#1a1a1a' : '#1a3a1a',
|
||||
padding: { x: 5, y: 3 },
|
||||
}).setScrollFactor(0).setDepth(252).setInteractive()
|
||||
btn.on('pointerover', () => btn.setStyle({ backgroundColor: '#2d6a4f' }))
|
||||
btn.on('pointerout', () => btn.setStyle({ backgroundColor: pri === 0 ? '#1a1a1a' : '#1a3a1a' }))
|
||||
btn.on('pointerdown', () => {
|
||||
const newPri = (v.priorities[j.key as keyof typeof v.priorities] + 1) % 5
|
||||
const newPriorities = { ...v.priorities, [j.key]: newPri }
|
||||
this.scene.get('Game').events.emit('updatePriorities', id, newPriorities)
|
||||
// Rebuild panel so priority buttons reflect the new values immediately
|
||||
this.buildNisseInfoPanel()
|
||||
})
|
||||
this.nisseInfoGroup.add(btn)
|
||||
})
|
||||
|
||||
// Static: work log header
|
||||
this.nisseInfoGroup.add(
|
||||
this.add.text(px + 10, py + 98, '── Work Log ──────────────────', {
|
||||
fontSize: '10px', color: '#555555', fontFamily: 'monospace',
|
||||
}).setScrollFactor(0).setDepth(251)
|
||||
)
|
||||
|
||||
// Dynamic: log text rows (pre-allocated)
|
||||
const logTexts: Phaser.GameObjects.Text[] = []
|
||||
for (let i = 0; i < LOG_ROWS; i++) {
|
||||
const t = this.add.text(px + 10, py + 112 + i * 14, '', {
|
||||
fontSize: '10px', color: '#888888', fontFamily: 'monospace',
|
||||
}).setScrollFactor(0).setDepth(251)
|
||||
this.nisseInfoGroup.add(t)
|
||||
logTexts.push(t)
|
||||
}
|
||||
|
||||
this.nisseInfoDynamic = { statusText: statusTxt, energyBar, energyPct, jobText: jobTxt, logTexts }
|
||||
this.refreshNisseInfoPanel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates only the dynamic parts of the Nisse info panel (status, energy,
|
||||
* job, work log) without destroying and recreating the full group.
|
||||
* Called every frame while the panel is visible.
|
||||
*/
|
||||
private refreshNisseInfoPanel(): void {
|
||||
const dyn = this.nisseInfoDynamic
|
||||
if (!dyn || !this.nisseInfoId) return
|
||||
|
||||
const state = stateManager.getState()
|
||||
const v = state.world.villagers[this.nisseInfoId]
|
||||
if (!v) { this.closeNisseInfoPanel(); return }
|
||||
|
||||
const gameScene = this.scene.get('Game') as any
|
||||
const workLog = (gameScene.villagerSystem?.getWorkLog(this.nisseInfoId) ?? []) as string[]
|
||||
const statusStr = (gameScene.villagerSystem?.getStatusText(this.nisseInfoId) ?? '—') as string
|
||||
|
||||
dyn.statusText.setText(statusStr)
|
||||
|
||||
// Energy bar
|
||||
const px = 10, py = 10
|
||||
dyn.energyBar.clear()
|
||||
dyn.energyBar.fillStyle(0x333333); dyn.energyBar.fillRect(px + 10, py + 46, 120, 7)
|
||||
const col = v.energy > 60 ? 0x4CAF50 : v.energy > 30 ? 0xFF9800 : 0xF44336
|
||||
dyn.energyBar.fillStyle(col); dyn.energyBar.fillRect(px + 10, py + 46, 120 * (v.energy / 100), 7)
|
||||
dyn.energyPct.setText(`${Math.round(v.energy)}%`)
|
||||
|
||||
// Job
|
||||
dyn.jobText.setText(`Job: ${v.job ? `${v.job.type} → (${v.job.tileX}, ${v.job.tileY})` : '—'}`)
|
||||
|
||||
// Work log rows
|
||||
dyn.logTexts.forEach((t, i) => {
|
||||
t.setText(workLog[i] ?? '')
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Resize ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -518,5 +882,8 @@ export class UIScene extends Phaser.Scene {
|
||||
if (this.buildMenuVisible) this.closeBuildMenu()
|
||||
if (this.villagerPanelVisible) this.closeVillagerPanel()
|
||||
if (this.contextMenuVisible) this.hideContextMenu()
|
||||
if (this.escMenuVisible) this.closeEscMenu()
|
||||
if (this.confirmVisible) this.hideConfirm()
|
||||
if (this.nisseInfoVisible) this.closeNisseInfoPanel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { FarmingSystem } from './FarmingSystem'
|
||||
|
||||
const ARRIVAL_PX = 3
|
||||
|
||||
const WORK_LOG_MAX = 20
|
||||
|
||||
interface VillagerRuntime {
|
||||
sprite: Phaser.GameObjects.Image
|
||||
nameLabel: Phaser.GameObjects.Text
|
||||
@@ -20,6 +22,8 @@ interface VillagerRuntime {
|
||||
destination: 'job' | 'stockpile' | 'bed' | null
|
||||
workTimer: number
|
||||
idleScanTimer: number
|
||||
/** Runtime-only activity log; not persisted. Max WORK_LOG_MAX entries. */
|
||||
workLog: string[]
|
||||
}
|
||||
|
||||
export class VillagerSystem {
|
||||
@@ -35,6 +39,7 @@ export class VillagerSystem {
|
||||
private nameIndex = 0
|
||||
|
||||
onMessage?: (msg: string) => void
|
||||
onNisseClick?: (villagerId: string) => void
|
||||
|
||||
/**
|
||||
* @param scene - The Phaser scene this system belongs to
|
||||
@@ -139,13 +144,21 @@ export class VillagerSystem {
|
||||
// Carrying items? → find stockpile
|
||||
if (v.job?.carrying && Object.values(v.job.carrying).some(n => (n ?? 0) > 0)) {
|
||||
const sp = this.nearestBuilding(v, 'stockpile_zone')
|
||||
if (sp) { this.beginWalk(v, rt, sp.tileX, sp.tileY, 'stockpile'); return }
|
||||
if (sp) {
|
||||
this.addLog(v.id, '→ Hauling to stockpile')
|
||||
this.beginWalk(v, rt, sp.tileX, sp.tileY, 'stockpile')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Low energy → find bed
|
||||
if (v.energy < 25) {
|
||||
const bed = this.findBed(v)
|
||||
if (bed) { this.beginWalk(v, rt, bed.tileX, bed.tileY, 'bed'); return }
|
||||
if (bed) {
|
||||
this.addLog(v.id, '→ Going to sleep (low energy)')
|
||||
this.beginWalk(v, rt, bed.tileX, bed.tileY, 'bed')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Find a job
|
||||
@@ -156,6 +169,7 @@ export class VillagerSystem {
|
||||
type: 'VILLAGER_SET_JOB', villagerId: v.id,
|
||||
job: { type: job.type, targetId: job.targetId, tileX: job.tileX, tileY: job.tileY, carrying: {} },
|
||||
})
|
||||
this.addLog(v.id, `→ Walking to ${job.type} at (${job.tileX}, ${job.tileY})`)
|
||||
this.beginWalk(v, rt, job.tileX, job.tileY, 'job')
|
||||
} else {
|
||||
// No job available — wait before scanning again
|
||||
@@ -218,10 +232,12 @@ export class VillagerSystem {
|
||||
this.adapter.send({ type: 'VILLAGER_DEPOSIT', villagerId: v.id })
|
||||
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
|
||||
rt.idleScanTimer = 0 // scan for a new job immediately after deposit
|
||||
this.addLog(v.id, '✓ Deposited at stockpile')
|
||||
break
|
||||
|
||||
case 'bed':
|
||||
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'sleeping' })
|
||||
this.addLog(v.id, '💤 Sleeping...')
|
||||
break
|
||||
|
||||
default:
|
||||
@@ -261,6 +277,7 @@ export class VillagerSystem {
|
||||
// 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.addLog(v.id, '✓ Chopped tree (+2 wood)')
|
||||
}
|
||||
} else if (job.type === 'mine') {
|
||||
const res = state.world.resources[job.targetId]
|
||||
@@ -269,6 +286,7 @@ export class VillagerSystem {
|
||||
// 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.addLog(v.id, '✓ Mined rock (+2 stone)')
|
||||
}
|
||||
} else if (job.type === 'farm') {
|
||||
const crop = state.world.crops[job.targetId]
|
||||
@@ -276,6 +294,7 @@ export class VillagerSystem {
|
||||
this.adapter.send({ type: 'VILLAGER_HARVEST_CROP', villagerId: v.id, cropId: job.targetId })
|
||||
this.farmingSystem.removeCropSpritePublic(job.targetId)
|
||||
this.adapter.send({ type: 'CHANGE_TILE', tileX: crop.tileX, tileY: crop.tileY, tile: 9 as any })
|
||||
this.addLog(v.id, `✓ Farmed ${crop.kind}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +323,7 @@ export class VillagerSystem {
|
||||
if (v.energy >= 100) {
|
||||
rt.sprite.setAngle(0)
|
||||
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
|
||||
this.addLog(v.id, '✓ Woke up (energy full)')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,7 +487,10 @@ export class VillagerSystem {
|
||||
const energyBar = this.scene.add.graphics().setDepth(12)
|
||||
const jobIcon = this.scene.add.text(v.x, v.y - 18, '', { fontSize: '10px' }).setDepth(13)
|
||||
|
||||
this.runtime.set(v.id, { sprite, nameLabel, energyBar, jobIcon, path: [], destination: null, workTimer: 0, idleScanTimer: 0 })
|
||||
sprite.setInteractive()
|
||||
sprite.on('pointerdown', () => this.onNisseClick?.(v.id))
|
||||
|
||||
this.runtime.set(v.id, { sprite, nameLabel, energyBar, jobIcon, path: [], destination: null, workTimer: 0, idleScanTimer: 0, workLog: [] })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -486,6 +509,21 @@ export class VillagerSystem {
|
||||
g.fillStyle(col); g.fillRect(x - W/2, y - 28, W * (energy / 100), H)
|
||||
}
|
||||
|
||||
// ─── Work log ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Prepends a message to the runtime work log for the given Nisse.
|
||||
* Trims the log to WORK_LOG_MAX entries. No-ops if the Nisse is not found.
|
||||
* @param villagerId - Target Nisse ID
|
||||
* @param msg - Log message to prepend
|
||||
*/
|
||||
private addLog(villagerId: string, msg: string): void {
|
||||
const rt = this.runtime.get(villagerId)
|
||||
if (!rt) return
|
||||
rt.workLog.unshift(msg)
|
||||
if (rt.workLog.length > WORK_LOG_MAX) rt.workLog.length = WORK_LOG_MAX
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -506,6 +544,15 @@ export class VillagerSystem {
|
||||
return '💭 Idle'
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of the runtime work log for the given Nisse (newest first).
|
||||
* @param villagerId - The Nisse's ID
|
||||
* @returns Array of log strings, or empty array if not found
|
||||
*/
|
||||
getWorkLog(villagerId: string): string[] {
|
||||
return [...(this.runtime.get(villagerId)?.workLog ?? [])]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current world position and remaining path for every Nisse
|
||||
* that is currently in the 'walking' state. Used by DebugSystem for
|
||||
|
||||
Reference in New Issue
Block a user