first commit
This commit is contained in:
@@ -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