first commit

This commit is contained in:
jakciehan
2026-04-10 22:59:39 +08:00
commit cc2e7b9bb0
89 changed files with 23631 additions and 0 deletions
+472
View File
@@ -0,0 +1,472 @@
/**
* 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;