/** * MapManager.js * Manages the tile-based game map: loading, rendering, terrain state, and collision queries. */ const { GRID_COLS, GRID_ROWS, TILE_SIZE, MAP_OFFSET_X, MAP_OFFSET_Y, TERRAIN, COLORS, } = require('../base/GameGlobal'); class MapManager { constructor() { /** @type {number[][]} 2D grid of terrain types */ this._grid = []; /** @type {boolean} Whether the base has been destroyed */ this._baseDestroyed = false; /** @type {boolean} Whether base walls are temporarily steel */ this._baseSteelTimer = 0; /** @type {number[][]} Backup of original base wall positions */ this._baseWallPositions = []; /** @type {Object} HP map for base wall tiles, keyed by 'row,col' */ this._baseWallHP = {}; /** Base wall default HP (hits required to destroy) */ this.BASE_WALL_MAX_HP = 3; } /** * Load a level grid. * @param {number[][]} grid - GRID_ROWS × GRID_COLS array of terrain values. */ loadGrid(grid) { // Deep clone so we don't mutate level data this._grid = grid.map((row) => [...row]); this._baseDestroyed = false; this._baseSteelTimer = 0; // Record base wall positions for shovel power-up and initialize HP this._baseWallPositions = []; this._baseWallHP = {}; for (let r = 0; r < GRID_ROWS; r++) { for (let c = 0; c < GRID_COLS; c++) { if (this._grid[r][c] === TERRAIN.BASE_WALL) { this._baseWallPositions.push([r, c]); this._baseWallHP[`${r},${c}`] = this.BASE_WALL_MAX_HP; } } } } /** * Update map state (e.g., shovel timer). * @param {number} dt - Delta time in seconds. */ update(dt) { if (this._baseSteelTimer > 0) { this._baseSteelTimer -= dt * 1000; if (this._baseSteelTimer <= 0) { this._baseSteelTimer = 0; // Revert steel walls back to brick and restore HP for (const [r, c] of this._baseWallPositions) { if (this._grid[r][c] === TERRAIN.STEEL) { this._grid[r][c] = TERRAIN.BASE_WALL; this._baseWallHP[`${r},${c}`] = this.BASE_WALL_MAX_HP; } } } } } /** * Render the map tiles. * @param {CanvasRenderingContext2D} ctx */ render(ctx) { for (let r = 0; r < GRID_ROWS; r++) { for (let c = 0; c < GRID_COLS; c++) { const terrain = this._grid[r][c]; if (terrain === TERRAIN.EMPTY) continue; const x = MAP_OFFSET_X + c * TILE_SIZE; const y = MAP_OFFSET_Y + r * TILE_SIZE; switch (terrain) { case TERRAIN.BRICK: this._drawBrick(ctx, x, y); break; case TERRAIN.BASE_WALL: this._drawBaseWall(ctx, x, y, r, c); break; case TERRAIN.STEEL: this._drawSteel(ctx, x, y); break; case TERRAIN.RIVER: this._drawRiver(ctx, x, y); break; case TERRAIN.FOREST: // Forest is drawn in a separate pass (on top of tanks) break; case TERRAIN.BASE: this._drawBase(ctx, x, y); break; } } } } /** * Render the forest overlay (drawn after tanks so it covers them). * @param {CanvasRenderingContext2D} ctx */ renderForestOverlay(ctx) { for (let r = 0; r < GRID_ROWS; r++) { for (let c = 0; c < GRID_COLS; c++) { if (this._grid[r][c] === TERRAIN.FOREST) { const x = MAP_OFFSET_X + c * TILE_SIZE; const y = MAP_OFFSET_Y + r * TILE_SIZE; this._drawForest(ctx, x, y); } } } } // ============================================================ // Tile Drawing Methods // ============================================================ _drawBrick(ctx, x, y) { const s = TILE_SIZE; ctx.fillStyle = COLORS.BRICK; ctx.fillRect(x, y, s, s); // Brick pattern (mortar lines) ctx.strokeStyle = '#8B4513'; ctx.lineWidth = 1; // Horizontal line ctx.beginPath(); ctx.moveTo(x, y + s / 2); ctx.lineTo(x + s, y + s / 2); ctx.stroke(); // Vertical lines (offset pattern) ctx.beginPath(); ctx.moveTo(x + s / 2, y); ctx.lineTo(x + s / 2, y + s / 2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x + s / 4, y + s / 2); ctx.lineTo(x + s / 4, y + s); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x + s * 3 / 4, y + s / 2); ctx.lineTo(x + s * 3 / 4, y + s); ctx.stroke(); } _drawSteel(ctx, x, y) { const s = TILE_SIZE; ctx.fillStyle = COLORS.STEEL; ctx.fillRect(x, y, s, s); // Steel shine effect ctx.fillStyle = '#A0A0A0'; ctx.fillRect(x + 2, y + 2, s / 2 - 2, s / 2 - 2); ctx.fillRect(x + s / 2 + 1, y + s / 2 + 1, s / 2 - 3, s / 2 - 3); // Border ctx.strokeStyle = '#606060'; ctx.lineWidth = 1; ctx.strokeRect(x + 0.5, y + 0.5, s - 1, s - 1); } _drawRiver(ctx, x, y) { const s = TILE_SIZE; ctx.fillStyle = COLORS.RIVER; ctx.fillRect(x, y, s, s); // Wave pattern ctx.strokeStyle = '#5B9BD5'; ctx.lineWidth = 1; for (let i = 0; i < 3; i++) { const wy = y + s * (i + 1) / 4; ctx.beginPath(); ctx.moveTo(x, wy); ctx.quadraticCurveTo(x + s / 4, wy - 2, x + s / 2, wy); ctx.quadraticCurveTo(x + s * 3 / 4, wy + 2, x + s, wy); ctx.stroke(); } } _drawForest(ctx, x, y) { const s = TILE_SIZE; ctx.fillStyle = COLORS.FOREST; ctx.globalAlpha = 0.85; ctx.fillRect(x, y, s, s); // Tree pattern ctx.fillStyle = '#008000'; const r = s / 4; ctx.beginPath(); ctx.arc(x + s / 3, y + s / 3, r, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(x + s * 2 / 3, y + s / 2, r, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(x + s / 2, y + s * 2 / 3, r, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1; } /** * Draw a base wall tile with visual HP indicator. * @private */ _drawBaseWall(ctx, x, y, row, col) { const s = TILE_SIZE; const key = `${row},${col}`; const hp = this._baseWallHP[key] || 0; const maxHP = this.BASE_WALL_MAX_HP; const ratio = hp / maxHP; // Base color darkens as HP decreases if (ratio > 0.66) { ctx.fillStyle = '#C47832'; // full HP - bright brick } else if (ratio > 0.33) { ctx.fillStyle = '#A05A20'; // medium HP - darker } else { ctx.fillStyle = '#7A3E10'; // low HP - very dark } ctx.fillRect(x, y, s, s); // Brick pattern (mortar lines) ctx.strokeStyle = '#5A2D0C'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, y + s / 2); ctx.lineTo(x + s, y + s / 2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x + s / 2, y); ctx.lineTo(x + s / 2, y + s / 2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x + s / 4, y + s / 2); ctx.lineTo(x + s / 4, y + s); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x + s * 3 / 4, y + s / 2); ctx.lineTo(x + s * 3 / 4, y + s); ctx.stroke(); // Reinforcement border to distinguish from normal brick ctx.strokeStyle = '#FFD700'; ctx.lineWidth = 1.5; ctx.strokeRect(x + 1, y + 1, s - 2, s - 2); // HP indicator dots at top const dotR = 2; const dotSpacing = 8; const startX = x + s / 2 - ((maxHP - 1) * dotSpacing) / 2; for (let i = 0; i < maxHP; i++) { ctx.fillStyle = i < hp ? '#FFD700' : '#333333'; ctx.beginPath(); ctx.arc(startX + i * dotSpacing, y + 5, dotR, 0, Math.PI * 2); ctx.fill(); } } _drawBase(ctx, x, y) { const s = TILE_SIZE; if (this._baseDestroyed) { // Destroyed base ctx.fillStyle = '#333333'; ctx.fillRect(x, y, s, s); ctx.strokeStyle = '#FF0000'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x + 2, y + 2); ctx.lineTo(x + s - 2, y + s - 2); ctx.moveTo(x + s - 2, y + 2); ctx.lineTo(x + 2, y + s - 2); ctx.stroke(); } else { // Eagle / base icon ctx.fillStyle = COLORS.BASE; ctx.fillRect(x, y, s, s); // Simple eagle shape ctx.fillStyle = '#000000'; ctx.font = `${Math.floor(s * 0.7)}px Arial`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('🦅', x + s / 2, y + s / 2); } } // ============================================================ // Collision & Query Methods // ============================================================ /** * Get terrain type at a grid position. * @param {number} row * @param {number} col * @returns {number} Terrain type, or -1 if out of bounds. */ getTerrain(row, col) { if (row < 0 || row >= GRID_ROWS || col < 0 || col >= GRID_COLS) return -1; return this._grid[row][col]; } /** * Set terrain at a grid position. * @param {number} row * @param {number} col * @param {number} terrain */ setTerrain(row, col, terrain) { if (row < 0 || row >= GRID_ROWS || col < 0 || col >= GRID_COLS) return; this._grid[row][col] = terrain; } /** * Convert pixel coordinates to grid coordinates. * @param {number} px - Pixel X (screen space). * @param {number} py - Pixel Y (screen space). * @returns {{row: number, col: number}} */ pixelToGrid(px, py) { const col = Math.floor((px - MAP_OFFSET_X) / TILE_SIZE); const row = Math.floor((py - MAP_OFFSET_Y) / TILE_SIZE); return { row, col }; } /** * Convert grid coordinates to pixel coordinates (top-left of tile). * @param {number} row * @param {number} col * @returns {{x: number, y: number}} */ gridToPixel(row, col) { return { x: MAP_OFFSET_X + col * TILE_SIZE, y: MAP_OFFSET_Y + row * TILE_SIZE, }; } /** * Check if a terrain tile blocks tank movement. * @param {number} row * @param {number} col * @returns {boolean} */ isTankBlocking(row, col) { const t = this.getTerrain(row, col); if (t === -1) return true; // out of bounds return ( t === TERRAIN.BRICK || t === TERRAIN.STEEL || t === TERRAIN.RIVER || t === TERRAIN.BASE || t === TERRAIN.BASE_WALL ); } /** * Check if a terrain tile blocks bullets. * @param {number} row * @param {number} col * @param {boolean} canBreakSteel - Whether the bullet can break steel. * @returns {'block'|'destroy'|'pass'} Result of bullet hitting this tile. */ bulletHitTerrain(row, col, canBreakSteel) { const t = this.getTerrain(row, col); if (t === -1) return 'block'; // out of bounds = wall switch (t) { case TERRAIN.BRICK: // Destroy the brick this._grid[row][col] = TERRAIN.EMPTY; return 'destroy'; case TERRAIN.BASE_WALL: { // Base wall has HP, reduce it const key = `${row},${col}`; const currentHP = (this._baseWallHP[key] || 1) - 1; this._baseWallHP[key] = currentHP; if (currentHP <= 0) { this._grid[row][col] = TERRAIN.EMPTY; return 'destroy'; } return 'block'; // damaged but not destroyed } case TERRAIN.STEEL: if (canBreakSteel) { this._grid[row][col] = TERRAIN.EMPTY; return 'destroy'; } return 'block'; case TERRAIN.BASE: this._baseDestroyed = true; return 'destroy'; case TERRAIN.RIVER: return 'pass'; // bullets fly over river case TERRAIN.FOREST: return 'pass'; // bullets pass through forest default: return 'pass'; } } /** * Check if a rectangular area collides with any blocking terrain. * Used for tank movement collision. * @param {number} x - Left edge (pixel). * @param {number} y - Top edge (pixel). * @param {number} w - Width. * @param {number} h - Height. * @returns {boolean} True if any blocking tile overlaps. */ rectCollidesWithTerrain(x, y, w, h) { // Get grid range that the rect covers const startCol = Math.floor((x - MAP_OFFSET_X) / TILE_SIZE); const endCol = Math.floor((x + w - 1 - MAP_OFFSET_X) / TILE_SIZE); const startRow = Math.floor((y - MAP_OFFSET_Y) / TILE_SIZE); const endRow = Math.floor((y + h - 1 - MAP_OFFSET_Y) / TILE_SIZE); for (let r = startRow; r <= endRow; r++) { for (let c = startCol; c <= endCol; c++) { if (this.isTankBlocking(r, c)) { return true; } } } return false; } /** * Activate shovel power-up: convert base walls to steel temporarily. * @param {number} duration - Duration in ms. */ activateShovel(duration) { this._baseSteelTimer = duration; for (const [r, c] of this._baseWallPositions) { if (this._grid[r][c] === TERRAIN.BASE_WALL || this._grid[r][c] === TERRAIN.EMPTY) { this._grid[r][c] = TERRAIN.STEEL; } } } /** Whether the base has been destroyed. */ get baseDestroyed() { return this._baseDestroyed; } /** Get the raw grid (read-only reference). */ get grid() { return this._grid; } } module.exports = MapManager;