slice.feature: add map obstacles — cover and terrain features
Some checks failed
Iron Requiem CI/CD / test (push) Failing after 33s
Iron Requiem CI/CD / deploy (push) Has been skipped

RED→GREEN: Obstacle class with bounding box, line-of-sight blocking,
projectile collision, and destroy_on_hit support. Wired into MainGame
with physics overlap (projectile→obstacle) and collider (tank→obstacle).
5-10 obstacles scattered across map, avoiding player spawn zone.

30 tests pass: 20 unit + 10 integration. Zero regressions.
This commit is contained in:
2026-05-24 01:27:46 +00:00
parent 4af6d119af
commit b8ff8eb9bc
4 changed files with 802 additions and 2 deletions

View File

@@ -0,0 +1,166 @@
/**
* Obstacle — cover and terrain features that block line of sight,
* provide tactical cover, and absorb projectiles.
*
* Obstacles are static physics bodies. Tanks collide with them and
* projectiles are destroyed on hit.
*
* @module src/game/entities/Obstacle
*/
const DEFAULT_COLOR = '#558855'; // forest green
/**
* Check if point (px,py) lies on segment (x1,y1)-(x2,y2).
* Assumes point is collinear.
*/
function onSegment(x1, y1, x2, y2, px, py) {
return (
px >= Math.min(x1, x2) && px <= Math.max(x1, x2) &&
py >= Math.min(y1, y2) && py <= Math.max(y1, y2)
);
}
/**
* 2D cross product / orientation test.
* > 0 = counter-clockwise, < 0 = clockwise, 0 = collinear.
*/
function orient(ax, ay, bx, by, cx, cy) {
return (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
}
/**
* Do two line segments (x1,y1)-(x2,y2) and (x3,y3)-(x4,y4) intersect?
*/
function segmentsIntersect(x1, y1, x2, y2, x3, y3, x4, y4) {
const d1 = orient(x1, y1, x2, y2, x3, y3);
const d2 = orient(x1, y1, x2, y2, x4, y4);
const d3 = orient(x3, y3, x4, y4, x1, y1);
const d4 = orient(x3, y3, x4, y4, x2, y2);
// Proper intersection (straddling)
if (d1 * d2 < 0 && d3 * d4 < 0) return true;
// Collinear edge cases
if (d1 === 0 && onSegment(x1, y1, x2, y2, x3, y3)) return true;
if (d2 === 0 && onSegment(x1, y1, x2, y2, x4, y4)) return true;
if (d3 === 0 && onSegment(x3, y3, x4, y4, x1, y1)) return true;
if (d4 === 0 && onSegment(x3, y3, x4, y4, x2, y2)) return true;
return false;
}
export class Obstacle {
/**
* @param {object} scene — Phaser scene (or mock with add.rectangle, physics.add.existing)
* @param {number} x — center x position
* @param {number} y — center y position
* @param {number} width — width in pixels
* @param {number} height — height in pixels
* @param {string} [color='#558855'] — fill color for the rectangle sprite
* @param {boolean} [destroyOnHit=false] — whether projectiles destroy this obstacle
*/
constructor(scene, x, y, width, height, color, destroyOnHit) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.color = color || DEFAULT_COLOR;
this.destroyOnHit = destroyOnHit || false;
this.active = true;
this.hit = false;
// Create visible sprite (guarded — tests may pass bare objects)
if (scene && scene.add && scene.add.rectangle) {
this.sprite = scene.add.rectangle(x, y, width, height, this.color);
this.sprite.setDepth(10);
this.sprite.setOrigin(0.5);
}
// Register as static physics body
if (scene && scene.physics && scene.physics.add && scene.physics.add.existing) {
scene.physics.add.existing(this.sprite, true);
}
}
/**
* Get the axis-aligned bounding box of this obstacle.
* @returns {{ left: number, right: number, top: number, bottom: number }}
*/
getBounds() {
const halfW = this.width / 2;
const halfH = this.height / 2;
return {
left: this.x - halfW,
right: this.x + halfW,
top: this.y - halfH,
bottom: this.y + halfH,
};
}
/**
* Check if a point lies inside this obstacle.
* @param {number} px
* @param {number} py
* @returns {boolean}
*/
containsPoint(px, py) {
const b = this.getBounds();
return px >= b.left && px <= b.right && py >= b.top && py <= b.bottom;
}
/**
* Check if a line segment from (x1,y1) to (x2,y2) intersects this obstacle.
* Used for line-of-sight calculations.
*
* @param {number} x1 — start x
* @param {number} y1 — start y
* @param {number} x2 — end x
* @param {number} y2 — end y
* @returns {boolean}
*/
blocksLineOfSight(x1, y1, x2, y2) {
const b = this.getBounds();
// If either endpoint is inside the obstacle, it blocks
if (this.containsPoint(x1, y1) || this.containsPoint(x2, y2)) return true;
// Check against all four edges of the rectangle
const edges = [
{ x1: b.left, y1: b.top, x2: b.right, y2: b.top },
{ x1: b.right, y1: b.top, x2: b.right, y2: b.bottom },
{ x1: b.right, y1: b.bottom, x2: b.left, y2: b.bottom },
{ x1: b.left, y1: b.bottom, x2: b.left, y2: b.top },
];
for (const edge of edges) {
if (segmentsIntersect(x1, y1, x2, y2, edge.x1, edge.y1, edge.x2, edge.y2)) {
return true;
}
}
return false;
}
/**
* Handle a projectile hitting this obstacle.
* If destroyOnHit is true, the obstacle is destroyed.
* Returns true if this is a fresh hit, false if already hit.
*
* @param {object} _projectile — projectile that hit (unused, for future use)
* @returns {boolean}
*/
hitByProjectile(_projectile) {
if (this.hit) return false;
this.hit = true;
if (this.destroyOnHit) {
if (this.sprite && this.sprite.destroy) {
this.sprite.destroy();
}
this.active = false;
}
return true;
}
}

