- 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)
460 lines
15 KiB
JavaScript
460 lines
15 KiB
JavaScript
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();
|
||
}
|
||
}
|