Files
tankwar_proj/js/managers/MapManager.js
T
2026-04-10 22:59:39 +08:00

473 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;