🎉 initial commit
This commit is contained in:
182
src/systems/BuildingSystem.ts
Normal file
182
src/systems/BuildingSystem.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import Phaser from 'phaser'
|
||||
import { TILE_SIZE, BUILDING_COSTS } from '../config'
|
||||
import { TileType, IMPASSABLE } from '../types'
|
||||
import type { BuildingType } from '../types'
|
||||
import { stateManager } from '../StateManager'
|
||||
import type { LocalAdapter } from '../NetworkAdapter'
|
||||
|
||||
const BUILDING_TILE: Partial<Record<BuildingType, TileType>> = {
|
||||
floor: TileType.FLOOR,
|
||||
wall: TileType.WALL,
|
||||
chest: TileType.FLOOR, // chest placed on floor tile
|
||||
// bed and stockpile_zone do NOT change the underlying tile
|
||||
}
|
||||
|
||||
export class BuildingSystem {
|
||||
private scene: Phaser.Scene
|
||||
private adapter: LocalAdapter
|
||||
private active = false
|
||||
private selectedBuilding: BuildingType = 'floor'
|
||||
private ghost!: Phaser.GameObjects.Rectangle
|
||||
private ghostLabel!: Phaser.GameObjects.Text
|
||||
private buildKey!: Phaser.Input.Keyboard.Key
|
||||
private cancelKey!: Phaser.Input.Keyboard.Key
|
||||
|
||||
onModeChange?: (active: boolean, building: BuildingType) => void
|
||||
onPlaced?: (msg: string) => void
|
||||
|
||||
constructor(scene: Phaser.Scene, adapter: LocalAdapter) {
|
||||
this.scene = scene
|
||||
this.adapter = adapter
|
||||
}
|
||||
|
||||
create(): void {
|
||||
this.ghost = this.scene.add.rectangle(0, 0, TILE_SIZE, TILE_SIZE, 0x00FF00, 0.35)
|
||||
this.ghost.setDepth(20)
|
||||
this.ghost.setVisible(false)
|
||||
this.ghost.setStrokeStyle(2, 0x00FF00, 0.8)
|
||||
|
||||
this.ghostLabel = this.scene.add.text(0, 0, '', {
|
||||
fontSize: '10px', color: '#ffffff', fontFamily: 'monospace',
|
||||
backgroundColor: '#000000aa', padding: { x: 3, y: 2 }
|
||||
})
|
||||
this.ghostLabel.setDepth(21)
|
||||
this.ghostLabel.setVisible(false)
|
||||
this.ghostLabel.setOrigin(0.5, 1)
|
||||
|
||||
this.buildKey = this.scene.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.B)
|
||||
this.cancelKey = this.scene.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.ESC)
|
||||
|
||||
// Click to place
|
||||
this.scene.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
||||
if (!this.active) return
|
||||
if (ptr.rightButtonDown()) {
|
||||
this.deactivate()
|
||||
return
|
||||
}
|
||||
this.tryPlace(ptr)
|
||||
})
|
||||
}
|
||||
|
||||
/** Select a building type and activate build mode */
|
||||
selectBuilding(kind: BuildingType): void {
|
||||
this.selectedBuilding = kind
|
||||
this.activate()
|
||||
}
|
||||
|
||||
private activate(): void {
|
||||
this.active = true
|
||||
this.ghost.setVisible(true)
|
||||
this.ghostLabel.setVisible(true)
|
||||
this.onModeChange?.(true, this.selectedBuilding)
|
||||
}
|
||||
|
||||
deactivate(): void {
|
||||
this.active = false
|
||||
this.ghost.setVisible(false)
|
||||
this.ghostLabel.setVisible(false)
|
||||
this.onModeChange?.(false, this.selectedBuilding)
|
||||
}
|
||||
|
||||
isActive(): boolean { return this.active }
|
||||
|
||||
update(): void {
|
||||
if (Phaser.Input.Keyboard.JustDown(this.buildKey)) {
|
||||
if (this.active) this.deactivate()
|
||||
// If not active, UIScene opens build menu
|
||||
}
|
||||
if (Phaser.Input.Keyboard.JustDown(this.cancelKey)) {
|
||||
this.deactivate()
|
||||
}
|
||||
|
||||
if (!this.active) return
|
||||
|
||||
// Update ghost to follow mouse (snapped to tile grid)
|
||||
const ptr = this.scene.input.activePointer
|
||||
const worldX = this.scene.cameras.main.scrollX + ptr.x
|
||||
const worldY = this.scene.cameras.main.scrollY + ptr.y
|
||||
const tileX = Math.floor(worldX / TILE_SIZE)
|
||||
const tileY = Math.floor(worldY / TILE_SIZE)
|
||||
const snapX = tileX * TILE_SIZE + TILE_SIZE / 2
|
||||
const snapY = tileY * TILE_SIZE + TILE_SIZE / 2
|
||||
|
||||
this.ghost.setPosition(snapX, snapY)
|
||||
this.ghostLabel.setPosition(snapX, snapY - TILE_SIZE / 2 - 2)
|
||||
|
||||
// Color ghost based on can-build
|
||||
const canBuild = this.canBuildAt(tileX, tileY)
|
||||
const color = canBuild ? 0x00FF00 : 0xFF4444
|
||||
this.ghost.setFillStyle(color, 0.35)
|
||||
this.ghost.setStrokeStyle(2, color, 0.9)
|
||||
|
||||
const costs = BUILDING_COSTS[this.selectedBuilding] ?? {}
|
||||
const costStr = Object.entries(costs).map(([k, v]) => `${v}${k[0].toUpperCase()}`).join(' ')
|
||||
this.ghostLabel.setText(`${this.selectedBuilding} [${costStr}]`)
|
||||
}
|
||||
|
||||
private canBuildAt(tileX: number, tileY: number): boolean {
|
||||
const state = stateManager.getState()
|
||||
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)
|
||||
if (IMPASSABLE.has(tile)) return false
|
||||
|
||||
// Check no resource node on this tile
|
||||
for (const res of Object.values(state.world.resources)) {
|
||||
if (res.tileX === tileX && res.tileY === tileY) return false
|
||||
}
|
||||
|
||||
// Check no existing building of any kind on this tile
|
||||
for (const b of Object.values(state.world.buildings)) {
|
||||
if (b.tileX === tileX && b.tileY === tileY) return false
|
||||
}
|
||||
|
||||
// Check have enough resources
|
||||
const costs = BUILDING_COSTS[this.selectedBuilding] ?? {}
|
||||
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
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private tryPlace(ptr: Phaser.Input.Pointer): void {
|
||||
const worldX = this.scene.cameras.main.scrollX + ptr.x
|
||||
const worldY = this.scene.cameras.main.scrollY + ptr.y
|
||||
const tileX = Math.floor(worldX / TILE_SIZE)
|
||||
const tileY = Math.floor(worldY / TILE_SIZE)
|
||||
|
||||
if (!this.canBuildAt(tileX, tileY)) {
|
||||
this.onPlaced?.('Cannot build here!')
|
||||
return
|
||||
}
|
||||
|
||||
const costs = BUILDING_COSTS[this.selectedBuilding] ?? {}
|
||||
const building = {
|
||||
id: `building_${tileX}_${tileY}_${Date.now()}`,
|
||||
tileX,
|
||||
tileY,
|
||||
kind: this.selectedBuilding,
|
||||
ownerId: stateManager.getState().player.id,
|
||||
}
|
||||
|
||||
this.adapter.send({ type: 'PLACE_BUILDING', building, costs })
|
||||
// Only change the tile type for buildings that have a floor/wall tile mapping
|
||||
const tileMapped = BUILDING_TILE[this.selectedBuilding]
|
||||
if (tileMapped !== undefined) {
|
||||
this.adapter.send({
|
||||
type: 'CHANGE_TILE',
|
||||
tileX,
|
||||
tileY,
|
||||
tile: tileMapped,
|
||||
})
|
||||
}
|
||||
this.onPlaced?.(`Placed ${this.selectedBuilding}!`)
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.ghost.destroy()
|
||||
this.ghostLabel.destroy()
|
||||
}
|
||||
}
|
||||
105
src/systems/CameraSystem.ts
Normal file
105
src/systems/CameraSystem.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import Phaser from 'phaser'
|
||||
import { WORLD_TILES } from '../config'
|
||||
import { stateManager } from '../StateManager'
|
||||
import type { LocalAdapter } from '../NetworkAdapter'
|
||||
|
||||
const CAMERA_SPEED = 400 // px/s
|
||||
const MIN_ZOOM = 0.25
|
||||
const MAX_ZOOM = 2.0
|
||||
const ZOOM_STEP = 0.1
|
||||
|
||||
export class CameraSystem {
|
||||
private scene: Phaser.Scene
|
||||
private adapter: LocalAdapter
|
||||
private keys!: {
|
||||
up: Phaser.Input.Keyboard.Key
|
||||
down: Phaser.Input.Keyboard.Key
|
||||
left: Phaser.Input.Keyboard.Key
|
||||
right: Phaser.Input.Keyboard.Key
|
||||
w: Phaser.Input.Keyboard.Key
|
||||
s: Phaser.Input.Keyboard.Key
|
||||
a: Phaser.Input.Keyboard.Key
|
||||
d: Phaser.Input.Keyboard.Key
|
||||
}
|
||||
private saveTimer = 0
|
||||
private readonly SAVE_TICK = 2000
|
||||
|
||||
constructor(scene: Phaser.Scene, adapter: LocalAdapter) {
|
||||
this.scene = scene
|
||||
this.adapter = adapter
|
||||
}
|
||||
|
||||
create(): void {
|
||||
const state = stateManager.getState()
|
||||
const cam = this.scene.cameras.main
|
||||
|
||||
// Start at saved player position (reused as camera anchor)
|
||||
cam.scrollX = state.player.x - cam.width / 2
|
||||
cam.scrollY = state.player.y - cam.height / 2
|
||||
|
||||
const kb = this.scene.input.keyboard!
|
||||
this.keys = {
|
||||
up: kb.addKey(Phaser.Input.Keyboard.KeyCodes.UP),
|
||||
down: kb.addKey(Phaser.Input.Keyboard.KeyCodes.DOWN),
|
||||
left: kb.addKey(Phaser.Input.Keyboard.KeyCodes.LEFT),
|
||||
right: kb.addKey(Phaser.Input.Keyboard.KeyCodes.RIGHT),
|
||||
w: kb.addKey(Phaser.Input.Keyboard.KeyCodes.W),
|
||||
s: kb.addKey(Phaser.Input.Keyboard.KeyCodes.S),
|
||||
a: kb.addKey(Phaser.Input.Keyboard.KeyCodes.A),
|
||||
d: kb.addKey(Phaser.Input.Keyboard.KeyCodes.D),
|
||||
}
|
||||
|
||||
// Scroll wheel zoom
|
||||
this.scene.input.on('wheel', (_ptr: Phaser.Input.Pointer, _objs: unknown, _dx: number, dy: number) => {
|
||||
const zoom = Phaser.Math.Clamp(cam.zoom - Math.sign(dy) * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM)
|
||||
cam.setZoom(zoom)
|
||||
})
|
||||
}
|
||||
|
||||
update(delta: number): void {
|
||||
const cam = this.scene.cameras.main
|
||||
const speed = CAMERA_SPEED * (delta / 1000) / cam.zoom
|
||||
|
||||
const up = this.keys.up.isDown || this.keys.w.isDown
|
||||
const down = this.keys.down.isDown || this.keys.s.isDown
|
||||
const left = this.keys.left.isDown || this.keys.a.isDown
|
||||
const right = this.keys.right.isDown || this.keys.d.isDown
|
||||
|
||||
let dx = 0, dy = 0
|
||||
if (left) dx -= speed
|
||||
if (right) dx += speed
|
||||
if (up) dy -= speed
|
||||
if (down) dy += speed
|
||||
|
||||
if (dx !== 0 && dy !== 0) { dx *= 0.707; dy *= 0.707 }
|
||||
|
||||
const worldW = WORLD_TILES * 32 // TILE_SIZE hardcoded since WORLD_PX may not exist
|
||||
const worldH = WORLD_TILES * 32
|
||||
cam.scrollX = Phaser.Math.Clamp(cam.scrollX + dx, 0, worldW - cam.width / cam.zoom)
|
||||
cam.scrollY = Phaser.Math.Clamp(cam.scrollY + dy, 0, worldH - cam.height / cam.zoom)
|
||||
|
||||
// Periodically save camera center as "player position"
|
||||
this.saveTimer += delta
|
||||
if (this.saveTimer >= this.SAVE_TICK) {
|
||||
this.saveTimer = 0
|
||||
this.adapter.send({
|
||||
type: 'PLAYER_MOVE',
|
||||
x: cam.scrollX + cam.width / 2,
|
||||
y: cam.scrollY + cam.height / 2,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
getCenterWorld(): { x: number; y: number } {
|
||||
const cam = this.scene.cameras.main
|
||||
return {
|
||||
x: cam.scrollX + cam.width / (2 * cam.zoom),
|
||||
y: cam.scrollY + cam.height / (2 * cam.zoom),
|
||||
}
|
||||
}
|
||||
|
||||
getCenterTile(): { tileX: number; tileY: number } {
|
||||
const { x, y } = this.getCenterWorld()
|
||||
return { tileX: Math.floor(x / 32), tileY: Math.floor(y / 32) }
|
||||
}
|
||||
}
|
||||
205
src/systems/FarmingSystem.ts
Normal file
205
src/systems/FarmingSystem.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import Phaser from 'phaser'
|
||||
import { TILE_SIZE, CROP_CONFIGS } from '../config'
|
||||
import { TileType } from '../types'
|
||||
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'
|
||||
|
||||
const TOOL_CYCLE: FarmingTool[] = ['none', 'hoe', 'wheat_seed', 'carrot_seed', 'water']
|
||||
|
||||
const TOOL_LABELS: Record<FarmingTool, string> = {
|
||||
none: '— None',
|
||||
hoe: '⛏ Hoe (till grass)',
|
||||
wheat_seed: '🌾 Wheat Seeds',
|
||||
carrot_seed: '🥕 Carrot Seeds',
|
||||
water: '💧 Watering Can',
|
||||
}
|
||||
|
||||
export class FarmingSystem {
|
||||
private scene: Phaser.Scene
|
||||
private adapter: LocalAdapter
|
||||
private currentTool: FarmingTool = 'none'
|
||||
private cropSprites = new Map<string, Phaser.GameObjects.Image>()
|
||||
private toolKey!: Phaser.Input.Keyboard.Key
|
||||
private clickCooldown = 0
|
||||
private readonly COOLDOWN = 300
|
||||
|
||||
/** Emitted when the tool changes — pass (tool, label) */
|
||||
onToolChange?: (tool: FarmingTool, label: string) => void
|
||||
/** Emitted for toast notifications */
|
||||
onMessage?: (msg: string) => void
|
||||
|
||||
constructor(scene: Phaser.Scene, adapter: LocalAdapter) {
|
||||
this.scene = scene
|
||||
this.adapter = adapter
|
||||
}
|
||||
|
||||
create(): void {
|
||||
// Restore crop sprites for any saved crops
|
||||
const state = stateManager.getState()
|
||||
for (const crop of Object.values(state.world.crops)) {
|
||||
this.spawnCropSprite(crop)
|
||||
}
|
||||
|
||||
this.toolKey = this.scene.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.F)
|
||||
|
||||
// Left-click to use current tool
|
||||
this.scene.input.on('pointerdown', (ptr: Phaser.Input.Pointer) => {
|
||||
if (this.currentTool === 'none') return
|
||||
if (ptr.rightButtonDown()) { this.setTool('none'); return }
|
||||
if (this.clickCooldown > 0) return
|
||||
this.useToolAt(ptr)
|
||||
this.clickCooldown = this.COOLDOWN
|
||||
})
|
||||
}
|
||||
|
||||
/** Called every frame. */
|
||||
update(delta: number): void {
|
||||
if (this.clickCooldown > 0) this.clickCooldown -= delta
|
||||
|
||||
// F key cycles through tools
|
||||
if (Phaser.Input.Keyboard.JustDown(this.toolKey)) {
|
||||
const idx = TOOL_CYCLE.indexOf(this.currentTool)
|
||||
this.setTool(TOOL_CYCLE[(idx + 1) % TOOL_CYCLE.length])
|
||||
}
|
||||
|
||||
// Tick crop growth
|
||||
const leveled = stateManager.tickCrops(delta)
|
||||
for (const id of leveled) this.refreshCropSprite(id)
|
||||
}
|
||||
|
||||
getCurrentTool(): FarmingTool { return this.currentTool }
|
||||
|
||||
private setTool(tool: FarmingTool): void {
|
||||
this.currentTool = tool
|
||||
this.onToolChange?.(tool, TOOL_LABELS[tool])
|
||||
}
|
||||
|
||||
// ─── Tool actions ─────────────────────────────────────────────────────────
|
||||
|
||||
private useToolAt(ptr: Phaser.Input.Pointer): void {
|
||||
const cam = this.scene.cameras.main
|
||||
const worldX = cam.scrollX + ptr.x
|
||||
const worldY = cam.scrollY + ptr.y
|
||||
const tileX = Math.floor(worldX / TILE_SIZE)
|
||||
const tileY = Math.floor(worldY / TILE_SIZE)
|
||||
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)
|
||||
}
|
||||
|
||||
private tillSoil(tileX: number, tileY: number, tile: TileType): void {
|
||||
if (tile !== TileType.GRASS && tile !== TileType.DARK_GRASS) {
|
||||
this.onMessage?.('Hoe only works on grass!')
|
||||
return
|
||||
}
|
||||
const state = stateManager.getState()
|
||||
const blocked =
|
||||
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)
|
||||
if (blocked) { this.onMessage?.('Something is in the way!'); return }
|
||||
|
||||
this.adapter.send({ type: 'CHANGE_TILE', tileX, tileY, tile: TileType.TILLED_SOIL })
|
||||
this.onMessage?.('Soil tilled ✓')
|
||||
}
|
||||
|
||||
private plantCrop(tileX: number, tileY: number, tile: TileType, kind: CropKind): void {
|
||||
if (tile !== TileType.TILLED_SOIL && tile !== TileType.WATERED_SOIL) {
|
||||
this.onMessage?.('Plant on tilled soil!')
|
||||
return
|
||||
}
|
||||
const state = stateManager.getState()
|
||||
const seedItem: ItemId = `${kind}_seed` as ItemId
|
||||
const have = state.world.stockpile[seedItem] ?? 0
|
||||
if (have <= 0) { this.onMessage?.(`No ${kind} seeds left!`); return }
|
||||
|
||||
if (Object.values(state.world.crops).some(c => c.tileX === tileX && c.tileY === tileY)) {
|
||||
this.onMessage?.('Already planted here!')
|
||||
return
|
||||
}
|
||||
|
||||
const cfg = CROP_CONFIGS[kind]
|
||||
const crop: CropState = {
|
||||
id: `crop_${tileX}_${tileY}_${Date.now()}`,
|
||||
tileX, tileY, kind,
|
||||
stage: 0, maxStage: cfg.stages,
|
||||
stageTimerMs: cfg.stageTimeMs,
|
||||
watered: tile === TileType.WATERED_SOIL,
|
||||
}
|
||||
this.adapter.send({ type: 'PLANT_CROP', crop, seedItem })
|
||||
this.spawnCropSprite(crop)
|
||||
this.onMessage?.(`${kind} seed planted! 🌱`)
|
||||
}
|
||||
|
||||
private waterTile(tileX: number, tileY: number, tile: TileType): void {
|
||||
if (tile !== TileType.TILLED_SOIL && tile !== TileType.WATERED_SOIL) {
|
||||
this.onMessage?.('Water tilled soil!')
|
||||
return
|
||||
}
|
||||
this.adapter.send({ type: 'CHANGE_TILE', tileX, tileY, tile: TileType.WATERED_SOIL })
|
||||
const state = stateManager.getState()
|
||||
const crop = Object.values(state.world.crops).find(c => c.tileX === tileX && c.tileY === tileY)
|
||||
if (crop) this.adapter.send({ type: 'WATER_CROP', cropId: crop.id })
|
||||
this.onMessage?.('Watered! (2× growth speed)')
|
||||
}
|
||||
|
||||
harvestCrop(id: string): void {
|
||||
const state = stateManager.getState()
|
||||
const crop = state.world.crops[id]
|
||||
if (!crop) return
|
||||
const cfg = CROP_CONFIGS[crop.kind]
|
||||
this.adapter.send({ type: 'HARVEST_CROP', cropId: id, rewards: cfg.rewards })
|
||||
this.adapter.send({ type: 'CHANGE_TILE', tileX: crop.tileX, tileY: crop.tileY, tile: TileType.TILLED_SOIL })
|
||||
this.removeCropSprite(id)
|
||||
const rewardStr = Object.entries(cfg.rewards).map(([k, v]) => `+${v} ${k}`).join(', ')
|
||||
this.onMessage?.(`${crop.kind} harvested! ${rewardStr}`)
|
||||
}
|
||||
|
||||
// ─── Sprite management ────────────────────────────────────────────────────
|
||||
|
||||
private spawnCropSprite(crop: CropState): void {
|
||||
const x = (crop.tileX + 0.5) * TILE_SIZE
|
||||
const y = (crop.tileY + 0.5) * TILE_SIZE
|
||||
const key = this.spriteKey(crop.kind, crop.stage, crop.maxStage)
|
||||
const sprite = this.scene.add.image(x, y, key)
|
||||
sprite.setOrigin(0.5, 0.85).setDepth(7)
|
||||
this.cropSprites.set(crop.id, sprite)
|
||||
}
|
||||
|
||||
private refreshCropSprite(cropId: string): void {
|
||||
const sprite = this.cropSprites.get(cropId)
|
||||
if (!sprite) return
|
||||
const crop = stateManager.getState().world.crops[cropId]
|
||||
if (!crop) return
|
||||
sprite.setTexture(this.spriteKey(crop.kind, crop.stage, crop.maxStage))
|
||||
// Subtle pop animation on growth
|
||||
this.scene.tweens.add({
|
||||
targets: sprite, scaleX: 1.25, scaleY: 1.25, duration: 80,
|
||||
yoyo: true, ease: 'Back.easeOut',
|
||||
})
|
||||
}
|
||||
|
||||
/** Called by VillagerSystem when a villager harvests a crop */
|
||||
public removeCropSpritePublic(id: string): void {
|
||||
this.removeCropSprite(id)
|
||||
}
|
||||
|
||||
private removeCropSprite(id: string): void {
|
||||
const s = this.cropSprites.get(id)
|
||||
if (s) { s.destroy(); this.cropSprites.delete(id) }
|
||||
}
|
||||
|
||||
private spriteKey(kind: CropKind, stage: number, maxStage: number): string {
|
||||
return `crop_${kind}_${Math.min(stage, maxStage)}`
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
for (const id of [...this.cropSprites.keys()]) this.removeCropSprite(id)
|
||||
}
|
||||
}
|
||||
120
src/systems/PlayerSystem.ts
Normal file
120
src/systems/PlayerSystem.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import Phaser from 'phaser'
|
||||
import { PLAYER_SPEED, TILE_SIZE, CAMERA_LERP } from '../config'
|
||||
import { stateManager } from '../StateManager'
|
||||
import type { LocalAdapter } from '../NetworkAdapter'
|
||||
|
||||
export class PlayerSystem {
|
||||
private scene: Phaser.Scene
|
||||
private adapter: LocalAdapter
|
||||
sprite!: Phaser.Physics.Arcade.Sprite
|
||||
private keys!: {
|
||||
up: Phaser.Input.Keyboard.Key
|
||||
down: Phaser.Input.Keyboard.Key
|
||||
left: Phaser.Input.Keyboard.Key
|
||||
right: Phaser.Input.Keyboard.Key
|
||||
w: Phaser.Input.Keyboard.Key
|
||||
s: Phaser.Input.Keyboard.Key
|
||||
a: Phaser.Input.Keyboard.Key
|
||||
d: Phaser.Input.Keyboard.Key
|
||||
}
|
||||
private facing: 'up' | 'down' | 'left' | 'right' = 'down'
|
||||
private moveTickTimer = 0
|
||||
private readonly SAVE_TICK = 500 // save position every 500ms
|
||||
|
||||
constructor(scene: Phaser.Scene, adapter: LocalAdapter) {
|
||||
this.scene = scene
|
||||
this.adapter = adapter
|
||||
}
|
||||
|
||||
create(groundLayer: Phaser.Tilemaps.TilemapLayer): void {
|
||||
const state = stateManager.getState()
|
||||
|
||||
this.sprite = this.scene.physics.add.sprite(
|
||||
state.player.x,
|
||||
state.player.y,
|
||||
'player_down'
|
||||
)
|
||||
|
||||
this.sprite.setCollideWorldBounds(true)
|
||||
this.sprite.setDepth(10)
|
||||
// Make the physics body smaller than the sprite for better feel
|
||||
this.sprite.setBodySize(TILE_SIZE - 10, TILE_SIZE - 10)
|
||||
|
||||
// Collide with tilemap
|
||||
this.scene.physics.add.collider(this.sprite, groundLayer)
|
||||
|
||||
// Camera follows player
|
||||
this.scene.cameras.main.startFollow(this.sprite, true, CAMERA_LERP, CAMERA_LERP)
|
||||
|
||||
// Input
|
||||
const kb = this.scene.input.keyboard!
|
||||
this.keys = {
|
||||
up: kb.addKey(Phaser.Input.Keyboard.KeyCodes.UP),
|
||||
down: kb.addKey(Phaser.Input.Keyboard.KeyCodes.DOWN),
|
||||
left: kb.addKey(Phaser.Input.Keyboard.KeyCodes.LEFT),
|
||||
right: kb.addKey(Phaser.Input.Keyboard.KeyCodes.RIGHT),
|
||||
w: kb.addKey(Phaser.Input.Keyboard.KeyCodes.W),
|
||||
s: kb.addKey(Phaser.Input.Keyboard.KeyCodes.S),
|
||||
a: kb.addKey(Phaser.Input.Keyboard.KeyCodes.A),
|
||||
d: kb.addKey(Phaser.Input.Keyboard.KeyCodes.D),
|
||||
}
|
||||
}
|
||||
|
||||
update(delta: number): void {
|
||||
const up = this.keys.up.isDown || this.keys.w.isDown
|
||||
const down = this.keys.down.isDown || this.keys.s.isDown
|
||||
const left = this.keys.left.isDown || this.keys.a.isDown
|
||||
const right = this.keys.right.isDown || this.keys.d.isDown
|
||||
|
||||
let vx = 0
|
||||
let vy = 0
|
||||
|
||||
if (left) vx -= PLAYER_SPEED
|
||||
if (right) vx += PLAYER_SPEED
|
||||
if (up) vy -= PLAYER_SPEED
|
||||
if (down) vy += PLAYER_SPEED
|
||||
|
||||
// Normalize diagonal movement
|
||||
if (vx !== 0 && vy !== 0) {
|
||||
vx *= 0.707
|
||||
vy *= 0.707
|
||||
}
|
||||
|
||||
this.sprite.setVelocity(vx, vy)
|
||||
|
||||
// Update facing direction
|
||||
if (vx < 0) this.setFacing('left')
|
||||
else if (vx > 0) this.setFacing('right')
|
||||
else if (vy < 0) this.setFacing('up')
|
||||
else if (vy > 0) this.setFacing('down')
|
||||
|
||||
// Periodically save position to state
|
||||
this.moveTickTimer += delta
|
||||
if (this.moveTickTimer >= this.SAVE_TICK) {
|
||||
this.moveTickTimer = 0
|
||||
this.adapter.send({
|
||||
type: 'PLAYER_MOVE',
|
||||
x: this.sprite.x,
|
||||
y: this.sprite.y,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private setFacing(dir: 'up' | 'down' | 'left' | 'right'): void {
|
||||
if (this.facing === dir) return
|
||||
this.facing = dir
|
||||
this.sprite.setTexture(`player_${dir}`)
|
||||
}
|
||||
|
||||
getPosition(): { x: number; y: number } {
|
||||
return { x: this.sprite.x, y: this.sprite.y }
|
||||
}
|
||||
|
||||
getTilePosition(): { tileX: number; tileY: number } {
|
||||
return {
|
||||
tileX: Math.floor(this.sprite.x / TILE_SIZE),
|
||||
tileY: Math.floor(this.sprite.y / TILE_SIZE),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
90
src/systems/ResourceSystem.ts
Normal file
90
src/systems/ResourceSystem.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import Phaser from 'phaser'
|
||||
import { TILE_SIZE } from '../config'
|
||||
import { TileType } from '../types'
|
||||
import { stateManager } from '../StateManager'
|
||||
import type { LocalAdapter } from '../NetworkAdapter'
|
||||
import type { ResourceNodeState } from '../types'
|
||||
|
||||
interface ResourceSprite {
|
||||
sprite: Phaser.GameObjects.Image
|
||||
node: ResourceNodeState
|
||||
healthBar: Phaser.GameObjects.Graphics
|
||||
}
|
||||
|
||||
export class ResourceSystem {
|
||||
private scene: Phaser.Scene
|
||||
private adapter: LocalAdapter
|
||||
private sprites = new Map<string, ResourceSprite>()
|
||||
|
||||
// Emits after each successful harvest: (message: string)
|
||||
onHarvest?: (message: string) => void
|
||||
|
||||
constructor(scene: Phaser.Scene, adapter: LocalAdapter) {
|
||||
this.scene = scene
|
||||
this.adapter = adapter
|
||||
}
|
||||
|
||||
create(): void {
|
||||
const state = stateManager.getState()
|
||||
|
||||
// Spawn sprites for all resources in state
|
||||
for (const node of Object.values(state.world.resources)) {
|
||||
this.spawnSprite(node)
|
||||
}
|
||||
}
|
||||
|
||||
private spawnSprite(node: ResourceNodeState): void {
|
||||
const x = (node.tileX + 0.5) * TILE_SIZE
|
||||
const y = (node.tileY + 0.5) * TILE_SIZE
|
||||
|
||||
const key = node.kind === 'tree' ? 'tree' : 'rock'
|
||||
const sprite = this.scene.add.image(x, y, key)
|
||||
|
||||
// Trees are taller; offset them upward so trunk sits on tile
|
||||
if (node.kind === 'tree') {
|
||||
sprite.setOrigin(0.5, 0.85)
|
||||
} else {
|
||||
sprite.setOrigin(0.5, 0.75)
|
||||
}
|
||||
|
||||
sprite.setDepth(5)
|
||||
|
||||
const healthBar = this.scene.add.graphics()
|
||||
healthBar.setDepth(6)
|
||||
healthBar.setVisible(false)
|
||||
|
||||
this.sprites.set(node.id, { sprite, node, healthBar })
|
||||
}
|
||||
|
||||
update(delta: number): void {
|
||||
// Hide all health bars each frame (no player proximity detection)
|
||||
for (const entry of this.sprites.values()) {
|
||||
entry.healthBar.setVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
private removeSprite(id: string): void {
|
||||
const entry = this.sprites.get(id)
|
||||
if (!entry) return
|
||||
entry.sprite.destroy()
|
||||
entry.healthBar.destroy()
|
||||
this.sprites.delete(id)
|
||||
}
|
||||
|
||||
/** Called by VillagerSystem when a villager finishes chopping/mining */
|
||||
public removeResource(id: string): void {
|
||||
this.removeSprite(id)
|
||||
}
|
||||
|
||||
/** 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()
|
||||
const idx = tileY * 512 + tileX // WORLD_TILES = 512
|
||||
const tile = state.world.tiles[idx] as TileType
|
||||
worldSystem.setTile(tileX, tileY, tile)
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
for (const [id] of this.sprites) this.removeSprite(id)
|
||||
}
|
||||
}
|
||||
393
src/systems/VillagerSystem.ts
Normal file
393
src/systems/VillagerSystem.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
import Phaser from 'phaser'
|
||||
import { TILE_SIZE, VILLAGER_SPEED, VILLAGER_SPAWN_INTERVAL, VILLAGER_WORK_TIMES, VILLAGER_NAMES } from '../config'
|
||||
import type { VillagerState, VillagerJob, JobType, AIState, ItemId } from '../types'
|
||||
import { stateManager } from '../StateManager'
|
||||
import { findPath } from '../utils/pathfinding'
|
||||
import type { LocalAdapter } from '../NetworkAdapter'
|
||||
import type { WorldSystem } from './WorldSystem'
|
||||
import type { ResourceSystem } from './ResourceSystem'
|
||||
import type { FarmingSystem } from './FarmingSystem'
|
||||
|
||||
const ARRIVAL_PX = 3
|
||||
|
||||
interface VillagerRuntime {
|
||||
sprite: Phaser.GameObjects.Image
|
||||
nameLabel: Phaser.GameObjects.Text
|
||||
energyBar: Phaser.GameObjects.Graphics
|
||||
jobIcon: Phaser.GameObjects.Text
|
||||
path: Array<{ tileX: number; tileY: number }>
|
||||
destination: 'job' | 'stockpile' | 'bed' | null
|
||||
workTimer: number
|
||||
idleScanTimer: number
|
||||
}
|
||||
|
||||
export class VillagerSystem {
|
||||
private scene: Phaser.Scene
|
||||
private adapter: LocalAdapter
|
||||
private worldSystem: WorldSystem
|
||||
private resourceSystem!: ResourceSystem
|
||||
private farmingSystem!: FarmingSystem
|
||||
|
||||
private runtime = new Map<string, VillagerRuntime>()
|
||||
private claimed = new Set<string>() // target IDs currently claimed by a villager
|
||||
private spawnTimer = 0
|
||||
private nameIndex = 0
|
||||
|
||||
onMessage?: (msg: string) => void
|
||||
|
||||
constructor(scene: Phaser.Scene, adapter: LocalAdapter, worldSystem: WorldSystem) {
|
||||
this.scene = scene
|
||||
this.adapter = adapter
|
||||
this.worldSystem = worldSystem
|
||||
}
|
||||
|
||||
/** Wire in sibling systems after construction */
|
||||
init(resourceSystem: ResourceSystem, farmingSystem: FarmingSystem): void {
|
||||
this.resourceSystem = resourceSystem
|
||||
this.farmingSystem = farmingSystem
|
||||
}
|
||||
|
||||
create(): void {
|
||||
const state = stateManager.getState()
|
||||
for (const v of Object.values(state.world.villagers)) {
|
||||
this.spawnSprite(v)
|
||||
// Re-claim any active job targets
|
||||
if (v.job) this.claimed.add(v.job.targetId)
|
||||
}
|
||||
}
|
||||
|
||||
update(delta: number): void {
|
||||
this.spawnTimer += delta
|
||||
if (this.spawnTimer >= VILLAGER_SPAWN_INTERVAL) {
|
||||
this.spawnTimer = 0
|
||||
this.trySpawn()
|
||||
}
|
||||
|
||||
const state = stateManager.getState()
|
||||
for (const v of Object.values(state.world.villagers)) {
|
||||
this.tickVillager(v, delta)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Per-villager tick ────────────────────────────────────────────────────
|
||||
|
||||
private tickVillager(v: VillagerState, delta: number): void {
|
||||
const rt = this.runtime.get(v.id)
|
||||
if (!rt) return
|
||||
|
||||
switch (v.aiState as AIState) {
|
||||
case 'idle': this.tickIdle(v, rt, delta); break
|
||||
case 'walking': this.tickWalking(v, rt, delta); break
|
||||
case 'working': this.tickWorking(v, rt, delta); break
|
||||
case 'sleeping':this.tickSleeping(v, rt, delta); break
|
||||
}
|
||||
|
||||
// Sync sprite to state position
|
||||
rt.sprite.setPosition(v.x, v.y)
|
||||
rt.nameLabel.setPosition(v.x, v.y - 22)
|
||||
rt.energyBar.setPosition(0, 0)
|
||||
this.drawEnergyBar(rt.energyBar, v.x, v.y, v.energy)
|
||||
|
||||
// Job icon
|
||||
const icons: Record<string, string> = { chop: '🪓', mine: '⛏', farm: '🌾', '': '' }
|
||||
const jobType = v.aiState === 'sleeping' ? '💤' : (v.job ? (icons[v.job.type] ?? '') : '')
|
||||
rt.jobIcon.setText(jobType).setPosition(v.x + 10, v.y - 18)
|
||||
}
|
||||
|
||||
// ─── IDLE ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private tickIdle(v: VillagerState, rt: VillagerRuntime, delta: number): void {
|
||||
// Decrement scan timer if cooling down
|
||||
if (rt.idleScanTimer > 0) {
|
||||
rt.idleScanTimer -= delta
|
||||
return
|
||||
}
|
||||
|
||||
// Carrying items? → find stockpile
|
||||
if (v.job?.carrying && Object.values(v.job.carrying).some(n => (n ?? 0) > 0)) {
|
||||
const sp = this.nearestBuilding(v, 'stockpile_zone')
|
||||
if (sp) { this.beginWalk(v, rt, sp.tileX, sp.tileY, 'stockpile'); return }
|
||||
}
|
||||
|
||||
// Low energy → find bed
|
||||
if (v.energy < 25) {
|
||||
const bed = this.findBed(v)
|
||||
if (bed) { this.beginWalk(v, rt, bed.tileX, bed.tileY, 'bed'); return }
|
||||
}
|
||||
|
||||
// Find a job
|
||||
const job = this.pickJob(v)
|
||||
if (job) {
|
||||
this.claimed.add(job.targetId)
|
||||
this.adapter.send({
|
||||
type: 'VILLAGER_SET_JOB', villagerId: v.id,
|
||||
job: { type: job.type, targetId: job.targetId, tileX: job.tileX, tileY: job.tileY, carrying: {} },
|
||||
})
|
||||
this.beginWalk(v, rt, job.tileX, job.tileY, 'job')
|
||||
} else {
|
||||
// No job available — wait before scanning again
|
||||
rt.idleScanTimer = 800 + Math.random() * 600
|
||||
}
|
||||
}
|
||||
|
||||
// ─── WALKING ──────────────────────────────────────────────────────────────
|
||||
|
||||
private tickWalking(v: VillagerState, rt: VillagerRuntime, delta: number): void {
|
||||
if (rt.path.length === 0) {
|
||||
this.onArrived(v, rt)
|
||||
return
|
||||
}
|
||||
|
||||
const next = rt.path[0]
|
||||
const tx = (next.tileX + 0.5) * TILE_SIZE
|
||||
const ty = (next.tileY + 0.5) * TILE_SIZE
|
||||
const dx = tx - v.x
|
||||
const dy = ty - v.y
|
||||
const dist = Math.hypot(dx, dy)
|
||||
|
||||
if (dist < ARRIVAL_PX) {
|
||||
;(v as { x: number; y: number }).x = tx
|
||||
;(v as { x: number; y: number }).y = ty
|
||||
rt.path.shift()
|
||||
} else {
|
||||
const step = Math.min(VILLAGER_SPEED * delta / 1000, dist)
|
||||
;(v as { x: number; y: number }).x += (dx / dist) * step
|
||||
;(v as { x: number; y: number }).y += (dy / dist) * step
|
||||
rt.sprite.setFlipX(dx < 0)
|
||||
}
|
||||
|
||||
// Slowly drain energy while active
|
||||
;(v as { energy: number }).energy = Math.max(0, v.energy - delta * 0.0015)
|
||||
}
|
||||
|
||||
private onArrived(v: VillagerState, rt: VillagerRuntime): void {
|
||||
switch (rt.destination) {
|
||||
case 'job':
|
||||
rt.workTimer = VILLAGER_WORK_TIMES[v.job?.type ?? 'chop'] ?? 3000
|
||||
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'working' })
|
||||
break
|
||||
|
||||
case 'stockpile':
|
||||
this.adapter.send({ type: 'VILLAGER_DEPOSIT', villagerId: v.id })
|
||||
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
|
||||
break
|
||||
|
||||
case 'bed':
|
||||
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'sleeping' })
|
||||
break
|
||||
|
||||
default:
|
||||
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
|
||||
}
|
||||
rt.destination = null
|
||||
}
|
||||
|
||||
// ─── WORKING ──────────────────────────────────────────────────────────────
|
||||
|
||||
private tickWorking(v: VillagerState, rt: VillagerRuntime, delta: number): void {
|
||||
rt.workTimer -= delta
|
||||
// Wobble while working
|
||||
rt.sprite.setAngle(Math.sin(Date.now() / 120) * 8)
|
||||
|
||||
if (rt.workTimer > 0) return
|
||||
rt.sprite.setAngle(0)
|
||||
|
||||
const job = v.job
|
||||
if (!job) { this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' }); return }
|
||||
|
||||
this.claimed.delete(job.targetId)
|
||||
const state = stateManager.getState()
|
||||
|
||||
if (job.type === 'chop') {
|
||||
if (state.world.resources[job.targetId]) {
|
||||
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
|
||||
this.resourceSystem.removeResource(job.targetId)
|
||||
}
|
||||
} else if (job.type === 'mine') {
|
||||
if (state.world.resources[job.targetId]) {
|
||||
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
|
||||
this.resourceSystem.removeResource(job.targetId)
|
||||
}
|
||||
} else if (job.type === 'farm') {
|
||||
const crop = state.world.crops[job.targetId]
|
||||
if (crop) {
|
||||
this.adapter.send({ type: 'VILLAGER_HARVEST_CROP', villagerId: v.id, cropId: job.targetId })
|
||||
this.farmingSystem.removeCropSpritePublic(job.targetId)
|
||||
this.adapter.send({ type: 'CHANGE_TILE', tileX: crop.tileX, tileY: crop.tileY, tile: 9 as any })
|
||||
}
|
||||
}
|
||||
|
||||
// Back to idle so decideAction handles depositing
|
||||
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
|
||||
}
|
||||
|
||||
// ─── SLEEPING ─────────────────────────────────────────────────────────────
|
||||
|
||||
private tickSleeping(v: VillagerState, rt: VillagerRuntime, delta: number): void {
|
||||
;(v as { energy: number }).energy = Math.min(100, v.energy + delta * 0.04)
|
||||
// Gentle bob while sleeping
|
||||
rt.sprite.setAngle(Math.sin(Date.now() / 600) * 5)
|
||||
if (v.energy >= 100) {
|
||||
rt.sprite.setAngle(0)
|
||||
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' })
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Job picking (RimWorld-style priority) ────────────────────────────────
|
||||
|
||||
private pickJob(v: VillagerState): { type: JobType; targetId: string; tileX: number; tileY: number } | null {
|
||||
const state = stateManager.getState()
|
||||
const p = v.priorities
|
||||
const vTX = Math.floor(v.x / TILE_SIZE)
|
||||
const vTY = Math.floor(v.y / TILE_SIZE)
|
||||
const dist = (tx: number, ty: number) => Math.abs(tx - vTX) + Math.abs(ty - vTY)
|
||||
|
||||
type C = { type: JobType; targetId: string; tileX: number; tileY: number; dist: number; pri: number }
|
||||
const candidates: C[] = []
|
||||
|
||||
if (p.chop > 0) {
|
||||
for (const res of Object.values(state.world.resources)) {
|
||||
if (res.kind !== 'tree' || this.claimed.has(res.id)) 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
|
||||
candidates.push({ type: 'mine', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.mine })
|
||||
}
|
||||
}
|
||||
if (p.farm > 0) {
|
||||
for (const crop of Object.values(state.world.crops)) {
|
||||
if (crop.stage < crop.maxStage || this.claimed.has(crop.id)) continue
|
||||
candidates.push({ type: 'farm', targetId: crop.id, tileX: crop.tileX, tileY: crop.tileY, dist: dist(crop.tileX, crop.tileY), pri: p.farm })
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) return null
|
||||
|
||||
// Lowest priority number wins; ties broken by distance
|
||||
const bestPri = Math.min(...candidates.map(c => c.pri))
|
||||
return candidates
|
||||
.filter(c => c.pri === bestPri)
|
||||
.sort((a, b) => a.dist - b.dist)[0] ?? null
|
||||
}
|
||||
|
||||
// ─── Pathfinding ──────────────────────────────────────────────────────────
|
||||
|
||||
private beginWalk(v: VillagerState, rt: VillagerRuntime, tileX: number, tileY: number, dest: VillagerRuntime['destination']): void {
|
||||
const sx = Math.floor(v.x / TILE_SIZE)
|
||||
const sy = Math.floor(v.y / TILE_SIZE)
|
||||
const path = findPath(sx, sy, tileX, tileY, (x, y) => this.worldSystem.isPassable(x, y), 700)
|
||||
|
||||
if (!path) {
|
||||
if (v.job) {
|
||||
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
|
||||
return
|
||||
}
|
||||
|
||||
rt.path = path
|
||||
rt.destination = dest
|
||||
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'walking' })
|
||||
}
|
||||
|
||||
// ─── Building finders ─────────────────────────────────────────────────────
|
||||
|
||||
private nearestBuilding(v: VillagerState, kind: string): { tileX: number; tileY: number } | null {
|
||||
const state = stateManager.getState()
|
||||
const hits = Object.values(state.world.buildings).filter(b => b.kind === kind)
|
||||
if (hits.length === 0) return null
|
||||
const vx = v.x / TILE_SIZE
|
||||
const vy = v.y / TILE_SIZE
|
||||
return hits.sort((a, b) => Math.hypot(a.tileX - vx, a.tileY - vy) - Math.hypot(b.tileX - vx, b.tileY - vy))[0]
|
||||
}
|
||||
|
||||
private findBed(v: VillagerState): { id: string; tileX: number; tileY: number } | null {
|
||||
const state = stateManager.getState()
|
||||
// Prefer assigned bed
|
||||
if (v.bedId && state.world.buildings[v.bedId]) return state.world.buildings[v.bedId] as any
|
||||
return this.nearestBuilding(v, 'bed') as any
|
||||
}
|
||||
|
||||
// ─── Spawning ─────────────────────────────────────────────────────────────
|
||||
|
||||
private trySpawn(): void {
|
||||
const state = stateManager.getState()
|
||||
const beds = Object.values(state.world.buildings).filter(b => b.kind === 'bed')
|
||||
const current = Object.keys(state.world.villagers).length
|
||||
if (current >= beds.length || beds.length === 0) return
|
||||
|
||||
// Find a free bed (not assigned to any existing villager)
|
||||
const taken = new Set(Object.values(state.world.villagers).map(v => v.bedId))
|
||||
const freeBed = beds.find(b => !taken.has(b.id))
|
||||
if (!freeBed) return
|
||||
|
||||
const name = VILLAGER_NAMES[this.nameIndex % VILLAGER_NAMES.length]
|
||||
this.nameIndex++
|
||||
|
||||
const villager: VillagerState = {
|
||||
id: `villager_${Date.now()}`,
|
||||
name,
|
||||
x: (freeBed.tileX + 0.5) * TILE_SIZE,
|
||||
y: (freeBed.tileY + 0.5) * TILE_SIZE,
|
||||
bedId: freeBed.id,
|
||||
job: null,
|
||||
priorities: { chop: 1, mine: 2, farm: 3 },
|
||||
energy: 100,
|
||||
aiState: 'idle',
|
||||
}
|
||||
|
||||
this.adapter.send({ type: 'SPAWN_VILLAGER', villager })
|
||||
this.spawnSprite(villager)
|
||||
this.onMessage?.(`${name} has joined the settlement! 🏘`)
|
||||
}
|
||||
|
||||
// ─── Sprite management ────────────────────────────────────────────────────
|
||||
|
||||
private spawnSprite(v: VillagerState): void {
|
||||
const sprite = this.scene.add.image(v.x, v.y, 'villager').setDepth(11)
|
||||
|
||||
const nameLabel = this.scene.add.text(v.x, v.y - 22, v.name, {
|
||||
fontSize: '8px', color: '#ffffff', fontFamily: 'monospace',
|
||||
backgroundColor: '#00000088', padding: { x: 2, y: 1 },
|
||||
}).setOrigin(0.5, 1).setDepth(12)
|
||||
|
||||
const energyBar = this.scene.add.graphics().setDepth(12)
|
||||
const jobIcon = this.scene.add.text(v.x, v.y - 18, '', { fontSize: '10px' }).setDepth(13)
|
||||
|
||||
this.runtime.set(v.id, { sprite, nameLabel, energyBar, jobIcon, path: [], destination: null, workTimer: 0, idleScanTimer: 0 })
|
||||
}
|
||||
|
||||
private drawEnergyBar(g: Phaser.GameObjects.Graphics, x: number, y: number, energy: number): void {
|
||||
const W = 20, H = 3
|
||||
g.clear()
|
||||
g.fillStyle(0x222222); g.fillRect(x - W/2, y - 28, W, H)
|
||||
const col = energy > 60 ? 0x4CAF50 : energy > 30 ? 0xFF9800 : 0xF44336
|
||||
g.fillStyle(col); g.fillRect(x - W/2, y - 28, W * (energy / 100), H)
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────
|
||||
|
||||
getStatusText(villagerId: string): string {
|
||||
const v = stateManager.getState().world.villagers[villagerId]
|
||||
if (!v) return '—'
|
||||
if (v.aiState === 'sleeping') return '💤 Sleeping'
|
||||
if (v.aiState === 'working' && v.job) return `⚒ ${v.job.type}ing`
|
||||
if (v.aiState === 'walking' && v.job) return `🚶 → ${v.job.type}`
|
||||
if (v.aiState === 'walking') return '🚶 Walking'
|
||||
const carrying = v.job?.carrying
|
||||
if (carrying && Object.values(carrying).some(n => (n ?? 0) > 0)) return '📦 Hauling'
|
||||
return '💭 Idle'
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
for (const rt of this.runtime.values()) {
|
||||
rt.sprite.destroy(); rt.nameLabel.destroy()
|
||||
rt.energyBar.destroy(); rt.jobIcon.destroy()
|
||||
}
|
||||
this.runtime.clear()
|
||||
}
|
||||
}
|
||||
128
src/systems/WorldSystem.ts
Normal file
128
src/systems/WorldSystem.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import Phaser from 'phaser'
|
||||
import { TILE_SIZE, WORLD_TILES } from '../config'
|
||||
import { TileType, IMPASSABLE } from '../types'
|
||||
import { stateManager } from '../StateManager'
|
||||
|
||||
const BIOME_COLORS: Record<number, string> = {
|
||||
0: '#1565C0', // DEEP_WATER
|
||||
1: '#42A5F5', // SHALLOW_WATER
|
||||
2: '#F5DEB3', // SAND
|
||||
3: '#66BB6A', // GRASS
|
||||
4: '#43A047', // DARK_GRASS
|
||||
5: '#33691E', // FOREST
|
||||
6: '#616161', // ROCK
|
||||
// Built types: show grass below
|
||||
7: '#66BB6A', 8: '#66BB6A', 9: '#66BB6A', 10: '#66BB6A',
|
||||
}
|
||||
|
||||
export class WorldSystem {
|
||||
private scene: Phaser.Scene
|
||||
private map!: Phaser.Tilemaps.Tilemap
|
||||
private tileset!: Phaser.Tilemaps.Tileset
|
||||
private bgImage!: Phaser.GameObjects.Image
|
||||
private builtLayer!: Phaser.Tilemaps.TilemapLayer
|
||||
|
||||
constructor(scene: Phaser.Scene) {
|
||||
this.scene = scene
|
||||
}
|
||||
|
||||
create(): void {
|
||||
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')!
|
||||
|
||||
for (let y = 0; y < WORLD_TILES; y++) {
|
||||
for (let x = 0; x < WORLD_TILES; x++) {
|
||||
const tile = state.world.tiles[y * WORLD_TILES + x]
|
||||
ctx.fillStyle = BIOME_COLORS[tile] ?? '#0a2210'
|
||||
ctx.fillRect(x, y, 1, 1)
|
||||
}
|
||||
}
|
||||
|
||||
this.scene.textures.addCanvas('terrain_bg', canvas)
|
||||
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)
|
||||
|
||||
// --- Built tile layer (sparse — only FLOOR, WALL, TILLED_SOIL, WATERED_SOIL) ---
|
||||
this.map = this.scene.make.tilemap({
|
||||
tileWidth: TILE_SIZE,
|
||||
tileHeight: TILE_SIZE,
|
||||
width: WORLD_TILES,
|
||||
height: WORLD_TILES,
|
||||
})
|
||||
|
||||
const ts = this.map.addTilesetImage('tiles', 'tiles', TILE_SIZE, TILE_SIZE, 0, 0, 0)
|
||||
if (!ts) throw new Error('Failed to add tileset')
|
||||
this.tileset = ts
|
||||
|
||||
const layer = this.map.createBlankLayer('built', this.tileset, 0, 0)
|
||||
if (!layer) throw new Error('Failed to create built layer')
|
||||
this.builtLayer = layer
|
||||
this.builtLayer.setDepth(1)
|
||||
|
||||
const BUILT_TILES = new Set([7, 8, 9, 10]) // FLOOR, WALL, TILLED_SOIL, WATERED_SOIL
|
||||
for (let y = 0; y < WORLD_TILES; y++) {
|
||||
for (let x = 0; x < WORLD_TILES; x++) {
|
||||
const t = state.world.tiles[y * WORLD_TILES + x]
|
||||
if (BUILT_TILES.has(t)) {
|
||||
this.builtLayer.putTileAt(t, x, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Camera bounds
|
||||
this.scene.cameras.main.setBounds(0, 0, WORLD_TILES * TILE_SIZE, WORLD_TILES * TILE_SIZE)
|
||||
}
|
||||
|
||||
getLayer(): Phaser.Tilemaps.TilemapLayer {
|
||||
return this.builtLayer
|
||||
}
|
||||
|
||||
setTile(tileX: number, tileY: number, type: TileType): void {
|
||||
const BUILT_TILES = new Set([TileType.FLOOR, TileType.WALL, TileType.TILLED_SOIL, TileType.WATERED_SOIL])
|
||||
if (BUILT_TILES.has(type)) {
|
||||
this.builtLayer.putTileAt(type, tileX, tileY)
|
||||
} else {
|
||||
// Reverting to natural: remove from built layer
|
||||
this.builtLayer.removeTileAt(tileX, tileY)
|
||||
}
|
||||
}
|
||||
|
||||
isPassable(tileX: number, tileY: number): boolean {
|
||||
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)
|
||||
}
|
||||
|
||||
worldToTile(worldX: number, worldY: number): { tileX: number; tileY: number } {
|
||||
return {
|
||||
tileX: Math.floor(worldX / TILE_SIZE),
|
||||
tileY: Math.floor(worldY / TILE_SIZE),
|
||||
}
|
||||
}
|
||||
|
||||
tileToWorld(tileX: number, tileY: number): { x: number; y: number } {
|
||||
return {
|
||||
x: tileX * TILE_SIZE + TILE_SIZE / 2,
|
||||
y: tileY * TILE_SIZE + TILE_SIZE / 2,
|
||||
}
|
||||
}
|
||||
|
||||
getTileType(tileX: number, tileY: number): TileType {
|
||||
const state = stateManager.getState()
|
||||
return state.world.tiles[tileY * WORLD_TILES + tileX] as TileType
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.map.destroy()
|
||||
this.bgImage.destroy()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user