384 lines
11 KiB
JavaScript
384 lines
11 KiB
JavaScript
/**
|
|
* CollisionManager.js
|
|
* Handles all collision detection between game entities each frame:
|
|
* bullet↔terrain, bullet↔tank, bullet↔bullet, bullet↔base, tank↔tank.
|
|
*/
|
|
|
|
const {
|
|
TILE_SIZE,
|
|
MAP_OFFSET_X,
|
|
MAP_OFFSET_Y,
|
|
MAP_WIDTH,
|
|
MAP_HEIGHT,
|
|
TERRAIN,
|
|
GRID_ROWS,
|
|
GRID_COLS,
|
|
} = require('../base/GameGlobal');
|
|
|
|
class CollisionManager {
|
|
/**
|
|
* @param {object} deps
|
|
* @param {import('../managers/MapManager')} deps.mapManager
|
|
* @param {Function} deps.onExplosion - Callback(x, y, isBig) to spawn explosion.
|
|
* @param {import('../base/EventBus')} deps.eventBus
|
|
*/
|
|
constructor(deps) {
|
|
this._map = deps.mapManager;
|
|
this._onExplosion = deps.onExplosion;
|
|
this._eventBus = deps.eventBus;
|
|
}
|
|
|
|
/**
|
|
* Run all collision checks for one frame.
|
|
* @param {object} entities
|
|
* @param {import('../entities/PlayerTank')} entities.player
|
|
* @param {Array<import('../entities/Tank')>} entities.enemies
|
|
* @param {Array<import('../entities/Bullet')>} entities.bullets
|
|
*/
|
|
update(entities) {
|
|
const { player, enemies, bullets } = entities;
|
|
const aliveBullets = bullets.filter((b) => b.alive);
|
|
const aliveEnemies = enemies.filter((e) => e.alive);
|
|
|
|
// 1. Bullet ↔ Terrain / Base
|
|
this._checkBulletTerrain(aliveBullets);
|
|
|
|
// 2. Bullet ↔ Tank
|
|
this._checkBulletTank(aliveBullets, player, aliveEnemies);
|
|
|
|
// 3. Bullet ↔ Bullet (player vs enemy)
|
|
this._checkBulletBullet(aliveBullets);
|
|
|
|
// 4. Tank ↔ Tank (player vs enemies)
|
|
// Note: In classic tank game, tanks block each other but don't destroy on contact.
|
|
// Player death on contact is optional - we implement it per requirements.
|
|
this._checkTankTank(player, aliveEnemies);
|
|
}
|
|
|
|
/**
|
|
* Check bullets against terrain tiles.
|
|
* @private
|
|
*/
|
|
_checkBulletTerrain(bullets) {
|
|
for (const bullet of bullets) {
|
|
if (!bullet.alive) continue;
|
|
|
|
const { row, col } = this._map.pixelToGrid(bullet.x, bullet.y);
|
|
|
|
// Out of map bounds
|
|
if (row < 0 || row >= GRID_ROWS || col < 0 || col >= GRID_COLS) {
|
|
bullet.destroy();
|
|
this._onExplosion(bullet.x, bullet.y, false);
|
|
continue;
|
|
}
|
|
|
|
const terrain = this._map.getTerrain(row, col);
|
|
|
|
if (terrain === TERRAIN.BRICK) {
|
|
// Destroy brick
|
|
this._map.setTerrain(row, col, TERRAIN.EMPTY);
|
|
|
|
// Lv3 bullets destroy adjacent bricks too
|
|
if (bullet.canBreakSteel) {
|
|
this._destroyAdjacentBricks(row, col, bullet.direction);
|
|
}
|
|
|
|
bullet.destroy();
|
|
this._onExplosion(bullet.x, bullet.y, false);
|
|
} else if (terrain === TERRAIN.BASE_WALL) {
|
|
// Player bullets are immune to base wall
|
|
if (bullet.owner === 'player') {
|
|
bullet.destroy();
|
|
continue;
|
|
}
|
|
|
|
// Base wall has HP - use bulletHitTerrain for HP tracking
|
|
const result = this._map.bulletHitTerrain(row, col, bullet.canBreakSteel);
|
|
|
|
// Lv3 bullets also damage adjacent base walls
|
|
if (bullet.canBreakSteel) {
|
|
this._destroyAdjacentBricks(row, col, bullet.direction);
|
|
}
|
|
|
|
bullet.destroy();
|
|
this._onExplosion(bullet.x, bullet.y, false);
|
|
} else if (terrain === TERRAIN.STEEL) {
|
|
if (bullet.canBreakSteel) {
|
|
this._map.setTerrain(row, col, TERRAIN.EMPTY);
|
|
bullet.destroy();
|
|
this._onExplosion(bullet.x, bullet.y, false);
|
|
} else {
|
|
// Bullet blocked by steel
|
|
bullet.destroy();
|
|
this._onExplosion(bullet.x, bullet.y, false);
|
|
}
|
|
} else if (terrain === TERRAIN.BASE) {
|
|
// Player bullets are immune to base
|
|
if (bullet.owner === 'player') {
|
|
bullet.destroy();
|
|
continue;
|
|
}
|
|
|
|
// Base hit by enemy bullet!
|
|
this._map._baseDestroyed = true;
|
|
bullet.destroy();
|
|
this._onExplosion(bullet.x, bullet.y, true);
|
|
this._eventBus.emit('base:destroyed');
|
|
}
|
|
// RIVER and FOREST: bullets pass through
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Destroy adjacent bricks for Lv3 bullet splash.
|
|
* @private
|
|
*/
|
|
_destroyAdjacentBricks(row, col, direction) {
|
|
const { DIRECTION } = require('../base/GameGlobal');
|
|
const offsets =
|
|
direction === DIRECTION.UP || direction === DIRECTION.DOWN
|
|
? [[0, -1], [0, 1]] // horizontal neighbors
|
|
: [[-1, 0], [1, 0]]; // vertical neighbors
|
|
|
|
for (const [dr, dc] of offsets) {
|
|
const nr = row + dr;
|
|
const nc = col + dc;
|
|
const t = this._map.getTerrain(nr, nc);
|
|
if (t === TERRAIN.BRICK) {
|
|
this._map.setTerrain(nr, nc, TERRAIN.EMPTY);
|
|
} else if (t === TERRAIN.BASE_WALL) {
|
|
// Base wall has HP - use bulletHitTerrain for HP tracking
|
|
this._map.bulletHitTerrain(nr, nc, false);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check bullets against tanks.
|
|
* @private
|
|
*/
|
|
_checkBulletTank(bullets, player, enemies) {
|
|
for (const bullet of bullets) {
|
|
if (!bullet.alive) continue;
|
|
|
|
const bb = bullet.getBounds();
|
|
|
|
if (bullet.owner === 'player') {
|
|
// Player bullet hits enemy
|
|
for (const enemy of enemies) {
|
|
if (!enemy.alive) continue;
|
|
const eb = enemy.getBounds();
|
|
if (this._rectsOverlap(bb, eb)) {
|
|
const destroyed = enemy.takeDamage(1);
|
|
bullet.destroy();
|
|
if (destroyed) {
|
|
this._onExplosion(enemy.x, enemy.y, true);
|
|
this._eventBus.emit('enemy:destroyed', { enemy });
|
|
} else {
|
|
this._onExplosion(bullet.x, bullet.y, false);
|
|
this._eventBus.emit('enemy:hit', { enemy });
|
|
if (GameGlobal.audioManager) GameGlobal.audioManager.playSFX('hit');
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
// Enemy bullet hits player
|
|
if (player && player.alive) {
|
|
const pb = player.getBounds();
|
|
if (this._rectsOverlap(bb, pb)) {
|
|
const destroyed = player.takeDamage(1);
|
|
bullet.destroy();
|
|
if (destroyed) {
|
|
this._onExplosion(player.x, player.y, true);
|
|
this._eventBus.emit('player:destroyed');
|
|
} else {
|
|
this._onExplosion(bullet.x, bullet.y, false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check bullet-bullet collisions (player vs enemy bullets cancel out).
|
|
* @private
|
|
*/
|
|
_checkBulletBullet(bullets) {
|
|
for (let i = 0; i < bullets.length; i++) {
|
|
if (!bullets[i].alive) continue;
|
|
for (let j = i + 1; j < bullets.length; j++) {
|
|
if (!bullets[j].alive) continue;
|
|
|
|
// Only cancel if different owners
|
|
if (bullets[i].owner === bullets[j].owner) continue;
|
|
|
|
const a = bullets[i].getBounds();
|
|
const b = bullets[j].getBounds();
|
|
|
|
if (this._rectsOverlap(a, b)) {
|
|
const mx = (bullets[i].x + bullets[j].x) / 2;
|
|
const my = (bullets[i].y + bullets[j].y) / 2;
|
|
bullets[i].destroy();
|
|
bullets[j].destroy();
|
|
this._onExplosion(mx, my, false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check tank-tank collisions.
|
|
* Classic Battle City behavior: tanks block each other on contact,
|
|
* they are pushed apart so they don't overlap. No damage is dealt.
|
|
* @private
|
|
*/
|
|
_checkTankTank(player, enemies) {
|
|
if (!player || !player.alive) return;
|
|
|
|
for (const enemy of enemies) {
|
|
if (!enemy.alive) continue;
|
|
if (player.collidesWith(enemy)) {
|
|
// Push tanks apart — resolve overlap along the axis with smallest penetration
|
|
this._separateTanks(player, enemy);
|
|
}
|
|
}
|
|
|
|
// Also prevent enemies from overlapping each other
|
|
for (let i = 0; i < enemies.length; i++) {
|
|
if (!enemies[i].alive) continue;
|
|
for (let j = i + 1; j < enemies.length; j++) {
|
|
if (!enemies[j].alive) continue;
|
|
if (enemies[i].collidesWith(enemies[j])) {
|
|
this._separateTanks(enemies[i], enemies[j]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a tank position is valid (within map bounds and not colliding with terrain).
|
|
* @private
|
|
*/
|
|
_isPositionValid(tank, x, y) {
|
|
const hs = tank.halfSize;
|
|
const left = x - hs;
|
|
const top = y - hs;
|
|
const right = x + hs;
|
|
const bottom = y + hs;
|
|
|
|
// Map boundary check
|
|
if (
|
|
left < MAP_OFFSET_X ||
|
|
top < MAP_OFFSET_Y ||
|
|
right > MAP_OFFSET_X + MAP_WIDTH ||
|
|
bottom > MAP_OFFSET_Y + MAP_HEIGHT
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
// Terrain collision check
|
|
if (this._map.rectCollidesWithTerrain(left, top, tank.size, tank.size)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Push two overlapping tanks apart along the axis of least penetration.
|
|
* Validates new positions against map bounds and terrain before applying.
|
|
* @private
|
|
*/
|
|
_separateTanks(tankA, tankB) {
|
|
const a = tankA.getBounds();
|
|
const b = tankB.getBounds();
|
|
|
|
// Calculate overlap on each axis
|
|
const overlapX = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
|
|
const overlapY = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
|
|
|
|
if (overlapX <= 0 || overlapY <= 0) return; // no real overlap
|
|
|
|
if (overlapX < overlapY) {
|
|
// Separate along X axis
|
|
const sign = tankA.x < tankB.x ? -1 : 1;
|
|
const halfPush = overlapX / 2;
|
|
|
|
const newAx = tankA.x + sign * halfPush;
|
|
const newBx = tankB.x - sign * halfPush;
|
|
|
|
const aValid = this._isPositionValid(tankA, newAx, tankA.y);
|
|
const bValid = this._isPositionValid(tankB, newBx, tankB.y);
|
|
|
|
if (aValid && bValid) {
|
|
tankA.x = newAx;
|
|
tankB.x = newBx;
|
|
} else if (aValid && !bValid) {
|
|
// B can't move, push A the full overlap
|
|
const fullAx = tankA.x + sign * overlapX;
|
|
if (this._isPositionValid(tankA, fullAx, tankA.y)) {
|
|
tankA.x = fullAx;
|
|
} else {
|
|
tankA.x = newAx; // at least push half
|
|
}
|
|
} else if (!aValid && bValid) {
|
|
// A can't move, push B the full overlap
|
|
const fullBx = tankB.x - sign * overlapX;
|
|
if (this._isPositionValid(tankB, fullBx, tankB.y)) {
|
|
tankB.x = fullBx;
|
|
} else {
|
|
tankB.x = newBx; // at least push half
|
|
}
|
|
}
|
|
// If neither is valid, don't move either (both stuck)
|
|
} else {
|
|
// Separate along Y axis
|
|
const sign = tankA.y < tankB.y ? -1 : 1;
|
|
const halfPush = overlapY / 2;
|
|
|
|
const newAy = tankA.y + sign * halfPush;
|
|
const newBy = tankB.y - sign * halfPush;
|
|
|
|
const aValid = this._isPositionValid(tankA, tankA.x, newAy);
|
|
const bValid = this._isPositionValid(tankB, tankB.x, newBy);
|
|
|
|
if (aValid && bValid) {
|
|
tankA.y = newAy;
|
|
tankB.y = newBy;
|
|
} else if (aValid && !bValid) {
|
|
const fullAy = tankA.y + sign * overlapY;
|
|
if (this._isPositionValid(tankA, tankA.x, fullAy)) {
|
|
tankA.y = fullAy;
|
|
} else {
|
|
tankA.y = newAy;
|
|
}
|
|
} else if (!aValid && bValid) {
|
|
const fullBy = tankB.y - sign * overlapY;
|
|
if (this._isPositionValid(tankB, tankB.x, fullBy)) {
|
|
tankB.y = fullBy;
|
|
} else {
|
|
tankB.y = newBy;
|
|
}
|
|
}
|
|
// If neither is valid, don't move either (both stuck)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* AABB overlap test.
|
|
* @private
|
|
*/
|
|
_rectsOverlap(a, b) {
|
|
return (
|
|
a.x < b.x + b.w &&
|
|
a.x + a.w > b.x &&
|
|
a.y < b.y + b.h &&
|
|
a.y + a.h > b.y
|
|
);
|
|
}
|
|
}
|
|
|
|
module.exports = CollisionManager;
|