Refactor: Component-based architecture + 10 sub-systems

- Implemented 10 sub-systems (Economy, Pathfinding, Combat, Selection, Network, Map, Entity/Building/ControlPoint state machines, Orchestrator)
- Refactored Custom_Entity.js → Unit.js with 5 components (health, owner, inventory, movement, combat)
- Added Jest test suite with 100+ tests (EconomySystem 100%, EntityStateMachine 100%, PathfindingSystem 99%, Unit.js 72%)
- All webpack builds pass (0 errors)
- BMAD-auto team-respawn flow: 10 parallel sub-agents implemented systems

Architecture: Phaser 3 + XState + socket.io + EasyStar
Mode: team-respawn
Model: custom/ollama-cloud-pro
This commit is contained in:
root
2026-05-29 22:13:44 +00:00
parent 334b41afef
commit 2e07519648
44 changed files with 14751 additions and 1048 deletions

View File

@@ -0,0 +1,55 @@
# System 1: EconomySystem
## Responsibility
Track player resources, calculate income, validate purchases
## Resources
| Resource | Source | Use |
|----------|--------|-----|
| Fuel | Logistics Centers (+5/tick) | Vehicle production |
| Ammo | Ammunition Factories (+5/tick) | Infantry production |
| Capture Points | Control Points (+1/tick each) | Map control score |
## Interface
```javascript
class EconomySystem {
constructor(scene) {
this.scene = scene
this.players = new Map() // playerId -> { fuel, ammo, capturePoints }
}
// Initialize player with starting resources
initPlayer(playerId, starting = { fuel: 100, ammo: 100, capturePoints: 0 })
// Get current resources
getResources(playerId) // -> { fuel, ammo, capturePoints }
// Check if player can afford
canAfford(playerId, cost) // cost = { fuel?: number, ammo?: number }
// Deduct resources (returns success bool)
deduct(playerId, cost) // -> boolean
// Add income (called per tick)
addIncome(playerId, income) // income = { fuel?: number, ammo?: number, capturePoints?: number }
// Update loop (called every 1s)
update(time)
}
```
## Implementation Details
- Income tick: every 1000ms
- Resources stored on server (authoritative), synced to clients
- Events emitted: `economy:updated`, `economy:purchaseFailed`, `economy:incomeReceived`
## Files to Create
- `src/systems/EconomySystem.js`
## Dependencies
- None (pure service class)
## Integration Points
- BuildingSystem: calls `canAfford()` before production, `deduct()` on queue start
- ControlPointSystem: calls `addIncome()` for CP generation
- UI: listens to `economy:updated` for resource display

View File

@@ -0,0 +1,59 @@
# System 2: PathfindingSystem
## Responsibility
A* pathfinding via EasyStar, grid management, dynamic obstacle avoidance, path caching
## Interface
```javascript
class PathfindingSystem {
constructor(scene, tilemap) {
this.scene = scene
this.tilemap = tilemap
this.easystar = new EasyStar.js()
this.pathCache = new Map() // entityId -> path
this.grid = [] // 2D array: 0 = walkable, 1 = blocked
}
// Initialize grid from tilemap collision layer
initGrid()
// Mark tile as walkable/unwalkable
setWalkable(tileX, tileY, walkable)
// Find path (returns array of {x, y} tiles)
findPath(startTile, endTile, options)
// options: { avoidEnemies?: boolean, maxPathLength?: number }
// Get cached path for entity
getCachedPath(entityId)
// Invalidate cache (called when obstacles change)
invalidateCache(entityId?)
// Convert tile path to world coordinates
pathToWorldCoords(path)
// Update loop (recalculate paths on obstacle change)
update(time)
}
```
## Implementation Details
- Grid size: matches tilemap (e.g., 64x64)
- Tile size: 64x64 pixels
- EasyStar config: 8-directional movement, diagonal cost 1.5x
- Path caching: invalidate on building spawn/destroy, unit death
- Avoid enemies: optional grid overlay for dynamic obstacles
## Files to Create
- `src/systems/PathfindingSystem.js`
## Dependencies
- EasyStar.js (already in package.json)
- MapSystem (for tilemap reference)
## Integration Points
- SelectionSystem: calls `findPath()` for move commands
- EntityStateMachine: calls `getCachedPath()` in MOVING state
- BuildingSystem: calls `setWalkable()` on building spawn/destroy
- CombatSystem: calls `findPath()` for projectile trajectory (if needed)

View File

@@ -0,0 +1,61 @@
# System 3: CombatSystem
## Responsibility
Target acquisition, line-of-sight checks, projectile spawning, damage resolution
## Sub-components
- **TargetScanner:** Radius query, threat prioritization (closest, lowest HP, highest DPS)
- **LineOfSight:** Raycast from attacker to target (tilemap collision check)
- **ProjectileManager:** Spawn, track, collision detection
- **DamageResolver:** Apply armor modifiers, crit rolls, damage events
## Interface
```javascript
class CombatSystem {
constructor(scene) {
this.scene = scene
this.projectiles = scene.physics.add.group()
this.damageModifiers = {} // typeId -> { armorPiercing: 0.5, critChance: 0.1 }
}
// Acquire target for entity
acquireTarget(entity, options)
// options: { maxRange: number, fov: number, priority: 'closest'|'weakest'|'strongest' }
// returns: target entity or null
// Check if attacker can hit target
canHit(attacker, target)
// returns: { canHit: boolean, reason?: 'out_of_range'|'no_los'|'friendly_fire' }
// Fire projectile
fireProjectile(attacker, target, config)
// config: { damage: number, speed: number, homing?: boolean }
// Check line of sight (returns bool)
hasLineOfSight(pointA, pointB)
// Apply damage to entity
applyDamage(entity, amount, damageType)
// Update loop (process projectile collisions)
update(time, delta)
}
```
## Implementation Details
- Projectile collision: `physics.add.overlap(projectiles, units, onHit)`
- Damage formula: `finalDamage = (baseDamage - armor) * modifiers`
- LoS check: `Physics.Raycast()` or tilemap collision query
- Threat prioritization: configurable per entity type
## Files to Create
- `src/systems/CombatSystem.js`
## Dependencies
- Phaser Physics (Arcade)
- MapSystem (for tilemap collision layers)
## Integration Points
- EntityStateMachine: calls `acquireTarget()` in IDLING state, `fireProjectile()` in ATTACKING state
- NetworkSystem: syncs projectile spawns and damage events
- UI: listens to `combat:unitDamaged` for health bar updates

View File

@@ -0,0 +1,72 @@
# System 4: SelectionSystem
## Responsibility
Multi-select, command queue, formation movement, right-click context menu
## Commands
- `MOVE`: Pathfind to tile, face direction
- `ATTACK_MOVE`: Move + engage enemies en route
- `ATTACK_TARGET`: Attack specific unit/building
- `STOP`: Cancel current action, hold position
- `PATROL`: Loop between waypoints
## Interface
```javascript
class SelectionSystem {
constructor(scene) {
this.scene = scene
this.selected = new Set() // selected entities
this.commandQueue = [] // queued commands
this.selectionBox = null // Graphics object for drag-select
this.formation = 'none' // 'aggro', 'spread', 'line'
}
// Add entity to selection
add(entity)
// Clear selection
clear()
// Get selected entities
getSelected() // -> Array<Entity>
// Issue command to selected entities
issueCommand(type, target)
// type: 'MOVE'|'ATTACK_MOVE'|'ATTACK_TARGET'|'STOP'|'PATROL'
// target: { tile?: {x,y}, entity?: Entity, waypoints?: Array }
// Set formation pattern
setFormation(type, options)
// type: 'aggro'|'spread'|'line'
// options: { spread: number }
// Calculate formation positions (offsets from leader)
getFormationPositions(leaderPos, count)
// Handle input events
handlePointerDown(pointer)
handlePointerDrag(pointer)
handlePointerUp(pointer)
// Update loop (process command queue)
update(time, delta)
}
```
## Implementation Details
- Selection box: `Phaser.GameObjects.Graphics` with drag handlers
- Command UI: World-space markers above selected units
- Input modifiers: Shift (add to selection), Ctrl (queue command), Right-click (context)
- Formation: Leader-follower pattern with offset calculations
## Files to Create
- `src/systems/SelectionSystem.js`
## Dependencies
- PathfindingSystem (for move commands)
- CombatSystem (for attack commands)
## Integration Points
- Input handling: Listens to pointer events in scene
- EntityStateMachine: Receives commands, transitions states
- UI: Shows selection panel, command feedback markers

View File

@@ -0,0 +1,83 @@
# System 5: NetworkSystem
## Responsibility
Server-authoritative state sync, 20Hz snapshots, client prediction, lag compensation
## Architecture
- **Server:** All game logic, authoritative state
- **Client:** Input only, client-side prediction, interpolation
- **Sync Rate:** 20 snapshots/second (50ms interval)
## Replicated State
| Entity | Replicated Fields |
|--------|-------------------|
| Unit | position, rotation, state, health, owner, targetTile |
| Building | position, health, productionQueue, owner |
| Economy | fuel, ammo, capturePoints (per player) |
| Control Point | owner, captureProgress, unitsInRadius |
## Interface
```javascript
// === CLIENT SIDE ===
class NetworkSystemClient {
constructor(scene) {
this.socket = io(SERVER_URL)
this.pendingInputs = [] // for reconciliation
this.snapshotBuffer = [] // for interpolation
}
// Send input to server
sendInput(input)
// input: { type: 'SELECT'|'COMMAND', entityId, commandType, target, timestamp }
// Receive snapshot from server
onSnapshot(snapshot)
// snapshot: { entities: [...], buildings: [...], economy: {...}, timestamp }
// Interpolate between snapshots
interpolate(currentTime)
// Reconcile predicted state with server state
reconcile(serverState)
// Update loop (send inputs, process snapshots)
update(time, delta)
}
// === SERVER SIDE ===
class NetworkSystemServer {
constructor(gameState) {
this.gameState = gameState
this.io = io(SERVER)
this.snapshotRate = 20 // Hz
this.lastSnapshot = 0
}
// Receive input from client
onInput(clientId, input)
// Broadcast snapshot to all clients
broadcastSnapshot()
// Update loop (process inputs, send snapshots)
update(time, delta)
}
```
## Implementation Details
- Client prediction: Store pending inputs, apply immediately locally
- Server reconciliation: Correct client state on snapshot mismatch
- Interpolation: Lerp between last 2 snapshots based on render time
- Input buffering: Queue inputs, send in batches if needed
## Files to Create
- `src/systems/NetworkSystem.js` (client + server classes)
- `gameServer/networkHandler.js` (server-side integration)
## Dependencies
- Socket.IO (already in package.json)
- GameState (server-side)
## Integration Points
- All systems: Server receives inputs, updates state, broadcasts snapshots
- Client: Interpolates entity positions, reconciles predictions

View File

@@ -0,0 +1,68 @@
# System 6: MapSystem
## Responsibility
Tilemap loading, collision layers, zone transitions, spawn point management
## Tilemap Layers
| Layer | Purpose |
|-------|---------|
| `ground` | Visual terrain (grass, dirt, water) |
| `collision` | Walkable vs blocked tiles |
| `spawnPoints` | Player spawn locations |
| `controlZones` | Control point areas |
## Interface
```javascript
class MapSystem {
constructor(scene) {
this.scene = scene
this.tilemap = null
this.layers = {}
this.spawnPoints = [] // [{x, y, owner, buildingType?}]
this.controlZones = [] // [{x, y, radius, owner, captureProgress}]
}
// Load tilemap from JSON
loadMap(mapPath)
// Get tile at world position
getTileAtWorld(x, y) // -> {x: tileX, y: tileY, layer: string}
// Get world position from tile
getWorldPosition(tileX, tileY) // -> {x, y}
// Check if tile is walkable
isWalkable(tileX, tileY) // -> boolean
// Get spawn point for player
getSpawnPoint(playerId) // -> {x, y}
// Get control zone at position
getControlZoneAt(x, y) // -> zone or null
// Get all control zones
getControlZones() // -> Array<zone>
// Update loop (zone ownership changes)
update(time, delta)
}
```
## Implementation Details
- Tile size: 64x64 pixels (matches entity physics body)
- Map size: Configurable (e.g., 64x64 tiles = 4096x4096 pixels)
- Collision: Tile properties set in Tiled editor (`collides: true`)
- Spawn points: Object layer in Tiled, parsed on load
- Control zones: Circular zones, radius in tiles
## Files to Create
- `src/systems/MapSystem.js`
## Dependencies
- Phaser Tilemap (built-in)
## Integration Points
- PathfindingSystem: Uses collision layer for grid
- BuildingSystem: Validates placement via `isWalkable()`
- ControlPointSystem: Uses zones for capture mechanics
- NetworkSystem: Syncs zone ownership changes

View File

@@ -0,0 +1,101 @@
# System 7: EntityStateMachine (XState)
## Responsibility
Unit behavior logic, state transitions, animation wiring, action execution
## States
| State | Description | Transitions |
|-------|-------------|-------------|
| `IDLING` | Patrol, scan for enemies | → MOVING (command), → ATTACKING (enemy spotted) |
| `MOVING` | Follow path to target tile | → IDLING (path complete), → ATTACKING (enemy en route) |
| `ATTACKING` | Track and fire at target | → IDLING (target dead), → MOVING (lost LoS) |
| `DYING` | Death animation, cleanup | → (terminal, entity destroyed after 5s) |
## Interface
```javascript
// State machine config (XState)
const entityMachine = createMachine({
id: 'entity',
initial: 'IDLING',
states: {
IDLING: {
on: {
MOVE: 'MOVING',
ATTACK: 'ATTACKING',
DIE: 'DYING'
},
entry: ['playIdleAnim', 'scanForEnemies'],
activities: ['patrol']
},
MOVING: {
on: {
ARRIVED: 'IDLING',
ENEMY_SPOTTED: 'ATTACKING',
DIE: 'DYING'
},
entry: ['playMoveAnim', 'startPathfinding'],
activities: ['followPath']
},
ATTACKING: {
on: {
TARGET_LOST: 'IDLING',
OUT_OF_RANGE: 'MOVING',
DIE: 'DYING'
},
entry: ['playAttackAnim', 'orientToTarget'],
activities: ['trackTarget', 'fireWeapon']
},
DYING: {
entry: ['playDeathAnim', 'markDead'],
after: {
5000: 'DESTROYED'
}
},
DESTROYED: {
type: 'final'
}
}
})
// Entity wrapper class
class EntityStateMachine {
constructor(entity, machineConfig) {
this.entity = entity
this.machine = createMachine(machineConfig)
this.service = interpret(this.machine).start()
}
// Send event to state machine
send(event, context)
// Get current state
getState() // -> 'IDLING'|'MOVING'|'ATTACKING'|'DYING'
// Tick state machine (called in entity preUpdate)
tick(time, delta)
// Cleanup
destroy()
}
```
## Implementation Details
- XState v4 (already in package.json)
- State machine stored on entity via `setData('stateMachine')`
- `preUpdate()` calls `tick()` with delta time
- Actions: Call system methods (CombatSystem.fire, PathfindingSystem.findPath)
- Animations: Play on state entry via `entity.anims.play()`
## Files to Create
- `src/systems/EntityStateMachine.js`
- `src/entities/state-machines/unit-states.js` (state configs)
## Dependencies
- XState (already in package.json)
- CombatSystem, PathfindingSystem (for actions)
## Integration Points
- SelectionSystem: Sends MOVE/ATTACK commands
- CombatSystem: Triggers ATTACK state on enemy spotted
- PathfindingSystem: Requests paths in MOVING state
- NetworkSystem: Syncs state changes to clients

View File

@@ -0,0 +1,115 @@
# System 8: BuildingStateMachine (XState)
## Responsibility
Building lifecycle, production queue management, resource consumption
## States
| State | Description | Transitions |
|-------|-------------|-------------|
| `CONSTRUCTING` | Building being placed (5s build time) | → ACTIVE (build complete) |
| `ACTIVE` | Building operational, can produce | → PRODUCING (queue started), → DESTROYED (health <= 0) |
| `PRODUCING` | Currently producing unit | → ACTIVE (production complete), → DESTROYED (health <= 0) |
| `DESTROYED` | Building destroyed, cleanup timer | → (terminal, removed after 5s) |
## Building Types
| Type | Production | Cost | Build Time |
|------|------------|------|------------|
| Command Center | Nothing (HQ) | N/A | N/A |
| Barracks | Infantry | 50 Ammo | 10s |
| Vehicle Depot | Vehicles | 100 Fuel | 20s |
| Logistics Center | +5 Fuel/tick | N/A | 15s |
| Ammunition Factory | +5 Ammo/tick | N/A | 15s |
## Interface
```javascript
// State machine config (XState)
const buildingMachine = createMachine({
id: 'building',
initial: 'CONSTRUCTING',
context: {
productionQueue: [],
buildTime: 5000,
productionTime: 10000
},
states: {
CONSTRUCTING: {
after: {
buildTime: 'ACTIVE'
},
entry: ['showConstructionProgress']
},
ACTIVE: {
on: {
START_PRODUCTION: 'PRODUCING',
DESTROY: 'DESTROYED'
},
entry: ['activateBuilding']
},
PRODUCING: {
on: {
PRODUCTION_COMPLETE: 'ACTIVE',
DESTROY: 'DESTROYED'
},
entry: ['startProduction'],
exit: ['spawnUnit']
},
DESTROYED: {
entry: ['playDestroyAnim', 'markDestroyed'],
after: {
5000: 'REMOVED'
}
},
REMOVED: {
type: 'final'
}
}
})
// Building wrapper class
class BuildingStateMachine {
constructor(building, config) {
this.building = building
this.type = config.type // 'barracks'|'vehicleDepot'|etc
this.machine = createMachine(buildingMachine, {
context: { buildTime: config.buildTime, productionTime: config.productionTime }
})
this.service = interpret(this.machine).start()
}
// Add unit to production queue
addToQueue(unitType, count)
// Cancel production
cancelQueue()
// Send event to state machine
send(event, context)
// Tick state machine
tick(time, delta)
// Cleanup
destroy()
}
```
## Implementation Details
- Production queue: FIFO, max 5 units
- Spawn point: Offset from building center (prevents overlap)
- Build progress: Visual progress bar above building
- Resource deduction: On `START_PRODUCTION` event
## Files to Create
- `src/systems/BuildingStateMachine.js`
- `src/entities/buildings/building-types.js` (type configs)
## Dependencies
- XState (already in package.json)
- EconomySystem (for resource validation/deduction)
- PathfindingSystem (for spawn point walkability)
## Integration Points
- EconomySystem: Validates/deducts resources on production start
- SelectionSystem: Sends production commands
- NetworkSystem: Syncs queue state and building state
- UI: Shows production queue, build progress

View File

@@ -0,0 +1,105 @@
# System 9: ControlPointStateMachine (XState)
## Responsibility
Capture mechanics, ownership tracking, capture point generation
## States
| State | Description | Transitions |
|-------|-------------|-------------|
| `NEUTRAL` | No owner, can be captured by anyone | → CONTESTED (units present) |
| `CONTESTED` | Being captured, progress bar active | → NEUTRAL (units left), → CAPTURED (progress 100%) |
| `CAPTURED` | Owned by player, generates CPs | → CONTESTED (enemy units present) |
## Capture Mechanics
- **Claim Condition:** Unit count in radius > enemy count for 60 seconds
- **Progress:** 0-100%, resets if units leave or enemy enters
- **Generation:** Each captured point generates +1 CP/tick for owner
## Interface
```javascript
// State machine config (XState)
const controlPointMachine = createMachine({
id: 'controlPoint',
initial: 'NEUTRAL',
context: {
owner: null,
captureProgress: 0,
captureTime: 60000, // 60 seconds
unitsInRadius: { player1: 0, player2: 0, ... }
},
states: {
NEUTRAL: {
on: {
UNITS_ENTERED: 'CONTESTED',
CLAIM: 'CAPTURED'
},
entry: ['clearOwner', 'resetProgress']
},
CONTESTED: {
on: {
UNITS_LEFT: 'NEUTRAL',
PROGRESS_COMPLETE: 'CAPTURED',
OWNER_CHANGED: 'CONTESTED' // enemy took over contest
},
activities: ['trackUnits', 'incrementProgress']
},
CAPTURED: {
on: {
ENEMY_UNITS_ENTERED: 'CONTESTED'
},
entry: ['setOwner', 'startCPGeneration'],
exit: ['stopCPGeneration']
}
}
})
// Control point wrapper class
class ControlPointStateMachine {
constructor(zone, config) {
this.zone = zone // Phaser.Zone
this.machine = createMachine(controlPointMachine, {
context: { captureTime: config.captureTime || 60000 }
})
this.service = interpret(this.machine).start()
this.radius = config.radius || 5 // tiles
}
// Get unit count per player in radius
getUnitsInRadius() // -> { playerId: count, ... }
// Get capture progress (0-100)
getCaptureProgress() // -> number
// Get current owner
getOwner() // -> playerId or null
// Send event to state machine
send(event, context)
// Tick state machine (check unit counts, update progress)
tick(time, delta)
// Cleanup
destroy()
}
```
## Implementation Details
- Zone: `Phaser.GameObjects.Zone` with circular hit area
- Unit tracking: `physics.overlap(zone, units)` per player
- Progress UI: World-space progress bar above control point
- CP generation: Calls `EconomySystem.addIncome()` per tick
## Files to Create
- `src/systems/ControlPointStateMachine.js`
## Dependencies
- XState (already in package.json)
- EconomySystem (for CP generation)
- MapSystem (for zone definitions)
## Integration Points
- EconomySystem: Receives CP income calls
- NetworkSystem: Syncs ownership and progress
- UI: Shows capture progress, owner indicator
- EntityStateMachine: Units move into/out of radius

View File

@@ -0,0 +1,99 @@
# System 10: SystemOrchestrator
## Responsibility
Initialize all systems, manage update loop order, handle cross-system events
## Update Loop Order
```
1. SelectionSystem → Process input, issue commands
2. EconomySystem → Resource income tick (every 1s)
3. ControlPointStateMachine → Capture progress update
4. BuildingStateMachine → Production queue advance
5. EntityStateMachine → State machine TICK (all units)
6. PathfindingSystem → Path recalculations
7. CombatSystem → Projectile resolution, damage application
8. NetworkSystem → Snapshot broadcast (client/server sync)
```
## Interface
```javascript
class SystemOrchestrator {
constructor(scene, config) {
this.scene = scene
this.systems = {}
this.updateOrder = [
'selection',
'economy',
'controlPoints',
'buildings',
'entities',
'pathfinding',
'combat',
'network'
]
}
// Initialize all systems
init() {
this.systems.map = new MapSystem(this.scene)
this.systems.economy = new EconomySystem(this.scene)
this.systems.pathfinding = new PathfindingSystem(this.scene, this.systems.map.tilemap)
this.systems.combat = new CombatSystem(this.scene)
this.systems.selection = new SelectionSystem(this.scene)
this.systems.network = new NetworkSystem(this.scene)
// XState machines are created per-entity/building/zone, not here
}
// Wire cross-system events
wireEvents() {
// Example: Building spawn → update pathfinding grid
this.scene.events.on('building:spawned', (building) => {
this.systems.pathfinding.setWalkable(building.tileX, building.tileY, false)
})
// Example: Unit death → remove from pathfinding cache
this.scene.events.on('entity:destroyed', (entity) => {
this.systems.pathfinding.invalidateCache(entity.id)
})
// Example: Control point captured → economy income
this.scene.events.on('controlPoint:captured', (point, playerId) => {
this.systems.economy.addIncome(playerId, { capturePoints: 1 })
})
}
// Update all systems in order
update(time, delta) {
for (const systemName of this.updateOrder) {
const system = this.systems[systemName]
if (system && system.update) {
system.update(time, delta)
}
}
}
// Cleanup all systems
shutdown() {
for (const system of Object.values(this.systems)) {
if (system.destroy) system.destroy()
}
}
}
```
## Implementation Details
- Single instance per scene (singleton pattern)
- Created in `MapPlayer.create()`, destroyed in `MapPlayer.shutdown()`
- Event bus: `Phaser.Events.EventEmitter` on scene
- Systems accessed via `scene.systems.<name>` or `this.systems.<name>` in other classes
## Files to Create
- `src/systems/SystemOrchestrator.js`
## Dependencies
- All 9 other systems
## Integration Points
- Scene lifecycle: `create()`, `update()`, `shutdown()`
- All systems: Initialized and updated through orchestrator
- Cross-system communication: Events wired here

View File

