✨ add mine building for automated stone production
- New 3×2 building placeable only on ROCK tiles without resources (50 stone + 200 wood) - Mine entrance at bottom-centre tile (tileX+1, tileY+1) — only passable tile - Other 5 footprint tiles blocked via resourceTiles index (impassable ROCK) - Nisse with mine priority > 0 walk to entrance, hide inside for MINE_WORK_MS (15 s), then reappear carrying MINE_STONE_YIELD (2) stone and haul to stockpile - Up to MINE_CAPACITY (3) Nisse work simultaneously; overflow Nisse wait for a slot - ⛏ X/3 world-space status label above building updated each frame - Surface rock harvesting unchanged; mine buildings take precedence in pickJob - Ghost resizes to 3×2 when mine is selected; placement validated across full footprint - Mine added to build menu with cost and placement hint Closes #42 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import Phaser from 'phaser'
|
||||
import { TILE_SIZE, VILLAGER_SPEED, VILLAGER_SPAWN_INTERVAL, VILLAGER_WORK_TIMES, VILLAGER_NAMES, WORLD_TILES } from '../config'
|
||||
import { TILE_SIZE, VILLAGER_SPEED, VILLAGER_SPAWN_INTERVAL, VILLAGER_WORK_TIMES, VILLAGER_NAMES, WORLD_TILES, MINE_CAPACITY, MINE_WORK_MS, MINE_STONE_YIELD } from '../config'
|
||||
import { TileType, PLANTABLE_TILES } from '../types'
|
||||
import type { VillagerState, VillagerJob, JobType, AIState, ItemId } from '../types'
|
||||
import { stateManager } from '../StateManager'
|
||||
@@ -37,7 +37,8 @@ export class VillagerSystem {
|
||||
private farmingSystem!: FarmingSystem
|
||||
|
||||
private runtime = new Map<string, VillagerRuntime>()
|
||||
private claimed = new Set<string>() // target IDs currently claimed by a villager
|
||||
private claimed = new Set<string>() // target IDs currently claimed (resources, crops, etc.)
|
||||
private mineClaimsMap = new Map<string, number>() // mine building ID → number of claimed slots
|
||||
private spawnTimer = 0
|
||||
private nameIndex = 0
|
||||
|
||||
@@ -72,6 +73,48 @@ export class VillagerSystem {
|
||||
this.farmingSystem = farmingSystem
|
||||
}
|
||||
|
||||
// ─── Claim helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Claims a job target. Mine buildings use a capacity counter (up to MINE_CAPACITY);
|
||||
* all other targets use the exclusive claimed Set.
|
||||
* @param targetId - The resource, crop, or building ID being claimed
|
||||
*/
|
||||
private claimTarget(targetId: string): void {
|
||||
if (stateManager.getState().world.buildings[targetId]?.kind === 'mine') {
|
||||
this.mineClaimsMap.set(targetId, (this.mineClaimsMap.get(targetId) ?? 0) + 1)
|
||||
} else {
|
||||
this.claimed.add(targetId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases a job target claim.
|
||||
* @param targetId - The previously claimed target ID
|
||||
*/
|
||||
private releaseTarget(targetId: string): void {
|
||||
if (stateManager.getState().world.buildings[targetId]?.kind === 'mine') {
|
||||
const n = this.mineClaimsMap.get(targetId) ?? 0
|
||||
if (n <= 1) this.mineClaimsMap.delete(targetId)
|
||||
else this.mineClaimsMap.set(targetId, n - 1)
|
||||
} else {
|
||||
this.claimed.delete(targetId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given target is fully claimed.
|
||||
* Mine buildings are claimed when their worker count reaches MINE_CAPACITY.
|
||||
* All other targets are claimed exclusively.
|
||||
* @param targetId - The target ID to check
|
||||
*/
|
||||
private isTargetClaimed(targetId: string): boolean {
|
||||
if (stateManager.getState().world.buildings[targetId]?.kind === 'mine') {
|
||||
return (this.mineClaimsMap.get(targetId) ?? 0) >= MINE_CAPACITY
|
||||
}
|
||||
return this.claimed.has(targetId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns sprites for all Nisse that exist in the saved state
|
||||
* and re-claims any active job targets.
|
||||
@@ -81,7 +124,7 @@ export class VillagerSystem {
|
||||
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)
|
||||
if (v.job) this.claimTarget(v.job.targetId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +216,7 @@ export class VillagerSystem {
|
||||
// Find a job
|
||||
const job = this.pickJob(v)
|
||||
if (job) {
|
||||
this.claimed.add(job.targetId)
|
||||
this.claimTarget(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: {} },
|
||||
@@ -232,10 +275,20 @@ export class VillagerSystem {
|
||||
*/
|
||||
private onArrived(v: VillagerState, rt: VillagerRuntime): void {
|
||||
switch (rt.destination) {
|
||||
case 'job':
|
||||
rt.workTimer = VILLAGER_WORK_TIMES[v.job?.type ?? 'chop'] ?? 3000
|
||||
case 'job': {
|
||||
// Mine buildings take longer than surface-rock mining and hide the Nisse sprite.
|
||||
const isMineBuilding = v.job?.type === 'mine' &&
|
||||
stateManager.getState().world.buildings[v.job.targetId]?.kind === 'mine'
|
||||
rt.workTimer = isMineBuilding ? MINE_WORK_MS : (VILLAGER_WORK_TIMES[v.job?.type ?? 'chop'] ?? 3000)
|
||||
if (isMineBuilding) {
|
||||
rt.sprite.setVisible(false)
|
||||
rt.nameLabel.setVisible(false)
|
||||
rt.energyBar.setVisible(false)
|
||||
rt.jobIcon.setVisible(false)
|
||||
}
|
||||
this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'working' })
|
||||
break
|
||||
}
|
||||
|
||||
case 'stockpile':
|
||||
this.adapter.send({ type: 'VILLAGER_DEPOSIT', villagerId: v.id })
|
||||
@@ -276,7 +329,7 @@ export class VillagerSystem {
|
||||
const job = v.job
|
||||
if (!job) { this.adapter.send({ type: 'VILLAGER_SET_AI', villagerId: v.id, aiState: 'idle' }); return }
|
||||
|
||||
this.claimed.delete(job.targetId)
|
||||
this.releaseTarget(job.targetId)
|
||||
const state = stateManager.getState()
|
||||
|
||||
if (job.type === 'chop') {
|
||||
@@ -293,14 +346,27 @@ export class VillagerSystem {
|
||||
this.addLog(v.id, `✓ Chopped tree (+2 wood, +${seeds} tree seed)`)
|
||||
}
|
||||
} else if (job.type === 'mine') {
|
||||
const res = state.world.resources[job.targetId]
|
||||
if (res) {
|
||||
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
|
||||
// ROCK tile stays ROCK after mining — empty rocky ground remains passable
|
||||
// and valid for mine building placement.
|
||||
this.worldSystem.removeResourceTile(res.tileX, res.tileY)
|
||||
this.resourceSystem.removeResource(job.targetId)
|
||||
this.addLog(v.id, '✓ Mined rock (+2 stone)')
|
||||
const building = state.world.buildings[job.targetId]
|
||||
if (building?.kind === 'mine') {
|
||||
// Mine building: yield stone directly into carrying, then show the Nisse again
|
||||
const mutableJob = v.job as { carrying: Partial<Record<'stone', number>> }
|
||||
mutableJob.carrying.stone = (mutableJob.carrying.stone ?? 0) + MINE_STONE_YIELD
|
||||
rt.sprite.setVisible(true)
|
||||
rt.nameLabel.setVisible(true)
|
||||
rt.energyBar.setVisible(true)
|
||||
rt.jobIcon.setVisible(true)
|
||||
this.addLog(v.id, `✓ Mined (+${MINE_STONE_YIELD} stone) at mine`)
|
||||
} else {
|
||||
// Surface rock: ROCK tile stays ROCK after mining
|
||||
const res = state.world.resources[job.targetId]
|
||||
if (res) {
|
||||
this.adapter.send({ type: 'VILLAGER_HARVEST_RESOURCE', villagerId: v.id, resourceId: job.targetId })
|
||||
// ROCK tile stays ROCK after mining — empty rocky ground remains passable
|
||||
// and valid for mine building placement.
|
||||
this.worldSystem.removeResourceTile(res.tileX, res.tileY)
|
||||
this.resourceSystem.removeResource(job.targetId)
|
||||
this.addLog(v.id, '✓ Mined rock (+2 stone)')
|
||||
}
|
||||
}
|
||||
} else if (job.type === 'farm') {
|
||||
const crop = state.world.crops[job.targetId]
|
||||
@@ -401,7 +467,7 @@ export class VillagerSystem {
|
||||
const naturalChop: C[] = []
|
||||
|
||||
for (const res of resources) {
|
||||
if (res.kind !== 'tree' || this.claimed.has(res.id)) continue
|
||||
if (res.kind !== 'tree' || this.isTargetClaimed(res.id)) continue
|
||||
// Skip trees with no reachable neighbour — A* cannot reach them.
|
||||
if (!this.hasAdjacentPassable(res.tileX, res.tileY)) continue
|
||||
const c: C = { type: 'chop', targetId: res.id, tileX: res.tileX, tileY: res.tileY, dist: dist(res.tileX, res.tileY), pri: p.chop }
|
||||
@@ -416,8 +482,15 @@ export class VillagerSystem {
|
||||
}
|
||||
|
||||
if (p.mine > 0) {
|
||||
// Mine buildings: walk to entrance tile (tileX+1, tileY+1) and work inside
|
||||
for (const b of buildings) {
|
||||
if (b.kind !== 'mine' || this.isTargetClaimed(b.id)) continue
|
||||
const eTileX = b.tileX + 1, eTileY = b.tileY + 1
|
||||
candidates.push({ type: 'mine', targetId: b.id, tileX: eTileX, tileY: eTileY, dist: dist(eTileX, eTileY), pri: p.mine })
|
||||
}
|
||||
// Surface rocks (still valid without a mine building)
|
||||
for (const res of resources) {
|
||||
if (res.kind !== 'rock' || this.claimed.has(res.id)) continue
|
||||
if (res.kind !== 'rock' || this.isTargetClaimed(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 })
|
||||
@@ -426,7 +499,7 @@ export class VillagerSystem {
|
||||
|
||||
if (p.farm > 0) {
|
||||
for (const crop of crops) {
|
||||
if (crop.stage < crop.maxStage || this.claimed.has(crop.id)) continue
|
||||
if (crop.stage < crop.maxStage || this.isTargetClaimed(crop.id)) continue
|
||||
candidates.push({ type: 'farm', targetId: crop.id, tileX: crop.tileX, tileY: crop.tileY, dist: dist(crop.tileX, crop.tileY), pri: p.farm })
|
||||
}
|
||||
}
|
||||
@@ -437,7 +510,7 @@ export class VillagerSystem {
|
||||
for (const key of zone.tiles) {
|
||||
const [tx, ty] = key.split(',').map(Number)
|
||||
const targetId = `forester_tile_${tx}_${ty}`
|
||||
if (this.claimed.has(targetId)) continue
|
||||
if (this.isTargetClaimed(targetId)) continue
|
||||
// Skip if tile is not plantable
|
||||
const tileType = state.world.tiles[ty * WORLD_TILES + tx] as TileType
|
||||
if (!PLANTABLE_TILES.has(tileType)) continue
|
||||
@@ -481,7 +554,7 @@ export class VillagerSystem {
|
||||
|
||||
if (!path) {
|
||||
if (v.job) {
|
||||
this.claimed.delete(v.job.targetId)
|
||||
this.releaseTarget(v.job.targetId)
|
||||
this.adapter.send({ type: 'VILLAGER_SET_JOB', villagerId: v.id, job: null })
|
||||
}
|
||||
rt.idleScanTimer = 4000 // longer delay after failed pathfind to avoid tight retry loops
|
||||
|
||||
Reference in New Issue
Block a user