slice.feature: add map obstacles — cover and terrain features
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:
166
src/game/entities/Obstacle.js
Normal file
166
src/game/entities/Obstacle.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
300
tests/game/entities/Obstacle.test.js
Normal file
300
tests/game/entities/Obstacle.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
270
tests/integration/obstacle-wiring.test.js
Normal file
270
tests/integration/obstacle-wiring.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user