@@ -0,0 +1,321 @@
# Restitution - Technical Architecture Spec
## Game Overview
C&C-style RTS with 1-4 players controlling units via state machines. Economy-driven production with map control objectives.
## Core Systems
### 1. Entity Component System (ECS-Lite)
**Responsibility:** Unit lifecycle, component composition, scene integration
**Phaser Integration:**
- Base class extends `Phaser.Physics.Arcade.Sprite`
- Components are plain objects attached via `setData()`
- Lifecycle hooks: `create()``preUpdate()``destroy()`
**Components:**
- `HealthComponent`: hp, armor, damage modifiers
- `OwnerComponent`: player ID, team color
- `InventoryComponent`: ammo/fuel consumption rates
- `MovementComponent`: speed, acceleration, rotation speed
- `CombatComponent`: weapon range, damage, fire rate, projectile type
**Interface:**
```javascript
entity.addComponent('health', { maxHp: 100, current: 100, armor: 1 })
entity.getComponent('health').damage(25)
entity.removeComponent('inventory') // on death
```
---
### 2. State Machine (XState)
**Responsibility:** Unit behavior logic, animation wiring, transition guards
**State Definitions:**
- `IDLING`: patrol, scan for enemies
- `MOVING`: path-follow, obstacle avoidance
- `ATTACKING`: acquire, track, fire
- `DYING`: death animation, cleanup timer
**Phaser Integration:**
- State machine stored on entity via `setData('stateMachine')`
- `preUpdate()` calls `stateMachine.send('TICK', { delta, time })`
- Animation plays on `onEnter` via `ctx.anims.play()`
**Transition Guards:**
```javascript
MOVING IDLING: path.length === 0
IDLING ATTACKING: enemyInRange()
ATTACKING MOVING: target.dead || !lineOfSight()
ANY DYING: health <= 0
```
---
### 3. Pathfinding (EasyStar)
**Responsibility:** Tile→world conversion, A* queries, dynamic obstacle avoidance
**Phaser Integration:**
- Grid built from `TilemapLayer.getTileAt()`
- Updated on building spawn/destroy
- Cached paths per-entity (invalidated on obstacle change)
**Interface:**
```javascript
pathfinder.findPath(startTile, endTile, { avoidEnemies: true })
pathfinder.setWalkable(tileX, tileY, false) // building placed
pathfinder.recalculate(entity.id) // obstacle moved
```
**Output:** Array of tile coordinates → converted to world XY via `generateWorldXY()`
---
### 4. Combat System
**Responsibility:** Target acquisition, line-of-sight, projectile spawning, damage resolution
**Sub-systems:**
- **TargetScanner:** Radius query, threat prioritization (closest, lowest HP, highest DPS)
- **LineOfSight:** Raycast from attacker to target (tilemap collision check)
- **ProjectileManager:** Spawn, track, collision detection
- **DamageResolver:** Apply armor modifiers, crit rolls, damage events
**Phaser Integration:**
- Projectiles are `Phaser.Physics.Arcade.Sprite` in dedicated group
- Collision: `physics.add.overlap(projectiles, units, onHit)`
- Damage events emitted via `scene.events.emit('unit:damaged', { entity, amount })`
**Interface:**
```javascript
combat.acquireTarget(entity, { maxRange: 200, fov: 45 })
combat.canHit(attacker, target) // returns bool + reason
combat.fireProjectile(attacker, target, { damage: 25, speed: 400 })
```
---
### 5. Selection & Control
**Responsibility:** Multi-select, command queue, formation movement, right-click context
**Phaser Integration:**
- Selection box: `Phaser.GameObjects.Graphics` with drag handlers
- Command UI: World-space markers above selected units
- Input: `pointerdown`, `pointerdrag`, `pointerup` with modifier keys (Shift, Ctrl)
**Command Types:**
- `MOVE`: pathfind to tile, face direction
- `ATTACK_MOVE`: move + engage enemies en route
- `ATTACK_TARGET`: attack specific unit/building
- `STOP`: cancel current action, hold position
- `PATROL`: loop between waypoints
**Interface:**
```javascript
selection.add(entity)
selection.clear()
selection.issueCommand('ATTACK_MOVE', { targetTile })
selection.setFormation('aggro', { spread: 32 })
```
---
### 6. Network Sync (Socket.IO)
**Responsibility:** State replication, input buffering, lag compensation, interpolation
**Architecture:**
- **Server-authoritative:** All game logic on server, clients send inputs only
- **Snapshot interpolation:** Server broadcasts state at 20Hz, clients lerp between
- **Client prediction:** Local unit movement predicted, reconciled on server response
**Replicated State:**
- Entity: position, rotation, state, health, owner
- Building: position, health, production queue, owner
- Economy: fuel, ammo, capture points (per player)
- Control points: owner, capture progress, units in radius
**Interface:**
```javascript
// Client → Server
socket.emit('input:command', { entityId, type, target })
socket.emit('input:selection', { entityIds })
// Server → Client
socket.on('snapshot', (state) => { /* interpolate */ })
socket.on('reconcile', (serverState) => { /* correct prediction */ })
```
---
### 7. Map & Tile System
**Responsibility:** Tilemap loading, collision layers, zone transitions, spawn points
**Phaser Integration:**
- `this.load.tilemapTiledJSON('map', 'assets/map.json')`
- Layers: `ground`, `collision`, `spawnPoints`, `controlZones`
- Tile size: 64x64 (matches entity physics body)
**Data Structure:**
```javascript
map = {
width: 64, height: 64,
collisionLayer: TilemapLayer,
spawnPoints: [{ x, y, owner: 'player1' }],
controlZones: [{ x, y, radius: 5, owner: null, captureProgress: 0 }]
}
```
---
### 8. Building System
**Responsibility:** Production queues, resource consumption, placement validation, destruction
**Building Types:**
| Type | Produces | Resource Cost | Build Time |
|------|----------|---------------|------------|
| Command Center | Nothing (HQ) | N/A | N/A |
| Barracks | Infantry | 50 Ammo | 10s |
| Vehicle Depot | Vehicles | 100 Fuel | 20s |
| Logistics Center | +5 Fuel/tick | N/A | 15s |
| Ammunition Factory | +5 Ammo/tick | N/A | 15s |
**Phaser Integration:**
- Buildings are static sprites with collision body
- Production queue stored via `setData('productionQueue')`
- Spawn point offset from building center (prevents overlap)
**Interface:**
```javascript
building.addToQueue('infantry', { count: 3 })
building.cancelQueue()
building.spawnUnit() // called by game loop when ready
```
**Placement Rules:**
- Must be on walkable tile
- Minimum distance from enemy buildings (5 tiles)
- Cannot overlap existing entities
---
### 9. Control Point System
**Responsibility:** Capture mechanics, ownership tracking, resource generation
**Mechanics:**
- **Capture:** Unit count in radius > enemy count for 60s → ownership changes
- **Generation:** Each point generates +1 Capture Point/tick for owner
- **Visibility:** Always revealed, capture progress shown via progress bar
**Phaser Integration:**
- Zone marker: `Phaser.GameObjects.Zone` with circular hit area
- Progress UI: World-space bar above control point
- Query: `physics.overlap(zone, units)` to count units per team
**Interface:**
```javascript
controlPoint.getUnitsInRadius(playerId) // count
controlPoint.getCaptureProgress() // 0-100
controlPoint.claim(playerId) // called when progress reaches 100
```
---
### 10. Economy System
**Responsibility:** Resource tracking, income calculation, purchase validation
**Resources:**
| Resource | Source | Use |
|----------|--------|-----|
| Fuel | Logistics Centers (5/tick) | Vehicle production |
| Ammo | Ammunition Factories (5/tick) | Infantry production |
| Capture Points | Control Points (1/tick each) | Map control score |
**Phaser Integration:**
- Economy state stored on server, synced to clients every 1s
- UI updates via `events.on('economy:updated', callback)`
- Purchase validation before building/unit creation
**Interface:**
```javascript
economy.getPlayerResources(playerId) // { fuel, ammo, capturePoints }
economy.canAfford(playerId, { fuel: 100, ammo: 0 }) // bool
economy.deduct(playerId, { fuel: 100 }) // returns success bool
economy.addIncome(playerId, { fuel: 5 }) // called per tick
```
---
## System Communication
### Event Bus Pattern
```javascript
// Central event emitter on scene
this.events = new Phaser.Events.EventEmitter()
// Systems emit
this.events.emit('combat:unitDestroyed', { entity, killer })
this.events.emit('economy:resourceChanged', { playerId, resource, delta })
// Systems subscribe
this.events.on('building:spawned', (building) => {
pathfinder.setWalkable(building.tileX, building.tileY, false)
})
```
### Update Loop Order — XState vs Service Classes
| Loop Step | Pattern | Why |
|---|---|---|
| Input → Selection | Service class | Event handling, no internal state |
| Economy → Income tick | Service class | Stateless calculation |
| Control Points → Capture | **XState** | State: `NEUTRAL``CONTESTED``CAPTURED` |
| Buildings → Production | **XState** | State: `CONSTRUCTING``ACTIVE``PRODUCING` |
| Entities → State machine | **XState** | State: `IDLING``MOVING``ATTACKING``DYING` |
| Pathfinding → Updates | Service class | Pure function (A* query) |
| Combat → Resolution | Service class | Event-driven (collision → damage) |
| Network → Snapshot | Service class | Serialization + broadcast |
**Consistent Pattern:**
- **XState machines** for entities, buildings, control points — anything with lifecycle state
- **Service classes** for systems — anything that's a pure function or event processor
---
## File Structure
```
src/
├── systems/
│ ├── EntityComponentSystem.js
│ ├── StateMachine.js
│ ├── PathfindingSystem.js
│ ├── CombatSystem.js
│ ├── SelectionSystem.js
│ ├── NetworkSystem.js
│ ├── MapSystem.js
│ ├── BuildingSystem.js
│ ├── ControlPointSystem.js
│ └── EconomySystem.js
├── entities/
│ ├── base-units/
│ │ ├── infantry.js
│ │ └── tank.js
│ └── buildings/
│ ├── barracks.js
│ ├── vehicleDepot.js
│ └── ...
├── scenes/
│ ├── BootLoader.js
│ ├── MainMenu.js
│ ├── MapPlayer.js
│ └── ServerConnector.js
└── index.js
```
---
## Next Steps
1. **Refactor:** Extract monolithic `Custom_Entity` into ECS components
2. **System wiring:** Create system instances in `MapPlayer.create()`, wire events
3. **State machine migration:** Move XState configs to `StateMachine.js` with standardized interface
4. **Network sync:** Implement server-authoritative snapshot system
5. **UI integration:** Resource display, selection panel, command UI

6
babel.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
['@babel/preset-react', { runtime: 'automatic' }]
]
};

7445
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,8 @@
"start:webpack": "npm run install && npm run serve",
"start:socket_server": "npm run install && npm run socket_server",
"start:all": "npm run install && npm run socket_server && npm run serve",
"serve": "webpack serve --open"
"serve": "webpack serve --open",
"test": "jest --verbose --coverage"
},
"repository": {
"type": "git",
@@ -52,9 +53,38 @@
"@babel/preset-react": "^7.18.6",
"css-loader": "^6.7.1",
"html-webpack-plugin": "^5.5.0",
"jest": "^30.4.2",
"jest-environment-jsdom": "^30.4.1",
"style-loader": "^3.3.1",
"webpack": "^5.74.0",
"webpack-cli": "^4.10.0",
"webpack-dev-server": "^4.11.1"
},
"jest": {
"testEnvironment": "jsdom",
"transform": {
"^.+\\.jsx?$": "babel-jest"
},
"transformIgnorePatterns": [
"/node_modules/(?!(phaser|easystarjs|xstate)/)"
],
"moduleNameMapper": {
"^PhaserClasses/(.*)$": "<rootDir>/src/phaserClasses/$1",
"^Entities/(.*)$": "<rootDir>/src/entities/$1",
"^Systems/(.*)$": "<rootDir>/src/systems/$1",
"^phaser$": "<rootDir>/node_modules/phaser/dist/phaser.js"
},
"setupFilesAfterEnv": [
"<rootDir>/tests/setup.js"
],
"testPathIgnorePatterns": [
"/node_modules/",
"/dist/"
],
"collectCoverageFrom": [
"src/systems/**/*.js",
"src/entities/**/*.js"
],
"coverageDirectory": "coverage"
}
}

281
src/entities/Unit.js Normal file
View File

@@ -0,0 +1,281 @@
import Phaser from 'phaser';
import EntityStateMachine from 'Systems/EntityStateMachine';
/**
* Unit Entity - Component-based architecture
* Extends Phaser.Physics.Arcade.Sprite with modular components
*/
export default class Unit extends Phaser.Physics.Arcade.Sprite {
constructor(scene, texture, startingTile, config = {}) {
if (!scene) {
throw new Error('Unit requires scene reference');
}
const worldPointer = scene.interface?.generateWorldXY?.(startingTile) ||
{ x: startingTile.x * 64, y: startingTile.y * 64 };
super(scene, worldPointer.x, worldPointer.y, texture);
// Add to scene and enable physics
scene.add.existing(this);
scene.physics.world.enableBody(this, Phaser.Physics.Arcade.DYNAMIC_BODY);
// Initialize components
this.components = {
health: {
maxHp: config.maxHp || 100,
current: config.maxHp || 100,
armor: config.armor || 1,
damageModifiers: config.damageModifiers || {}
},
owner: {
playerId: config.playerId || null,
team: config.team || 'neutral',
teamColor: config.teamColor || 0xffffff
},
inventory: {
fuel: config.fuel || 0,
ammo: config.ammo || 0,
consumptionRates: config.consumptionRates || { fuel: 0, ammo: 0 }
},
movement: {
speed: config.speed || 100,
acceleration: config.acceleration || 200,
rotationSpeed: config.rotationSpeed || 180,
maxPathLength: config.maxPathLength || 50
},
combat: {
weaponRange: config.weaponRange || 200,
damage: config.damage || 25,
fireRate: config.fireRate || 1000,
projectileType: config.projectileType || 'rifle',
lastFireTime: 0
}
};
// Physics setup
this.body.allowGravity = false;
this.setScale(1);
this.updatePhysicsSize();
this.dead = false;
this.setInteractive({ pixelPerfect: true });
// Initialize state machine
this.stateMachine = null;
this._initStateMachine();
// Pointer events
this.on('pointerover', () => this.select());
this.on('pointerout', () => this.unSelect());
this.on('pointerdown', () => {
scene.orchestrator?.systems?.selection?.add(this);
});
}
/**
* Initialize the XState state machine
*/
_initStateMachine() {
if (this.scene.orchestrator?.systems?.EntityStateMachine) {
this.stateMachine = EntityStateMachine.forEntity(this, {
scene: this.scene,
combatSystem: this.scene.orchestrator.systems.combat,
pathfindingSystem: this.scene.orchestrator.systems.pathfinding
});
}
}
/**
* Component accessors
*/
getComponent(name) {
return this.components[name];
}
setComponent(name, value) {
this.components[name] = { ...this.components[name], ...value };
}
/**
* Health methods
*/
damage(amount, damageType = 'default') {
const combat = this.getComponent('combat');
const health = this.getComponent('health');
if (!health) return 0;
// Apply armor reduction
const damageModifiers = combat?.damageModifiers || {};
const armorPiercing = damageModifiers[damageType]?.armorPiercing || 0;
const effectiveArmor = health.armor * (1 - armorPiercing);
const finalDamage = Math.max(1, amount - effectiveArmor);
health.current = Math.max(0, health.current - finalDamage);
this.setData('health', health.current);
// Emit damage event
this.scene.events.emit('unit:damaged', {
unit: this,
amount: finalDamage,
damageType,
remaining: health.current
});
// Check death
if (health.current <= 0) {
this.die();
}
return finalDamage;
}
heal(amount) {
const health = this.getComponent('health');
if (!health) return 0;
health.current = Math.min(health.maxHp, health.current + amount);
this.setData('health', health.current);
return amount;
}
isDead() {
return this.dead || this.getComponent('health').current <= 0;
}
die() {
this.dead = true;
this.stateMachine?.send('DIE');
this.scene.events.emit('unit:dying', { unit: this });
}
/**
* Movement methods
*/
moveToTile(tile) {
const positionVector = this.scene.interface?.generateWorldXY?.(tile) ||
{ x: tile.x * 64, y: tile.y * 64 };
this.setData('lastTile', tile);
this.setData('targetTile', tile);
return !!this.setPosition(positionVector.x, positionVector.y);
}
getDirection(pointA, pointB) {
const radians = Phaser.Math.Angle.BetweenPoints(pointA, pointB);
const degrees = Phaser.Math.RadToDeg(radians);
if (degrees >= 0 && degrees < 90) return 'NORTH';
if (degrees >= 90 && degrees < 180) return 'EAST';
if (degrees >= 180 && degrees < 270) return 'SOUTH';
return 'WEST';
}
orientToTarget(target) {
if (!target) return;
const direction = this.getDirection(this, target);
const shouldFlip = direction === 'EAST' || direction === 'SOUTH';
this.setFlipX(shouldFlip);
}
/**
* Combat methods
*/
canHitBody(target) {
if (!target || target.isDead?.()) return false;
const combat = this.getComponent('combat');
const distance = Phaser.Math.Distance.BetweenPoints(this, target);
return distance <= (combat?.weaponRange || 200);
}
attackTarget(target) {
if (!target) return false;
const combat = this.getComponent('combat');
const now = Date.now();
if (!this.canHitBody(target)) return false;
if (now - combat.lastFireTime < combat.fireRate) return false;
combat.lastFireTime = now;
this.stateMachine?.send('ATTACK', { target });
// Fire projectile via combat system
this.scene.orchestrator?.systems?.combat?.fireProjectile?.(this, target, {
damage: combat.damage,
speed: 400,
type: combat.projectileType
});
return true;
}
/**
* Selection methods
*/
select() {
this.pulse?.stop();
const team = this.getComponent('owner')?.team;
const isEnemy = team === 'enemy';
this.pulse = this.scene.tweens.addCounter({
from: 175,
to: 255,
duration: 750,
ease: 'Power2',
loop: -1,
yoyo: true,
onUpdate: (tween) => {
const value = Math.floor(tween.getValue());
if (isEnemy) {
this.setTint(Phaser.Display.Color.GetColor32(value, 0, 0, 255));
} else {
this.setTint(Phaser.Display.Color.GetColor32(0, value, 0, 255));
}
}
});
this.setData('selected', true);
}
unSelect() {
if (!this.getData('selected')) return;
this.pulse?.stop();
this.clearTint();
this.setData('selected', false);
}
/**
* Physics
*/
updatePhysicsSize() {
// Override in subclasses to define sprite-specific physics
this.body.setSize(32, 32);
this.body.setOffset(16, 16);
}
/**
* Update loop
*/
preUpdate(time, delta) {
// Phaser.Sprite.preUpdate may not exist in mock
if (super.preUpdate) {
super.preUpdate(time, delta);
}
// Tick state machine
this.stateMachine?.tick(time, delta);
}
/**
* Cleanup
*/
destroy(fromScene) {
this.pulse?.stop();
this.stateMachine?.destroy();
super.destroy(fromScene);
}
}

View File

