Compare commits
13 Commits
feature/ti
...
fix/change
| Author | SHA1 | Date | |
|---|---|---|---|
| b024cf36fb | |||
| 8197348cfc | |||
| 732d9100ab | |||
| f2a1811a36 | |||
| 774054db56 | |||
| 0ed3bfaea6 | |||
| 1d46446012 | |||
| a5c37f20f6 | |||
| 174db14c7a | |||
| c7ebf49bf2 | |||
| b259d966ee | |||
| 9b22f708a5 | |||
| a0e813e86b |
11
CHANGELOG.md
11
CHANGELOG.md
@@ -7,6 +7,17 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### Added
|
||||||
- **Unified Tile System** (Issue #14):
|
- **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
|
- 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
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export const VILLAGER_NAMES = [
|
|||||||
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
|
||||||
|
|
||||||
|
/** 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). */
|
/** 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
|
export const TREE_SEEDLING_STAGE_MS = 60_000 // 1 min per stage → 2 min total
|
||||||
|
|
||||||
|
|||||||
@@ -78,8 +78,10 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.adapter.onAction = (action) => {
|
this.adapter.onAction = (action) => {
|
||||||
if (action.type === 'CHANGE_TILE') {
|
if (action.type === 'CHANGE_TILE') {
|
||||||
this.worldSystem.setTile(action.tileX, action.tileY, action.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') {
|
} else if (action.type === 'SPAWN_RESOURCE') {
|
||||||
this.resourceSystem.spawnResourcePublic(action.resource)
|
this.resourceSystem.spawnResourcePublic(action.resource)
|
||||||
|
this.worldSystem.addResourceTile(action.resource.tileX, action.resource.tileY)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { BuildingType, JobPriorities } from '../types'
|
|||||||
import type { FarmingTool } from '../systems/FarmingSystem'
|
import type { FarmingTool } from '../systems/FarmingSystem'
|
||||||
import type { DebugData } from '../systems/DebugSystem'
|
import type { DebugData } from '../systems/DebugSystem'
|
||||||
import { stateManager } from '../StateManager'
|
import { stateManager } from '../StateManager'
|
||||||
|
import { UI_SETTINGS_KEY } from '../config'
|
||||||
|
|
||||||
const ITEM_ICONS: Record<string, string> = {
|
const ITEM_ICONS: Record<string, string> = {
|
||||||
wood: '🪵', stone: '🪨', wheat_seed: '🌱', carrot_seed: '🥕',
|
wood: '🪵', stone: '🪨', wheat_seed: '🌱', carrot_seed: '🥕',
|
||||||
@@ -46,6 +47,11 @@ export class UIScene extends Phaser.Scene {
|
|||||||
logTexts: Phaser.GameObjects.Text[]
|
logTexts: Phaser.GameObjects.Text[]
|
||||||
} | null = null
|
} | 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' }) }
|
constructor() { super({ key: 'UI' }) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,6 +59,7 @@ export class UIScene extends Phaser.Scene {
|
|||||||
* keyboard shortcuts (B, V, F3, ESC).
|
* keyboard shortcuts (B, V, F3, ESC).
|
||||||
*/
|
*/
|
||||||
create(): void {
|
create(): void {
|
||||||
|
this.loadUISettings()
|
||||||
this.createStockpilePanel()
|
this.createStockpilePanel()
|
||||||
this.createHintText()
|
this.createHintText()
|
||||||
this.createToast()
|
this.createToast()
|
||||||
@@ -85,6 +92,7 @@ export class UIScene extends Phaser.Scene {
|
|||||||
this.escMenuGroup = this.add.group()
|
this.escMenuGroup = this.add.group()
|
||||||
this.confirmGroup = this.add.group()
|
this.confirmGroup = this.add.group()
|
||||||
this.nisseInfoGroup = this.add.group()
|
this.nisseInfoGroup = this.add.group()
|
||||||
|
this.settingsGroup = this.add.group()
|
||||||
|
|
||||||
this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
this.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
||||||
if (ptr.rightButtonDown()) {
|
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. */
|
/** Creates the stockpile panel in the top-right corner with item rows and population count. */
|
||||||
private createStockpilePanel(): void {
|
private createStockpilePanel(): void {
|
||||||
const x = this.scale.width - 178, y = 10
|
const x = this.scale.width - 178, y = 10
|
||||||
this.stockpilePanel = this.add.rectangle(x, y, 168, 187, 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)
|
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','tree_seed','wheat','carrot'] as const
|
const items = ['wood','stone','wheat_seed','carrot_seed','tree_seed','wheat','carrot'] as const
|
||||||
items.forEach((item, i) => {
|
items.forEach((item, i) => {
|
||||||
const t = this.add.text(x + 10, y + 26 + i * 22, `${ITEM_ICONS[item]} ${item}: 0`, { fontSize: '13px', color: '#88dd88', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
|
const t = this.add.text(x + 10, y + 26 + i * 22, `${ITEM_ICONS[item]} ${item}: 0`, { fontSize: '13px', color: '#88dd88', fontFamily: 'monospace' }).setScrollFactor(0).setDepth(101)
|
||||||
this.stockpileTexts.set(item, t)
|
this.stockpileTexts.set(item, t)
|
||||||
})
|
})
|
||||||
this.popText = this.add.text(x + 10, y + 167, '👥 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. */
|
/** 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)' },
|
{ kind: 'stockpile_zone', label: '📦 Stockpile', cost: 'free (workers deliver here)' },
|
||||||
]
|
]
|
||||||
const menuX = this.scale.width / 2 - 150, menuY = this.scale.height / 2 - 140
|
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(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))
|
||||||
|
|
||||||
@@ -263,7 +273,7 @@ export class UIScene extends Phaser.Scene {
|
|||||||
const px = this.scale.width / 2 - panelW / 2
|
const px = this.scale.width / 2 - panelW / 2
|
||||||
const py = this.scale.height / 2 - panelH / 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(bg)
|
||||||
|
|
||||||
this.villagerPanelGroup.add(
|
this.villagerPanelGroup.add(
|
||||||
@@ -383,10 +393,11 @@ 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')
|
||||||
this.debugPanelText = this.add.text(10, 80, '', {
|
this.debugPanelText = this.add.text(10, 80, '', {
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
color: '#cccccc',
|
color: '#cccccc',
|
||||||
backgroundColor: '#000000cc',
|
backgroundColor: `#000000${hexAlpha}`,
|
||||||
padding: { x: 8, y: 6 },
|
padding: { x: 8, y: 6 },
|
||||||
lineSpacing: 2,
|
lineSpacing: 2,
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
@@ -463,7 +474,7 @@ export class UIScene extends Phaser.Scene {
|
|||||||
const mx = Math.min(x, this.scale.width - menuW - 4)
|
const mx = Math.min(x, this.scale.width - menuW - 4)
|
||||||
const my = Math.min(y, this.scale.height - menuH - 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)
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(300)
|
||||||
this.contextMenuGroup.add(bg)
|
this.contextMenuGroup.add(bg)
|
||||||
|
|
||||||
@@ -521,6 +532,7 @@ export class UIScene extends Phaser.Scene {
|
|||||||
if (this.buildMenuVisible) { this.closeBuildMenu(); return }
|
if (this.buildMenuVisible) { this.closeBuildMenu(); return }
|
||||||
if (this.villagerPanelVisible){ this.closeVillagerPanel(); return }
|
if (this.villagerPanelVisible){ this.closeVillagerPanel(); return }
|
||||||
if (this.nisseInfoVisible) { this.closeNisseInfoPanel(); return }
|
if (this.nisseInfoVisible) { this.closeNisseInfoPanel(); 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 mode: let BuildingSystem / FarmingSystem handle their own ESC key.
|
||||||
// We only skip opening the ESC menu while those modes are active.
|
// 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: '⚙️ Settings', action: () => this.doSettings() },
|
||||||
{ label: '🆕 New Game', action: () => this.doNewGame() },
|
{ 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 mx = this.scale.width / 2 - menuW / 2
|
||||||
const my = this.scale.height / 2 - menuH / 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)
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(400)
|
||||||
this.escMenuGroup.add(bg)
|
this.escMenuGroup.add(bg)
|
||||||
this.escMenuGroup.add(
|
this.escMenuGroup.add(
|
||||||
@@ -602,10 +615,155 @@ export class UIScene extends Phaser.Scene {
|
|||||||
window.location.reload()
|
window.location.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Opens an empty Settings panel (placeholder). */
|
/** Opens the Settings overlay. */
|
||||||
private doSettings(): void {
|
private doSettings(): void {
|
||||||
this.closeEscMenu()
|
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. */
|
/** 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 dx = this.scale.width / 2 - dialogW / 2
|
||||||
const dy = this.scale.height / 2 - dialogH / 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)
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(500)
|
||||||
this.confirmGroup.add(bg)
|
this.confirmGroup.add(bg)
|
||||||
|
|
||||||
@@ -692,7 +850,6 @@ export class UIScene extends Phaser.Scene {
|
|||||||
private openNisseInfoPanel(villagerId: string): void {
|
private openNisseInfoPanel(villagerId: string): void {
|
||||||
this.nisseInfoId = villagerId
|
this.nisseInfoId = villagerId
|
||||||
this.nisseInfoVisible = true
|
this.nisseInfoVisible = true
|
||||||
this.scene.get('Game').events.emit('uiMenuOpen')
|
|
||||||
this.buildNisseInfoPanel()
|
this.buildNisseInfoPanel()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -703,7 +860,6 @@ 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.scene.get('Game').events.emit('uiMenuClose')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -730,7 +886,7 @@ export class UIScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// Background
|
// Background
|
||||||
this.nisseInfoGroup.add(
|
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)
|
.setOrigin(0, 0).setScrollFactor(0).setDepth(250)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -883,6 +1039,7 @@ export class UIScene extends Phaser.Scene {
|
|||||||
if (this.villagerPanelVisible) this.closeVillagerPanel()
|
if (this.villagerPanelVisible) this.closeVillagerPanel()
|
||||||
if (this.contextMenuVisible) this.hideContextMenu()
|
if (this.contextMenuVisible) this.hideContextMenu()
|
||||||
if (this.escMenuVisible) this.closeEscMenu()
|
if (this.escMenuVisible) this.closeEscMenu()
|
||||||
|
if (this.settingsVisible) this.closeSettings()
|
||||||
if (this.confirmVisible) this.hideConfirm()
|
if (this.confirmVisible) this.hideConfirm()
|
||||||
if (this.nisseInfoVisible) this.closeNisseInfoPanel()
|
if (this.nisseInfoVisible) this.closeNisseInfoPanel()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ export class TreeSeedlingSystem {
|
|||||||
this.removeSprite(id)
|
this.removeSprite(id)
|
||||||
this.adapter.send({ type: 'REMOVE_TREE_SEEDLING', seedlingId: id })
|
this.adapter.send({ type: 'REMOVE_TREE_SEEDLING', seedlingId: id })
|
||||||
this.adapter.send({ type: 'CHANGE_TILE', tileX, tileY, tile: TileType.FOREST })
|
this.adapter.send({ type: 'CHANGE_TILE', tileX, tileY, tile: TileType.FOREST })
|
||||||
this.worldSystem.refreshTerrainTile(tileX, tileY, TileType.FOREST)
|
|
||||||
|
|
||||||
const resourceId = `tree_grown_${tileX}_${tileY}_${Date.now()}`
|
const resourceId = `tree_grown_${tileX}_${tileY}_${Date.now()}`
|
||||||
this.adapter.send({
|
this.adapter.send({
|
||||||
|
|||||||
@@ -274,10 +274,9 @@ export class VillagerSystem {
|
|||||||
const res = state.world.resources[job.targetId]
|
const res = state.world.resources[job.targetId]
|
||||||
if (res) {
|
if (res) {
|
||||||
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
|
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
|
||||||
// Clear the FOREST tile so the area becomes passable for future pathfinding
|
|
||||||
this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.DARK_GRASS })
|
this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.DARK_GRASS })
|
||||||
// Start recovery timer so DARK_GRASS reverts to GRASS after 5 minutes
|
|
||||||
this.adapter.send({ type: 'TILE_RECOVERY_START', tileX: res.tileX, tileY: res.tileY })
|
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.resourceSystem.removeResource(job.targetId)
|
||||||
this.addLog(v.id, '✓ Chopped tree (+2 wood)')
|
this.addLog(v.id, '✓ Chopped tree (+2 wood)')
|
||||||
}
|
}
|
||||||
@@ -285,8 +284,8 @@ export class VillagerSystem {
|
|||||||
const res = state.world.resources[job.targetId]
|
const res = state.world.resources[job.targetId]
|
||||||
if (res) {
|
if (res) {
|
||||||
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
|
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
|
||||||
// Clear the ROCK tile so the area becomes passable for future pathfinding
|
|
||||||
this.adapter.send({ type: 'CHANGE_TILE', tileX: res.tileX, tileY: res.tileY, tile: TileType.GRASS })
|
this.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.resourceSystem.removeResource(job.targetId)
|
||||||
this.addLog(v.id, '✓ Mined rock (+2 stone)')
|
this.addLog(v.id, '✓ Mined rock (+2 stone)')
|
||||||
}
|
}
|
||||||
@@ -351,12 +350,17 @@ export class VillagerSystem {
|
|||||||
if (p.chop > 0) {
|
if (p.chop > 0) {
|
||||||
for (const res of Object.values(state.world.resources)) {
|
for (const res of Object.values(state.world.resources)) {
|
||||||
if (res.kind !== 'tree' || this.claimed.has(res.id)) continue
|
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 })
|
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) {
|
if (p.mine > 0) {
|
||||||
for (const res of Object.values(state.world.resources)) {
|
for (const res of Object.values(state.world.resources)) {
|
||||||
if (res.kind !== 'rock' || this.claimed.has(res.id)) continue
|
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 })
|
candidates.push({ type: 'mine', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.mine })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -397,7 +401,7 @@ export class VillagerSystem {
|
|||||||
this.claimed.delete(v.job.targetId)
|
this.claimed.delete(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 = 1500 // longer delay after failed pathfind
|
rt.idleScanTimer = 4000 // longer delay after failed pathfind to avoid tight retry loops
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,6 +438,22 @@ export class VillagerSystem {
|
|||||||
return this.nearestBuilding(v, 'bed') as any
|
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 ─────────────────────────────────────────────────────────────
|
// ─── Spawning ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Phaser from 'phaser'
|
import Phaser from 'phaser'
|
||||||
import { TILE_SIZE, WORLD_TILES } from '../config'
|
import { TILE_SIZE, WORLD_TILES } from '../config'
|
||||||
import { TileType, IMPASSABLE } from '../types'
|
import { TileType, IMPASSABLE, RESOURCE_TERRAIN } from '../types'
|
||||||
import { stateManager } from '../StateManager'
|
import { stateManager } from '../StateManager'
|
||||||
|
|
||||||
const BIOME_COLORS: Record<number, string> = {
|
const BIOME_COLORS: Record<number, string> = {
|
||||||
@@ -18,6 +18,12 @@ const BIOME_COLORS: Record<number, string> = {
|
|||||||
export class WorldSystem {
|
export class WorldSystem {
|
||||||
private scene: Phaser.Scene
|
private scene: Phaser.Scene
|
||||||
private map!: Phaser.Tilemaps.Tilemap
|
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 tileset!: Phaser.Tilemaps.Tileset
|
||||||
private bgImage!: Phaser.GameObjects.Image
|
private bgImage!: Phaser.GameObjects.Image
|
||||||
private builtLayer!: Phaser.Tilemaps.TilemapLayer
|
private builtLayer!: Phaser.Tilemaps.TilemapLayer
|
||||||
@@ -85,6 +91,8 @@ export class WorldSystem {
|
|||||||
|
|
||||||
// Camera bounds
|
// Camera bounds
|
||||||
this.scene.cameras.main.setBounds(0, 0, WORLD_TILES * TILE_SIZE, WORLD_TILES * TILE_SIZE)
|
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). */
|
/** Returns the built-tile tilemap layer (floor, wall, soil). */
|
||||||
@@ -111,6 +119,10 @@ export class WorldSystem {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns whether the tile at the given coordinates can be walked on.
|
* 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.
|
* Out-of-bounds tiles are treated as impassable.
|
||||||
* @param tileX - Tile column
|
* @param tileX - Tile column
|
||||||
* @param tileY - Tile row
|
* @param tileY - Tile row
|
||||||
@@ -119,7 +131,45 @@ export class WorldSystem {
|
|||||||
if (tileX < 0 || tileY < 0 || tileX >= WORLD_TILES || tileY >= WORLD_TILES) return false
|
if (tileX < 0 || tileY < 0 || tileX >= WORLD_TILES || tileY >= WORLD_TILES) return false
|
||||||
const state = stateManager.getState()
|
const state = stateManager.getState()
|
||||||
const tile = state.world.tiles[tileY * WORLD_TILES + tileX]
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
11
src/types.ts
11
src/types.ts
@@ -12,14 +12,21 @@ export enum TileType {
|
|||||||
WATERED_SOIL = 10,
|
WATERED_SOIL = 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tiles that are always impassable regardless of what is on them. */
|
||||||
export const IMPASSABLE = new Set<number>([
|
export const IMPASSABLE = new Set<number>([
|
||||||
TileType.DEEP_WATER,
|
TileType.DEEP_WATER,
|
||||||
TileType.SHALLOW_WATER,
|
TileType.SHALLOW_WATER,
|
||||||
TileType.FOREST,
|
|
||||||
TileType.ROCK,
|
|
||||||
TileType.WALL,
|
TileType.WALL,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. */
|
/** Tiles on which tree seedlings may be planted. */
|
||||||
export const PLANTABLE_TILES = new Set<TileType>([TileType.GRASS, TileType.DARK_GRASS])
|
export const PLANTABLE_TILES = new Set<TileType>([TileType.GRASS, TileType.DARK_GRASS])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user