You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
tank-battle/src/game.js

829 lines
26 KiB

(() => {
"use strict";
function initGame() {
let canvas = document.querySelector("#game") || document.querySelector("canvas");
if (!canvas) {
canvas = document.createElement("canvas");
document.body.appendChild(canvas);
}
canvas.id = canvas.id || "game";
canvas.width = 800;
canvas.height = 600;
canvas.tabIndex = 0;
canvas.style.maxWidth = "100%";
canvas.style.display = "block";
canvas.style.margin = canvas.style.margin || "0 auto";
const ctx = canvas.getContext("2d");
function ensureText(id, fallbackParent = document.body) {
let element = document.querySelector(`#${id}`);
if (!element) {
element = document.createElement("span");
element.id = id;
fallbackParent.appendChild(element);
}
return element;
}
let hud = document.querySelector("#hud");
if (!hud) {
hud = document.createElement("div");
hud.id = "hud";
hud.style.cssText =
"width:800px;max-width:100%;margin:8px auto;color:#e8eadf;font:16px/1.4 system-ui,sans-serif;display:flex;gap:18px;flex-wrap:wrap;";
hud.innerHTML =
'Score: <span id="score">0</span> Health: <span id="lives">100%</span> Enemies: <span id="enemies">0</span>';
canvas.parentNode.insertBefore(hud, canvas.nextSibling);
}
let overlay = document.querySelector("#overlay");
if (!overlay) {
overlay = document.createElement("div");
overlay.id = "overlay";
overlay.style.cssText =
"width:800px;max-width:100%;margin:8px auto;color:#e8eadf;font:18px/1.4 system-ui,sans-serif;text-align:center;";
overlay.innerHTML =
'<div id="statusLabel"></div><h2 id="statusTitle"></h2><p id="statusText"></p><button id="primaryAction" type="button">Start</button>';
hud.parentNode.insertBefore(overlay, hud.nextSibling);
}
const scoreEl = ensureText("score", hud);
const livesEl = ensureText("lives", hud);
const enemiesEl = ensureText("enemies", hud);
const statusLabel = ensureText("statusLabel", overlay);
const statusTitle = ensureText("statusTitle", overlay);
const statusText = ensureText("statusText", overlay);
const primaryAction = document.querySelector("#primaryAction") || (() => {
const button = document.createElement("button");
button.id = "primaryAction";
button.type = "button";
button.textContent = "Start";
overlay.appendChild(button);
return button;
})();
const statusChip = document.querySelector("#statusChip");
const startAction = document.querySelector("#startAction");
const pauseAction = document.querySelector("#pauseAction");
const stopAction = document.querySelector("#stopAction");
const endAction = document.querySelector("#endAction");
const fireAction = document.querySelector("#fireAction");
const touchButtons = [...document.querySelectorAll("[data-control]")];
const WIDTH = canvas.width;
const HEIGHT = canvas.height;
const TILE = 40;
const PLAYER_SPEED = 150;
const ENEMY_SPEED = 82;
const BULLET_SPEED = 330;
const TANK_SIZE = 32;
const BULLET_SIZE = 6;
const FIRE_COOLDOWN = 0.34;
const ENEMY_FIRE_COOLDOWN = 1.35;
const BRICK_SIZE = 20;
const PLAYER_MAX_HEALTH = 100;
const PLAYER_HIT_DAMAGE = 34;
const DIRS = {
up: { x: 0, y: -1 },
down: { x: 0, y: 1 },
left: { x: -1, y: 0 },
right: { x: 1, y: 0 },
};
const keys = new Set();
let state = "menu";
let score = 0;
let health = PLAYER_MAX_HEALTH;
let player;
let enemies = [];
let bullets = [];
let particles = [];
let lastTime = 0;
// Static brick layout (kept stable across games). Steel walls are randomized
// on every reset, so they are added separately in resetGame via regenerateWalls.
const BRICK_LAYOUT = [
[120, 80, TILE, TILE * 3],
[240, 120, TILE * 4, TILE],
[640, 160, TILE * 2, TILE],
[80, 320, TILE * 3, TILE],
[280, 280, TILE, TILE * 3],
[400, 360, TILE * 4, TILE],
[160, 480, TILE * 2, TILE],
[480, 500, TILE * 3, TILE],
];
// Rectangles that steel walls must avoid so spawns are not blocked or boxed in.
// Each entry is padded around a player/enemy starting position.
const SPAWN_KEEPOUTS = [
spawnKeepout(384, 536), // player
spawnKeepout(84, 64),
spawnKeepout(384, 64),
spawnKeepout(684, 64),
spawnKeepout(84, 220),
spawnKeepout(684, 260),
];
let walls = [];
function spawnKeepout(x, y) {
// Generous margin (1.5 tiles) around the spawn keeps lanes open so tanks
// are never immediately boxed in by randomized steel.
const margin = TILE * 1.5;
return {
x: x - margin,
y: y - margin,
w: TANK_SIZE + margin * 2,
h: TANK_SIZE + margin * 2,
};
}
function buildBrickWalls() {
const pieces = [];
for (const [x, y, w, h] of BRICK_LAYOUT) {
pieces.push(...brickWall(x, y, w, h));
}
return pieces;
}
function randomSteelWalls(occupied) {
// Generate a handful of vertical/horizontal steel segments at random tile
// positions, skipping any that overlap a spawn keep-out zone.
const pieces = [];
const segmentCount = 5 + Math.floor(Math.random() * 3); // 5-7 steel wall segments
const cols = Math.floor(WIDTH / TILE);
const rows = Math.floor(HEIGHT / TILE);
let placedSegments = 0;
let attempts = 0;
while (placedSegments < segmentCount && attempts < segmentCount * 30) {
attempts += 1;
const vertical = Math.random() < 0.5;
const length = (2 + Math.floor(Math.random() * 3)) * TILE; // 2-4 tiles long
const x = Math.floor(Math.random() * cols) * TILE;
const y = Math.floor(Math.random() * rows) * TILE;
const w = vertical ? TILE : length;
const h = vertical ? length : TILE;
const candidate = { x, y, w, h };
if (x + w > WIDTH || y + h > HEIGHT) continue;
if (SPAWN_KEEPOUTS.some((zone) => intersects(candidate, zone))) continue;
if (occupied.some((area) => intersects(candidate, area))) continue;
pieces.push(...steelWall(x, y, w, h));
occupied.push(candidate);
placedSegments += 1;
}
return pieces;
}
function regenerateWalls() {
const bricks = buildBrickWalls();
walls = [...bricks, ...randomSteelWalls([...bricks])];
}
function rect(x, y, w, h, type = "brick") {
return { x, y, w, h, type, destructible: type === "brick" };
}
function brickWall(x, y, w, h) {
return splitWall(x, y, w, h, "brick");
}
function steelWall(x, y, w, h) {
return splitWall(x, y, w, h, "steel");
}
function splitWall(x, y, w, h, type) {
const pieces = [];
for (let py = y; py < y + h; py += BRICK_SIZE) {
for (let px = x; px < x + w; px += BRICK_SIZE) {
pieces.push(rect(px, py, Math.min(BRICK_SIZE, x + w - px), Math.min(BRICK_SIZE, y + h - py), type));
}
}
return pieces;
}
function tank(x, y, color, dir = "up", team = "enemy") {
return {
x,
y,
w: TANK_SIZE,
h: TANK_SIZE,
color,
dir,
team,
fireTimer: 0,
aiTimer: 0.4 + Math.random() * 1.2,
avoidTimer: 0,
alive: true,
};
}
function resetGame() {
score = 0;
health = PLAYER_MAX_HEALTH;
bullets = [];
particles = [];
regenerateWalls();
player = tank(384, 536, "#77b255", "up", "player");
enemies = [
tank(84, 64, "#db5a42", "down"),
tank(384, 64, "#d9923b", "down"),
tank(684, 64, "#db5a42", "down"),
tank(84, 220, "#d9923b", "right"),
tank(684, 260, "#db5a42", "left"),
];
setState("playing");
updateHud();
}
function setState(next) {
state = next;
const visible = state !== "playing";
overlay.classList.toggle("is-hidden", !visible);
overlay.style.display = visible ? "" : "none";
if (state === "menu") {
if (statusChip) statusChip.textContent = "待命";
statusLabel.textContent = "Ready";
statusTitle.textContent = "Defend the field";
statusText.textContent = "Press Enter or tap Start to roll out.";
primaryAction.textContent = "Start";
if (endAction) endAction.hidden = true;
} else if (state === "paused") {
if (statusChip) statusChip.textContent = "暂停";
statusLabel.textContent = "Paused";
statusTitle.textContent = "Hold position";
statusText.textContent = "Press P or tap Resume to continue.";
primaryAction.textContent = "Resume";
if (endAction) endAction.hidden = false;
} else if (state === "ended") {
if (statusChip) statusChip.textContent = "结束";
statusLabel.textContent = "Ended";
statusTitle.textContent = "Mission ended";
statusText.textContent = `Final score: ${score}. Press Enter to restart.`;
primaryAction.textContent = "Restart";
if (endAction) endAction.hidden = true;
} else if (state === "gameOver") {
if (statusChip) statusChip.textContent = "战损";
statusLabel.textContent = "Defeat";
statusTitle.textContent = "Armor breached";
statusText.textContent = `Final score: ${score}. Press Enter to restart.`;
primaryAction.textContent = "Restart";
if (endAction) endAction.hidden = true;
} else if (state === "win") {
if (statusChip) statusChip.textContent = "胜利";
statusLabel.textContent = "Victory";
statusTitle.textContent = "Field secured";
statusText.textContent = `All enemies destroyed. Score: ${score}.`;
primaryAction.textContent = "Play again";
if (endAction) endAction.hidden = true;
} else {
if (statusChip) statusChip.textContent = "作战中";
if (endAction) endAction.hidden = false;
}
}
function updateHud() {
scoreEl.textContent = score;
livesEl.textContent = `${health}%`;
enemiesEl.textContent = enemies.filter((enemy) => enemy.alive).length;
}
function startOrResume() {
if (state === "playing") return;
if (state === "paused") {
setState("playing");
return;
}
resetGame();
}
function togglePause() {
if (state === "playing") setState("paused");
else if (state === "paused") setState("playing");
}
function endGame() {
if (state === "playing" || state === "paused") setState("ended");
}
function fire(source) {
if (source.fireTimer > 0 || !source.alive) return;
const dir = DIRS[source.dir];
bullets.push({
x: source.x + source.w / 2 - BULLET_SIZE / 2 + dir.x * 20,
y: source.y + source.h / 2 - BULLET_SIZE / 2 + dir.y * 20,
w: BULLET_SIZE,
h: BULLET_SIZE,
vx: dir.x * BULLET_SPEED,
vy: dir.y * BULLET_SPEED,
owner: source.team,
});
source.fireTimer = source.team === "player" ? FIRE_COOLDOWN : ENEMY_FIRE_COOLDOWN;
}
function update(dt) {
if (state !== "playing") return;
player.fireTimer = Math.max(0, player.fireTimer - dt);
movePlayer(dt);
updateEnemies(dt);
updateBullets(dt);
updateParticles(dt);
if (enemies.every((enemy) => !enemy.alive)) setState("win");
updateHud();
}
function movePlayer(dt) {
let move = null;
if (keys.has("arrowup") || keys.has("w")) move = "up";
else if (keys.has("arrowdown") || keys.has("s")) move = "down";
else if (keys.has("arrowleft") || keys.has("a")) move = "left";
else if (keys.has("arrowright") || keys.has("d")) move = "right";
if (!move) return;
player.dir = move;
moveTank(player, DIRS[move].x * PLAYER_SPEED * dt, DIRS[move].y * PLAYER_SPEED * dt);
}
function updateEnemies(dt) {
const livingEnemies = enemies.filter((enemy) => enemy.alive);
for (const enemy of livingEnemies) {
enemy.fireTimer = Math.max(0, enemy.fireTimer - dt);
enemy.avoidTimer = Math.max(0, enemy.avoidTimer - dt);
enemy.aiTimer -= dt;
if (enemy.aiTimer <= 0 && enemy.avoidTimer <= 0) {
enemy.dir = chooseEnemyDirection(enemy);
enemy.aiTimer = 0.45 + Math.random() * 1.25;
}
const dir = DIRS[enemy.dir];
const moved = moveTank(enemy, dir.x * ENEMY_SPEED * dt, dir.y * ENEMY_SPEED * dt, livingEnemies);
if (!moved) {
const escapeDir = chooseOpenEnemyDirection(enemy, livingEnemies);
enemy.dir = escapeDir || randomDirection();
enemy.avoidTimer = 0.55;
enemy.aiTimer = 0.2;
const next = DIRS[enemy.dir];
moveTank(enemy, next.x * ENEMY_SPEED * dt, next.y * ENEMY_SPEED * dt, livingEnemies);
}
if (canSeePlayer(enemy) || Math.random() < dt * 0.18) {
if (enemy.avoidTimer <= 0) facePlayer(enemy);
fire(enemy);
}
}
}
function chooseEnemyDirection(enemy) {
if (Math.random() < 0.65) {
const dx = player.x - enemy.x;
const dy = player.y - enemy.y;
if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? "right" : "left";
return dy > 0 ? "down" : "up";
}
return randomDirection();
}
function randomDirection() {
const names = Object.keys(DIRS);
return names[Math.floor(Math.random() * names.length)];
}
function chooseOpenEnemyDirection(enemy, livingEnemies) {
const preferred = shuffledDirections().sort((a, b) => distanceFromPlayer(enemy, b) - distanceFromPlayer(enemy, a));
return preferred.find((name) => canMove(enemy, name, livingEnemies));
}
function shuffledDirections() {
const names = Object.keys(DIRS);
for (let i = names.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[names[i], names[j]] = [names[j], names[i]];
}
return names;
}
function distanceFromPlayer(enemy, dirName) {
const dir = DIRS[dirName];
const nextX = enemy.x + dir.x * TILE;
const nextY = enemy.y + dir.y * TILE;
const dx = nextX - player.x;
const dy = nextY - player.y;
return dx * dx + dy * dy;
}
function canMove(entity, dirName, peerEnemies) {
const dir = DIRS[dirName];
const probe = { ...entity, x: entity.x + dir.x * 4, y: entity.y + dir.y * 4 };
return !isBlocked(probe, peerEnemies.filter((peer) => peer !== entity));
}
function facePlayer(enemy) {
const dx = player.x - enemy.x;
const dy = player.y - enemy.y;
enemy.dir = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? "right" : "left") : dy > 0 ? "down" : "up";
}
function canSeePlayer(enemy) {
const sameColumn = Math.abs(enemy.x - player.x) < TILE * 0.7;
const sameRow = Math.abs(enemy.y - player.y) < TILE * 0.7;
if (!sameColumn && !sameRow) return false;
const beam = sameColumn
? rect(enemy.x + enemy.w / 2 - 3, Math.min(enemy.y, player.y), 6, Math.abs(enemy.y - player.y))
: rect(Math.min(enemy.x, player.x), enemy.y + enemy.h / 2 - 3, Math.abs(enemy.x - player.x), 6);
return !walls.some((wall) => intersects(beam, wall));
}
function moveTank(entity, dx, dy, peerEnemies = enemies) {
const originalX = entity.x;
const originalY = entity.y;
entity.x += dx;
if (isBlocked(entity, peerEnemies)) entity.x = originalX;
entity.y += dy;
if (isBlocked(entity, peerEnemies)) entity.y = originalY;
return entity.x !== originalX || entity.y !== originalY;
}
function isBlocked(entity, peerEnemies) {
if (entity.x < 0 || entity.y < 0 || entity.x + entity.w > WIDTH || entity.y + entity.h > HEIGHT) return true;
if (walls.some((wall) => intersects(entity, wall))) return true;
if (entity.team === "player") {
return enemies.some((enemy) => enemy.alive && intersects(entity, enemy));
}
if (intersects(entity, player)) return true;
return peerEnemies.some((enemy) => enemy !== entity && enemy.alive && intersects(entity, enemy));
}
function updateBullets(dt) {
for (const bullet of bullets) {
bullet.x += bullet.vx * dt;
bullet.y += bullet.vy * dt;
}
const canceledBullets = new Set();
for (let i = 0; i < bullets.length; i += 1) {
if (canceledBullets.has(i)) continue;
for (let j = i + 1; j < bullets.length; j += 1) {
if (canceledBullets.has(j)) continue;
if (bullets[i].owner === bullets[j].owner) continue;
if (!intersects(expandedBullet(bullets[i]), expandedBullet(bullets[j]))) continue;
canceledBullets.add(i);
canceledBullets.add(j);
spark((bullets[i].x + bullets[j].x) / 2, (bullets[i].y + bullets[j].y) / 2, "#f7df7b");
break;
}
}
const remaining = [];
for (let index = 0; index < bullets.length; index += 1) {
if (canceledBullets.has(index)) continue;
const bullet = bullets[index];
const out =
bullet.x < -20 || bullet.y < -20 || bullet.x > WIDTH + 20 || bullet.y > HEIGHT + 20;
if (out) continue;
const wallIndex = walls.findIndex((wall) => intersects(bullet, wall));
if (wallIndex !== -1) {
const wall = walls[wallIndex];
if (wall.destructible) {
spark(bullet.x, bullet.y, "#e0913a");
walls.splice(wallIndex, 1);
if (bullet.owner === "player") score += 5;
} else {
spark(bullet.x, bullet.y, "#c4c8bd");
}
continue;
}
if (bullet.owner === "player") {
const hit = enemies.find((enemy) => enemy.alive && intersects(bullet, enemy));
if (hit) {
hit.alive = false;
score += 100;
spark(hit.x + hit.w / 2, hit.y + hit.h / 2, hit.color);
continue;
}
} else if (intersects(bullet, player)) {
health = Math.max(0, health - PLAYER_HIT_DAMAGE);
spark(player.x + player.w / 2, player.y + player.h / 2, "#77b255");
if (health <= 0) {
player.alive = false;
setState("gameOver");
} else {
player.x = 384;
player.y = 536;
player.dir = "up";
}
continue;
}
remaining.push(bullet);
}
bullets = remaining;
}
function expandedBullet(bullet) {
const padding = 5;
return {
x: bullet.x - padding,
y: bullet.y - padding,
w: bullet.w + padding * 2,
h: bullet.h + padding * 2,
};
}
function updateParticles(dt) {
particles = particles
.map((particle) => ({
...particle,
life: particle.life - dt,
x: particle.x + particle.vx * dt,
y: particle.y + particle.vy * dt,
}))
.filter((particle) => particle.life > 0);
}
function spark(x, y, color) {
for (let i = 0; i < 10; i += 1) {
const angle = Math.random() * Math.PI * 2;
const speed = 35 + Math.random() * 95;
particles.push({
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 0.22 + Math.random() * 0.24,
color,
});
}
}
function intersects(a, b) {
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;
}
function draw() {
ctx.clearRect(0, 0, WIDTH, HEIGHT);
drawGrid();
walls.forEach(drawWall);
enemies.filter((enemy) => enemy.alive).forEach(drawTank);
if (player?.alive !== false) drawTank(player);
bullets.forEach(drawBullet);
particles.forEach(drawParticle);
}
function drawGrid() {
ctx.fillStyle = "#1a1f16";
ctx.fillRect(0, 0, WIDTH, HEIGHT);
ctx.strokeStyle = "rgba(120, 129, 95, 0.15)";
ctx.lineWidth = 1;
for (let x = 0; x <= WIDTH; x += TILE) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, HEIGHT);
ctx.stroke();
}
for (let y = 0; y <= HEIGHT; y += TILE) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(WIDTH, y);
ctx.stroke();
}
}
function drawWall(wall) {
if (wall.type === "steel") {
ctx.fillStyle = "#6f7770";
ctx.fillRect(wall.x, wall.y, wall.w, wall.h);
ctx.fillStyle = "rgba(255,255,255,0.22)";
ctx.fillRect(wall.x + 3, wall.y + 3, wall.w - 6, 3);
ctx.fillStyle = "rgba(0,0,0,0.24)";
ctx.fillRect(wall.x + 3, wall.y + wall.h - 6, wall.w - 6, 3);
return;
}
ctx.fillStyle = "#8e5a3d";
ctx.fillRect(wall.x, wall.y, wall.w, wall.h);
ctx.strokeStyle = "rgba(40,24,16,0.58)";
ctx.lineWidth = 1;
ctx.strokeRect(wall.x + 0.5, wall.y + 0.5, wall.w - 1, wall.h - 1);
ctx.fillStyle = "rgba(255,220,150,0.1)";
ctx.fillRect(wall.x + 3, wall.y + 3, wall.w - 6, 3);
}
function drawTank(entity) {
const cx = entity.x + entity.w / 2;
const cy = entity.y + entity.h / 2;
const isPlayer = entity.team === "player";
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(directionAngle(entity.dir));
let glowColor, glowBlur, outerColor, innerColor, coreColor;
if (isPlayer) {
if (health > 66) {
glowColor = "rgba(132, 238, 212, 0.75)";
glowBlur = 12;
outerColor = "#e8fff7";
innerColor = "#102822";
coreColor = entity.color;
} else if (health > 33) {
glowColor = "rgba(240, 182, 70, 0.45)";
glowBlur = 8;
outerColor = "#c8c0a0";
innerColor = "#1e2818";
coreColor = "#9a9850";
} else {
const pulse = 0.5 + 0.5 * Math.sin(Date.now() / 180);
glowColor = `rgba(212, 93, 69, ${0.5 + 0.4 * pulse})`;
glowBlur = 10 + 6 * pulse;
outerColor = "#3a2020";
innerColor = "#1a0c0c";
coreColor = "#6b2828";
}
ctx.shadowColor = glowColor;
ctx.shadowBlur = glowBlur;
}
ctx.fillStyle = isPlayer ? outerColor : "#2c0f0a";
ctx.fillRect(-18, -18, 36, 36);
ctx.shadowBlur = 0;
ctx.fillStyle = isPlayer ? innerColor : "#10140f";
ctx.fillRect(-15, -15, 30, 30);
ctx.fillStyle = coreColor || entity.color;
ctx.fillRect(-12, -12, 24, 24);
ctx.fillStyle = "rgba(255,255,255,0.18)";
ctx.fillRect(-7, -8, 14, 16);
if (isPlayer && health <= 66 && health > 33) {
ctx.strokeStyle = "rgba(30, 20, 10, 0.55)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(-10, -6);
ctx.lineTo(4, 8);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(6, -10);
ctx.lineTo(12, -2);
ctx.stroke();
ctx.fillStyle = "rgba(30, 20, 10, 0.4)";
ctx.fillRect(-8, 4, 5, 5);
ctx.fillRect(5, -4, 4, 4);
}
if (isPlayer && health <= 33) {
ctx.strokeStyle = "rgba(80, 10, 10, 0.6)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(-12, -8);
ctx.lineTo(6, 10);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(8, -12);
ctx.lineTo(-4, 6);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(-6, -12);
ctx.lineTo(10, 4);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(-10, 2);
ctx.lineTo(8, -6);
ctx.stroke();
}
ctx.fillStyle = isPlayer ? "#f4fff8" : "#1b0805";
if (isPlayer && health <= 33 && Math.floor(Date.now() / 300) % 2 === 0) {
ctx.globalAlpha = 0.3;
}
ctx.fillRect(-4, -26, 8, 20);
if (isPlayer) {
ctx.fillStyle = "#f4fff8";
ctx.fillRect(-2, -10, 4, 20);
ctx.fillRect(-10, -2, 20, 4);
ctx.fillStyle = "#79e5c9";
ctx.fillRect(-5, -5, 10, 10);
if (health <= 33 && Math.floor(Date.now() / 300) % 2 === 0) {
ctx.fillStyle = "rgba(212, 93, 69, 0.7)";
ctx.beginPath();
ctx.moveTo(0, -14);
ctx.lineTo(5, -6);
ctx.lineTo(-5, -6);
ctx.closePath();
ctx.fill();
}
} else {
ctx.fillStyle = "#ffd2c2";
ctx.beginPath();
ctx.moveTo(0, -9);
ctx.lineTo(8, 8);
ctx.lineTo(-8, 8);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.restore();
}
function directionAngle(dir) {
if (dir === "right") return Math.PI / 2;
if (dir === "down") return Math.PI;
if (dir === "left") return -Math.PI / 2;
return 0;
}
function drawBullet(bullet) {
ctx.fillStyle = bullet.owner === "player" ? "#f0c451" : "#f07a5a";
ctx.fillRect(bullet.x, bullet.y, bullet.w, bullet.h);
}
function drawParticle(particle) {
ctx.globalAlpha = Math.max(0, particle.life * 3);
ctx.fillStyle = particle.color;
ctx.fillRect(particle.x, particle.y, 4, 4);
ctx.globalAlpha = 1;
}
function loop(time) {
const dt = Math.min(0.033, (time - lastTime) / 1000 || 0);
lastTime = time;
update(dt);
draw();
requestAnimationFrame(loop);
}
window.addEventListener("keydown", (event) => {
const key = event.key.toLowerCase();
if (["arrowup", "arrowdown", "arrowleft", "arrowright", " ", "spacebar"].includes(key)) {
event.preventDefault();
}
if (key === "enter") startOrResume();
else if (key === "p") togglePause();
else if (key === "escape") endGame();
else if ((key === " " || key === "spacebar") && state === "playing") fire(player);
keys.add(key);
});
window.addEventListener("keyup", (event) => {
keys.delete(event.key.toLowerCase());
});
function bindHoldButton(button, key) {
const press = (event) => {
event.preventDefault();
button.setPointerCapture?.(event.pointerId);
keys.add(key);
canvas.focus({ preventScroll: true });
};
const release = (event) => {
event.preventDefault();
keys.delete(key);
try {
button.releasePointerCapture?.(event.pointerId);
} catch {
// Some browsers release capture automatically on cancel/leave.
}
};
button.addEventListener("pointerdown", press);
button.addEventListener("pointerup", release);
button.addEventListener("pointercancel", release);
button.addEventListener("pointerleave", release);
}
const touchKeyMap = {
up: "arrowup",
down: "arrowdown",
left: "arrowleft",
right: "arrowright",
};
for (const button of touchButtons) {
const key = touchKeyMap[button.dataset.control];
if (key) bindHoldButton(button, key);
}
primaryAction.addEventListener("click", startOrResume);
if (startAction) startAction.addEventListener("click", startOrResume);
if (pauseAction) pauseAction.addEventListener("click", togglePause);
if (stopAction) stopAction.addEventListener("click", endGame);
if (endAction) endAction.addEventListener("click", endGame);
if (fireAction) {
fireAction.addEventListener("pointerdown", (event) => {
event.preventDefault();
canvas.focus({ preventScroll: true });
if (state === "playing") fire(player);
});
}
regenerateWalls();
player = tank(384, 536, "#77b255", "up", "player");
updateHud();
setState("menu");
requestAnimationFrame(loop);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initGame, { once: true });
} else {
initGame();
}
})();