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