Compare commits
22 Commits
1d8b2b2b9c
...
fix/debug-
| Author | SHA1 | Date | |
|---|---|---|---|
| ae6c14d9a1 | |||
| 3e099d92e2 | |||
| f78645bb79 | |||
| 84aa1a7ce5 | |||
| 24ee3257df | |||
| 78c184c560 | |||
| 7ff3d82e11 | |||
| 20858a1be1 | |||
| 3b021127a4 | |||
| 26c3807481 | |||
| d02ed33435 | |||
| c7cf971e54 | |||
| 08dffa135f | |||
| 4f2e9f73b6 | |||
| 84b6e51746 | |||
| 5f646d54ca | |||
| 94b2f7f457 | |||
| cd171c859c | |||
| d9ef57c6b0 | |||
| 87f69b4774 | |||
| 8d2c58cb5f | |||
| 986c2ea9eb |
15
CHANGELOG.md
15
CHANGELOG.md
@@ -7,6 +7,21 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Debug-Panel überlagert Nisse-Info-Panel** (Issue #41): F3-Debug-Panel weicht dynamisch aus — wenn das Nisse-Info-Panel offen ist, erscheint das Debug-Panel unterhalb davon statt darüber
|
||||
- **Stockpile-Overlay Transparenz** (Issue #39): `updateStaticPanelOpacity()` verwendete `setAlpha()` statt `setFillStyle()` — dadurch wurde die Opacity quadratisch statt linear angewendet; bei 100 % blieb das Panel sichtbar transparent
|
||||
- **Action Bar Transparenz** (Issue #40): Action Bar ignorierte `uiOpacity` komplett — Hintergrund war hardcoded auf 0.92; wird jetzt korrekt mit `uiOpacity` erstellt und per `updateStaticPanelOpacity()` live aktualisiert
|
||||
|
||||
### Performance
|
||||
- **Event-queue timers** (Issue #36): crops, tree seedlings, and tile-recovery events now use a sorted priority queue with absolute `gameTime` timestamps instead of per-frame countdown iteration — O(due items) per tick instead of O(total items); `WorldState.gameTime` tracks the in-game clock; save migrated from v5 to v6
|
||||
|
||||
### Added
|
||||
- **Action log in F3 debug panel** (Issue #37): last 15 actions dispatched through the adapter are shown in the F3 overlay under "Last Actions"; ring buffer maintained in `LocalAdapter`
|
||||
|
||||
### 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 1–2 `tree_seed` in den Stockpile
|
||||
|
||||
@@ -8,12 +8,42 @@ export interface NetworkAdapter {
|
||||
onAction?: (action: GameAction) => void
|
||||
}
|
||||
|
||||
const ACTION_LOG_SIZE = 15
|
||||
|
||||
/** Singleplayer: apply actions immediately and synchronously */
|
||||
export class LocalAdapter implements NetworkAdapter {
|
||||
onAction?: (action: GameAction) => void
|
||||
|
||||
/** Ring-buffer of the last ACTION_LOG_SIZE dispatched action summaries. */
|
||||
private _actionLog: string[] = []
|
||||
|
||||
send(action: GameAction): void {
|
||||
stateManager.apply(action)
|
||||
this._recordAction(action)
|
||||
this.onAction?.(action)
|
||||
}
|
||||
|
||||
/** Returns a copy of the recent action log (oldest first). */
|
||||
getActionLog(): readonly string[] { return this._actionLog }
|
||||
|
||||
/**
|
||||
* Appends a short summary of the action to the ring-buffer.
|
||||
* @param action - The dispatched game action
|
||||
*/
|
||||
private _recordAction(action: GameAction): void {
|
||||
let entry = action.type
|
||||
if ('tileX' in action && 'tileY' in action)
|
||||
entry += ` (${(action as any).tileX},${(action as any).tileY})`
|
||||
else if ('villagerId' in action)
|
||||
entry += ` v=…${(action as any).villagerId.slice(-4)}`
|
||||
else if ('resourceId' in action)
|
||||
entry += ` r=…${(action as any).resourceId.slice(-4)}`
|
||||
else if ('cropId' in action)
|
||||
entry += ` c=…${(action as any).cropId.slice(-4)}`
|
||||
else if ('seedlingId' in action)
|
||||
entry += ` s=…${(action as any).seedlingId.slice(-4)}`
|
||||
|
||||
if (this._actionLog.length >= ACTION_LOG_SIZE) this._actionLog.shift()
|
||||
this._actionLog.push(entry)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,42 @@ import { SAVE_KEY, WORLD_TILES, CROP_CONFIGS, TREE_SEEDLING_STAGE_MS, TILE_RECOV
|
||||
import type { GameStateData, GameAction, PlayerState, WorldState, ItemId } from './types'
|
||||
import { TileType } from './types'
|
||||
|
||||
// ─── Internal queue entry types ───────────────────────────────────────────────
|
||||
|
||||
/** Scheduled crop-growth entry. Two entries are created per stage (normal + watered path). */
|
||||
interface CropEntry {
|
||||
id: string
|
||||
fireAt: number
|
||||
expectedStage: number
|
||||
/** If true this entry only fires when crop.watered === true. */
|
||||
wateredPath: boolean
|
||||
}
|
||||
|
||||
/** Scheduled seedling-growth entry. One entry per stage. */
|
||||
interface SeedlingEntry {
|
||||
id: string
|
||||
fireAt: number
|
||||
expectedStage: number
|
||||
}
|
||||
|
||||
/** Scheduled tile-recovery entry. One entry per tile. */
|
||||
interface RecoveryEntry {
|
||||
key: string
|
||||
fireAt: number
|
||||
}
|
||||
|
||||
// ─── State factories ───────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_PLAYER: PlayerState = {
|
||||
id: 'player1',
|
||||
x: 8192, y: 8192,
|
||||
inventory: {}, // empty — seeds now in stockpile
|
||||
inventory: {},
|
||||
}
|
||||
|
||||
function makeEmptyWorld(seed: number): WorldState {
|
||||
return {
|
||||
seed,
|
||||
gameTime: 0,
|
||||
tiles: new Array(WORLD_TILES * WORLD_TILES).fill(3),
|
||||
resources: {},
|
||||
buildings: {},
|
||||
@@ -25,21 +52,164 @@ function makeEmptyWorld(seed: number): WorldState {
|
||||
|
||||
function makeDefaultState(): GameStateData {
|
||||
return {
|
||||
version: 5,
|
||||
version: 6,
|
||||
world: makeEmptyWorld(Math.floor(Math.random() * 999999)),
|
||||
player: { ...DEFAULT_PLAYER, inventory: { ...DEFAULT_PLAYER.inventory } },
|
||||
}
|
||||
}
|
||||
|
||||
// ─── StateManager ─────────────────────────────────────────────────────────────
|
||||
|
||||
class StateManager {
|
||||
private state: GameStateData
|
||||
|
||||
// In-memory event queues (not persisted; rebuilt from state on load).
|
||||
private cropQueue: CropEntry[] = []
|
||||
private seedlingQueue: SeedlingEntry[] = []
|
||||
private recoveryQueue: RecoveryEntry[] = []
|
||||
|
||||
constructor() {
|
||||
this.state = this.load() ?? makeDefaultState()
|
||||
this.rebuildQueues()
|
||||
}
|
||||
|
||||
getState(): Readonly<GameStateData> { return this.state }
|
||||
|
||||
/** Returns the current accumulated in-game time in milliseconds. */
|
||||
getGameTime(): number { return this.state.world.gameTime }
|
||||
|
||||
/**
|
||||
* Advances the in-game clock by delta milliseconds.
|
||||
* Must be called once per frame before any tick methods.
|
||||
* @param delta - Frame delta in milliseconds
|
||||
*/
|
||||
advanceTime(delta: number): void {
|
||||
this.state.world.gameTime += delta
|
||||
}
|
||||
|
||||
// ─── Queue helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Inserts an entry into a sorted queue in ascending fireAt order.
|
||||
* Uses binary search for O(log n) position find; O(n) splice insert.
|
||||
*/
|
||||
private static insertSorted<T extends { fireAt: number }>(queue: T[], entry: T): void {
|
||||
let lo = 0, hi = queue.length
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >>> 1
|
||||
if (queue[mid].fireAt <= entry.fireAt) lo = mid + 1
|
||||
else hi = mid
|
||||
}
|
||||
queue.splice(lo, 0, entry)
|
||||
}
|
||||
|
||||
/** Enqueues both growth entries (normal + watered path) for a crop's current stage. */
|
||||
private enqueueCropStage(id: string, expectedStage: number, growsAt: number, growsAtWatered: number): void {
|
||||
StateManager.insertSorted(this.cropQueue, { id, fireAt: growsAt, expectedStage, wateredPath: false })
|
||||
StateManager.insertSorted(this.cropQueue, { id, fireAt: growsAtWatered, expectedStage, wateredPath: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds all three event queues from the persisted state.
|
||||
* Called once after construction or load.
|
||||
*/
|
||||
private rebuildQueues(): void {
|
||||
this.cropQueue = []
|
||||
this.seedlingQueue = []
|
||||
this.recoveryQueue = []
|
||||
|
||||
for (const crop of Object.values(this.state.world.crops)) {
|
||||
if (crop.stage >= crop.maxStage) continue
|
||||
this.enqueueCropStage(crop.id, crop.stage, crop.growsAt, crop.growsAtWatered)
|
||||
}
|
||||
|
||||
for (const s of Object.values(this.state.world.treeSeedlings)) {
|
||||
if (s.stage < 2) {
|
||||
StateManager.insertSorted(this.seedlingQueue, { id: s.id, fireAt: s.growsAt, expectedStage: s.stage })
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, fireAt] of Object.entries(this.state.world.tileRecovery)) {
|
||||
StateManager.insertSorted(this.recoveryQueue, { key, fireAt })
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tick methods ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Drains the crop queue up to the current gameTime.
|
||||
* Returns IDs of crops that advanced a stage this frame.
|
||||
*/
|
||||
tickCrops(): string[] {
|
||||
const now = this.state.world.gameTime
|
||||
const advanced: string[] = []
|
||||
|
||||
while (this.cropQueue.length > 0 && this.cropQueue[0].fireAt <= now) {
|
||||
const entry = this.cropQueue.shift()!
|
||||
const crop = this.state.world.crops[entry.id]
|
||||
if (!crop || crop.stage !== entry.expectedStage) continue // already removed or stale stage
|
||||
if (entry.wateredPath && !crop.watered) continue // fast-path skipped: not watered
|
||||
|
||||
crop.stage++
|
||||
advanced.push(crop.id)
|
||||
|
||||
if (crop.stage < crop.maxStage) {
|
||||
const cfg = CROP_CONFIGS[crop.kind]
|
||||
crop.growsAt = now + cfg.stageTimeMs
|
||||
crop.growsAtWatered = now + cfg.stageTimeMs / 2
|
||||
this.enqueueCropStage(crop.id, crop.stage, crop.growsAt, crop.growsAtWatered)
|
||||
}
|
||||
}
|
||||
return advanced
|
||||
}
|
||||
|
||||
/**
|
||||
* Drains the seedling queue up to the current gameTime.
|
||||
* Returns IDs of seedlings that advanced a stage this frame.
|
||||
*/
|
||||
tickSeedlings(): string[] {
|
||||
const now = this.state.world.gameTime
|
||||
const advanced: string[] = []
|
||||
|
||||
while (this.seedlingQueue.length > 0 && this.seedlingQueue[0].fireAt <= now) {
|
||||
const entry = this.seedlingQueue.shift()!
|
||||
const s = this.state.world.treeSeedlings[entry.id]
|
||||
if (!s || s.stage !== entry.expectedStage) continue // removed or stale
|
||||
|
||||
s.stage = Math.min(s.stage + 1, 2)
|
||||
advanced.push(s.id)
|
||||
|
||||
if (s.stage < 2) {
|
||||
s.growsAt = now + TREE_SEEDLING_STAGE_MS
|
||||
StateManager.insertSorted(this.seedlingQueue, { id: s.id, fireAt: s.growsAt, expectedStage: s.stage })
|
||||
}
|
||||
}
|
||||
return advanced
|
||||
}
|
||||
|
||||
/**
|
||||
* Drains the tile-recovery queue up to the current gameTime.
|
||||
* Returns keys ("tileX,tileY") of tiles that have reverted to GRASS.
|
||||
*/
|
||||
tickTileRecovery(): string[] {
|
||||
const now = this.state.world.gameTime
|
||||
const recovered: string[] = []
|
||||
|
||||
while (this.recoveryQueue.length > 0 && this.recoveryQueue[0].fireAt <= now) {
|
||||
const entry = this.recoveryQueue.shift()!
|
||||
const fireAt = this.state.world.tileRecovery[entry.key]
|
||||
// Skip if the entry was superseded (tile re-planted, resetting its fireAt)
|
||||
if (fireAt === undefined || fireAt > now) continue
|
||||
delete this.state.world.tileRecovery[entry.key]
|
||||
recovered.push(entry.key)
|
||||
const [tx, ty] = entry.key.split(',').map(Number)
|
||||
this.state.world.tiles[ty * WORLD_TILES + tx] = TileType.GRASS
|
||||
}
|
||||
return recovered
|
||||
}
|
||||
|
||||
// ─── State mutations ───────────────────────────────────────────────────────
|
||||
|
||||
apply(action: GameAction): void {
|
||||
const s = this.state
|
||||
const w = s.world
|
||||
@@ -66,7 +236,6 @@ 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: [] }
|
||||
}
|
||||
@@ -74,7 +243,6 @@ class StateManager {
|
||||
}
|
||||
|
||||
case 'REMOVE_BUILDING':
|
||||
// Remove associated forester zone when the hut is demolished
|
||||
if (w.buildings[action.buildingId]?.kind === 'forester_hut') {
|
||||
delete w.foresterZones[action.buildingId]
|
||||
}
|
||||
@@ -90,22 +258,24 @@ class StateManager {
|
||||
w.crops[action.crop.id] = { ...action.crop }
|
||||
const have = w.stockpile[action.seedItem] ?? 0
|
||||
w.stockpile[action.seedItem] = Math.max(0, have - 1)
|
||||
// Enqueue growth timers for both normal and watered paths
|
||||
this.enqueueCropStage(action.crop.id, 0, action.crop.growsAt, action.crop.growsAtWatered)
|
||||
break
|
||||
}
|
||||
|
||||
case 'WATER_CROP': {
|
||||
const c = w.crops[action.cropId]; if (c) c.watered = true; break
|
||||
// No queue change needed — the wateredPath entry was enqueued at planting time
|
||||
}
|
||||
|
||||
case 'HARVEST_CROP': {
|
||||
delete w.crops[action.cropId]
|
||||
for (const [k, v] of Object.entries(action.rewards))
|
||||
w.stockpile[k as ItemId] = (w.stockpile[k as ItemId] ?? 0) + (v ?? 0)
|
||||
// Stale queue entries will be skipped automatically (crop no longer exists)
|
||||
break
|
||||
}
|
||||
|
||||
// ── Villager actions ──────────────────────────────────────────────────
|
||||
|
||||
case 'SPAWN_VILLAGER':
|
||||
w.villagers[action.villager.id] = { ...action.villager }; break
|
||||
|
||||
@@ -163,22 +333,30 @@ class StateManager {
|
||||
case 'PLANT_TREE_SEED': {
|
||||
w.treeSeedlings[action.seedling.id] = { ...action.seedling }
|
||||
w.stockpile.tree_seed = Math.max(0, (w.stockpile.tree_seed ?? 0) - 1)
|
||||
// Cancel any tile recovery on this tile
|
||||
delete w.tileRecovery[`${action.seedling.tileX},${action.seedling.tileY}`]
|
||||
// Enqueue growth timer
|
||||
StateManager.insertSorted(this.seedlingQueue, {
|
||||
id: action.seedling.id, fireAt: action.seedling.growsAt, expectedStage: 0
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'REMOVE_TREE_SEEDLING':
|
||||
delete w.treeSeedlings[action.seedlingId]
|
||||
// Stale queue entries will be skipped automatically
|
||||
break
|
||||
|
||||
case 'SPAWN_RESOURCE':
|
||||
w.resources[action.resource.id] = { ...action.resource }
|
||||
break
|
||||
|
||||
case 'TILE_RECOVERY_START':
|
||||
w.tileRecovery[`${action.tileX},${action.tileY}`] = TILE_RECOVERY_MS
|
||||
case 'TILE_RECOVERY_START': {
|
||||
const fireAt = w.gameTime + TILE_RECOVERY_MS
|
||||
const key = `${action.tileX},${action.tileY}`
|
||||
w.tileRecovery[key] = fireAt
|
||||
StateManager.insertSorted(this.recoveryQueue, { key, fireAt })
|
||||
break
|
||||
}
|
||||
|
||||
case 'FORESTER_ZONE_UPDATE': {
|
||||
const zone = w.foresterZones[action.buildingId]
|
||||
@@ -188,60 +366,7 @@ class StateManager {
|
||||
}
|
||||
}
|
||||
|
||||
tickCrops(delta: number): string[] {
|
||||
const advanced: string[] = []
|
||||
for (const crop of Object.values(this.state.world.crops)) {
|
||||
if (crop.stage >= crop.maxStage) continue
|
||||
crop.stageTimerMs -= delta * (crop.watered ? 2 : 1)
|
||||
if (crop.stageTimerMs <= 0) {
|
||||
crop.stage = Math.min(crop.stage + 1, crop.maxStage)
|
||||
crop.stageTimerMs = CROP_CONFIGS[crop.kind].stageTimeMs
|
||||
advanced.push(crop.id)
|
||||
}
|
||||
}
|
||||
return advanced
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances all tree-seedling growth timers.
|
||||
* Returns IDs of seedlings that have reached stage 2 (ready to mature into a tree).
|
||||
* @param delta - Frame delta in milliseconds
|
||||
* @returns Array of seedling IDs that are now mature
|
||||
*/
|
||||
tickSeedlings(delta: number): string[] {
|
||||
const advanced: string[] = []
|
||||
for (const s of Object.values(this.state.world.treeSeedlings)) {
|
||||
s.stageTimerMs -= delta
|
||||
if (s.stageTimerMs <= 0) {
|
||||
s.stage = Math.min(s.stage + 1, 2)
|
||||
s.stageTimerMs = TREE_SEEDLING_STAGE_MS
|
||||
advanced.push(s.id)
|
||||
}
|
||||
}
|
||||
return advanced
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticks tile-recovery timers.
|
||||
* Returns keys ("tileX,tileY") of tiles that have now recovered back to GRASS.
|
||||
* @param delta - Frame delta in milliseconds
|
||||
* @returns Array of recovered tile keys
|
||||
*/
|
||||
tickTileRecovery(delta: number): string[] {
|
||||
const recovered: string[] = []
|
||||
const rec = this.state.world.tileRecovery
|
||||
for (const key of Object.keys(rec)) {
|
||||
rec[key] -= delta
|
||||
if (rec[key] <= 0) {
|
||||
delete rec[key]
|
||||
recovered.push(key)
|
||||
// Update tiles array directly (DARK_GRASS → GRASS)
|
||||
const [tx, ty] = key.split(',').map(Number)
|
||||
this.state.world.tiles[ty * WORLD_TILES + tx] = TileType.GRASS
|
||||
}
|
||||
}
|
||||
return recovered
|
||||
}
|
||||
// ─── Persistence ───────────────────────────────────────────────────────────
|
||||
|
||||
save(): void {
|
||||
try { localStorage.setItem(SAVE_KEY, JSON.stringify(this.state)) } catch (_) {}
|
||||
@@ -252,20 +377,40 @@ class StateManager {
|
||||
const raw = localStorage.getItem(SAVE_KEY)
|
||||
if (!raw) return null
|
||||
const p = JSON.parse(raw) as GameStateData
|
||||
if (p.version !== 5) return null
|
||||
|
||||
// ── Migrate v5 → v6: countdown timers → absolute gameTime timestamps ──
|
||||
if ((p.version as number) === 5) {
|
||||
p.world.gameTime = 0
|
||||
for (const crop of Object.values(p.world.crops)) {
|
||||
const old = crop as any
|
||||
const ms = old.stageTimerMs ?? CROP_CONFIGS[crop.kind]?.stageTimeMs ?? 20_000
|
||||
crop.growsAt = ms
|
||||
crop.growsAtWatered = ms / 2
|
||||
delete old.stageTimerMs
|
||||
}
|
||||
for (const s of Object.values(p.world.treeSeedlings)) {
|
||||
const old = s as any
|
||||
s.growsAt = old.stageTimerMs ?? TREE_SEEDLING_STAGE_MS
|
||||
delete old.stageTimerMs
|
||||
}
|
||||
// tileRecovery values were remaining-ms countdowns; with gameTime=0 they equal fireAt directly
|
||||
p.version = 6
|
||||
}
|
||||
|
||||
if (p.version !== 6) return null
|
||||
|
||||
if (!p.world.crops) p.world.crops = {}
|
||||
if (!p.world.villagers) p.world.villagers = {}
|
||||
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
|
||||
if (!p.world.gameTime) p.world.gameTime = 0
|
||||
|
||||
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: [] }
|
||||
@@ -278,6 +423,7 @@ class StateManager {
|
||||
reset(): void {
|
||||
localStorage.removeItem(SAVE_KEY)
|
||||
this.state = makeDefaultState()
|
||||
this.rebuildQueues()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ export class GameScene extends Phaser.Scene {
|
||||
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.debugSystem = new DebugSystem(this, this.villagerSystem, this.worldSystem, this.adapter)
|
||||
|
||||
this.worldSystem.create()
|
||||
this.renderPersistentObjects()
|
||||
@@ -145,6 +145,9 @@ export class GameScene extends Phaser.Scene {
|
||||
update(_time: number, delta: number): void {
|
||||
if (this.menuOpen) return
|
||||
|
||||
// Advance the in-game clock first so all tick methods see the updated time
|
||||
stateManager.advanceTime(delta)
|
||||
|
||||
this.cameraSystem.update(delta)
|
||||
|
||||
this.resourceSystem.update(delta)
|
||||
@@ -153,8 +156,8 @@ export class GameScene extends Phaser.Scene {
|
||||
this.villagerSystem.update(delta)
|
||||
this.debugSystem.update()
|
||||
|
||||
// Tick tile-recovery timers; refresh canvas for any tiles that reverted to GRASS
|
||||
const recovered = stateManager.tickTileRecovery(delta)
|
||||
// Drain tile-recovery queue; refresh canvas for any tiles that reverted to GRASS
|
||||
const recovered = stateManager.tickTileRecovery()
|
||||
for (const key of recovered) {
|
||||
const [tx, ty] = key.split(',').map(Number)
|
||||
this.worldSystem.refreshTerrainTile(tx, ty, TileType.GRASS)
|
||||
@@ -179,18 +182,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
|
||||
|
||||
@@ -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,30 +400,12 @@ 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). */
|
||||
private createDebugPanel(): void {
|
||||
const hexAlpha = Math.round(this.uiOpacity * 255).toString(16).padStart(2, '0')
|
||||
this.debugPanelText = this.add.text(10, 80, '', {
|
||||
this.debugPanelText = this.add.text(10, 10, '', {
|
||||
fontSize: '12px',
|
||||
color: '#cccccc',
|
||||
backgroundColor: `#000000${hexAlpha}`,
|
||||
@@ -423,9 +419,20 @@ export class UIScene extends Phaser.Scene {
|
||||
private toggleDebugPanel(): void {
|
||||
this.debugActive = !this.debugActive
|
||||
this.debugPanelText.setVisible(this.debugActive)
|
||||
this.repositionDebugPanel()
|
||||
this.scene.get('Game').events.emit('debugToggle')
|
||||
}
|
||||
|
||||
/**
|
||||
* Repositions the debug panel to avoid overlapping the Nisse info panel.
|
||||
* When the Nisse info panel is open, the debug panel shifts below it.
|
||||
*/
|
||||
private repositionDebugPanel(): void {
|
||||
const NISSE_PANEL_H = 120 + 10 * 14 + 16 // matches buildNisseInfoPanel: 276px
|
||||
const debugY = this.nisseInfoVisible ? 10 + NISSE_PANEL_H + 10 : 10
|
||||
this.debugPanelText.setY(debugY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads current debug data from DebugSystem and updates the panel text.
|
||||
* Called every frame while debug mode is active.
|
||||
@@ -467,6 +474,9 @@ export class UIScene extends Phaser.Scene {
|
||||
'',
|
||||
`Paths: ${data.activePaths} (cyan lines in world)`,
|
||||
'',
|
||||
'── Last Actions ───────────────',
|
||||
...(data.actionLog.length > 0 ? data.actionLog : ['—']),
|
||||
'',
|
||||
'[F3] close',
|
||||
])
|
||||
}
|
||||
@@ -547,6 +557,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 +600,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 +629,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. */
|
||||
@@ -774,11 +794,13 @@ export class UIScene extends Phaser.Scene {
|
||||
|
||||
/**
|
||||
* Applies the current uiOpacity to all static UI elements that are not
|
||||
* rebuilt on open (stockpile panel, debug panel background).
|
||||
* rebuilt on open (stockpile panel, action bar, debug panel background).
|
||||
* Called whenever uiOpacity changes.
|
||||
*/
|
||||
private updateStaticPanelOpacity(): void {
|
||||
this.stockpilePanel.setAlpha(this.uiOpacity)
|
||||
this.stockpilePanel.setFillStyle(0x000000, this.uiOpacity)
|
||||
this.actionBarBg.setFillStyle(0x080808, this.uiOpacity)
|
||||
this.updateCategoryHighlights()
|
||||
const hexAlpha = Math.round(this.uiOpacity * 255).toString(16).padStart(2, '0')
|
||||
this.debugPanelText.setStyle({ backgroundColor: `#000000${hexAlpha}` })
|
||||
}
|
||||
@@ -868,6 +890,7 @@ export class UIScene extends Phaser.Scene {
|
||||
this.nisseInfoId = villagerId
|
||||
this.nisseInfoVisible = true
|
||||
this.buildNisseInfoPanel()
|
||||
this.repositionDebugPanel()
|
||||
}
|
||||
|
||||
/** Closes and destroys the Nisse info panel. */
|
||||
@@ -877,6 +900,7 @@ export class UIScene extends Phaser.Scene {
|
||||
this.nisseInfoId = null
|
||||
this.nisseInfoGroup.destroy(true)
|
||||
this.nisseInfoGroup = this.add.group()
|
||||
this.repositionDebugPanel()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1150,6 +1174,154 @@ 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, this.uiOpacity)
|
||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(300)
|
||||
|
||||
this.actionBuildBtn = this.add.rectangle(8, barY + 8, 88, 32, 0x1a3a1a, this.uiOpacity)
|
||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive()
|
||||
this.actionBuildBtn.on('pointerover', () => {
|
||||
if (this.activeCategory !== 'build') this.actionBuildBtn.setFillStyle(0x2a5a2a, this.uiOpacity)
|
||||
})
|
||||
this.actionBuildBtn.on('pointerout', () => {
|
||||
if (this.activeCategory !== 'build') this.actionBuildBtn.setFillStyle(0x1a3a1a, this.uiOpacity)
|
||||
})
|
||||
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, this.uiOpacity)
|
||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive()
|
||||
this.actionNisseBtn.on('pointerover', () => {
|
||||
if (this.activeCategory !== 'nisse') this.actionNisseBtn.setFillStyle(0x2a2a5a, this.uiOpacity)
|
||||
})
|
||||
this.actionNisseBtn.on('pointerout', () => {
|
||||
if (this.activeCategory !== 'nisse') this.actionNisseBtn.setFillStyle(0x1a1a3a, this.uiOpacity)
|
||||
})
|
||||
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, this.uiOpacity)
|
||||
this.actionNisseBtn.setFillStyle(this.activeCategory === 'nisse' ? 0x3d3d7a : 0x1a1a3a, this.uiOpacity)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
this.actionBarBg.setAlpha(0)
|
||||
|
||||
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 + UIScene.BAR_H, 0x080808, this.uiOpacity)
|
||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(299)
|
||||
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, this.uiOpacity)
|
||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive()
|
||||
btn.on('pointerover', () => btn.setFillStyle(0x2d4a2d, this.uiOpacity))
|
||||
btn.on('pointerout', () => btn.setFillStyle(0x1a2a1a, this.uiOpacity))
|
||||
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()
|
||||
this.actionBarBg.setAlpha(1)
|
||||
}
|
||||
|
||||
// ─── Resize ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -1170,11 +1342,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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import Phaser from 'phaser'
|
||||
import { TILE_SIZE } from '../config'
|
||||
import { TileType } from '../types'
|
||||
import { stateManager } from '../StateManager'
|
||||
import type { LocalAdapter } from '../NetworkAdapter'
|
||||
import type { VillagerSystem } from './VillagerSystem'
|
||||
import type { WorldSystem } from './WorldSystem'
|
||||
|
||||
@@ -18,6 +19,8 @@ export interface DebugData {
|
||||
nisseByState: { idle: number; walking: number; working: number; sleeping: number }
|
||||
jobsByType: { chop: number; mine: number; farm: number }
|
||||
activePaths: number
|
||||
/** Recent actions dispatched through the adapter (newest last). */
|
||||
actionLog: readonly string[]
|
||||
}
|
||||
|
||||
/** Human-readable names for TileType enum values. */
|
||||
@@ -39,6 +42,7 @@ export class DebugSystem {
|
||||
private scene: Phaser.Scene
|
||||
private villagerSystem: VillagerSystem
|
||||
private worldSystem: WorldSystem
|
||||
private adapter: LocalAdapter
|
||||
private pathGraphics!: Phaser.GameObjects.Graphics
|
||||
private active = false
|
||||
|
||||
@@ -46,11 +50,13 @@ export class DebugSystem {
|
||||
* @param scene - The Phaser scene this system belongs to
|
||||
* @param villagerSystem - Used to read active paths for visualization
|
||||
* @param worldSystem - Used to read tile types under the mouse
|
||||
* @param adapter - Used to read the recent action log
|
||||
*/
|
||||
constructor(scene: Phaser.Scene, villagerSystem: VillagerSystem, worldSystem: WorldSystem) {
|
||||
constructor(scene: Phaser.Scene, villagerSystem: VillagerSystem, worldSystem: WorldSystem, adapter: LocalAdapter) {
|
||||
this.scene = scene
|
||||
this.villagerSystem = villagerSystem
|
||||
this.worldSystem = worldSystem
|
||||
this.adapter = adapter
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,6 +165,7 @@ export class DebugSystem {
|
||||
nisseByState,
|
||||
jobsByType,
|
||||
activePaths: this.villagerSystem.getActivePaths().length,
|
||||
actionLog: this.adapter.getActionLog(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,8 +74,8 @@ export class FarmingSystem {
|
||||
this.setTool(TOOL_CYCLE[(idx + 1) % TOOL_CYCLE.length])
|
||||
}
|
||||
|
||||
// Tick crop growth
|
||||
const leveled = stateManager.tickCrops(delta)
|
||||
// Drain crop growth queue (no delta — gameTime is advanced by GameScene)
|
||||
const leveled = stateManager.tickCrops()
|
||||
for (const id of leveled) this.refreshCropSprite(id)
|
||||
}
|
||||
|
||||
@@ -151,11 +151,13 @@ export class FarmingSystem {
|
||||
}
|
||||
|
||||
const cfg = CROP_CONFIGS[kind]
|
||||
const now = stateManager.getGameTime()
|
||||
const crop: CropState = {
|
||||
id: `crop_${tileX}_${tileY}_${Date.now()}`,
|
||||
tileX, tileY, kind,
|
||||
stage: 0, maxStage: cfg.stages,
|
||||
stageTimerMs: cfg.stageTimeMs,
|
||||
growsAt: now + cfg.stageTimeMs,
|
||||
growsAtWatered: now + cfg.stageTimeMs / 2,
|
||||
watered: tile === TileType.WATERED_SOIL,
|
||||
}
|
||||
this.adapter.send({ type: 'PLANT_CROP', crop, seedItem })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -38,7 +38,8 @@ export class TreeSeedlingSystem {
|
||||
* @param delta - Frame delta in milliseconds
|
||||
*/
|
||||
update(delta: number): void {
|
||||
const advanced = stateManager.tickSeedlings(delta)
|
||||
// Drain seedling growth queue (no delta — gameTime is advanced by GameScene)
|
||||
const advanced = stateManager.tickSeedlings()
|
||||
for (const id of advanced) {
|
||||
const state = stateManager.getState()
|
||||
const seedling = state.world.treeSeedlings[id]
|
||||
@@ -91,7 +92,7 @@ export class TreeSeedlingSystem {
|
||||
const seedling: TreeSeedlingState = {
|
||||
id, tileX, tileY,
|
||||
stage: 0,
|
||||
stageTimerMs: TREE_SEEDLING_STAGE_MS,
|
||||
growsAt: stateManager.getGameTime() + TREE_SEEDLING_STAGE_MS,
|
||||
underlyingTile,
|
||||
}
|
||||
|
||||
@@ -110,7 +111,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
16
src/types.ts
16
src/types.ts
@@ -90,7 +90,11 @@ export interface CropState {
|
||||
kind: CropKind
|
||||
stage: number
|
||||
maxStage: number
|
||||
stageTimerMs: number
|
||||
/** gameTime (ms) when this stage fires at normal (unwatered) speed. */
|
||||
growsAt: number
|
||||
/** gameTime (ms) when this stage fires if the crop is watered (half normal time).
|
||||
* Both entries are enqueued at plant/stage-advance time; the stale one is skipped. */
|
||||
growsAtWatered: number
|
||||
watered: boolean
|
||||
}
|
||||
|
||||
@@ -107,8 +111,8 @@ export interface TreeSeedlingState {
|
||||
tileY: number
|
||||
/** Growth stage: 0 = sprout, 1 = sapling, 2 = mature (converts to resource). */
|
||||
stage: number
|
||||
/** Time remaining until next stage advance, in milliseconds. */
|
||||
stageTimerMs: number
|
||||
/** gameTime (ms) when this seedling advances to the next stage. */
|
||||
growsAt: number
|
||||
/** The tile type that was under the seedling when planted (GRASS or DARK_GRASS). */
|
||||
underlyingTile: TileType
|
||||
}
|
||||
@@ -125,6 +129,8 @@ export interface ForesterZoneState {
|
||||
|
||||
export interface WorldState {
|
||||
seed: number
|
||||
/** Accumulated in-game time in milliseconds. Used as the clock for all event-queue timers. */
|
||||
gameTime: number
|
||||
tiles: number[]
|
||||
resources: Record<string, ResourceNodeState>
|
||||
buildings: Record<string, BuildingState>
|
||||
@@ -134,8 +140,8 @@ export interface WorldState {
|
||||
/** Planted tree seedlings, keyed by ID. */
|
||||
treeSeedlings: Record<string, TreeSeedlingState>
|
||||
/**
|
||||
* Recovery timers for DARK_GRASS tiles, keyed by "tileX,tileY".
|
||||
* Value is remaining milliseconds until the tile reverts to GRASS.
|
||||
* Tile recovery fire-times, keyed by "tileX,tileY".
|
||||
* Value is the gameTime (ms) at which the tile reverts to GRASS.
|
||||
*/
|
||||
tileRecovery: Record<string, number>
|
||||
/** Forester zone definitions, keyed by forester_hut building ID. */
|
||||
|
||||
Reference in New Issue
Block a user