Compare commits
27 Commits
feature/ac
...
feature/de
| Author | SHA1 | Date | |
|---|---|---|---|
| fc10201469 | |||
| 6eeb47c720 | |||
| fcf805d4d2 | |||
| ccc224e2b9 | |||
| 202ff435f7 | |||
| a6c2aa5309 | |||
| 3bf143993e | |||
| 0a706b8def | |||
| ae6c14d9a1 | |||
| 3e099d92e2 | |||
| f78645bb79 | |||
| 84aa1a7ce5 | |||
| 24ee3257df | |||
| 78c184c560 | |||
| 7ff3d82e11 | |||
| 20858a1be1 | |||
| 3b021127a4 | |||
| 26c3807481 | |||
| d02ed33435 | |||
| c7cf971e54 | |||
| 08dffa135f | |||
| 4f2e9f73b6 | |||
| 84b6e51746 | |||
| 5f646d54ca | |||
| 94b2f7f457 | |||
| cd171c859c | |||
| d9ef57c6b0 |
19
CHANGELOG.md
19
CHANGELOG.md
@@ -7,6 +7,25 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Demolish Mode** (Issue #50): New 💥 Demolish button in the action bar; hover shows a red ghost over any building with a refund percentage; buildings demolished within 3 minutes return 100% of costs (linear decay to 0%); mine footprint tiles are unblocked on teardown; Nisse working inside a demolished building are rescued and resume idle; tile types are restored where applicable (floor/wall/chest → grass)
|
||||||
|
- **Mine Building** (Issue #42): 3×2 building placeable only on resource-free ROCK tiles (costs: 200 wood + 50 stone); Nisse with mine priority walk to the entrance, disappear inside for 15 s, then reappear carrying 2 stone; up to 3 Nisse work simultaneously; ⛏ X/3 status label shown directly on the building in world space; surface rock harvesting remains functional alongside the building
|
||||||
|
|
||||||
|
### 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
|
### Added
|
||||||
- **Försterkreislauf** (Issue #25):
|
- **Försterkreislauf** (Issue #25):
|
||||||
- **Setzlinge beim Fällen**: Jeder gefällte Baum gibt 1–2 `tree_seed` in den Stockpile
|
- **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
|
onAction?: (action: GameAction) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ACTION_LOG_SIZE = 15
|
||||||
|
|
||||||
/** Singleplayer: apply actions immediately and synchronously */
|
/** Singleplayer: apply actions immediately and synchronously */
|
||||||
export class LocalAdapter implements NetworkAdapter {
|
export class LocalAdapter implements NetworkAdapter {
|
||||||
onAction?: (action: GameAction) => void
|
onAction?: (action: GameAction) => void
|
||||||
|
|
||||||
|
/** Ring-buffer of the last ACTION_LOG_SIZE dispatched action summaries. */
|
||||||
|
private _actionLog: string[] = []
|
||||||
|
|
||||||
send(action: GameAction): void {
|
send(action: GameAction): void {
|
||||||
stateManager.apply(action)
|
stateManager.apply(action)
|
||||||
|
this._recordAction(action)
|
||||||
this.onAction?.(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 type { GameStateData, GameAction, PlayerState, WorldState, ItemId } from './types'
|
||||||
import { TileType } 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 = {
|
const DEFAULT_PLAYER: PlayerState = {
|
||||||
id: 'player1',
|
id: 'player1',
|
||||||
x: 8192, y: 8192,
|
x: 8192, y: 8192,
|
||||||
inventory: {}, // empty — seeds now in stockpile
|
inventory: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeEmptyWorld(seed: number): WorldState {
|
function makeEmptyWorld(seed: number): WorldState {
|
||||||
return {
|
return {
|
||||||
seed,
|
seed,
|
||||||
|
gameTime: 0,
|
||||||
tiles: new Array(WORLD_TILES * WORLD_TILES).fill(3),
|
tiles: new Array(WORLD_TILES * WORLD_TILES).fill(3),
|
||||||
resources: {},
|
resources: {},
|
||||||
buildings: {},
|
buildings: {},
|
||||||
@@ -25,21 +52,164 @@ function makeEmptyWorld(seed: number): WorldState {
|
|||||||
|
|
||||||
function makeDefaultState(): GameStateData {
|
function makeDefaultState(): GameStateData {
|
||||||
return {
|
return {
|
||||||
version: 5,
|
version: 6,
|
||||||
world: makeEmptyWorld(Math.floor(Math.random() * 999999)),
|
world: makeEmptyWorld(Math.floor(Math.random() * 999999)),
|
||||||
player: { ...DEFAULT_PLAYER, inventory: { ...DEFAULT_PLAYER.inventory } },
|
player: { ...DEFAULT_PLAYER, inventory: { ...DEFAULT_PLAYER.inventory } },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── StateManager ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class StateManager {
|
class StateManager {
|
||||||
private state: GameStateData
|
private state: GameStateData
|
||||||
|
|
||||||
|
// In-memory event queues (not persisted; rebuilt from state on load).
|
||||||
|
private cropQueue: CropEntry[] = []
|
||||||
|
private seedlingQueue: SeedlingEntry[] = []
|
||||||
|
private recoveryQueue: RecoveryEntry[] = []
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.state = this.load() ?? makeDefaultState()
|
this.state = this.load() ?? makeDefaultState()
|
||||||
|
this.rebuildQueues()
|
||||||
}
|
}
|
||||||
|
|
||||||
getState(): Readonly<GameStateData> { return this.state }
|
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 {
|
apply(action: GameAction): void {
|
||||||
const s = this.state
|
const s = this.state
|
||||||
const w = s.world
|
const w = s.world
|
||||||
@@ -63,10 +233,9 @@ class StateManager {
|
|||||||
w.tiles[action.tileY * WORLD_TILES + action.tileX] = action.tile; break
|
w.tiles[action.tileY * WORLD_TILES + action.tileX] = action.tile; break
|
||||||
|
|
||||||
case 'PLACE_BUILDING': {
|
case 'PLACE_BUILDING': {
|
||||||
w.buildings[action.building.id] = action.building
|
w.buildings[action.building.id] = { ...action.building, builtAt: w.gameTime }
|
||||||
for (const [k, v] of Object.entries(action.costs))
|
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))
|
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') {
|
if (action.building.kind === 'forester_hut') {
|
||||||
w.foresterZones[action.building.id] = { buildingId: action.building.id, tiles: [] }
|
w.foresterZones[action.building.id] = { buildingId: action.building.id, tiles: [] }
|
||||||
}
|
}
|
||||||
@@ -74,7 +243,6 @@ class StateManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'REMOVE_BUILDING':
|
case 'REMOVE_BUILDING':
|
||||||
// Remove associated forester zone when the hut is demolished
|
|
||||||
if (w.buildings[action.buildingId]?.kind === 'forester_hut') {
|
if (w.buildings[action.buildingId]?.kind === 'forester_hut') {
|
||||||
delete w.foresterZones[action.buildingId]
|
delete w.foresterZones[action.buildingId]
|
||||||
}
|
}
|
||||||
@@ -90,22 +258,24 @@ class StateManager {
|
|||||||
w.crops[action.crop.id] = { ...action.crop }
|
w.crops[action.crop.id] = { ...action.crop }
|
||||||
const have = w.stockpile[action.seedItem] ?? 0
|
const have = w.stockpile[action.seedItem] ?? 0
|
||||||
w.stockpile[action.seedItem] = Math.max(0, have - 1)
|
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
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'WATER_CROP': {
|
case 'WATER_CROP': {
|
||||||
const c = w.crops[action.cropId]; if (c) c.watered = true; break
|
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': {
|
case 'HARVEST_CROP': {
|
||||||
delete w.crops[action.cropId]
|
delete w.crops[action.cropId]
|
||||||
for (const [k, v] of Object.entries(action.rewards))
|
for (const [k, v] of Object.entries(action.rewards))
|
||||||
w.stockpile[k as ItemId] = (w.stockpile[k as ItemId] ?? 0) + (v ?? 0)
|
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
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Villager actions ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
case 'SPAWN_VILLAGER':
|
case 'SPAWN_VILLAGER':
|
||||||
w.villagers[action.villager.id] = { ...action.villager }; break
|
w.villagers[action.villager.id] = { ...action.villager }; break
|
||||||
|
|
||||||
@@ -163,22 +333,30 @@ class StateManager {
|
|||||||
case 'PLANT_TREE_SEED': {
|
case 'PLANT_TREE_SEED': {
|
||||||
w.treeSeedlings[action.seedling.id] = { ...action.seedling }
|
w.treeSeedlings[action.seedling.id] = { ...action.seedling }
|
||||||
w.stockpile.tree_seed = Math.max(0, (w.stockpile.tree_seed ?? 0) - 1)
|
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}`]
|
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
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'REMOVE_TREE_SEEDLING':
|
case 'REMOVE_TREE_SEEDLING':
|
||||||
delete w.treeSeedlings[action.seedlingId]
|
delete w.treeSeedlings[action.seedlingId]
|
||||||
|
// Stale queue entries will be skipped automatically
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'SPAWN_RESOURCE':
|
case 'SPAWN_RESOURCE':
|
||||||
w.resources[action.resource.id] = { ...action.resource }
|
w.resources[action.resource.id] = { ...action.resource }
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'TILE_RECOVERY_START':
|
case 'TILE_RECOVERY_START': {
|
||||||
w.tileRecovery[`${action.tileX},${action.tileY}`] = TILE_RECOVERY_MS
|
const fireAt = w.gameTime + TILE_RECOVERY_MS
|
||||||
|
const key = `${action.tileX},${action.tileY}`
|
||||||
|
w.tileRecovery[key] = fireAt
|
||||||
|
StateManager.insertSorted(this.recoveryQueue, { key, fireAt })
|
||||||
break
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case 'FORESTER_ZONE_UPDATE': {
|
case 'FORESTER_ZONE_UPDATE': {
|
||||||
const zone = w.foresterZones[action.buildingId]
|
const zone = w.foresterZones[action.buildingId]
|
||||||
@@ -188,60 +366,7 @@ class StateManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tickCrops(delta: number): string[] {
|
// ─── Persistence ───────────────────────────────────────────────────────────
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
save(): void {
|
save(): void {
|
||||||
try { localStorage.setItem(SAVE_KEY, JSON.stringify(this.state)) } catch (_) {}
|
try { localStorage.setItem(SAVE_KEY, JSON.stringify(this.state)) } catch (_) {}
|
||||||
@@ -252,24 +377,46 @@ class StateManager {
|
|||||||
const raw = localStorage.getItem(SAVE_KEY)
|
const raw = localStorage.getItem(SAVE_KEY)
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
const p = JSON.parse(raw) as GameStateData
|
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.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 = {}
|
||||||
if (!p.world.treeSeedlings) p.world.treeSeedlings = {}
|
if (!p.world.treeSeedlings) p.world.treeSeedlings = {}
|
||||||
if (!p.world.tileRecovery) p.world.tileRecovery = {}
|
if (!p.world.tileRecovery) p.world.tileRecovery = {}
|
||||||
if (!p.world.foresterZones) p.world.foresterZones = {}
|
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)) {
|
for (const v of Object.values(p.world.villagers)) {
|
||||||
if (v.aiState === 'walking' || v.aiState === 'working') v.aiState = 'idle'
|
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
|
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)) {
|
for (const b of Object.values(p.world.buildings)) {
|
||||||
if (b.kind === 'forester_hut' && !p.world.foresterZones[b.id]) {
|
if (b.kind === 'forester_hut' && !p.world.foresterZones[b.id]) {
|
||||||
p.world.foresterZones[b.id] = { buildingId: b.id, tiles: [] }
|
p.world.foresterZones[b.id] = { buildingId: b.id, tiles: [] }
|
||||||
}
|
}
|
||||||
|
// Migrate buildings without builtAt (pre-demolish saves): set to 0 = no refund
|
||||||
|
if (typeof (b as any).builtAt === 'undefined') (b as any).builtAt = 0
|
||||||
}
|
}
|
||||||
return p
|
return p
|
||||||
} catch (_) { return null }
|
} catch (_) { return null }
|
||||||
@@ -278,6 +425,7 @@ class StateManager {
|
|||||||
reset(): void {
|
reset(): void {
|
||||||
localStorage.removeItem(SAVE_KEY)
|
localStorage.removeItem(SAVE_KEY)
|
||||||
this.state = makeDefaultState()
|
this.state = makeDefaultState()
|
||||||
|
this.rebuildQueues()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,18 @@ export const BUILDING_COSTS: Record<BuildingType, Record<string, number>> = {
|
|||||||
bed: { wood: 6 },
|
bed: { wood: 6 },
|
||||||
stockpile_zone:{ wood: 0 },
|
stockpile_zone:{ wood: 0 },
|
||||||
forester_hut: { wood: 50 },
|
forester_hut: { wood: 50 },
|
||||||
|
mine: { stone: 50, wood: 200 },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Maximum number of Nisse that can work inside a mine simultaneously. */
|
||||||
|
export const MINE_CAPACITY = 3
|
||||||
|
|
||||||
|
/** Milliseconds a Nisse spends inside a mine per visit. */
|
||||||
|
export const MINE_WORK_MS = 15_000
|
||||||
|
|
||||||
|
/** Stone yielded per mine visit. */
|
||||||
|
export const MINE_STONE_YIELD = 2
|
||||||
|
|
||||||
/** Max Chebyshev radius (in tiles) that a forester hut's zone can extend. */
|
/** Max Chebyshev radius (in tiles) that a forester hut's zone can extend. */
|
||||||
export const FORESTER_ZONE_RADIUS = 5
|
export const FORESTER_ZONE_RADIUS = 5
|
||||||
|
|
||||||
@@ -51,6 +61,9 @@ export const VILLAGER_NAMES = [
|
|||||||
'Orla','Pike','Quinn','Rook','Sera','Tull','Uma','Vex',
|
'Orla','Pike','Quinn','Rook','Sera','Tull','Uma','Vex',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** Milliseconds after placement during which demolishing gives a full refund (linearly decays to 0%). */
|
||||||
|
export const DEMOLISH_REFUND_MS = 180_000 // 3 minutes
|
||||||
|
|
||||||
export const SAVE_KEY = 'tg_save_v5'
|
export const SAVE_KEY = 'tg_save_v5'
|
||||||
export const AUTOSAVE_INTERVAL = 30_000
|
export const AUTOSAVE_INTERVAL = 30_000
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Phaser from 'phaser'
|
import Phaser from 'phaser'
|
||||||
import { AUTOSAVE_INTERVAL, TILE_SIZE } from '../config'
|
import { AUTOSAVE_INTERVAL, TILE_SIZE, MINE_CAPACITY } from '../config'
|
||||||
import { TileType } from '../types'
|
import { TileType } from '../types'
|
||||||
import type { BuildingType } from '../types'
|
import type { BuildingType } from '../types'
|
||||||
import { stateManager } from '../StateManager'
|
import { stateManager } from '../StateManager'
|
||||||
@@ -27,6 +27,8 @@ export class GameScene extends Phaser.Scene {
|
|||||||
foresterZoneSystem!: ForesterZoneSystem
|
foresterZoneSystem!: ForesterZoneSystem
|
||||||
private autosaveTimer = 0
|
private autosaveTimer = 0
|
||||||
private menuOpen = false
|
private menuOpen = false
|
||||||
|
/** World-space status labels for mine buildings, keyed by building ID. */
|
||||||
|
private mineStatusTexts = new Map<string, Phaser.GameObjects.Text>()
|
||||||
|
|
||||||
constructor() { super({ key: 'Game' }) }
|
constructor() { super({ key: 'Game' }) }
|
||||||
|
|
||||||
@@ -46,7 +48,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.villagerSystem.init(this.resourceSystem, this.farmingSystem)
|
this.villagerSystem.init(this.resourceSystem, this.farmingSystem)
|
||||||
this.treeSeedlingSystem = new TreeSeedlingSystem(this, this.adapter, this.worldSystem)
|
this.treeSeedlingSystem = new TreeSeedlingSystem(this, this.adapter, this.worldSystem)
|
||||||
this.foresterZoneSystem = new ForesterZoneSystem(this, this.adapter)
|
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.worldSystem.create()
|
||||||
this.renderPersistentObjects()
|
this.renderPersistentObjects()
|
||||||
@@ -62,6 +64,31 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.events.emit('toast', msg)
|
this.events.emit('toast', msg)
|
||||||
this.renderPersistentObjects()
|
this.renderPersistentObjects()
|
||||||
}
|
}
|
||||||
|
this.buildingSystem.onDemolishModeChange = (active) => this.events.emit('demolishModeChanged', active)
|
||||||
|
this.buildingSystem.onDemolished = (building, refund) => {
|
||||||
|
// Remove the building sprite
|
||||||
|
this.children.getByName(`bobj_${building.id}`)?.destroy()
|
||||||
|
|
||||||
|
// Mine-specific cleanup: unblock the 5 passability tiles and remove status label
|
||||||
|
if (building.kind === 'mine') {
|
||||||
|
for (let dy = 0; dy < 2; dy++) {
|
||||||
|
for (let dx = 0; dx < 3; dx++) {
|
||||||
|
if (dx === 1 && dy === 1) continue // entrance tile was never blocked
|
||||||
|
this.worldSystem.removeResourceTile(building.tileX + dx, building.tileY + dy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.mineStatusTexts.get(building.id)?.destroy()
|
||||||
|
this.mineStatusTexts.delete(building.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rescue any Nisse working in or walking to this building
|
||||||
|
this.villagerSystem.rescueNisseFromBuilding(building.id)
|
||||||
|
|
||||||
|
const refundMsg = Object.keys(refund).length
|
||||||
|
? ` (+${Object.entries(refund).map(([k, v]) => `${v} ${k}`).join(', ')})`
|
||||||
|
: ' (no refund)'
|
||||||
|
this.events.emit('toast', `Demolished ${building.kind}${refundMsg}`)
|
||||||
|
}
|
||||||
|
|
||||||
this.farmingSystem.create()
|
this.farmingSystem.create()
|
||||||
this.farmingSystem.onMessage = (msg) => this.events.emit('toast', msg)
|
this.farmingSystem.onMessage = (msg) => this.events.emit('toast', msg)
|
||||||
@@ -100,7 +127,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
// Detect left-clicks on forester huts to open the zone panel
|
// Detect left-clicks on forester huts to open the zone panel
|
||||||
this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
||||||
if (ptr.rightButtonDown() || this.menuOpen) return
|
if (ptr.rightButtonDown() || this.menuOpen) return
|
||||||
if (this.buildingSystem.isActive()) return
|
if (this.buildingSystem.isActive() || this.buildingSystem.isDemolishActive()) return
|
||||||
const tileX = Math.floor(ptr.worldX / TILE_SIZE)
|
const tileX = Math.floor(ptr.worldX / TILE_SIZE)
|
||||||
const tileY = Math.floor(ptr.worldY / TILE_SIZE)
|
const tileY = Math.floor(ptr.worldY / TILE_SIZE)
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
@@ -114,7 +141,9 @@ export class GameScene extends Phaser.Scene {
|
|||||||
|
|
||||||
this.scene.launch('UI')
|
this.scene.launch('UI')
|
||||||
|
|
||||||
this.events.on('selectBuilding', (kind: BuildingType) => this.buildingSystem.selectBuilding(kind))
|
this.events.on('selectBuilding', (kind: BuildingType) => this.buildingSystem.selectBuilding(kind))
|
||||||
|
this.events.on('activateDemolish', () => this.buildingSystem.activateDemolish())
|
||||||
|
this.events.on('deactivateDemolish', () => this.buildingSystem.deactivateDemolish())
|
||||||
this.events.on('uiMenuOpen', () => { this.menuOpen = true })
|
this.events.on('uiMenuOpen', () => { this.menuOpen = true })
|
||||||
this.events.on('uiMenuClose', () => { this.menuOpen = false })
|
this.events.on('uiMenuClose', () => { this.menuOpen = false })
|
||||||
this.events.on('uiRequestBuildMenu', () => {
|
this.events.on('uiRequestBuildMenu', () => {
|
||||||
@@ -145,6 +174,9 @@ export class GameScene extends Phaser.Scene {
|
|||||||
update(_time: number, delta: number): void {
|
update(_time: number, delta: number): void {
|
||||||
if (this.menuOpen) return
|
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.cameraSystem.update(delta)
|
||||||
|
|
||||||
this.resourceSystem.update(delta)
|
this.resourceSystem.update(delta)
|
||||||
@@ -153,8 +185,8 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.villagerSystem.update(delta)
|
this.villagerSystem.update(delta)
|
||||||
this.debugSystem.update()
|
this.debugSystem.update()
|
||||||
|
|
||||||
// Tick tile-recovery timers; refresh canvas for any tiles that reverted to GRASS
|
// Drain tile-recovery queue; refresh canvas for any tiles that reverted to GRASS
|
||||||
const recovered = stateManager.tickTileRecovery(delta)
|
const recovered = stateManager.tickTileRecovery()
|
||||||
for (const key of recovered) {
|
for (const key of recovered) {
|
||||||
const [tx, ty] = key.split(',').map(Number)
|
const [tx, ty] = key.split(',').map(Number)
|
||||||
this.worldSystem.refreshTerrainTile(tx, ty, TileType.GRASS)
|
this.worldSystem.refreshTerrainTile(tx, ty, TileType.GRASS)
|
||||||
@@ -162,6 +194,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
|
|
||||||
this.events.emit('cameraMoved', this.cameraSystem.getCenterTile())
|
this.events.emit('cameraMoved', this.cameraSystem.getCenterTile())
|
||||||
this.buildingSystem.update()
|
this.buildingSystem.update()
|
||||||
|
this.updateMineStatusLabels()
|
||||||
|
|
||||||
this.autosaveTimer -= delta
|
this.autosaveTimer -= delta
|
||||||
if (this.autosaveTimer <= 0) {
|
if (this.autosaveTimer <= 0) {
|
||||||
@@ -179,18 +212,19 @@ export class GameScene extends Phaser.Scene {
|
|||||||
const name = `bobj_${building.id}`
|
const name = `bobj_${building.id}`
|
||||||
if (this.children.getByName(name)) continue
|
if (this.children.getByName(name)) continue
|
||||||
|
|
||||||
|
const worldDepth = building.tileY + 5
|
||||||
if (building.kind === 'chest') {
|
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(0x8B4513); g.fillRect(wx - 10, wy - 7, 20, 14)
|
||||||
g.fillStyle(0xCD853F); g.fillRect(wx - 9, wy - 6, 18, 6)
|
g.fillStyle(0xCD853F); g.fillRect(wx - 9, wy - 6, 18, 6)
|
||||||
g.lineStyle(1, 0x5C3317); g.strokeRect(wx - 10, wy - 7, 20, 14)
|
g.lineStyle(1, 0x5C3317); g.strokeRect(wx - 10, wy - 7, 20, 14)
|
||||||
} else if (building.kind === 'bed') {
|
} 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') {
|
} else if (building.kind === 'stockpile_zone') {
|
||||||
this.add.image(wx, wy, 'stockpile_obj').setName(name).setDepth(4).setAlpha(0.8)
|
this.add.image(wx, wy, 'stockpile_obj').setName(name).setDepth(4).setAlpha(0.8)
|
||||||
} else if (building.kind === 'forester_hut') {
|
} else if (building.kind === 'forester_hut') {
|
||||||
// Draw a simple log-cabin silhouette for the 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
|
// Body
|
||||||
g.fillStyle(0x6B3F16); g.fillRect(wx - 12, wy - 9, 24, 18)
|
g.fillStyle(0x6B3F16); g.fillRect(wx - 12, wy - 9, 24, 18)
|
||||||
// Roof
|
// Roof
|
||||||
@@ -199,10 +233,105 @@ export class GameScene extends Phaser.Scene {
|
|||||||
g.fillStyle(0x2a1500); g.fillRect(wx - 4, wy + 1, 8, 8)
|
g.fillStyle(0x2a1500); g.fillRect(wx - 4, wy + 1, 8, 8)
|
||||||
// Tree symbol on the roof
|
// Tree symbol on the roof
|
||||||
g.fillStyle(0x228B22); g.fillTriangle(wx - 6, wy - 11, wx + 6, wy - 11, wx, wy - 20)
|
g.fillStyle(0x228B22); g.fillTriangle(wx - 6, wy - 11, wx + 6, wy - 11, wx, wy - 20)
|
||||||
|
} else if (building.kind === 'mine') {
|
||||||
|
this.renderMineBuilding(building)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws a mine building (3×2 tiles) at the given building position.
|
||||||
|
* Blocks the 5 non-entrance tiles in the WorldSystem passability index and
|
||||||
|
* creates a world-space status label showing current / max workers.
|
||||||
|
* @param building - The mine building state to render
|
||||||
|
*/
|
||||||
|
private renderMineBuilding(building: ReturnType<typeof stateManager.getState>['world']['buildings'][string]): void {
|
||||||
|
const name = `bobj_${building.id}`
|
||||||
|
const left = building.tileX * TILE_SIZE
|
||||||
|
const top = building.tileY * TILE_SIZE
|
||||||
|
const W = TILE_SIZE * 3 // 96 px
|
||||||
|
const H = TILE_SIZE * 2 // 64 px
|
||||||
|
|
||||||
|
const g = this.add.graphics().setName(name).setDepth(building.tileY + 6)
|
||||||
|
|
||||||
|
// Rocky stone face
|
||||||
|
g.fillStyle(0x424242); g.fillRect(left, top, W, H)
|
||||||
|
|
||||||
|
// Stone texture highlights — top row
|
||||||
|
g.fillStyle(0x5a5a5a, 0.7)
|
||||||
|
g.fillRect(left + 4, top + 3, 20, 10)
|
||||||
|
g.fillRect(left + 28, top + 5, 18, 9)
|
||||||
|
g.fillRect(left + 52, top + 3, 22, 11)
|
||||||
|
g.fillRect(left + 76, top + 5, 14, 10)
|
||||||
|
|
||||||
|
// Stone highlights — bottom row sides (left of entrance, right of entrance)
|
||||||
|
g.fillRect(left + 4, top + 36, 16, 10)
|
||||||
|
g.fillRect(left + 70, top + 37, 18, 10)
|
||||||
|
g.fillStyle(0x3a3a3a, 0.5)
|
||||||
|
g.fillRect(left + 6, top + 50, 18, 8)
|
||||||
|
g.fillRect(left + 68, top + 51, 16, 8)
|
||||||
|
|
||||||
|
// Wooden support posts flanking entrance
|
||||||
|
g.fillStyle(0x8B4513)
|
||||||
|
g.fillRect(left + 30, top + 22, 8, H - 22) // left post
|
||||||
|
g.fillRect(left + 58, top + 22, 8, H - 22) // right post
|
||||||
|
|
||||||
|
// Lintel beam across top of entrance
|
||||||
|
g.fillStyle(0x6B3311)
|
||||||
|
g.fillRect(left + 28, top + 20, 40, 10)
|
||||||
|
|
||||||
|
// Horizontal wood grain lines on posts
|
||||||
|
g.lineStyle(1, 0x5C2A00, 0.4)
|
||||||
|
for (let yy = top + 28; yy < top + H; yy += 7) {
|
||||||
|
g.lineBetween(left + 30, yy, left + 38, yy)
|
||||||
|
g.lineBetween(left + 58, yy, left + 66, yy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mine shaft (dark entrance opening)
|
||||||
|
g.fillStyle(0x0d0d0d); g.fillRect(left + 38, top + 30, 20, H - 30)
|
||||||
|
g.fillStyle(0x000000, 0.5); g.fillRect(left + 38, top + 30, 4, H - 30) // left shadow
|
||||||
|
|
||||||
|
// Rail track at entrance floor
|
||||||
|
g.fillStyle(0x888888); g.fillRect(left + 42, top + H - 5, 12, 2)
|
||||||
|
g.fillStyle(0x777777)
|
||||||
|
g.fillRect(left + 44, top + H - 8, 2, 6)
|
||||||
|
g.fillRect(left + 50, top + H - 8, 2, 6)
|
||||||
|
|
||||||
|
// Block the 5 non-entrance tiles in the passability index.
|
||||||
|
// Entrance = (tileX+1, tileY+1) — stays passable.
|
||||||
|
for (let dy = 0; dy < 2; dy++) {
|
||||||
|
for (let dx = 0; dx < 3; dx++) {
|
||||||
|
if (dx === 1 && dy === 1) continue // entrance tile: skip
|
||||||
|
this.worldSystem.addResourceTile(building.tileX + dx, building.tileY + dy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the ⛏ X/3 status label above the building (only once)
|
||||||
|
if (!this.mineStatusTexts.has(building.id)) {
|
||||||
|
const cx = left + W / 2
|
||||||
|
const st = this.add.text(cx, top - 4, `⛏ 0/${MINE_CAPACITY}`, {
|
||||||
|
fontSize: '10px', color: '#ffdd88', fontFamily: 'monospace',
|
||||||
|
backgroundColor: '#00000088', padding: { x: 2, y: 1 },
|
||||||
|
}).setOrigin(0.5, 1).setDepth(building.tileY + 7)
|
||||||
|
this.mineStatusTexts.set(building.id, st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the ⛏ X/3 status label for every mine building each frame.
|
||||||
|
* Counts Nisse currently working (inside) the mine from the game state.
|
||||||
|
*/
|
||||||
|
private updateMineStatusLabels(): void {
|
||||||
|
const state = stateManager.getState()
|
||||||
|
const villagers = Object.values(state.world.villagers)
|
||||||
|
for (const [buildingId, text] of this.mineStatusTexts) {
|
||||||
|
const count = villagers.filter(
|
||||||
|
v => v.job?.targetId === buildingId && v.aiState === 'working'
|
||||||
|
).length
|
||||||
|
text.setText(`⛏ ${count}/${MINE_CAPACITY}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Saves game state and destroys all systems cleanly on scene shutdown. */
|
/** Saves game state and destroys all systems cleanly on scene shutdown. */
|
||||||
shutdown(): void {
|
shutdown(): void {
|
||||||
stateManager.save()
|
stateManager.save()
|
||||||
|
|||||||
@@ -67,9 +67,11 @@ export class UIScene extends Phaser.Scene {
|
|||||||
private actionBuildLabel!: Phaser.GameObjects.Text
|
private actionBuildLabel!: Phaser.GameObjects.Text
|
||||||
private actionNisseBtn!: Phaser.GameObjects.Rectangle
|
private actionNisseBtn!: Phaser.GameObjects.Rectangle
|
||||||
private actionNisseLabel!: Phaser.GameObjects.Text
|
private actionNisseLabel!: Phaser.GameObjects.Text
|
||||||
|
private actionDemolishBtn!: Phaser.GameObjects.Rectangle
|
||||||
|
private actionDemolishLabel!: Phaser.GameObjects.Text
|
||||||
private actionTrayGroup!: Phaser.GameObjects.Group
|
private actionTrayGroup!: Phaser.GameObjects.Group
|
||||||
private actionTrayVisible = false
|
private actionTrayVisible = false
|
||||||
private activeCategory: 'build' | 'nisse' | null = null
|
private activeCategory: 'build' | 'nisse' | 'demolish' | null = null
|
||||||
|
|
||||||
constructor() { super({ key: 'UI' }) }
|
constructor() { super({ key: 'UI' }) }
|
||||||
|
|
||||||
@@ -89,10 +91,16 @@ export class UIScene extends Phaser.Scene {
|
|||||||
this.createActionBar()
|
this.createActionBar()
|
||||||
|
|
||||||
const gameScene = this.scene.get('Game')
|
const gameScene = this.scene.get('Game')
|
||||||
gameScene.events.on('buildModeChanged', (a: boolean, b: BuildingType) => this.onBuildModeChanged(a, b))
|
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('farmToolChanged', (t: FarmingTool, l: string) => this.onFarmToolChanged(t, l))
|
||||||
gameScene.events.on('toast', (m: string) => this.showToast(m))
|
gameScene.events.on('toast', (m: string) => this.showToast(m))
|
||||||
gameScene.events.on('openBuildMenu', () => this.toggleBuildMenu())
|
gameScene.events.on('openBuildMenu', () => this.toggleBuildMenu())
|
||||||
|
gameScene.events.on('demolishModeChanged', (active: boolean) => {
|
||||||
|
if (!active && this.activeCategory === 'demolish') {
|
||||||
|
this.activeCategory = null
|
||||||
|
this.updateCategoryHighlights()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.B)
|
this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.B)
|
||||||
.on('down', () => gameScene.events.emit('uiRequestBuildMenu'))
|
.on('down', () => gameScene.events.emit('uiRequestBuildMenu'))
|
||||||
@@ -229,9 +237,10 @@ export class UIScene extends Phaser.Scene {
|
|||||||
{ kind: 'bed', label: '🛏 Bed', cost: '6 wood (+1 villager)' },
|
{ kind: 'bed', label: '🛏 Bed', cost: '6 wood (+1 villager)' },
|
||||||
{ kind: 'stockpile_zone', label: '📦 Stockpile', cost: 'free (workers deliver here)' },
|
{ kind: 'stockpile_zone', label: '📦 Stockpile', cost: 'free (workers deliver here)' },
|
||||||
{ kind: 'forester_hut', label: '🌲 Forester Hut', cost: '50 wood' },
|
{ kind: 'forester_hut', label: '🌲 Forester Hut', cost: '50 wood' },
|
||||||
|
{ kind: 'mine', label: '⛏ Mine', cost: '200 wood + 50 stone (place on ROCK)' },
|
||||||
]
|
]
|
||||||
const menuX = this.scale.width / 2 - 150, menuY = this.scale.height / 2 - 168
|
const menuX = this.scale.width / 2 - 150, menuY = this.scale.height / 2 - 186
|
||||||
const bg = this.add.rectangle(menuX, menuY, 300, 326, 0x000000, this.uiOpacity).setOrigin(0,0).setScrollFactor(0).setDepth(200)
|
const bg = this.add.rectangle(menuX, menuY, 300, 372, 0x000000, this.uiOpacity).setOrigin(0,0).setScrollFactor(0).setDepth(200)
|
||||||
this.buildMenuGroup.add(bg)
|
this.buildMenuGroup.add(bg)
|
||||||
this.buildMenuGroup.add(this.add.text(menuX + 150, menuY + 14, 'BUILD MENU [B/ESC]', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setOrigin(0.5,0).setScrollFactor(0).setDepth(201))
|
this.buildMenuGroup.add(this.add.text(menuX + 150, menuY + 14, 'BUILD MENU [B/ESC]', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setOrigin(0.5,0).setScrollFactor(0).setDepth(201))
|
||||||
|
|
||||||
@@ -405,7 +414,7 @@ export class UIScene extends Phaser.Scene {
|
|||||||
/** Creates the debug panel text object (initially hidden). */
|
/** Creates the debug panel text object (initially hidden). */
|
||||||
private createDebugPanel(): void {
|
private createDebugPanel(): void {
|
||||||
const hexAlpha = Math.round(this.uiOpacity * 255).toString(16).padStart(2, '0')
|
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',
|
fontSize: '12px',
|
||||||
color: '#cccccc',
|
color: '#cccccc',
|
||||||
backgroundColor: `#000000${hexAlpha}`,
|
backgroundColor: `#000000${hexAlpha}`,
|
||||||
@@ -419,9 +428,20 @@ export class UIScene extends Phaser.Scene {
|
|||||||
private toggleDebugPanel(): void {
|
private toggleDebugPanel(): void {
|
||||||
this.debugActive = !this.debugActive
|
this.debugActive = !this.debugActive
|
||||||
this.debugPanelText.setVisible(this.debugActive)
|
this.debugPanelText.setVisible(this.debugActive)
|
||||||
|
this.repositionDebugPanel()
|
||||||
this.scene.get('Game').events.emit('debugToggle')
|
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.
|
* Reads current debug data from DebugSystem and updates the panel text.
|
||||||
* Called every frame while debug mode is active.
|
* Called every frame while debug mode is active.
|
||||||
@@ -463,6 +483,9 @@ export class UIScene extends Phaser.Scene {
|
|||||||
'',
|
'',
|
||||||
`Paths: ${data.activePaths} (cyan lines in world)`,
|
`Paths: ${data.activePaths} (cyan lines in world)`,
|
||||||
'',
|
'',
|
||||||
|
'── Last Actions ───────────────',
|
||||||
|
...(data.actionLog.length > 0 ? data.actionLog : ['—']),
|
||||||
|
'',
|
||||||
'[F3] close',
|
'[F3] close',
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
@@ -548,9 +571,9 @@ export class UIScene extends Phaser.Scene {
|
|||||||
if (this.nisseInfoVisible) { this.closeNisseInfoPanel(); return }
|
if (this.nisseInfoVisible) { this.closeNisseInfoPanel(); return }
|
||||||
if (this.settingsVisible) { this.closeSettings(); return }
|
if (this.settingsVisible) { this.closeSettings(); return }
|
||||||
if (this.escMenuVisible) { this.closeEscMenu(); return }
|
if (this.escMenuVisible) { this.closeEscMenu(); return }
|
||||||
// Build/farm mode: let BuildingSystem / FarmingSystem handle their own ESC key.
|
// Build/farm/demolish mode: let their systems handle ESC. Skip opening the ESC menu.
|
||||||
// We only skip opening the ESC menu while those modes are active.
|
|
||||||
if (this.inBuildMode || this.inFarmMode) return
|
if (this.inBuildMode || this.inFarmMode) return
|
||||||
|
if (this.activeCategory === 'demolish') { this.deactivateCategory(); return }
|
||||||
this.openEscMenu()
|
this.openEscMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -780,11 +803,13 @@ export class UIScene extends Phaser.Scene {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies the current uiOpacity to all static UI elements that are not
|
* 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.
|
* Called whenever uiOpacity changes.
|
||||||
*/
|
*/
|
||||||
private updateStaticPanelOpacity(): void {
|
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')
|
const hexAlpha = Math.round(this.uiOpacity * 255).toString(16).padStart(2, '0')
|
||||||
this.debugPanelText.setStyle({ backgroundColor: `#000000${hexAlpha}` })
|
this.debugPanelText.setStyle({ backgroundColor: `#000000${hexAlpha}` })
|
||||||
}
|
}
|
||||||
@@ -874,6 +899,7 @@ export class UIScene extends Phaser.Scene {
|
|||||||
this.nisseInfoId = villagerId
|
this.nisseInfoId = villagerId
|
||||||
this.nisseInfoVisible = true
|
this.nisseInfoVisible = true
|
||||||
this.buildNisseInfoPanel()
|
this.buildNisseInfoPanel()
|
||||||
|
this.repositionDebugPanel()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Closes and destroys the Nisse info panel. */
|
/** Closes and destroys the Nisse info panel. */
|
||||||
@@ -883,6 +909,7 @@ export class UIScene extends Phaser.Scene {
|
|||||||
this.nisseInfoId = null
|
this.nisseInfoId = null
|
||||||
this.nisseInfoGroup.destroy(true)
|
this.nisseInfoGroup.destroy(true)
|
||||||
this.nisseInfoGroup = this.add.group()
|
this.nisseInfoGroup = this.add.group()
|
||||||
|
this.repositionDebugPanel()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1166,16 +1193,16 @@ export class UIScene extends Phaser.Scene {
|
|||||||
const { width, height } = this.scale
|
const { width, height } = this.scale
|
||||||
const barY = height - UIScene.BAR_H
|
const barY = height - UIScene.BAR_H
|
||||||
|
|
||||||
this.actionBarBg = this.add.rectangle(0, barY, width, UIScene.BAR_H, 0x080808, 0.92)
|
this.actionBarBg = this.add.rectangle(0, barY, width, UIScene.BAR_H, 0x080808, this.uiOpacity)
|
||||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(300)
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(300)
|
||||||
|
|
||||||
this.actionBuildBtn = this.add.rectangle(8, barY + 8, 88, 32, 0x1a3a1a, 0.9)
|
this.actionBuildBtn = this.add.rectangle(8, barY + 8, 88, 32, 0x1a3a1a, this.uiOpacity)
|
||||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive()
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive()
|
||||||
this.actionBuildBtn.on('pointerover', () => {
|
this.actionBuildBtn.on('pointerover', () => {
|
||||||
if (this.activeCategory !== 'build') this.actionBuildBtn.setFillStyle(0x2a5a2a, 0.9)
|
if (this.activeCategory !== 'build') this.actionBuildBtn.setFillStyle(0x2a5a2a, this.uiOpacity)
|
||||||
})
|
})
|
||||||
this.actionBuildBtn.on('pointerout', () => {
|
this.actionBuildBtn.on('pointerout', () => {
|
||||||
if (this.activeCategory !== 'build') this.actionBuildBtn.setFillStyle(0x1a3a1a, 0.9)
|
if (this.activeCategory !== 'build') this.actionBuildBtn.setFillStyle(0x1a3a1a, this.uiOpacity)
|
||||||
})
|
})
|
||||||
this.actionBuildBtn.on('pointerdown', () => this.toggleCategory('build'))
|
this.actionBuildBtn.on('pointerdown', () => this.toggleCategory('build'))
|
||||||
|
|
||||||
@@ -1183,27 +1210,41 @@ export class UIScene extends Phaser.Scene {
|
|||||||
fontSize: '12px', color: '#cccccc', fontFamily: 'monospace',
|
fontSize: '12px', color: '#cccccc', fontFamily: 'monospace',
|
||||||
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(302)
|
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(302)
|
||||||
|
|
||||||
this.actionNisseBtn = this.add.rectangle(104, barY + 8, 88, 32, 0x1a1a3a, 0.9)
|
this.actionNisseBtn = this.add.rectangle(104, barY + 8, 88, 32, 0x1a1a3a, this.uiOpacity)
|
||||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive()
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive()
|
||||||
this.actionNisseBtn.on('pointerover', () => {
|
this.actionNisseBtn.on('pointerover', () => {
|
||||||
if (this.activeCategory !== 'nisse') this.actionNisseBtn.setFillStyle(0x2a2a5a, 0.9)
|
if (this.activeCategory !== 'nisse') this.actionNisseBtn.setFillStyle(0x2a2a5a, this.uiOpacity)
|
||||||
})
|
})
|
||||||
this.actionNisseBtn.on('pointerout', () => {
|
this.actionNisseBtn.on('pointerout', () => {
|
||||||
if (this.activeCategory !== 'nisse') this.actionNisseBtn.setFillStyle(0x1a1a3a, 0.9)
|
if (this.activeCategory !== 'nisse') this.actionNisseBtn.setFillStyle(0x1a1a3a, this.uiOpacity)
|
||||||
})
|
})
|
||||||
this.actionNisseBtn.on('pointerdown', () => this.toggleCategory('nisse'))
|
this.actionNisseBtn.on('pointerdown', () => this.toggleCategory('nisse'))
|
||||||
|
|
||||||
this.actionNisseLabel = this.add.text(148, barY + UIScene.BAR_H / 2, '👥 Nisse', {
|
this.actionNisseLabel = this.add.text(148, barY + UIScene.BAR_H / 2, '👥 Nisse', {
|
||||||
fontSize: '12px', color: '#cccccc', fontFamily: 'monospace',
|
fontSize: '12px', color: '#cccccc', fontFamily: 'monospace',
|
||||||
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(302)
|
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(302)
|
||||||
|
|
||||||
|
this.actionDemolishBtn = this.add.rectangle(200, barY + 8, 88, 32, 0x3a1a1a, this.uiOpacity)
|
||||||
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive()
|
||||||
|
this.actionDemolishBtn.on('pointerover', () => {
|
||||||
|
if (this.activeCategory !== 'demolish') this.actionDemolishBtn.setFillStyle(0x5a2a2a, this.uiOpacity)
|
||||||
|
})
|
||||||
|
this.actionDemolishBtn.on('pointerout', () => {
|
||||||
|
if (this.activeCategory !== 'demolish') this.actionDemolishBtn.setFillStyle(0x3a1a1a, this.uiOpacity)
|
||||||
|
})
|
||||||
|
this.actionDemolishBtn.on('pointerdown', () => this.toggleCategory('demolish'))
|
||||||
|
|
||||||
|
this.actionDemolishLabel = this.add.text(244, barY + UIScene.BAR_H / 2, '💥 Demolish', {
|
||||||
|
fontSize: '12px', color: '#cccccc', fontFamily: 'monospace',
|
||||||
|
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(302)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggles the given action bar category on or off.
|
* Toggles the given action bar category on or off.
|
||||||
* Selecting the active category deselects it; selecting a new one closes the previous.
|
* Selecting the active category deselects it; selecting a new one closes the previous.
|
||||||
* @param cat - The category to toggle ('build' or 'nisse')
|
* @param cat - The category to toggle
|
||||||
*/
|
*/
|
||||||
private toggleCategory(cat: 'build' | 'nisse'): void {
|
private toggleCategory(cat: 'build' | 'nisse' | 'demolish'): void {
|
||||||
if (this.activeCategory === cat) {
|
if (this.activeCategory === cat) {
|
||||||
this.deactivateCategory()
|
this.deactivateCategory()
|
||||||
return
|
return
|
||||||
@@ -1211,14 +1252,17 @@ export class UIScene extends Phaser.Scene {
|
|||||||
// Close whatever was open before
|
// Close whatever was open before
|
||||||
if (this.activeCategory === 'build') this.closeActionTray()
|
if (this.activeCategory === 'build') this.closeActionTray()
|
||||||
if (this.activeCategory === 'nisse' && this.villagerPanelVisible) this.closeVillagerPanel()
|
if (this.activeCategory === 'nisse' && this.villagerPanelVisible) this.closeVillagerPanel()
|
||||||
|
if (this.activeCategory === 'demolish') this.scene.get('Game').events.emit('deactivateDemolish')
|
||||||
|
|
||||||
this.activeCategory = cat
|
this.activeCategory = cat
|
||||||
this.updateCategoryHighlights()
|
this.updateCategoryHighlights()
|
||||||
|
|
||||||
if (cat === 'build') {
|
if (cat === 'build') {
|
||||||
this.openActionTray()
|
this.openActionTray()
|
||||||
} else {
|
} else if (cat === 'nisse') {
|
||||||
this.openVillagerPanel()
|
this.openVillagerPanel()
|
||||||
|
} else {
|
||||||
|
this.scene.get('Game').events.emit('activateDemolish')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1228,17 +1272,19 @@ export class UIScene extends Phaser.Scene {
|
|||||||
private deactivateCategory(): void {
|
private deactivateCategory(): void {
|
||||||
if (this.activeCategory === 'build') this.closeActionTray()
|
if (this.activeCategory === 'build') this.closeActionTray()
|
||||||
if (this.activeCategory === 'nisse' && this.villagerPanelVisible) this.closeVillagerPanel()
|
if (this.activeCategory === 'nisse' && this.villagerPanelVisible) this.closeVillagerPanel()
|
||||||
|
if (this.activeCategory === 'demolish') this.scene.get('Game').events.emit('deactivateDemolish')
|
||||||
this.activeCategory = null
|
this.activeCategory = null
|
||||||
this.updateCategoryHighlights()
|
this.updateCategoryHighlights()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the visual highlight of the Build and Nisse buttons
|
* Updates the visual highlight of the Build, Nisse, and Demolish buttons
|
||||||
* to reflect the current active category.
|
* to reflect the current active category.
|
||||||
*/
|
*/
|
||||||
private updateCategoryHighlights(): void {
|
private updateCategoryHighlights(): void {
|
||||||
this.actionBuildBtn.setFillStyle(this.activeCategory === 'build' ? 0x3d7a3d : 0x1a3a1a, 0.9)
|
this.actionBuildBtn.setFillStyle(this.activeCategory === 'build' ? 0x3d7a3d : 0x1a3a1a, this.uiOpacity)
|
||||||
this.actionNisseBtn.setFillStyle(this.activeCategory === 'nisse' ? 0x3d3d7a : 0x1a1a3a, 0.9)
|
this.actionNisseBtn.setFillStyle(this.activeCategory === 'nisse' ? 0x3d3d7a : 0x1a1a3a, this.uiOpacity)
|
||||||
|
this.actionDemolishBtn.setFillStyle(this.activeCategory === 'demolish' ? 0x7a3d3d : 0x3a1a1a, this.uiOpacity)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1250,12 +1296,13 @@ export class UIScene extends Phaser.Scene {
|
|||||||
this.actionTrayVisible = true
|
this.actionTrayVisible = true
|
||||||
this.actionTrayGroup.destroy(true)
|
this.actionTrayGroup.destroy(true)
|
||||||
this.actionTrayGroup = this.add.group()
|
this.actionTrayGroup = this.add.group()
|
||||||
|
this.actionBarBg.setAlpha(0)
|
||||||
|
|
||||||
const { width, height } = this.scale
|
const { width, height } = this.scale
|
||||||
const trayY = height - UIScene.BAR_H - UIScene.TRAY_H
|
const trayY = height - UIScene.BAR_H - UIScene.TRAY_H
|
||||||
|
|
||||||
const bg = this.add.rectangle(0, trayY, width, UIScene.TRAY_H, 0x0d0d0d, 0.88)
|
const bg = this.add.rectangle(0, trayY, width, UIScene.TRAY_H + UIScene.BAR_H, 0x080808, this.uiOpacity)
|
||||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(300)
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(299)
|
||||||
this.actionTrayGroup.add(bg)
|
this.actionTrayGroup.add(bg)
|
||||||
|
|
||||||
const buildings: { kind: BuildingType; emoji: string; label: string }[] = [
|
const buildings: { kind: BuildingType; emoji: string; label: string }[] = [
|
||||||
@@ -1265,15 +1312,16 @@ export class UIScene extends Phaser.Scene {
|
|||||||
{ kind: 'bed', emoji: '🛏', label: 'Bed' },
|
{ kind: 'bed', emoji: '🛏', label: 'Bed' },
|
||||||
{ kind: 'stockpile_zone', emoji: '📦', label: 'Stockpile' },
|
{ kind: 'stockpile_zone', emoji: '📦', label: 'Stockpile' },
|
||||||
{ kind: 'forester_hut', emoji: '🌲', label: 'Forester' },
|
{ kind: 'forester_hut', emoji: '🌲', label: 'Forester' },
|
||||||
|
{ kind: 'mine', emoji: '⛏', label: 'Mine' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const itemW = 84
|
const itemW = 84
|
||||||
buildings.forEach((b, i) => {
|
buildings.forEach((b, i) => {
|
||||||
const bx = 8 + i * (itemW + 4)
|
const bx = 8 + i * (itemW + 4)
|
||||||
const btn = this.add.rectangle(bx, trayY + 4, itemW, UIScene.TRAY_H - 8, 0x1a2a1a, 0.9)
|
const btn = this.add.rectangle(bx, trayY + 4, itemW, UIScene.TRAY_H - 8, 0x1a2a1a, this.uiOpacity)
|
||||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive()
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(301).setInteractive()
|
||||||
btn.on('pointerover', () => btn.setFillStyle(0x2d4a2d, 0.9))
|
btn.on('pointerover', () => btn.setFillStyle(0x2d4a2d, this.uiOpacity))
|
||||||
btn.on('pointerout', () => btn.setFillStyle(0x1a2a1a, 0.9))
|
btn.on('pointerout', () => btn.setFillStyle(0x1a2a1a, this.uiOpacity))
|
||||||
btn.on('pointerdown', () => {
|
btn.on('pointerdown', () => {
|
||||||
this.closeActionTray()
|
this.closeActionTray()
|
||||||
this.deactivateCategory()
|
this.deactivateCategory()
|
||||||
@@ -1300,6 +1348,7 @@ export class UIScene extends Phaser.Scene {
|
|||||||
this.actionTrayVisible = false
|
this.actionTrayVisible = false
|
||||||
this.actionTrayGroup.destroy(true)
|
this.actionTrayGroup.destroy(true)
|
||||||
this.actionTrayGroup = this.add.group()
|
this.actionTrayGroup = this.add.group()
|
||||||
|
this.actionBarBg.setAlpha(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Resize ───────────────────────────────────────────────────────────────
|
// ─── Resize ───────────────────────────────────────────────────────────────
|
||||||
@@ -1330,7 +1379,9 @@ export class UIScene extends Phaser.Scene {
|
|||||||
this.actionBuildBtn.setPosition(8, height - UIScene.BAR_H + 8)
|
this.actionBuildBtn.setPosition(8, height - UIScene.BAR_H + 8)
|
||||||
this.actionBuildLabel.setPosition(48, height - UIScene.BAR_H + UIScene.BAR_H / 2)
|
this.actionBuildLabel.setPosition(48, height - UIScene.BAR_H + UIScene.BAR_H / 2)
|
||||||
this.actionNisseBtn.setPosition(104, height - UIScene.BAR_H + 8)
|
this.actionNisseBtn.setPosition(104, height - UIScene.BAR_H + 8)
|
||||||
this.actionNisseLabel.setPosition(144, height - UIScene.BAR_H + UIScene.BAR_H / 2)
|
this.actionNisseLabel.setPosition(148, height - UIScene.BAR_H + UIScene.BAR_H / 2)
|
||||||
|
this.actionDemolishBtn.setPosition(200, height - UIScene.BAR_H + 8)
|
||||||
|
this.actionDemolishLabel.setPosition(244, height - UIScene.BAR_H + UIScene.BAR_H / 2)
|
||||||
if (this.actionTrayVisible) this.closeActionTray()
|
if (this.actionTrayVisible) this.closeActionTray()
|
||||||
// Close centered panels — their position is calculated on open, so they
|
// Close centered panels — their position is calculated on open, so they
|
||||||
// would be off-center if left open during a resize
|
// would be off-center if left open during a resize
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Phaser from 'phaser'
|
import Phaser from 'phaser'
|
||||||
import { TILE_SIZE, BUILDING_COSTS } from '../config'
|
import { TILE_SIZE, BUILDING_COSTS, DEMOLISH_REFUND_MS } from '../config'
|
||||||
import { TileType, IMPASSABLE } from '../types'
|
import { TileType, IMPASSABLE } from '../types'
|
||||||
import type { BuildingType } from '../types'
|
import type { BuildingType, BuildingState, ItemId } from '../types'
|
||||||
import { stateManager } from '../StateManager'
|
import { stateManager } from '../StateManager'
|
||||||
import type { LocalAdapter } from '../NetworkAdapter'
|
import type { LocalAdapter } from '../NetworkAdapter'
|
||||||
|
|
||||||
@@ -12,10 +12,18 @@ const BUILDING_TILE: Partial<Record<BuildingType, TileType>> = {
|
|||||||
// bed and stockpile_zone do NOT change the underlying tile
|
// bed and stockpile_zone do NOT change the underlying tile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tile type to restore when a building that changed its tile is demolished. */
|
||||||
|
const DEMOLISH_RESTORE_TILE: Partial<Record<BuildingType, TileType>> = {
|
||||||
|
floor: TileType.GRASS,
|
||||||
|
wall: TileType.GRASS,
|
||||||
|
chest: TileType.GRASS,
|
||||||
|
}
|
||||||
|
|
||||||
export class BuildingSystem {
|
export class BuildingSystem {
|
||||||
private scene: Phaser.Scene
|
private scene: Phaser.Scene
|
||||||
private adapter: LocalAdapter
|
private adapter: LocalAdapter
|
||||||
private active = false
|
private active = false
|
||||||
|
private demolishActive = false
|
||||||
private selectedBuilding: BuildingType = 'floor'
|
private selectedBuilding: BuildingType = 'floor'
|
||||||
private ghost!: Phaser.GameObjects.Rectangle
|
private ghost!: Phaser.GameObjects.Rectangle
|
||||||
private ghostLabel!: Phaser.GameObjects.Text
|
private ghostLabel!: Phaser.GameObjects.Text
|
||||||
@@ -24,15 +32,26 @@ export class BuildingSystem {
|
|||||||
|
|
||||||
onModeChange?: (active: boolean, building: BuildingType) => void
|
onModeChange?: (active: boolean, building: BuildingType) => void
|
||||||
onPlaced?: (msg: string) => void
|
onPlaced?: (msg: string) => void
|
||||||
|
onDemolishModeChange?: (active: boolean) => void
|
||||||
|
/**
|
||||||
|
* Called after a building is demolished with the removed building data and the refund items.
|
||||||
|
* @param building - The BuildingState that was removed
|
||||||
|
* @param refund - Items returned to stockpile
|
||||||
|
*/
|
||||||
|
onDemolished?: (building: BuildingState, refund: Partial<Record<ItemId, number>>) => void
|
||||||
|
|
||||||
constructor(scene: Phaser.Scene, adapter: LocalAdapter) {
|
constructor(scene: Phaser.Scene, adapter: LocalAdapter) {
|
||||||
this.scene = scene
|
this.scene = scene
|
||||||
this.adapter = adapter
|
this.adapter = adapter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialises ghost sprite, label, and keyboard/pointer handlers for
|
||||||
|
* both build mode and demolish mode.
|
||||||
|
*/
|
||||||
create(): void {
|
create(): void {
|
||||||
this.ghost = this.scene.add.rectangle(0, 0, TILE_SIZE, TILE_SIZE, 0x00FF00, 0.35)
|
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.setVisible(false)
|
||||||
this.ghost.setStrokeStyle(2, 0x00FF00, 0.8)
|
this.ghost.setStrokeStyle(2, 0x00FF00, 0.8)
|
||||||
|
|
||||||
@@ -40,27 +59,71 @@ export class BuildingSystem {
|
|||||||
fontSize: '10px', color: '#ffffff', fontFamily: 'monospace',
|
fontSize: '10px', color: '#ffffff', fontFamily: 'monospace',
|
||||||
backgroundColor: '#000000aa', padding: { x: 3, y: 2 }
|
backgroundColor: '#000000aa', padding: { x: 3, y: 2 }
|
||||||
})
|
})
|
||||||
this.ghostLabel.setDepth(21)
|
this.ghostLabel.setDepth(1001)
|
||||||
this.ghostLabel.setVisible(false)
|
this.ghostLabel.setVisible(false)
|
||||||
this.ghostLabel.setOrigin(0.5, 1)
|
this.ghostLabel.setOrigin(0.5, 1)
|
||||||
|
|
||||||
this.buildKey = this.scene.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.B)
|
this.buildKey = this.scene.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.B)
|
||||||
this.cancelKey = this.scene.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.ESC)
|
this.cancelKey = this.scene.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.ESC)
|
||||||
|
|
||||||
// Click to place
|
// Click to place (build mode) or demolish (demolish mode)
|
||||||
this.scene.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
this.scene.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
||||||
if (!this.active) return
|
|
||||||
if (ptr.rightButtonDown()) {
|
if (ptr.rightButtonDown()) {
|
||||||
this.deactivate()
|
if (this.active) this.deactivate()
|
||||||
|
if (this.demolishActive) this.deactivateDemolish()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.tryPlace(ptr)
|
if (this.active) this.tryPlace(ptr)
|
||||||
|
else if (this.demolishActive) this.tryDemolish(ptr)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the tile footprint dimensions for the given building type.
|
||||||
|
* @param kind - The building type to query
|
||||||
|
* @returns Width and height in tiles
|
||||||
|
*/
|
||||||
|
private getFootprint(kind: BuildingType): { w: number; h: number } {
|
||||||
|
if (kind === 'mine') return { w: 3, h: 2 }
|
||||||
|
return { w: 1, h: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all tile positions occupied by the given building,
|
||||||
|
* expanding multi-tile buildings (e.g. mine: 3×2) to their full footprint.
|
||||||
|
* @param b - The building state to expand
|
||||||
|
* @returns Array of tile positions in the building's footprint
|
||||||
|
*/
|
||||||
|
private getBuildingFootprintTiles(b: BuildingState): Array<{ tileX: number; tileY: number }> {
|
||||||
|
if (b.kind === 'mine') {
|
||||||
|
const result: Array<{ tileX: number; tileY: number }> = []
|
||||||
|
for (let dy = 0; dy < 2; dy++)
|
||||||
|
for (let dx = 0; dx < 3; dx++)
|
||||||
|
result.push({ tileX: b.tileX + dx, tileY: b.tileY + dy })
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return [{ tileX: b.tileX, tileY: b.tileY }]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the building whose footprint contains the given tile, if any.
|
||||||
|
* @param tileX - Tile column to check
|
||||||
|
* @param tileY - Tile row to check
|
||||||
|
* @returns The matching BuildingState, or undefined
|
||||||
|
*/
|
||||||
|
private findBuildingAtTile(tileX: number, tileY: number): BuildingState | undefined {
|
||||||
|
const buildings = Object.values(stateManager.getState().world.buildings)
|
||||||
|
return buildings.find(b =>
|
||||||
|
this.getBuildingFootprintTiles(b).some(t => t.tileX === tileX && t.tileY === tileY)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Select a building type and activate build mode */
|
/** Select a building type and activate build mode */
|
||||||
selectBuilding(kind: BuildingType): void {
|
selectBuilding(kind: BuildingType): void {
|
||||||
|
if (this.demolishActive) this.deactivateDemolish()
|
||||||
this.selectedBuilding = kind
|
this.selectedBuilding = kind
|
||||||
|
const { w, h } = this.getFootprint(kind)
|
||||||
|
this.ghost.setSize(w * TILE_SIZE, h * TILE_SIZE)
|
||||||
this.activate()
|
this.activate()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +143,37 @@ export class BuildingSystem {
|
|||||||
|
|
||||||
isActive(): boolean { return this.active }
|
isActive(): boolean { return this.active }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activates demolish mode. Deactivates build mode if currently active.
|
||||||
|
* In demolish mode the ghost turns red and clicking a building removes it.
|
||||||
|
*/
|
||||||
|
activateDemolish(): void {
|
||||||
|
if (this.active) this.deactivate()
|
||||||
|
this.demolishActive = true
|
||||||
|
this.ghost.setSize(TILE_SIZE, TILE_SIZE)
|
||||||
|
this.ghost.setFillStyle(0xFF2222, 0.35)
|
||||||
|
this.ghost.setStrokeStyle(2, 0xFF2222, 0.9)
|
||||||
|
this.ghost.setVisible(true)
|
||||||
|
this.ghostLabel.setVisible(true)
|
||||||
|
this.onDemolishModeChange?.(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deactivates demolish mode and hides the ghost.
|
||||||
|
*/
|
||||||
|
deactivateDemolish(): void {
|
||||||
|
this.demolishActive = false
|
||||||
|
this.ghost.setVisible(false)
|
||||||
|
this.ghostLabel.setVisible(false)
|
||||||
|
this.onDemolishModeChange?.(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if demolish mode is currently active. */
|
||||||
|
isDemolishActive(): boolean { return this.demolishActive }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates ghost position and label each frame for both build and demolish modes.
|
||||||
|
*/
|
||||||
update(): void {
|
update(): void {
|
||||||
if (Phaser.Input.Keyboard.JustDown(this.buildKey)) {
|
if (Phaser.Input.Keyboard.JustDown(this.buildKey)) {
|
||||||
if (this.active) this.deactivate()
|
if (this.active) this.deactivate()
|
||||||
@@ -87,23 +181,30 @@ export class BuildingSystem {
|
|||||||
}
|
}
|
||||||
if (Phaser.Input.Keyboard.JustDown(this.cancelKey)) {
|
if (Phaser.Input.Keyboard.JustDown(this.cancelKey)) {
|
||||||
this.deactivate()
|
this.deactivate()
|
||||||
|
this.deactivateDemolish()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.active) return
|
if (this.active) {
|
||||||
|
this.updateBuildGhost()
|
||||||
|
} else if (this.demolishActive) {
|
||||||
|
this.updateDemolishGhost()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update ghost to follow mouse (snapped to tile grid)
|
/**
|
||||||
|
* Updates the green/red build-mode ghost to follow the mouse, snapped to the tile grid.
|
||||||
|
*/
|
||||||
|
private updateBuildGhost(): void {
|
||||||
const ptr = this.scene.input.activePointer
|
const ptr = this.scene.input.activePointer
|
||||||
const worldX = ptr.worldX
|
const tileX = Math.floor(ptr.worldX / TILE_SIZE)
|
||||||
const worldY = ptr.worldY
|
const tileY = Math.floor(ptr.worldY / TILE_SIZE)
|
||||||
const tileX = Math.floor(worldX / TILE_SIZE)
|
const { w, h } = this.getFootprint(this.selectedBuilding)
|
||||||
const tileY = Math.floor(worldY / TILE_SIZE)
|
const snapX = tileX * TILE_SIZE + (w * TILE_SIZE) / 2
|
||||||
const snapX = tileX * TILE_SIZE + TILE_SIZE / 2
|
const snapY = tileY * TILE_SIZE + (h * TILE_SIZE) / 2
|
||||||
const snapY = tileY * TILE_SIZE + TILE_SIZE / 2
|
|
||||||
|
|
||||||
this.ghost.setPosition(snapX, snapY)
|
this.ghost.setPosition(snapX, snapY)
|
||||||
this.ghostLabel.setPosition(snapX, snapY - TILE_SIZE / 2 - 2)
|
this.ghostLabel.setPosition(snapX, snapY - (h * TILE_SIZE) / 2 - 2)
|
||||||
|
|
||||||
// Color ghost based on can-build
|
|
||||||
const canBuild = this.canBuildAt(tileX, tileY)
|
const canBuild = this.canBuildAt(tileX, tileY)
|
||||||
const color = canBuild ? 0x00FF00 : 0xFF4444
|
const color = canBuild ? 0x00FF00 : 0xFF4444
|
||||||
this.ghost.setFillStyle(color, 0.35)
|
this.ghost.setFillStyle(color, 0.35)
|
||||||
@@ -114,8 +215,62 @@ export class BuildingSystem {
|
|||||||
this.ghostLabel.setText(`${this.selectedBuilding} [${costStr}]`)
|
this.ghostLabel.setText(`${this.selectedBuilding} [${costStr}]`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the red demolish ghost to follow the mouse. Highlights the hovered building's
|
||||||
|
* footprint and shows the refund percentage in the label.
|
||||||
|
*/
|
||||||
|
private updateDemolishGhost(): void {
|
||||||
|
const ptr = this.scene.input.activePointer
|
||||||
|
const tileX = Math.floor(ptr.worldX / TILE_SIZE)
|
||||||
|
const tileY = Math.floor(ptr.worldY / TILE_SIZE)
|
||||||
|
|
||||||
|
const building = this.findBuildingAtTile(tileX, tileY)
|
||||||
|
if (building) {
|
||||||
|
const { w, h } = this.getFootprint(building.kind)
|
||||||
|
const snapX = building.tileX * TILE_SIZE + (w * TILE_SIZE) / 2
|
||||||
|
const snapY = building.tileY * TILE_SIZE + (h * TILE_SIZE) / 2
|
||||||
|
this.ghost.setSize(w * TILE_SIZE, h * TILE_SIZE)
|
||||||
|
this.ghost.setPosition(snapX, snapY)
|
||||||
|
this.ghostLabel.setPosition(snapX, snapY - (h * TILE_SIZE) / 2 - 2)
|
||||||
|
this.ghost.setFillStyle(0xFF2222, 0.45)
|
||||||
|
this.ghost.setStrokeStyle(2, 0xFF2222, 1)
|
||||||
|
|
||||||
|
const refundPct = this.calcRefundPct(building)
|
||||||
|
const label = refundPct > 0
|
||||||
|
? `${building.kind} [refund ${Math.round(refundPct * 100)}%]`
|
||||||
|
: `${building.kind} [no refund]`
|
||||||
|
this.ghostLabel.setText(label)
|
||||||
|
} else {
|
||||||
|
// No building under cursor — small neutral ghost
|
||||||
|
const snapX = tileX * TILE_SIZE + TILE_SIZE / 2
|
||||||
|
const snapY = tileY * TILE_SIZE + TILE_SIZE / 2
|
||||||
|
this.ghost.setSize(TILE_SIZE, TILE_SIZE)
|
||||||
|
this.ghost.setPosition(snapX, snapY)
|
||||||
|
this.ghostLabel.setPosition(snapX, snapY - TILE_SIZE / 2 - 2)
|
||||||
|
this.ghost.setFillStyle(0x444444, 0.2)
|
||||||
|
this.ghost.setStrokeStyle(1, 0x666666, 0.5)
|
||||||
|
this.ghostLabel.setText('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the refund fraction (0–1) for a building based on how long ago it was built.
|
||||||
|
* Returns 1.0 within the first 3 minutes, decaying linearly to 0.
|
||||||
|
* @param building - The building to evaluate
|
||||||
|
* @returns Refund fraction between 0 and 1
|
||||||
|
*/
|
||||||
|
private calcRefundPct(building: BuildingState): number {
|
||||||
|
const elapsed = stateManager.getGameTime() - (building.builtAt ?? 0)
|
||||||
|
return Math.max(0, 1 - elapsed / DEMOLISH_REFUND_MS)
|
||||||
|
}
|
||||||
|
|
||||||
private canBuildAt(tileX: number, tileY: number): boolean {
|
private canBuildAt(tileX: number, tileY: number): boolean {
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
|
|
||||||
|
if (this.selectedBuilding === 'mine') {
|
||||||
|
return this.canPlaceMineAt(tileX, tileY)
|
||||||
|
}
|
||||||
|
|
||||||
const tile = state.world.tiles[tileY * 512 + tileX] as TileType // 512 = WORLD_TILES
|
const tile = state.world.tiles[tileY * 512 + tileX] as TileType // 512 = WORLD_TILES
|
||||||
|
|
||||||
// Can only build on passable ground tiles (not water, not existing buildings)
|
// Can only build on passable ground tiles (not water, not existing buildings)
|
||||||
@@ -141,6 +296,40 @@ export class BuildingSystem {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a 3×2 mine can be placed with top-left at (tileX, tileY).
|
||||||
|
* All 6 tiles must be ROCK with no resources and no overlapping buildings.
|
||||||
|
* @param tileX - Top-left tile column
|
||||||
|
* @param tileY - Top-left tile row
|
||||||
|
*/
|
||||||
|
private canPlaceMineAt(tileX: number, tileY: number): boolean {
|
||||||
|
const state = stateManager.getState()
|
||||||
|
|
||||||
|
// Check costs
|
||||||
|
const costs = BUILDING_COSTS.mine
|
||||||
|
for (const [item, qty] of Object.entries(costs)) {
|
||||||
|
const have = state.world.stockpile[item as keyof typeof state.world.stockpile] ?? 0
|
||||||
|
if (have < qty) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const resources = Object.values(state.world.resources)
|
||||||
|
const buildings = Object.values(state.world.buildings)
|
||||||
|
|
||||||
|
for (let dy = 0; dy < 2; dy++) {
|
||||||
|
for (let dx = 0; dx < 3; dx++) {
|
||||||
|
const tx = tileX + dx, ty = tileY + dy
|
||||||
|
// Must be ROCK
|
||||||
|
if ((state.world.tiles[ty * 512 + tx] as TileType) !== TileType.ROCK) return false
|
||||||
|
// No resource on this tile
|
||||||
|
if (resources.some(r => r.tileX === tx && r.tileY === ty)) return false
|
||||||
|
// No existing building footprint overlapping this tile
|
||||||
|
if (buildings.some(b => this.getBuildingFootprintTiles(b).some(t => t.tileX === tx && t.tileY === ty))) return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
private tryPlace(ptr: Phaser.Input.Pointer): void {
|
private tryPlace(ptr: Phaser.Input.Pointer): void {
|
||||||
const worldX = ptr.worldX
|
const worldX = ptr.worldX
|
||||||
const worldY = ptr.worldY
|
const worldY = ptr.worldY
|
||||||
@@ -148,33 +337,92 @@ export class BuildingSystem {
|
|||||||
const tileY = Math.floor(worldY / TILE_SIZE)
|
const tileY = Math.floor(worldY / TILE_SIZE)
|
||||||
|
|
||||||
if (!this.canBuildAt(tileX, tileY)) {
|
if (!this.canBuildAt(tileX, tileY)) {
|
||||||
this.onPlaced?.('Cannot build here!')
|
const missing = this.getMissingResources(tileX, tileY)
|
||||||
|
this.onPlaced?.(missing.length ? `Need: ${missing}` : 'Cannot build here!')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const costs = BUILDING_COSTS[this.selectedBuilding] ?? {}
|
const costs = BUILDING_COSTS[this.selectedBuilding] ?? {}
|
||||||
const building = {
|
const building: BuildingState = {
|
||||||
id: `building_${tileX}_${tileY}_${Date.now()}`,
|
id: `building_${tileX}_${tileY}_${Date.now()}`,
|
||||||
tileX,
|
tileX,
|
||||||
tileY,
|
tileY,
|
||||||
kind: this.selectedBuilding,
|
kind: this.selectedBuilding,
|
||||||
ownerId: stateManager.getState().player.id,
|
ownerId: stateManager.getState().player.id,
|
||||||
|
builtAt: stateManager.getGameTime(),
|
||||||
}
|
}
|
||||||
|
|
||||||
this.adapter.send({ type: 'PLACE_BUILDING', building, costs })
|
this.adapter.send({ type: 'PLACE_BUILDING', building, costs })
|
||||||
// Only change the tile type for buildings that have a floor/wall tile mapping
|
// Mine keeps its ROCK tile type; footprint blocking is handled in renderPersistentObjects.
|
||||||
const tileMapped = BUILDING_TILE[this.selectedBuilding]
|
// Other buildings change tile type where applicable.
|
||||||
if (tileMapped !== undefined) {
|
if (this.selectedBuilding !== 'mine') {
|
||||||
this.adapter.send({
|
const tileMapped = BUILDING_TILE[this.selectedBuilding]
|
||||||
type: 'CHANGE_TILE',
|
if (tileMapped !== undefined) {
|
||||||
tileX,
|
this.adapter.send({ type: 'CHANGE_TILE', tileX, tileY, tile: tileMapped })
|
||||||
tileY,
|
}
|
||||||
tile: tileMapped,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
this.onPlaced?.(`Placed ${this.selectedBuilding}!`)
|
this.onPlaced?.(`Placed ${this.selectedBuilding}!`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a human-readable string describing which resources are missing
|
||||||
|
* to build the currently selected building at the given tile.
|
||||||
|
* @param tileX - Tile column
|
||||||
|
* @param tileY - Tile row
|
||||||
|
* @returns Comma-separated missing resource string, or empty string if nothing is missing
|
||||||
|
*/
|
||||||
|
private getMissingResources(tileX: number, tileY: number): string {
|
||||||
|
const state = stateManager.getState()
|
||||||
|
const costs = BUILDING_COSTS[this.selectedBuilding] ?? {}
|
||||||
|
const parts: string[] = []
|
||||||
|
for (const [item, qty] of Object.entries(costs)) {
|
||||||
|
const have = state.world.stockpile[item as keyof typeof state.world.stockpile] ?? 0
|
||||||
|
if (have < qty) parts.push(`${qty - have} ${item}`)
|
||||||
|
}
|
||||||
|
return parts.join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to demolish the building at the clicked tile.
|
||||||
|
* Calculates the time-based refund, removes the building from state,
|
||||||
|
* restores the tile type if applicable, and fires onDemolished.
|
||||||
|
* @param ptr - The pointer that was clicked
|
||||||
|
*/
|
||||||
|
private tryDemolish(ptr: Phaser.Input.Pointer): void {
|
||||||
|
const tileX = Math.floor(ptr.worldX / TILE_SIZE)
|
||||||
|
const tileY = Math.floor(ptr.worldY / TILE_SIZE)
|
||||||
|
|
||||||
|
const building = this.findBuildingAtTile(tileX, tileY)
|
||||||
|
if (!building) return
|
||||||
|
|
||||||
|
// Calculate refund
|
||||||
|
const costs = BUILDING_COSTS[building.kind] ?? {}
|
||||||
|
const refundPct = this.calcRefundPct(building)
|
||||||
|
const refund: Partial<Record<ItemId, number>> = {}
|
||||||
|
for (const [item, qty] of Object.entries(costs)) {
|
||||||
|
const amount = Math.floor((qty ?? 0) * refundPct)
|
||||||
|
if (amount > 0) refund[item as ItemId] = amount
|
||||||
|
}
|
||||||
|
|
||||||
|
this.adapter.send({ type: 'REMOVE_BUILDING', buildingId: building.id })
|
||||||
|
|
||||||
|
// Restore tile type for buildings that changed it on placement
|
||||||
|
const restoreTile = DEMOLISH_RESTORE_TILE[building.kind]
|
||||||
|
if (restoreTile !== undefined) {
|
||||||
|
this.adapter.send({ type: 'CHANGE_TILE', tileX: building.tileX, tileY: building.tileY, tile: restoreTile })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return resources to stockpile
|
||||||
|
if (Object.keys(refund).length > 0) {
|
||||||
|
this.adapter.send({ type: 'ADD_ITEMS', items: refund })
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onDemolished?.(building, refund)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans up ghost sprites on scene shutdown.
|
||||||
|
*/
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.ghost.destroy()
|
this.ghost.destroy()
|
||||||
this.ghostLabel.destroy()
|
this.ghostLabel.destroy()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Phaser from 'phaser'
|
|||||||
import { TILE_SIZE } from '../config'
|
import { TILE_SIZE } from '../config'
|
||||||
import { TileType } from '../types'
|
import { TileType } from '../types'
|
||||||
import { stateManager } from '../StateManager'
|
import { stateManager } from '../StateManager'
|
||||||
|
import type { LocalAdapter } from '../NetworkAdapter'
|
||||||
import type { VillagerSystem } from './VillagerSystem'
|
import type { VillagerSystem } from './VillagerSystem'
|
||||||
import type { WorldSystem } from './WorldSystem'
|
import type { WorldSystem } from './WorldSystem'
|
||||||
|
|
||||||
@@ -18,6 +19,8 @@ export interface DebugData {
|
|||||||
nisseByState: { idle: number; walking: number; working: number; sleeping: number }
|
nisseByState: { idle: number; walking: number; working: number; sleeping: number }
|
||||||
jobsByType: { chop: number; mine: number; farm: number }
|
jobsByType: { chop: number; mine: number; farm: number }
|
||||||
activePaths: number
|
activePaths: number
|
||||||
|
/** Recent actions dispatched through the adapter (newest last). */
|
||||||
|
actionLog: readonly string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Human-readable names for TileType enum values. */
|
/** Human-readable names for TileType enum values. */
|
||||||
@@ -39,6 +42,7 @@ export class DebugSystem {
|
|||||||
private scene: Phaser.Scene
|
private scene: Phaser.Scene
|
||||||
private villagerSystem: VillagerSystem
|
private villagerSystem: VillagerSystem
|
||||||
private worldSystem: WorldSystem
|
private worldSystem: WorldSystem
|
||||||
|
private adapter: LocalAdapter
|
||||||
private pathGraphics!: Phaser.GameObjects.Graphics
|
private pathGraphics!: Phaser.GameObjects.Graphics
|
||||||
private active = false
|
private active = false
|
||||||
|
|
||||||
@@ -46,11 +50,13 @@ export class DebugSystem {
|
|||||||
* @param scene - The Phaser scene this system belongs to
|
* @param scene - The Phaser scene this system belongs to
|
||||||
* @param villagerSystem - Used to read active paths for visualization
|
* @param villagerSystem - Used to read active paths for visualization
|
||||||
* @param worldSystem - Used to read tile types under the mouse
|
* @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.scene = scene
|
||||||
this.villagerSystem = villagerSystem
|
this.villagerSystem = villagerSystem
|
||||||
this.worldSystem = worldSystem
|
this.worldSystem = worldSystem
|
||||||
|
this.adapter = adapter
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -159,6 +165,7 @@ export class DebugSystem {
|
|||||||
nisseByState,
|
nisseByState,
|
||||||
jobsByType,
|
jobsByType,
|
||||||
activePaths: this.villagerSystem.getActivePaths().length,
|
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])
|
this.setTool(TOOL_CYCLE[(idx + 1) % TOOL_CYCLE.length])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tick crop growth
|
// Drain crop growth queue (no delta — gameTime is advanced by GameScene)
|
||||||
const leveled = stateManager.tickCrops(delta)
|
const leveled = stateManager.tickCrops()
|
||||||
for (const id of leveled) this.refreshCropSprite(id)
|
for (const id of leveled) this.refreshCropSprite(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,11 +151,13 @@ export class FarmingSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cfg = CROP_CONFIGS[kind]
|
const cfg = CROP_CONFIGS[kind]
|
||||||
|
const now = stateManager.getGameTime()
|
||||||
const crop: CropState = {
|
const crop: CropState = {
|
||||||
id: `crop_${tileX}_${tileY}_${Date.now()}`,
|
id: `crop_${tileX}_${tileY}_${Date.now()}`,
|
||||||
tileX, tileY, kind,
|
tileX, tileY, kind,
|
||||||
stage: 0, maxStage: cfg.stages,
|
stage: 0, maxStage: cfg.stages,
|
||||||
stageTimerMs: cfg.stageTimeMs,
|
growsAt: now + cfg.stageTimeMs,
|
||||||
|
growsAtWatered: now + cfg.stageTimeMs / 2,
|
||||||
watered: tile === TileType.WATERED_SOIL,
|
watered: tile === TileType.WATERED_SOIL,
|
||||||
}
|
}
|
||||||
this.adapter.send({ type: 'PLANT_CROP', crop, seedItem })
|
this.adapter.send({ type: 'PLANT_CROP', crop, seedItem })
|
||||||
|
|||||||
@@ -47,10 +47,10 @@ export class ResourceSystem {
|
|||||||
sprite.setOrigin(0.5, 0.75)
|
sprite.setOrigin(0.5, 0.75)
|
||||||
}
|
}
|
||||||
|
|
||||||
sprite.setDepth(5)
|
sprite.setDepth(node.tileY + 5)
|
||||||
|
|
||||||
const healthBar = this.scene.add.graphics()
|
const healthBar = this.scene.add.graphics()
|
||||||
healthBar.setDepth(6)
|
healthBar.setDepth(node.tileY + 6)
|
||||||
healthBar.setVisible(false)
|
healthBar.setVisible(false)
|
||||||
|
|
||||||
this.sprites.set(node.id, { sprite, node, healthBar })
|
this.sprites.set(node.id, { sprite, node, healthBar })
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ export class TreeSeedlingSystem {
|
|||||||
* @param delta - Frame delta in milliseconds
|
* @param delta - Frame delta in milliseconds
|
||||||
*/
|
*/
|
||||||
update(delta: number): void {
|
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) {
|
for (const id of advanced) {
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
const seedling = state.world.treeSeedlings[id]
|
const seedling = state.world.treeSeedlings[id]
|
||||||
@@ -91,7 +92,7 @@ export class TreeSeedlingSystem {
|
|||||||
const seedling: TreeSeedlingState = {
|
const seedling: TreeSeedlingState = {
|
||||||
id, tileX, tileY,
|
id, tileX, tileY,
|
||||||
stage: 0,
|
stage: 0,
|
||||||
stageTimerMs: TREE_SEEDLING_STAGE_MS,
|
growsAt: stateManager.getGameTime() + TREE_SEEDLING_STAGE_MS,
|
||||||
underlyingTile,
|
underlyingTile,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +111,7 @@ export class TreeSeedlingSystem {
|
|||||||
const key = `seedling_${Math.min(s.stage, 2)}`
|
const key = `seedling_${Math.min(s.stage, 2)}`
|
||||||
const sprite = this.scene.add.image(x, y, key)
|
const sprite = this.scene.add.image(x, y, key)
|
||||||
.setOrigin(0.5, 0.85)
|
.setOrigin(0.5, 0.85)
|
||||||
.setDepth(5)
|
.setDepth(s.tileY + 5)
|
||||||
this.sprites.set(s.id, sprite)
|
this.sprites.set(s.id, sprite)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Phaser from 'phaser'
|
import Phaser from 'phaser'
|
||||||
import { TILE_SIZE, VILLAGER_SPEED, VILLAGER_SPAWN_INTERVAL, VILLAGER_WORK_TIMES, VILLAGER_NAMES, WORLD_TILES } from '../config'
|
import { TILE_SIZE, VILLAGER_SPEED, VILLAGER_SPAWN_INTERVAL, VILLAGER_WORK_TIMES, VILLAGER_NAMES, WORLD_TILES, MINE_CAPACITY, MINE_WORK_MS, MINE_STONE_YIELD } from '../config'
|
||||||
import { TileType, PLANTABLE_TILES } from '../types'
|
import { TileType, PLANTABLE_TILES } 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'
|
||||||
@@ -13,6 +13,9 @@ const ARRIVAL_PX = 3
|
|||||||
|
|
||||||
const WORK_LOG_MAX = 20
|
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 {
|
interface VillagerRuntime {
|
||||||
sprite: Phaser.GameObjects.Image
|
sprite: Phaser.GameObjects.Image
|
||||||
nameLabel: Phaser.GameObjects.Text
|
nameLabel: Phaser.GameObjects.Text
|
||||||
@@ -34,7 +37,8 @@ export class VillagerSystem {
|
|||||||
private farmingSystem!: FarmingSystem
|
private farmingSystem!: FarmingSystem
|
||||||
|
|
||||||
private runtime = new Map<string, VillagerRuntime>()
|
private runtime = new Map<string, VillagerRuntime>()
|
||||||
private claimed = new Set<string>() // target IDs currently claimed by a villager
|
private claimed = new Set<string>() // target IDs currently claimed (resources, crops, etc.)
|
||||||
|
private mineClaimsMap = new Map<string, number>() // mine building ID → number of claimed slots
|
||||||
private spawnTimer = 0
|
private spawnTimer = 0
|
||||||
private nameIndex = 0
|
private nameIndex = 0
|
||||||
|
|
||||||
@@ -69,6 +73,48 @@ export class VillagerSystem {
|
|||||||
this.farmingSystem = farmingSystem
|
this.farmingSystem = farmingSystem
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Claim helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claims a job target. Mine buildings use a capacity counter (up to MINE_CAPACITY);
|
||||||
|
* all other targets use the exclusive claimed Set.
|
||||||
|
* @param targetId - The resource, crop, or building ID being claimed
|
||||||
|
*/
|
||||||
|
private claimTarget(targetId: string): void {
|
||||||
|
if (stateManager.getState().world.buildings[targetId]?.kind === 'mine') {
|
||||||
|
this.mineClaimsMap.set(targetId, (this.mineClaimsMap.get(targetId) ?? 0) + 1)
|
||||||
|
} else {
|
||||||
|
this.claimed.add(targetId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases a job target claim.
|
||||||
|
* @param targetId - The previously claimed target ID
|
||||||
|
*/
|
||||||
|
private releaseTarget(targetId: string): void {
|
||||||
|
if (stateManager.getState().world.buildings[targetId]?.kind === 'mine') {
|
||||||
|
const n = this.mineClaimsMap.get(targetId) ?? 0
|
||||||
|
if (n <= 1) this.mineClaimsMap.delete(targetId)
|
||||||
|
else this.mineClaimsMap.set(targetId, n - 1)
|
||||||
|
} else {
|
||||||
|
this.claimed.delete(targetId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given target is fully claimed.
|
||||||
|
* Mine buildings are claimed when their worker count reaches MINE_CAPACITY.
|
||||||
|
* All other targets are claimed exclusively.
|
||||||
|
* @param targetId - The target ID to check
|
||||||
|
*/
|
||||||
|
private isTargetClaimed(targetId: string): boolean {
|
||||||
|
if (stateManager.getState().world.buildings[targetId]?.kind === 'mine') {
|
||||||
|
return (this.mineClaimsMap.get(targetId) ?? 0) >= MINE_CAPACITY
|
||||||
|
}
|
||||||
|
return this.claimed.has(targetId)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spawns sprites for all Nisse that exist in the saved state
|
* Spawns sprites for all Nisse that exist in the saved state
|
||||||
* and re-claims any active job targets.
|
* and re-claims any active job targets.
|
||||||
@@ -78,7 +124,7 @@ export class VillagerSystem {
|
|||||||
for (const v of Object.values(state.world.villagers)) {
|
for (const v of Object.values(state.world.villagers)) {
|
||||||
this.spawnSprite(v)
|
this.spawnSprite(v)
|
||||||
// Re-claim any active job targets
|
// Re-claim any active job targets
|
||||||
if (v.job) this.claimed.add(v.job.targetId)
|
if (v.job) this.claimTarget(v.job.targetId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,15 +164,15 @@ export class VillagerSystem {
|
|||||||
case 'sleeping':this.tickSleeping(v, rt, delta); break
|
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.sprite.setPosition(v.x, v.y)
|
||||||
|
|
||||||
rt.nameLabel.setPosition(v.x, v.y - 22)
|
rt.nameLabel.setPosition(v.x, v.y - 22)
|
||||||
rt.energyBar.setPosition(0, 0)
|
rt.energyBar.setPosition(0, 0)
|
||||||
this.drawEnergyBar(rt.energyBar, v.x, v.y, v.energy)
|
this.drawEnergyBar(rt.energyBar, v.x, v.y, v.energy)
|
||||||
|
|
||||||
// Job icon
|
// Job icon
|
||||||
const icons: Record<string, string> = { chop: '🪓', mine: '⛏', farm: '🌾', forester: '🌲', '': '' }
|
const jobType = v.aiState === 'sleeping' ? '💤' : (v.job ? (JOB_ICONS[v.job.type] ?? '') : '')
|
||||||
const jobType = v.aiState === 'sleeping' ? '💤' : (v.job ? (icons[v.job.type] ?? '') : '')
|
|
||||||
rt.jobIcon.setText(jobType).setPosition(v.x + 10, v.y - 18)
|
rt.jobIcon.setText(jobType).setPosition(v.x + 10, v.y - 18)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +216,7 @@ export class VillagerSystem {
|
|||||||
// Find a job
|
// Find a job
|
||||||
const job = this.pickJob(v)
|
const job = this.pickJob(v)
|
||||||
if (job) {
|
if (job) {
|
||||||
this.claimed.add(job.targetId)
|
this.claimTarget(job.targetId)
|
||||||
this.adapter.send({
|
this.adapter.send({
|
||||||
type: 'VILLAGER_SET_JOB', villagerId: v.id,
|
type: 'VILLAGER_SET_JOB', villagerId: v.id,
|
||||||
job: { type: job.type, targetId: job.targetId, tileX: job.tileX, tileY: job.tileY, carrying: {} },
|
job: { type: job.type, targetId: job.targetId, tileX: job.tileX, tileY: job.tileY, carrying: {} },
|
||||||
@@ -229,10 +275,20 @@ export class VillagerSystem {
|
|||||||
*/
|
*/
|
||||||
private onArrived(v: VillagerState, rt: VillagerRuntime): void {
|
private onArrived(v: VillagerState, rt: VillagerRuntime): void {
|
||||||
switch (rt.destination) {
|
switch (rt.destination) {
|
||||||
case 'job':
|
case 'job': {
|
||||||
rt.workTimer = VILLAGER_WORK_TIMES[v.job?.type ?? 'chop'] ?? 3000
|
// Mine buildings take longer than surface-rock mining and hide the Nisse sprite.
|
||||||
|
const isMineBuilding = v.job?.type === 'mine' &&
|
||||||
|
stateManager.getState().world.buildings[v.job.targetId]?.kind === 'mine'
|
||||||
|
rt.workTimer = isMineBuilding ? MINE_WORK_MS : (VILLAGER_WORK_TIMES[v.job?.type ?? 'chop'] ?? 3000)
|
||||||
|
if (isMineBuilding) {
|
||||||
|
rt.sprite.setVisible(false)
|
||||||
|
rt.nameLabel.setVisible(false)
|
||||||
|
rt.energyBar.setVisible(false)
|
||||||
|
rt.jobIcon.setVisible(false)
|
||||||
|
}
|
||||||
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'working' })
|
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'working' })
|
||||||
break
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case 'stockpile':
|
case 'stockpile':
|
||||||
this.adapter.send({ type: 'VILLAGER_DEPOSIT', villagerId: v.id })
|
this.adapter.send({ type: 'VILLAGER_DEPOSIT', villagerId: v.id })
|
||||||
@@ -273,7 +329,7 @@ export class VillagerSystem {
|
|||||||
const job = v.job
|
const job = v.job
|
||||||
if (!job) { this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' }); return }
|
if (!job) { this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' }); return }
|
||||||
|
|
||||||
this.claimed.delete(job.targetId)
|
this.releaseTarget(job.targetId)
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
|
|
||||||
if (job.type === 'chop') {
|
if (job.type === 'chop') {
|
||||||
@@ -290,13 +346,27 @@ export class VillagerSystem {
|
|||||||
this.addLog(v.id, `✓ Chopped tree (+2 wood, +${seeds} tree seed)`)
|
this.addLog(v.id, `✓ Chopped tree (+2 wood, +${seeds} tree seed)`)
|
||||||
}
|
}
|
||||||
} else if (job.type === 'mine') {
|
} else if (job.type === 'mine') {
|
||||||
const res = state.world.resources[job.targetId]
|
const building = state.world.buildings[job.targetId]
|
||||||
if (res) {
|
if (building?.kind === 'mine') {
|
||||||
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
|
// Mine building: yield stone directly into carrying, then show the Nisse again
|
||||||
this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.GRASS })
|
const mutableJob = v.job as { carrying: Partial<Record<'stone', number>> }
|
||||||
this.worldSystem.removeResourceTile(res.tileX, res.tileY)
|
mutableJob.carrying.stone = (mutableJob.carrying.stone ?? 0) + MINE_STONE_YIELD
|
||||||
this.resourceSystem.removeResource(job.targetId)
|
rt.sprite.setVisible(true)
|
||||||
this.addLog(v.id, '✓ Mined rock (+2 stone)')
|
rt.nameLabel.setVisible(true)
|
||||||
|
rt.energyBar.setVisible(true)
|
||||||
|
rt.jobIcon.setVisible(true)
|
||||||
|
this.addLog(v.id, `✓ Mined (+${MINE_STONE_YIELD} stone) at mine`)
|
||||||
|
} else {
|
||||||
|
// Surface rock: ROCK tile stays ROCK after mining
|
||||||
|
const res = state.world.resources[job.targetId]
|
||||||
|
if (res) {
|
||||||
|
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
|
||||||
|
// ROCK tile stays ROCK after mining — empty rocky ground remains passable
|
||||||
|
// and valid for mine building placement.
|
||||||
|
this.worldSystem.removeResourceTile(res.tileX, res.tileY)
|
||||||
|
this.resourceSystem.removeResource(job.targetId)
|
||||||
|
this.addLog(v.id, '✓ Mined rock (+2 stone)')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (job.type === 'farm') {
|
} else if (job.type === 'farm') {
|
||||||
const crop = state.world.crops[job.targetId]
|
const crop = state.world.crops[job.targetId]
|
||||||
@@ -376,21 +446,28 @@ export class VillagerSystem {
|
|||||||
const vTY = Math.floor(v.y / TILE_SIZE)
|
const vTY = Math.floor(v.y / TILE_SIZE)
|
||||||
const dist = (tx: number, ty: number) => Math.abs(tx - vTX) + Math.abs(ty - vTY)
|
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 }
|
type C = { type: JobType; targetId: string; tileX: number; tileY: number; dist: number; pri: number }
|
||||||
const candidates: C[] = []
|
const candidates: C[] = []
|
||||||
|
|
||||||
if (p.chop > 0) {
|
if (p.chop > 0) {
|
||||||
// Build the set of all tiles belonging to forester zones for chop priority
|
// Build the set of all tiles belonging to forester zones for chop priority
|
||||||
const zoneTiles = new Set<string>()
|
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)
|
for (const key of zone.tiles) zoneTiles.add(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
const zoneChop: C[] = []
|
const zoneChop: C[] = []
|
||||||
const naturalChop: 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
|
if (res.kind !== 'tree' || this.isTargetClaimed(res.id)) continue
|
||||||
// Skip trees with no reachable neighbour — A* cannot reach them.
|
// Skip trees with no reachable neighbour — A* cannot reach them.
|
||||||
if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue
|
if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue
|
||||||
const c: C = { type: 'chop', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.chop }
|
const c: C = { type: 'chop', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.chop }
|
||||||
@@ -405,8 +482,15 @@ export class VillagerSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (p.mine > 0) {
|
if (p.mine > 0) {
|
||||||
for (const res of Object.values(state.world.resources)) {
|
// Mine buildings: walk to entrance tile (tileX+1, tileY+1) and work inside
|
||||||
if (res.kind !== 'rock' || this.claimed.has(res.id)) continue
|
for (const b of buildings) {
|
||||||
|
if (b.kind !== 'mine' || this.isTargetClaimed(b.id)) continue
|
||||||
|
const eTileX = b.tileX + 1, eTileY = b.tileY + 1
|
||||||
|
candidates.push({ type: 'mine', targetId: b.id, tileX: eTileX, tileY: eTileY, dist: dist(eTileX, eTileY), pri: p.mine })
|
||||||
|
}
|
||||||
|
// Surface rocks (still valid without a mine building)
|
||||||
|
for (const res of resources) {
|
||||||
|
if (res.kind !== 'rock' || this.isTargetClaimed(res.id)) continue
|
||||||
// Same reachability guard for rock tiles.
|
// Same reachability guard for rock tiles.
|
||||||
if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue
|
if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue
|
||||||
candidates.push({ type: 'mine', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.mine })
|
candidates.push({ type: 'mine', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.mine })
|
||||||
@@ -414,28 +498,28 @@ export class VillagerSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (p.farm > 0) {
|
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
|
if (crop.stage < crop.maxStage || this.isTargetClaimed(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 })
|
candidates.push({ type: 'farm', targetId: crop.id, tileX: crop.tileX, tileY: crop.tileY, dist: dist(crop.tileX, crop.tileY), pri: p.farm })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (p.forester > 0 && (state.world.stockpile.tree_seed ?? 0) > 0) {
|
if (p.forester > 0 && (state.world.stockpile.tree_seed ?? 0) > 0) {
|
||||||
// Find empty plantable zone tiles to seed
|
// 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) {
|
for (const key of zone.tiles) {
|
||||||
const [tx, ty] = key.split(',').map(Number)
|
const [tx, ty] = key.split(',').map(Number)
|
||||||
const targetId = `forester_tile_${tx}_${ty}`
|
const targetId = `forester_tile_${tx}_${ty}`
|
||||||
if (this.claimed.has(targetId)) continue
|
if (this.isTargetClaimed(targetId)) continue
|
||||||
// Skip if tile is not plantable
|
// Skip if tile is not plantable
|
||||||
const tileType = state.world.tiles[ty * WORLD_TILES + tx] as TileType
|
const tileType = state.world.tiles[ty * WORLD_TILES + tx] as TileType
|
||||||
if (!PLANTABLE_TILES.has(tileType)) continue
|
if (!PLANTABLE_TILES.has(tileType)) continue
|
||||||
// Skip if something occupies this tile
|
// Skip if something occupies this tile — reuse already-extracted arrays
|
||||||
const occupied =
|
const occupied =
|
||||||
Object.values(state.world.resources).some(r => r.tileX === tx && r.tileY === ty) ||
|
resources.some(r => r.tileX === tx && r.tileY === ty) ||
|
||||||
Object.values(state.world.buildings).some(b => b.tileX === tx && b.tileY === ty) ||
|
buildings.some(b => b.tileX === tx && b.tileY === ty) ||
|
||||||
Object.values(state.world.crops).some(c => c.tileX === tx && c.tileY === ty) ||
|
crops.some(c => c.tileX === tx && c.tileY === ty) ||
|
||||||
Object.values(state.world.treeSeedlings).some(s => s.tileX === tx && s.tileY === ty)
|
seedlings.some(s => s.tileX === tx && s.tileY === ty)
|
||||||
if (occupied) continue
|
if (occupied) continue
|
||||||
candidates.push({ type: 'forester', targetId, tileX: tx, tileY: ty, dist: dist(tx, ty), pri: p.forester })
|
candidates.push({ type: 'forester', targetId, tileX: tx, tileY: ty, dist: dist(tx, ty), pri: p.forester })
|
||||||
}
|
}
|
||||||
@@ -444,8 +528,9 @@ export class VillagerSystem {
|
|||||||
|
|
||||||
if (candidates.length === 0) return null
|
if (candidates.length === 0) return null
|
||||||
|
|
||||||
// Lowest priority number wins; ties broken by distance
|
// Lowest priority number wins; ties broken by distance — avoid spread+map allocation
|
||||||
const bestPri = Math.min(...candidates.map(c => c.pri))
|
let bestPri = candidates[0].pri
|
||||||
|
for (let i = 1; i < candidates.length; i++) if (candidates[i].pri < bestPri) bestPri = candidates[i].pri
|
||||||
return candidates
|
return candidates
|
||||||
.filter(c => c.pri === bestPri)
|
.filter(c => c.pri === bestPri)
|
||||||
.sort((a, b) => a.dist - b.dist)[0] ?? null
|
.sort((a, b) => a.dist - b.dist)[0] ?? null
|
||||||
@@ -469,7 +554,7 @@ export class VillagerSystem {
|
|||||||
|
|
||||||
if (!path) {
|
if (!path) {
|
||||||
if (v.job) {
|
if (v.job) {
|
||||||
this.claimed.delete(v.job.targetId)
|
this.releaseTarget(v.job.targetId)
|
||||||
this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: null })
|
this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: null })
|
||||||
}
|
}
|
||||||
rt.idleScanTimer = 4000 // longer delay after failed pathfind to avoid tight retry loops
|
rt.idleScanTimer = 4000 // longer delay after failed pathfind to avoid tight retry loops
|
||||||
@@ -569,16 +654,23 @@ export class VillagerSystem {
|
|||||||
* for a newly added Nisse.
|
* for a newly added Nisse.
|
||||||
* @param v - Villager state to create sprites for
|
* @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 {
|
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, {
|
const nameLabel = this.scene.add.text(v.x, v.y - 22, v.name, {
|
||||||
fontSize: '8px', color: '#ffffff', fontFamily: 'monospace',
|
fontSize: '8px', color: '#ffffff', fontFamily: 'monospace',
|
||||||
backgroundColor: '#00000088', padding: { x: 2, y: 1 },
|
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 energyBar = this.scene.add.graphics().setDepth(901)
|
||||||
const jobIcon = this.scene.add.text(v.x, v.y - 18, '', { fontSize: '10px' }).setDepth(13)
|
const jobIcon = this.scene.add.text(v.x, v.y - 18, '', { fontSize: '10px' }).setDepth(902)
|
||||||
|
|
||||||
sprite.setInteractive()
|
sprite.setInteractive()
|
||||||
sprite.on('pointerdown', () => this.onNisseClick?.(v.id))
|
sprite.on('pointerdown', () => this.onNisseClick?.(v.id))
|
||||||
@@ -667,6 +759,41 @@ export class VillagerSystem {
|
|||||||
return result
|
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.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Rescues all Nisse that were working inside a demolished building.
|
||||||
|
* Makes hidden sprites visible again, clears their jobs, and resets AI to idle.
|
||||||
|
* Also releases any mine-capacity claims for that building.
|
||||||
|
* @param buildingId - ID of the building that was demolished
|
||||||
|
*/
|
||||||
|
rescueNisseFromBuilding(buildingId: string): void {
|
||||||
|
this.mineClaimsMap.delete(buildingId)
|
||||||
|
const state = stateManager.getState()
|
||||||
|
for (const v of Object.values(state.world.villagers)) {
|
||||||
|
if (v.job?.targetId !== buildingId) continue
|
||||||
|
const rt = this.runtime.get(v.id)
|
||||||
|
if (!rt) continue
|
||||||
|
// Make sprite visible in case the Nisse was hidden inside the mine
|
||||||
|
rt.sprite.setVisible(true)
|
||||||
|
rt.nameLabel.setVisible(true)
|
||||||
|
rt.energyBar.setVisible(true)
|
||||||
|
rt.jobIcon.setVisible(true)
|
||||||
|
rt.workTimer = 0
|
||||||
|
rt.idleScanTimer = 0
|
||||||
|
this.claimed.delete(buildingId)
|
||||||
|
this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: null })
|
||||||
|
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
|
||||||
|
this.addLog(v.id, '! Building demolished — resuming')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Destroys all Nisse sprites and clears the runtime map.
|
* Destroys all Nisse sprites and clears the runtime map.
|
||||||
* Should be called when the scene shuts down.
|
* Should be called when the scene shuts down.
|
||||||
|
|||||||
@@ -172,6 +172,16 @@ export class WorldSystem {
|
|||||||
this.resourceTiles.delete(tileY * WORLD_TILES + tileX)
|
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.
|
* Converts world pixel coordinates to tile coordinates.
|
||||||
* @param worldX - World X in pixels
|
* @param worldX - World X in pixels
|
||||||
|
|||||||
20
src/types.ts
20
src/types.ts
@@ -32,7 +32,7 @@ export const PLANTABLE_TILES = new Set<TileType>([TileType.GRASS, TileType.DARK_
|
|||||||
|
|
||||||
export type ItemId = 'wood' | 'stone' | 'wheat_seed' | 'carrot_seed' | 'wheat' | 'carrot' | 'tree_seed'
|
export type ItemId = 'wood' | 'stone' | 'wheat_seed' | 'carrot_seed' | 'wheat' | 'carrot' | 'tree_seed'
|
||||||
|
|
||||||
export type BuildingType = 'floor' | 'wall' | 'chest' | 'bed' | 'stockpile_zone' | 'forester_hut'
|
export type BuildingType = 'floor' | 'wall' | 'chest' | 'bed' | 'stockpile_zone' | 'forester_hut' | 'mine'
|
||||||
|
|
||||||
export type CropKind = 'wheat' | 'carrot'
|
export type CropKind = 'wheat' | 'carrot'
|
||||||
|
|
||||||
@@ -81,6 +81,8 @@ export interface BuildingState {
|
|||||||
tileY: number
|
tileY: number
|
||||||
kind: BuildingType
|
kind: BuildingType
|
||||||
ownerId: string
|
ownerId: string
|
||||||
|
/** In-game time (ms) when the building was placed. Used for demolish refund calculation. */
|
||||||
|
builtAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CropState {
|
export interface CropState {
|
||||||
@@ -90,7 +92,11 @@ export interface CropState {
|
|||||||
kind: CropKind
|
kind: CropKind
|
||||||
stage: number
|
stage: number
|
||||||
maxStage: 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
|
watered: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,8 +113,8 @@ export interface TreeSeedlingState {
|
|||||||
tileY: number
|
tileY: number
|
||||||
/** Growth stage: 0 = sprout, 1 = sapling, 2 = mature (converts to resource). */
|
/** Growth stage: 0 = sprout, 1 = sapling, 2 = mature (converts to resource). */
|
||||||
stage: number
|
stage: number
|
||||||
/** Time remaining until next stage advance, in milliseconds. */
|
/** gameTime (ms) when this seedling advances to the next stage. */
|
||||||
stageTimerMs: number
|
growsAt: number
|
||||||
/** The tile type that was under the seedling when planted (GRASS or DARK_GRASS). */
|
/** The tile type that was under the seedling when planted (GRASS or DARK_GRASS). */
|
||||||
underlyingTile: TileType
|
underlyingTile: TileType
|
||||||
}
|
}
|
||||||
@@ -125,6 +131,8 @@ export interface ForesterZoneState {
|
|||||||
|
|
||||||
export interface WorldState {
|
export interface WorldState {
|
||||||
seed: number
|
seed: number
|
||||||
|
/** Accumulated in-game time in milliseconds. Used as the clock for all event-queue timers. */
|
||||||
|
gameTime: number
|
||||||
tiles: number[]
|
tiles: number[]
|
||||||
resources: Record<string, ResourceNodeState>
|
resources: Record<string, ResourceNodeState>
|
||||||
buildings: Record<string, BuildingState>
|
buildings: Record<string, BuildingState>
|
||||||
@@ -134,8 +142,8 @@ export interface WorldState {
|
|||||||
/** Planted tree seedlings, keyed by ID. */
|
/** Planted tree seedlings, keyed by ID. */
|
||||||
treeSeedlings: Record<string, TreeSeedlingState>
|
treeSeedlings: Record<string, TreeSeedlingState>
|
||||||
/**
|
/**
|
||||||
* Recovery timers for DARK_GRASS tiles, keyed by "tileX,tileY".
|
* Tile recovery fire-times, keyed by "tileX,tileY".
|
||||||
* Value is remaining milliseconds until the tile reverts to GRASS.
|
* Value is the gameTime (ms) at which the tile reverts to GRASS.
|
||||||
*/
|
*/
|
||||||
tileRecovery: Record<string, number>
|
tileRecovery: Record<string, number>
|
||||||
/** Forester zone definitions, keyed by forester_hut building ID. */
|
/** Forester zone definitions, keyed by forester_hut building ID. */
|
||||||
|
|||||||
Reference in New Issue
Block a user