/** * 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} entities.enemies * @param {Array} 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;