420 lines
15 KiB
JavaScript
420 lines
15 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;
|
|
|
|
// Collision size (can differ from visual size for large tanks like Boss)
|
|
this.colliderSize = config.colliderSize || this.size;
|
|
// Half-size for collision calculations
|
|
this.halfSize = this.size / 2;
|
|
this.colliderHalfSize = this.colliderSize / 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.colliderHalfSize;
|
|
const minY = MAP_OFFSET_Y + this.colliderHalfSize;
|
|
const maxX = MAP_OFFSET_X + MAP_WIDTH - this.colliderHalfSize;
|
|
const maxY = MAP_OFFSET_Y + MAP_HEIGHT - this.colliderHalfSize;
|
|
|
|
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.colliderHalfSize;
|
|
const top = newY - this.colliderHalfSize;
|
|
|
|
// Terrain collision check
|
|
if (mapManager && mapManager.rectCollidesWithTerrain(left, top, this.colliderSize, this.colliderSize)) {
|
|
// 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.colliderHalfSize;
|
|
const minY = MAP_OFFSET_Y + this.colliderHalfSize;
|
|
const maxX = MAP_OFFSET_X + MAP_WIDTH - this.colliderHalfSize;
|
|
const maxY = MAP_OFFSET_Y + MAP_HEIGHT - this.colliderHalfSize;
|
|
|
|
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;
|
|
const colliderHS = this.colliderHalfSize;
|
|
const colliderS = this.colliderSize;
|
|
|
|
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 - colliderHS;
|
|
const top = testY - colliderHS;
|
|
if (
|
|
left >= MAP_OFFSET_X &&
|
|
top >= MAP_OFFSET_Y &&
|
|
left + colliderS <= MAP_OFFSET_X + MAP_WIDTH &&
|
|
top + colliderS <= MAP_OFFSET_Y + MAP_HEIGHT &&
|
|
!mapManager.rectCollidesWithTerrain(left, top, colliderS, colliderS)
|
|
) {
|
|
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 + colliderHS, Math.min(this.x, MAP_OFFSET_X + MAP_WIDTH - colliderHS));
|
|
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 + colliderHS, Math.min(this.x, MAP_OFFSET_X + MAP_WIDTH - colliderHS));
|
|
}
|
|
} 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 - colliderHS;
|
|
const top = testY - colliderHS;
|
|
if (
|
|
left >= MAP_OFFSET_X &&
|
|
top >= MAP_OFFSET_Y &&
|
|
left + colliderS <= MAP_OFFSET_X + MAP_WIDTH &&
|
|
top + colliderS <= MAP_OFFSET_Y + MAP_HEIGHT &&
|
|
!mapManager.rectCollidesWithTerrain(left, top, colliderS, colliderS)
|
|
) {
|
|
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 + colliderHS, Math.min(this.y, MAP_OFFSET_Y + MAP_HEIGHT - colliderHS));
|
|
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 + colliderHS, Math.min(this.y, MAP_OFFSET_Y + MAP_HEIGHT - colliderHS));
|
|
}
|
|
}
|
|
|
|
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();
|
|
// Gold border for local player identification
|
|
if (this._isLocal) {
|
|
ctx.strokeStyle = '#FFD700';
|
|
ctx.lineWidth = 2;
|
|
ctx.strokeRect(-this.halfSize, -this.halfSize, this.size, this.size);
|
|
// Also outline the barrel area
|
|
ctx.strokeRect(-this.size * 0.15 / 2, -this.halfSize - barrelExtra, this.size * 0.15, barrelExtra);
|
|
}
|
|
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);
|
|
|
|
// Gold border for local player identification
|
|
if (this._isLocal) {
|
|
ctx.strokeStyle = '#FFD700';
|
|
ctx.lineWidth = 2;
|
|
ctx.strokeRect(-hs, -hs, this.size, this.size);
|
|
// Barrel outline
|
|
ctx.strokeRect(-barrelW / 2, -hs - barrelH, barrelW, barrelH);
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Get the axis-aligned bounding box.
|
|
* @returns {{x: number, y: number, w: number, h: number}}
|
|
*/
|
|
getBounds() {
|
|
return {
|
|
x: this.x - this.colliderHalfSize,
|
|
y: this.y - this.colliderHalfSize,
|
|
w: this.colliderSize,
|
|
h: this.colliderSize,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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;
|