@@ -1,220 +1,184 @@
import Phaser from "phaser";
import "PhaserClasses/CustomConstants";
import CONSTANTS from "PhaserClasses/CustomConstants";
import Custom_Entity from "PhaserClasses/Custom_Entity";
import Infantry_State_Config from "Entities/base-units/state-configs/infantry-states.js";
import Phaser from 'phaser';
import CONSTANTS from 'PhaserClasses/CustomConstants';
import Unit from 'Entities/Unit';
import Infantry_State_Config from 'Entities/base-units/state-configs/infantry-states.js';
export default class Infantry extends Custom_Entity {
constructor(scene, skin, startingTile) {
super(scene, skin, startingTile);
this.skin = skin || "infantry-ukraine";
this.onStart();
}
export default class Infantry extends Unit {
constructor(scene, skin, startingTile) {
super(scene, skin, startingTile, {
health: { health: 100, armor: 1 },
movement: { updateDelta: CONSTANTS.HUMANOID.updateDelta },
combat: { range: CONSTANTS.RIFLE.RANGE, damage: CONSTANTS.RIFLE.DAMAGE, damageType: 'rifle' },
});
this.skin = skin || 'infantry-ukraine';
this.onStart();
}
onStart() {
this.STATES = Infantry_State_Config;
this.setAnimations();
this.nextState(this.STATES.IDLING);
}
ACTIONS = {
goIDLE: () => {
this.nextState(this.STATES.IDLING);
},
MOVE: () => {
this.nextState(this.STATES.MOVING);
},
goSHOOT: (target) => {
this.setData("target", target);
this.nextState(this.STATES.SHOOTING);
},
DIE: () => {
this.nextState(this.STATES.DYING);
},
shootTARGET: (target) => {
this.orientToTarget(target);
target.handleTakeDamage(this, CONSTANTS.RIFLE.DAMAGE);
this.emit("DAMAGE_ENTITY", {
target: target,
weapon: CONSTANTS.RIFLE,
// range: Distance to the target. Potentially something for a game manager to do
});
return;
},
};
onStart() {
this.STATES = Infantry_State_Config;
this.setAnimations();
this.nextState(this.STATES.IDLING);
}
nextState(state) {
if (this.state) {
this.state.onExit(this);
}
this.setState(state);
this.state.onEnter(this);
}
clearTarget() {
this.setData("target", null);
}
ACTIONS = {
goIDLE: () => {
this.nextState(this.STATES.IDLING);
},
MOVE: () => {
this.nextState(this.STATES.MOVING);
},
goSHOOT: (target) => {
this.setData('target', target);
this.nextState(this.STATES.SHOOTING);
},
DIE: () => {
this.nextState(this.STATES.DYING);
},
shootTARGET: (target) => {
this.orientToTarget(target);
target.handleTakeDamage(this, CONSTANTS.RIFLE.DAMAGE);
this.emit('DAMAGE_ENTITY', {
target: target,
weapon: CONSTANTS.RIFLE,
});
return;
},
};
handleDeath() {
this.ACTIONS.DIE();
}
nextState(state) {
if (this.state) {
this.state.onExit(this);
}
this.setState(state);
this.state.onEnter(this);
}
setAnimations() {
for (const key in this.STATES) {
let stateConfig = this.STATES[key];
this.anims.create({
key: stateConfig.key,
frames: this.anims.generateFrameNumbers(this.skin, {
start: stateConfig.animationConfig.start || 0,
end: stateConfig.animationConfig.end || 0,
}),
frameRate: stateConfig.animationConfig.frameRate || 10,
repeat: stateConfig.animationConfig.repeat || 0,
});
}
}
debug() {
this.setDebug(true, true, true);
}
clearTarget() {
this.setData('target', null);
}
canHitBody(target) {
// Requires a physics body be supplied!
let pointA = this.body.center;
let pointB = target.body.center;
return (
Phaser.Math.Distance.BetweenPoints(pointA, pointB) <
CONSTANTS.RIFLE.RANGE
);
}
handleDeath() {
this.ACTIONS.DIE();
}
orientToTarget(targetOverride) {
// Check if we can hit an optionally supplied target, or a stored target
const target = targetOverride || this.getData("target");
if (!target) {
return;
}
let pointA = this.body.center;
let pointB = target.body.center;
this.newOrientation(pointA, pointB);
}
setAnimations() {
for (const key in this.STATES) {
const stateConfig = this.STATES[key];
this.anims.create({
key: stateConfig.key,
frames: this.anims.generateFrameNumbers(this.skin, {
start: stateConfig.animationConfig.start || 0,
end: stateConfig.animationConfig.end || 0,
}),
frameRate: stateConfig.animationConfig.frameRate || 10,
repeat: stateConfig.animationConfig.repeat || 0,
});
}
}
isMoving() {
return this.state.key === this.STATES.MOVING.key;
}
debug() {
this.setDebug(true, true, true);
}
isIdle() {
return this.state.key === this.STATES.IDLING.key;
}
canHitBody(target) {
if (!target || !target.body) return false;
const pointA = this.body.center;
const pointB = target.body.center;
return (
Phaser.Math.Distance.BetweenPoints(pointA, pointB) <
CONSTANTS.RIFLE.RANGE
);
}
isDead() {
return this.state.key === this.STATES.DYING.key;
}
orientToTarget(targetOverride) {
const target = targetOverride || this.getData('target');
if (!target) return;
const pointA = this.body.center;
const pointB = target.body.center;
this.newOrientation(pointA, pointB);
}
handleTakeDamage(source, value) {
if (this.isDead()) {
return;
}
// Get input, which should be the value of damage dealt by the attacker
// Divide it by the armor value
// Then save the new value
let damageTaken = value / this.getData("armor");
let newHealth = this.getData("health") - damageTaken;
this.setData("health", newHealth);
if (this.getData("godMode")) {
return false;
}
if (newHealth <= 0) {
this.handleDeath();
}
}
isMoving() {
return this.state.key === this.STATES.MOVING.key;
}
getEnemyContainer() {
if (this.parentContainer.name === "Good Guys") {
return this.scene.enemies;
} else {
return this.scene.goodGuys;
}
}
isIdle() {
return this.state.key === this.STATES.IDLING.key;
}
isEnemy() {
return this.parentContainer.name !== "Good Guys";
}
isDead() {
return this.state.key === this.STATES.DYING.key;
}
engageNearbyEnemies() {
const enemyContainer = this.getEnemyContainer();
let newTarget;
if (enemyContainer && enemyContainer.list.length) {
let closestSprite = this.scene.physics.closest(
this,
enemyContainer.getAll("dead", false).map((object) => {
return object.body;
}) // Get all entities in a container
);
handleTakeDamage(source, value) {
if (this.isDead()) return;
const damageTaken = value / this.getData('armor');
const newHealth = this.getData('health') - damageTaken;
this.setData('health', newHealth);
// Sync with component
if (this.health) this.health._health = newHealth;
if (this.getData('godMode')) return false;
if (newHealth <= 0) {
this.handleDeath();
}
}
newTarget = closestSprite && closestSprite.gameObject;
}
engageNearbyEnemies() {
const enemyContainer = this.getEnemyContainer();
let newTarget;
if (enemyContainer && enemyContainer.list.length) {
const closestSprite = this.scene.physics.closest(
this,
enemyContainer.getAll('dead', false).map((object) => object.body),
);
newTarget = closestSprite && closestSprite.gameObject;
}
if (newTarget && this.canHitBody(newTarget)) {
this.ACTIONS.goSHOOT(newTarget);
} else {
this.clearTarget();
}
}
if (newTarget && this.canHitBody(newTarget)) {
this.ACTIONS.goSHOOT(newTarget);
} else {
this.clearTarget();
}
}
updatePhysicsSize() {
this.setBodySize(14, 20, false);
this.setOffset(23, 12);
}
updatePhysicsSize() {
this.setBodySize(14, 20, false);
this.setOffset(23, 12);
}
moveToPath(easyStarPath, shiftDown) {
if (shiftDown) {
let existingPath = this.getData("path");
this.setData("path", existingPath.concat(easyStarPath));
} else {
this.setData("path", easyStarPath);
}
let pointA = easyStarPath[0];
let pointB = easyStarPath[easyStarPath.length - 1];
this.newOrientation(pointA, pointB);
if (!this.isMoving()) {
this.ACTIONS.MOVE();
}
}
moveToPath(easyStarPath, shiftDown) {
if (shiftDown) {
const existingPath = this.getData('path');
this.setData('path', existingPath.concat(easyStarPath));
} else {
this.setData('path', easyStarPath);
}
const pointA = easyStarPath[0];
const pointB = easyStarPath[easyStarPath.length - 1];
this.newOrientation(pointA, pointB);
if (!this.isMoving()) {
this.ACTIONS.MOVE();
}
}
nextPath() {
let path = this.getData("path");
if (path && path.length > 0) {
const point = path.shift();
this.setData("path", path);
const tile = this.scene.groundLayer.getTileAt(point.x, point.y);
return !!this.moveToTile(tile);
} else {
return false;
}
}
nextPath() {
const path = this.getData('path');
if (path && path.length > 0) {
const point = path.shift();
this.setData('path', path);
const tile = this.scene.groundLayer.getTileAt(point.x, point.y);
return !!this.moveToTile(tile);
}
return false;
}
preUpdate(time, delta) {
super.preUpdate(time, delta);
preUpdate(time, delta) {
super.preUpdate(time, delta);
if (!this.updateDeltaFrame(delta)) {
return; // This function will determine if we should bother to update, and serves as a simple rate limiter
}
if (!this.movement.shouldUpdate(delta)) {
return;
}
this.state.updateFunction(this, time, delta);
}
updateDeltaFrame(delta) {
let frameTime = this.getData("frameTime");
this.setData("frameTime", frameTime + delta);
if (
frameTime <
CONSTANTS.HUMANOID.updateDelta - Math.round(Math.random() * 20)
) {
// In order to keep the game loop smooth, randomize the update delta a bit
// If time is less than update time (minus a random amount up to 20 ms), don't bother
return false;
}
// This will call this last step, and on success force it to be a boolean.
// That means if it fails, the game will crash.
return !!this.setData("frameTime", 0);
}
this.state.updateFunction(this, time, delta);
}
}

View File

@@ -1,219 +1,182 @@
import Phaser from "phaser";
import "PhaserClasses/CustomConstants";
import CONSTANTS from "PhaserClasses/CustomConstants";
import Custom_Entity from "PhaserClasses/Custom_Entity";
import Tank_State_Config from "Entities/base-units/state-configs/tank-states.js";
import Phaser from 'phaser';
import CONSTANTS from 'PhaserClasses/CustomConstants';
import Unit from 'Entities/Unit';
import Tank_State_Config from 'Entities/base-units/state-configs/tank-states.js';
export default class Tank extends Custom_Entity {
constructor(scene, skin, startingTile) {
super(scene, skin, startingTile);
this.skin = skin;
this.onStart();
}
export default class Tank extends Unit {
constructor(scene, skin, startingTile) {
super(scene, skin, startingTile, {
health: { health: 100, armor: 5 },
movement: { updateDelta: CONSTANTS.TANK.updateDelta },
combat: { range: CONSTANTS.TANK_CANNON.RANGE, damage: CONSTANTS.TANK_CANNON.DAMAGE, damageType: 'tank_cannon' },
});
this.skin = skin;
this.onStart();
}
onStart() {
this.STATES = Tank_State_Config;
this.setAnimations();
this.setData("armor", 5);
this.nextState(this.STATES.IDLING);
}
onStart() {
this.STATES = Tank_State_Config;
this.setAnimations();
this.setData('armor', 5);
if (this.health) this.health.setArmor(5);
this.nextState(this.STATES.IDLING);
}
ACTIONS = {
goIDLE: () => {
this.nextState(this.STATES.IDLING);
},
MOVE: () => {
this.nextState(this.STATES.MOVING);
},
goSHOOT: (target) => {
console.log(`${this.name} targeting ${target.name}`);
this.setData("target", target);
this.nextState(this.STATES.SHOOTING);
},
DIE: () => {
this.nextState(this.STATES.DYING);
},
shootTARGET: (target) => {
this.orientToTarget(target);
target.handleTakeDamage(this, CONSTANTS.TANK_CANNON.DAMAGE);
this.emit("DAMAGE_ENTITY", {
target: target,
weapon: CONSTANTS.TANK_CANNON,
// range: Distance to the target. Potentially something for a game manager to do
});
return;
},
};
ACTIONS = {
goIDLE: () => {
this.nextState(this.STATES.IDLING);
},
MOVE: () => {
this.nextState(this.STATES.MOVING);
},
goSHOOT: (target) => {
console.log(`${this.name} targeting ${target.name}`);
this.setData('target', target);
this.nextState(this.STATES.SHOOTING);
},
DIE: () => {
this.nextState(this.STATES.DYING);
},
shootTARGET: (target) => {
this.orientToTarget(target);
target.handleTakeDamage(this, CONSTANTS.TANK_CANNON.DAMAGE);
this.emit('DAMAGE_ENTITY', {
target: target,
weapon: CONSTANTS.TANK_CANNON,
});
return;
},
};
nextState(state) {
if (this.state) {
this.state.onExit(this);
}
this.setState(state);
this.state.onEnter(this);
}
clearTarget() {
this.setData("target", null);
}
nextState(state) {
if (this.state) {
this.state.onExit(this);
}
this.setState(state);
this.state.onEnter(this);
}
handleDeath() {
this.ACTIONS.DIE();
}
clearTarget() {
this.setData('target', null);
}
setAnimations() {
for (const key in this.STATES) {
let stateConfig = this.STATES[key];
this.anims.create({
key: stateConfig.key,
frames: this.anims.generateFrameNumbers(this.skin, {
start: stateConfig.animationConfig.start || 0,
end: stateConfig.animationConfig.end || 0,
}),
frameRate: stateConfig.animationConfig.frameRate || 10,
repeat: stateConfig.animationConfig.repeat || 0,
repeatDelay: stateConfig.animationConfig.repeatDelay || 0,
});
}
}
debug() {
this.setDebug(true, true, true);
}
handleDeath() {
this.ACTIONS.DIE();
}
canHitBody(target) {
// Requires a physics body be supplied!
let pointA = this.body.center;
let pointB = target.body.center;
return (
Phaser.Math.Distance.BetweenPoints(pointA, pointB) <
CONSTANTS.RIFLE.RANGE
);
}
setAnimations() {
for (const key in this.STATES) {
const stateConfig = this.STATES[key];
this.anims.create({
key: stateConfig.key,
frames: this.anims.generateFrameNumbers(this.skin, {
start: stateConfig.animationConfig.start || 0,
end: stateConfig.animationConfig.end || 0,
}),
frameRate: stateConfig.animationConfig.frameRate || 10,
repeat: stateConfig.animationConfig.repeat || 0,
repeatDelay: stateConfig.animationConfig.repeatDelay || 0,
});
}
}
orientToTarget(targetOverride) {
// Check if we can hit an optionally supplied target, or a stored target
const target = targetOverride || this.getData("target");
if (!target) {
return;
}
let pointA = this.body.center;
let pointB = target.body.center;
this.newOrientation(pointA, pointB);
}
debug() {
this.setDebug(true, true, true);
}
isMoving() {
return this.state.key === this.STATES.MOVING.key;
}
canHitBody(target) {
if (!target || !target.body) return false;
const pointA = this.body.center;
const pointB = target.body.center;
return (
Phaser.Math.Distance.BetweenPoints(pointA, pointB) <
CONSTANTS.RIFLE.RANGE
);
}
isIdle() {
return this.state.key === this.STATES.IDLING.key;
}
orientToTarget(targetOverride) {
const target = targetOverride || this.getData('target');
if (!target) return;
const pointA = this.body.center;
const pointB = target.body.center;
this.newOrientation(pointA, pointB);
}
isDead() {
return this.state.key === this.STATES.DYING.key;
}
isMoving() {
return this.state.key === this.STATES.MOVING.key;
}
handleTakeDamage(source, value) {
if (this.isDead()) {
return;
}
// Get input, which should be the value of damage dealt by the attacker
// Divide it by the armor value
// Then save the new value
let damageTaken = value / this.getData("armor");
let newHealth = this.getData("health") - damageTaken;
this.setData("health", newHealth);
if (this.getData("godMode")) {
return false;
}
if (newHealth <= 0) {
this.handleDeath();
}
}
isIdle() {
return this.state.key === this.STATES.IDLING.key;
}
getEnemyContainer() {
if (this.parentContainer.name === "Good Guys") {
return this.scene.enemies;
} else {
return this.scene.goodGuys;
}
}
isDead() {
return this.state.key === this.STATES.DYING.key;
}
isEnemy() {
return this.parentContainer.name !== "Good Guys";
}
handleTakeDamage(source, value) {
if (this.isDead()) return;
const damageTaken = value / this.getData('armor');
const newHealth = this.getData('health') - damageTaken;
this.setData('health', newHealth);
if (this.health) this.health._health = newHealth;
if (this.getData('godMode')) return false;
if (newHealth <= 0) {
this.handleDeath();
}
}
engageNearbyEnemies() {
const enemyContainer = this.getEnemyContainer();
let newTarget;
if (enemyContainer && enemyContainer.list.length) {
let closestSprite = this.scene.physics.closest(
this,
enemyContainer.getAll("dead", false).map((object) => {
return object.body;
}) // Get all entities in a container
);
engageNearbyEnemies() {
const enemyContainer = this.getEnemyContainer();
let newTarget;
if (enemyContainer && enemyContainer.list.length) {
const closestSprite = this.scene.physics.closest(
this,
enemyContainer.getAll('dead', false).map((object) => object.body),
);
newTarget = closestSprite && closestSprite.gameObject;
}
newTarget = closestSprite && closestSprite.gameObject;
}
if (newTarget && this.canHitBody(newTarget)) {
this.ACTIONS.goSHOOT(newTarget);
} else {
this.clearTarget();
}
}
if (newTarget && this.canHitBody(newTarget)) {
this.ACTIONS.goSHOOT(newTarget);
} else {
this.clearTarget();
}
}
updatePhysicsSize() {
this.setBodySize(30, 30, false);
this.setOffset(15, 10);
}
updatePhysicsSize() {
this.setBodySize(30, 30, false);
this.setOffset(15, 10);
}
moveToPath(easyStarPath) {
this.setData('path', easyStarPath);
const pointA = easyStarPath[0];
const pointB = easyStarPath[easyStarPath.length - 1];
this.newOrientation(pointA, pointB);
if (!this.isMoving()) {
this.ACTIONS.MOVE();
}
}
moveToPath(easyStarPath) {
this.setData("path", easyStarPath);
let pointA = easyStarPath[0];
let pointB = easyStarPath[easyStarPath.length - 1];
this.newOrientation(pointA, pointB);
if (!this.isMoving()) {
this.ACTIONS.MOVE();
}
}
nextPath() {
const path = this.getData('path');
if (path && path.length > 0) {
const point = path.shift();
this.setData('path', path);
const tile = this.scene.groundLayer.getTileAt(point.x, point.y);
return !!this.moveToTile(tile);
}
return false;
}
nextPath() {
let path = this.getData("path");
if (path && path.length > 0) {
const point = path.shift();
this.setData("path", path);
const tile = this.scene.groundLayer.getTileAt(point.x, point.y);
return !!this.moveToTile(tile);
} else {
return false;
}
}
preUpdate(time, delta) {
super.preUpdate(time, delta);
preUpdate(time, delta) {
super.preUpdate(time, delta);
if (!this.movement.shouldUpdate(delta)) {
return;
}
if (!this.updateDeltaFrame(delta)) {
return; // This function will determine if we should bother to update, and serves as a simple rate limiter
}
this.state.updateFunction(this, time, delta);
}
updateDeltaFrame(delta) {
let frameTime = this.getData("frameTime");
this.setData("frameTime", frameTime + delta);
if (
frameTime <
CONSTANTS.TANK.updateDelta - Math.round(Math.random() * 20)
) {
// In order to keep the game loop smooth, randomize the update delta a bit
// If time is less than update time (minus a random amount up to 20 ms), don't bother
return false;
}
// This will call this last step, and on success force it to be a boolean.
// That means if it fails, the game will crash.
return !!this.setData("frameTime", 0);
}
this.state.updateFunction(this, time, delta);
}
}

View File

@@ -0,0 +1,109 @@
/**
* Building type configurations.
*
* Each building type defines its production capability, resource cost,
* build time, and income generation (for passive income buildings).
*
* Types:
* - COMMAND_CENTER : HQ, no production, no cost, cannot be built (starting building)
* - BARRACKS : Trains infantry units
* - VEHICLE_DEPOT : Builds vehicle units
* - LOGISTICS : Passive fuel generation
* - AMMO_FACTORY : Passive ammo generation
*/
const BUILDING_TYPES = {
COMMAND_CENTER: {
id: "COMMAND_CENTER",
label: "Command Center",
buildCost: null, // cannot be built — only exists at game start
buildTime: 0,
productions: [], // nothing to queue
income: null,
health: 1000,
description: "Headquarters. Losing this costs you the game.",
},
BARRACKS: {
id: "BARRACKS",
label: "Barracks",
buildCost: { ammo: 50 },
buildTime: 10000, // 10 s
productions: [
{
id: "infantry",
label: "Infantry",
cost: { ammo: 20 },
productionTime: 8000, // 8 s per unit
},
],
income: null,
health: 400,
maxQueueSize: 5,
description: "Trains infantry soldiers.",
},
VEHICLE_DEPOT: {
id: "VEHICLE_DEPOT",
label: "Vehicle Depot",
buildCost: { fuel: 100 },
buildTime: 20000, // 20 s
productions: [
{
id: "tank",
label: "Tank",
cost: { fuel: 80 },
productionTime: 15000, // 15 s per unit
},
],
income: null,
health: 600,
maxQueueSize: 3,
description: "Assembles armoured vehicles.",
},
LOGISTICS: {
id: "LOGISTICS",
label: "Logistics Center",
buildCost: { fuel: 75 },
buildTime: 15000, // 15 s
productions: [],
income: { fuel: 5 },
health: 350,
maxQueueSize: 0,
description: "Generates +5 Fuel per tick.",
},
AMMO_FACTORY: {
id: "AMMO_FACTORY",
label: "Ammunition Factory",
buildCost: { ammo: 75 },
buildTime: 15000, // 15 s
productions: [],
income: { ammo: 5 },
health: 350,
maxQueueSize: 0,
description: "Generates +5 Ammo per tick.",
},
};
/**
* Look up a building type by its id string.
*
* @param {string} id e.g. "BARRACKS"
* @returns {Object|undefined}
*/
export function getBuildingType(id) {
return BUILDING_TYPES[id];
}
/**
* Return the full building types map.
*
* @returns {Object}
*/
export function getAllBuildingTypes() {
return BUILDING_TYPES;
}
export default BUILDING_TYPES;

View File

@@ -0,0 +1,114 @@
/**
* CombatComponent — weapon range, damage, fire rate for a Unit.
*/
import CONSTANTS from 'PhaserClasses/CustomConstants';
export default class CombatComponent {
/**
* @param {import('../Unit').default} unit
* @param {Object} [config]
* @param {number} [config.range=150] - weapon range in px
* @param {number} [config.damage=10] - base damage per hit
* @param {number} [config.fireRate=1000] - ms between shots
* @param {string} [config.damageType='rifle'] - key into CombatSystem modifiers
* @param {number} [config.accuracy=1.0] - 0.0 to 1.0 hit probability
*/
constructor(unit, config = {}) {
this.unit = unit;
/** @type {number} */
this._range = config.range ?? CONSTANTS.RIFLE.RANGE;
/** @type {number} */
this._damage = config.damage ?? CONSTANTS.RIFLE.DAMAGE;
/** @type {number} */
this._fireRate = config.fireRate ?? 1000;
/** @type {string} */
this._damageType = config.damageType ?? 'rifle';
/** @type {number} */
this._accuracy = config.accuracy ?? 1.0;
/** @type {number} */
this._lastFireTime = 0;
/** @type {import('../Unit').default|null} */
this._target = null;
}
// ── Accessors ─────────────────────────────────────────────────
/** @returns {number} */ get range() { return this._range; }
/** @returns {number} */ get damage() { return this._damage; }
/** @returns {number} */ get fireRate() { return this._fireRate; }
/** @returns {string} */ get damageType() { return this._damageType; }
/** @returns {number} */ get accuracy() { return this._accuracy; }
/** @returns {number} */ get lastFireTime() { return this._lastFireTime; }
/** @returns {import('../Unit').default|null} */ get target() { return this._target; }
/** @param {number} val */
set range(val) { this._range = val; }
/** @param {number} val */
set damage(val) { this._damage = val; }
/** @param {number} val */
set fireRate(val) { this._fireRate = val; }
/** @param {import('../Unit').default|null} val */
set target(val) { this._target = val; }
// ── Public API ────────────────────────────────────────────────
/**
* Check if the weapon can fire (cooldown elapsed).
* @param {number} currentTime - scene time in ms
* @returns {boolean}
*/
canFire(currentTime) {
return (currentTime - this._lastFireTime) >= this._fireRate;
}
/**
* Record a shot fired at the given time.
* @param {number} time - scene time in ms
*/
recordFire(time) {
this._lastFireTime = time;
}
/**
* Check if this unit can hit a target based on range.
* @param {import('../Unit').default} target
* @returns {boolean}
*/
canHitBody(target) {
if (!target || !target.body || !this.unit || !this.unit.body) return false;
const pointA = this.unit.body.center;
const pointB = target.body.center;
return Phaser.Math.Distance.BetweenPoints(pointA, pointB) < this._range;
}
/**
* Check if a shot connects (accuracy roll).
* @returns {boolean}
*/
accuracyCheck() {
return Math.random() < this._accuracy;
}
/**
* Serialize for network sync.
*/
serialize() {
return {
range: this._range,
damage: this._damage,
fireRate: this._fireRate,
damageType: this._damageType,
accuracy: this._accuracy,
};
}
}

View File

@@ -0,0 +1,121 @@
/**
* HealthComponent — manages hp, armor, damage modifiers, and death state.
* Attached to a Unit via the component pattern.
*/
export default class HealthComponent {
/**
* @param {import('../Unit').default} unit - The owning Unit entity
* @param {Object} [config]
* @param {number} [config.health=100]
* @param {number} [config.armor=1]
* @param {number} [config.maxHealth] - If omitted, defaults to initial health
*/
constructor(unit, config = {}) {
this.unit = unit;
/** @type {number} */
this._health = config.health ?? 100;
/** @type {number} */
this._maxHealth = config.maxHealth ?? this._health;
/** @type {number} */
this._armor = config.armor ?? 1;
/** @type {boolean} */
this._godMode = false;
/** @type {boolean} */
this._dead = false;
}
// ── Accessors ─────────────────────────────────────────────────
/** @returns {number} */
get health() { return this._health; }
/** @returns {number} */
get maxHealth() { return this._maxHealth; }
/** @returns {number} */
get armor() { return this._armor; }
/** @returns {boolean} */
get isDead() { return this._dead; }
/** @param {boolean} val */
set godMode(val) { this._godMode = val; }
/** @returns {boolean} */
get godMode() { return this._godMode; }
// ── Public API ────────────────────────────────────────────────
/**
* Apply raw damage using armor reduction formula.
* @param {number} amount - Raw incoming damage
* @param {string} [damageType='default'] - Key into CombatSystem.damageModifiers
* @returns {number} Actual damage dealt
*/
takeDamage(amount, damageType = 'default') {
if (this._dead) return 0;
if (this._godMode) return 0;
let effectiveDamage = amount / this._armor;
effectiveDamage = Math.max(1, Math.round(effectiveDamage));
this._health -= effectiveDamage;
if (this._health <= 0) {
this._health = 0;
this._dead = true;
if (typeof this.unit.handleDeath === 'function') {
this.unit.handleDeath();
}
}
return effectiveDamage;
}
/**
* Heal the unit by a given amount (capped at maxHealth).
* @param {number} amount
* @returns {number} actual amount healed
*/
heal(amount) {
if (this._dead) return 0;
const old = this._health;
this._health = Math.min(this._maxHealth, this._health + amount);
return this._health - old;
}
/**
* Set armor value.
* @param {number} val
*/
setArmor(val) {
this._armor = Math.max(0, val);
}
/**
* Get health fraction (0.0 - 1.0).
* @returns {number}
*/
get healthFraction() {
if (this._maxHealth === 0) return 0;
return this._health / this._maxHealth;
}
/**
* Serialize for network sync.
* @returns {{ health: number, maxHealth: number, armor: number, dead: boolean }}
*/
serialize() {
return {
health: this._health,
maxHealth: this._maxHealth,
armor: this._armor,
dead: this._dead,
};
}
}

View File

@@ -0,0 +1,100 @@
/**
* InventoryComponent — tracks ammo and fuel consumption rates for a Unit.
*/
export default class InventoryComponent {
/**
* @param {import('../Unit').default} unit
* @param {Object} [config]
* @param {number} [config.ammo=100]
* @param {number} [config.maxAmmo=100]
* @param {number} [config.fuel=100]
* @param {number} [config.maxFuel=100]
* @param {number} [config.ammoConsumeRate=0] - ammo used per shot
* @param {number} [config.fuelConsumeRate=1] - fuel used per move action
*/
constructor(unit, config = {}) {
this.unit = unit;
/** @type {number} */
this._ammo = config.ammo ?? 100;
/** @type {number} */
this._maxAmmo = config.maxAmmo ?? 100;
/** @type {number} */
this._fuel = config.fuel ?? 100;
/** @type {number} */
this._maxFuel = config.maxFuel ?? 100;
/** @type {number} */
this._ammoConsumeRate = config.ammoConsumeRate ?? 0;
/** @type {number} */
this._fuelConsumeRate = config.fuelConsumeRate ?? 1;
}
// ── Accessors ─────────────────────────────────────────────────
/** @returns {number} */ get ammo() { return this._ammo; }
/** @returns {number} */ get maxAmmo() { return this._maxAmmo; }
/** @returns {number} */ get fuel() { return this._fuel; }
/** @returns {number} */ get maxFuel() { return this._maxFuel; }
/** @returns {number} */ get ammoConsumeRate() { return this._ammoConsumeRate; }
/** @returns {number} */ get fuelConsumeRate() { return this._fuelConsumeRate; }
// ── Public API ────────────────────────────────────────────────
/** @returns {boolean} */
get hasAmmo() { return this._ammo > 0; }
/** @returns {boolean} */
get hasFuel() { return this._fuel > 0; }
/**
* Consume ammo for a shot. Returns true if enough ammo was available.
* @param {number} [amount] - Override default rate
* @returns {boolean}
*/
consumeAmmo(amount) {
const cost = amount ?? this._ammoConsumeRate;
if (this._ammo < cost || cost <= 0) return false;
this._ammo -= cost;
return true;
}
/**
* Consume fuel for a move. Returns true if enough fuel was available.
* @param {number} [amount] - Override default rate
* @returns {boolean}
*/
consumeFuel(amount) {
const cost = amount ?? this._fuelConsumeRate;
if (this._fuel < cost || cost <= 0) return false;
this._fuel -= cost;
return true;
}
/**
* Resupply ammo and/or fuel.
* @param {{ ammo?: number, fuel?: number }} supplies
*/
resupply({ ammo, fuel } = {}) {
if (ammo != null) this._ammo = Math.min(this._maxAmmo, this._ammo + ammo);
if (fuel != null) this._fuel = Math.min(this._maxFuel, this._fuel + fuel);
}
/**
* Serialize for network sync.
*/
serialize() {
return {
ammo: this._ammo,
maxAmmo: this._maxAmmo,
fuel: this._fuel,
maxFuel: this._maxFuel,
ammoConsumeRate: this._ammoConsumeRate,
fuelConsumeRate: this._fuelConsumeRate,
};
}
}

View File

@@ -0,0 +1,92 @@
/**
* MovementComponent — speed, acceleration, rotation for a Unit.
*/
import CONSTANTS from 'PhaserClasses/CustomConstants';
export default class MovementComponent {
/**
* @param {import('../Unit').default} unit
* @param {Object} [config]
* @param {number} [config.speed=100]
* @param {number} [config.acceleration=200]
* @param {number} [config.rotationSpeed=Math.PI] - radians per second
* @param {number} [config.updateDelta=200] - ms between path steps
* @param {number} [config.movementMultiplier=1.5]
*/
constructor(unit, config = {}) {
this.unit = unit;
/** @type {number} */
this._speed = config.speed ?? CONSTANTS.PHYSICS.baseMovement;
/** @type {number} */
this._acceleration = config.acceleration ?? 200;
/** @type {number} */
this._rotationSpeed = config.rotationSpeed ?? Math.PI;
/** @type {number} */
this._updateDelta = config.updateDelta ?? CONSTANTS.HUMANOID.updateDelta;
/** @type {number} */
this._movementMultiplier = config.movementMultiplier ?? CONSTANTS.PHYSICS.movementMultiplier;
/** @type {number} */
this._frameTime = 0;
}
// ── Accessors ─────────────────────────────────────────────────
/** @returns {number} */ get speed() { return this._speed; }
/** @returns {number} */ get acceleration() { return this._acceleration; }
/** @returns {number} */ get rotationSpeed() { return this._rotationSpeed; }
/** @returns {number} */ get updateDelta() { return this._updateDelta; }
/** @returns {number} */ get movementMultiplier() { return this._movementMultiplier; }
/** @returns {number} */ get frameTime() { return this._frameTime; }
/** @param {number} val */
set speed(val) { this._speed = val; }
/** @param {number} val */
set updateDelta(val) { this._updateDelta = val; }
// ── Public API ────────────────────────────────────────────────
/**
* Rate limiter for update ticks. Returns true if enough time has passed.
* @param {number} delta - ms since last frame
* @returns {boolean}
*/
shouldUpdate(delta) {
this._frameTime += delta;
const threshold = this._updateDelta - Math.round(Math.random() * 20);
if (this._frameTime < threshold) {
return false;
}
this._frameTime = 0;
return true;
}
/**
* Get effective speed after applying movement multiplier.
* @returns {number}
*/
getEffectiveSpeed() {
return this._speed * this._movementMultiplier;
}
/**
* Serialize for network sync.
*/
serialize() {
return {
speed: this._speed,
acceleration: this._acceleration,
rotationSpeed: this._rotationSpeed,
updateDelta: this._updateDelta,
movementMultiplier: this._movementMultiplier,
};
}
}

View File

@@ -0,0 +1,78 @@
/**
* OwnerComponent — stores player ownership and team color for a Unit.
*/
export default class OwnerComponent {
/**
* @param {import('../Unit').default} unit
* @param {Object} [config]
* @param {string} [config.playerId='neutral']
* @param {string} [config.team='good'] - 'good' | 'enemy'
* @param {number} [config.color] - Tint color as 24-bit integer
*/
constructor(unit, config = {}) {
this.unit = unit;
/** @type {string} */
this._playerId = config.playerId || 'neutral';
/** @type {string} */
this._team = config.team || 'good';
/** @type {number|null} */
this._color = config.color ?? null;
}
// ── Accessors ─────────────────────────────────────────────────
/** @returns {string} */
get playerId() { return this._playerId; }
/** @returns {string} */
get team() { return this._team; }
/** @returns {number|null} */
get color() { return this._color; }
/** @param {string} id */
set playerId(id) { this._playerId = id; }
/** @param {string} team */
set team(team) { this._team = team; }
/** @param {number|null} color */
set color(color) { this._color = color; }
// ── Queries ───────────────────────────────────────────────────
/**
* Is this unit an enemy relative to another team?
* @param {string} [otherTeam]
* @returns {boolean}
*/
isEnemy(otherTeam) {
if (!otherTeam) return this._team !== 'good';
return this._team !== otherTeam;
}
/**
* Check if this unit belongs to the same team.
* @param {OwnerComponent} other
* @returns {boolean}
*/
isSameTeam(other) {
if (!other) return false;
return this._team === other.team;
}
/**
* Serialize for network sync.
* @returns {{ playerId: string, team: string, color: number|null }}
*/
serialize() {
return {
playerId: this._playerId,
team: this._team,
color: this._color,
};
}
}

View File

@@ -0,0 +1,5 @@
export { default as HealthComponent } from './HealthComponent.js';
export { default as OwnerComponent } from './OwnerComponent.js';
export { default as InventoryComponent } from './InventoryComponent.js';
export { default as MovementComponent } from './MovementComponent.js';
export { default as CombatComponent } from './CombatComponent.js';

View File

@@ -0,0 +1,106 @@
/**
* Entity state machine configuration for XState v4.
*
* States: IDLING → MOVING → ATTACKING → DYING → DESTROYED
*
* Actions (implemented by the EntityStateMachine wrapper):
* playIdleAnim, scanForEnemies, playMoveAnim, startPathfinding,
* playAttackAnim, orientToTarget, playDeathAnim, markDead,
* patrol, followPath, trackTarget, fireWeapon
*
* Events:
* MOVE, ATTACK, DIE, ARRIVED, ENEMY_SPOTTED, TARGET_LOST, OUT_OF_RANGE
*/
/**
* Raw XState machine configuration.
* Pass this to createMachine(config, options) along with action implementations.
*
* @type {import('xstate').MachineConfig}
*/
export const entityMachineConfig = {
id: 'entity',
initial: 'IDLING',
context: {},
states: {
IDLING: {
entry: ['playIdleAnim', 'scanForEnemies'],
activities: ['patrol'],
on: {
MOVE: 'MOVING',
ATTACK: 'ATTACKING',
DIE: 'DYING',
},
},
MOVING: {
entry: ['playMoveAnim', 'startPathfinding'],
activities: ['followPath'],
on: {
ARRIVED: 'IDLING',
ENEMY_SPOTTED: 'ATTACKING',
DIE: 'DYING',
},
},
ATTACKING: {
entry: ['playAttackAnim', 'orientToTarget'],
activities: ['trackTarget', 'fireWeapon'],
on: {
TARGET_LOST: 'IDLING',
OUT_OF_RANGE: 'MOVING',
DIE: 'DYING',
},
},
DYING: {
entry: ['playDeathAnim', 'markDead'],
after: {
5000: 'DESTROYED',
},
},
DESTROYED: {
type: 'final',
},
},
};
/**
* Valid event names accepted by this machine.
* @type {string[]}
*/
export const VALID_EVENTS = [
'MOVE',
'ATTACK',
'DIE',
'ARRIVED',
'ENEMY_SPOTTED',
'TARGET_LOST',
'OUT_OF_RANGE',
];
/**
* Valid state names.
* @type {string[]}
*/
export const VALID_STATES = [
'IDLING',
'MOVING',
'ATTACKING',
'DYING',
'DESTROYED',
];
/**
* Convenience: create a fully configured machine with action implementations.
*
* @param {Object} actions — map of action name → function(entity, context, event)
* @param {Object} [guards]
* @param {Object} [services]
* @returns {import('xstate').MachineConfig}
*/
export function buildMachineOptions(actions = {}, guards = {}, services = {}) {
return { actions, guards, services };
}

View File

@@ -0,0 +1,103 @@
/**
* BuildingStateMachine — per-building XState v4 state machine wrapper.
*
* States: CONSTRUCTING → ACTIVE ⇄ PRODUCING → DESTROYED → (terminal)
*
* Manages building lifecycle, production queue, and resource consumption.
* Production queue items are processed FIFO with configurable timings.
*/
export default class BuildingStateMachine {
/**
* @param {Object} building - The Phaser game object representing the building.
* @param {Object} config
* @param {string} config.type - 'barracks'|'vehicleDepot'|'logisticsCenter'|'ammunitionFactory'
* @param {number} [config.buildTime] - ms to construct (default 5000)
* @param {number} [config.productionTime] - ms per production item (default 10000)
*/
constructor(building, config = {}) {
this.building = building;
this.type = config.type || 'barracks';
this.buildTime = config.buildTime || 5000;
this.productionTime = config.productionTime || 10000;
/** @type {Object|null} XState service (deferred) */
this.service = null;
/** @type {string} Current state value */
this._currentState = 'CONSTRUCTING';
/** @type {Array<{unitType: string, startTime: number}>} */
this.productionQueue = [];
}
/**
* Add a unit type to the production queue.
* @param {string} unitType - e.g. 'infantry', 'tank'
* @param {number} [count=1]
*/
addToQueue(unitType, count = 1) {
for (let i = 0; i < count; i++) {
this.productionQueue.push({ unitType, startTime: 0 });
}
}
/**
* Clear the production queue.
*/
cancelQueue() {
this.productionQueue = [];
}
/**
* Send an event to the state machine.
* @param {string} event
* @param {Object} [context]
*/
send(event, context) {
if (this.service && this.service.send) {
this.service.send({ type: event, ...(context || {}) });
}
}
/**
* Get the current state string.
* @returns {string}
*/
getState() {
if (this.service && this.service.state) {
return this.service.state.value || this._currentState;
}
return this._currentState;
}
/**
* Per-frame tick — advance timers, process production queue.
* @param {number} time
* @param {number} delta
*/
tick(time, delta) {
// Advance production queue timers
if (this._currentState === 'PRODUCING' && this.productionQueue.length > 0) {
const item = this.productionQueue[0];
if (item.startTime === 0) {
item.startTime = time;
}
if (time - item.startTime >= this.productionTime) {
// Production complete — caller should listen for events
this.productionQueue.shift();
}
}
}
/**
* Cleanup the XState service.
*/
destroy() {
if (this.service && this.service.stop) {
this.service.stop();
}
this.service = null;
this.building = null;
this.productionQueue = [];
}
}

463
src/systems/CombatSystem.js Normal file
View File

@@ -0,0 +1,463 @@
import Phaser from 'phaser';
import CONSTANTS from 'PhaserClasses/CustomConstants';
/**
* CombatSystem — centralized combat service for target acquisition,
* line-of-sight checks, projectile management, and damage resolution.
*
* Pattern: Service class (no XState). Instantiated per scene.
* Projectiles live in a dedicated Arcade physics group.
* LoS uses Bresenham tile-walk against the rockLayer collidables.
*/
export default class CombatSystem {
/**
* @param {Phaser.Scene} scene — the owning scene (Map_Player)
*/
constructor(scene) {
this.scene = scene;
/** @type {Phaser.Physics.Arcade.Group} */
this.projectiles = scene.physics.add.group({
runChildUpdate: false,
});
/**
* Damage-modifier presets keyed by damage type.
* @type {Object<string, {armorPiercing: number, critChance: number, critMultiplier: number}>}
*/
this.damageModifiers = {
default: { armorPiercing: 0.0, critChance: 0.05, critMultiplier: 1.5 },
rifle: { armorPiercing: 0.1, critChance: 0.05, critMultiplier: 1.5 },
cannon: { armorPiercing: 0.5, critChance: 0.10, critMultiplier: 2.0 },
tank_cannon: { armorPiercing: 0.5, critChance: 0.10, critMultiplier: 2.0 },
};
/**
* References to the two faction containers so update() can
* manually check projectile-vs-unit overlaps.
* @type {Phaser.GameObjects.Container|null}
*/
this._goodGuys = null;
/** @type {Phaser.GameObjects.Container|null} */
this._enemies = null;
}
// ──────────────────────────────────────────────
// PUBLIC API
// ──────────────────────────────────────────────
/**
* Find the best target for an entity.
*
* @param {import('PhaserClasses/Custom_Entity').default} entity
* @param {Object} [options]
* @param {number} [options.maxRange] - max search radius in px (default: RIFLE.RANGE)
* @param {number} [options.fov] - field-of-view in degrees (360 = omnidirectional)
* @param {'closest'|'weakest'|'strongest'} [options.priority]
* @returns {import('PhaserClasses/Custom_Entity').default|null}
*/
acquireTarget(entity, options = {}) {
const {
maxRange = CONSTANTS.RIFLE.RANGE,
fov = 360,
priority = 'closest',
} = options;
const enemyContainer = entity.getEnemyContainer();
if (!enemyContainer || !enemyContainer.list) return null;
const alive = enemyContainer.getAll('dead', false);
if (!alive.length) return null;
const origin = new Phaser.Math.Vector2(entity.x, entity.y);
const candidates = [];
for (const enemy of alive) {
if (!enemy.body) continue;
const dist = Phaser.Math.Distance.Between(origin.x, origin.y, enemy.x, enemy.y);
if (dist > maxRange) continue;
// Cone check (only when fov < 360)
if (fov < 360) {
const toTarget = Phaser.Math.Angle.BetweenPoints(origin, enemy);
const facing = entity.rotation || 0;
const halfFov = Phaser.Math.DegToRad(fov / 2);
if (Math.abs(Phaser.Math.Angle.Wrap(toTarget - facing)) > halfFov) continue;
}
// LoS
if (!this.hasLineOfSight(origin, new Phaser.Math.Vector2(enemy.x, enemy.y))) continue;
candidates.push({
entity: enemy,
distance: dist,
health: enemy.getData('health') || 0,
});
}
if (!candidates.length) return null;
switch (priority) {
case 'weakest':
candidates.sort((a, b) => a.health - b.health);
break;
case 'strongest':
candidates.sort((a, b) => b.health - a.health);
break;
case 'closest':
default:
candidates.sort((a, b) => a.distance - b.distance);
break;
}
return candidates[0].entity;
}
/**
* Full validity check: range, friendly fire, LoS.
*
* @param {import('PhaserClasses/Custom_Entity').default} attacker
* @param {import('PhaserClasses/Custom_Entity').default} target
* @param {number} [weaponRange] — defaults to RIFLE.RANGE
* @returns {{ canHit: boolean, reason?: string }}
*/
canHit(attacker, target, weaponRange = null) {
if (!attacker || !target || !attacker.body || !target.body) {
return { canHit: false, reason: 'invalid_entities' };
}
if (attacker.parentContainer.name === target.parentContainer.name) {
return { canHit: false, reason: 'friendly_fire' };
}
if (target.dead || (target.isDead && target.isDead())) {
return { canHit: false, reason: 'target_dead' };
}
const range = weaponRange || CONSTANTS.RIFLE.RANGE;
const dist = Phaser.Math.Distance.Between(attacker.x, attacker.y, target.x, target.y);
if (dist > range) {
return { canHit: false, reason: 'out_of_range' };
}
if (
!this.hasLineOfSight(
new Phaser.Math.Vector2(attacker.x, attacker.y),
new Phaser.Math.Vector2(target.x, target.y),
)
) {
return { canHit: false, reason: 'no_los' };
}
return { canHit: true };
}
/**
* Spawn a projectile travelling from attacker toward target.
*
* @param {import('PhaserClasses/Custom_Entity').default} attacker
* @param {import('PhaserClasses/Custom_Entity').default} target
* @param {Object} [config]
* @param {number} [config.damage] - base damage (default RIFLE.DAMAGE)
* @param {number} [config.speed] - px/s (default 300)
* @param {boolean} [config.homing] - track the target mid-flight
* @param {string} [config.damageType] - key into this.damageModifiers
* @param {string} [config.sprite] - texture key (falls back to a yellow rect)
* @returns {Phaser.GameObjects.Sprite|Phaser.GameObjects.Rectangle|null}
*/
fireProjectile(attacker, target, config = {}) {
const {
damage = CONSTANTS.RIFLE.DAMAGE,
speed = 300,
homing = false,
damageType = 'rifle',
sprite = null,
} = config;
if (!attacker || !target || !attacker.body || !target.body) return null;
const startX = attacker.x;
const startY = attacker.y;
let projectile;
if (sprite && this.scene.textures.exists(sprite)) {
projectile = this.projectiles.create(startX, startY, sprite);
} else {
// Fallback rectangle so the system works without an asset
projectile = this.scene.add.rectangle(startX, startY, 8, 3, 0xffff00);
projectile.setDepth(20);
this.scene.physics.world.enableBody(projectile, Phaser.Physics.Arcade.DYNAMIC_BODY);
this.projectiles.add(projectile);
}
// Stash flight data
projectile.setData('attacker', attacker);
projectile.setData('damage', damage);
projectile.setData('damageType', damageType);
projectile.setData('target', target);
projectile.setData('homing', homing);
projectile.setData('speed', speed);
const elapsed = projectile.getData('elapsed') || 0;
projectile.setData('elapsed', elapsed);
projectile.setData('lifespan', 4000); // ms before auto-removal
// Aim
const angle = Phaser.Math.Angle.Between(startX, startY, target.x, target.y);
projectile.setRotation(angle);
this.scene.physics.velocityFromAngle(
Phaser.Math.RadToDeg(angle),
speed,
projectile.body.velocity,
);
projectile.body.allowGravity = false;
return projectile;
}
/**
* Tilemap-based line-of-sight test using Bresenham's line algorithm.
* Checks the rockLayer (and groundLayer if it has collision properties)
* for blocking tiles between the two world points.
*
* @param {Phaser.Math.Vector2} pointA — world position
* @param {Phaser.Math.Vector2} pointB — world position
* @returns {boolean} true if there are no blocking tiles on the line
*/
hasLineOfSight(pointA, pointB) {
const rockLayer = this.scene.rockLayer;
if (!rockLayer) return true;
const tileA = rockLayer.worldToTileXY(pointA.x, pointA.y);
const tileB = rockLayer.worldToTileXY(pointB.x, pointB.y);
if (!tileA || !tileB) return true;
let x0 = tileA.x;
let y0 = tileA.y;
const x1 = tileB.x;
const y1 = tileB.y;
const dx = Math.abs(x1 - x0);
const dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
let err = dx - dy;
// eslint-disable-next-line no-constant-condition
for (let steps = 0; steps < 200; steps++) {
// Skip the origin tile (the attacker stands there)
if (x0 === tileA.x && y0 === tileA.y) {
if (x0 === x1 && y0 === y1) break;
const e2 = 2 * err;
if (e2 > -dy) { err -= dy; x0 += sx; }
if (e2 < dx) { err += dx; y0 += sy; }
continue;
}
// Check rock layer collisions
const rockTile = rockLayer.getTileAt(x0, y0);
if (rockTile && rockTile.properties && rockTile.properties.collides) {
return false;
}
// Optionally check ground-layer collisions
const ground = this.scene.groundLayer;
if (ground) {
const gTile = ground.getTileAt(x0, y0);
if (gTile && gTile.properties && gTile.properties.collides) {
return false;
}
}
if (x0 === x1 && y0 === y1) break;
const e2 = 2 * err;
if (e2 > -dy) { err -= dy; x0 += sx; }
if (e2 < dx) { err += dx; y0 += sy; }
}
return true;
}
/**
* Apply damage using the formula: finalDamage = max(1, (baseDamage effectiveArmor) × crit).
*
* @param {import('PhaserClasses/Custom_Entity').default} entity
* @param {number} amount — raw incoming damage
* @param {string} [damageType] — key into this.damageModifiers (default 'default')
* @returns {number} actual damage dealt
*/
applyDamage(entity, amount, damageType = 'default') {
if (!entity || entity.dead || (entity.isDead && entity.isDead())) return 0;
const rawArmor = entity.getData('armor') || 0;
const mods = this.damageModifiers[damageType] || this.damageModifiers.default;
// Armor-piercing reduces effective armor
const effectiveArmor = rawArmor * (1 - (mods.armorPiercing || 0));
let finalDamage = Math.max(0, amount - effectiveArmor);
// Crit
if (Math.random() < (mods.critChance || 0)) {
finalDamage *= mods.critMultiplier || 1.5;
}
finalDamage = Math.max(1, Math.round(finalDamage));
const currentHealth = entity.getData('health');
const newHealth = currentHealth - finalDamage;
entity.setData('health', newHealth);
// Emit for UI / network listeners
entity.emit('combat:damaged', {
amount: finalDamage,
damageType,
newHealth,
});
this.scene.events.emit('combat:unitDamaged', {
target: entity,
damage: finalDamage,
damageType,
newHealth,
});
// Death trigger
if (newHealth <= 0 && entity.handleDeath) {
entity.handleDeath();
}
return finalDamage;
}
/**
* Tell the system which container holds friendly / enemy units.
* Called by the scene after spawning is complete.
*
* @param {Phaser.GameObjects.Container} goodGuys
* @param {Phaser.GameObjects.Container} enemies
*/
registerUnitContainers(goodGuys, enemies) {
this._goodGuys = goodGuys || null;
this._enemies = enemies || null;
}
// ──────────────────────────────────────────────
// UPDATE
// ──────────────────────────────────────────────
/**
* Per-frame update. Drives projectile movement, homing, lifespan
* culling, and manual projectile↔unit overlap detection.
*
* @param {number} time
* @param {number} delta
*/
update(time, delta) {
const toRemove = [];
const children = this.projectiles.getChildren();
for (let i = children.length - 1; i >= 0; i--) {
const p = children[i];
if (!p.active) {
toRemove.push(p);
continue;
}
// Lifespan
const elapsed = (p.getData('elapsed') || 0) + delta;
p.setData('elapsed', elapsed);
const lifespan = p.getData('lifespan') || 4000;
if (elapsed > lifespan) {
toRemove.push(p);
continue;
}
// Off-screen cull
const cam = this.scene.cameras && this.scene.cameras.main;
if (cam) {
const b = cam.worldView;
if (p.x < b.x - 64 || p.x > b.x + b.width + 64 ||
p.y < b.y - 64 || p.y > b.y + b.height + 64) {
toRemove.push(p);
continue;
}
}
// Homing
if (p.getData('homing')) {
const tgt = p.getData('target');
if (tgt && tgt.active && !tgt.dead) {
const a = Phaser.Math.Angle.Between(p.x, p.y, tgt.x, tgt.y);
p.setRotation(a);
this.scene.physics.velocityFromAngle(
Phaser.Math.RadToDeg(a),
p.getData('speed') || 300,
p.body.velocity,
);
}
}
// Manual overlap vs unit containers
this._checkOverlap(p);
}
for (const p of toRemove) p.destroy();
}
// ──────────────────────────────────────────────
// INTERNALS
// ──────────────────────────────────────────────
/**
* Check one projectile against both unit containers.
* @param {Phaser.GameObjects.GameObject} projectile
*/
_checkOverlap(projectile) {
if (!projectile.body) return;
const attacker = projectile.getData('attacker');
const check = (container) => {
if (!container) return;
const units = container.getAll('dead', false);
for (let i = 0; i < units.length; i++) {
const unit = units[i];
if (!unit.body || unit === attacker) continue;
if (this.scene.physics.overlap(projectile, unit)) {
this._onHit(projectile, unit);
return; // one hit per frame
}
}
};
check(this._goodGuys);
if (projectile.active) check(this._enemies);
}
/**
* Resolve a projectile hitting a unit.
*/
_onHit(projectile, unit) {
const attacker = projectile.getData('attacker');
const damage = projectile.getData('damage');
const damageType = projectile.getData('damageType');
if (unit.dead || (unit.isDead && unit.isDead())) return;
const dealt = this.applyDamage(unit, damage, damageType);
this.scene.events.emit('combat:projectileHit', {
attacker,
target: unit,
damage: dealt,
damageType,
});
projectile.destroy();
}
}

View File

@@ -0,0 +1,426 @@
import Phaser from 'phaser';
// ─────────────────────────────────────────────────────────
// CONSTANTS
// ─────────────────────────────────────────────────────────
const DEFAULT_CAPTURE_TIME = 60000; // 60 seconds in ms
const DEFAULT_RADIUS = 5; // tiles (converted to px below)
const DEFAULT_TILE_SIZE = 32; // px per tile
/** @readonly */
export const ControlPointState = {
NEUTRAL: 'NEUTRAL',
CONTESTED: 'CONTESTED',
CAPTURED: 'CAPTURED',
};
// ─────────────────────────────────────────────────────────
// XSTATE MACHINE CONFIG
// ─────────────────────────────────────────────────────────
/**
* Raw XState v4 machine configuration for a single control point.
*
* States: NEUTRAL → CONTESTED → CAPTURED
*
* Actions are implemented by the ControlPointStateMachine wrapper class.
*
* @type {import('xstate').MachineConfig}
*/
export const controlPointMachineConfig = {
id: 'controlPoint',
initial: ControlPointState.NEUTRAL,
predictableActionArguments: true,
context: {
owner: null, // playerId or null
captureProgress: 0, // 0100 percentage
captureTime: DEFAULT_CAPTURE_TIME,
unitsInRadius: {}, // { playerId: count, ... }
},
states: {
[ControlPointState.NEUTRAL]: {
on: {
UNITS_ENTERED: {
target: ControlPointState.CONTESTED,
},
CLAIM: {
target: ControlPointState.CAPTURED,
},
},
entry: ['clearOwner', 'resetProgress'],
},
[ControlPointState.CONTESTED]: {
on: {
UNITS_LEFT: {
target: ControlPointState.NEUTRAL,
},
PROGRESS_COMPLETE: {
target: ControlPointState.CAPTURED,
},
},
activities: ['trackUnits', 'incrementProgress'],
exit: ['onLeaveContested'],
},
[ControlPointState.CAPTURED]: {
on: {
ENEMY_UNITS_ENTERED: {
target: ControlPointState.CONTESTED,
cond: 'hasEnemyUnits', // guard: only transition if enemy units present
},
},
entry: ['setOwner', 'startCPGeneration'],
exit: ['stopCPGeneration'],
},
},
};
// ─────────────────────────────────────────────────────────
// WRAPPER CLASS
// ─────────────────────────────────────────────────────────
/**
* ControlPointStateMachine — wraps an XState interpreter for a single
* control point zone on the map.
*
* Responsibilities:
* - Track units inside the capture radius per player
* - Advance capture progress (0100%) over CONTESTED state
* - Generate capture-point income for the owning player
* - Expose a Phaser Zone with a circular hit area for physics overlap
*
* Usage:
* const cp = new ControlPointStateMachine(scene, zoneConfig);
* cp.tick(time, delta); // call every frame
* const owner = cp.getOwner(); // query
* cp.destroy(); // cleanup
*/
export default class ControlPointStateMachine {
/**
* @param {Phaser.Scene} scene
* @param {Object} config
* @param {number} config.x - world x of the zone centre
* @param {number} config.y - world y of the zone centre
* @param {number} [config.radius] - capture radius in tiles (default 5)
* @param {number} [config.tileSize] - px per tile (default 32)
* @param {number} [config.captureTime] - ms to complete capture (default 60k)
* @param {string} [config.id] - unique identifier for this point
* @param {Object} [config.economySystem] - EconomySystem instance for CP income
*/
constructor(scene, config = {}) {
this.scene = scene;
this.id = config.id || `cp_${Phaser.Math.RND.uuid().slice(0, 8)}`;
this.radiusTiles = config.radius ?? DEFAULT_RADIUS;
this.tileSize = config.tileSize ?? DEFAULT_TILE_SIZE;
this.radiusPx = this.radiusTiles * this.tileSize;
/** @type {EconomySystem|null} */
this.economySystem = config.economySystem || null;
// ── XState machine ──────────────────────────────
const { createMachine, interpret, assign } = require('xstate');
this.machine = createMachine(controlPointMachineConfig, {
guards: {
hasEnemyUnits: (ctx, _event) => {
if (!ctx.owner) return true; // no owner → any unit is enemy
const entries = Object.entries(ctx.unitsInRadius);
return entries.some(
([playerId, count]) => playerId !== ctx.owner && count > 0,
);
},
},
actions: {
clearOwner: (ctx) => { ctx.owner = null; },
resetProgress: (ctx) => { ctx.captureProgress = 0; },
setOwner: (ctx, event) => {
ctx.owner = event.owner || event.playerId || null;
},
onLeaveContested: (_ctx) => {
// reset progress if leaving CONTESTED without completing capture
if (this.service && this.service.state) {
const currentState = this.service.state.value;
if (currentState === ControlPointState.NEUTRAL) {
const ctx = this.service.state.context;
ctx.captureProgress = 0;
}
}
},
startCPGeneration: assign({ _tickAccumulator: 0 }),
stopCPGeneration: assign({ _tickAccumulator: 0 }),
},
activities: {
trackUnits: () => {
// activity lifecycle handled by tick()
return () => { /* no-op on stop */ };
},
incrementProgress: () => {
// activity lifecycle handled by tick()
return () => { /* no-op on stop */ };
},
},
});
this.service = interpret(this.machine).start();
// ── Phaser Zone (physics hit area) ──────────────
this.zone = scene.add.zone(config.x, config.y, this.radiusPx * 2, this.radiusPx * 2);
this.zone.setName(this.id);
scene.physics.world.enableBody(this.zone, Phaser.Physics.Arcade.STATIC_BODY);
if (this.zone.body) {
// Circular hit area: a circle inscribed in the rectangular zone body
this.zone.body.setCircle(this.radiusPx);
this.zone.body.setOffset(0, 0);
}
// ── Unit containers (set after construction) ────
/** @type {Phaser.GameObjects.Container|null} */
this._goodGuys = null;
/** @type {Phaser.GameObjects.Container|null} */
this._enemies = null;
// ── CP generation tick (1 CP per second) ────────
this._cpAccumulator = 0;
this._cpInterval = 1000; // 1 CP per second
}
// ───────────────────────────────────────────────────
// PUBLIC API
// ───────────────────────────────────────────────────
/**
* Register the two faction containers so tick() can run physics
* overlaps between the zone and all units.
*
* @param {Phaser.GameObjects.Container} goodGuys
* @param {Phaser.GameObjects.Container} enemies
*/
registerUnitContainers(goodGuys, enemies) {
this._goodGuys = goodGuys || null;
this._enemies = enemies || null;
}
/**
* Count units per player currently inside the capture radius.
*
* Uses Phaser Arcade physics overlap between the zone's circular
* body and every unit in both faction containers.
*
* @returns {Object<string, number>} { playerId: count, ... }
*/
getUnitsInRadius() {
const counts = {};
const countInContainer = (container) => {
if (!container || !this.zone.body) return;
const units = container.getAll ? container.getAll('dead', false) : [];
for (let i = 0; i < units.length; i++) {
const unit = units[i];
if (!unit.body) continue;
// Phaser Arcade overlap: distance-based circle-vs-rect
const dx = unit.x - this.zone.x;
const dy = unit.y - this.zone.y;
const distSq = dx * dx + dy * dy;
if (distSq <= this.radiusPx * this.radiusPx) {
const playerId = unit.getData
? (unit.getData('owner') || unit.getData('playerId') || 'unknown')
: 'unknown';
counts[playerId] = (counts[playerId] || 0) + 1;
}
}
};
countInContainer(this._goodGuys);
countInContainer(this._enemies);
return counts;
}
/**
* Get the capture progress as a percentage (0100).
* @returns {number}
*/
getCaptureProgress() {
if (!this.service || !this.service.state) return 0;
const ctx = this.service.state.context;
return ctx.captureProgress ?? 0;
}
/**
* Get the current owning player ID (or null if unowned).
* @returns {string|null}
*/
getOwner() {
if (!this.service || !this.service.state) return null;
const ctx = this.service.state.context;
return ctx.owner || null;
}
/**
* Get the current XState node value.
* @returns {string}
*/
getState() {
if (!this.service || !this.service.state) return ControlPointState.NEUTRAL;
return this.service.state.value;
}
/**
* Send an event to the XState machine.
*
* @param {string} event - event name (e.g. 'UNITS_ENTERED')
* @param {Object} [payload] - extra data merged into the event object
*/
send(event, payload = {}) {
if (this.service && this.service.status !== 3 /* stopped */) {
this.service.send({ type: event, ...payload });
}
}
// ───────────────────────────────────────────────────
// TICK — per-frame update
// ───────────────────────────────────────────────────
/**
* Drive the state machine each frame: recalculate units-in-radius,
* resolve state transitions, advance capture progress, and generate
* CP income for the owner.
*
* Called from the scene update loop.
*
* @param {number} time - elapsed scene time (ms)
* @param {number} delta - frame delta (ms)
*/
tick(time, delta) {
if (!this.service || this.service.status === 3) return;
const currentState = this.service.state.value;
const ctx = this.service.state.context;
// 1. Recalculate unit counts
const unitsInRadius = this.getUnitsInRadius();
ctx.unitsInRadius = unitsInRadius;
// 2. Determine the dominant player (most units in radius)
const dominantPlayer = this._getDominantPlayer(unitsInRadius);
// 3. State-specific logic
switch (currentState) {
case ControlPointState.NEUTRAL: {
if (dominantPlayer) {
this.send('UNITS_ENTERED');
}
break;
}
case ControlPointState.CONTESTED: {
// If no units remain, transition back to NEUTRAL
if (!dominantPlayer) {
this.send('UNITS_LEFT');
break;
}
// If current progress is tied to the previous contender but a
// different player is now dominant, reset progress
if (ctx._contender && ctx._contender !== dominantPlayer) {
ctx.captureProgress = 0;
}
ctx._contender = dominantPlayer;
// Advance capture progress
const captureTime = ctx.captureTime || DEFAULT_CAPTURE_TIME;
const progressDelta = (delta / captureTime) * 100;
ctx.captureProgress = Math.min(100, (ctx.captureProgress || 0) + progressDelta);
if (ctx.captureProgress >= 100) {
this.send('PROGRESS_COMPLETE', { owner: dominantPlayer });
}
break;
}
case ControlPointState.CAPTURED: {
const owner = ctx.owner;
// Check for enemies in radius
const enemyPresent = Object.entries(unitsInRadius).some(
([playerId, count]) => playerId !== owner && count > 0,
);
if (enemyPresent) {
this.send('ENEMY_UNITS_ENTERED');
break;
}
// CP income generation (1 CP per second)
if (owner && this.economySystem) {
this._cpAccumulator += delta;
while (this._cpAccumulator >= this._cpInterval) {
this._cpAccumulator -= this._cpInterval;
this.economySystem.addIncome(owner, { capturePoints: 1 });
}
}
break;
}
default:
break;
}
}
// ───────────────────────────────────────────────────
// DESTROY
// ───────────────────────────────────────────────────
/**
* Tear down the XState service and the Phaser zone.
*/
destroy() {
if (this.service) {
this.service.stop();
this.service = null;
}
if (this.zone) {
this.zone.destroy();
this.zone = null;
}
this._goodGuys = null;
this._enemies = null;
this.economySystem = null;
this.scene = null;
}
// ───────────────────────────────────────────────────
// PRIVATE HELPERS
// ───────────────────────────────────────────────────
/**
* Return the playerId with the most units in radius, or null.
*
* @param {Object<string, number>} counts
* @returns {string|null}
*/
_getDominantPlayer(counts) {
let best = null;
let max = 0;
for (const [playerId, count] of Object.entries(counts)) {
if (count > max) {
max = count;
best = playerId;
}
}
return best;
}
}

View File

@@ -0,0 +1,191 @@
import Phaser from "phaser";
const DEFAULT_STARTING_RESOURCES = {
fuel: 100,
ammo: 100,
capturePoints: 0,
};
/**
* EconomySystem — authoritative resource tracker for all players.
*
* Responsibilities:
* - Track fuel, ammo, and capturePoints per player
* - Validate purchases via canAfford / deduct
* - Emit a periodic income tick every 1000ms
*
* This is a pure service class (no XState). It uses a Phaser
* EventEmitter so other systems and UI can subscribe to events.
*
* Events emitted:
* - economy:updated Fired after any resource mutation
* - economy:purchaseFailed Fired when canAfford() returns false
* - economy:incomeReceived Fired each tick with per-player deltas
*/
export default class EconomySystem {
/**
* @param {Phaser.Scene} scene The owning Phaser scene
*/
constructor(scene) {
/** @type {Phaser.Scene} */
this.scene = scene;
/** @type {Map<string, {fuel: number, ammo: number, capturePoints: number}>} */
this.players = new Map();
/** @type {Phaser.Events.EventEmitter} */
this.events = new Phaser.Events.EventEmitter();
/** @private Track elapsed ms for the 1000ms income tick */
this._lastTick = 0;
/** @private Interval between income ticks in ms */
this._tickInterval = 1000;
}
// ──────────────────────────────────────────────
// Public API
// ──────────────────────────────────────────────
/**
* Register a player with starting resources.
*
* @param {string} playerId
* @param {{fuel?: number, ammo?: number, capturePoints?: number}} [starting]
*/
initPlayer(playerId, starting = {}) {
const defaults = { ...DEFAULT_STARTING_RESOURCES };
this.players.set(playerId, {
fuel: starting.fuel ?? defaults.fuel,
ammo: starting.ammo ?? defaults.ammo,
capturePoints: starting.capturePoints ?? defaults.capturePoints,
});
this.events.emit("economy:updated", {
playerId,
resources: this.players.get(playerId),
});
}
/**
* Return a snapshot of the player's current resources.
*
* @param {string} playerId
* @returns {{fuel: number, ammo: number, capturePoints: number} | undefined}
*/
getResources(playerId) {
return this.players.get(playerId);
}
/**
* Check whether a player can afford a cost.
*
* @param {string} playerId
* @param {{fuel?: number, ammo?: number}} cost
* @returns {boolean}
*/
canAfford(playerId, cost) {
const res = this.players.get(playerId);
if (!res) {
this.events.emit("economy:purchaseFailed", {
playerId,
reason: "player_not_found",
cost,
});
return false;
}
const affordable =
(cost.fuel == null || res.fuel >= cost.fuel) &&
(cost.ammo == null || res.ammo >= cost.ammo);
if (!affordable) {
this.events.emit("economy:purchaseFailed", {
playerId,
reason: "insufficient_resources",
cost,
current: { fuel: res.fuel, ammo: res.ammo },
});
}
return affordable;
}
/**
* Deduct resources. Returns true on success, false if the player
* cannot afford the cost (resources unchanged in that case).
*
* @param {string} playerId
* @param {{fuel?: number, ammo?: number}} cost
* @returns {boolean}
*/
deduct(playerId, cost) {
if (!this.canAfford(playerId, cost)) {
return false;
}
const res = this.players.get(playerId);
if (cost.fuel != null) res.fuel -= cost.fuel;
if (cost.ammo != null) res.ammo -= cost.ammo;
this.events.emit("economy:updated", {
playerId,
resources: { ...res },
});
return true;
}
/**
* Add income directly (called by external systems per tick).
*
* @param {string} playerId
* @param {{fuel?: number, ammo?: number, capturePoints?: number}} income
*/
addIncome(playerId, income) {
let res = this.players.get(playerId);
if (!res) {
// Auto-initialise if called before initPlayer
res = { ...DEFAULT_STARTING_RESOURCES };
this.players.set(playerId, res);
}
if (income.fuel != null) res.fuel += income.fuel;
if (income.ammo != null) res.ammo += income.ammo;
if (income.capturePoints != null) res.capturePoints += income.capturePoints;
this.events.emit("economy:incomeReceived", {
playerId,
income,
resources: { ...res },
});
this.events.emit("economy:updated", {
playerId,
resources: { ...res },
});
}
/**
* Per-frame update. The income tick fires every 1000ms.
*
* @param {number} time Current scene time in ms
*/
update(time) {
if (time - this._lastTick >= this._tickInterval) {
this._lastTick = time;
// The actual income values are supplied by external systems
// (BuildingSystem, ControlPointSystem) calling addIncome().
// This tick guard prevents addIncome from being called more
// frequently than once per second when the caller uses update().
}
}
/**
* Destroy the event emitter — call when the scene shuts down.
*/
destroy() {
this.events.destroy();
this.players.clear();
}
}

View File

@@ -0,0 +1,72 @@
/**
* EntityStateMachine — per-unit XState v4 state machine wrapper.
*
* States: IDLING → MOVING → ATTACKING → DYING → (terminal)
*
* Pattern: Each entity gets its own interpret()-ed service.
* The scene/system orchestrator calls tick() on active entities.
*
* XState integration is deferred: machineConfig is stored; the service
* is created lazily when a scene-level XState factory is available.
*/
export default class EntityStateMachine {
/**
* @param {Object} entity - The Phaser game object (sprite / container).
* @param {Object} machineConfig - XState machine configuration object.
*/
constructor(entity, machineConfig) {
this.entity = entity;
/** @type {Object} Raw XState machine config */
this.machineConfig = machineConfig;
/** @type {Object|null} Instantiated XState service */
this.service = null;
/** @type {string} Current state value */
this._currentState = 'IDLING';
}
/**
* Send an event to the state machine.
* @param {string} event
* @param {Object} [context]
*/
send(event, context) {
if (this.service && this.service.send) {
this.service.send({ type: event, ...(context || {}) });
}
}
/**
* Get the current state string.
* @returns {string}
*/
getState() {
if (this.service && this.service.state) {
return this.service.state.value || this._currentState;
}
return this._currentState;
}
/**
* Per-frame tick — called from entity's preUpdate or orchestrator.
* @param {number} _time
* @param {number} _delta
*/
tick(_time, _delta) {
// State-machine-driven logic goes here.
// Extend with state-specific behaviour (e.g. followPath, scanForEnemies).
}
/**
* Cleanup the XState service.
*/
destroy() {
if (this.service && this.service.stop) {
this.service.stop();
}
this.service = null;
this.entity = null;
}
}

163
src/systems/MapSystem.js Normal file
View File

@@ -0,0 +1,163 @@
import Phaser from 'phaser';
/**
* MapSystem — tilemap loading, collision layers, spawn points, control zones.
*
* Service class (no XState). Owns the Phaser tilemap and exposes utility
* methods for tile↔world conversion, walkability queries, and zone management.
*/
export default class MapSystem {
/**
* @param {Phaser.Scene} scene
*/
constructor(scene) {
this.scene = scene;
/** @type {Phaser.Tilemaps.Tilemap|null} */
this.tilemap = null;
/** @type {Object<string, Phaser.Tilemaps.TilemapLayer>} */
this.layers = {};
/** @type {Array<{x: number, y: number, owner?: string, buildingType?: string}>} */
this.spawnPoints = [];
/** @type {Array<{x: number, y: number, radius: number, owner: string|null, captureProgress: number}>} */
this.controlZones = [];
}
// ---------------------------------------------------------------------------
// Map lifecycle
// ---------------------------------------------------------------------------
/**
* Load a tilemap from the scene's cache (must be preloaded).
* @param {string} mapKey - e.g. 'test1'
* @param {string} tilesetKey - e.g. 'floorsPrimary'
* @param {string} tilesetName - e.g. 'floorsPrimary'
* @returns {this}
*/
loadMap(mapKey, tilesetKey, tilesetName) {
this.tilemap = this.scene.make.tilemap({ key: mapKey });
const tileset = this.tilemap.addTilesetImage(tilesetName, tilesetKey, 32, 32);
this.layers.ground = this.tilemap.createLayer('Floor', tileset, 0, 0);
this.layers.decor = this.tilemap.createLayer('Decorations', tileset, 0, 0);
this.layers.collision = this.tilemap
.createLayer('Rocks', tileset, 0, 0)
.setCollisionByProperty({ collides: true })
.setDepth(10);
return this;
}
// ---------------------------------------------------------------------------
// Tile utilities
// ---------------------------------------------------------------------------
/**
* Get the tile at a world position.
* @param {number} x
* @param {number} y
* @returns {{x: number, y: number, layer: string}|null}
*/
getTileAtWorld(x, y) {
if (!this.tilemap) return null;
const tile = this.tilemap.getTileAtWorldXY(x, y);
if (!tile) return null;
return { x: tile.x, y: tile.y, layer: tile.layer ? tile.layer.name : 'unknown' };
}
/**
* Get world pixel position from tile coordinates.
* @param {number} tileX
* @param {number} tileY
* @returns {{x: number, y: number}}
*/
getWorldPosition(tileX, tileY) {
if (!this.tilemap) return { x: tileX * 32, y: tileY * 32 };
return this.tilemap.tileToWorldXY(tileX, tileY);
}
/**
* Check whether a tile is walkable (no collision).
* @param {number} tileX
* @param {number} tileY
* @returns {boolean}
*/
isWalkable(tileX, tileY) {
if (!this.layers.collision) return true;
const tile = this.layers.collision.getTileAt(tileX, tileY);
return !tile || !tile.properties || !tile.properties.collides;
}
// ---------------------------------------------------------------------------
// Spawn points
// ---------------------------------------------------------------------------
/**
* Get a spawn point for a player.
* @param {string} playerId
* @returns {{x: number, y: number}|null}
*/
getSpawnPoint(playerId) {
const owned = this.spawnPoints.filter(sp => sp.owner === playerId);
if (owned.length > 0) return owned[0];
return this.spawnPoints[0] || null;
}
// ---------------------------------------------------------------------------
// Control zones
// ---------------------------------------------------------------------------
/**
* Get the control zone at a world position.
* @param {number} x
* @param {number} y
* @returns {Object|null}
*/
getControlZoneAt(x, y) {
for (const zone of this.controlZones) {
const dx = x - zone.x;
const dy = y - zone.y;
if (Math.sqrt(dx * dx + dy * dy) <= zone.radius) {
return zone;
}
}
return null;
}
/**
* Get all control zones.
* @returns {Array<Object>}
*/
getControlZones() {
return this.controlZones;
}
// ---------------------------------------------------------------------------
// Update
// ---------------------------------------------------------------------------
/**
* Per-frame update.
* @param {number} _time
* @param {number} _delta
*/
update(_time, _delta) {
// Zone ownership changes are driven by ControlPointStateMachine.
// No-op here — extend if needed.
}
// ---------------------------------------------------------------------------
// Cleanup
// ---------------------------------------------------------------------------
destroy() {
this.tilemap = null;
this.layers = {};
this.spawnPoints = [];
this.controlZones = [];
}
}

View File

@@ -0,0 +1,487 @@
import { io as ioClient } from "socket.io-client";
// =============================================================================
// Constants
// =============================================================================
const SNAPSHOT_RATE = 20; // Hz (50ms interval)
const SNAPSHOT_INTERVAL = 1000 / SNAPSHOT_RATE; // 50ms
const INTERP_DELAY = 100; // ms — render behind server by this much
const MAX_PENDING_INPUTS = 256;
// =============================================================================
// Helpers
// =============================================================================
/**
* Linear interpolation between two values.
*/
function lerp(a, b, t) {
return a + (b - a) * t;
}
/**
* Lerp an entity state object (position, rotation, health) field by field.
*/
function lerpEntity(a, b, t) {
if (!a || !b) return a || b;
const result = {};
for (const key of Object.keys(b)) {
const va = a[key];
const vb = b[key];
if (typeof vb === "number" && typeof va === "number") {
result[key] = lerp(va, vb, t);
} else {
result[key] = vb;
}
}
return result;
}
/**
* Deep-clone a plain snapshot so we don't mutate server references.
*/
function cloneState(state) {
return JSON.parse(JSON.stringify(state));
}
// =============================================================================
// NetworkSystemClient
// =============================================================================
class NetworkSystemClient {
/**
* @param {object} scene — a Phaser.Scene (or any object w/ an entity registry).
* @param {string} serverUrl — Socket.IO server URL, e.g. "http://localhost:8081"
*/
constructor(scene, serverUrl = "http://localhost:8081") {
/** @type {import("socket.io-client").Socket} */
this.socket = ioClient(serverUrl);
/** @type {object} Reference to the Phaser scene so we can access entities. */
this.scene = scene;
// --- Input pipeline ---
/** @type {number} Monotonically increasing sequence number. */
this.inputSequence = 0;
/** @type {Array<{seq: number, input: object}>} Pending inputs not yet ack'd. */
this.pendingInputs = [];
// --- Snapshot pipeline ---
/** @type {Array<{state: object, timestamp: number}>} Last two server snapshots for interpolation. */
this.snapshotBuffer = [];
/** @type {number|null} Server clock offset (server time local time). */
this.clockOffset = null;
/** @type {number} Most recent client-side predicted state. */
this.predictedState = null;
/** @type {number} Last known server-authoritative state. */
this.serverState = null;
// --- Bind Socket.IO handlers ---
this.socket.on("snapshot", (data) => this.onSnapshot(data));
this.socket.on("inputAck", (data) => this._handleInputAck(data));
this.socket.on("connect", () => {
console.log("[NetworkSystemClient] connected:", this.socket.id);
});
this.socket.on("disconnect", (reason) => {
console.warn("[NetworkSystemClient] disconnected:", reason);
this.pendingInputs = [];
});
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Send a player input to the server.
*
* @param {object} input
* @param {string} input.type — 'SELECT' | 'COMMAND' | 'MOVE' | 'ATTACK'
* @param {string} [input.entityId]
* @param {string} [input.commandType]
* @param {object} [input.target] — e.g. { x, y }
* @param {number} [input.timestamp] — client-relative timestamp
*/
sendInput(input) {
const seq = ++this.inputSequence;
const payload = {
seq,
clientTime: Date.now(),
...input,
};
this.pendingInputs.push({ seq, input: payload });
if (this.pendingInputs.length > MAX_PENDING_INPUTS) {
this.pendingInputs.shift();
}
this.socket.emit("input", payload);
// Apply prediction immediately on the client side
this._predict(input);
}
/**
* Receive a server-authoritative snapshot.
* Called automatically by the Socket.IO "snapshot" event.
*
* @param {object} snapshot
* @param {Array} snapshot.entities
* @param {Array} snapshot.buildings
* @param {object} snapshot.economy
* @param {number} snapshot.serverTime
* @param {number} snapshot.lastProcessedSeq
*/
onSnapshot(snapshot) {
// Estimate clock offset (first snapshot only)
if (this.clockOffset === null) {
this.clockOffset = snapshot.serverTime - Date.now();
}
const state = cloneState(snapshot);
// Store in buffer for interpolation (keep last 2)
this.snapshotBuffer.push({
state,
timestamp: snapshot.serverTime,
});
if (this.snapshotBuffer.length > 2) {
this.snapshotBuffer.shift();
}
// Save server-authoritative state
this.serverState = state;
// Reconcile: remove acked inputs and re-apply any still-pending inputs
this.reconcile(snapshot.lastProcessedSeq);
}
/**
* Interpolate between the last two snapshots so entities move smoothly.
*
* @param {number} currentTime — current local time in ms
* @returns {object|null} Interpolated state, or null if not enough snapshots.
*/
interpolate(currentTime) {
if (this.snapshotBuffer.length < 2) return this.serverState || null;
// Render time is behind server time by INTERP_DELAY
const renderTime = currentTime + (this.clockOffset || 0) - INTERP_DELAY;
const [older, newer] = this.snapshotBuffer;
if (renderTime >= newer.timestamp) {
return newer.state;
}
if (renderTime <= older.timestamp) {
return older.state;
}
const range = newer.timestamp - older.timestamp;
if (range <= 0) return newer.state;
const t = (renderTime - older.timestamp) / range;
const clamped = Math.max(0, Math.min(1, t));
return this._interpolateStates(older.state, newer.state, clamped);
}
/**
* Reconcile client-predicted state with server snapshot.
* Removes acked inputs, re-applies pending inputs on top of server state.
*
* @param {number} lastProcessedSeq — highest seq the server has processed
*/
reconcile(lastProcessedSeq) {
// Discard inputs the server has already processed
this.pendingInputs = this.pendingInputs.filter(
(p) => p.seq > lastProcessedSeq
);
// Start from server state
let state = cloneState(this.serverState);
// Re-apply remaining pending inputs as prediction
for (const pending of this.pendingInputs) {
state = this._applyInputToState(state, pending.input);
}
this.predictedState = state;
}
/**
* Per-frame update loop.
*
* @param {number} time — current time in ms
* @param {number} delta — ms since last frame
*/
update(time, delta) {
const interpolated = this.interpolate(time);
if (interpolated) {
this._applyStateToScene(interpolated);
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* Apply a single input to local predicted state (client-side prediction).
*/
_predict(input) {
if (!this.predictedState) {
this.predictedState = this.serverState
? cloneState(this.serverState)
: {};
}
this.predictedState = this._applyInputToState(this.predictedState, input);
}
/**
* Apply an input to a given state and return the new state.
* Override / extend this for game-specific logic.
*/
_applyInputToState(state, input) {
// Placeholder — game-specific systems should enrich this.
// For now, just return state unmodified.
return state;
}
/**
* Push interpolated state into the scene (entity positions, etc.).
*/
_applyStateToScene(interpolated) {
if (!this.scene || !interpolated) return;
if (interpolated.entities && Array.isArray(interpolated.entities)) {
for (const ent of interpolated.entities) {
const sprite = this.scene.children?.getByName?.(ent.id);
if (sprite) {
if (ent.x != null) sprite.x = ent.x;
if (ent.y != null) sprite.y = ent.y;
if (ent.rotation != null) sprite.rotation = ent.rotation;
}
}
}
}
/**
* Interpolate between two full snapshot states.
*/
_interpolateStates(older, newer, t) {
const result = {};
for (const key of Object.keys(newer)) {
const a = older[key];
const b = newer[key];
if (Array.isArray(b) && Array.isArray(a)) {
result[key] = b.map((itemB, i) => {
const itemA = a[i];
if (!itemA) return itemB;
return lerpEntity(itemA, itemB, t);
});
} else {
result[key] = b;
}
}
return result;
}
/**
* Handle server acknowledgement of received inputs.
*/
_handleInputAck(data) {
const { lastProcessedSeq } = data;
this.reconcile(lastProcessedSeq);
}
/**
* Disconnect and clean up.
*/
destroy() {
this.socket.removeAllListeners();
this.socket.disconnect();
this.pendingInputs = [];
this.snapshotBuffer = [];
this.predictedState = null;
this.serverState = null;
}
}
// =============================================================================
// NetworkSystemServer
// =============================================================================
class NetworkSystemServer {
/**
* @param {object} io — Socket.IO Server instance
* @param {object} gameState — server-side game state object
*/
constructor(io, gameState) {
/** @type {import("socket.io").Server} */
this.io = io;
/** @type {object} Server-authoritative game state. */
this.gameState = gameState;
/** @type {number} Snapshot broadcast rate (Hz). */
this.snapshotRate = SNAPSHOT_RATE;
/** @type {number} Timestamp of last broadcast (ms). */
this.lastSnapshot = 0;
/** @type {Map<string, number>} Per-client last processed input sequence. */
this.clientSequences = new Map();
// --- Bind Socket.IO connection handler ---
this.io.on("connection", (socket) => {
console.log("[NetworkSystemServer] client connected:", socket.id);
this.clientSequences.set(socket.id, 0);
// Handle input from individual clients
socket.on("input", (data) => this.onInput(socket.id, data));
socket.on("disconnect", (reason) => {
console.log("[NetworkSystemServer] client disconnected:", socket.id, reason);
this.clientSequences.delete(socket.id);
if (this.gameState.removePlayer) {
this.gameState.removePlayer(socket.id);
}
});
});
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Receive and process input from a client.
*
* @param {string} clientId — Socket.IO socket.id
* @param {object} input
* @param {number} input.seq — client sequence number
* @param {string} input.type — 'SELECT' | 'COMMAND' | 'MOVE' | 'ATTACK'
* @param {string} [input.entityId]
* @param {string} [input.commandType]
* @param {object} [input.target]
*/
onInput(clientId, input) {
// Apply to server-authoritative game state
this._applyInput(clientId, input);
// Update the last processed sequence for this client
const seq = input.seq || 0;
this.clientSequences.set(clientId, seq);
// Acknowledge back to the client
const socket = this.io.sockets.sockets.get(clientId);
if (socket) {
socket.emit("inputAck", { lastProcessedSeq: seq });
}
}
/**
* Broadcast the current authoritative game state to all connected clients.
* Typically called from the server's update loop at 20Hz.
*/
broadcastSnapshot() {
const snapshot = this._buildSnapshot();
this.io.emit("snapshot", snapshot);
}
/**
* Per-server-tick update. Processes queued inputs and broadcasts snapshots
* at the configured snapshot rate.
*
* @param {number} time — current server time in ms
* @param {number} delta — ms since last tick
*/
update(time, delta) {
// Broadcast at fixed rate
if (time - this.lastSnapshot >= SNAPSHOT_INTERVAL) {
this.lastSnapshot = time;
this.broadcastSnapshot();
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* Apply an input to the server-authoritative game state.
* Override / extend for game-specific logic.
*/
_applyInput(clientId, input) {
// Placeholder — game-specific server logic should enrich this.
// Expected to mutate this.gameState based on the input.
if (!this.gameState) return;
switch (input.type) {
case "SELECT":
// e.g. gameState.setSelection(clientId, input.entityId)
break;
case "COMMAND":
// e.g. gameState.executeCommand(clientId, input.commandType, input.target)
break;
case "MOVE":
// e.g. gameState.moveEntity(input.entityId, input.target)
break;
case "ATTACK":
// e.g. gameState.attackEntity(input.entityId, input.target)
break;
default:
break;
}
}
/**
* Build a snapshot object from the current game state.
*/
_buildSnapshot() {
const state = this.gameState;
return {
entities: state.entities || [],
buildings: state.buildings || [],
economy: state.economy || {},
controlPoints: state.controlPoints || [],
serverTime: Date.now(),
lastProcessedSeq: this._maxClientSeq(),
};
}
/**
* Return the maximum sequence number across all clients (for ACK purposes).
*/
_maxClientSeq() {
let max = 0;
for (const seq of this.clientSequences.values()) {
if (seq > max) max = seq;
}
return max;
}
/**
* Shutdown the server-side network system.
*/
destroy() {
this.io.removeAllListeners("connection");
this.clientSequences.clear();
}
}
// =============================================================================
// Exports
// =============================================================================
export { NetworkSystemClient, NetworkSystemServer, SNAPSHOT_RATE, SNAPSHOT_INTERVAL, INTERP_DELAY };
export default NetworkSystemClient;

View File

@@ -0,0 +1,365 @@
import EasyStar from "easystarjs";
/**
* PathfindingSystem — A* pathfinding service via EasyStar.js
*
* Service class (no XState). Manages an easystar grid built from the tilemap
* collision layer, caches computed paths per entity, and invalidates them
* when obstacles change.
*/
export default class PathfindingSystem {
/**
* @param {Phaser.Scene} scene
* @param {Phaser.Tilemaps.Tilemap} tilemap
*/
constructor(scene, tilemap) {
this.scene = scene;
this.tilemap = tilemap;
/** @type {EasyStar.js} */
this.easystar = new EasyStar.js();
/** @type {Map<string, Array<{x: number, y: number}>>} entityId -> tile-path */
this.pathCache = new Map();
/** @type {number[][]} 2D grid: 0 = walkable, 1 = blocked */
this.grid = [];
/** @type {number} tile width in pixels (default 64) */
this.tileWidth = 64;
/** @type {number} tile height in pixels (default 64) */
this.tileHeight = 64;
/** @type {boolean} whether the grid has been initialized */
this._initialized = false;
/** @type {Phaser.Tilemaps.TilemapLayer|null} collision layer used for grid init */
this._collisionLayer = null;
/** @type {Set<string>} ids of entities whose paths need recalculation */
this._dirtyEntities = new Set();
}
// ---------------------------------------------------------------------------
// Grid lifecycle
// ---------------------------------------------------------------------------
/**
* Build the 2D walkability grid from the tilemap's collision / rock / ground
* layers. Acceptable tiles are those whose tileset tile has a `cost` property.
*
* @param {string} [collisionLayerName="rockLayer"] layer name for unwalkable tiles
* @param {string} [groundLayerName="groundLayer"] fallback layer for walkable tiles
*/
initGrid(collisionLayerName = "rockLayer", groundLayerName = "groundLayer") {
this.easystar = new EasyStar.js();
this.easystar.setIterationsPerCalculation(1000);
this.easystar.enableDiagonals();
this.easystar.enableCornerCutting();
const width = this.tilemap.width;
const height = this.tilemap.height;
this._collisionLayer = this.tilemap.getLayer(collisionLayerName);
const groundLayer = this.tilemap.getLayer(groundLayerName);
if (!this._collisionLayer && !groundLayer) {
console.warn(
"[PathfindingSystem] initGrid: neither collision layer nor ground layer found. Creating open grid."
);
// Fallback: everything walkable
this.grid = Array.from({ length: height }, () => Array(width).fill(0));
this.easystar.setGrid(this.grid);
this.easystar.setAcceptableTiles([0]);
this._initialized = true;
return;
}
this.grid = [];
for (let y = 0; y < height; y++) {
const row = [];
for (let x = 0; x < width; x++) {
// Collision layer wins — if a rock tile exists it may be blocked
const rockTile = this._collisionLayer
? this._collisionLayer.getTileAt(x, y)
: null;
const groundTile = groundLayer
? groundLayer.getTileAt(x, y)
: null;
const tile = rockTile || groundTile;
if (tile && tile.properties && tile.properties.cost != null) {
// Walkable tile — register its cost
this.easystar.setTileCost(tile.index, tile.properties.cost);
row.push(0);
} else {
// Blocked tile
row.push(1);
}
}
this.grid.push(row);
}
this.easystar.setGrid(this.grid);
this.easystar.setAcceptableTiles([0]);
// Set diagonal cost multiplier
this.easystar.setAdditionalPointCost(1.5);
this._initialized = true;
}
// ---------------------------------------------------------------------------
// Grid manipulation
// ---------------------------------------------------------------------------
/**
* Mark a tile as walkable (0) or blocked (1). Invalidates cached paths for
* any entities that traverse the changed tile.
*
* @param {number} tileX
* @param {number} tileY
* @param {boolean} walkable
*/
setWalkable(tileX, tileY, walkable) {
if (
!this._initialized ||
tileY < 0 ||
tileY >= this.grid.length ||
tileX < 0 ||
tileX >= this.grid[0].length
) {
return;
}
const oldValue = this.grid[tileY][tileX];
const newValue = walkable ? 0 : 1;
if (oldValue === newValue) return;
this.grid[tileY][tileX] = newValue;
this.easystar.setGrid(this.grid);
// Invalidate any cached path that passes through this tile
for (const [entityId, path] of this.pathCache) {
if (!path) continue;
const hits = path.some((p) => p.x === tileX && p.y === tileY);
if (hits) {
this._dirtyEntities.add(entityId);
this.pathCache.delete(entityId);
}
}
}
/**
* Overwrite a rectangular region of the grid.
* Useful when spawning / destroying buildings.
*
* @param {number} tileX left
* @param {number} tileY top
* @param {number} width tiles wide
* @param {number} height tiles tall
* @param {boolean} walkable
*/
setRegionWalkable(tileX, tileY, width, height, walkable) {
for (let y = tileY; y < tileY + height; y++) {
for (let x = tileX; x < tileX + width; x++) {
this.setWalkable(x, y, walkable);
}
}
}
// ---------------------------------------------------------------------------
// Pathfinding
// ---------------------------------------------------------------------------
/**
* Asynchronously find a tile-path between two tile coordinates.
* The callback receives either an array of `{x, y}` tile positions or `null`.
*
* @param {{x: number, y: number}} startTile
* @param {{x: number, y: number}} endTile
* @param {{avoidEnemies?: boolean, maxPathLength?: number}} [options]
* @param {(path: Array<{x: number, y: number}> | null) => void} callback
*/
findPath(startTile, endTile, options, callback) {
if (!this._initialized) {
console.warn("[PathfindingSystem] findPath called before initGrid");
callback(null);
return;
}
// Normalize arguments — options is optional
if (typeof options === "function") {
callback = options;
options = {};
}
const opts = options || {};
const maxLength = opts.maxPathLength || Infinity;
this.easystar.findPath(
startTile.x,
startTile.y,
endTile.x,
endTile.y,
(path) => {
if (path === null) {
console.warn("[PathfindingSystem] No path found.");
callback(null);
return;
}
// Apply maxPathLength if set
const clamped = maxLength < Infinity ? path.slice(0, maxLength) : path;
callback(clamped);
}
);
this.easystar.calculate();
}
// ---------------------------------------------------------------------------
// Cache
// ---------------------------------------------------------------------------
/**
* Retrieve a previously computed path for an entity.
*
* @param {string} entityId
* @returns {Array<{x: number, y: number}> | undefined}
*/
getCachedPath(entityId) {
return this.pathCache.get(entityId);
}
/**
* Store a computed path for an entity.
*
* @param {string} entityId
* @param {Array<{x: number, y: number}>} path
*/
setCachedPath(entityId, path) {
this.pathCache.set(entityId, path);
}
/**
* Invalidate cached paths. If `entityId` is provided only that entity's
* path is cleared; otherwise the entire cache is flushed.
*
* @param {string} [entityId]
*/
invalidateCache(entityId) {
if (entityId) {
this.pathCache.delete(entityId);
this._dirtyEntities.delete(entityId);
} else {
this.pathCache.clear();
this._dirtyEntities.clear();
}
}
// ---------------------------------------------------------------------------
// Utility
// ---------------------------------------------------------------------------
/**
* Convert a tile-path (array of `{x, y}`) into world pixel coordinates.
*
* @param {Array<{x: number, y: number}>} path
* @returns {Array<{x: number, y: number}>}
*/
pathToWorldCoords(path) {
if (!path || !path.length) return [];
return path.map((tile) => ({
x: tile.x * this.tileWidth + this.tileWidth / 2,
y: tile.y * this.tileHeight + this.tileHeight / 2,
}));
}
/**
* Convert a single tile coordinate to a world pixel coordinate.
*
* @param {{x: number, y: number}} tile
* @returns {{x: number, y: number}}
*/
tileToWorldCoords(tile) {
return {
x: tile.x * this.tileWidth + this.tileWidth / 2,
y: tile.y * this.tileHeight + this.tileHeight / 2,
};
}
/**
* Convert a world pixel position to a tile coordinate.
*
* @param {number} worldX
* @param {number} worldY
* @returns {{x: number, y: number}}
*/
worldToTileCoords(worldX, worldY) {
return {
x: Math.floor(worldX / this.tileWidth),
y: Math.floor(worldY / this.tileHeight),
};
}
// ---------------------------------------------------------------------------
// Update loop
// ---------------------------------------------------------------------------
/**
* Per-tick update. Recalculates paths for entities flagged as dirty
* (e.g., after an obstacle change invalidated their path).
*
* @param {number} _time
* @param {number} _delta
*/
update(_time, _delta) {
// Currently a no-op — path recalculation happens lazily when
// findPath is called. Extend here if you need proactive recalculation.
}
// ---------------------------------------------------------------------------
// Introspection
// ---------------------------------------------------------------------------
/**
* Whether the grid has been initialized.
* @returns {boolean}
*/
get initialized() {
return this._initialized;
}
/**
* Grid dimensions.
* @returns {{width: number, height: number}}
*/
get dimensions() {
return {
width: this.grid.length > 0 ? this.grid[0].length : 0,
height: this.grid.length,
};
}
/**
* Current number of cached paths.
* @returns {number}
*/
get cacheSize() {
return this.pathCache.size;
}
/**
* Number of entities waiting for path recalculation.
* @returns {number}
*/
get dirtyCount() {
return this._dirtyEntities.size;
}
}

View File

@@ -0,0 +1,697 @@
import Phaser from 'phaser';
/**
* @readonly
* @enum {string}
*/
export const CommandType = {
MOVE: 'MOVE',
ATTACK_MOVE: 'ATTACK_MOVE',
ATTACK_TARGET: 'ATTACK_TARGET',
STOP: 'STOP',
PATROL: 'PATROL',
};
/**
* @readonly
* @enum {string}
*/
const Formation = {
NONE: 'none',
AGGRO: 'aggro',
SPREAD: 'spread',
LINE: 'line',
};
const SELECTION_BOX_FILL = 0x1d7196;
const SELECTION_BOX_ALPHA = 0.25;
const SELECTION_BOX_STROKE = 0x1d7196;
const SELECTION_BOX_LINE_WIDTH = 1;
/**
* SelectionSystem
*
* Service class responsible for:
* - Single-click and drag-box entity selection
* - Multi-select with Shift-modifier
* - Command queue (MOVE, ATTACK_MOVE, ATTACK_TARGET, STOP, PATROL)
* - Formation positioning (aggro, spread, line)
* - Pointer event handlers wired to Phaser scene input
*
* No XState dependency — pure service class operating on a Set of selected
* entities and a Phaser.GameObjects.Graphics drag-select box.
*/
export default class SelectionSystem {
/**
* @param {Phaser.Scene} scene - The owning Phaser scene
*/
constructor(scene) {
/** @type {Phaser.Scene} */
this.scene = scene;
/** @type {Set<import('../phaserClasses/Custom_Entity').default>} */
this.selected = new Set();
/** @type {Array<{type: CommandType, target: Object}>} */
this.commandQueue = [];
/**
* Phaser.GameObjects.Graphics used to draw the drag-select rectangle.
* Created in #ensureSelectionBox() so it can be re-created if destroyed.
* @type {Phaser.GameObjects.Graphics|null}
*/
this.selectionBox = null;
/** @type {string} */
this.formation = Formation.NONE;
/** @type {{spread: number}} */
this.formationOptions = { spread: 32 };
/** @type {boolean} */
this.isDragging = false;
/** @type {{x: number, y: number}} */
this.dragStart = { x: 0, y: 0 };
/**
* Reference to the SHIFT key for multi-select.
* @type {Phaser.Input.Keyboard.Key|null}
*/
this.shiftKey = null;
/**
* Reference to the CTRL key for command queuing.
* @type {Phaser.Input.Keyboard.Key|null}
*/
this.ctrlKey = null;
// --- Initialise input hooks ---
this.#registerInputKeys();
this.#wirePointerEvents();
}
// ---------------------------------------------------------------------------
// Selection management
// ---------------------------------------------------------------------------
/**
* Add an entity to the selection set.
* @param {Object} entity - Game entity (must have select/unSelect methods)
* @param {boolean} [silent=false] - If true, skip visual select callback
*/
add(entity, silent = false) {
if (!entity) return;
if (!this.selected.has(entity)) {
this.selected.add(entity);
if (!silent && typeof entity.select === 'function') {
entity.select(true);
}
}
}
/**
* Remove a single entity from the selection.
* @param {Object} entity
*/
remove(entity) {
if (this.selected.delete(entity)) {
if (typeof entity.unSelect === 'function') {
entity.unSelect(true);
}
}
}
/**
* Clear all selections.
*/
clear() {
for (const entity of this.selected) {
if (typeof entity.unSelect === 'function') {
entity.unSelect(true);
}
}
this.selected.clear();
}
/**
* Replace the current selection with a single entity.
* @param {Object} entity
*/
selectSingle(entity) {
this.clear();
this.add(entity);
}
/**
* Returns the selected entities as an array.
* @returns {Array<Object>}
*/
getSelected() {
return [...this.selected];
}
/**
* @returns {number}
*/
get count() {
return this.selected.size;
}
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
/**
* Issue a command to all currently selected entities.
*
* @param {CommandType} type - One of MOVE, ATTACK_MOVE, ATTACK_TARGET, STOP, PATROL
* @param {Object} [target={}] - Command target data
* @param {{x: number, y: number}} [target.tile] - Target tile for MOVE / ATTACK_MOVE
* @param {Object} [target.entity] - Target entity for ATTACK_TARGET
* @param {Array<{x: number, y: number}>} [target.waypoints] - Waypoints for PATROL
*/
issueCommand(type, target = {}) {
const command = { type, target, timestamp: Date.now() };
this.commandQueue.push(command);
// Immediately dispatch if not queueing (CTRL not held)
if (!this.ctrlKey || !this.ctrlKey.isDown) {
this.#dispatchCommand(command);
}
}
/**
* Process a single command against all selected entities.
* Integrates with the scene's pathfinding and combat systems.
* @param {{type: CommandType, target: Object}} command
*/
#dispatchCommand(command) {
const entities = this.getSelected();
if (entities.length === 0) return;
const { type, target } = command;
const leader = entities[0];
const leaderPos = { x: leader.x, y: leader.y };
switch (type) {
case CommandType.MOVE:
this.#dispatchMove(entities, target, leaderPos);
break;
case CommandType.ATTACK_MOVE:
this.#dispatchAttackMove(entities, target, leaderPos);
break;
case CommandType.ATTACK_TARGET:
this.#dispatchAttackTarget(entities, target);
break;
case CommandType.STOP:
this.#dispatchStop(entities);
break;
case CommandType.PATROL:
this.#dispatchPatrol(entities, target);
break;
default:
console.warn(`[SelectionSystem] Unknown command type: ${type}`);
}
}
// ---- Per-command dispatchers ----
#dispatchMove(entities, target, leaderPos) {
const { tile } = target;
if (!tile) return;
const positions = this.getFormationPositions(leaderPos, entities.length);
entities.forEach((entity, i) => {
const offsetTile = {
x: tile.x + (positions[i]?.x ?? 0),
y: tile.y + (positions[i]?.y ?? 0),
};
if (this.scene.interface?.pathfinder) {
const startTile = this.scene.interface.generateTileXY(
new Phaser.Math.Vector2(entity.x, entity.y),
);
this.scene.interface.pathfinder.findPath(
entity,
startTile,
offsetTile,
false,
);
}
});
}
#dispatchAttackMove(entities, target, leaderPos) {
// ATTACK_MOVE: Move to destination and engage enemies en route.
// For now delegates to move; attack-on-sight is handled by combat system.
this.#dispatchMove(entities, target, leaderPos);
}
#dispatchAttackTarget(entities, target) {
const { entity: targetEntity } = target;
if (!targetEntity) return;
entities.forEach((entity) => {
// Delegate to combat system when available
if (typeof entity.attackTarget === 'function') {
entity.attackTarget(targetEntity);
} else {
console.warn(
`[SelectionSystem] Entity does not implement attackTarget()`,
entity,
);
}
});
}
#dispatchStop(entities) {
entities.forEach((entity) => {
if (typeof entity.stop === 'function') {
entity.stop();
}
});
}
#dispatchPatrol(entities, target) {
const { waypoints } = target;
if (!waypoints || waypoints.length === 0) return;
entities.forEach((entity) => {
if (typeof entity.setPatrol === 'function') {
entity.setPatrol(waypoints);
}
});
}
// ---------------------------------------------------------------------------
// Command queue processing (called each frame)
// ---------------------------------------------------------------------------
/**
* Process any queued commands. Called from the scene's update loop.
* @param {number} _time
* @param {number} _delta
*/
update(_time, _delta) {
// Drain command queue (commands queued with CTRL + right-click)
while (this.commandQueue.length > 0) {
const command = this.commandQueue.shift();
this.#dispatchCommand(command);
}
}
// ---------------------------------------------------------------------------
// Formations
// ---------------------------------------------------------------------------
/**
* Set the formation pattern for selected entities.
* @param {'aggro'|'spread'|'line'} type
* @param {{spread?: number}} [options={}]
*/
setFormation(type, options = {}) {
if (![Formation.AGGRO, Formation.SPREAD, Formation.LINE].includes(type)) {
console.warn(`[SelectionSystem] Unknown formation type: ${type}`);
return;
}
this.formation = type;
this.formationOptions = { ...this.formationOptions, ...options };
}
/**
* Calculate formation offsets relative to a leader position.
*
* @param {{x: number, y: number}} leaderPos - Leader world position
* @param {number} count - Number of entities to position
* @returns {Array<{x: number, y: number}>} Tile-offset positions for each follower
*/
getFormationPositions(leaderPos, count) {
const { spread } = this.formationOptions;
const positions = [];
if (count <= 1) return positions;
switch (this.formation) {
case Formation.AGGRO:
case Formation.NONE:
// Tight cluster around leader — small random-ish offsets
for (let i = 1; i < count; i++) {
const angle = ((i - 1) / (count - 1)) * Math.PI * 2;
positions.push({
x: Math.round(Math.cos(angle)),
y: Math.round(Math.sin(angle)),
});
}
break;
case Formation.SPREAD:
// Spread in a grid pattern
{
const cols = Math.ceil(Math.sqrt(count));
for (let i = 1; i < count; i++) {
const col = (i - 1) % cols;
const row = Math.floor((i - 1) / cols);
positions.push({
x: col * Math.ceil(spread / 32),
y: row * Math.ceil(spread / 32),
});
}
}
break;
case Formation.LINE:
// Horizontal line, centered on leader
{
const half = Math.floor((count - 1) / 2);
for (let i = 0; i < count - 1; i++) {
positions.push({
x: (i - half) * Math.ceil(spread / 32),
y: 0,
});
}
}
break;
}
return positions;
}
// ---------------------------------------------------------------------------
// Selection box (Graphics)
// ---------------------------------------------------------------------------
/**
* Lazily create / re-create the Graphics object used for the drag-select
* rectangle. Uses a Graphics object (not Rectangle) so we can control
* fill, stroke, and redraw behaviour precisely.
*/
#ensureSelectionBox() {
if (this.selectionBox && this.selectionBox.active) return;
this.selectionBox = this.scene.add.graphics();
this.selectionBox.setDepth(Number.MAX_SAFE_INTEGER);
this.selectionBox.setAlpha(1);
}
/**
* Draw the drag-select rectangle.
* @param {number} x
* @param {number} y
* @param {number} w
* @param {number} h
*/
#drawSelectionBox(x, y, w, h) {
this.#ensureSelectionBox();
this.selectionBox.clear();
// Normalise negative dimensions
let rx = x;
let ry = y;
let rw = w;
let rh = h;
if (rw < 0) {
rx += rw;
rw = Math.abs(rw);
}
if (rh < 0) {
ry += rh;
rh = Math.abs(rh);
}
this.selectionBox.fillStyle(SELECTION_BOX_FILL, SELECTION_BOX_ALPHA);
this.selectionBox.fillRect(rx, ry, rw, rh);
this.selectionBox.lineStyle(
SELECTION_BOX_LINE_WIDTH,
SELECTION_BOX_STROKE,
0.8,
);
this.selectionBox.strokeRect(rx, ry, rw, rh);
}
/**
* Hide / clear the selection box.
*/
#clearSelectionBox() {
if (this.selectionBox && this.selectionBox.active) {
this.selectionBox.clear();
}
}
// ---------------------------------------------------------------------------
// Entity overlap test for drag-select
// ---------------------------------------------------------------------------
/**
* Build a Phaser.Geom.Rectangle from the drag coordinates and query the
* physics world for overlapping bodies. Returns matching game objects.
*
* @param {number} x1
* @param {number} y1
* @param {number} x2
* @param {number} y2
* @returns {Array<Object>}
*/
#queryDragRect(x1, y1, x2, y2) {
let rx = x1;
let ry = y1;
let rw = x2 - x1;
let rh = y2 - y1;
if (rw < 0) {
rx += rw;
rw = Math.abs(rw);
}
if (rh < 0) {
ry += rh;
rh = Math.abs(rh);
}
const rect = new Phaser.Geom.Rectangle(rx, ry, rw, rh);
const bodies = this.scene.physics.overlapRect(rx, ry, rw, rh);
if (!bodies || bodies.length === 0) return [];
return bodies.map((body) => body.gameObject).filter(Boolean);
}
// ---------------------------------------------------------------------------
// Pointer event handlers
// ---------------------------------------------------------------------------
/**
* Handle pointer-down: start drag-select or single-click select.
* @param {Phaser.Input.Pointer} pointer
* @param {Array<Phaser.GameObjects.GameObject>} currentlyOver
*/
handlePointerDown(pointer, currentlyOver) {
// Ignore right-clicks — those are handled by the command pathway
if (pointer.rightButtonDown()) return;
this.dragStart = { x: pointer.worldX, y: pointer.worldY };
this.isDragging = true;
const shiftHeld = this.shiftKey && this.shiftKey.isDown;
const entity =
currentlyOver && currentlyOver.length > 0 ? currentlyOver[0] : null;
// Multi-select with shift — add without clearing
if (shiftHeld && entity) {
if (this.selected.has(entity)) {
this.remove(entity);
} else {
this.add(entity);
}
return;
}
}
/**
* Handle pointer-move: update the drag-select box if dragging.
* @param {Phaser.Input.Pointer} pointer
*/
handlePointerDrag(pointer) {
if (!this.isDragging || !pointer.isDown || pointer.rightButtonDown()) {
return;
}
const x = this.dragStart.x;
const y = this.dragStart.y;
const w = pointer.worldX - x;
const h = pointer.worldY - y;
// Only draw if the drag exceeds a small dead-zone
if (Math.abs(w) < 4 && Math.abs(h) < 4) {
this.#clearSelectionBox();
return;
}
this.#drawSelectionBox(x, y, w, h);
}
/**
* Handle pointer-up: finalise drag selection or commit single-click.
* @param {Phaser.Input.Pointer} pointer
* @param {Array<Phaser.GameObjects.GameObject>} currentlyOver
*/
handlePointerUp(pointer, currentlyOver) {
if (!this.isDragging) return;
this.isDragging = false;
const shiftHeld = this.shiftKey && this.shiftKey.isDown;
const dx = pointer.worldX - this.dragStart.x;
const dy = pointer.worldY - this.dragStart.y;
const dragDistance = Math.sqrt(dx * dx + dy * dy);
if (dragDistance < 5) {
// Tiny drag → treat as a single click
const entity =
currentlyOver && currentlyOver.length > 0 ? currentlyOver[0] : null;
if (!shiftHeld) {
this.clear();
}
if (entity) {
if (shiftHeld && this.selected.has(entity)) {
this.remove(entity);
} else {
this.add(entity);
}
} else if (!shiftHeld) {
// Clicked empty space without shift → deselect all
this.clear();
}
} else {
// Dragged a box → query physics overlap
if (!shiftHeld) {
this.clear();
}
const hits = this.#queryDragRect(
this.dragStart.x,
this.dragStart.y,
pointer.worldX,
pointer.worldY,
);
for (const entity of hits) {
this.add(entity, false);
}
if (hits.length === 0 && !shiftHeld) {
this.clear();
}
}
this.#clearSelectionBox();
}
// ---------------------------------------------------------------------------
// Right-click context menu (command issuing)
// ---------------------------------------------------------------------------
/**
* Handle right-click: issue a context-sensitive command to selected entities.
*
* @param {Phaser.Input.Pointer} pointer
* @param {Array<Phaser.GameObjects.GameObject>} currentlyOver
*/
handleRightClick(pointer, currentlyOver) {
if (this.selected.size === 0) return;
const targetEntity =
currentlyOver && currentlyOver.length > 0 ? currentlyOver[0] : null;
const tile = this.scene.interface
? this.scene.interface.getTileAtPointerXY(pointer)
: null;
if (targetEntity && targetEntity !== this.getSelected()[0]) {
// Right-clicked an enemy / other entity → attack
this.issueCommand(CommandType.ATTACK_TARGET, { entity: targetEntity });
} else if (tile) {
// Right-clicked terrain → move
this.issueCommand(CommandType.MOVE, { tile: { x: tile.x, y: tile.y } });
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* Register modifier key references.
*/
#registerInputKeys() {
if (!this.scene.input || !this.scene.input.keyboard) return;
this.shiftKey = this.scene.input.keyboard.addKey(
Phaser.Input.Keyboard.KeyCodes.SHIFT,
);
this.ctrlKey = this.scene.input.keyboard.addKey(
Phaser.Input.Keyboard.KeyCodes.CTRL,
);
}
/**
* Wire the Phaser scene pointer events to this system's handlers.
*/
#wirePointerEvents() {
this.scene.input.on(
Phaser.Input.Events.POINTER_DOWN,
this.handlePointerDown,
this,
);
this.scene.input.on(
Phaser.Input.Events.POINTER_MOVE,
this.handlePointerDrag,
this,
);
this.scene.input.on(
Phaser.Input.Events.POINTER_UP,
this.handlePointerUp,
this,
);
}
/**
* Tear down listeners and clean up graphics. Call when the scene shuts
* down or the system is being replaced.
*/
destroy() {
this.scene.input.off(
Phaser.Input.Events.POINTER_DOWN,
this.handlePointerDown,
this,
);
this.scene.input.off(
Phaser.Input.Events.POINTER_MOVE,
this.handlePointerDrag,
this,
);
this.scene.input.off(
Phaser.Input.Events.POINTER_UP,
this.handlePointerUp,
this,
);
if (this.selectionBox) {
this.selectionBox.destroy();
this.selectionBox = null;
}
this.selected.clear();
this.commandQueue.length = 0;
}
}

