473 lines
13 KiB
JavaScript
473 lines
13 KiB
JavaScript
/**
|
||
* 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;
|