429 lines
16 KiB
JavaScript
429 lines
16 KiB
JavaScript
/**
|
|
* Slice 2 — Collision/Damage Loop unit tests
|
|
*
|
|
* Tests: player fire, projectile hit resolution, enemy projectile
|
|
* hitting tank, hatch hitbox, screen shake, tank HP tracking.
|
|
*
|
|
* Uses Jest globals only — no vitest imports.
|
|
* RED phase: MainGame does not yet implement these behaviors.
|
|
*/
|
|
|
|
// ─── Mock Phaser before any imports ──────────────────────────────────
|
|
jest.mock('phaser', () => {
|
|
const orig = jest.requireActual('phaser');
|
|
return {
|
|
...orig,
|
|
Scene: class MockScene {
|
|
constructor(config) {
|
|
this.scene = config || {};
|
|
this.scene.launch = jest.fn();
|
|
}
|
|
add = {
|
|
image: jest.fn(() => ({ setOrigin: jest.fn(() => ({})) })),
|
|
existing: jest.fn(),
|
|
rectangle: jest.fn(() => ({ setOrigin: jest.fn(() => ({})) })),
|
|
graphics: jest.fn(() => ({
|
|
lineStyle: jest.fn(() => ({})),
|
|
lineBetween: jest.fn(() => ({})),
|
|
setDepth: jest.fn(() => ({})),
|
|
})),
|
|
text: jest.fn(() => ({ setDepth: jest.fn(() => ({})) })),
|
|
};
|
|
cameras = {
|
|
main: {
|
|
startFollow: jest.fn(),
|
|
shake: jest.fn(),
|
|
},
|
|
};
|
|
textures = { exists: jest.fn(() => false) };
|
|
physics = {
|
|
add: {
|
|
group: jest.fn(() => ({
|
|
maxSize: 100,
|
|
getChildren: jest.fn(() => []),
|
|
getFirstDead: jest.fn(() => null),
|
|
create: jest.fn(() => null),
|
|
killAndHide: jest.fn(),
|
|
})),
|
|
existing: jest.fn(),
|
|
},
|
|
overlap: jest.fn(),
|
|
};
|
|
input = {
|
|
keyboard: {
|
|
addKeys: jest.fn(() => ({
|
|
W: { isDown: false },
|
|
A: { isDown: false },
|
|
S: { isDown: false },
|
|
D: { isDown: false },
|
|
E: { isDown: false },
|
|
ONE: { isDown: false },
|
|
TWO: { isDown: false },
|
|
THREE: { isDown: false },
|
|
FOUR: { isDown: false },
|
|
})),
|
|
},
|
|
on: jest.fn(),
|
|
};
|
|
game = { __saveManager: null };
|
|
},
|
|
Input: {
|
|
Keyboard: { KeyCodes: { W: 87, A: 65, S: 83, D: 68, E: 69, ONE: 49, TWO: 50, THREE: 51, FOUR: 52 } },
|
|
},
|
|
Physics: { Arcade: { Sprite: class {} } },
|
|
};
|
|
});
|
|
|
|
// ─── Imports ─────────────────────────────────────────────────────────
|
|
import { MainGame } from '../src/game/scenes/MainGame.js';
|
|
import { AmmoSystem } from '../src/game/systems/AmmoSystem.js';
|
|
import { shells } from '../src/data/shells.js';
|
|
import { enemies } from '../src/data/enemies.js';
|
|
import { Projectile } from '../src/game/entities/Projectile.js';
|
|
import { Enemy } from '../src/game/entities/Enemy.js';
|
|
import { CommanderHatch } from '../src/game/entities/CommanderHatch.js';
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
function freshScene() {
|
|
const mg = new MainGame();
|
|
// Stub textures.exists to avoid missing texture errors
|
|
mg.textures.exists = jest.fn(() => true);
|
|
mg.create();
|
|
return mg;
|
|
}
|
|
|
|
function createEnemyTarget(typeKey = 'type59', x = 500, y = 300) {
|
|
return new Enemy({}, typeKey, x, y);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// Tank HP tracking
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
|
|
describe('MainGame — tank HP', () => {
|
|
it('initializes tankHP to 300 in create()', () => {
|
|
const mg = freshScene();
|
|
expect(mg.tankHP).toBeDefined();
|
|
expect(mg.tankHP).toBe(300);
|
|
});
|
|
|
|
it('decreases tankHP when _onEnemyProjHitTank is called', () => {
|
|
const mg = freshScene();
|
|
mg.tankHP = 300;
|
|
|
|
// Simulate enemy projectile hitting tank
|
|
const projSprite = { active: true, destroy: jest.fn() };
|
|
mg._onEnemyProjHitTank(mg.tank, projSprite);
|
|
|
|
expect(mg.tankHP).toBeLessThan(300);
|
|
expect(mg.tankHP).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it('does not go below 0 HP', () => {
|
|
const mg = freshScene();
|
|
mg.tankHP = 5;
|
|
|
|
const projSprite = { active: true, destroy: jest.fn() };
|
|
mg._onEnemyProjHitTank(mg.tank, projSprite);
|
|
|
|
expect(mg.tankHP).toBeGreaterThanOrEqual(0);
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// Player fire — mouse click handler
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
|
|
describe('MainGame — player fire (pointerdown)', () => {
|
|
it('registers pointerdown event handler in create()', () => {
|
|
const mg = freshScene();
|
|
// input.on was called with ('pointerdown', fn, context) where fn is _onFire
|
|
const pointerdownCalls = mg.input.on.mock.calls.filter(
|
|
c => c[0] === 'pointerdown'
|
|
);
|
|
expect(pointerdownCalls.length).toBe(1);
|
|
expect(pointerdownCalls[0][1]).toBeInstanceOf(Function);
|
|
});
|
|
|
|
it('_onFire() spawns a projectile in projectileGroup when ammo available', () => {
|
|
const mg = freshScene();
|
|
// Ensure ammoSystem has ammo
|
|
expect(mg.ammoSystem.getTotalRemaining()).toBeGreaterThan(0);
|
|
|
|
// Count projectiles before fire
|
|
const before = mg.projectileGroup.getChildren().length;
|
|
mg._onFire();
|
|
const after = mg.projectileGroup.getChildren().length;
|
|
|
|
// Projectile group should have one more active sprite
|
|
expect(after).toBeGreaterThanOrEqual(before);
|
|
});
|
|
|
|
it('_onFire() does NOT throw when ammo system is empty', () => {
|
|
const mg = freshScene();
|
|
// Drain all ammo
|
|
const types = Object.keys(shells);
|
|
for (const t of types) {
|
|
mg.ammoSystem.selectShell(t);
|
|
const count = mg.ammoSystem.getInventory()[t];
|
|
for (let i = 0; i < count; i++) mg.ammoSystem.fire();
|
|
}
|
|
|
|
expect(() => mg._onFire()).not.toThrow();
|
|
});
|
|
|
|
it('_onFire() creates a Projectile with correct shell config from ammoSystem', () => {
|
|
const mg = freshScene();
|
|
mg.turret.angle = 45; // fire at 45 degrees
|
|
|
|
// Spy on projectileGroup.create
|
|
const createSpy = jest.spyOn(mg.projectileGroup, 'create');
|
|
|
|
mg._onFire();
|
|
|
|
// projectGroup.create should have been called to spawn the projectile sprite
|
|
expect(createSpy).toHaveBeenCalled();
|
|
|
|
createSpy.mockRestore();
|
|
});
|
|
|
|
it('_onFire() respects fire rate — rapid calls do not overshoot', () => {
|
|
const mg = freshScene();
|
|
const initialAmmo = mg.ammoSystem.getTotalRemaining();
|
|
|
|
// Fire twice rapidly
|
|
mg._onFire();
|
|
mg._onFire();
|
|
|
|
// Ammo should decrease by at least 1 but not more than 2
|
|
const remaining = mg.ammoSystem.getTotalRemaining();
|
|
expect(remaining).toBeGreaterThanOrEqual(initialAmmo - 2);
|
|
expect(remaining).toBeLessThan(initialAmmo);
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// Player projectile → enemy hit resolution
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
|
|
describe('MainGame — _checkPlayerProjectileHits', () => {
|
|
it('exists as a method on MainGame', () => {
|
|
const mg = freshScene();
|
|
expect(typeof mg._checkPlayerProjectileHits).toBe('function');
|
|
});
|
|
|
|
it('detects enemy within projectile hit radius and applies damage', () => {
|
|
const mg = freshScene();
|
|
// Place an enemy at a known position
|
|
const enemy = createEnemyTarget('type59', 320, 180);
|
|
mg.enemies = [enemy];
|
|
|
|
// Create a player projectile near the enemy
|
|
const proj = new Projectile(320, 180, 0, shells.apcbc);
|
|
proj.alive = true;
|
|
|
|
// Manually add a sprite to projectileGroup (simulate _onFire output)
|
|
const sprite = { active: true, x: 320, y: 180, projectile: proj, destroy: jest.fn() };
|
|
// Override getChildren to return our sprite
|
|
mg.projectileGroup.getChildren = jest.fn(() => [sprite]);
|
|
|
|
// Before hit
|
|
const hpBefore = enemy.hp;
|
|
|
|
mg._checkPlayerProjectileHits(16);
|
|
|
|
// Enemy should have taken damage
|
|
expect(enemy.hp).toBeLessThan(hpBefore);
|
|
});
|
|
|
|
it('does NOT hit enemies outside projectile hit radius', () => {
|
|
const mg = freshScene();
|
|
// Enemy far away from projectile
|
|
const enemy = createEnemyTarget('type59', 2000, 2000);
|
|
mg.enemies = [enemy];
|
|
|
|
const proj = new Projectile(320, 180, 0, shells.apcbc);
|
|
proj.alive = true;
|
|
const sprite = { active: true, x: 320, y: 180, projectile: proj, destroy: jest.fn() };
|
|
mg.projectileGroup.getChildren = jest.fn(() => [sprite]);
|
|
|
|
const hpBefore = enemy.hp;
|
|
mg._checkPlayerProjectileHits(16);
|
|
|
|
// Enemy should NOT have taken damage
|
|
expect(enemy.hp).toBe(hpBefore);
|
|
});
|
|
|
|
it('marks projectile as dead after hit', () => {
|
|
const mg = freshScene();
|
|
const enemy = createEnemyTarget('type59', 320, 180);
|
|
mg.enemies = [enemy];
|
|
|
|
const proj = new Projectile(320, 180, 0, shells.apcbc);
|
|
proj.alive = true;
|
|
const destroyFn = jest.fn();
|
|
const sprite = { active: true, x: 320, y: 180, projectile: proj, destroy: destroyFn };
|
|
mg.projectileGroup.getChildren = jest.fn(() => [sprite]);
|
|
|
|
mg._checkPlayerProjectileHits(16);
|
|
|
|
// Projectile should be despawned
|
|
expect(proj.alive).toBe(false);
|
|
expect(destroyFn).toHaveBeenCalled();
|
|
});
|
|
|
|
it('APCBC penetration vs Type 59 armor: penetration=100, armor=120 → non-penetration, reduced damage', () => {
|
|
const mg = freshScene();
|
|
// Type 59 has armor=120, APCBC pen=100 → does not penetrate
|
|
const enemy = createEnemyTarget('type59', 320, 180); // armor=120
|
|
mg.enemies = [enemy];
|
|
|
|
const proj = new Projectile(320, 180, 0, shells.apcbc); // pen=100
|
|
proj.alive = true;
|
|
const sprite = { active: true, x: 320, y: 180, projectile: proj, destroy: jest.fn() };
|
|
mg.projectileGroup.getChildren = jest.fn(() => [sprite]);
|
|
|
|
const hpBefore = enemy.hp;
|
|
mg._checkPlayerProjectileHits(16);
|
|
|
|
// Did take damage but reduced (100 * 0.25 = 25)
|
|
const damageDealt = hpBefore - enemy.hp;
|
|
expect(damageDealt).toBeGreaterThan(0);
|
|
expect(damageDealt).toBeLessThan(100); // not full penetration damage
|
|
});
|
|
|
|
it('APCR penetration vs Type 62 armor: penetration=140, armor=40 → full penetration', () => {
|
|
const mg = freshScene();
|
|
// Type 62 has armor=40, APCR pen=140 → full penetration
|
|
const enemy = createEnemyTarget('type62', 320, 180); // armor=40, hp=100
|
|
mg.enemies = [enemy];
|
|
|
|
const proj = new Projectile(320, 180, 0, shells.apcr); // pen=140
|
|
proj.alive = true;
|
|
const sprite = { active: true, x: 320, y: 180, projectile: proj, destroy: jest.fn() };
|
|
mg.projectileGroup.getChildren = jest.fn(() => [sprite]);
|
|
|
|
mg._checkPlayerProjectileHits(16);
|
|
|
|
// Full damage — should kill the Type 62 (100 hp vs 140 pen)
|
|
expect(enemy.hp).toBe(0);
|
|
expect(enemy.active).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// Enemy projectile → tank hit (physics overlap)
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
|
|
describe('MainGame — physics.overlap for enemy projectiles', () => {
|
|
it('registers physics.overlap in create() for enemyProjectileGroup vs tank', () => {
|
|
const mg = freshScene();
|
|
expect(mg.physics.overlap).toHaveBeenCalledWith(
|
|
mg.enemyProjectileGroup,
|
|
mg.tank,
|
|
expect.any(Function),
|
|
null,
|
|
mg
|
|
);
|
|
});
|
|
|
|
it('_onEnemyProjHitTank destroys the enemy projectile sprite', () => {
|
|
const mg = freshScene();
|
|
const destroyFn = jest.fn();
|
|
const projSprite = { active: true, destroy: destroyFn };
|
|
|
|
mg._onEnemyProjHitTank(mg.tank, projSprite);
|
|
|
|
expect(destroyFn).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// Hatch hitbox — unbuttoned tank hit
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
|
|
describe('MainGame — hatch hitbox on enemy projectile hit', () => {
|
|
it('commanderHatch.takeHit() is called when unbuttoned and enemy proj hits tank', () => {
|
|
const mg = freshScene();
|
|
// Unbutton the commander
|
|
mg.commanderHatch.toggle();
|
|
expect(mg.commanderHatch.isUnbuttoned).toBe(true);
|
|
|
|
const takeHitSpy = jest.spyOn(mg.commanderHatch, 'takeHit');
|
|
const projSprite = { active: true, destroy: jest.fn() };
|
|
|
|
mg._onEnemyProjHitTank(mg.tank, projSprite);
|
|
|
|
expect(takeHitSpy).toHaveBeenCalled();
|
|
takeHitSpy.mockRestore();
|
|
});
|
|
|
|
it('commanderHatch.takeHit() is NOT called when buttoned up', () => {
|
|
const mg = freshScene();
|
|
// Ensure buttoned up (default)
|
|
expect(mg.commanderHatch.isUnbuttoned).toBe(false);
|
|
|
|
const takeHitSpy = jest.spyOn(mg.commanderHatch, 'takeHit');
|
|
const projSprite = { active: true, destroy: jest.fn() };
|
|
|
|
mg._onEnemyProjHitTank(mg.tank, projSprite);
|
|
|
|
expect(takeHitSpy).not.toHaveBeenCalled();
|
|
takeHitSpy.mockRestore();
|
|
});
|
|
|
|
it('forces hatch closed after unbuttoned hit (suppression)', () => {
|
|
const mg = freshScene();
|
|
mg.commanderHatch.toggle(); // unbutton
|
|
expect(mg.commanderHatch.isUnbuttoned).toBe(true);
|
|
|
|
const projSprite = { active: true, destroy: jest.fn() };
|
|
mg._onEnemyProjHitTank(mg.tank, projSprite);
|
|
|
|
// Hatch should be forced closed by takeHit()
|
|
expect(mg.commanderHatch.isUnbuttoned).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// Screen shake on tank hit
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
|
|
describe('MainGame — screen shake on damage', () => {
|
|
it('triggers camera shake when enemy projectile hits tank', () => {
|
|
const mg = freshScene();
|
|
const projSprite = { active: true, destroy: jest.fn() };
|
|
|
|
mg._onEnemyProjHitTank(mg.tank, projSprite);
|
|
|
|
expect(mg.cameras.main.shake).toHaveBeenCalled();
|
|
});
|
|
|
|
it('shake duration and intensity match expected values', () => {
|
|
const mg = freshScene();
|
|
const projSprite = { active: true, destroy: jest.fn() };
|
|
|
|
mg._onEnemyProjHitTank(mg.tank, projSprite);
|
|
|
|
// shake(100, 0.01) per spec
|
|
expect(mg.cameras.main.shake).toHaveBeenCalledWith(100, 0.01);
|
|
});
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// Tank death on HP reaching 0
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
|
|
describe('MainGame — tank death', () => {
|
|
it('sets tank dead flag when HP reaches 0 from enemy projectile hit', () => {
|
|
const mg = freshScene();
|
|
mg.tankHP = 1;
|
|
|
|
const projSprite = { active: true, destroy: jest.fn() };
|
|
mg._onEnemyProjHitTank(mg.tank, projSprite);
|
|
|
|
expect(mg.tankHP).toBe(0);
|
|
expect(mg.tankDead).toBe(true);
|
|
});
|
|
});
|