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:
55
_bmad-output/planning-artifacts/tech-plan-01-economy.md
Normal file
55
_bmad-output/planning-artifacts/tech-plan-01-economy.md
Normal 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
|
||||
59
_bmad-output/planning-artifacts/tech-plan-02-pathfinding.md
Normal file
59
_bmad-output/planning-artifacts/tech-plan-02-pathfinding.md
Normal 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)
|
||||
61
_bmad-output/planning-artifacts/tech-plan-03-combat.md
Normal file
61
_bmad-output/planning-artifacts/tech-plan-03-combat.md
Normal 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
|
||||
72
_bmad-output/planning-artifacts/tech-plan-04-selection.md
Normal file
72
_bmad-output/planning-artifacts/tech-plan-04-selection.md
Normal 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
|
||||
83
_bmad-output/planning-artifacts/tech-plan-05-network.md
Normal file
83
_bmad-output/planning-artifacts/tech-plan-05-network.md
Normal 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
|
||||
68
_bmad-output/planning-artifacts/tech-plan-06-map.md
Normal file
68
_bmad-output/planning-artifacts/tech-plan-06-map.md
Normal 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
|
||||
101
_bmad-output/planning-artifacts/tech-plan-07-entity-sm.md
Normal file
101
_bmad-output/planning-artifacts/tech-plan-07-entity-sm.md
Normal 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
|
||||
115
_bmad-output/planning-artifacts/tech-plan-08-building-sm.md
Normal file
115
_bmad-output/planning-artifacts/tech-plan-08-building-sm.md
Normal 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
|
||||
105
_bmad-output/planning-artifacts/tech-plan-09-controlpoint-sm.md
Normal file
105
_bmad-output/planning-artifacts/tech-plan-09-controlpoint-sm.md
Normal 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
|
||||
99
_bmad-output/planning-artifacts/tech-plan-10-orchestrator.md
Normal file
99
_bmad-output/planning-artifacts/tech-plan-10-orchestrator.md
Normal 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
|
||||
321
_bmad-output/planning-artifacts/tech-spec-architecture.md
Normal file
321
_bmad-output/planning-artifacts/tech-spec-architecture.md
Normal 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
6
babel.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
['@babel/preset-env', { targets: { node: 'current' } }],
|
||||
['@babel/preset-react', { runtime: 'automatic' }]
|
||||
]
|
||||
};
|
||||
7445
package-lock.json
generated
7445
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
32
package.json
32
package.json
@@ -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
281
src/entities/Unit.js
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
109
src/entities/buildings/building-types.js
Normal file
109
src/entities/buildings/building-types.js
Normal 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;
|
||||
114
src/entities/components/CombatComponent.js
Normal file
114
src/entities/components/CombatComponent.js
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
121
src/entities/components/HealthComponent.js
Normal file
121
src/entities/components/HealthComponent.js
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
100
src/entities/components/InventoryComponent.js
Normal file
100
src/entities/components/InventoryComponent.js
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
92
src/entities/components/MovementComponent.js
Normal file
92
src/entities/components/MovementComponent.js
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
78
src/entities/components/OwnerComponent.js
Normal file
78
src/entities/components/OwnerComponent.js
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
5
src/entities/components/index.js
Normal file
5
src/entities/components/index.js
Normal 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';
|
||||
106
src/entities/state-machines/unit-states.js
Normal file
106
src/entities/state-machines/unit-states.js
Normal 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 };
|
||||
}
|
||||
103
src/systems/BuildingStateMachine.js
Normal file
103
src/systems/BuildingStateMachine.js
Normal 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
463
src/systems/CombatSystem.js
Normal 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();
|
||||
}
|
||||
}
|
||||
426
src/systems/ControlPointStateMachine.js
Normal file
426
src/systems/ControlPointStateMachine.js
Normal 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, // 0–100 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 (0–100%) 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 (0–100).
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
191
src/systems/EconomySystem.js
Normal file
191
src/systems/EconomySystem.js
Normal 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();
|
||||
}
|
||||
}
|
||||
72
src/systems/EntityStateMachine.js
Normal file
72
src/systems/EntityStateMachine.js
Normal 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
163
src/systems/MapSystem.js
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
487
src/systems/NetworkSystem.js
Normal file
487
src/systems/NetworkSystem.js
Normal 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;
|
||||
365
src/systems/PathfindingSystem.js
Normal file
365
src/systems/PathfindingSystem.js
Normal 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;
|
||||
}
|
||||
}
|
||||
697
src/systems/SelectionSystem.js
Normal file
697
src/systems/SelectionSystem.js
Normal 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;
|
||||
}
|
||||
}
|
||||
509
src/systems/SystemOrchestrator.js
Normal file
509
src/systems/SystemOrchestrator.js
Normal 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
169
tests/CombatSystem.test.js
Normal 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
151
tests/EconomySystem.test.js
Normal 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
280
tests/Unit.test.js
Normal 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
123
tests/setup.js
Normal 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();
|
||||
429
tests/unit/CombatSystem.test.js
Normal file
429
tests/unit/CombatSystem.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
247
tests/unit/EconomySystem.test.js
Normal file
247
tests/unit/EconomySystem.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
234
tests/unit/EntityStateMachine.test.js
Normal file
234
tests/unit/EntityStateMachine.test.js
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
348
tests/unit/PathfindingSystem.test.js
Normal file
348
tests/unit/PathfindingSystem.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user