- 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)
736 lines
20 KiB
JavaScript
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;
|
|
}
|
|
}
|