Files
restitution/src/systems/CombatSystem.js
kaykayyali 804aec6a11 M2.4: combat loop e2e + unit data fields
- Unit.js: add health/armor to entity data
- CombatSystem.js: integration hooks for M2 combat loop
- EntityStateMachine.js: orchestrator.registerEntity refactor
- playwright.config.js: M2 e2e config
- tests/e2e/combat-loop.{spec,e2e}.js: M2 combat loop verification
- tests/e2e/milestone-2-combat-loop.spec.js: M2 acceptance test
- .gitignore: exclude debug scripts + screenshots from commits
2026-06-27 16:44:41 +00:00

463 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 rawA = tilemap.worldToTileXY(pointA.x, pointA.y);
const rawB = tilemap.worldToTileXY(pointB.x, pointB.y);
if (!rawA || !rawB) return true;
const tileA = { x: Math.floor(rawA.x), y: Math.floor(rawA.y) };
const tileB = { x: Math.floor(rawB.x), y: Math.floor(rawB.y) };
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();
}
}