Files
restitution/src/systems/CombatSystem.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

460 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Phaser from 'phaser';
import CONSTANTS from 'PhaserClasses/CustomConstants';
import ProjectileSprite from './ProjectileSprite';
/**
* 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)
* @param {import('./TeamManager').default} teamManager — centralized team registry
*/
constructor(scene, teamManager) {
this.scene = scene;
this.teamManager = teamManager;
/** @type {Phaser.Physics.Arcade.Group} */
this.projectiles = scene.physics.add.group({
runChildUpdate: true,
});
/**
* 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 },
};
}
// ──────────────────────────────────────────────
// 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 weaponRange = entity?.components?.combat?.weaponRange ?? entity?.components?.combat?.range;
const {
maxRange = weaponRange ?? CONSTANTS.RIFLE.RANGE,
fov = 360,
priority = 'closest',
} = options;
const attackerTeam = this.teamManager.getEntityTeam(entity);
if (attackerTeam == null) return null;
const allGrouped = this.teamManager.getAllUnitsGrouped();
if (!allGrouped.size) return null;
const origin = new Phaser.Math.Vector2(entity.x, entity.y);
const candidates = [];
for (const [teamId, unitSet] of allGrouped) {
if (teamId === attackerTeam) continue; // skip own team
for (const enemy of unitSet) {
if (enemy.dead || (enemy.isDead && enemy.isDead())) continue;
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 && attacker.parentContainer.name === target.parentContainer.name) {
return { canHit: false, reason: 'friendly_fire' };
}
// Team-aware friendly fire check
if (this.teamManager && !this.teamManager.isEnemy(attacker, target)) {
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,
damageType = 'rifle',
sprite = null,
} = config;
if (!attacker || !target || !attacker.body || !target.body) return null;
const startX = attacker.x;
const startY = attacker.y;
const angle = Phaser.Math.Angle.Between(startX, startY, target.x, target.y);
const texture = (sprite && this.scene.textures.exists(sprite)) ? sprite : '__WHITE';
const projectile = new ProjectileSprite(
this.scene,
startX,
startY,
texture,
angle,
speed,
damage,
attacker,
);
this.projectiles.add(projectile);
projectile.setData('damageType', damageType);
projectile.setData('attacker', attacker);
projectile.setData('damage', damage);
projectile.setData('speed', speed);
projectile.setData('lifespan', 4000);
projectile.setData('elapsed', 0);
projectile.setData('target', target);
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 tilemap = this.scene.map || this.scene.orchestrator?.systems?.map?.tilemap;
if (!tilemap) return true;
// Use worldToTileXY from the tilemap, not from a layer
const tileA = tilemap.worldToTileXY(pointA.x, pointA.y);
const tileB = tilemap.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 via tilemap object
const rockTile = tilemap.getTileAt(x0, y0, true, 'Rocks');
if (rockTile && rockTile.properties && rockTile.properties.collides) {
return false;
}
// Optionally check ground-layer collisions
const ground = tilemap.getTileAt(x0, y0, true, 'Floor');
if (ground && ground.properties && ground.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;
}
// ──────────────────────────────────────────────
/**
* 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 culling (ProjectileSprite.preUpdate also does this)
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;
}
}
// Manual overlap vs all teams
this._checkOverlap(p);
}
for (const p of toRemove) p.destroy();
// Auto-engage: iterate all team groups
const grouped = this.teamManager.getAllUnitsGrouped();
for (const [, unitSet] of grouped) {
this._processCombatGroup(unitSet, time);
}
}
/**
* Internal auto-engage loop for a team.
* Iterates alive units, checks fire cooldown, acquires target, fires.
* @param {Set|Array} units — iterable of unit entities
* @param {number} time
*/
_processCombatGroup(units, time) {
if (!units) return;
const list = Array.from(units).filter((u) => !u.dead && !(u.isDead && u.isDead()));
for (let i = 0; i < list.length; i++) {
const unit = list[i];
if (!unit || !unit.body || unit.dead || (unit.isDead && unit.isDead())) continue;
const combat = unit.components?.combat;
if (combat && !combat.canFire(time)) continue;
const target = this.acquireTarget(unit);
if (target) {
const hit = this.canHit(unit, target);
if (hit.canHit) {
this.fireProjectile(unit, target, {
damage: combat?.damage ?? undefined,
speed: 400,
damageType: combat?.damageType ?? 'rifle',
sprite: combat?.projectileType ?? undefined,
});
combat?.recordFire(time);
}
}
}
}
// ──────────────────────────────────────────────
// INTERNALS
// ──────────────────────────────────────────────
/**
* Check one projectile against all unit teams.
* @param {Phaser.GameObjects.GameObject} projectile
*/
_checkOverlap(projectile) {
if (!projectile.body) return;
const attacker = projectile.getData('attacker');
const grouped = this.teamManager.getAllUnitsGrouped();
for (const [, unitSet] of grouped) {
if (!projectile.active) return; // stop if already destroyed on hit
for (const unit of unitSet) {
if (!unit.body || unit === attacker) continue;
if (!this.teamManager.isEnemy(attacker, unit)) continue; // skip friendlies
if (this.scene.physics.overlap(projectile, unit)) {
this._onHit(projectile, unit);
return; // one hit per frame
}
}
}
}
/**
* 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();
}
}