first commit
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user