Files
tankwar_proj/js/entities/BotTank.js
T
2026-04-10 22:59:39 +08:00

276 lines
7.1 KiB
JavaScript

/**
* 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;