- Tree seedlings: plant tree_seed on grass via farming tool; two-stage growth (sprout → sapling → young tree, ~1 min/stage); matures into a harvestable FOREST resource tile - Tile recovery: Nisse chops start a 5-min DARK_GRASS→GRASS timer; terrain canvas updated live via WorldSystem.refreshTerrainTile() - New TreeSeedlingSystem manages sprites, growth ticking, maturation - BootScene generates seedling_0/1/2 textures procedurally - FarmingSystem adds tree_seed to tool cycle (F key) - Stockpile panel shows tree_seed (default: 5); panel height adjusted - StateManager v5: treeSeedlings + tileRecovery in WorldState - WorldSystem uses CanvasTexture for live single-pixel updates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
232 lines
9.0 KiB
TypeScript
232 lines
9.0 KiB
TypeScript
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' | 'tree_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',
|
||
}
|
||
|
||
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
|
||
/**
|
||
* 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
|
||
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 worldX = ptr.worldX
|
||
const worldY = ptr.worldY
|
||
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 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 {
|
||
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)
|
||
}
|
||
}
|