first commit
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Bullet.js
|
||||
* Bullet entity that travels in a straight line and interacts with terrain/tanks.
|
||||
*/
|
||||
|
||||
const {
|
||||
BULLET_SPEED,
|
||||
BULLET_SIZE,
|
||||
DIRECTION,
|
||||
DIR_VECTORS,
|
||||
MAP_OFFSET_X,
|
||||
MAP_OFFSET_Y,
|
||||
MAP_WIDTH,
|
||||
MAP_HEIGHT,
|
||||
} = require('../base/GameGlobal');
|
||||
|
||||
class Bullet {
|
||||
constructor() {
|
||||
this.x = 0;
|
||||
this.y = 0;
|
||||
this.direction = DIRECTION.UP;
|
||||
this.speed = BULLET_SPEED;
|
||||
this.size = BULLET_SIZE;
|
||||
this.halfSize = BULLET_SIZE / 2;
|
||||
this.alive = false;
|
||||
this.canBreakSteel = false;
|
||||
|
||||
/** @type {'player'|'enemy'} */
|
||||
this.owner = 'player';
|
||||
/** @type {object|null} Reference to the tank that fired this bullet */
|
||||
this.ownerTank = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize/reset the bullet for reuse from object pool.
|
||||
* @param {object} config
|
||||
* @param {number} config.x
|
||||
* @param {number} config.y
|
||||
* @param {number} config.direction
|
||||
* @param {string} config.owner - 'player' or 'enemy'
|
||||
* @param {boolean} [config.canBreakSteel]
|
||||
* @param {object} [config.ownerTank]
|
||||
*/
|
||||
init(config) {
|
||||
this.x = config.x;
|
||||
this.y = config.y;
|
||||
this.direction = config.direction;
|
||||
this.owner = config.owner || 'player';
|
||||
this.canBreakSteel = config.canBreakSteel || false;
|
||||
this.ownerTank = config.ownerTank || null;
|
||||
this.alive = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update bullet position.
|
||||
* @param {number} dt - Delta time in seconds.
|
||||
*/
|
||||
update(dt) {
|
||||
if (!this.alive) return;
|
||||
|
||||
const vec = DIR_VECTORS[this.direction];
|
||||
const moveAmount = this.speed * dt * 60;
|
||||
|
||||
this.x += vec.dx * moveAmount;
|
||||
this.y += vec.dy * moveAmount;
|
||||
|
||||
// Check map boundaries
|
||||
if (
|
||||
this.x < MAP_OFFSET_X ||
|
||||
this.y < MAP_OFFSET_Y ||
|
||||
this.x > MAP_OFFSET_X + MAP_WIDTH ||
|
||||
this.y > MAP_OFFSET_Y + MAP_HEIGHT
|
||||
) {
|
||||
this.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the bullet.
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
*/
|
||||
render(ctx) {
|
||||
if (!this.alive) return;
|
||||
|
||||
ctx.fillStyle = this.owner === 'player' ? '#FFFF00' : '#FF6600';
|
||||
ctx.fillRect(
|
||||
this.x - this.halfSize,
|
||||
this.y - this.halfSize,
|
||||
this.size,
|
||||
this.size
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bounding box.
|
||||
* @returns {{x: number, y: number, w: number, h: number}}
|
||||
*/
|
||||
getBounds() {
|
||||
return {
|
||||
x: this.x - this.halfSize,
|
||||
y: this.y - this.halfSize,
|
||||
w: this.size,
|
||||
h: this.size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the bullet (mark for recycling).
|
||||
*/
|
||||
destroy() {
|
||||
this.alive = false;
|
||||
// Decrement owner's active bullet count
|
||||
if (this.ownerTank && typeof this.ownerTank.activeBullets === 'number') {
|
||||
this.ownerTank.activeBullets = Math.max(0, this.ownerTank.activeBullets - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Bullet;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Explosion.js
|
||||
* Simple explosion effect using frame-based animation.
|
||||
* Managed via object pool for performance.
|
||||
*/
|
||||
|
||||
class Explosion {
|
||||
constructor() {
|
||||
this.x = 0;
|
||||
this.y = 0;
|
||||
this.alive = false;
|
||||
this.size = 0;
|
||||
this.maxSize = 30;
|
||||
this._timer = 0;
|
||||
this._duration = 0.3; // seconds
|
||||
this._phase = 0; // 0 to 1
|
||||
this._isBig = false; // big explosion for tank destruction
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the explosion.
|
||||
* @param {number} x - Center X.
|
||||
* @param {number} y - Center Y.
|
||||
* @param {boolean} [isBig=false] - Whether this is a large explosion (tank death).
|
||||
*/
|
||||
init(x, y, isBig = false) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.alive = true;
|
||||
this._timer = 0;
|
||||
this._phase = 0;
|
||||
this._isBig = isBig;
|
||||
this.maxSize = isBig ? 50 : 25;
|
||||
this._duration = isBig ? 0.5 : 0.3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update explosion animation.
|
||||
* @param {number} dt - Delta time in seconds.
|
||||
*/
|
||||
update(dt) {
|
||||
if (!this.alive) return;
|
||||
|
||||
this._timer += dt;
|
||||
this._phase = this._timer / this._duration;
|
||||
|
||||
if (this._phase >= 1) {
|
||||
this.alive = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Size grows then shrinks
|
||||
if (this._phase < 0.4) {
|
||||
this.size = this.maxSize * (this._phase / 0.4);
|
||||
} else {
|
||||
this.size = this.maxSize * (1 - (this._phase - 0.4) / 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the explosion.
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
*/
|
||||
render(ctx) {
|
||||
if (!this.alive) return;
|
||||
|
||||
ctx.save();
|
||||
|
||||
const alpha = 1 - this._phase * 0.5;
|
||||
ctx.globalAlpha = alpha;
|
||||
|
||||
// Outer glow
|
||||
if (this._isBig) {
|
||||
ctx.fillStyle = '#FF4500';
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, this.size * 1.2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Main explosion
|
||||
ctx.fillStyle = '#FF8C00';
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Inner bright core
|
||||
ctx.fillStyle = '#FFFF00';
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, this.size * 0.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// White center flash (early phase only)
|
||||
if (this._phase < 0.3) {
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, this.size * 0.2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Explosion;
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* PlayerTank.js
|
||||
* Player-controlled tank with fire level, lives, shield, and respawn logic.
|
||||
*/
|
||||
|
||||
const Tank = require('./Tank');
|
||||
const {
|
||||
TANK_TYPE,
|
||||
TANK_CONFIG,
|
||||
FIRE_LEVEL,
|
||||
MAX_BULLETS_BY_LEVEL,
|
||||
DIRECTION,
|
||||
DEFAULT_LIVES,
|
||||
SHIELD_DURATION,
|
||||
INVINCIBLE_BLINK_INTERVAL,
|
||||
TILE_SIZE,
|
||||
MAP_OFFSET_X,
|
||||
MAP_OFFSET_Y,
|
||||
} = require('../base/GameGlobal');
|
||||
|
||||
class PlayerTank extends Tank {
|
||||
/**
|
||||
* @param {object} params
|
||||
* @param {number} params.col - Spawn grid column.
|
||||
* @param {number} params.row - Spawn grid row.
|
||||
*/
|
||||
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,
|
||||
hp: cfg.hp,
|
||||
color: cfg.color,
|
||||
size: cfg.size,
|
||||
direction: DIRECTION.UP,
|
||||
});
|
||||
|
||||
this.type = TANK_TYPE.PLAYER;
|
||||
this.spawnCol = params.col;
|
||||
this.spawnRow = params.row;
|
||||
|
||||
// Skin colors (reserved for future use)
|
||||
this._skinColors = null;
|
||||
|
||||
// Fire level system
|
||||
this.fireLevel = FIRE_LEVEL.LV1;
|
||||
|
||||
// Lives
|
||||
this.lives = DEFAULT_LIVES;
|
||||
|
||||
// Shield (invincibility)
|
||||
this._shieldTimer = 0; // ms remaining
|
||||
this._shieldBlink = false;
|
||||
this._blinkTimer = 0;
|
||||
|
||||
// Active bullets count (managed externally)
|
||||
this.activeBullets = 0;
|
||||
|
||||
// Respawn invincibility (short shield on spawn)
|
||||
this._respawnShieldDuration = 3000; // 3 seconds on respawn
|
||||
}
|
||||
|
||||
/**
|
||||
* Update player tank state.
|
||||
* @param {number} dt - Delta time in seconds.
|
||||
*/
|
||||
update(dt) {
|
||||
if (!this.alive) return;
|
||||
|
||||
// Shield timer
|
||||
if (this._shieldTimer > 0) {
|
||||
this._shieldTimer -= dt * 1000;
|
||||
this._blinkTimer += dt * 1000;
|
||||
|
||||
if (this._blinkTimer >= INVINCIBLE_BLINK_INTERVAL) {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle player death: lose a life and respawn, or game over.
|
||||
* @returns {boolean} True if player has lives remaining and respawned.
|
||||
*/
|
||||
die() {
|
||||
this.alive = false;
|
||||
this.lives--;
|
||||
|
||||
if (this.lives > 0) {
|
||||
this.respawn();
|
||||
return true;
|
||||
}
|
||||
return false; // game over
|
||||
}
|
||||
|
||||
/**
|
||||
* Respawn at the spawn point with temporary invincibility.
|
||||
*/
|
||||
respawn() {
|
||||
this.x = MAP_OFFSET_X + this.spawnCol * TILE_SIZE + TILE_SIZE / 2;
|
||||
this.y = MAP_OFFSET_Y + this.spawnRow * TILE_SIZE + TILE_SIZE / 2;
|
||||
this.direction = DIRECTION.UP;
|
||||
this.hp = 1;
|
||||
this.alive = true;
|
||||
this.visible = true;
|
||||
this.fireLevel = FIRE_LEVEL.LV1;
|
||||
this.activeBullets = 0;
|
||||
|
||||
// Temporary shield on respawn
|
||||
this.activateShield(this._respawnShieldDuration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate shield (invincibility).
|
||||
* @param {number} duration - Duration in ms.
|
||||
*/
|
||||
activateShield(duration) {
|
||||
this._shieldTimer = duration;
|
||||
this._blinkTimer = 0;
|
||||
this._shieldBlink = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade fire level.
|
||||
*/
|
||||
upgradeFireLevel() {
|
||||
if (this.fireLevel < FIRE_LEVEL.LV3) {
|
||||
this.fireLevel++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a life.
|
||||
*/
|
||||
addLife() {
|
||||
this.lives++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the player can fire (based on active bullets and fire level).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canFire() {
|
||||
return this.alive && this.activeBullets < MAX_BULLETS_BY_LEVEL[this.fireLevel];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the bullet should break steel.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canBreakSteel() {
|
||||
return this.fireLevel >= FIRE_LEVEL.LV3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render player tank with shield effect.
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
*/
|
||||
render(ctx) {
|
||||
if (!this.alive) return;
|
||||
|
||||
// Call base render
|
||||
super.render(ctx);
|
||||
|
||||
// Draw shield effect
|
||||
if (this._shieldTimer > 0 && this._shieldBlink) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = '#00FFFF';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.globalAlpha = 0.6;
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, this.halfSize + 4, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the player is currently shielded. */
|
||||
get isShielded() {
|
||||
return this._shieldTimer > 0;
|
||||
}
|
||||
|
||||
/** Get max bullets allowed on screen. */
|
||||
get maxBullets() {
|
||||
return MAX_BULLETS_BY_LEVEL[this.fireLevel];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PlayerTank;
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* PowerUp.js
|
||||
* Power-up item entity with type, position, timer, and blink animation.
|
||||
*/
|
||||
|
||||
const {
|
||||
POWERUP_TYPE,
|
||||
POWERUP_DURATION,
|
||||
TILE_SIZE,
|
||||
MAP_OFFSET_X,
|
||||
MAP_OFFSET_Y,
|
||||
MAP_WIDTH,
|
||||
MAP_HEIGHT,
|
||||
GRID_COLS,
|
||||
GRID_ROWS,
|
||||
} = require('../base/GameGlobal');
|
||||
|
||||
/** Power-up visual config */
|
||||
const POWERUP_VISUALS = {
|
||||
[POWERUP_TYPE.STAR]: { emoji: '⭐', color: '#FFD700', label: 'STAR' },
|
||||
[POWERUP_TYPE.CLOCK]: { emoji: '🕒', color: '#87CEEB', label: 'CLOCK' },
|
||||
[POWERUP_TYPE.BOMB]: { emoji: '💣', color: '#FF4500', label: 'BOMB' },
|
||||
[POWERUP_TYPE.HELMET]: { emoji: '🛡️', color: '#00CED1', label: 'SHIELD' },
|
||||
[POWERUP_TYPE.SHOVEL]: { emoji: '🏠', color: '#8B4513', label: 'SHOVEL' },
|
||||
[POWERUP_TYPE.TANK]: { emoji: '+1', color: '#32CD32', label: 'LIFE' },
|
||||
};
|
||||
|
||||
class PowerUp {
|
||||
/**
|
||||
* @param {string} type - POWERUP_TYPE value.
|
||||
* @param {number} x - Pixel X.
|
||||
* @param {number} y - Pixel Y.
|
||||
*/
|
||||
constructor(type, x, y) {
|
||||
this.type = type;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.alive = true;
|
||||
this.size = TILE_SIZE * 0.9;
|
||||
this.halfSize = this.size / 2;
|
||||
|
||||
this._timer = 0;
|
||||
this._duration = POWERUP_DURATION;
|
||||
this._blinkStart = POWERUP_DURATION * 0.7; // start blinking at 70% of duration
|
||||
this._visible = true;
|
||||
this._blinkTimer = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update power-up timer and blink.
|
||||
* @param {number} dt - Delta time in seconds.
|
||||
*/
|
||||
update(dt) {
|
||||
if (!this.alive) return;
|
||||
|
||||
this._timer += dt * 1000;
|
||||
|
||||
// Blink when about to expire
|
||||
if (this._timer >= this._blinkStart) {
|
||||
this._blinkTimer += dt * 1000;
|
||||
if (this._blinkTimer >= 150) {
|
||||
this._blinkTimer = 0;
|
||||
this._visible = !this._visible;
|
||||
}
|
||||
}
|
||||
|
||||
// Expire
|
||||
if (this._timer >= this._duration) {
|
||||
this.alive = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the power-up.
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
*/
|
||||
render(ctx) {
|
||||
if (!this.alive || !this._visible) return;
|
||||
|
||||
const visual = POWERUP_VISUALS[this.type];
|
||||
const x = this.x - this.halfSize;
|
||||
const y = this.y - this.halfSize;
|
||||
|
||||
// Background glow
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.fillStyle = visual.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, this.halfSize + 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// Background box
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.globalAlpha = 0.7;
|
||||
ctx.fillRect(x, y, this.size, this.size);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Border
|
||||
ctx.strokeStyle = visual.color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(x, y, this.size, this.size);
|
||||
|
||||
// Icon/text
|
||||
ctx.fillStyle = visual.color;
|
||||
ctx.font = `bold ${Math.floor(this.size * 0.5)}px Arial`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
if (this.type === POWERUP_TYPE.TANK) {
|
||||
ctx.fillText('+1', this.x, this.y);
|
||||
} else {
|
||||
ctx.font = `${Math.floor(this.size * 0.6)}px Arial`;
|
||||
ctx.fillText(visual.emoji, this.x, this.y);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bounding box for collision.
|
||||
* @returns {{x: number, y: number, w: number, h: number}}
|
||||
*/
|
||||
getBounds() {
|
||||
return {
|
||||
x: this.x - this.halfSize,
|
||||
y: this.y - this.halfSize,
|
||||
w: this.size,
|
||||
h: this.size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random position on the map (avoiding terrain).
|
||||
* @param {MapManager} mapManager
|
||||
* @returns {{x: number, y: number}}
|
||||
*/
|
||||
static randomPosition(mapManager) {
|
||||
let attempts = 0;
|
||||
while (attempts < 50) {
|
||||
const col = Math.floor(Math.random() * GRID_COLS);
|
||||
const row = Math.floor(Math.random() * (GRID_ROWS - 2)) + 1; // avoid top/bottom rows
|
||||
const terrain = mapManager.getTerrain(row, col);
|
||||
|
||||
// Only place on empty tiles
|
||||
if (terrain === 0) { // TERRAIN.EMPTY
|
||||
return {
|
||||
x: MAP_OFFSET_X + col * TILE_SIZE + TILE_SIZE / 2,
|
||||
y: MAP_OFFSET_Y + row * TILE_SIZE + TILE_SIZE / 2,
|
||||
};
|
||||
}
|
||||
attempts++;
|
||||
}
|
||||
|
||||
// Fallback: center of map
|
||||
return {
|
||||
x: MAP_OFFSET_X + MAP_WIDTH / 2,
|
||||
y: MAP_OFFSET_Y + MAP_HEIGHT / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a random power-up type based on level-adjusted probabilities.
|
||||
* @param {number} levelNum - Current level number.
|
||||
* @returns {string} POWERUP_TYPE value.
|
||||
*/
|
||||
static randomType(levelNum) {
|
||||
// Base probabilities (weights)
|
||||
const weights = {
|
||||
[POWERUP_TYPE.STAR]: Math.max(10, 30 - levelNum), // decreases with level
|
||||
[POWERUP_TYPE.CLOCK]: 15,
|
||||
[POWERUP_TYPE.BOMB]: 10,
|
||||
[POWERUP_TYPE.HELMET]: 15,
|
||||
[POWERUP_TYPE.SHOVEL]: 10,
|
||||
[POWERUP_TYPE.TANK]: 10,
|
||||
};
|
||||
|
||||
const totalWeight = Object.values(weights).reduce((a, b) => a + b, 0);
|
||||
let rand = Math.random() * totalWeight;
|
||||
|
||||
for (const [type, weight] of Object.entries(weights)) {
|
||||
rand -= weight;
|
||||
if (rand <= 0) return type;
|
||||
}
|
||||
|
||||
return POWERUP_TYPE.STAR; // fallback
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PowerUp;
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* Tank.js
|
||||
* Base class for all tanks (player and enemy).
|
||||
* Handles position, direction, movement, rendering, and collision box.
|
||||
*/
|
||||
|
||||
const {
|
||||
TILE_SIZE,
|
||||
MAP_OFFSET_X,
|
||||
MAP_OFFSET_Y,
|
||||
MAP_WIDTH,
|
||||
MAP_HEIGHT,
|
||||
DIRECTION,
|
||||
DIR_VECTORS,
|
||||
} = require('../base/GameGlobal');
|
||||
|
||||
class Tank {
|
||||
/**
|
||||
* @param {object} config
|
||||
* @param {number} config.x - Pixel X position (center).
|
||||
* @param {number} config.y - Pixel Y position (center).
|
||||
* @param {number} config.speed - Movement speed (pixels per frame at 60fps).
|
||||
* @param {number} config.hp - Hit points.
|
||||
* @param {string} config.color - Fill color.
|
||||
* @param {number} config.size - Tank size in pixels.
|
||||
* @param {number} [config.direction] - Initial direction.
|
||||
*/
|
||||
constructor(config) {
|
||||
this.x = config.x;
|
||||
this.y = config.y;
|
||||
this.speed = config.speed || 2;
|
||||
this.hp = config.hp || 1;
|
||||
this.maxHp = config.hp || 1;
|
||||
this.color = config.color || '#FFFFFF';
|
||||
this.size = config.size || TILE_SIZE * 0.85;
|
||||
this.direction = config.direction !== undefined ? config.direction : DIRECTION.UP;
|
||||
this.alive = true;
|
||||
this.visible = true;
|
||||
|
||||
// Half-size for collision calculations
|
||||
this.halfSize = this.size / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the tank in a direction.
|
||||
* @param {number} dir - DIRECTION enum value.
|
||||
* @param {number} dt - Delta time in seconds.
|
||||
* @param {MapManager} mapManager - For collision checking.
|
||||
* @returns {boolean} Whether the tank actually moved.
|
||||
*/
|
||||
move(dir, dt, mapManager) {
|
||||
if (!this.alive) return false;
|
||||
|
||||
const prevDir = this.direction;
|
||||
this.direction = dir;
|
||||
|
||||
// When changing direction, snap to nearest grid alignment first
|
||||
// and do NOT advance forward this frame — classic Battle City behavior.
|
||||
if (prevDir !== dir) {
|
||||
this._snapToGrid(prevDir);
|
||||
return false;
|
||||
}
|
||||
|
||||
const vec = DIR_VECTORS[dir];
|
||||
const moveAmount = this.speed * dt * 60; // normalize to 60fps
|
||||
|
||||
let newX = this.x + vec.dx * moveAmount;
|
||||
let newY = this.y + vec.dy * moveAmount;
|
||||
|
||||
// Clamp to map boundaries instead of rejecting movement entirely.
|
||||
// This allows the tank to slide along the edge smoothly.
|
||||
const minX = MAP_OFFSET_X + this.halfSize;
|
||||
const minY = MAP_OFFSET_Y + this.halfSize;
|
||||
const maxX = MAP_OFFSET_X + MAP_WIDTH - this.halfSize;
|
||||
const maxY = MAP_OFFSET_Y + MAP_HEIGHT - this.halfSize;
|
||||
|
||||
newX = Math.max(minX, Math.min(newX, maxX));
|
||||
newY = Math.max(minY, Math.min(newY, maxY));
|
||||
|
||||
// If position didn't change after clamping, we're stuck at the boundary
|
||||
if (newX === this.x && newY === this.y) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate bounding box at clamped position
|
||||
const left = newX - this.halfSize;
|
||||
const top = newY - this.halfSize;
|
||||
|
||||
// Terrain collision check
|
||||
if (mapManager && mapManager.rectCollidesWithTerrain(left, top, this.size, this.size)) {
|
||||
// Try to align to grid for smoother movement along walls
|
||||
return this._tryAlignedMove(dir, dt, mapManager);
|
||||
}
|
||||
|
||||
this.x = newX;
|
||||
this.y = newY;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap the tank center to the nearest grid-cell center on the axis
|
||||
* of the OLD direction. This prevents the tank from "drifting" when
|
||||
* turning and ensures clean grid-aligned movement.
|
||||
* @param {number} oldDir - The direction the tank was facing before turning.
|
||||
* @private
|
||||
*/
|
||||
_snapToGrid(oldDir) {
|
||||
const halfTile = TILE_SIZE / 2;
|
||||
const minX = MAP_OFFSET_X + this.halfSize;
|
||||
const minY = MAP_OFFSET_Y + this.halfSize;
|
||||
const maxX = MAP_OFFSET_X + MAP_WIDTH - this.halfSize;
|
||||
const maxY = MAP_OFFSET_Y + MAP_HEIGHT - this.halfSize;
|
||||
|
||||
if (oldDir === DIRECTION.UP || oldDir === DIRECTION.DOWN) {
|
||||
// Was moving vertically → snap Y to nearest grid-cell center
|
||||
const rowExact = (this.y - MAP_OFFSET_Y - halfTile) / TILE_SIZE;
|
||||
const nearestRow = Math.round(rowExact);
|
||||
let alignedY = MAP_OFFSET_Y + nearestRow * TILE_SIZE + halfTile;
|
||||
// Clamp to map bounds so snapping doesn't push tank outside
|
||||
alignedY = Math.max(minY, Math.min(alignedY, maxY));
|
||||
if (Math.abs(alignedY - this.y) < TILE_SIZE * 0.5) {
|
||||
this.y = alignedY;
|
||||
}
|
||||
} else {
|
||||
// Was moving horizontally → snap X to nearest grid-cell center
|
||||
const colExact = (this.x - MAP_OFFSET_X - halfTile) / TILE_SIZE;
|
||||
const nearestCol = Math.round(colExact);
|
||||
let alignedX = MAP_OFFSET_X + nearestCol * TILE_SIZE + halfTile;
|
||||
// Clamp to map bounds so snapping doesn't push tank outside
|
||||
alignedX = Math.max(minX, Math.min(alignedX, maxX));
|
||||
if (Math.abs(alignedX - this.x) < TILE_SIZE * 0.5) {
|
||||
this.x = alignedX;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to move with grid alignment (helps navigate around corners).
|
||||
* When blocked, find the nearest gap in the perpendicular axis and slide
|
||||
* the tank towards it so the player can smoothly pass through openings
|
||||
* between bricks — classic Battle City "snap-to-gap" behaviour.
|
||||
* @private
|
||||
*/
|
||||
_tryAlignedMove(dir, dt, mapManager) {
|
||||
const moveAmount = this.speed * dt * 60;
|
||||
const vec = DIR_VECTORS[dir];
|
||||
const halfTile = TILE_SIZE / 2;
|
||||
|
||||
if (dir === DIRECTION.UP || dir === DIRECTION.DOWN) {
|
||||
// Moving vertically but blocked — try to slide horizontally into a gap
|
||||
|
||||
// Check two candidate column alignments (left-snap and right-snap)
|
||||
const colExact = (this.x - MAP_OFFSET_X - halfTile) / TILE_SIZE;
|
||||
const colLeft = Math.floor(colExact);
|
||||
const colRight = Math.ceil(colExact);
|
||||
|
||||
const candidates = [];
|
||||
for (const col of [colLeft, colRight]) {
|
||||
const alignedX = MAP_OFFSET_X + col * TILE_SIZE + halfTile;
|
||||
const diffX = alignedX - this.x;
|
||||
// Only consider if the offset is within a comfortable snap threshold
|
||||
if (Math.abs(diffX) < TILE_SIZE * 0.55) {
|
||||
// Check whether moving in the desired direction would be clear at this aligned X
|
||||
const testX = alignedX;
|
||||
const testY = this.y + vec.dy * moveAmount;
|
||||
const left = testX - this.halfSize;
|
||||
const top = testY - this.halfSize;
|
||||
if (
|
||||
left >= MAP_OFFSET_X &&
|
||||
top >= MAP_OFFSET_Y &&
|
||||
left + this.size <= MAP_OFFSET_X + MAP_WIDTH &&
|
||||
top + this.size <= MAP_OFFSET_Y + MAP_HEIGHT &&
|
||||
!mapManager.rectCollidesWithTerrain(left, top, this.size, this.size)
|
||||
) {
|
||||
candidates.push({ alignedX, diffX: Math.abs(diffX) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length > 0) {
|
||||
// Pick the closest gap
|
||||
candidates.sort((a, b) => a.diffX - b.diffX);
|
||||
const best = candidates[0];
|
||||
const diffX = best.alignedX - this.x;
|
||||
const slideAmount = Math.min(Math.abs(diffX), moveAmount);
|
||||
this.x += Math.sign(diffX) * slideAmount;
|
||||
// Clamp to map bounds after sliding
|
||||
this.x = Math.max(MAP_OFFSET_X + this.halfSize, Math.min(this.x, MAP_OFFSET_X + MAP_WIDTH - this.halfSize));
|
||||
return false;
|
||||
}
|
||||
|
||||
// No gap found — just do a basic grid-align slide
|
||||
const gridCol = Math.round(colExact);
|
||||
const alignedX = MAP_OFFSET_X + gridCol * TILE_SIZE + halfTile;
|
||||
const diffX = alignedX - this.x;
|
||||
if (Math.abs(diffX) < TILE_SIZE * 0.4) {
|
||||
const slideAmount = Math.min(Math.abs(diffX), moveAmount);
|
||||
this.x += Math.sign(diffX) * slideAmount;
|
||||
// Clamp to map bounds after sliding
|
||||
this.x = Math.max(MAP_OFFSET_X + this.halfSize, Math.min(this.x, MAP_OFFSET_X + MAP_WIDTH - this.halfSize));
|
||||
}
|
||||
} else {
|
||||
// Moving horizontally but blocked — try to slide vertically into a gap
|
||||
|
||||
const rowExact = (this.y - MAP_OFFSET_Y - halfTile) / TILE_SIZE;
|
||||
const rowUp = Math.floor(rowExact);
|
||||
const rowDown = Math.ceil(rowExact);
|
||||
|
||||
const candidates = [];
|
||||
for (const row of [rowUp, rowDown]) {
|
||||
const alignedY = MAP_OFFSET_Y + row * TILE_SIZE + halfTile;
|
||||
const diffY = alignedY - this.y;
|
||||
if (Math.abs(diffY) < TILE_SIZE * 0.55) {
|
||||
const testX = this.x + vec.dx * moveAmount;
|
||||
const testY = alignedY;
|
||||
const left = testX - this.halfSize;
|
||||
const top = testY - this.halfSize;
|
||||
if (
|
||||
left >= MAP_OFFSET_X &&
|
||||
top >= MAP_OFFSET_Y &&
|
||||
left + this.size <= MAP_OFFSET_X + MAP_WIDTH &&
|
||||
top + this.size <= MAP_OFFSET_Y + MAP_HEIGHT &&
|
||||
!mapManager.rectCollidesWithTerrain(left, top, this.size, this.size)
|
||||
) {
|
||||
candidates.push({ alignedY, diffY: Math.abs(diffY) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length > 0) {
|
||||
candidates.sort((a, b) => a.diffY - b.diffY);
|
||||
const best = candidates[0];
|
||||
const diffY = best.alignedY - this.y;
|
||||
const slideAmount = Math.min(Math.abs(diffY), moveAmount);
|
||||
this.y += Math.sign(diffY) * slideAmount;
|
||||
// Clamp to map bounds after sliding
|
||||
this.y = Math.max(MAP_OFFSET_Y + this.halfSize, Math.min(this.y, MAP_OFFSET_Y + MAP_HEIGHT - this.halfSize));
|
||||
return false;
|
||||
}
|
||||
|
||||
// No gap found — basic grid-align slide
|
||||
const gridRow = Math.round(rowExact);
|
||||
const alignedY = MAP_OFFSET_Y + gridRow * TILE_SIZE + halfTile;
|
||||
const diffY = alignedY - this.y;
|
||||
if (Math.abs(diffY) < TILE_SIZE * 0.4) {
|
||||
const slideAmount = Math.min(Math.abs(diffY), moveAmount);
|
||||
this.y += Math.sign(diffY) * slideAmount;
|
||||
// Clamp to map bounds after sliding
|
||||
this.y = Math.max(MAP_OFFSET_Y + this.halfSize, Math.min(this.y, MAP_OFFSET_Y + MAP_HEIGHT - this.halfSize));
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take damage.
|
||||
* @param {number} [amount=1]
|
||||
* @returns {boolean} Whether the tank was destroyed.
|
||||
*/
|
||||
takeDamage(amount = 1) {
|
||||
if (!this.alive) return false;
|
||||
this.hp -= amount;
|
||||
if (this.hp <= 0) {
|
||||
this.hp = 0;
|
||||
this.alive = false;
|
||||
return true; // destroyed
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the tank.
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
*/
|
||||
render(ctx) {
|
||||
if (!this.alive || !this.visible) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(this.x, this.y);
|
||||
|
||||
// Rotate based on direction
|
||||
const angles = {
|
||||
[DIRECTION.UP]: 0,
|
||||
[DIRECTION.DOWN]: Math.PI,
|
||||
[DIRECTION.LEFT]: -Math.PI / 2,
|
||||
[DIRECTION.RIGHT]: Math.PI / 2,
|
||||
};
|
||||
ctx.rotate(angles[this.direction]);
|
||||
|
||||
const hs = this.halfSize;
|
||||
|
||||
// Determine colors: use skin colors if this is a player tank with a skin
|
||||
let bodyColor = this.color;
|
||||
let turretColor = this._darkenColor(this.color, 0.3);
|
||||
let trackColor = this._darkenColor(this.color, 0.4);
|
||||
|
||||
if (this._skinColors) {
|
||||
bodyColor = this._skinColors.body || bodyColor;
|
||||
turretColor = this._skinColors.turret || turretColor;
|
||||
trackColor = this._skinColors.track || trackColor;
|
||||
}
|
||||
|
||||
// Tank body
|
||||
ctx.fillStyle = bodyColor;
|
||||
ctx.fillRect(-hs, -hs, this.size, this.size);
|
||||
|
||||
// Tank turret (barrel)
|
||||
const barrelW = this.size * 0.15;
|
||||
const barrelH = this.size * 0.5;
|
||||
ctx.fillStyle = turretColor;
|
||||
ctx.fillRect(-barrelW / 2, -hs - barrelH, barrelW, barrelH);
|
||||
|
||||
// Tank body detail (center square)
|
||||
const innerSize = this.size * 0.4;
|
||||
ctx.fillStyle = this._darkenColor(bodyColor, 0.2);
|
||||
ctx.fillRect(-innerSize / 2, -innerSize / 2, innerSize, innerSize);
|
||||
|
||||
// Tracks
|
||||
const trackW = this.size * 0.12;
|
||||
ctx.fillStyle = trackColor;
|
||||
ctx.fillRect(-hs, -hs, trackW, this.size);
|
||||
ctx.fillRect(hs - trackW, -hs, trackW, this.size);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the axis-aligned bounding box.
|
||||
* @returns {{x: number, y: number, w: number, h: number}}
|
||||
*/
|
||||
getBounds() {
|
||||
return {
|
||||
x: this.x - this.halfSize,
|
||||
y: this.y - this.halfSize,
|
||||
w: this.size,
|
||||
h: this.size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check collision with another tank.
|
||||
* @param {Tank} other
|
||||
* @returns {boolean}
|
||||
*/
|
||||
collidesWith(other) {
|
||||
if (!this.alive || !other.alive) return false;
|
||||
const a = this.getBounds();
|
||||
const b = other.getBounds();
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Darken a hex color.
|
||||
* @param {string} hex
|
||||
* @param {number} factor - 0 to 1
|
||||
* @returns {string}
|
||||
*/
|
||||
_darkenColor(hex, factor) {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
const dr = Math.floor(r * (1 - factor));
|
||||
const dg = Math.floor(g * (1 - factor));
|
||||
const db = Math.floor(b * (1 - factor));
|
||||
return `rgb(${dr},${dg},${db})`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Tank;
|
||||
Reference in New Issue
Block a user