- 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)
339 lines
10 KiB
JavaScript
339 lines
10 KiB
JavaScript
/**
|
|
* CombatSystem Unit Tests
|
|
*/
|
|
|
|
// Mock Phaser
|
|
jest.mock('phaser', () => ({
|
|
Math: {
|
|
Distance: {
|
|
Between: jest.fn((x1, y1, x2, y2) => Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)),
|
|
},
|
|
Angle: {
|
|
Between: jest.fn(() => 0),
|
|
BetweenPoints: jest.fn(() => 0),
|
|
Wrap: jest.fn((angle) => angle),
|
|
},
|
|
Vector2: class {
|
|
constructor(x, y) { this.x = x; this.y = y; }
|
|
},
|
|
DegToRad: jest.fn((deg) => deg * Math.PI / 180),
|
|
RadToDeg: jest.fn((rad) => rad * 180 / Math.PI),
|
|
},
|
|
Physics: {
|
|
Arcade: {
|
|
DYNAMIC_BODY: 0,
|
|
Sprite: class MockArcadeSprite {
|
|
constructor(scene, x, y, texture) {
|
|
this.scene = scene;
|
|
this.x = x;
|
|
this.y = y;
|
|
this.texture = texture;
|
|
this.active = true;
|
|
this.visible = true;
|
|
this.body = {
|
|
velocity: { x: 0, y: 0 },
|
|
allowGravity: false,
|
|
setSize: jest.fn(),
|
|
setOffset: jest.fn(),
|
|
setVelocity: jest.fn(),
|
|
};
|
|
this._data = {};
|
|
this.setData = jest.fn((k, v) => { this._data[k] = v; });
|
|
this.getData = jest.fn((k) => this._data[k] ?? null);
|
|
this.setTint = jest.fn();
|
|
this.clearTint = jest.fn();
|
|
this.setRotation = jest.fn();
|
|
this.setDepth = jest.fn();
|
|
this.destroy = jest.fn();
|
|
this.emit = jest.fn();
|
|
}
|
|
preUpdate() {}
|
|
},
|
|
},
|
|
},
|
|
Display: {
|
|
Color: {
|
|
GetColor32: jest.fn(() => 0xffff00),
|
|
},
|
|
},
|
|
GameObjects: {
|
|
Sprite: class {},
|
|
Rectangle: class {},
|
|
Graphics: class {},
|
|
Container: class {},
|
|
Zone: class {},
|
|
},
|
|
}));
|
|
|
|
import CombatSystem from '../src/systems/CombatSystem';
|
|
|
|
const createMockScene = () => ({
|
|
physics: {
|
|
add: {
|
|
group: jest.fn(() => ({
|
|
create: jest.fn(),
|
|
killAndHide: jest.fn(),
|
|
add: jest.fn().mockImplementation((sprite) => sprite),
|
|
getChildren: jest.fn(() => []),
|
|
}))
|
|
},
|
|
overlap: jest.fn(),
|
|
world: { enableBody: jest.fn() },
|
|
velocityFromAngle: jest.fn(),
|
|
},
|
|
events: {
|
|
emit: jest.fn(),
|
|
on: jest.fn(),
|
|
off: jest.fn(),
|
|
},
|
|
add: {
|
|
existing: jest.fn(),
|
|
sprite: jest.fn(),
|
|
rectangle: jest.fn(() => ({
|
|
setDepth: jest.fn(),
|
|
setData: jest.fn(),
|
|
getData: jest.fn(),
|
|
setRotation: jest.fn(),
|
|
body: { velocity: { x: 0, y: 0 }, allowGravity: true, setVelocity: jest.fn() },
|
|
})),
|
|
},
|
|
textures: { exists: jest.fn(() => false) },
|
|
tweens: { addCounter: jest.fn(() => ({ stop: jest.fn() })) },
|
|
});
|
|
|
|
/**
|
|
* Helper to build a minimal entity that passes all CombatSystem guards.
|
|
*/
|
|
function entity(opts = {}) {
|
|
const team = opts.team ?? 'team-a';
|
|
const e = {
|
|
x: opts.x ?? 0,
|
|
y: opts.y ?? 0,
|
|
dead: opts.dead ?? false,
|
|
rotation: opts.rotation ?? 0,
|
|
getData: jest.fn((key) => {
|
|
if (key === 'health') return opts.health ?? 100;
|
|
if (key === 'armor') return opts.armor ?? 1;
|
|
if (key === 'teamId') return team;
|
|
return undefined;
|
|
}),
|
|
setData: jest.fn(),
|
|
emit: jest.fn(),
|
|
isDead: jest.fn(() => opts.dead ?? false),
|
|
body: { center: { x: opts.x ?? 0, y: opts.y ?? 0 } },
|
|
parentContainer: { name: team },
|
|
getEnemyContainer: jest.fn(() => ({
|
|
list: opts.enemies ?? [],
|
|
getAll: jest.fn(() => (opts.enemies ?? []).filter(e => !e.dead)),
|
|
})),
|
|
};
|
|
return e;
|
|
}
|
|
|
|
describe('CombatSystem', () => {
|
|
let scene;
|
|
let combat;
|
|
let mockTeamManager;
|
|
|
|
beforeEach(() => {
|
|
scene = createMockScene();
|
|
mockTeamManager = {
|
|
getEntityTeam: jest.fn((e) => e.getData?.('teamId') ?? null),
|
|
getAllUnitsGrouped: jest.fn(() => new Map()),
|
|
isEnemy: jest.fn((a, b) => {
|
|
const ta = a.getData?.('teamId') ?? a.parentContainer?.name;
|
|
const tb = b.getData?.('teamId') ?? b.parentContainer?.name;
|
|
return ta !== tb;
|
|
}),
|
|
getTeams: jest.fn(() => []),
|
|
};
|
|
combat = new CombatSystem(scene, mockTeamManager);
|
|
});
|
|
|
|
describe('acquireTarget', () => {
|
|
it('should return null when no enemies in range', () => {
|
|
const e = entity({ x: 0, y: 0, enemies: [] });
|
|
const target = combat.acquireTarget(e, { maxRange: 200 });
|
|
expect(target).toBeNull();
|
|
});
|
|
|
|
it('should return closest enemy when multiple in range', () => {
|
|
const enemy1 = entity({ x: 100, y: 0, team: 'bad-guys' });
|
|
const enemy2 = entity({ x: 50, y: 0, team: 'bad-guys' });
|
|
const e = entity({ x: 0, y: 0, team: 'good-guys', enemies: [enemy1, enemy2] });
|
|
|
|
// Populate TeamManager
|
|
mockTeamManager.getAllUnitsGrouped.mockReturnValue(
|
|
new Map([
|
|
['good-guys', new Set([e])],
|
|
['bad-guys', new Set([enemy1, enemy2])],
|
|
])
|
|
);
|
|
|
|
// Override hasLineOfSight to always pass
|
|
combat.hasLineOfSight = jest.fn(() => true);
|
|
|
|
const target = combat.acquireTarget(e, { maxRange: 200, priority: 'closest' });
|
|
expect(target).toBe(enemy2);
|
|
});
|
|
|
|
it('should filter out dead enemies', () => {
|
|
const deadEnemy = entity({ x: 50, y: 0, team: 'bad-guys', dead: true });
|
|
const liveEnemy = entity({ x: 100, y: 0, team: 'bad-guys' });
|
|
const e = entity({ x: 0, y: 0, team: 'good-guys', enemies: [deadEnemy, liveEnemy] });
|
|
|
|
mockTeamManager.getAllUnitsGrouped.mockReturnValue(
|
|
new Map([
|
|
['good-guys', new Set([e])],
|
|
['bad-guys', new Set([deadEnemy, liveEnemy])],
|
|
])
|
|
);
|
|
combat.hasLineOfSight = jest.fn(() => true);
|
|
|
|
const target = combat.acquireTarget(e, { maxRange: 200 });
|
|
expect(target).toBe(liveEnemy);
|
|
});
|
|
});
|
|
|
|
describe('canHit', () => {
|
|
let attacker, target;
|
|
|
|
beforeEach(() => {
|
|
attacker = entity({ x: 0, y: 0, team: 'good-guys' });
|
|
target = entity({ x: 100, y: 0, team: 'bad-guys' });
|
|
});
|
|
|
|
it('should return false for friendly fire', () => {
|
|
target.parentContainer.name = 'good-guys';
|
|
target.getData.mockImplementation((key) => {
|
|
if (key === 'health') return 100;
|
|
if (key === 'armor') return 1;
|
|
if (key === 'teamId') return 'good-guys';
|
|
return undefined;
|
|
});
|
|
|
|
const result = combat.canHit(attacker, target);
|
|
expect(result.canHit).toBe(false);
|
|
expect(result.reason).toBe('friendly_fire');
|
|
});
|
|
|
|
it('should return false for dead target', () => {
|
|
target.dead = true;
|
|
target.isDead = jest.fn(() => true);
|
|
|
|
const result = combat.canHit(attacker, target);
|
|
expect(result.canHit).toBe(false);
|
|
expect(result.reason).toBe('target_dead');
|
|
});
|
|
|
|
it('should return false when out of range', () => {
|
|
target.x = 500; // Beyond default 200 range
|
|
|
|
combat.hasLineOfSight = jest.fn(() => false);
|
|
|
|
const result = combat.canHit(attacker, target);
|
|
expect(result.canHit).toBe(false);
|
|
expect(result.reason).toBe('out_of_range');
|
|
});
|
|
|
|
it('should return true when all conditions met', () => {
|
|
combat.hasLineOfSight = jest.fn(() => true);
|
|
|
|
const result = combat.canHit(attacker, target);
|
|
expect(result.canHit).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('applyDamage', () => {
|
|
it('should apply damage with armor reduction', () => {
|
|
const e = entity({ health: 100, armor: 5 });
|
|
// Override getData for health to return expected structure
|
|
e.getData = jest.fn((key) => {
|
|
if (key === 'health') return 100;
|
|
if (key === 'armor') return 5;
|
|
return undefined;
|
|
});
|
|
|
|
const damage = combat.applyDamage(e, 20, 'rifle');
|
|
|
|
expect(damage).toBeLessThanOrEqual(16); // 20 - (5 * 0.9) = 15.5 → round to 16 (no crit)
|
|
expect(e.setData).toHaveBeenCalledWith('health', expect.any(Number));
|
|
});
|
|
|
|
it('should apply minimum 1 damage', () => {
|
|
const e = entity({ health: 100, armor: 50 });
|
|
e.getData = jest.fn((key) => {
|
|
if (key === 'health') return 100;
|
|
if (key === 'armor') return 50;
|
|
return undefined;
|
|
});
|
|
|
|
const damage = combat.applyDamage(e, 10, 'rifle');
|
|
|
|
expect(damage).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it('should apply critical hit multiplier', () => {
|
|
const e = entity({ health: 100, armor: 0 });
|
|
e.getData = jest.fn((key) => {
|
|
if (key === 'health') return 100;
|
|
if (key === 'armor') return 0;
|
|
return undefined;
|
|
});
|
|
|
|
// Mock crit roll to succeed
|
|
combat.damageModifiers = {
|
|
rifle: { armorPiercing: 0, critChance: 1.0, critMultiplier: 2.0 },
|
|
};
|
|
|
|
const damage = combat.applyDamage(e, 20, 'rifle');
|
|
|
|
expect(damage).toBe(40); // 20 * 2.0
|
|
});
|
|
});
|
|
|
|
describe('fireProjectile', () => {
|
|
it('creates a ProjectileSprite and adds it to the group', () => {
|
|
const attacker = entity({ x: 0, y: 0, team: 'good-guys' });
|
|
const target = entity({ x: 100, y: 0, team: 'bad-guys' });
|
|
combat.hasLineOfSight = jest.fn(() => true);
|
|
|
|
const p = combat.fireProjectile(attacker, target);
|
|
|
|
expect(p).not.toBeNull();
|
|
expect(p.texture).toBe('__WHITE');
|
|
expect(p.setTint).toHaveBeenCalledWith(0x0000ff); // blue for good guys
|
|
});
|
|
|
|
it('tints enemy projectiles red', () => {
|
|
const attacker = entity({ x: 0, y: 0, team: 'bad-guys' });
|
|
const target = entity({ x: 100, y: 0, team: 'good-guys' });
|
|
combat.hasLineOfSight = jest.fn(() => true);
|
|
|
|
const p = combat.fireProjectile(attacker, target);
|
|
|
|
expect(p.setTint).toHaveBeenCalledWith(0xff0000);
|
|
});
|
|
|
|
it('emits combat:projectileHit on _onHit', () => {
|
|
const attacker = entity({ x: 0, y: 0, team: 'good-guys' });
|
|
const target = entity({ x: 100, y: 0, team: 'bad-guys', health: 100, armor: 0 });
|
|
target.getData = jest.fn((key) => (key === 'health' ? 100 : (key === 'armor' ? 0 : undefined)));
|
|
|
|
combat.damageModifiers = {
|
|
rifle: { armorPiercing: 0, critChance: 0, critMultiplier: 1.5 },
|
|
};
|
|
|
|
const p = combat.fireProjectile(attacker, target);
|
|
combat._onHit(p, target);
|
|
|
|
expect(target.setData).toHaveBeenCalledWith('health', expect.any(Number));
|
|
expect(scene.events.emit).toHaveBeenCalledWith(
|
|
'combat:projectileHit',
|
|
expect.objectContaining({ attacker, target }),
|
|
);
|
|
expect(p.destroy).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|