/** * EnemyTank.js * Enemy tank with AI behavior: patrol, chase, and attack states. */ const Tank = require('./Tank'); const { TANK_TYPE, TANK_CONFIG, DIRECTION, DIR_VECTORS, GRID_COLS, GRID_ROWS, TILE_SIZE, MAP_OFFSET_X, MAP_OFFSET_Y, } = require('../base/GameGlobal'); /** AI States */ const AI_STATE = { PATROL: 'patrol', CHASE: 'chase', ATTACK: 'attack', }; class EnemyTank extends Tank { /** * @param {object} params * @param {string} params.type - TANK_TYPE enum value. * @param {number} params.col - Spawn grid column. * @param {number} params.row - Spawn grid row. * @param {number} [params.levelNum] - Current level number (affects AI). * @param {boolean} [params.hasPowerUp] - Whether destroying this tank drops a power-up. */ constructor(params) { const cfg = TANK_CONFIG[params.type]; const spawnX = MAP_OFFSET_X + params.col * TILE_SIZE + TILE_SIZE / 2; const spawnY = MAP_OFFSET_Y + params.row * TILE_SIZE + TILE_SIZE / 2; const speedMul = params.speedMultiplier || 1; super({ x: spawnX, y: spawnY, speed: cfg.speed * speedMul, hp: cfg.hp, color: cfg.color, size: cfg.size, direction: DIRECTION.DOWN, }); this.type = params.type; this.score = cfg.score || 100; this.hasPowerUp = params.hasPowerUp || false; this.levelNum = params.levelNum || 1; // AI state this._aiState = AI_STATE.PATROL; this._moveTimer = 0; this._dirChangeInterval = 1.5 + Math.random() * 2; // seconds this._shootTimer = 0; this._shootInterval = 1 + Math.random() * 1.5; // seconds this._stuckTimer = 0; this._lastX = spawnX; this._lastY = spawnY; // Frozen state (from clock power-up) this.frozen = false; // Active bullets tracking this.activeBullets = 0; this._maxBullets = params.type === TANK_TYPE.ENEMY_BOSS ? 2 : 1; // HP indicator blink for armored tanks this._hitBlink = 0; } /** * Update enemy tank AI and state. * @param {number} dt - Delta time in seconds. * @param {MapManager} mapManager * @param {{x: number, y: number}} basePos - Base position for targeting. * @param {Function} onShoot - Callback to fire a bullet. */ update(dt, mapManager, basePos, onShoot) { if (!this.alive || this.frozen) return; // Hit blink effect if (this._hitBlink > 0) { this._hitBlink -= dt; } // 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 based on level if (this.levelNum >= 10 && this.type !== TANK_TYPE.ENEMY_NORMAL) { this._aiState = AI_STATE.CHASE; } else if (Math.random() < 0.3) { this._aiState = AI_STATE.CHASE; } else { this._aiState = AI_STATE.PATROL; } // Direction change if (this._moveTimer >= this._dirChangeInterval || this._stuckTimer > 0.5) { this._moveTimer = 0; this._stuckTimer = 0; this._chooseDirection(mapManager, basePos); } // Move this.move(this.direction, dt, mapManager); // Shoot if (this._shootTimer >= this._shootInterval) { this._shootTimer = 0; this._shootInterval = 0.8 + Math.random() * 1.5; if (this.activeBullets < this._maxBullets && onShoot) { onShoot(this); } } } /** * Choose a new direction based on AI state. * @private */ _chooseDirection(mapManager, basePos) { if (this._aiState === AI_STATE.CHASE && basePos) { // Move towards base this._chaseTarget(basePos, mapManager); } else { // Random patrol this._randomDirection(mapManager); } } /** * Chase a target position (usually the base). * @private */ _chaseTarget(target, mapManager) { const dx = target.x - this.x; const dy = target.y - this.y; // Prefer the axis with greater distance const dirs = []; if (Math.abs(dy) > Math.abs(dx)) { dirs.push(dy > 0 ? DIRECTION.DOWN : DIRECTION.UP); dirs.push(dx > 0 ? DIRECTION.RIGHT : DIRECTION.LEFT); } else { dirs.push(dx > 0 ? DIRECTION.RIGHT : DIRECTION.LEFT); dirs.push(dy > 0 ? DIRECTION.DOWN : DIRECTION.UP); } // Add random alternatives for variety 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 immediately 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.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. * @private */ _randomDirection(mapManager) { const dirs = [DIRECTION.UP, DIRECTION.DOWN, DIRECTION.LEFT, DIRECTION.RIGHT]; // Bias towards down (towards base) 40% of the time if (Math.random() < 0.4) { this.direction = DIRECTION.DOWN; return; } this.direction = dirs[Math.floor(Math.random() * dirs.length)]; } /** * Override takeDamage to add hit blink. */ takeDamage(amount = 1) { this._hitBlink = 0.15; return super.takeDamage(amount); } /** * Render with HP indicator for armored tanks. * @param {CanvasRenderingContext2D} ctx */ render(ctx) { if (!this.alive) return; // Hit blink effect if (this._hitBlink > 0) { ctx.save(); ctx.globalAlpha = 0.5; super.render(ctx); ctx.restore(); } else { super.render(ctx); } // Power-up indicator (flashing border) if (this.hasPowerUp) { ctx.save(); ctx.strokeStyle = '#FF0000'; ctx.lineWidth = 2; const blink = Math.sin(Date.now() / 150) > 0; if (blink) { ctx.strokeRect( this.x - this.halfSize - 2, this.y - this.halfSize - 2, this.size + 4, this.size + 4 ); } ctx.restore(); } // HP bar for armored/boss tanks if (this.maxHp > 1) { const barW = this.size; const barH = 3; const barX = this.x - this.halfSize; const barY = this.y - this.halfSize - 6; ctx.fillStyle = '#333333'; ctx.fillRect(barX, barY, barW, barH); ctx.fillStyle = this.hp > this.maxHp * 0.3 ? '#00FF00' : '#FF0000'; ctx.fillRect(barX, barY, barW * (this.hp / this.maxHp), barH); } } /** Whether this enemy can fire. */ canFire() { return this.alive && !this.frozen && this.activeBullets < this._maxBullets; } } module.exports = EnemyTank;