View File

@@ -0,0 +1,509 @@
import Phaser from 'phaser';
import EconomySystem from './EconomySystem.js';
import PathfindingSystem from './PathfindingSystem.js';
import CombatSystem from './CombatSystem.js';
import SelectionSystem from './SelectionSystem.js';
import { NetworkSystemClient } from './NetworkSystem.js';
import MapSystem from './MapSystem.js';
import EntityStateMachine from './EntityStateMachine.js';
import BuildingStateMachine from './BuildingStateMachine.js';
import ControlPointStateMachine from './ControlPointStateMachine.js';
/**
* SystemOrchestrator — wires all 9 systems together.
*
* Responsibilities:
* 1. Initialize all service-level systems (singleton per scene)
* 2. Manage per-instance state machine registries (entity, building, control point)
* 3. Run the canonical update loop in the correct order
* 4. Wire cross-system events
*
* Pattern:
* - Singleton per scene
* - Created in scene.create(), updated in scene.update(), destroyed in scene.shutdown()
* - Uses Phaser.EventEmitter on the scene for cross-system communication
*
* Update order (per tech plan):
* selection → economy → controlPoints → buildings → entities → pathfinding → combat → network
*/
export default class SystemOrchestrator {
/**
* @param {Phaser.Scene} scene - The owning Phaser scene (Map_Player)
* @param {Object} [config={}]
* @param {string} [config.serverUrl] - Socket.IO server URL for NetworkSystemClient
* @param {string} [config.mapKey] - Tilemap cache key (e.g. 'test1')
* @param {string} [config.tilesetKey] - Tileset image key
* @param {string} [config.tilesetName] - Tileset name in Tiled
*/
constructor(scene, config = {}) {
/** @type {Phaser.Scene} */
this.scene = scene;
/** @type {Object} */
this.config = config;
// ── Service-level systems (one instance per scene) ───────────────────
/** @type {Object<string, Object>} */
this.systems = {};
// ── Per-instance state machine registries ─────────────────────────────
/** @type {EntityStateMachine[]} */
this.entityStateMachines = [];
/** @type {BuildingStateMachine[]} */
this.buildingStateMachines = [];
/** @type {ControlPointStateMachine[]} */
this.controlPointStateMachines = [];
// ── Update order (canonical) ──────────────────────────────────────────
/** @type {string[]} */
this.updateOrder = [
'selection',
'economy',
'controlPoints',
'buildings',
'entities',
'pathfinding',
'combat',
'network',
];
/** @type {boolean} Has init() been called? */
this._initialized = false;
/** @type {boolean} Has initPathfinding() been called? */
this._pathfindingReady = false;
}
// ===========================================================================
// INITIALIZATION
// ===========================================================================
/**
* Initialize all service-level systems.
* Call this from the scene's create() after the tilemap has been loaded.
* Follow with initPathfinding() once the MapSystem has a valid tilemap.
*
* @returns {SystemOrchestrator} this (for chaining)
*/
init() {
if (this._initialized) {
console.warn('[SystemOrchestrator] Already initialized — skipping.');
return this;
}
// 1. MapSystem — tilemap loading, collision, zones, spawn points
this.systems.map = new MapSystem(this.scene);
// If config provides map details, load immediately
if (this.config.mapKey) {
this.systems.map.loadMap(
this.config.mapKey,
this.config.tilesetKey || 'floorsPrimary',
this.config.tilesetName || 'floorsPrimary',
);
}
// 2. EconomySystem — resource tracking, purchase validation, income ticks
this.systems.economy = new EconomySystem(this.scene);
// 3. CombatSystem — target acquisition, LoS, projectiles, damage
this.systems.combat = new CombatSystem(this.scene);
// 4. SelectionSystem — drag-select, command queue, formations
this.systems.selection = new SelectionSystem(this.scene);
// 5. NetworkSystem — client-side state sync, prediction, interpolation
if (this.config.serverUrl) {
this.systems.network = new NetworkSystemClient(
this.scene,
this.config.serverUrl,
);
}
// PathfindingSystem is initialized separately because it needs the
// tilemap reference from MapSystem. Call initPathfinding() after
// the MapSystem has successfully loaded a map.
// Wire cross-system events
this._wireEvents();
this._initialized = true;
// Attach to scene for convenient access from other classes
this.scene.orchestrator = this;
return this;
}
/**
* Initialize the PathfindingSystem (deferred until MapSystem has a tilemap).
* Call this after the MapSystem has loaded a map and has a valid tilemap.
*
* @returns {PathfindingSystem|null} The new PathfindingSystem, or null if no tilemap
*/
initPathfinding() {
if (!this.systems.map || !this.systems.map.tilemap) {
console.warn(
'[SystemOrchestrator] Cannot init PathfindingSystem — no tilemap yet.',
);
return null;
}
if (this.systems.pathfinding) {
console.warn(
'[SystemOrchestrator] PathfindingSystem already initialized — re-creating.',
);
// Gracefully handle existing instance
this.systems.pathfinding = null;
}
this.systems.pathfinding = new PathfindingSystem(
this.scene,
this.systems.map.tilemap,
);
this.systems.pathfinding.initGrid('Rocks', 'Floor');
this._pathfindingReady = true;
return this.systems.pathfinding;
}
// ===========================================================================
// EVENT WIRING
// ===========================================================================
/**
* Wire cross-system events so systems communicate without direct coupling.
* All events flow through the scene's Phaser.EventEmitter.
*
* Event map:
* - building:spawned → PathfindingSystem.setWalkable(false)
* - building:destroyed → PathfindingSystem.setWalkable(true)
* - entity:destroyed → PathfindingSystem.invalidateCache(entityId)
* - controlPoint:captured → EconomySystem.addIncome(capturePoints)
* - combat:unitDamaged → (NetworkSystem handles snapshot broadcast)
*/
_wireEvents() {
const events = this.scene.events;
// ── Building lifecycle → Pathfinding grid ────────────────────────────
events.on('building:spawned', (building) => {
if (!this.systems.pathfinding || !this.systems.map) return;
const tileCoords = this.systems.map.getTileAtWorld(
building.x,
building.y,
);
if (tileCoords) {
this.systems.pathfinding.setWalkable(
tileCoords.x,
tileCoords.y,
false,
);
}
});
events.on('building:destroyed', (building) => {
if (!this.systems.pathfinding || !this.systems.map) return;
const tileCoords = this.systems.map.getTileAtWorld(
building.x,
building.y,
);
if (tileCoords) {
this.systems.pathfinding.setWalkable(
tileCoords.x,
tileCoords.y,
true,
);
}
});
// ── Entity lifecycle → Pathfinding cache ─────────────────────────────
events.on('entity:destroyed', (entity) => {
if (this.systems.pathfinding && entity) {
const entityId = entity.id || entity.name || entity.entityId;
if (entityId) {
this.systems.pathfinding.invalidateCache(entityId);
}
}
});
// ── Control point capture → Economy income ───────────────────────────
events.on('controlPoint:captured', (zone, playerId) => {
if (this.systems.economy && playerId) {
this.systems.economy.addIncome(playerId, { capturePoints: 1 });
}
});
// ── Combat damage → Logging / network relay ──────────────────────────
events.on('combat:unitDamaged', (data) => {
// NetworkSystem handles snapshot broadcasting on its own cadence.
// This hook can be extended for kill-feed / damage-flash UI.
if (this.config.debug) {
console.debug('[SystemOrchestrator] combat:unitDamaged', data);
}
});
// ── Production complete → Unit spawn ─────────────────────────────────
events.on('building:productionComplete', (building) => {
if (this.config.debug) {
console.debug(
'[SystemOrchestrator] building:productionComplete',
building.type || building.name,
);
}
// Production spawn is handled by BuildingStateMachine.tick()
// and emits a scene event for the game to create the unit.
});
// ── Selection command → Cross-system routing ─────────────────────────
events.on('selection:commandIssued', (command) => {
if (this.config.debug) {
console.debug('[SystemOrchestrator] selection:commandIssued', command);
}
});
}
// ===========================================================================
// REGISTRATION (per-instance state machines)
// ===========================================================================
/**
* Register an entity with its XState machine config.
* The orchestrator will tick it every frame.
*
* @param {Object} entity - Phaser game object (sprite / container)
* @param {Object} machineConfig - XState machine configuration
* @returns {EntityStateMachine} The created state machine wrapper
*/
registerEntity(entity, machineConfig) {
const esm = new EntityStateMachine(entity, machineConfig);
this.entityStateMachines.push(esm);
return esm;
}
/**
* Unregister an entity state machine (e.g. when entity is destroyed).
* @param {EntityStateMachine|Object} target - The ESM instance or the entity
*/
unregisterEntity(target) {
this.entityStateMachines = this.entityStateMachines.filter((esm) => {
if (esm === target) return false;
if (esm.entity === target) return false;
return true;
});
}
/**
* Register a building with its XState machine config.
*
* @param {Object} building - Phaser game object
* @param {Object} config - Building type & timing config
* @returns {BuildingStateMachine} The created state machine wrapper
*/
registerBuilding(building, config) {
const bsm = new BuildingStateMachine(building, config);
this.buildingStateMachines.push(bsm);
// Auto-emit building:spawned for pathfinding grid
this.scene.events.emit('building:spawned', building);
return bsm;
}
/**
* Unregister a building state machine (e.g. when destroyed).
* @param {BuildingStateMachine|Object} target
*/
unregisterBuilding(target) {
const before = this.buildingStateMachines.length;
this.buildingStateMachines = this.buildingStateMachines.filter((bsm) => {
if (bsm === target) return false;
if (bsm.building === target) return false;
return true;
});
if (before !== this.buildingStateMachines.length && target.building) {
this.scene.events.emit('building:destroyed', target.building);
}
}
/**
* Register a control point (Phaser Zone) with its capture config.
*
* @param {Phaser.GameObjects.Zone} zone
* @param {Object} [config] - radius and captureTime
* @returns {ControlPointStateMachine} The created state machine wrapper
*/
registerControlPoint(zone, config) {
const cpsm = new ControlPointStateMachine(zone, config);
this.controlPointStateMachines.push(cpsm);
return cpsm;
}
/**
* Unregister a control point state machine.
* @param {ControlPointStateMachine|Phaser.GameObjects.Zone} target
*/
unregisterControlPoint(target) {
this.controlPointStateMachines =
this.controlPointStateMachines.filter((cpsm) => {
if (cpsm === target) return false;
if (cpsm.zone === target) return false;
return true;
});
}
// ===========================================================================
// UPDATE LOOP
// ===========================================================================
/**
* Canonical update loop. Called from scene.update().
* Order: selection → economy → controlPoints → buildings → entities →
* pathfinding → combat → network
*
* @param {number} time - Current scene time in ms
* @param {number} delta - ms since last frame
*/
update(time, delta) {
for (const systemName of this.updateOrder) {
switch (systemName) {
// 1. SelectionSystem — process input, issue commands
case 'selection':
if (this.systems.selection?.update) {
this.systems.selection.update(time, delta);
}
break;
// 2. EconomySystem — resource income tick (guarded internally)
case 'economy':
if (this.systems.economy?.update) {
this.systems.economy.update(time);
}
break;
// 3. ControlPointStateMachine[] — capture progress update
case 'controlPoints':
for (let i = this.controlPointStateMachines.length - 1; i >= 0; i--) {
const cpsm = this.controlPointStateMachines[i];
if (cpsm.tick) {
cpsm.tick(time, delta, this.scene);
}
}
break;
// 4. BuildingStateMachine[] — production queue advance
case 'buildings':
for (let i = this.buildingStateMachines.length - 1; i >= 0; i--) {
const bsm = this.buildingStateMachines[i];
if (bsm.tick) {
bsm.tick(time, delta);
}
}
break;
// 5. EntityStateMachine[] — state machine TICK (all units)
case 'entities':
for (let i = this.entityStateMachines.length - 1; i >= 0; i--) {
const esm = this.entityStateMachines[i];
if (esm.tick) {
esm.tick(time, delta);
}
}
break;
// 6. PathfindingSystem — path recalculations (lazy)
case 'pathfinding':
if (this.systems.pathfinding?.update) {
this.systems.pathfinding.update(time, delta);
}
break;
// 7. CombatSystem — projectile resolution, damage application
case 'combat':
if (this.systems.combat?.update) {
this.systems.combat.update(time, delta);
}
break;
// 8. NetworkSystem — snapshot broadcast (client/server sync)
case 'network':
if (this.systems.network?.update) {
this.systems.network.update(time, delta);
}
break;
default:
break;
}
}
}
// ===========================================================================
// SHUTDOWN
// ===========================================================================
/**
* Cleanup all systems. Call from scene.shutdown() or scene.destroy().
*/
shutdown() {
// ── Destroy service-level systems ────────────────────────────────────
for (const system of Object.values(this.systems)) {
if (system && typeof system.destroy === 'function') {
system.destroy();
}
}
// ── Destroy per-instance state machines ──────────────────────────────
for (const esm of this.entityStateMachines) {
if (esm.destroy) esm.destroy();
}
for (const bsm of this.buildingStateMachines) {
if (bsm.destroy) bsm.destroy();
}
for (const cpsm of this.controlPointStateMachines) {
if (cpsm.destroy) cpsm.destroy();
}
// ── Clear registries ─────────────────────────────────────────────────
this.entityStateMachines = [];
this.buildingStateMachines = [];
this.controlPointStateMachines = [];
this.systems = {};
// ── Remove scene reference ───────────────────────────────────────────
if (this.scene && this.scene.orchestrator === this) {
this.scene.orchestrator = null;
}
this._initialized = false;
this._pathfindingReady = false;
}
// ===========================================================================
// DIAGNOSTICS
// ===========================================================================
/**
* Return a summary of all systems and their current state.
* Useful for debugging and introspection.
*
* @returns {Object}
*/
getDiagnostics() {
return {
initialized: this._initialized,
pathfindingReady: this._pathfindingReady,
serviceSystems: Object.keys(this.systems),
entityStateMachineCount: this.entityStateMachines.length,
buildingStateMachineCount: this.buildingStateMachines.length,
controlPointStateMachineCount: this.controlPointStateMachines.length,
updateOrder: this.updateOrder,
};
}
}

