Files
restitution/src/systems/SelectionSystem.js
kaykayyali 8fc45968b5 M2.3 + M2.4 + TeamManager integration: projectile sprites, death handling, build menu, production panel, building placer
- ProjectileSprite.js: physics arcade sprite, faction tint, off-screen culling
- CombatSystem: refactored enemy selection to use TeamManager instead of legacy containers
- Death handling: DYING alpha tween (500ms), smoke puff (300ms), unit:killed event, cleanup
- TeamManager: centralized team registry replacing goodGuys/badGuys containers
- HealthBarSystem, ResourceBar, CaptureProgressUI, BuildMenu, BuildingPlacer, BuildingRenderer, ProductionPanel
- Map_Player: wired new subsystems, removed legacy container creation
- Tests: ProjectileSprite (4), DeathHandling (13), CombatSystem updated

47 tests passed at dev time (M2.3), 158/158 at dev time (M2.4)
2026-06-01 05:18:33 +00:00

736 lines
20 KiB
JavaScript

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 pathfinding = this.scene.orchestrator?.systems?.pathfinding;
if (!pathfinding) {
console.warn('[SelectionSystem] PathfindingSystem not available');
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),
};
const startTile = pathfinding.worldToTile(entity.x, entity.y);
pathfinding.findPath(startTile.x, startTile.y, offsetTile.x, offsetTile.y).then((path) => {
if (path === null) {
console.warn(`[SelectionSystem] No path found for entity`);
return;
}
// Entities expect the path format from EasyStar: [{x, y}, ...]
// Tank.moveToPath(path) and Infantry.moveToPath(path, shiftDown)
if (typeof entity.moveToPath === 'function') {
// Determine if shift is held (waypoint append for infantry)
const shiftDown = this.shiftKey?.isDown ?? false;
entity.moveToPath(path, shiftDown);
}
});
});
}
#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,
);
// --- Right-click: issue commands ---
// Phaser fires POINTER_DOWN for both buttons. We multiplex inside the
// handler: left/middle goes through handlePointerDown → handlePointerUp,
// right goes through handleRightClick.
this.scene.input.on(
Phaser.Input.Events.POINTER_DOWN,
this.#onPointerDownMultiplex,
this,
);
}
/**
* Multiplex POINTER_DOWN: right-clicks → handleRightClick,
* everything else → already handled by handlePointerDown.
* This is a separate listener registered after handlePointerDown so both
* fire, but handlePointerDown bails early when rightButtonDown() is true.
*/
#onPointerDownMultiplex(pointer, currentlyOver) {
if (pointer.rightButtonDown()) {
this.handleRightClick(pointer, currentlyOver);
}
}
/**
* 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,
);
this.scene.input.off(
Phaser.Input.Events.POINTER_DOWN,
this.#onPointerDownMultiplex,
this,
);
if (this.selectionBox) {
this.selectionBox.destroy();
this.selectionBox = null;
}
this.selected.clear();
this.commandQueue.length = 0;
}
}