Compare commits
16 Commits
feature/ni
...
fix/change
| Author | SHA1 | Date | |
|---|---|---|---|
| b024cf36fb | |||
| 8197348cfc | |||
| 732d9100ab | |||
| f2a1811a36 | |||
| 774054db56 | |||
| 0ed3bfaea6 | |||
| 1d46446012 | |||
| a5c37f20f6 | |||
| 174db14c7a | |||
| c7ebf49bf2 | |||
| b259d966ee | |||
| 9b22f708a5 | |||
| a0e813e86b | |||
| 18c8ccb644 | |||
| bbbb3e1f58 | |||
| 822ca620d9 |
20
CHANGELOG.md
20
CHANGELOG.md
@@ -7,6 +7,26 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Nisse idle loop** (Issue #22): Nisse no longer retry unreachable trees/rocks in an infinite 1.5 s loop — `pickJob` now skips resources with no adjacent passable tile via `hasAdjacentPassable()`; pathfind-fail cooldown raised to 4 s
|
||||
- **Resource-based passability** (Issue #22): FOREST and ROCK terrain tiles are only impassable when a tree/rock resource occupies them — empty forest floor and rocky ground are now walkable; `WorldSystem` maintains an O(1) `resourceTiles` index kept in sync at runtime
|
||||
- **Terrain canvas not updating** (Issue #22): `CHANGE_TILE` now calls `refreshTerrainTile()` centrally via the adapter handler, fixing the visual glitch where chopped trees left a dark FOREST-coloured pixel instead of DARK_GRASS
|
||||
- **Stockpile panel** (Issue #20): panel background now uses `uiOpacity` and updates live when Settings opacity changes; panel height increased so the Nisse count row no longer overlaps the carrot row
|
||||
- **ESC menu** (Issue #20): internal bottom padding corrected — last button now has 16px gap to panel edge instead of 0px
|
||||
|
||||
### Added
|
||||
- **Overlay Opacity Setting** (Issue #16): all UI overlay backgrounds (build menu, villager panel, context menu, ESC menu, confirm dialog, Nisse info panel, debug panel) now use a central `uiOpacity` value instead of hardcoded alphas
|
||||
- **Settings Screen**: ESC menu → Settings now opens a real overlay with an overlay-opacity row (− / value% / + step buttons, range 40 %–100 %, default 80 %); setting persisted in `localStorage` under `tg_ui_settings`, separate from game save so New Game does not wipe it
|
||||
|
||||
### Added
|
||||
- **Unified Tile System** (Issue #14):
|
||||
- Tree seedlings: player plants `tree_seed` on grass/dark-grass via the F-key farming tool; seedling grows through two stages (sprout → sapling → young tree, ~1 min each); on maturity it becomes a FOREST tile with a harvestable tree resource
|
||||
- Tile recovery: when a Nisse chops a tree, the resulting DARK_GRASS tile starts a 5-minute recovery timer and reverts to GRASS automatically, with the terrain canvas updated in real time
|
||||
- Three new procedural seedling textures (`seedling_0/1/2`) generated in BootScene
|
||||
- `tree_seed` added to stockpile display (default 5 at game start) and to the farming tool cycle
|
||||
- `WorldSystem.refreshTerrainTile()` updates the terrain canvas for a single tile without regenerating the full background
|
||||
- New `TreeSeedlingSystem` manages seedling sprites, growth ticking, and maturation
|
||||
|
||||
### Added
|
||||
- **Nisse Info Panel** (Issue #9): clicking a Nisse opens a top-left panel with name, AI status, energy bar, active job, job priority buttons, and a live work log (last 10 of 20 runtime entries); closes with ESC, ✕ button, or by clicking another Nisse
|
||||
- Work log tracks: walking to job, hauling to stockpile, going to sleep, waking up, chopped/mined/farmed results, deposited at stockpile
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SAVE_KEY, WORLD_TILES, CROP_CONFIGS } from './config'
|
||||
import { SAVE_KEY, WORLD_TILES, CROP_CONFIGS, TREE_SEEDLING_STAGE_MS, TILE_RECOVERY_MS } from './config'
|
||||
import type { GameStateData, GameAction, PlayerState, WorldState, ItemId } from './types'
|
||||
import { TileType } from './types'
|
||||
|
||||
const DEFAULT_PLAYER: PlayerState = {
|
||||
id: 'player1',
|
||||
@@ -15,13 +16,15 @@ function makeEmptyWorld(seed: number): WorldState {
|
||||
buildings: {},
|
||||
crops: {},
|
||||
villagers: {},
|
||||
stockpile: { wood: 10, stone: 5, wheat_seed: 10, carrot_seed: 5, wheat: 0, carrot: 0 },
|
||||
stockpile: { wood: 10, stone: 5, wheat_seed: 10, carrot_seed: 5, wheat: 0, carrot: 0, tree_seed: 5 },
|
||||
treeSeedlings: {},
|
||||
tileRecovery: {},
|
||||
}
|
||||
}
|
||||
|
||||
function makeDefaultState(): GameStateData {
|
||||
return {
|
||||
version: 4,
|
||||
version: 5,
|
||||
world: makeEmptyWorld(Math.floor(Math.random() * 999999)),
|
||||
player: { ...DEFAULT_PLAYER, inventory: { ...DEFAULT_PLAYER.inventory } },
|
||||
}
|
||||
@@ -146,6 +149,26 @@ class StateManager {
|
||||
if (v) v.priorities = { ...action.priorities }
|
||||
break
|
||||
}
|
||||
|
||||
case 'PLANT_TREE_SEED': {
|
||||
w.treeSeedlings[action.seedling.id] = { ...action.seedling }
|
||||
w.stockpile.tree_seed = Math.max(0, (w.stockpile.tree_seed ?? 0) - 1)
|
||||
// Cancel any tile recovery on this tile
|
||||
delete w.tileRecovery[`${action.seedling.tileX},${action.seedling.tileY}`]
|
||||
break
|
||||
}
|
||||
|
||||
case 'REMOVE_TREE_SEEDLING':
|
||||
delete w.treeSeedlings[action.seedlingId]
|
||||
break
|
||||
|
||||
case 'SPAWN_RESOURCE':
|
||||
w.resources[action.resource.id] = { ...action.resource }
|
||||
break
|
||||
|
||||
case 'TILE_RECOVERY_START':
|
||||
w.tileRecovery[`${action.tileX},${action.tileY}`] = TILE_RECOVERY_MS
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +186,47 @@ class StateManager {
|
||||
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 {
|
||||
try { localStorage.setItem(SAVE_KEY, JSON.stringify(this.state)) } catch (_) {}
|
||||
}
|
||||
@@ -172,10 +236,12 @@ class StateManager {
|
||||
const raw = localStorage.getItem(SAVE_KEY)
|
||||
if (!raw) return null
|
||||
const p = JSON.parse(raw) as GameStateData
|
||||
if (p.version !== 4) return null
|
||||
if (!p.world.crops) p.world.crops = {}
|
||||
if (!p.world.villagers) p.world.villagers = {}
|
||||
if (!p.world.stockpile) p.world.stockpile = {}
|
||||
if (p.version !== 5) return null
|
||||
if (!p.world.crops) p.world.crops = {}
|
||||
if (!p.world.villagers) p.world.villagers = {}
|
||||
if (!p.world.stockpile) p.world.stockpile = {}
|
||||
if (!p.world.treeSeedlings) p.world.treeSeedlings = {}
|
||||
if (!p.world.tileRecovery) p.world.tileRecovery = {}
|
||||
// Reset in-flight AI states to idle on load so runtime timers start fresh
|
||||
for (const v of Object.values(p.world.villagers)) {
|
||||
if (v.aiState === 'walking' || v.aiState === 'working') v.aiState = 'idle'
|
||||
|
||||
@@ -46,5 +46,14 @@ export const VILLAGER_NAMES = [
|
||||
'Orla','Pike','Quinn','Rook','Sera','Tull','Uma','Vex',
|
||||
]
|
||||
|
||||
export const SAVE_KEY = 'tg_save_v4'
|
||||
export const SAVE_KEY = 'tg_save_v5'
|
||||
export const AUTOSAVE_INTERVAL = 30_000
|
||||
|
||||
/** localStorage key for UI settings (opacity etc.) — separate from the game save. */
|
||||
export const UI_SETTINGS_KEY = 'tg_ui_settings'
|
||||
|
||||
/** Milliseconds for one tree-seedling stage to advance (two stages = full tree). */
|
||||
export const TREE_SEEDLING_STAGE_MS = 60_000 // 1 min per stage → 2 min total
|
||||
|
||||
/** Milliseconds before a bare DARK_GRASS tile (after tree felling) reverts to GRASS. */
|
||||
export const TILE_RECOVERY_MS = 300_000 // 5 minutes
|
||||
|
||||
@@ -20,6 +20,7 @@ export class BootScene extends Phaser.Scene {
|
||||
this.buildResourceTextures()
|
||||
this.buildPlayerTexture()
|
||||
this.buildCropTextures()
|
||||
this.buildSeedlingTextures()
|
||||
this.buildUITextures()
|
||||
this.buildVillagerAndBuildingTextures()
|
||||
this.generateWorldIfNeeded()
|
||||
@@ -287,6 +288,40 @@ export class BootScene extends Phaser.Scene {
|
||||
g3.generateTexture('crop_carrot_3', W, H); g3.destroy()
|
||||
}
|
||||
|
||||
// ─── Tree seedling textures (3 growth stages) ────────────────────────────
|
||||
|
||||
/**
|
||||
* Generates textures for the three tree-seedling growth stages:
|
||||
* seedling_0 – small sprout
|
||||
* seedling_1 – sapling with leaves
|
||||
* seedling_2 – young tree (about to mature into a FOREST tile)
|
||||
*/
|
||||
private buildSeedlingTextures(): void {
|
||||
// Stage 0: tiny sprout
|
||||
const g0 = this.add.graphics()
|
||||
g0.fillStyle(0x6D4C41); g0.fillRect(10, 20, 4, 10)
|
||||
g0.fillStyle(0x66BB6A); g0.fillEllipse(12, 16, 12, 8)
|
||||
g0.fillStyle(0x4CAF50); g0.fillEllipse(12, 13, 8, 6)
|
||||
g0.generateTexture('seedling_0', 24, 32); g0.destroy()
|
||||
|
||||
// Stage 1: sapling
|
||||
const g1 = this.add.graphics()
|
||||
g1.fillStyle(0x6D4C41); g1.fillRect(9, 15, 5, 16)
|
||||
g1.fillStyle(0x4CAF50); g1.fillCircle(12, 12, 8)
|
||||
g1.fillStyle(0x66BB6A, 0.7); g1.fillCircle(7, 16, 5); g1.fillCircle(17, 16, 5)
|
||||
g1.fillStyle(0x81C784); g1.fillCircle(12, 8, 5)
|
||||
g1.generateTexture('seedling_1', 24, 32); g1.destroy()
|
||||
|
||||
// Stage 2: young tree (mature, ready to become a resource)
|
||||
const g2 = this.add.graphics()
|
||||
g2.fillStyle(0x000000, 0.15); g2.fillEllipse(12, 28, 16, 6)
|
||||
g2.fillStyle(0x6D4C41); g2.fillRect(9, 14, 6, 14)
|
||||
g2.fillStyle(0x2E7D32); g2.fillCircle(12, 9, 10)
|
||||
g2.fillStyle(0x388E3C); g2.fillCircle(7, 13, 7); g2.fillCircle(17, 13, 7)
|
||||
g2.fillStyle(0x43A047); g2.fillCircle(12, 6, 7)
|
||||
g2.generateTexture('seedling_2', 24, 32); g2.destroy()
|
||||
}
|
||||
|
||||
// ─── UI panel texture ─────────────────────────────────────────────────────
|
||||
|
||||
private buildUITextures(): void {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Phaser from 'phaser'
|
||||
import { AUTOSAVE_INTERVAL, TILE_SIZE } from '../config'
|
||||
import { TileType } from '../types'
|
||||
import type { BuildingType } from '../types'
|
||||
import { stateManager } from '../StateManager'
|
||||
import { LocalAdapter } from '../NetworkAdapter'
|
||||
@@ -10,6 +11,7 @@ import { BuildingSystem } from '../systems/BuildingSystem'
|
||||
import { FarmingSystem } from '../systems/FarmingSystem'
|
||||
import { VillagerSystem } from '../systems/VillagerSystem'
|
||||
import { DebugSystem } from '../systems/DebugSystem'
|
||||
import { TreeSeedlingSystem } from '../systems/TreeSeedlingSystem'
|
||||
|
||||
export class GameScene extends Phaser.Scene {
|
||||
private adapter!: LocalAdapter
|
||||
@@ -20,6 +22,7 @@ export class GameScene extends Phaser.Scene {
|
||||
private farmingSystem!: FarmingSystem
|
||||
villagerSystem!: VillagerSystem
|
||||
debugSystem!: DebugSystem
|
||||
private treeSeedlingSystem!: TreeSeedlingSystem
|
||||
private autosaveTimer = 0
|
||||
private menuOpen = false
|
||||
|
||||
@@ -37,9 +40,10 @@ export class GameScene extends Phaser.Scene {
|
||||
this.resourceSystem = new ResourceSystem(this, this.adapter)
|
||||
this.buildingSystem = new BuildingSystem(this, this.adapter)
|
||||
this.farmingSystem = new FarmingSystem(this, this.adapter)
|
||||
this.villagerSystem = new VillagerSystem(this, this.adapter, this.worldSystem)
|
||||
this.villagerSystem = new VillagerSystem(this, this.adapter, this.worldSystem)
|
||||
this.villagerSystem.init(this.resourceSystem, this.farmingSystem)
|
||||
this.debugSystem = new DebugSystem(this, this.villagerSystem, this.worldSystem)
|
||||
this.treeSeedlingSystem = new TreeSeedlingSystem(this, this.adapter, this.worldSystem)
|
||||
this.debugSystem = new DebugSystem(this, this.villagerSystem, this.worldSystem)
|
||||
|
||||
this.worldSystem.create()
|
||||
this.renderPersistentObjects()
|
||||
@@ -57,8 +61,12 @@ export class GameScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
this.farmingSystem.create()
|
||||
this.farmingSystem.onMessage = (msg) => this.events.emit('toast', msg)
|
||||
this.farmingSystem.onToolChange = (tool, label) => this.events.emit('farmToolChanged', tool, label)
|
||||
this.farmingSystem.onMessage = (msg) => this.events.emit('toast', msg)
|
||||
this.farmingSystem.onToolChange = (tool, label) => this.events.emit('farmToolChanged', tool, label)
|
||||
this.farmingSystem.onPlantTreeSeed = (tileX, tileY, tile) =>
|
||||
this.treeSeedlingSystem.plantSeedling(tileX, tileY, tile)
|
||||
|
||||
this.treeSeedlingSystem.create()
|
||||
|
||||
this.villagerSystem.create()
|
||||
this.villagerSystem.onMessage = (msg) => this.events.emit('toast', msg)
|
||||
@@ -70,6 +78,10 @@ export class GameScene extends Phaser.Scene {
|
||||
this.adapter.onAction = (action) => {
|
||||
if (action.type === 'CHANGE_TILE') {
|
||||
this.worldSystem.setTile(action.tileX, action.tileY, action.tile)
|
||||
this.worldSystem.refreshTerrainTile(action.tileX, action.tileY, action.tile)
|
||||
} else if (action.type === 'SPAWN_RESOURCE') {
|
||||
this.resourceSystem.spawnResourcePublic(action.resource)
|
||||
this.worldSystem.addResourceTile(action.resource.tileX, action.resource.tileY)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,9 +114,17 @@ export class GameScene extends Phaser.Scene {
|
||||
|
||||
this.resourceSystem.update(delta)
|
||||
this.farmingSystem.update(delta)
|
||||
this.treeSeedlingSystem.update(delta)
|
||||
this.villagerSystem.update(delta)
|
||||
this.debugSystem.update()
|
||||
|
||||
// Tick tile-recovery timers; refresh canvas for any tiles that reverted to GRASS
|
||||
const recovered = stateManager.tickTileRecovery(delta)
|
||||
for (const key of recovered) {
|
||||
const [tx, ty] = key.split(',').map(Number)
|
||||
this.worldSystem.refreshTerrainTile(tx, ty, TileType.GRASS)
|
||||
}
|
||||
|
||||
this.events.emit('cameraMoved', this.cameraSystem.getCenterTile())
|
||||
this.buildingSystem.update()
|
||||
|
||||
@@ -144,6 +164,7 @@ export class GameScene extends Phaser.Scene {
|
||||
this.resourceSystem.destroy()
|
||||
this.buildingSystem.destroy()
|
||||
this.farmingSystem.destroy()
|
||||
this.treeSeedlingSystem.destroy()
|
||||
this.villagerSystem.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@ import type { BuildingType, JobPriorities } from '../types'
|
||||
import type { FarmingTool } from '../systems/FarmingSystem'
|
||||
import type { DebugData } from '../systems/DebugSystem'
|
||||
import { stateManager } from '../StateManager'
|
||||
import { UI_SETTINGS_KEY } from '../config'
|
||||
|
||||
const ITEM_ICONS: Record<string, string> = {
|
||||
wood: '🪵', stone: '🪨', wheat_seed: '🌱', carrot_seed: '🥕',
|
||||
wheat: '🌾', carrot: '🧡',
|
||||
wheat: '🌾', carrot: '🧡', tree_seed: '🌲',
|
||||
}
|
||||
|
||||
export class UIScene extends Phaser.Scene {
|
||||
@@ -46,6 +47,11 @@ export class UIScene extends Phaser.Scene {
|
||||
logTexts: Phaser.GameObjects.Text[]
|
||||
} | null = null
|
||||
|
||||
/** Current overlay background opacity (0.4–1.0, default 0.8). Persisted in localStorage. */
|
||||
private uiOpacity = 0.8
|
||||
private settingsGroup!: Phaser.GameObjects.Group
|
||||
private settingsVisible = false
|
||||
|
||||
constructor() { super({ key: 'UI' }) }
|
||||
|
||||
/**
|
||||
@@ -53,6 +59,7 @@ export class UIScene extends Phaser.Scene {
|
||||
* keyboard shortcuts (B, V, F3, ESC).
|
||||
*/
|
||||
create(): void {
|
||||
this.loadUISettings()
|
||||
this.createStockpilePanel()
|
||||
this.createHintText()
|
||||
this.createToast()
|
||||
@@ -85,6 +92,7 @@ export class UIScene extends Phaser.Scene {
|
||||
this.escMenuGroup = this.add.group()
|
||||
this.confirmGroup = this.add.group()
|
||||
this.nisseInfoGroup = this.add.group()
|
||||
this.settingsGroup = this.add.group()
|
||||
|
||||
this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
||||
if (ptr.rightButtonDown()) {
|
||||
@@ -119,14 +127,16 @@ export class UIScene extends Phaser.Scene {
|
||||
/** Creates the stockpile panel in the top-right corner with item rows and population count. */
|
||||
private createStockpilePanel(): void {
|
||||
const x = this.scale.width - 178, y = 10
|
||||
this.stockpilePanel = this.add.rectangle(x, y, 168, 165, 0x000000, 0.72).setOrigin(0, 0).setScrollFactor(0).setDepth(100)
|
||||
// 7 items × 22px + 26px header + 12px gap + 18px popText row + 10px bottom = 210px
|
||||
this.stockpilePanel = this.add.rectangle(x, y, 168, 210, 0x000000, this.uiOpacity).setOrigin(0, 0).setScrollFactor(0).setDepth(100)
|
||||
this.stockpileTitleText = this.add.text(x + 10, y + 7, '⚡ STOCKPILE', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
|
||||
const items = ['wood','stone','wheat_seed','carrot_seed','wheat','carrot'] as const
|
||||
const items = ['wood','stone','wheat_seed','carrot_seed','tree_seed','wheat','carrot'] as const
|
||||
items.forEach((item, i) => {
|
||||
const t = this.add.text(x + 10, y + 26 + i * 22, `${ITEM_ICONS[item]} ${item}: 0`, { fontSize: '13px', color: '#88dd88', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
|
||||
this.stockpileTexts.set(item, t)
|
||||
})
|
||||
this.popText = this.add.text(x + 10, y + 145, '👥 Nisse: 0 / 0', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
|
||||
// last item (i=6) bottom edge ≈ y+190 → popText starts at y+192 with 8px gap
|
||||
this.popText = this.add.text(x + 10, y + 192, '👥 Nisse: 0 / 0', { fontSize: '11px', color: '#aaaaaa', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
|
||||
}
|
||||
|
||||
/** Refreshes all item quantities and colors in the stockpile panel. */
|
||||
@@ -196,7 +206,7 @@ export class UIScene extends Phaser.Scene {
|
||||
{ kind: 'stockpile_zone', label: '📦 Stockpile', cost: 'free (workers deliver here)' },
|
||||
]
|
||||
const menuX = this.scale.width / 2 - 150, menuY = this.scale.height / 2 - 140
|
||||
const bg = this.add.rectangle(menuX, menuY, 300, 280, 0x000000, 0.88).setOrigin(0,0).setScrollFactor(0).setDepth(200)
|
||||
const bg = this.add.rectangle(menuX, menuY, 300, 280, 0x000000, this.uiOpacity).setOrigin(0,0).setScrollFactor(0).setDepth(200)
|
||||
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))
|
||||
|
||||
@@ -263,7 +273,7 @@ export class UIScene extends Phaser.Scene {
|
||||
const px = this.scale.width / 2 - panelW / 2
|
||||
const py = this.scale.height / 2 - panelH / 2
|
||||
|
||||
const bg = this.add.rectangle(px, py, panelW, panelH, 0x0a0a0a, 0.92).setOrigin(0,0).setScrollFactor(0).setDepth(210)
|
||||
const bg = this.add.rectangle(px, py, panelW, panelH, 0x0a0a0a, this.uiOpacity).setOrigin(0,0).setScrollFactor(0).setDepth(210)
|
||||
this.villagerPanelGroup.add(bg)
|
||||
|
||||
this.villagerPanelGroup.add(
|
||||
@@ -383,10 +393,11 @@ export class UIScene extends Phaser.Scene {
|
||||
|
||||
/** Creates the debug panel text object (initially hidden). */
|
||||
private createDebugPanel(): void {
|
||||
const hexAlpha = Math.round(this.uiOpacity * 255).toString(16).padStart(2, '0')
|
||||
this.debugPanelText = this.add.text(10, 80, '', {
|
||||
fontSize: '12px',
|
||||
color: '#cccccc',
|
||||
backgroundColor: '#000000cc',
|
||||
backgroundColor: `#000000${hexAlpha}`,
|
||||
padding: { x: 8, y: 6 },
|
||||
lineSpacing: 2,
|
||||
fontFamily: 'monospace',
|
||||
@@ -463,7 +474,7 @@ export class UIScene extends Phaser.Scene {
|
||||
const mx = Math.min(x, this.scale.width - menuW - 4)
|
||||
const my = Math.min(y, this.scale.height - menuH - 4)
|
||||
|
||||
const bg = this.add.rectangle(mx, my, menuW, menuH, 0x000000, 0.88)
|
||||
const bg = this.add.rectangle(mx, my, menuW, menuH, 0x000000, this.uiOpacity)
|
||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(300)
|
||||
this.contextMenuGroup.add(bg)
|
||||
|
||||
@@ -521,6 +532,7 @@ export class UIScene extends Phaser.Scene {
|
||||
if (this.buildMenuVisible) { this.closeBuildMenu(); return }
|
||||
if (this.villagerPanelVisible){ this.closeVillagerPanel(); return }
|
||||
if (this.nisseInfoVisible) { this.closeNisseInfoPanel(); return }
|
||||
if (this.settingsVisible) { this.closeSettings(); return }
|
||||
if (this.escMenuVisible) { this.closeEscMenu(); return }
|
||||
// Build/farm mode: let BuildingSystem / FarmingSystem handle their own ESC key.
|
||||
// We only skip opening the ESC menu while those modes are active.
|
||||
@@ -560,11 +572,12 @@ export class UIScene extends Phaser.Scene {
|
||||
{ label: '⚙️ Settings', action: () => this.doSettings() },
|
||||
{ label: '🆕 New Game', action: () => this.doNewGame() },
|
||||
]
|
||||
const menuH = 16 + entries.length * (btnH + 8) + 8
|
||||
// 32px header + entries × (btnH + 8px gap) + 8px bottom padding
|
||||
const menuH = 32 + entries.length * (btnH + 8) + 8
|
||||
const mx = this.scale.width / 2 - menuW / 2
|
||||
const my = this.scale.height / 2 - menuH / 2
|
||||
|
||||
const bg = this.add.rectangle(mx, my, menuW, menuH, 0x0a0a0a, 0.95)
|
||||
const bg = this.add.rectangle(mx, my, menuW, menuH, 0x0a0a0a, this.uiOpacity)
|
||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(400)
|
||||
this.escMenuGroup.add(bg)
|
||||
this.escMenuGroup.add(
|
||||
@@ -602,10 +615,155 @@ export class UIScene extends Phaser.Scene {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
/** Opens an empty Settings panel (placeholder). */
|
||||
/** Opens the Settings overlay. */
|
||||
private doSettings(): void {
|
||||
this.closeEscMenu()
|
||||
this.showToast('Settings — coming soon')
|
||||
this.openSettings()
|
||||
}
|
||||
|
||||
// ─── Settings overlay ─────────────────────────────────────────────────────
|
||||
|
||||
/** Opens the settings overlay if it is not already open. */
|
||||
private openSettings(): void {
|
||||
if (this.settingsVisible) return
|
||||
this.settingsVisible = true
|
||||
this.scene.get('Game').events.emit('uiMenuOpen')
|
||||
this.buildSettings()
|
||||
}
|
||||
|
||||
/** Closes and destroys the settings overlay. */
|
||||
private closeSettings(): void {
|
||||
if (!this.settingsVisible) return
|
||||
this.settingsVisible = false
|
||||
this.settingsGroup.destroy(true)
|
||||
this.settingsGroup = this.add.group()
|
||||
this.scene.get('Game').events.emit('uiMenuClose')
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the settings overlay with an overlay-opacity row (step buttons).
|
||||
* Destroying and recreating this method is used to refresh the displayed value.
|
||||
*/
|
||||
private buildSettings(): void {
|
||||
if (this.settingsGroup) this.settingsGroup.destroy(true)
|
||||
this.settingsGroup = this.add.group()
|
||||
|
||||
const panelW = 280
|
||||
const panelH = 130
|
||||
const px = this.scale.width / 2 - panelW / 2
|
||||
const py = this.scale.height / 2 - panelH / 2
|
||||
|
||||
// Background
|
||||
const bg = this.add.rectangle(px, py, panelW, panelH, 0x0a0a0a, this.uiOpacity)
|
||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(450)
|
||||
this.settingsGroup.add(bg)
|
||||
|
||||
// Title
|
||||
this.settingsGroup.add(
|
||||
this.add.text(px + panelW / 2, py + 14, '⚙️ SETTINGS [ESC close]', {
|
||||
fontSize: '11px', color: '#666666', fontFamily: 'monospace',
|
||||
}).setOrigin(0.5, 0).setScrollFactor(0).setDepth(451)
|
||||
)
|
||||
|
||||
// Opacity label
|
||||
this.settingsGroup.add(
|
||||
this.add.text(px + 16, py + 58, 'Overlay opacity:', {
|
||||
fontSize: '13px', color: '#cccccc', fontFamily: 'monospace',
|
||||
}).setOrigin(0, 0.5).setScrollFactor(0).setDepth(451)
|
||||
)
|
||||
|
||||
// Minus button
|
||||
const minusBtn = this.add.rectangle(px + 170, py + 47, 26, 22, 0x1a1a2e, 0.9)
|
||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(451).setInteractive()
|
||||
minusBtn.on('pointerover', () => minusBtn.setFillStyle(0x2a2a4e, 0.9))
|
||||
minusBtn.on('pointerout', () => minusBtn.setFillStyle(0x1a1a2e, 0.9))
|
||||
minusBtn.on('pointerdown', () => {
|
||||
this.uiOpacity = Math.max(0.4, Math.round((this.uiOpacity - 0.1) * 10) / 10)
|
||||
this.saveUISettings()
|
||||
this.updateStaticPanelOpacity()
|
||||
this.buildSettings()
|
||||
})
|
||||
this.settingsGroup.add(minusBtn)
|
||||
this.settingsGroup.add(
|
||||
this.add.text(px + 183, py + 58, '−', {
|
||||
fontSize: '15px', color: '#ffffff', fontFamily: 'monospace',
|
||||
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(452)
|
||||
)
|
||||
|
||||
// Value display
|
||||
this.settingsGroup.add(
|
||||
this.add.text(px + 215, py + 58, `${Math.round(this.uiOpacity * 100)}%`, {
|
||||
fontSize: '13px', color: '#aaaaaa', fontFamily: 'monospace',
|
||||
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(451)
|
||||
)
|
||||
|
||||
// Plus button
|
||||
const plusBtn = this.add.rectangle(px + 242, py + 47, 26, 22, 0x1a1a2e, 0.9)
|
||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(451).setInteractive()
|
||||
plusBtn.on('pointerover', () => plusBtn.setFillStyle(0x2a2a4e, 0.9))
|
||||
plusBtn.on('pointerout', () => plusBtn.setFillStyle(0x1a1a2e, 0.9))
|
||||
plusBtn.on('pointerdown', () => {
|
||||
this.uiOpacity = Math.min(1.0, Math.round((this.uiOpacity + 0.1) * 10) / 10)
|
||||
this.saveUISettings()
|
||||
this.updateStaticPanelOpacity()
|
||||
this.buildSettings()
|
||||
})
|
||||
this.settingsGroup.add(plusBtn)
|
||||
this.settingsGroup.add(
|
||||
this.add.text(px + 255, py + 58, '+', {
|
||||
fontSize: '15px', color: '#ffffff', fontFamily: 'monospace',
|
||||
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(452)
|
||||
)
|
||||
|
||||
// Close button
|
||||
const closeBtnRect = this.add.rectangle(px + panelW / 2 - 50, py + 92, 100, 28, 0x1a1a2e, 0.9)
|
||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(451).setInteractive()
|
||||
closeBtnRect.on('pointerover', () => closeBtnRect.setFillStyle(0x2a2a4e, 0.9))
|
||||
closeBtnRect.on('pointerout', () => closeBtnRect.setFillStyle(0x1a1a2e, 0.9))
|
||||
closeBtnRect.on('pointerdown', () => this.closeSettings())
|
||||
this.settingsGroup.add(closeBtnRect)
|
||||
this.settingsGroup.add(
|
||||
this.add.text(px + panelW / 2, py + 106, 'Close', {
|
||||
fontSize: '13px', color: '#dddddd', fontFamily: 'monospace',
|
||||
}).setOrigin(0.5, 0.5).setScrollFactor(0).setDepth(452)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads UI settings from localStorage and applies the stored opacity value.
|
||||
* Falls back to the default (0.8) if no setting is found.
|
||||
*/
|
||||
private loadUISettings(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(UI_SETTINGS_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as { opacity?: number }
|
||||
if (typeof parsed.opacity === 'number') {
|
||||
this.uiOpacity = Math.max(0.4, Math.min(1.0, parsed.opacity))
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the current UI settings (opacity) to localStorage.
|
||||
* Stored separately from the game save so New Game does not wipe it.
|
||||
*/
|
||||
private saveUISettings(): void {
|
||||
try {
|
||||
localStorage.setItem(UI_SETTINGS_KEY, JSON.stringify({ opacity: this.uiOpacity }))
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the current uiOpacity to all static UI elements that are not
|
||||
* rebuilt on open (stockpile panel, debug panel background).
|
||||
* Called whenever uiOpacity changes.
|
||||
*/
|
||||
private updateStaticPanelOpacity(): void {
|
||||
this.stockpilePanel.setAlpha(this.uiOpacity)
|
||||
const hexAlpha = Math.round(this.uiOpacity * 255).toString(16).padStart(2, '0')
|
||||
this.debugPanelText.setStyle({ backgroundColor: `#000000${hexAlpha}` })
|
||||
}
|
||||
|
||||
/** Shows a confirmation dialog before starting a new game. */
|
||||
@@ -634,7 +792,7 @@ export class UIScene extends Phaser.Scene {
|
||||
const dx = this.scale.width / 2 - dialogW / 2
|
||||
const dy = this.scale.height / 2 - dialogH / 2
|
||||
|
||||
const bg = this.add.rectangle(dx, dy, dialogW, dialogH, 0x0a0a0a, 0.97)
|
||||
const bg = this.add.rectangle(dx, dy, dialogW, dialogH, 0x0a0a0a, this.uiOpacity)
|
||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(500)
|
||||
this.confirmGroup.add(bg)
|
||||
|
||||
@@ -692,7 +850,6 @@ export class UIScene extends Phaser.Scene {
|
||||
private openNisseInfoPanel(villagerId: string): void {
|
||||
this.nisseInfoId = villagerId
|
||||
this.nisseInfoVisible = true
|
||||
this.scene.get('Game').events.emit('uiMenuOpen')
|
||||
this.buildNisseInfoPanel()
|
||||
}
|
||||
|
||||
@@ -703,7 +860,6 @@ export class UIScene extends Phaser.Scene {
|
||||
this.nisseInfoId = null
|
||||
this.nisseInfoGroup.destroy(true)
|
||||
this.nisseInfoGroup = this.add.group()
|
||||
this.scene.get('Game').events.emit('uiMenuClose')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -730,7 +886,7 @@ export class UIScene extends Phaser.Scene {
|
||||
|
||||
// Background
|
||||
this.nisseInfoGroup.add(
|
||||
this.add.rectangle(px, py, panelW, panelH, 0x050510, 0.93)
|
||||
this.add.rectangle(px, py, panelW, panelH, 0x050510, this.uiOpacity)
|
||||
.setOrigin(0, 0).setScrollFactor(0).setDepth(250)
|
||||
)
|
||||
|
||||
@@ -883,6 +1039,7 @@ export class UIScene extends Phaser.Scene {
|
||||
if (this.villagerPanelVisible) this.closeVillagerPanel()
|
||||
if (this.contextMenuVisible) this.hideContextMenu()
|
||||
if (this.escMenuVisible) this.closeEscMenu()
|
||||
if (this.settingsVisible) this.closeSettings()
|
||||
if (this.confirmVisible) this.hideConfirm()
|
||||
if (this.nisseInfoVisible) this.closeNisseInfoPanel()
|
||||
}
|
||||
|
||||
@@ -5,15 +5,16 @@ import type { CropKind, CropState, ItemId } from '../types'
|
||||
import { stateManager } from '../StateManager'
|
||||
import type { LocalAdapter } from '../NetworkAdapter'
|
||||
|
||||
export type FarmingTool = 'none' | 'hoe' | 'wheat_seed' | 'carrot_seed' | 'water'
|
||||
export type FarmingTool = 'none' | 'hoe' | 'wheat_seed' | 'carrot_seed' | 'tree_seed' | 'water'
|
||||
|
||||
const TOOL_CYCLE: FarmingTool[] = ['none', 'hoe', 'wheat_seed', 'carrot_seed', 'water']
|
||||
const TOOL_CYCLE: FarmingTool[] = ['none', 'hoe', 'wheat_seed', 'carrot_seed', 'tree_seed', 'water']
|
||||
|
||||
const TOOL_LABELS: Record<FarmingTool, string> = {
|
||||
none: '— None',
|
||||
hoe: '⛏ Hoe (till grass)',
|
||||
wheat_seed: '🌾 Wheat Seeds',
|
||||
carrot_seed: '🥕 Carrot Seeds',
|
||||
tree_seed: '🌲 Tree Seeds (plant on grass)',
|
||||
water: '💧 Watering Can',
|
||||
}
|
||||
|
||||
@@ -30,6 +31,14 @@ export class FarmingSystem {
|
||||
onToolChange?: (tool: FarmingTool, label: string) => void
|
||||
/** Emitted for toast notifications */
|
||||
onMessage?: (msg: string) => void
|
||||
/**
|
||||
* Called when the player uses the tree_seed tool on a tile.
|
||||
* @param tileX - Target tile column
|
||||
* @param tileY - Target tile row
|
||||
* @param underlyingTile - The tile type at that position
|
||||
* @returns true if planting succeeded, false if validation failed
|
||||
*/
|
||||
onPlantTreeSeed?: (tileX: number, tileY: number, underlyingTile: TileType) => boolean
|
||||
|
||||
constructor(scene: Phaser.Scene, adapter: LocalAdapter) {
|
||||
this.scene = scene
|
||||
@@ -87,9 +96,27 @@ export class FarmingSystem {
|
||||
const state = stateManager.getState()
|
||||
const tile = state.world.tiles[tileY * 512 + tileX] as TileType
|
||||
|
||||
if (this.currentTool === 'hoe') this.tillSoil(tileX, tileY, tile)
|
||||
else if (this.currentTool === 'water') this.waterTile(tileX, tileY, tile)
|
||||
else this.plantCrop(tileX, tileY, tile, this.currentTool.replace('_seed', '') as CropKind)
|
||||
if (this.currentTool === 'hoe') this.tillSoil(tileX, tileY, tile)
|
||||
else if (this.currentTool === 'water') this.waterTile(tileX, tileY, tile)
|
||||
else if (this.currentTool === 'tree_seed') this.plantTreeSeed(tileX, tileY, tile)
|
||||
else this.plantCrop(tileX, tileY, tile, this.currentTool.replace('_seed', '') as CropKind)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegates tree-seedling planting to the registered callback (TreeSeedlingSystem).
|
||||
* Only works on GRASS or DARK_GRASS tiles. Shows a toast on success or failure.
|
||||
* @param tileX - Target tile column
|
||||
* @param tileY - Target tile row
|
||||
* @param tile - Current tile type at that position
|
||||
*/
|
||||
private plantTreeSeed(tileX: number, tileY: number, tile: TileType): void {
|
||||
if (tile !== TileType.GRASS && tile !== TileType.DARK_GRASS) {
|
||||
this.onMessage?.('Plant tree seeds on grass!')
|
||||
return
|
||||
}
|
||||
const ok = this.onPlantTreeSeed?.(tileX, tileY, tile)
|
||||
if (ok === false) this.onMessage?.('No tree seeds, or tile is occupied!')
|
||||
else if (ok) this.onMessage?.('Tree seed planted! 🌱 (~2 min to grow)')
|
||||
}
|
||||
|
||||
private tillSoil(tileX: number, tileY: number, tile: TileType): void {
|
||||
|
||||
@@ -76,6 +76,16 @@ export class ResourceSystem {
|
||||
this.removeSprite(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns a sprite for a resource that was created at runtime
|
||||
* (e.g. a tree grown from a seedling). The resource must already be
|
||||
* present in the game state when this is called.
|
||||
* @param node - The resource node to render
|
||||
*/
|
||||
public spawnResourcePublic(node: ResourceNodeState): void {
|
||||
this.spawnSprite(node)
|
||||
}
|
||||
|
||||
/** Called when WorldSystem changes a tile (e.g. after tree removed) */
|
||||
syncTileChange(tileX: number, tileY: number, worldSystem: { setTile: (x: number, y: number, type: TileType) => void }): void {
|
||||
const state = stateManager.getState()
|
||||
|
||||
130
src/systems/TreeSeedlingSystem.ts
Normal file
130
src/systems/TreeSeedlingSystem.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import Phaser from 'phaser'
|
||||
import { TILE_SIZE, TREE_SEEDLING_STAGE_MS } from '../config'
|
||||
import { TileType, PLANTABLE_TILES } from '../types'
|
||||
import type { TreeSeedlingState } from '../types'
|
||||
import { stateManager } from '../StateManager'
|
||||
import type { LocalAdapter } from '../NetworkAdapter'
|
||||
import type { WorldSystem } from './WorldSystem'
|
||||
|
||||
export class TreeSeedlingSystem {
|
||||
private scene: Phaser.Scene
|
||||
private adapter: LocalAdapter
|
||||
private worldSystem: WorldSystem
|
||||
private sprites = new Map<string, Phaser.GameObjects.Image>()
|
||||
|
||||
/**
|
||||
* @param scene - The Phaser scene this system belongs to
|
||||
* @param adapter - Network adapter for dispatching state actions
|
||||
* @param worldSystem - Used to refresh the terrain canvas when a seedling matures
|
||||
*/
|
||||
constructor(scene: Phaser.Scene, adapter: LocalAdapter, worldSystem: WorldSystem) {
|
||||
this.scene = scene
|
||||
this.adapter = adapter
|
||||
this.worldSystem = worldSystem
|
||||
}
|
||||
|
||||
/** Spawns sprites for all seedlings that exist in the saved state. */
|
||||
create(): void {
|
||||
const state = stateManager.getState()
|
||||
for (const s of Object.values(state.world.treeSeedlings)) {
|
||||
this.spawnSprite(s)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticks all seedling growth timers and handles stage changes.
|
||||
* Stage 0→1: updates the sprite to the sapling texture.
|
||||
* Stage 1→2: removes the seedling, spawns a tree resource, and updates the terrain canvas.
|
||||
* @param delta - Frame delta in milliseconds
|
||||
*/
|
||||
update(delta: number): void {
|
||||
const advanced = stateManager.tickSeedlings(delta)
|
||||
for (const id of advanced) {
|
||||
const state = stateManager.getState()
|
||||
const seedling = state.world.treeSeedlings[id]
|
||||
if (!seedling) continue
|
||||
|
||||
if (seedling.stage === 2) {
|
||||
// Fully mature: become a FOREST tile and a real tree resource
|
||||
const { tileX, tileY } = seedling
|
||||
this.removeSprite(id)
|
||||
this.adapter.send({ type: 'REMOVE_TREE_SEEDLING', seedlingId: id })
|
||||
this.adapter.send({ type: 'CHANGE_TILE', tileX, tileY, tile: TileType.FOREST })
|
||||
|
||||
const resourceId = `tree_grown_${tileX}_${tileY}_${Date.now()}`
|
||||
this.adapter.send({
|
||||
type: 'SPAWN_RESOURCE',
|
||||
resource: { id: resourceId, tileX, tileY, kind: 'tree', hp: 3 },
|
||||
})
|
||||
} else {
|
||||
// Stage 0→1: update sprite to sapling
|
||||
const sprite = this.sprites.get(id)
|
||||
if (sprite) sprite.setTexture(`seedling_${seedling.stage}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to plant a tree seedling on a grass tile.
|
||||
* Validates that the stockpile has at least one tree_seed, the tile type is
|
||||
* plantable (GRASS or DARK_GRASS), and no other object occupies the tile.
|
||||
* @param tileX - Target tile column
|
||||
* @param tileY - Target tile row
|
||||
* @param underlyingTile - The current tile type (stored on the seedling for later restoration)
|
||||
* @returns true if the seedling was planted, false if validation failed
|
||||
*/
|
||||
plantSeedling(tileX: number, tileY: number, underlyingTile: TileType): boolean {
|
||||
const state = stateManager.getState()
|
||||
|
||||
if ((state.world.stockpile.tree_seed ?? 0) <= 0) return false
|
||||
if (!PLANTABLE_TILES.has(underlyingTile)) return false
|
||||
|
||||
const occupied =
|
||||
Object.values(state.world.resources).some(r => r.tileX === tileX && r.tileY === tileY) ||
|
||||
Object.values(state.world.buildings).some(b => b.tileX === tileX && b.tileY === tileY) ||
|
||||
Object.values(state.world.crops).some(c => c.tileX === tileX && c.tileY === tileY) ||
|
||||
Object.values(state.world.treeSeedlings).some(s => s.tileX === tileX && s.tileY === tileY)
|
||||
|
||||
if (occupied) return false
|
||||
|
||||
const id = `seedling_${tileX}_${tileY}_${Date.now()}`
|
||||
const seedling: TreeSeedlingState = {
|
||||
id, tileX, tileY,
|
||||
stage: 0,
|
||||
stageTimerMs: TREE_SEEDLING_STAGE_MS,
|
||||
underlyingTile,
|
||||
}
|
||||
|
||||
this.adapter.send({ type: 'PLANT_TREE_SEED', seedling })
|
||||
this.spawnSprite(seedling)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and registers the sprite for a seedling.
|
||||
* @param s - Seedling state to render
|
||||
*/
|
||||
private spawnSprite(s: TreeSeedlingState): void {
|
||||
const x = (s.tileX + 0.5) * TILE_SIZE
|
||||
const y = (s.tileY + 0.5) * TILE_SIZE
|
||||
const key = `seedling_${Math.min(s.stage, 2)}`
|
||||
const sprite = this.scene.add.image(x, y, key)
|
||||
.setOrigin(0.5, 0.85)
|
||||
.setDepth(5)
|
||||
this.sprites.set(s.id, sprite)
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys the sprite for a seedling and removes it from the registry.
|
||||
* @param id - Seedling ID
|
||||
*/
|
||||
private removeSprite(id: string): void {
|
||||
const s = this.sprites.get(id)
|
||||
if (s) { s.destroy(); this.sprites.delete(id) }
|
||||
}
|
||||
|
||||
/** Destroys all seedling sprites and clears the registry. */
|
||||
destroy(): void {
|
||||
for (const id of [...this.sprites.keys()]) this.removeSprite(id)
|
||||
}
|
||||
}
|
||||
@@ -274,8 +274,9 @@ export class VillagerSystem {
|
||||
const res = state.world.resources[job.targetId]
|
||||
if (res) {
|
||||
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
|
||||
// Clear the FOREST tile so the area becomes passable for future pathfinding
|
||||
this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.DARK_GRASS })
|
||||
this.adapter.send({ type: 'TILE_RECOVERY_START', tileX: res.tileX, tileY: res.tileY })
|
||||
this.worldSystem.removeResourceTile(res.tileX, res.tileY)
|
||||
this.resourceSystem.removeResource(job.targetId)
|
||||
this.addLog(v.id, '✓ Chopped tree (+2 wood)')
|
||||
}
|
||||
@@ -283,8 +284,8 @@ export class VillagerSystem {
|
||||
const res = state.world.resources[job.targetId]
|
||||
if (res) {
|
||||
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
|
||||
// Clear the ROCK tile so the area becomes passable for future pathfinding
|
||||
this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.GRASS })
|
||||
this.worldSystem.removeResourceTile(res.tileX, res.tileY)
|
||||
this.resourceSystem.removeResource(job.targetId)
|
||||
this.addLog(v.id, '✓ Mined rock (+2 stone)')
|
||||
}
|
||||
@@ -349,12 +350,17 @@ export class VillagerSystem {
|
||||
if (p.chop > 0) {
|
||||
for (const res of Object.values(state.world.resources)) {
|
||||
if (res.kind !== 'tree' || this.claimed.has(res.id)) continue
|
||||
// Skip trees with no reachable neighbour — A* cannot enter an impassable goal
|
||||
// tile unless at least one passable neighbour exists to jump from.
|
||||
if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue
|
||||
candidates.push({ type: 'chop', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.chop })
|
||||
}
|
||||
}
|
||||
if (p.mine > 0) {
|
||||
for (const res of Object.values(state.world.resources)) {
|
||||
if (res.kind !== 'rock' || this.claimed.has(res.id)) continue
|
||||
// Same reachability guard for rock tiles.
|
||||
if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue
|
||||
candidates.push({ type: 'mine', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.mine })
|
||||
}
|
||||
}
|
||||
@@ -395,7 +401,7 @@ export class VillagerSystem {
|
||||
this.claimed.delete(v.job.targetId)
|
||||
this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: null })
|
||||
}
|
||||
rt.idleScanTimer = 1500 // longer delay after failed pathfind
|
||||
rt.idleScanTimer = 4000 // longer delay after failed pathfind to avoid tight retry loops
|
||||
return
|
||||
}
|
||||
|
||||
@@ -432,6 +438,22 @@ export class VillagerSystem {
|
||||
return this.nearestBuilding(v, 'bed') as any
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if at least one of the 8 neighbours of the given tile is passable.
|
||||
* Used to pre-filter job targets that are fully enclosed by impassable terrain —
|
||||
* such as trees deep inside a dense forest cluster where A* can never reach the goal
|
||||
* tile because no passable tile is adjacent to it.
|
||||
* @param tileX - Target tile X
|
||||
* @param tileY - Target tile Y
|
||||
*/
|
||||
private hasAdjacentPassable(tileX: number, tileY: number): boolean {
|
||||
const DIRS = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]] as const
|
||||
for (const [dx, dy] of DIRS) {
|
||||
if (this.worldSystem.isPassable(tileX + dx, tileY + dy)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ─── Spawning ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Phaser from 'phaser'
|
||||
import { TILE_SIZE, WORLD_TILES } from '../config'
|
||||
import { TileType, IMPASSABLE } from '../types'
|
||||
import { TileType, IMPASSABLE, RESOURCE_TERRAIN } from '../types'
|
||||
import { stateManager } from '../StateManager'
|
||||
|
||||
const BIOME_COLORS: Record<number, string> = {
|
||||
@@ -18,9 +18,16 @@ const BIOME_COLORS: Record<number, string> = {
|
||||
export class WorldSystem {
|
||||
private scene: Phaser.Scene
|
||||
private map!: Phaser.Tilemaps.Tilemap
|
||||
/**
|
||||
* Spatial index: tile keys (tileY * WORLD_TILES + tileX) for every tile
|
||||
* that is currently occupied by a tree or rock resource.
|
||||
* Used by isPassable() to decide if a FOREST or ROCK terrain tile is blocked.
|
||||
*/
|
||||
private resourceTiles = new Set<number>()
|
||||
private tileset!: Phaser.Tilemaps.Tileset
|
||||
private bgImage!: Phaser.GameObjects.Image
|
||||
private builtLayer!: Phaser.Tilemaps.TilemapLayer
|
||||
private bgCanvasTexture!: Phaser.Textures.CanvasTexture
|
||||
|
||||
/** @param scene - The Phaser scene this system belongs to */
|
||||
constructor(scene: Phaser.Scene) {
|
||||
@@ -35,10 +42,8 @@ export class WorldSystem {
|
||||
const state = stateManager.getState()
|
||||
|
||||
// --- Canvas background (1px per tile, scaled up, LINEAR filtered) ---
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = WORLD_TILES
|
||||
canvas.height = WORLD_TILES
|
||||
const ctx = canvas.getContext('2d')!
|
||||
const canvasTexture = this.scene.textures.createCanvas('terrain_bg', WORLD_TILES, WORLD_TILES) as Phaser.Textures.CanvasTexture
|
||||
const ctx = canvasTexture.context
|
||||
|
||||
for (let y = 0; y < WORLD_TILES; y++) {
|
||||
for (let x = 0; x < WORLD_TILES; x++) {
|
||||
@@ -48,12 +53,14 @@ export class WorldSystem {
|
||||
}
|
||||
}
|
||||
|
||||
this.scene.textures.addCanvas('terrain_bg', canvas)
|
||||
canvasTexture.refresh()
|
||||
this.bgCanvasTexture = canvasTexture
|
||||
|
||||
this.bgImage = this.scene.add.image(0, 0, 'terrain_bg')
|
||||
.setOrigin(0, 0)
|
||||
.setScale(TILE_SIZE)
|
||||
.setDepth(0)
|
||||
this.scene.textures.get('terrain_bg').setFilter(Phaser.Textures.FilterMode.LINEAR)
|
||||
canvasTexture.setFilter(Phaser.Textures.FilterMode.LINEAR)
|
||||
|
||||
// --- Built tile layer (sparse — only FLOOR, WALL, TILLED_SOIL, WATERED_SOIL) ---
|
||||
this.map = this.scene.make.tilemap({
|
||||
@@ -84,6 +91,8 @@ export class WorldSystem {
|
||||
|
||||
// Camera bounds
|
||||
this.scene.cameras.main.setBounds(0, 0, WORLD_TILES * TILE_SIZE, WORLD_TILES * TILE_SIZE)
|
||||
|
||||
this.initResourceTiles()
|
||||
}
|
||||
|
||||
/** Returns the built-tile tilemap layer (floor, wall, soil). */
|
||||
@@ -110,6 +119,10 @@ export class WorldSystem {
|
||||
|
||||
/**
|
||||
* Returns whether the tile at the given coordinates can be walked on.
|
||||
* Water and wall tiles are always impassable.
|
||||
* Forest and rock terrain tiles are only impassable when a resource
|
||||
* (tree or rock) currently occupies them — empty forest floor and bare
|
||||
* rocky ground are walkable.
|
||||
* Out-of-bounds tiles are treated as impassable.
|
||||
* @param tileX - Tile column
|
||||
* @param tileY - Tile row
|
||||
@@ -118,7 +131,45 @@ export class WorldSystem {
|
||||
if (tileX < 0 || tileY < 0 || tileX >= WORLD_TILES || tileY >= WORLD_TILES) return false
|
||||
const state = stateManager.getState()
|
||||
const tile = state.world.tiles[tileY * WORLD_TILES + tileX]
|
||||
return !IMPASSABLE.has(tile)
|
||||
if (IMPASSABLE.has(tile)) return false
|
||||
if (RESOURCE_TERRAIN.has(tile)) {
|
||||
return !this.resourceTiles.has(tileY * WORLD_TILES + tileX)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the resource tile index from the current world state.
|
||||
* Called once in create() so that isPassable() has an O(1) lookup.
|
||||
*/
|
||||
private initResourceTiles(): void {
|
||||
this.resourceTiles.clear()
|
||||
const state = stateManager.getState()
|
||||
for (const res of Object.values(state.world.resources)) {
|
||||
this.resourceTiles.add(res.tileY * WORLD_TILES + res.tileX)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a newly placed resource so isPassable() treats the tile as blocked.
|
||||
* Call this whenever a resource is added at runtime (e.g. a seedling matures).
|
||||
* @param tileX - Resource tile column
|
||||
* @param tileY - Resource tile row
|
||||
*/
|
||||
addResourceTile(tileX: number, tileY: number): void {
|
||||
this.resourceTiles.add(tileY * WORLD_TILES + tileX)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a resource from the tile index so isPassable() treats the tile as free.
|
||||
* Call this when a resource is removed at runtime (e.g. after chopping/mining).
|
||||
* Not strictly required when the tile type also changes (FOREST → DARK_GRASS),
|
||||
* but keeps the index clean for correctness.
|
||||
* @param tileX - Resource tile column
|
||||
* @param tileY - Resource tile row
|
||||
*/
|
||||
removeResourceTile(tileX: number, tileY: number): void {
|
||||
this.resourceTiles.delete(tileY * WORLD_TILES + tileX)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,6 +208,21 @@ export class WorldSystem {
|
||||
return state.world.tiles[tileY * WORLD_TILES + tileX] as TileType
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a single tile's pixel on the background canvas and refreshes the GPU texture.
|
||||
* Used when a natural tile changes at runtime (e.g. DARK_GRASS → GRASS after recovery,
|
||||
* or GRASS → FOREST when a seedling matures).
|
||||
* @param tileX - Tile column
|
||||
* @param tileY - Tile row
|
||||
* @param type - New tile type to reflect visually
|
||||
*/
|
||||
refreshTerrainTile(tileX: number, tileY: number, type: TileType): void {
|
||||
const color = BIOME_COLORS[type] ?? '#0a2210'
|
||||
this.bgCanvasTexture.context.fillStyle = color
|
||||
this.bgCanvasTexture.context.fillRect(tileX, tileY, 1, 1)
|
||||
this.bgCanvasTexture.refresh()
|
||||
}
|
||||
|
||||
/** Destroys the tilemap and background image. */
|
||||
destroy(): void {
|
||||
this.map.destroy()
|
||||
|
||||
39
src/types.ts
39
src/types.ts
@@ -12,15 +12,25 @@ export enum TileType {
|
||||
WATERED_SOIL = 10,
|
||||
}
|
||||
|
||||
/** Tiles that are always impassable regardless of what is on them. */
|
||||
export const IMPASSABLE = new Set<number>([
|
||||
TileType.DEEP_WATER,
|
||||
TileType.SHALLOW_WATER,
|
||||
TileType.FOREST,
|
||||
TileType.ROCK,
|
||||
TileType.WALL,
|
||||
])
|
||||
|
||||
export type ItemId = 'wood' | 'stone' | 'wheat_seed' | 'carrot_seed' | 'wheat' | 'carrot'
|
||||
/**
|
||||
* Terrain tiles whose passability depends on whether a resource
|
||||
* (tree or rock) is currently placed on them.
|
||||
* An empty FOREST tile is walkable forest floor; a ROCK tile without a
|
||||
* rock resource is just rocky ground.
|
||||
*/
|
||||
export const RESOURCE_TERRAIN = new Set<number>([TileType.FOREST, TileType.ROCK])
|
||||
|
||||
/** Tiles on which tree seedlings may be planted. */
|
||||
export const PLANTABLE_TILES = new Set<TileType>([TileType.GRASS, TileType.DARK_GRASS])
|
||||
|
||||
export type ItemId = 'wood' | 'stone' | 'wheat_seed' | 'carrot_seed' | 'wheat' | 'carrot' | 'tree_seed'
|
||||
|
||||
export type BuildingType = 'floor' | 'wall' | 'chest' | 'bed' | 'stockpile_zone'
|
||||
|
||||
@@ -90,6 +100,18 @@ export interface PlayerState {
|
||||
inventory: Partial<Record<ItemId, number>>
|
||||
}
|
||||
|
||||
export interface TreeSeedlingState {
|
||||
id: string
|
||||
tileX: number
|
||||
tileY: number
|
||||
/** Growth stage: 0 = sprout, 1 = sapling, 2 = mature (converts to resource). */
|
||||
stage: number
|
||||
/** Time remaining until next stage advance, in milliseconds. */
|
||||
stageTimerMs: number
|
||||
/** The tile type that was under the seedling when planted (GRASS or DARK_GRASS). */
|
||||
underlyingTile: TileType
|
||||
}
|
||||
|
||||
export interface WorldState {
|
||||
seed: number
|
||||
tiles: number[]
|
||||
@@ -98,6 +120,13 @@ export interface WorldState {
|
||||
crops: Record<string, CropState>
|
||||
villagers: Record<string, VillagerState>
|
||||
stockpile: Partial<Record<ItemId, number>>
|
||||
/** Planted tree seedlings, keyed by ID. */
|
||||
treeSeedlings: Record<string, TreeSeedlingState>
|
||||
/**
|
||||
* Recovery timers for DARK_GRASS tiles, keyed by "tileX,tileY".
|
||||
* Value is remaining milliseconds until the tile reverts to GRASS.
|
||||
*/
|
||||
tileRecovery: Record<string, number>
|
||||
}
|
||||
|
||||
export interface GameStateData {
|
||||
@@ -123,3 +152,7 @@ export type GameAction =
|
||||
| { type: 'VILLAGER_HARVEST_CROP'; villagerId: string; cropId: string }
|
||||
| { type: 'VILLAGER_DEPOSIT'; villagerId: string }
|
||||
| { type: 'UPDATE_PRIORITIES'; villagerId: string; priorities: JobPriorities }
|
||||
| { type: 'PLANT_TREE_SEED'; seedling: TreeSeedlingState }
|
||||
| { type: 'REMOVE_TREE_SEEDLING'; seedlingId: string }
|
||||
| { type: 'SPAWN_RESOURCE'; resource: ResourceNodeState }
|
||||
| { type: 'TILE_RECOVERY_START'; tileX: number; tileY: number }
|
||||
|
||||
Reference in New Issue
Block a user