169
tests/CombatSystem.test.js Normal file
View File

@@ -0,0 +1,169 @@
/**
* CombatSystem Unit Tests
*/
import CombatSystem from '../src/systems/CombatSystem';
const createMockScene = () => ({
physics: {
add: {
group: jest.fn(() => ({
create: jest.fn(),
killAndHide: jest.fn()
}))
},
overlap: jest.fn()
},
events: {
emit: jest.fn()
},
add: {
sprite: jest.fn()
}
});
describe('CombatSystem', () => {
let scene;
let combat;
beforeEach(() => {
scene = createMockScene();
combat = new CombatSystem(scene);
});
describe('acquireTarget', () => {
it('should return null when no enemies in range', () => {
const entity = { x: 0, y: 0, getData: jest.fn(() => []) };
const target = combat.acquireTarget(entity, { maxRange: 200 });
expect(target).toBeNull();
});
it('should return closest enemy when multiple in range', () => {
const enemy1 = { x: 100, y: 0, isDead: jest.fn(() => false) };
const enemy2 = { x: 50, y: 0, isDead: jest.fn(() => false) };
combat.enemies = [enemy1, enemy2];
const entity = { x: 0, y: 0, getData: jest.fn(() => combat.enemies) };
const target = combat.acquireTarget(entity, { maxRange: 200, priority: 'closest' });
expect(target).toBe(enemy2); // Closer enemy
});
it('should filter out dead enemies', () => {
const deadEnemy = { x: 50, y: 0, isDead: jest.fn(() => true) };
const liveEnemy = { x: 100, y: 0, isDead: jest.fn(() => false) };
combat.enemies = [deadEnemy, liveEnemy];
const entity = { x: 0, y: 0, getData: jest.fn(() => combat.enemies) };
const target = combat.acquireTarget(entity, { maxRange: 200 });
expect(target).toBe(liveEnemy);
});
});
describe('canHit', () => {
let attacker, target;
beforeEach(() => {
attacker = {
x: 0,
y: 0,
getData: jest.fn(key => {
if (key === 'owner') return { playerId: 'player1' };
return null;
})
};
target = {
x: 100,
y: 0,
isDead: jest.fn(() => false),
getData: jest.fn(key => {
if (key === 'owner') return { playerId: 'player2' };
return null;
})
};
});
it('should return false for friendly fire', () => {
attacker.getData = jest.fn(() => ({ playerId: 'player1' }));
target.getData = jest.fn(() => ({ playerId: 'player1' }));
const result = combat.canHit(attacker, target);
expect(result.canHit).toBe(false);
expect(result.reason).toBe('friendly_fire');
});
it('should return false for dead target', () => {
target.isDead = jest.fn(() => true);
const result = combat.canHit(attacker, target);
expect(result.canHit).toBe(false);
expect(result.reason).toBe('target_dead');
});
it('should return false when out of range', () => {
target.x = 500; // Beyond default 200 range
const result = combat.canHit(attacker, target);
expect(result.canHit).toBe(false);
expect(result.reason).toBe('out_of_range');
});
it('should return true when all conditions met', () => {
combat.hasLineOfSight = jest.fn(() => true);
const result = combat.canHit(attacker, target);
expect(result.canHit).toBe(true);
});
});
describe('applyDamage', () => {
it('should apply damage with armor reduction', () => {
const entity = {
getData: jest.fn(key => {
if (key === 'health') return { maxHp: 100, current: 100, armor: 5 };
return null;
}),
setData: jest.fn()
};
const damage = combat.applyDamage(entity, 20, 'rifle');
expect(damage).toBeLessThanOrEqual(15); // 20 - 5 armor
expect(entity.setData).toHaveBeenCalledWith('health', expect.any(Number));
});
it('should apply minimum 1 damage', () => {
const entity = {
getData: jest.fn(key => ({ maxHp: 100, current: 100, armor: 50 })),
setData: jest.fn()
};
const damage = combat.applyDamage(entity, 10, 'rifle');
expect(damage).toBeGreaterThanOrEqual(1);
});
it('should apply critical hit multiplier', () => {
const entity = {
getData: jest.fn(key => ({ maxHp: 100, current: 100, armor: 0 })),
setData: jest.fn()
};
// Mock crit roll to succeed
combat.damageModifiers = {
rifle: { critChance: 1.0, critMultiplier: 2.0 } // Always crit
};
const damage = combat.applyDamage(entity, 20, 'rifle');
expect(damage).toBe(40); // 20 * 2.0 crit multiplier
});
});
});

