/** * BotTank.js * AI-controlled bot tank for 3v3 team mode. * Used to fill empty slots when matchmaking times out, * or to take over disconnected players. * Reuses EnemyTank-style AI logic adapted for team play. */ const Tank = require('./Tank'); const { TANK_CONFIG, TANK_TYPE, DIRECTION, DIR_VECTORS, TILE_SIZE, MAP_OFFSET_X, MAP_OFFSET_Y, } = require('../base/GameGlobal'); /** AI States */ const BOT_STATE = { PATROL: 'patrol', ATTACK_BASE: 'attack_base', DEFEND: 'defend', }; class BotTank extends Tank { /** * @param {object} params * @param {number} params.col - Spawn grid column. * @param {number} params.row - Spawn grid row. * @param {string} params.team - 'A' or 'B'. * @param {string} params.playerId - Bot player id. * @param {string} [params.color] - Tank color override. */ constructor(params) { const cfg = TANK_CONFIG[TANK_TYPE.PLAYER]; const spawnX = MAP_OFFSET_X + params.col * TILE_SIZE + TILE_SIZE / 2; const spawnY = MAP_OFFSET_Y + params.row * TILE_SIZE + TILE_SIZE / 2; super({ x: spawnX, y: spawnY, speed: cfg.speed * 0.9, // Slightly slower than human players hp: cfg.hp, color: params.color || (params.team === 'A' ? '#4A90D9' : '#E94560'), size: cfg.size, direction: params.team === 'A' ? DIRECTION.RIGHT : DIRECTION.LEFT, }); this.team = params.team; this.playerId = params.playerId; this.lives = 999; // Unlimited lives for 3v3 // AI state this._botState = BOT_STATE.PATROL; this._moveTimer = 0; this._dirChangeInterval = 1.5 + Math.random() * 2; this._shootTimer = 0; this._shootInterval = 1.2 + Math.random() * 1.5; this._stuckTimer = 0; this._lastX = spawnX; this._lastY = spawnY; // Active bullets tracking this.activeBullets = 0; this._maxBullets = 1; // Target (enemy base position) this._targetBase = null; // Shield (invincibility) — same as PlayerTank this._shieldTimer = 0; this._shieldBlink = false; this._blinkTimer = 0; } /** * Activate shield (invincibility). * @param {number} duration - Duration in ms. */ activateShield(duration) { this._shieldTimer = duration; this._blinkTimer = 0; this._shieldBlink = false; } /** * Update bot tank state (shield timer etc.). * @param {number} dt - Delta time in seconds. */ update(dt) { if (!this.alive) return; // Shield timer (same as PlayerTank.update) if (this._shieldTimer > 0) { this._shieldTimer -= dt * 1000; this._blinkTimer += dt * 1000; if (this._blinkTimer >= 100) { this._blinkTimer = 0; this._shieldBlink = !this._shieldBlink; } if (this._shieldTimer <= 0) { this._shieldTimer = 0; this._shieldBlink = false; } } } /** * Override takeDamage to check shield. * @param {number} amount * @returns {boolean} Whether destroyed. */ takeDamage(amount = 1) { if (this._shieldTimer > 0) return false; // invincible return super.takeDamage(amount); } /** * Set the target base position for the bot to attack. * @param {{x: number, y: number}} basePos */ setTargetBase(basePos) { this._targetBase = basePos; } /** * Update bot AI and state. * @param {number} dt - Delta time in seconds. * @param {MapManager} mapManager * @param {Function} [onShoot] - Callback to fire a bullet. */ updateAI(dt, mapManager, onShoot) { if (!this.alive) return; // Movement AI this._moveTimer += dt; this._shootTimer += dt; // Check if stuck const moved = Math.abs(this.x - this._lastX) + Math.abs(this.y - this._lastY); if (moved < 0.5) { this._stuckTimer += dt; } else { this._stuckTimer = 0; } this._lastX = this.x; this._lastY = this.y; // Determine AI behavior if (Math.random() < 0.5 && this._targetBase) { this._botState = BOT_STATE.ATTACK_BASE; } else { this._botState = BOT_STATE.PATROL; } // Direction change if (this._moveTimer >= this._dirChangeInterval || this._stuckTimer > 0.5) { this._moveTimer = 0; this._stuckTimer = 0; this._chooseDirection(mapManager); } // Move this.move(this.direction, dt, mapManager); // Shoot if (this._shootTimer >= this._shootInterval) { this._shootTimer = 0; this._shootInterval = 1 + Math.random() * 1.5; if (this.activeBullets < this._maxBullets && onShoot) { onShoot(this); } } } /** * Choose a new direction based on AI state. * @private */ _chooseDirection(mapManager) { if (this._botState === BOT_STATE.ATTACK_BASE && this._targetBase) { this._chaseTarget(this._targetBase, mapManager); } else { this._randomDirection(); } } /** * Chase a target position (enemy base). * @private */ _chaseTarget(target, mapManager) { const dx = target.x - this.x; const dy = target.y - this.y; const dirs = []; if (Math.abs(dx) > Math.abs(dy)) { dirs.push(dx > 0 ? DIRECTION.RIGHT : DIRECTION.LEFT); dirs.push(dy > 0 ? DIRECTION.DOWN : DIRECTION.UP); } else { dirs.push(dy > 0 ? DIRECTION.DOWN : DIRECTION.UP); dirs.push(dx > 0 ? DIRECTION.RIGHT : DIRECTION.LEFT); } const allDirs = [DIRECTION.UP, DIRECTION.DOWN, DIRECTION.LEFT, DIRECTION.RIGHT]; for (const d of allDirs) { if (!dirs.includes(d)) dirs.push(d); } // Try each direction, pick the first that isn't blocked for (const dir of dirs) { const vec = DIR_VECTORS[dir]; const testX = this.x + vec.dx * TILE_SIZE; const testY = this.y + vec.dy * TILE_SIZE; const left = testX - this.halfSize; const top = testY - this.halfSize; if (mapManager && !mapManager.rectCollidesWithTerrain(left, top, this.size, this.size)) { this.direction = dir; return; } } // Fallback: random this.direction = allDirs[Math.floor(Math.random() * allDirs.length)]; } /** * Choose a random direction with bias towards enemy base side. * @private */ _randomDirection() { const dirs = [DIRECTION.UP, DIRECTION.DOWN, DIRECTION.LEFT, DIRECTION.RIGHT]; // Bias towards enemy base direction if (Math.random() < 0.4) { // Team A bots go right, Team B bots go left this.direction = this.team === 'A' ? DIRECTION.RIGHT : DIRECTION.LEFT; return; } this.direction = dirs[Math.floor(Math.random() * dirs.length)]; } /** Whether this bot can fire. */ canFire() { return this.alive && this.activeBullets < this._maxBullets; } /** Check if this bot can break steel (always false for bots). */ canBreakSteel() { return false; } /** * Render with bot indicator. * @param {CanvasRenderingContext2D} ctx */ render(ctx) { if (!this.alive) return; super.render(ctx); // Bot indicator (small robot icon above tank) ctx.fillStyle = '#AAAAAA'; ctx.font = '8px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('🤖', this.x, this.y - this.halfSize - 6); } } module.exports = BotTank;