/** * 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;