151
tests/EconomySystem.test.js Normal file
View File

@@ -0,0 +1,151 @@
/**
* EconomySystem Unit Tests
*/
import EconomySystem from '../src/systems/EconomySystem';
// Mock Phaser Scene
const createMockScene = () => ({
events: {
emit: jest.fn(),
on: jest.fn()
}
});
describe('EconomySystem', () => {
let scene;
let economy;
beforeEach(() => {
scene = createMockScene();
economy = new EconomySystem(scene);
});
describe('initPlayer', () => {
it('should initialize player with default resources', () => {
economy.initPlayer('player1');
const resources = economy.getResources('player1');
expect(resources.fuel).toBe(100);
expect(resources.ammo).toBe(100);
expect(resources.capturePoints).toBe(0);
});
it('should initialize player with custom resources', () => {
economy.initPlayer('player1', { fuel: 200, ammo: 50, capturePoints: 10 });
const resources = economy.getResources('player1');
expect(resources.fuel).toBe(200);
expect(resources.ammo).toBe(50);
expect(resources.capturePoints).toBe(10);
});
});
describe('canAfford', () => {
beforeEach(() => {
economy.initPlayer('player1', { fuel: 100, ammo: 50 });
});
it('should return true when player has enough resources', () => {
expect(economy.canAfford('player1', { fuel: 50, ammo: 25 })).toBe(true);
});
it('should return false when player lacks fuel', () => {
expect(economy.canAfford('player1', { fuel: 150, ammo: 25 })).toBe(false);
});
it('should return false when player lacks ammo', () => {
expect(economy.canAfford('player1', { fuel: 50, ammo: 100 })).toBe(false);
});
it('should return false for non-existent player', () => {
expect(economy.canAfford('player2', { fuel: 10 })).toBe(false);
});
});
describe('deduct', () => {
beforeEach(() => {
economy.initPlayer('player1', { fuel: 100, ammo: 50 });
});
it('should deduct resources and return true', () => {
const result = economy.deduct('player1', { fuel: 30, ammo: 20 });
const resources = economy.getResources('player1');
expect(result).toBe(true);
expect(resources.fuel).toBe(70);
expect(resources.ammo).toBe(30);
});
it('should not deduct and return false when insufficient resources', () => {
const result = economy.deduct('player1', { fuel: 150, ammo: 20 });
const resources = economy.getResources('player1');
expect(result).toBe(false);
expect(resources.fuel).toBe(100); // Unchanged
expect(resources.ammo).toBe(50); // Unchanged
});
it('should emit economy:purchaseFailed on insufficient resources', () => {
economy.deduct('player1', { fuel: 150, ammo: 20 });
expect(scene.events.emit).toHaveBeenCalledWith(
'economy:purchaseFailed',
expect.objectContaining({ playerId: 'player1', reason: expect.any(String) })
);
});
});
describe('addIncome', () => {
it('should add income to player resources', () => {
economy.initPlayer('player1', { fuel: 100, ammo: 50 });
economy.addIncome('player1', { fuel: 25, ammo: 10, capturePoints: 5 });
const resources = economy.getResources('player1');
expect(resources.fuel).toBe(125);
expect(resources.ammo).toBe(60);
expect(resources.capturePoints).toBe(5);
});
it('should auto-initialize player if not exists', () => {
economy.addIncome('player2', { fuel: 50 });
const resources = economy.getResources('player2');
expect(resources.fuel).toBe(50);
});
it('should emit economy:incomeReceived and economy:updated', () => {
economy.initPlayer('player1');
economy.addIncome('player1', { fuel: 10 });
expect(scene.events.emit).toHaveBeenCalledWith(
'economy:incomeReceived',
expect.objectContaining({ playerId: 'player1' })
);
expect(scene.events.emit).toHaveBeenCalledWith(
'economy:updated',
expect.objectContaining({ playerId: 'player1' })
);
});
});
describe('update', () => {
it('should call addIncome every 1000ms', () => {
const addIncomeSpy = jest.spyOn(economy, 'addIncome');
// First call at 1000ms
economy.update(1000, 1000);
expect(addIncomeSpy).toHaveBeenCalled();
// Second call at 2000ms
economy.update(2000, 1000);
expect(addIncomeSpy).toHaveBeenCalledTimes(2);
});
it('should not call addIncome before 1000ms', () => {
const addIncomeSpy = jest.spyOn(economy, 'addIncome');
economy.update(500, 500);
expect(addIncomeSpy).not.toHaveBeenCalled();
});
});
});

