Files
2026-05-02 13:50:52 +08:00

398 lines
14 KiB
JavaScript

/**
* 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');
const { drawTankSkin, DESIGN_HALF_SIZE } = require('./TankSkinRenderer');
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]);
// ★ Unified skin path — any tank with a skin id uses the SAME drawing
// code as the SkinScene preview. Scale to match the actual tank size.
// Clip laterally to the collision box so wide tracks / decorations
// don't make the tank look wider than non-skinned tanks. Leave the
// top/bottom un-clipped so the barrel can extend naturally (same as
// legacy rendering).
if (this._skinId) {
const t = (typeof performance !== 'undefined' ? performance.now() : Date.now()) / 1000;
const k = this.halfSize / DESIGN_HALF_SIZE;
// Clip: lateral bounds = collision box; vertical = generous to allow barrel
const barrelExtra = this.size * 0.55; // same as legacy barrelH
ctx.beginPath();
ctx.rect(-this.halfSize, -this.halfSize - barrelExtra, this.size, this.size + barrelExtra * 2);
ctx.clip();
ctx.save();
ctx.scale(k, k);
drawTankSkin(ctx, this._skinId, this._skinColors, t);
ctx.restore();
ctx.restore();
return;
}
// ── Legacy fallback for tanks without a skin id (enemy AI, etc.) ──
const hs = this.halfSize;
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;