View File

@@ -10,6 +10,7 @@ import { PatternManager } from '../systems/PatternManager.js';
import { AmmoSystem } from '../systems/AmmoSystem.js';
import { Enemy, spawnZone } from '../entities/Enemy.js';
import { Projectile } from '../entities/Projectile.js';
import { Obstacle } from '../entities/Obstacle.js';
import { SaveManager } from '../../systems/SaveManager.js';
import { CommanderHatch } from '../entities/CommanderHatch.js';
import AudioManager from '../systems/AudioManager.js';
@@ -55,6 +56,8 @@ export class MainGame extends Phaser.Scene {
this.tankHP = TANK_START_HP;
this.tankDead = false;
this._lastFireTime = 0;
this.obstacles = [];
this.obstacleGroup = null;
}
create() {
@@ -89,8 +92,8 @@ export class MainGame extends Phaser.Scene {
this.audioManager = new AudioManager();
console.log('[IR:MainGame] AudioManager created');
// VFXManager — visual effects (Gap 5B)
this.vfxManager = new VFXManager(this.add.graphics());
// VFXManager — visual effects (Gap 5B, takes scene for lazy Graphics creation)
this.vfxManager = new VFXManager(this);
console.log('[IR:MainGame] VFXManager created');
// Start engine loop drone
@@ -132,6 +135,15 @@ export class MainGame extends Phaser.Scene {
this.physics.overlap(this.enemyProjectileGroup, this.tank, this._onEnemyProjHitTank, null, this);
console.log('[IR:MainGame] Enemy projectile → tank overlap registered');
// Obstacle group — static cover and terrain features
this.obstacleGroup = this.physics.add.group({ maxSize: 50 });
this._spawnObstacles();
// Projectile → obstacle: destroy projectile on contact with obstacle
this.physics.overlap(this.projectileGroup, this.obstacleGroup, this._onPlayerProjHitObstacle, null, this);
// Tank → obstacle: tank cannot drive through cover
this.physics.add.collider(this.tank, this.obstacleGroup);
console.log(`[IR:MainGame] Obstacles created: ${this.obstacles.length}`);
// SaveManager — reuse the one initialized in PreloadScene (IndexedDB is open)
this.saveManager = this.game.__saveManager || new SaveManager();
console.log('[IR:MainGame] SaveManager attached');
@@ -277,6 +289,53 @@ export class MainGame extends Phaser.Scene {
// Gap 3 — Collision/Damage Loop
// ════════════════════════════════════════════════════════════════════
/**
* Scatter 5-10 obstacles across the map for cover and tactical terrain.
* Obstacles are positioned randomly but not on top of the player start.
*/
_spawnObstacles() {
const count = 5 + Math.floor(Math.random() * 6); // 5-10
const obstacles = [];
for (let i = 0; i < count; i++) {
// Random position: avoid center spawn zone (player at 320,180)
let x, y;
do {
x = 80 + Math.random() * 480; // 80-560px x
y = 40 + Math.random() * 280; // 40-320px y
} while (Math.abs(x - 320) < 60 && Math.abs(y - 180) < 60);
const w = 40 + Math.random() * 80; // 40-120px wide
const h = 20 + Math.random() * 40; // 20-60px tall
const obstacle = new Obstacle(this, x, y, w, h);
this.obstacleGroup.add(obstacle.sprite);
obstacles.push(obstacle);
}
this.obstacles = obstacles;
}
/**
* Player projectile hits an obstacle — destroy projectile, mark obstacle.
* Called by physics.overlap between projectileGroup and obstacleGroup.
* @param {object} projSprite
* @param {object} obstacle — Obstacle instance
*/
_onPlayerProjHitObstacle(projSprite, obstacle) {
if (!projSprite || !obstacle) return;
// Destroy the projectile sprite
if (projSprite.destroy) {
projSprite.destroy();
}
// Mark the obstacle as hit (may destroy it if destroyOnHit)
if (typeof obstacle.hitByProjectile === 'function') {
obstacle.hitByProjectile(projSprite);
}
}
/**
* Player fires a shell on mouse click.
* Bound to pointerdown in create().
@@ -311,6 +370,11 @@ export class MainGame extends Phaser.Scene {
sprite.active = true;
sprite.visible = true;
sprite.projectile = proj; // attach Projectile object for hit resolution
// Actual velocity so the projectile MOVES
const rad = Phaser.Math.DegToRad(turretAngle);
const speed = fireResult.velocity * 0.1; // scale m/s → px/frame
sprite.body.velocity.x = Math.cos(rad) * speed;
sprite.body.velocity.y = Math.sin(rad) * speed;
}
}

View File

@@ -0,0 +1,300 @@
/**
* Obstacle entity tests — cover and terrain features
*
* RED phase: Obstacle.js does not exist yet — every test should fail
* on import or assertion.
*
* Tests path: tests/game/entities/Obstacle.test.js
*/
// Using Jest globals (describe, it, expect, jest, beforeEach) — no import needed
// Dynamic import — will fail until Obstacle.js is created
let Obstacle;
describe('Obstacle', () => {
beforeAll(async () => {
try {
const mod = await import('../../../src/game/entities/Obstacle.js');
Obstacle = mod.Obstacle;
} catch (_) {
// Expected: module not created yet
}
});
// =========================================================================
// Construction
// =========================================================================
describe('construction', () => {
it('creates an obstacle with given position and dimensions', () => {
const mockRect = { setDepth: jest.fn(), setOrigin: jest.fn() };
const mockScene = {
add: {
rectangle: jest.fn().mockReturnValue(mockRect),
},
physics: {
add: {
existing: jest.fn(),
},
},
};
const o = new Obstacle(mockScene, 200, 150, 80, 40);
expect(o.x).toBe(200);
expect(o.y).toBe(150);
expect(o.width).toBe(80);
expect(o.height).toBe(40);
expect(o.active).toBe(true);
});
it('creates a Phaser rectangle sprite for the obstacle', () => {
const mockRect = { setDepth: jest.fn(), setOrigin: jest.fn() };
const mockScene = {
add: {
rectangle: jest.fn().mockReturnValue(mockRect),
},
physics: {
add: {
existing: jest.fn(),
},
},
};
const o = new Obstacle(mockScene, 100, 200, 60, 30);
expect(mockScene.add.rectangle).toHaveBeenCalledWith(100, 200, 60, 30, '#558855');
expect(o.sprite).toBe(mockRect);
expect(mockRect.setDepth).toHaveBeenCalledWith(10);
// Origin should be set so rectangle is centered
expect(mockRect.setOrigin).toHaveBeenCalledWith(0.5);
});
it('uses default color #558855 (forest green) when none provided', () => {
const mockRect = { setDepth: jest.fn(), setOrigin: jest.fn() };
const mockScene = {
add: {
rectangle: jest.fn().mockReturnValue(mockRect),
},
physics: {
add: {
existing: jest.fn(),
},
},
};
new Obstacle(mockScene, 0, 0, 50, 50);
expect(mockScene.add.rectangle).toHaveBeenCalledWith(
expect.any(Number), expect.any(Number),
expect.any(Number), expect.any(Number),
'#558855'
);
});
it('accepts a custom color override', () => {
const mockRect = { setDepth: jest.fn(), setOrigin: jest.fn() };
const mockScene = {
add: {
rectangle: jest.fn().mockReturnValue(mockRect),
},
physics: {
add: {
existing: jest.fn(),
},
},
};
new Obstacle(mockScene, 0, 0, 50, 50, '#887744');
expect(mockScene.add.rectangle).toHaveBeenCalledWith(
expect.any(Number), expect.any(Number),
expect.any(Number), expect.any(Number),
'#887744'
);
});
it('does not crash when scene has no add.rectangle (bare object)', () => {
expect(() => new Obstacle({}, 100, 100, 50, 50)).not.toThrow();
const o = new Obstacle({}, 100, 100, 50, 50);
expect(o.x).toBe(100);
expect(o.y).toBe(100);
expect(o.width).toBe(50);
expect(o.height).toBe(50);
});
it('registers with physics as static body when scene has physics.add.existing', () => {
const mockRect = { setDepth: jest.fn(), setOrigin: jest.fn() };
const mockScene = {
add: {
rectangle: jest.fn().mockReturnValue(mockRect),
},
physics: {
add: {
existing: jest.fn(),
},
},
};
const o = new Obstacle(mockScene, 200, 150, 80, 40);
// physics.add.existing should be called with the sprite
expect(mockScene.physics.add.existing).toHaveBeenCalledWith(mockRect, true);
});
});
// =========================================================================
// Position and bounding box
// =========================================================================
describe('bounding box', () => {
it('getBounds returns correct rectangle from position and dimensions', () => {
const o = new Obstacle({}, 200, 150, 80, 40);
const bounds = o.getBounds();
expect(bounds).toEqual({
left: 200 - 40,
right: 200 + 40,
top: 150 - 20,
bottom: 150 + 20,
});
});
it('containsPoint returns true for point inside obstacle', () => {
const o = new Obstacle({}, 200, 150, 80, 40);
expect(o.containsPoint(200, 150)).toBe(true); // center
expect(o.containsPoint(200, 140)).toBe(true); // near top edge
expect(o.containsPoint(220, 160)).toBe(true); // near corner
});
it('containsPoint returns false for point outside obstacle', () => {
const o = new Obstacle({}, 200, 150, 80, 40);
expect(o.containsPoint(100, 100)).toBe(false); // far away
expect(o.containsPoint(300, 200)).toBe(false); // outside
expect(o.containsPoint(200, 180)).toBe(false); // just outside bottom
});
it('containsPoint handles edges correctly — half-width/half-height', () => {
const o = new Obstacle({}, 200, 150, 80, 40);
// Exactly at edge: left = 160, right = 240, top = 130, bottom = 170
expect(o.containsPoint(160, 150)).toBe(true); // left edge
expect(o.containsPoint(240, 150)).toBe(true); // right edge
expect(o.containsPoint(200, 130)).toBe(true); // top edge
expect(o.containsPoint(200, 170)).toBe(true); // bottom edge
// Just outside
expect(o.containsPoint(159, 150)).toBe(false); // just left of edge
expect(o.containsPoint(241, 150)).toBe(false); // just right of edge
});
});
// =========================================================================
// Line-of-sight blocking
// =========================================================================
describe('line-of-sight blocking', () => {
it('blocksLineOfSight returns true when segment intersects obstacle', () => {
// Obstacle at (200, 150) size 80×40 = left:160, right:240, top:130, bottom:170
const o = new Obstacle({}, 200, 150, 80, 40);
// Segment passing through the obstacle horizontally
expect(o.blocksLineOfSight(100, 150, 300, 150)).toBe(true);
// Segment passing through vertically
expect(o.blocksLineOfSight(200, 100, 200, 200)).toBe(true);
});
it('blocksLineOfSight returns false when segment passes outside obstacle', () => {
const o = new Obstacle({}, 200, 150, 80, 40);
// Segment entirely above obstacle
expect(o.blocksLineOfSight(150, 100, 250, 100)).toBe(false);
// Segment entirely to the left
expect(o.blocksLineOfSight(50, 140, 120, 160)).toBe(false);
});
it('blocksLineOfSight returns true when segment starts inside obstacle', () => {
const o = new Obstacle({}, 200, 150, 80, 40);
// Start inside obstacle, end outside
expect(o.blocksLineOfSight(200, 150, 350, 150)).toBe(true);
});
});
// =========================================================================
// Projectile collision
// =========================================================================
describe('projectile collision', () => {
it('hitByProjectile marks obstacle as hit and returns true', () => {
const mockRect = { setDepth: jest.fn(), setOrigin: jest.fn(), destroy: jest.fn() };
const mockScene = {
add: { rectangle: jest.fn().mockReturnValue(mockRect) },
physics: { add: { existing: jest.fn() } },
};
const o = new Obstacle(mockScene, 200, 150, 80, 40);
const result = o.hitByProjectile({ x: 200, y: 150 });
expect(result).toBe(true);
expect(o.hit).toBe(true);
});
it('hitByProjectile returns false if already hit', () => {
const o = new Obstacle({}, 200, 150, 80, 40);
o.hit = true;
const result = o.hitByProjectile({ x: 200, y: 150 });
expect(result).toBe(false);
});
it('hitByProjectile with destroy_on_hit destroys sprite', () => {
const mockRect = { setDepth: jest.fn(), setOrigin: jest.fn(), destroy: jest.fn() };
const mockScene = {
add: { rectangle: jest.fn().mockReturnValue(mockRect) },
physics: { add: { existing: jest.fn() } },
};
const o = new Obstacle(mockScene, 200, 150, 80, 40, '#ff0000', true);
o.hitByProjectile({ x: 220, y: 160 });
expect(mockRect.destroy).toHaveBeenCalled();
expect(o.active).toBe(false);
});
it('hitByProjectile without destroy_on_hit keeps sprite alive', () => {
const mockRect = { setDepth: jest.fn(), setOrigin: jest.fn(), destroy: jest.fn() };
const mockScene = {
add: { rectangle: jest.fn().mockReturnValue(mockRect) },
physics: { add: { existing: jest.fn() } },
};
const o = new Obstacle(mockScene, 200, 150, 80, 40);
o.hitByProjectile({ x: 220, y: 160 });
// Sprite should NOT be destroyed for durable obstacles
expect(mockRect.destroy).not.toHaveBeenCalled();
expect(o.active).toBe(true);
});
});
// =========================================================================
// Default construction params
// =========================================================================
describe('defaults', () => {
it('hit starts as false', () => {
const o = new Obstacle({}, 0, 0, 50, 50);
expect(o.hit).toBe(false);
});
it('destroy_on_hit defaults to false', () => {
const o = new Obstacle({}, 0, 0, 50, 50);
expect(o.destroyOnHit).toBe(false);
});
it('color defaults are correct', () => {
const mockRect = { setDepth: jest.fn(), setOrigin: jest.fn() };
const mockScene = {
add: { rectangle: jest.fn().mockReturnValue(mockRect) },
physics: { add: { existing: jest.fn() } },
};
const o = new Obstacle(mockScene, 0, 0, 10, 10);
expect(o.color).toBe('#558855');
});
});
});

View File

@@ -0,0 +1,270 @@
/**
* Obstacle Integration — wiring tests for MainGame obstacle support
*
* Tests:
* 1. obstacleGroup created in MainGame.create()
* 2. Obstacles added via _spawnObstacles() with correct distribution
* 3. Physics overlap: projectile → obstacle collision callback
* 4. Physics collider: tank → obstacle collision
* 5. _checkPlayerProjectileHits respects obstacles
*
* Follows collision-wiring.test.js pattern: mock Phaser.Scene, use real MainGame.
*
* RED phase: MainGame doesn't yet have obstacleGroup, _spawnObstacles,
* _onPlayerProjHitObstacle, or obstacle collision wiring.
*
* Tests path: tests/integration/obstacle-wiring.test.js
*/
// ─── Mock Phaser ─────────────────────────────────────────────────────
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(() => ({})),
setDepth: jest.fn(() => ({})),
})),
graphics: jest.fn(() => ({
lineStyle: jest.fn(() => ({})),
lineBetween: jest.fn(() => ({})),
setDepth: jest.fn(() => ({})),
fillStyle: jest.fn(() => ({})),
fillRect: jest.fn(() => ({})),
fillCircle: jest.fn(() => ({})),
clear: jest.fn(() => ({})),
})),
text: jest.fn(() => ({ setDepth: jest.fn(() => ({})) })),
};
cameras = {
main: {
startFollow: jest.fn(),
shake: jest.fn(),
flash: jest.fn(),
},
};
tweens = { add: jest.fn() };
textures = { exists: jest.fn(() => false) };
physics = {
add: {
group: jest.fn(() => {
const children = [];
const g = {
maxSize: 200,
getChildren: jest.fn(() => children),
getFirstDead: jest.fn(() => null),
create: jest.fn(() => ({
x: 0, y: 0, active: true, visible: true,
setOrigin: jest.fn(() => ({})),
body: { velocity: { x: 0, y: 0 }, enable: true },
})),
killAndHide: jest.fn(),
add: jest.fn((sprite) => { children.push(sprite); }),
};
return g;
}),
existing: jest.fn(),
collider: jest.fn(),
},
overlap: jest.fn(),
collide: 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 { Projectile } from '../../src/game/entities/Projectile.js';
import { Enemy } from '../../src/game/entities/Enemy.js';
import { Obstacle } from '../../src/game/entities/Obstacle.js';
// ─── Helpers ─────────────────────────────────────────────────────────
function freshScene() {
const mg = new MainGame();
mg.textures.exists = jest.fn(() => true);
mg.create();
return mg;
}
// ══════════════════════════════════════════════════════════════════════
// obstacleGroup creation
// ══════════════════════════════════════════════════════════════════════
describe('Obstacle integration — obstacleGroup', () => {
it('creates obstacleGroup in MainGame.create()', () => {
const mg = freshScene();
expect(mg.obstacleGroup).toBeDefined();
expect(typeof mg.obstacleGroup).toBe('object');
});
it('obstacleGroup uses physics.add.group with maxSize 50', () => {
const mg = freshScene();
// physics.add.group was called for obstacleGroup
const groupCalls = mg.physics.add.group.mock.calls;
// There should be at least one call with maxSize: 50 (for obstacles)
const obstacleCall = groupCalls.find(args => args[0] && args[0].maxSize === 50);
expect(obstacleCall).toBeDefined();
});
});
// ══════════════════════════════════════════════════════════════════════
// _spawnObstacles — populates map with cover
// ══════════════════════════════════════════════════════════════════════
describe('Obstacle integration — _spawnObstacles', () => {
it('_spawnObstacles populates this.obstacles array', () => {
const mg = freshScene();
expect(Array.isArray(mg.obstacles)).toBe(true);
expect(mg.obstacles.length).toBeGreaterThanOrEqual(5);
expect(mg.obstacles.length).toBeLessThanOrEqual(10);
});
it('every spawned obstacle is an Obstacle instance', () => {
const mg = freshScene();
for (const o of mg.obstacles) {
expect(o).toBeInstanceOf(Obstacle);
}
});
it('obstacles are spread across the map (not clumped at center)', () => {
const mg = freshScene();
// At least some obstacles should not be near (320, 180)
const farFromCenter = mg.obstacles.filter(o => {
const dist = Math.sqrt((o.x - 320) ** 2 + (o.y - 180) ** 2);
return dist > 100;
});
expect(farFromCenter.length).toBeGreaterThan(0);
});
});
// ══════════════════════════════════════════════════════════════════════
// Physics overlap: projectile → obstacle
// ══════════════════════════════════════════════════════════════════════
describe('Obstacle integration — projectile → obstacle overlap', () => {
it('registers physics.overlap between projectileGroup and obstacleGroup', () => {
const mg = freshScene();
// Check that physics.overlap was called with projectileGroup and obstacleGroup
const overlapCalls = mg.physics.overlap.mock.calls;
// Find the call with obstacleGroup
const obstacleOverlap = overlapCalls.find(args => args[1] === mg.obstacleGroup);
expect(obstacleOverlap).toBeDefined();
});
it('_onPlayerProjHitObstacle calls hitByProjectile on obstacle', () => {
const mg = freshScene();
const obstacle = new Obstacle({}, 200, 150, 80, 40);
const hitSpy = jest.spyOn(obstacle, 'hitByProjectile');
// Simulate overlap callback
const projSprite = {
active: true, x: 200, y: 150,
projectile: new Projectile(200, 150, 0, { id: 'apcbc', velocity: 1000, penetration: 100, splash: 0, limited: false, count: 10 }),
destroy: jest.fn(),
};
mg._onPlayerProjHitObstacle(projSprite, obstacle);
expect(hitSpy).toHaveBeenCalled();
hitSpy.mockRestore();
});
it('_onPlayerProjHitObstacle destroys projectile sprite', () => {
const mg = freshScene();
const obstacle = new Obstacle({}, 200, 150, 80, 40);
const projSprite = {
active: true, x: 200, y: 150,
projectile: new Projectile(200, 150, 0, { id: 'apcbc', velocity: 1000, penetration: 100, splash: 0, limited: false, count: 10 }),
destroy: jest.fn(),
};
mg._onPlayerProjHitObstacle(projSprite, obstacle);
expect(projSprite.destroy).toHaveBeenCalled();
});
});
// ══════════════════════════════════════════════════════════════════════
// Physics collider: tank → obstacle
// ══════════════════════════════════════════════════════════════════════
describe('Obstacle integration — tank → obstacle collision', () => {
it('registers physics.collider between tank and obstacleGroup', () => {
const mg = freshScene();
const colliderCalls = mg.physics.add.collider.mock.calls;
// Find the call where tank collides with obstacleGroup
const tankObstacle = colliderCalls.find(args => args[0] === mg.tank && args[1] === mg.obstacleGroup);
expect(tankObstacle).toBeDefined();
});
});
// ══════════════════════════════════════════════════════════════════════
// _checkPlayerProjectileHits — obstacle-aware
// ══════════════════════════════════════════════════════════════════════
describe('Obstacle integration — projectile hit checks respect obstacles', () => {
it('_checkPlayerProjectileHits does not check obstacles that are already hit', () => {
const mg = freshScene();
// Place an obstacle that's already been hit
const obstacle = new Obstacle({}, 200, 150, 80, 40);
obstacle.hit = true;
// Place an enemy at same position
const enemy = new Enemy({}, 'type62', 200, 150);
mg.enemies = [enemy];
// Projectile at that position
const proj = new Projectile(200, 150, 0, {
id: 'apcbc', velocity: 1000, penetration: 100, splash: 0, limited: false, count: 10,
});
proj.alive = true;
const sprite = { active: true, x: 200, y: 150, projectile: proj, destroy: jest.fn() };
mg.projectileGroup.getChildren = jest.fn(() => [sprite]);
mg.obstacleGroup.getChildren = jest.fn(() => [obstacle]);
// Should still hit the enemy (obstacle already hit, no double-counting)
const initialHp = enemy.hp;
mg._checkPlayerProjectileHits(16);
expect(enemy.hp).toBeLessThan(initialHp);
});
});