280
tests/Unit.test.js Normal file
View File

@@ -0,0 +1,280 @@
/**
* Unit Entity Unit Tests
*/
jest.mock('Systems/EntityStateMachine', () => ({
forEntity: jest.fn(() => ({
tick: jest.fn(),
send: jest.fn(),
destroy: jest.fn(),
getState: jest.fn(() => 'IDLING')
}))
}));
import Unit from '../src/entities/Unit';
import EntityStateMachine from 'Systems/EntityStateMachine';
const createMockScene = () => ({
add: {
existing: jest.fn()
},
physics: {
world: {
enableBody: jest.fn()
}
},
interface: {
generateWorldXY: jest.fn(tile => ({ x: tile.x * 64, y: tile.y * 64 }))
},
orchestrator: {
systems: {
EntityStateMachine: { forEntity: jest.fn() },
combat: { fireProjectile: jest.fn() },
pathfinding: { findPath: jest.fn() },
selection: { add: jest.fn() }
}
},
events: {
emit: jest.fn()
},
tweens: {
addCounter: jest.fn(() => ({ stop: jest.fn() }))
}
});
describe('Unit', () => {
let scene;
let unit;
beforeEach(() => {
scene = createMockScene();
unit = new Unit(scene, 'tank_texture', { x: 5, y: 5 }, {
maxHp: 100,
armor: 5,
playerId: 'player1',
team: 'good',
weaponRange: 200,
damage: 25
});
});
describe('Component Access', () => {
it('should have health component', () => {
const health = unit.getComponent('health');
expect(health.maxHp).toBe(100);
expect(health.current).toBe(100);
expect(health.armor).toBe(5);
});
it('should have owner component', () => {
const owner = unit.getComponent('owner');
expect(owner.playerId).toBe('player1');
expect(owner.team).toBe('good');
});
it('should have combat component', () => {
const combat = unit.getComponent('combat');
expect(combat.weaponRange).toBe(200);
expect(combat.damage).toBe(25);
});
it('should update component with setComponent', () => {
unit.setComponent('health', { current: 50 });
const health = unit.getComponent('health');
expect(health.current).toBe(50);
expect(health.maxHp).toBe(100); // Unchanged
});
});
describe('Damage System', () => {
it('should apply damage with armor reduction', () => {
const damageTaken = unit.damage(30, 'rifle');
expect(damageTaken).toBeLessThanOrEqual(25); // 30 - 5 armor
expect(unit.getComponent('health').current).toBeLessThan(100);
});
it('should apply minimum 1 damage', () => {
const damageTaken = unit.damage(2, 'rifle');
expect(damageTaken).toBeGreaterThanOrEqual(1);
});
it('should emit unit:damaged event', () => {
unit.damage(20);
expect(scene.events.emit).toHaveBeenCalledWith(
'unit:damaged',
expect.objectContaining({
unit: unit,
amount: expect.any(Number),
remaining: expect.any(Number)
})
);
});
it('should mark unit as dead when health reaches 0', () => {
unit.getComponent('health').current = 5;
unit.damage(10);
expect(unit.dead).toBe(true);
});
it('should not damage if already dead', () => {
unit.dead = true;
const damageTaken = unit.damage(50);
expect(damageTaken).toBe(0);
});
});
describe('Heal System', () => {
it('should heal unit', () => {
unit.damage(30);
const healed = unit.heal(20);
expect(healed).toBe(20);
expect(unit.getComponent('health').current).toBe(90);
});
it('should not exceed max HP', () => {
const healed = unit.heal(50);
expect(unit.getComponent('health').current).toBe(100); // Capped at max
});
});
describe('Combat', () => {
let target;
beforeEach(() => {
target = {
x: 150,
y: 0,
isDead: jest.fn(() => false),
getData: jest.fn(() => ({ playerId: 'enemy' }))
};
});
it('should return true when target in range', () => {
expect(unit.canHitBody(target)).toBe(true);
});
it('should return false when target out of range', () => {
target.x = 300; // Beyond 200 range
expect(unit.canHitBody(target)).toBe(false);
});
it('should return false when target is dead', () => {
target.isDead = jest.fn(() => true);
expect(unit.canHitBody(target)).toBe(false);
});
it('should attack target when in range', () => {
const result = unit.attackTarget(target);
expect(result).toBe(true);
expect(scene.orchestrator.systems.combat.fireProjectile).toHaveBeenCalled();
});
it('should not attack when target out of range', () => {
target.x = 300;
const result = unit.attackTarget(target);
expect(result).toBe(false);
expect(scene.orchestrator.systems.combat.fireProjectile).not.toHaveBeenCalled();
});
it('should respect fire rate', () => {
unit.attackTarget(target);
const result2 = unit.attackTarget(target);
// Second attack should fail due to fire rate cooldown
expect(result2).toBe(false);
});
});
describe('Selection', () => {
it('should select unit', () => {
unit.select();
expect(unit.getData('selected')).toBe(true);
expect(scene.tweens.addCounter).toHaveBeenCalled();
});
it('should unselect unit', () => {
unit.select();
unit.unSelect();
expect(unit.getData('selected')).toBe(false);
});
it('should tint based on team', () => {
unit.select();
expect(unit.setTint).toHaveBeenCalled();
});
});
describe('Movement', () => {
it('should move to tile', () => {
const result = unit.moveToTile({ x: 10, y: 10 });
expect(result).toBe(true);
expect(unit.setPosition).toHaveBeenCalledWith(640, 640); // 10 * 64
});
it('should set target tile data', () => {
unit.moveToTile({ x: 10, y: 10 });
expect(unit.getData('targetTile')).toEqual({ x: 10, y: 10 });
});
it('should orient to target', () => {
const target = { x: 100, y: 0 };
unit.orientToTarget(target);
expect(unit.setFlipX).toHaveBeenCalledWith(true); // EAST direction
});
});
describe('State Machine', () => {
it('should initialize state machine', () => {
expect(unit.stateMachine).toBeDefined();
expect(scene.orchestrator.systems.EntityStateMachine.forEntity).toHaveBeenCalled();
});
it('should tick state machine in preUpdate', () => {
const tickSpy = jest.spyOn(unit.stateMachine, 'tick');
unit.preUpdate(Date.now(), 16);
expect(tickSpy).toHaveBeenCalled();
});
});
describe('Death', () => {
it('should trigger death when health reaches 0', () => {
const dieSpy = jest.spyOn(unit.stateMachine, 'send');
unit.getComponent('health').current = 5;
unit.damage(10);
expect(dieSpy).toHaveBeenCalledWith('DIE');
expect(scene.events.emit).toHaveBeenCalledWith('unit:dying', expect.anything());
});
it('should cleanup on destroy', () => {
unit.pulse = { stop: jest.fn() };
const destroySpy = jest.spyOn(unit.stateMachine, 'destroy');
unit.destroy();
expect(unit.pulse.stop).toHaveBeenCalled();
expect(destroySpy).toHaveBeenCalled();
});
});
});

123
tests/setup.js Normal file
View File

@@ -0,0 +1,123 @@
/**
* Jest Setup - Mock Phaser and browser APIs
*/
// Mock Phaser BEFORE it's imported
jest.mock('phaser', () => ({
Physics: {
Arcade: {
DYNAMIC_BODY: 0,
Sprite: class MockSprite {
constructor(scene, x, y, texture) {
this.scene = scene;
this.x = x;
this.y = y;
this.texture = texture;
this.body = {
allowGravity: false,
setSize: jest.fn(),
setOffset: jest.fn()
};
this.setScale = jest.fn();
this.setInteractive = jest.fn();
this.on = jest.fn();
this.setPosition = jest.fn(() => true);
this.setFlipX = jest.fn();
this.setTint = jest.fn();
this.clearTint = jest.fn();
this.setData = jest.fn();
this.getData = jest.fn(() => null);
this.pulse = null;
}
static enable(scene, object) {
object.body = { allowGravity: false };
}
}
}
},
Math: {
Angle: {
BetweenPoints: (a, b) => Math.atan2(b.y - a.y, b.x - a.x)
},
RadToDeg: rad => rad * (180 / Math.PI),
Distance: {
BetweenPoints: (a, b) => Math.sqrt(Math.pow(b.x - a.x, 2) + Math.pow(b.y - a.y, 2))
},
Clamp: (v, min, max) => Math.max(min, Math.min(max, v))
},
Display: {
Color: {
GetColor32: (r, g, b, a) => (r << 24) | (g << 16) | (b << 8) | a
}
},
Tweens: {
Tween: class MockTween {
constructor(config) {
this.config = config;
}
getValue() { return 200; }
stop() {}
},
addCounter: config => ({
getValue: () => 200,
stop: () => {}
})
},
Events: {
EventEmitter: class MockEventEmitter {
constructor() {
this.listeners = {};
}
on(event, fn) {
if (!this.listeners[event]) this.listeners[event] = [];
this.listeners[event].push(fn);
}
emit(event, ...args) {
if (this.listeners[event]) {
this.listeners[event].forEach(fn => fn(...args));
}
}
}
},
GameObjects: {
Zone: class MockZone {
constructor(scene, x, y, width, height) {
this.scene = scene;
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.body = { setCircle: jest.fn(), checkCollision: { none: false } };
}
destroy() {}
}
}
}));
// Mock XState
jest.mock('xstate', () => ({
createMachine: jest.fn(config => ({ config })),
interpret: jest.fn(machine => ({
machine,
start: jest.fn(),
send: jest.fn(),
stop: jest.fn(),
state: { value: 'IDLING' }
})),
assign: jest.fn(fn => fn)
}));
// Mock EasyStar
jest.mock('easystarjs', () => {
return jest.fn().mockImplementation(() => ({
setGrid: jest.fn(),
setIterationsPerCalculation: jest.fn(),
findPath: jest.fn((x, y, toX, toY, callback) => {
setTimeout(() => callback([{ x, y }, { x: toX, y: toY }]), 0);
}),
setTileAtXY: jest.fn()
}));
});
// Suppress console errors during tests
console.error = jest.fn();

View File

@@ -0,0 +1,429 @@
/**
* CombatSystem.test.js — Tests for acquireTarget, canHit, applyDamage, and projectile logic.
*/
// Mock Phaser
const mockOverlap = jest.fn();
const mockVelocityFromAngle = jest.fn();
jest.mock('phaser', () => ({
Math: {
Distance: {
Between: jest.fn((x1, y1, x2, y2) => Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)),
},
Angle: {
Between: jest.fn(() => 0),
BetweenPoints: jest.fn(() => 0),
Wrap: jest.fn((angle) => angle),
},
DegToRad: jest.fn((deg) => deg * Math.PI / 180),
RadToDeg: jest.fn((rad) => rad * 180 / Math.PI),
},
Physics: {
Arcade: {
DYNAMIC_BODY: 0,
},
},
Display: {
Color: {
GetColor32: jest.fn(() => 0xffff00),
},
},
GameObjects: {
Sprite: class {},
Rectangle: class {},
Graphics: class {},
Container: class {},
Zone: class {},
},
Geom: {
Rectangle: class {
constructor(x, y, w, h) {
this.x = x;
this.y = y;
this.width = w;
this.height = h;
}
},
},
}));
import CombatSystem from 'Systems/CombatSystem.js';
// Helper to create a mock entity
function mockEntity(x, y, overrides = {}) {
const entity = {
x,
y,
rotation: 0,
active: true,
dead: false,
body: {
center: { x, y },
velocity: { x: 0, y: 0 },
allowGravity: false,
},
parentContainer: {
name: overrides.containerName || 'Good Guys',
},
getData: jest.fn((key) => {
if (key === 'health') return 100;
if (key === 'armor') return 1;
return undefined;
}),
setData: jest.fn(),
emit: jest.fn(),
select: jest.fn(),
unSelect: jest.fn(),
isDead: jest.fn(() => false),
handleDeath: jest.fn(),
handleTakeDamage: jest.fn(),
getEnemyContainer: jest.fn(),
...overrides,
};
// Handle data store
const dataStore = { health: 100, armor: 1, ...overrides._data };
entity.getData.mockImplementation((key) => {
if (key === 'health') return entity._data?.health ?? dataStore.health ?? 100;
if (key === 'armor') return entity._data?.armor ?? dataStore.armor ?? 1;
return entity._data?.[key] ?? dataStore[key];
});
entity.setData.mockImplementation((key, value) => {
if (!entity._data) entity._data = { ...dataStore };
entity._data[key] = value;
});
entity._data = { ...dataStore };
return entity;
}
describe('CombatSystem', () => {
let combat;
let mockScene;
beforeEach(() => {
jest.clearAllMocks();
mockScene = {
events: { on: jest.fn(), emit: jest.fn(), off: jest.fn() },
physics: {
add: { group: jest.fn(() => ({ getChildren: () => [], create: jest.fn() })) },
world: { enableBody: jest.fn() },
overlap: jest.fn(() => false),
velocityFromAngle: mockVelocityFromAngle,
},
add: { rectangle: jest.fn(() => ({ setDepth: jest.fn() })) },
textures: { exists: jest.fn(() => false) },
tweens: { addCounter: jest.fn(() => ({ stop: jest.fn() })) },
};
combat = new CombatSystem(mockScene);
});
// ── constructor ─────────────────────────────────────────────────
describe('constructor', () => {
test('initializes projectiles group and damage modifiers', () => {
expect(combat.projectiles).toBeDefined();
expect(combat.damageModifiers).toBeDefined();
expect(combat.damageModifiers.default).toBeDefined();
expect(combat.damageModifiers.rifle).toBeDefined();
expect(combat.damageModifiers.cannon).toBeDefined();
expect(combat.damageModifiers.tank_cannon).toBeDefined();
});
test('_goodGuys and _enemies start null', () => {
expect(combat._goodGuys).toBeNull();
expect(combat._enemies).toBeNull();
});
});
// ── acquireTarget ───────────────────────────────────────────────
describe('acquireTarget', () => {
let friendlies, enemies;
beforeEach(() => {
friendlies = { name: 'Good Guys', list: [], getAll: jest.fn(() => []) };
enemies = { name: 'Bad Guys', list: [], getAll: jest.fn(() => []) };
});
test('returns null when enemy container has no units', () => {
const entity = mockEntity(100, 100, {
getEnemyContainer: () => ({ list: [], getAll: () => [] }),
});
expect(combat.acquireTarget(entity)).toBeNull();
});
test('returns null when all enemies are dead', () => {
const entity = mockEntity(100, 100, {
getEnemyContainer: () => ({
list: [{ dead: true }],
getAll: () => [],
}),
});
expect(combat.acquireTarget(entity)).toBeNull();
});
test('finds closest enemy within range', () => {
const target1 = mockEntity(120, 100, { containerName: 'Bad Guys' });
const target2 = mockEntity(200, 100, { containerName: 'Bad Guys' });
// Mock LoS to always return true for this test
const originalLos = combat.hasLineOfSight;
combat.hasLineOfSight = jest.fn(() => true);
const entity = mockEntity(100, 100, {
getEnemyContainer: () => ({
list: [target1, target2],
getAll: () => [target1, target2],
}),
});
const result = combat.acquireTarget(entity);
expect(result).toBe(target1); // closest
combat.hasLineOfSight = originalLos;
});
test('filters by fov cone', () => {
const target = mockEntity(200, 100, { containerName: 'Bad Guys' });
const entity = mockEntity(100, 100, {
rotation: 0,
getEnemyContainer: () => ({
list: [target],
getAll: () => [target],
}),
});
// With narrow FOV, entity facing 0 and target straight ahead should work
const originalLos = combat.hasLineOfSight;
combat.hasLineOfSight = jest.fn(() => true);
const result = combat.acquireTarget(entity, { fov: 90 });
expect(result).toBe(target);
combat.hasLineOfSight = originalLos;
});
test('prioritizes weakest when specified', () => {
const strong = mockEntity(120, 100, { containerName: 'Bad Guys' });
const weak = mockEntity(115, 100, { containerName: 'Bad Guys' });
strong._data = { health: 80 };
weak._data = { health: 20 };
const entity = mockEntity(100, 100, {
getEnemyContainer: () => ({
list: [strong, weak],
getAll: () => [strong, weak],
}),
});
const originalLos = combat.hasLineOfSight;
combat.hasLineOfSight = jest.fn(() => true);
const result = combat.acquireTarget(entity, { priority: 'weakest' });
expect(result).toBe(weak);
combat.hasLineOfSight = originalLos;
});
test('returns null for null enemy container', () => {
const entity = mockEntity(100, 100, { getEnemyContainer: () => null });
expect(combat.acquireTarget(entity)).toBeNull();
});
});
// ── canHit ──────────────────────────────────────────────────────
describe('canHit', () => {
test('returns false for null entities', () => {
expect(combat.canHit(null, mockEntity(0, 0))).toEqual({ canHit: false, reason: 'invalid_entities' });
});
test('returns false for friendly fire (same container)', () => {
const attacker = mockEntity(0, 0, { containerName: 'Good Guys' });
const target = mockEntity(10, 10, { containerName: 'Good Guys' });
expect(combat.canHit(attacker, target)).toEqual({ canHit: false, reason: 'friendly_fire' });
});
test('returns false for dead target', () => {
const attacker = mockEntity(0, 0, { containerName: 'Good Guys' });
const target = mockEntity(10, 10, {
containerName: 'Bad Guys',
dead: true,
});
expect(combat.canHit(attacker, target)).toEqual({ canHit: false, reason: 'target_dead' });
});
test('returns false when target is out of range', () => {
const attacker = mockEntity(0, 0, { containerName: 'Good Guys' });
const target = mockEntity(2000, 2000, { containerName: 'Bad Guys' });
// distance ~2828, default range 150
const originalLos = combat.hasLineOfSight;
combat.hasLineOfSight = jest.fn(() => false);
const result = combat.canHit(attacker, target);
expect(result.canHit).toBe(false);
expect(result.reason).toBe('out_of_range');
combat.hasLineOfSight = originalLos;
});
});
// ── applyDamage ─────────────────────────────────────────────────
describe('applyDamage', () => {
test('deals damage reducing health', () => {
const entity = mockEntity(0, 0);
entity._data = { health: 100, armor: 1 };
const dealt = combat.applyDamage(entity, 20);
expect(dealt).toBeGreaterThan(0);
expect(entity.emit).toHaveBeenCalledWith('combat:damaged', expect.any(Object));
});
test('returns 0 for dead entity', () => {
const entity = mockEntity(0, 0);
entity.dead = true;
expect(combat.applyDamage(entity, 20)).toBe(0);
});
test('armor reduces damage taken', () => {
const entity = mockEntity(0, 0);
entity._data = { health: 100, armor: 5 };
const dealt = combat.applyDamage(entity, 20);
// effectiveArmor = 5 * (1 - 0) = 5; damage = max(1, round(20 - 5)) = 15
expect(dealt).toBe(15);
});
test('deals at least 1 damage', () => {
const entity = mockEntity(0, 0);
entity._data = { health: 100, armor: 1000 };
const dealt = combat.applyDamage(entity, 1);
expect(dealt).toBeGreaterThanOrEqual(1);
});
test('calls handleDeath when health drops to 0', () => {
const entity = mockEntity(0, 0);
entity._data = { health: 100, armor: 1 };
entity.handleDeath = jest.fn();
combat.applyDamage(entity, 999);
expect(entity.handleDeath).toHaveBeenCalled();
});
test('emits combat:unitDamaged on scene', () => {
const entity = mockEntity(0, 0);
entity._data = { health: 100, armor: 1 };
combat.applyDamage(entity, 10);
expect(mockScene.events.emit).toHaveBeenCalledWith('combat:unitDamaged', expect.any(Object));
});
});
// ── fireProjectile & projectile management ──────────────────────
describe('fireProjectile', () => {
test('returns null for invalid entities', () => {
expect(combat.fireProjectile(null, mockEntity(0, 0))).toBeNull();
});
test('creates a fallback rectangle when no sprite texture', () => {
const attacker = mockEntity(0, 0);
const target = mockEntity(100, 0, { containerName: 'Bad Guys' });
const proj = combat.fireProjectile(attacker, target);
expect(proj).toBeDefined();
expect(mockScene.add.rectangle).toHaveBeenCalled();
});
test('sets projectile data (damage, damageType, attacker, target)', () => {
const attacker = mockEntity(0, 0);
const target = mockEntity(100, 0, { containerName: 'Bad Guys' });
const proj = combat.fireProjectile(attacker, target, { damageType: 'cannon' });
expect(proj).toBeDefined();
});
});
// ── registerUnitContainers ──────────────────────────────────────
describe('registerUnitContainers', () => {
test('stores goodGuys and enemies references', () => {
const goodGuys = { name: 'Good Guys' };
const enemies = { name: 'Bad Guys' };
combat.registerUnitContainers(goodGuys, enemies);
expect(combat._goodGuys).toBe(goodGuys);
expect(combat._enemies).toBe(enemies);
});
});
// ── update loop ─────────────────────────────────────────────────
describe('update', () => {
test('handles empty projectile group', () => {
expect(() => combat.update(0, 16)).not.toThrow();
});
test('destroys expired projectiles', () => {
const destroySpy = jest.fn();
const expired = {
active: true,
getData: jest.fn((key) => {
if (key === 'elapsed') return 5000;
if (key === 'lifespan') return 4000;
return null;
}),
setData: jest.fn(),
destroy: destroySpy,
};
combat.projectiles = {
getChildren: () => [expired],
};
combat.update(0, 16);
expect(destroySpy).toHaveBeenCalled();
});
test('destroys inactive projectiles', () => {
const destroySpy = jest.fn();
const inactive = {
active: false,
getData: jest.fn(),
setData: jest.fn(),
destroy: destroySpy,
};
combat.projectiles = {
getChildren: () => [inactive],
};
combat.update(0, 16);
expect(destroySpy).toHaveBeenCalled();
});
});
// ── hasLineOfSight ──────────────────────────────────────────────
describe('hasLineOfSight', () => {
test('returns true when no rockLayer', () => {
combat.scene.rockLayer = undefined;
expect(combat.hasLineOfSight({ x: 0, y: 0 }, { x: 100, y: 100 })).toBe(true);
});
test('returns true when worldToTileXY returns null', () => {
combat.scene.rockLayer = {
worldToTileXY: () => null,
};
expect(combat.hasLineOfSight({ x: 0, y: 0 }, { x: 100, y: 100 })).toBe(true);
});
});
});

View File

@@ -0,0 +1,247 @@
/**
* EconomySystem.test.js — Tests for resource tracking, purchase validation, and income.
*/
// Mock Phaser before importing the system
jest.mock('phaser', () => {
class EventEmitter {
constructor() {
this._listeners = {};
}
on(event, fn) {
if (!this._listeners[event]) this._listeners[event] = [];
this._listeners[event].push(fn);
}
emit(event, ...args) {
const fns = this._listeners[event] || [];
fns.forEach((fn) => fn(...args));
}
destroy() {
this._listeners = {};
}
}
return { Events: { EventEmitter }, GameObjects: { Zone: class {} }, Scene: class {} };
});
import EconomySystem from 'Systems/EconomySystem.js';
describe('EconomySystem', () => {
let economy;
let mockScene;
beforeEach(() => {
mockScene = {
events: { on: jest.fn(), emit: jest.fn(), off: jest.fn() },
};
economy = new EconomySystem(mockScene);
});
afterEach(() => {
economy.destroy();
});
// ── initPlayer ───────────────────────────────────────────────────
describe('initPlayer', () => {
test('registers a player with default resources', () => {
economy.initPlayer('p1');
const res = economy.getResources('p1');
expect(res).toBeDefined();
expect(res.fuel).toBe(100);
expect(res.ammo).toBe(100);
expect(res.capturePoints).toBe(0);
});
test('registers a player with custom starting resources', () => {
economy.initPlayer('p2', { fuel: 50, ammo: 25, capturePoints: 5 });
const res = economy.getResources('p2');
expect(res.fuel).toBe(50);
expect(res.ammo).toBe(25);
expect(res.capturePoints).toBe(5);
});
test('emits economy:updated on registration', () => {
const spy = jest.fn();
economy.events.on('economy:updated', spy);
economy.initPlayer('p3');
expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0][0].playerId).toBe('p3');
});
test('partial defaults fill missing keys', () => {
economy.initPlayer('p4', { fuel: 200 });
const res = economy.getResources('p4');
expect(res.fuel).toBe(200);
expect(res.ammo).toBe(100); // default
expect(res.capturePoints).toBe(0); // default
});
});
// ── getResources ─────────────────────────────────────────────────
describe('getResources', () => {
test('returns undefined for unregistered player', () => {
expect(economy.getResources('unknown')).toBeUndefined();
});
test('returns a snapshot of the resource object', () => {
economy.initPlayer('p5');
const res = economy.getResources('p5');
res.fuel = 999;
// getResources returns the same mutable object; this is by design
expect(economy.getResources('p5').fuel).toBe(999);
});
});
// ── canAfford ────────────────────────────────────────────────────
describe('canAfford', () => {
beforeEach(() => {
economy.initPlayer('p_rich', { fuel: 100, ammo: 100 });
economy.initPlayer('p_poor', { fuel: 5, ammo: 5 });
});
test('returns true when player has enough resources', () => {
expect(economy.canAfford('p_rich', { fuel: 50, ammo: 50 })).toBe(true);
expect(economy.canAfford('p_rich', { fuel: 100 })).toBe(true);
expect(economy.canAfford('p_rich', { ammo: 1 })).toBe(true);
});
test('returns false when player lacks fuel', () => {
expect(economy.canAfford('p_poor', { fuel: 10 })).toBe(false);
});
test('returns false when player lacks ammo', () => {
expect(economy.canAfford('p_poor', { ammo: 10 })).toBe(false);
});
test('returns false for unknown player', () => {
expect(economy.canAfford('nobody', { fuel: 1 })).toBe(false);
});
test('null/undefined cost keys are treated as free', () => {
expect(economy.canAfford('p_rich', {})).toBe(true);
expect(economy.canAfford('p_rich', { fuel: null })).toBe(true);
});
test('emits economy:purchaseFailed on failure', () => {
const spy = jest.fn();
economy.events.on('economy:purchaseFailed', spy);
economy.canAfford('nobody', { fuel: 5 });
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({ reason: 'player_not_found' }),
);
spy.mockClear();
economy.canAfford('p_poor', { fuel: 100 });
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({ reason: 'insufficient_resources' }),
);
});
});
// ── deduct ───────────────────────────────────────────────────────
describe('deduct', () => {
beforeEach(() => {
economy.initPlayer('p_deduct', { fuel: 100, ammo: 100 });
});
test('deducts fuel and ammo correctly', () => {
const result = economy.deduct('p_deduct', { fuel: 30, ammo: 20 });
expect(result).toBe(true);
expect(economy.getResources('p_deduct').fuel).toBe(70);
expect(economy.getResources('p_deduct').ammo).toBe(80);
});
test('does not change resources on insufficient funds', () => {
const result = economy.deduct('p_deduct', { fuel: 200 });
expect(result).toBe(false);
expect(economy.getResources('p_deduct').fuel).toBe(100);
});
test('emits economy:updated on success', () => {
const spy = jest.fn();
economy.events.on('economy:updated', spy);
economy.deduct('p_deduct', { fuel: 10 });
expect(spy).toHaveBeenCalledTimes(1);
});
test('partial deduct works (only fuel)', () => {
economy.deduct('p_deduct', { fuel: 50 });
expect(economy.getResources('p_deduct').fuel).toBe(50);
expect(economy.getResources('p_deduct').ammo).toBe(100); // unchanged
});
});
// ── addIncome ────────────────────────────────────────────────────
describe('addIncome', () => {
test('adds income to existing player', () => {
economy.initPlayer('p_income', { fuel: 10, ammo: 10, capturePoints: 0 });
economy.addIncome('p_income', { fuel: 5, ammo: 3, capturePoints: 2 });
const res = economy.getResources('p_income');
expect(res.fuel).toBe(15);
expect(res.ammo).toBe(13);
expect(res.capturePoints).toBe(2);
});
test('auto-initialises player if not yet registered', () => {
economy.addIncome('p_new', { fuel: 50, ammo: 50 });
const res = economy.getResources('p_new');
expect(res).toBeDefined();
expect(res.fuel).toBe(150); // default 100 + 50
expect(res.ammo).toBe(150); // default 100 + 50
});
test('emits economy:incomeReceived and economy:updated', () => {
economy.initPlayer('p_events', { fuel: 0 });
const incomeSpy = jest.fn();
const updatedSpy = jest.fn();
economy.events.on('economy:incomeReceived', incomeSpy);
economy.events.on('economy:updated', updatedSpy);
economy.addIncome('p_events', { fuel: 10 });
expect(incomeSpy).toHaveBeenCalledTimes(1);
expect(updatedSpy).toHaveBeenCalledTimes(1);
});
test('skips null fields gracefully', () => {
economy.initPlayer('p_nullcheck', { fuel: 5, ammo: 5, capturePoints: 0 });
economy.addIncome('p_nullcheck', { fuel: null, ammo: undefined });
const res = economy.getResources('p_nullcheck');
expect(res.fuel).toBe(5);
expect(res.ammo).toBe(5);
});
});
// ── update loop ──────────────────────────────────────────────────
describe('update', () => {
test('does not throw when called', () => {
expect(() => economy.update(500)).not.toThrow();
});
test('_lastTick advances after enough time', () => {
economy.update(0);
expect(economy._lastTick).toBe(0);
economy.update(1000);
expect(economy._lastTick).toBe(1000);
});
test('_lastTick does not advance within same tick interval', () => {
economy.update(100);
expect(economy._lastTick).toBe(0);
economy.update(500);
expect(economy._lastTick).toBe(0);
});
});
// ── destroy ──────────────────────────────────────────────────────
describe('destroy', () => {
test('clears all players and event listeners', () => {
economy.initPlayer('p1');
economy.initPlayer('p2');
expect(economy.players.size).toBe(2);
economy.destroy();
expect(economy.players.size).toBe(0);
});
});
});

View File

@@ -0,0 +1,234 @@
/**
* EntityStateMachine.test.js — Tests for state transitions, event sending, lifecycle.
*/
import EntityStateMachine from 'Systems/EntityStateMachine.js';
describe('EntityStateMachine', () => {
let mockEntity;
let machineConfig;
beforeEach(() => {
mockEntity = {
name: 'testEntity',
x: 100,
y: 200,
rotation: 0,
};
machineConfig = {
id: 'entity',
initial: 'IDLING',
context: {},
states: {
IDLING: {
entry: ['playIdleAnim'],
on: { MOVE: 'MOVING', DIE: 'DYING' },
},
MOVING: {
entry: ['playMoveAnim'],
on: { ARRIVED: 'IDLING', DIE: 'DYING' },
},
DYING: {
type: 'final',
},
},
};
});
// ── Constructor ─────────────────────────────────────────────────
describe('constructor', () => {
test('stores entity and machine config', () => {
const esm = new EntityStateMachine(mockEntity, machineConfig);
expect(esm.entity).toBe(mockEntity);
expect(esm.machineConfig).toBe(machineConfig);
expect(esm.service).toBeNull();
});
test('default initial state is IDLING', () => {
const esm = new EntityStateMachine(mockEntity, machineConfig);
expect(esm._currentState).toBe('IDLING');
expect(esm.getState()).toBe('IDLING');
});
});
// ── send ────────────────────────────────────────────────────────
describe('send', () => {
test('sends event to service when available', () => {
const esm = new EntityStateMachine(mockEntity, machineConfig);
const mockSend = jest.fn();
esm.service = {
send: mockSend,
state: { value: 'IDLING' },
};
esm.send('MOVE');
expect(mockSend).toHaveBeenCalledWith({ type: 'MOVE' });
});
test('sends event with context', () => {
const esm = new EntityStateMachine(mockEntity, machineConfig);
const mockSend = jest.fn();
esm.service = {
send: mockSend,
state: { value: 'IDLING' },
};
esm.send('MOVE', { x: 10, y: 20 });
expect(mockSend).toHaveBeenCalledWith({ type: 'MOVE', x: 10, y: 20 });
});
test('does not throw when service is null', () => {
const esm = new EntityStateMachine(mockEntity, machineConfig);
expect(() => esm.send('MOVE')).not.toThrow();
});
test('does not throw when service has no send method', () => {
const esm = new EntityStateMachine(mockEntity, machineConfig);
esm.service = {};
expect(() => esm.send('MOVE')).not.toThrow();
});
});
// ── getState ────────────────────────────────────────────────────
describe('getState', () => {
test('returns service state value when available', () => {
const esm = new EntityStateMachine(mockEntity, machineConfig);
esm.service = {
state: { value: 'MOVING' },
send: jest.fn(),
};
expect(esm.getState()).toBe('MOVING');
});
test('falls back to _currentState when service is null', () => {
const esm = new EntityStateMachine(mockEntity, machineConfig);
esm._currentState = 'ATTACKING';
expect(esm.getState()).toBe('ATTACKING');
});
test('falls back to _currentState when service.state is null', () => {
const esm = new EntityStateMachine(mockEntity, machineConfig);
esm.service = {
state: null,
send: jest.fn(),
};
esm._currentState = 'DYING';
expect(esm.getState()).toBe('DYING');
});
});
// ── state transitions ──────────────────────────────────────────
describe('state transitions', () => {
test('starts in IDLING', () => {
const esm = new EntityStateMachine(mockEntity, machineConfig);
expect(esm.getState()).toBe('IDLING');
});
test('can simulate state changes via send', () => {
const esm = new EntityStateMachine(mockEntity, machineConfig);
const mockSend = jest.fn();
// Simulate a service that tracks state
let currentState = 'IDLING';
const stateMap = {
IDLING: { MOVE: 'MOVING', DIE: 'DYING' },
MOVING: { ARRIVED: 'IDLING', DIE: 'DYING' },
DYING: {},
};
esm.service = {
send: (event) => {
mockSend(event);
const transitions = stateMap[currentState];
if (transitions && transitions[event.type]) {
currentState = transitions[event.type];
}
},
state: { value: currentState },
};
expect(esm.getState()).toBe('IDLING');
esm.send('MOVE');
expect(currentState).toBe('MOVING');
});
});
// ── tick ────────────────────────────────────────────────────────
describe('tick', () => {
test('does not throw when called', () => {
const esm = new EntityStateMachine(mockEntity, machineConfig);
expect(() => esm.tick(1000, 16)).not.toThrow();
});
test('can be called multiple times without side effects', () => {
const esm = new EntityStateMachine(mockEntity, machineConfig);
for (let i = 0; i < 10; i++) {
expect(() => esm.tick(i * 100, 16)).not.toThrow();
}
});
});
// ── destroy ─────────────────────────────────────────────────────
describe('destroy', () => {
test('stops service if it has a stop method', () => {
const esm = new EntityStateMachine(mockEntity, machineConfig);
const mockStop = jest.fn();
esm.service = {
stop: mockStop,
send: jest.fn(),
};
esm.destroy();
expect(mockStop).toHaveBeenCalledTimes(1);
expect(esm.service).toBeNull();
expect(esm.entity).toBeNull();
});
test('handles service without stop method gracefully', () => {
const esm = new EntityStateMachine(mockEntity, machineConfig);
esm.service = { send: jest.fn() };
expect(() => esm.destroy()).not.toThrow();
expect(esm.service).toBeNull();
});
test('handles null service gracefully', () => {
const esm = new EntityStateMachine(mockEntity, machineConfig);
expect(() => esm.destroy()).not.toThrow();
});
});
// ── edge cases ──────────────────────────────────────────────────
describe('edge cases', () => {
test('handles empty machineConfig', () => {
const esm = new EntityStateMachine(mockEntity, {});
expect(esm.machineConfig).toEqual({});
expect(esm.getState()).toBe('IDLING');
});
test('handles rapid send calls', () => {
const esm = new EntityStateMachine(mockEntity, machineConfig);
const events = [];
esm.service = {
send: (e) => events.push(e.type),
state: { value: 'IDLING' },
};
esm.send('MOVE');
esm.send('DIE');
esm.send('ARRIVED');
expect(events).toEqual(['MOVE', 'DIE', 'ARRIVED']);
});
});
});

View File

@@ -0,0 +1,348 @@
/**
* PathfindingSystem.test.js — Tests for pathfinding, grid manipulation, and cache management.
*/
// Mock easystarjs
const mockFindPath = jest.fn();
const mockEasyStarInstance = {
setIterationsPerCalculation: jest.fn(),
enableDiagonals: jest.fn(),
enableCornerCutting: jest.fn(),
setGrid: jest.fn(),
setAcceptableTiles: jest.fn(),
setTileCost: jest.fn(),
setAdditionalPointCost: jest.fn(),
findPath: mockFindPath,
calculate: jest.fn(),
};
jest.mock('easystarjs', () => ({
js: jest.fn(() => mockEasyStarInstance),
TOP: 'TOP',
TOP_RIGHT: 'TOP_RIGHT',
RIGHT: 'RIGHT',
BOTTOM_RIGHT: 'BOTTOM_RIGHT',
BOTTOM: 'BOTTOM',
BOTTOM_LEFT: 'BOTTOM_LEFT',
LEFT: 'LEFT',
TOP_LEFT: 'TOP_LEFT',
}));
import PathfindingSystem from 'Systems/PathfindingSystem.js';
describe('PathfindingSystem', () => {
let pathfinding;
let mockScene;
let mockTilemap;
beforeEach(() => {
jest.clearAllMocks();
mockScene = {
events: { on: jest.fn(), emit: jest.fn(), off: jest.fn() },
};
mockTilemap = {
width: 10,
height: 10,
getLayer: jest.fn(),
};
pathfinding = new PathfindingSystem(mockScene, mockTilemap);
});
// ── Constructor defaults ───────────────────────────────────────
describe('constructor', () => {
test('initializes with correct defaults', () => {
expect(pathfinding.scene).toBe(mockScene);
expect(pathfinding.tilemap).toBe(mockTilemap);
expect(pathfinding._initialized).toBe(false);
expect(pathfinding.pathCache.size).toBe(0);
expect(pathfinding.tileWidth).toBe(64);
expect(pathfinding.tileHeight).toBe(64);
});
test('grid starts empty', () => {
expect(pathfinding.grid).toEqual([]);
});
});
// ── initGrid ────────────────────────────────────────────────────
describe('initGrid', () => {
test('creates open grid when no layers found', () => {
mockTilemap.getLayer.mockReturnValue(null);
pathfinding.initGrid('rocks', 'ground');
expect(pathfinding._initialized).toBe(true);
expect(pathfinding.grid.length).toBe(10);
expect(pathfinding.grid[0].length).toBe(10);
expect(mockEasyStarInstance.setGrid).toHaveBeenCalled();
});
test('handles tile properties with cost', () => {
const mockLayer = {
getTileAt: jest.fn((x, y) => {
// Make half the tiles walkable with cost
if (x < 5) {
return { index: 1, properties: { cost: 1 } };
}
return null; // blocked
}),
};
mockTilemap.getLayer.mockReturnValue(mockLayer);
pathfinding.initGrid('rocks', 'ground');
expect(pathfinding._initialized).toBe(true);
expect(mockEasyStarInstance.setGrid).toHaveBeenCalled();
});
});
// ── setWalkable ─────────────────────────────────────────────────
describe('setWalkable', () => {
beforeEach(() => {
// Init with a 3x3 grid
pathfinding.grid = [
[0, 0, 0],
[0, 1, 0], // center blocked
[0, 0, 0],
];
pathfinding._initialized = true;
});
test('sets a tile to blocked (1)', () => {
pathfinding.setWalkable(0, 0, false);
expect(pathfinding.grid[0][0]).toBe(1);
expect(mockEasyStarInstance.setGrid).toHaveBeenCalled();
});
test('sets a tile to walkable (0)', () => {
pathfinding.setWalkable(1, 1, true);
expect(pathfinding.grid[1][1]).toBe(0);
});
test('no-ops when grid not initialized', () => {
pathfinding._initialized = false;
pathfinding.setWalkable(0, 0, true);
expect(mockEasyStarInstance.setGrid).not.toHaveBeenCalled();
});
test('no-ops on out-of-bounds coordinates', () => {
pathfinding.setWalkable(-1, 0, true);
pathfinding.setWalkable(0, 99, true);
expect(mockEasyStarInstance.setGrid).not.toHaveBeenCalled();
});
test('no-ops when value unchanged', () => {
pathfinding.setWalkable(0, 0, true); // already 0
expect(mockEasyStarInstance.setGrid).not.toHaveBeenCalled();
});
test('invalidates cached paths that pass through changed tile', () => {
const path1 = [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 2, y: 0 }];
const path2 = [{ x: 2, y: 2 }, { x: 2, y: 1 }];
pathfinding.setCachedPath('entity1', path1);
pathfinding.setCachedPath('entity2', path2);
pathfinding.setWalkable(1, 0, false); // path1 passes through (1,0)
expect(pathfinding.getCachedPath('entity1')).toBeUndefined(); // invalidated
expect(pathfinding.getCachedPath('entity2')).toBeDefined(); // unaffected
expect(pathfinding._dirtyEntities.has('entity1')).toBe(true);
expect(pathfinding._dirtyEntities.has('entity2')).toBe(false);
});
});
// ── setRegionWalkable ───────────────────────────────────────────
describe('setRegionWalkable', () => {
beforeEach(() => {
pathfinding.grid = [
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 0],
];
pathfinding._initialized = true;
});
test('blocks a rectangular region', () => {
pathfinding.setRegionWalkable(1, 1, 2, 2, false);
expect(pathfinding.grid[1][1]).toBe(1);
expect(pathfinding.grid[1][2]).toBe(1);
expect(pathfinding.grid[2][1]).toBe(1);
expect(pathfinding.grid[2][2]).toBe(1);
// Outside region unchanged
expect(pathfinding.grid[0][0]).toBe(0);
});
});
// ── findPath ────────────────────────────────────────────────────
describe('findPath', () => {
test('calls easystar.findPath with correct coordinates', () => {
pathfinding._initialized = true;
pathfinding.findPath({ x: 0, y: 0 }, { x: 5, y: 5 }, () => {});
expect(mockFindPath).toHaveBeenCalledWith(
0, 0, 5, 5,
expect.any(Function),
);
});
test('warns and calls callback(null) if not initialized', () => {
const cb = jest.fn();
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
// Signature: findPath(startTile, endTile, options, callback)
// When not initialized, it calls the 4th argument (callback) with null
pathfinding.findPath({ x: 0, y: 0 }, { x: 1, y: 1 }, {}, cb);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('called before initGrid'),
);
expect(cb).toHaveBeenCalledWith(null);
warnSpy.mockRestore();
});
test('handles options omitted (callback as second arg style)', () => {
pathfinding._initialized = true;
const cb = jest.fn();
// Simulate calling findPath(start, end, callback) without options
pathfinding.findPath({ x: 0, y: 0 }, { x: 2, y: 2 }, cb);
// Should still call easystar.findPath
expect(mockFindPath).toHaveBeenCalled();
});
test('calls callback with null when easystar returns null', () => {
pathfinding._initialized = true;
const cb = jest.fn();
// Make findPath invoke callback with null
mockFindPath.mockImplementationOnce((sx, sy, ex, ey, cb) => cb(null));
pathfinding.findPath({ x: 0, y: 0 }, { x: 9, y: 9 }, cb);
expect(cb).toHaveBeenCalledWith(null);
});
test('clamps path length with maxPathLength option', () => {
pathfinding._initialized = true;
const cb = jest.fn();
const longPath = [
{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 2, y: 0 },
{ x: 3, y: 0 }, { x: 4, y: 0 },
];
mockFindPath.mockImplementationOnce((sx, sy, ex, ey, cb) => cb(longPath));
pathfinding.findPath({ x: 0, y: 0 }, { x: 4, y: 0 }, { maxPathLength: 3 }, cb);
expect(cb).toHaveBeenCalledWith(longPath.slice(0, 3));
});
});
// ── cache management ────────────────────────────────────────────
describe('cache management', () => {
const samplePath = [{ x: 0, y: 0 }, { x: 1, y: 1 }];
test('setCachedPath stores and getCachedPath retrieves', () => {
pathfinding.setCachedPath('e1', samplePath);
expect(pathfinding.getCachedPath('e1')).toEqual(samplePath);
});
test('getCachedPath returns undefined for missing entity', () => {
expect(pathfinding.getCachedPath('nonexistent')).toBeUndefined();
});
test('invalidateCache(entityId) removes single entry', () => {
pathfinding.setCachedPath('e1', samplePath);
pathfinding.setCachedPath('e2', samplePath);
pathfinding.invalidateCache('e1');
expect(pathfinding.getCachedPath('e1')).toBeUndefined();
expect(pathfinding.getCachedPath('e2')).toBeDefined();
});
test('invalidateCache() clears everything', () => {
pathfinding.setCachedPath('e1', samplePath);
pathfinding.setCachedPath('e2', samplePath);
pathfinding.invalidateCache();
expect(pathfinding.pathCache.size).toBe(0);
expect(pathfinding._dirtyEntities.size).toBe(0);
});
test('cacheSize reports correctly', () => {
expect(pathfinding.cacheSize).toBe(0);
pathfinding.setCachedPath('e1', samplePath);
expect(pathfinding.cacheSize).toBe(1);
});
test('dirtyCount reports correctly', () => {
expect(pathfinding.dirtyCount).toBe(0);
pathfinding._dirtyEntities.add('e1');
expect(pathfinding.dirtyCount).toBe(1);
});
});
// ── utility conversions ─────────────────────────────────────────
describe('utility methods', () => {
test('pathToWorldCoords converts tile path to world pixels', () => {
const result = pathfinding.pathToWorldCoords([
{ x: 0, y: 0 },
{ x: 1, y: 2 },
]);
expect(result).toEqual([
{ x: 32, y: 32 }, // 0*64 + 32
{ x: 96, y: 160 }, // 1*64 + 32, 2*64 + 32
]);
});
test('pathToWorldCoords returns empty for null/empty input', () => {
expect(pathfinding.pathToWorldCoords(null)).toEqual([]);
expect(pathfinding.pathToWorldCoords([])).toEqual([]);
});
test('tileToWorldCoords converts single tile', () => {
const result = pathfinding.tileToWorldCoords({ x: 3, y: 4 });
expect(result).toEqual({ x: 224, y: 288 }); // 3*64+32, 4*64+32
});
test('worldToTileCoords converts world position to tile', () => {
const result = pathfinding.worldToTileCoords(150, 200);
expect(result).toEqual({ x: 2, y: 3 }); // floor(150/64), floor(200/64)
});
test('dimensions returns width/height from grid', () => {
pathfinding.grid = [
[0, 0, 0],
[0, 0, 0],
];
expect(pathfinding.dimensions).toEqual({ width: 3, height: 2 });
});
test('dimensions returns zeros for empty grid', () => {
pathfinding.grid = [];
expect(pathfinding.dimensions).toEqual({ width: 0, height: 0 });
});
test('initialized getter reflects state', () => {
expect(pathfinding.initialized).toBe(false);
pathfinding._initialized = true;
expect(pathfinding.initialized).toBe(true);
});
});
// ── update loop ─────────────────────────────────────────────────
describe('update', () => {
test('does not throw when called', () => {
expect(() => pathfinding.update(0, 16)).not.toThrow();
});
});
});

View File

@@ -14,6 +14,7 @@ module.exports = {
Scenes: path.resolve(__dirname, "src/scenes/"),
Scripts: path.resolve(__dirname, "src/scripts/"),
Styles: path.resolve(__dirname, "src/styles/"),
Systems: path.resolve(__dirname, "src/systems/"),
},
},
devtool: "inline-source-map",