(() => { "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: 0 Lives: 3 Enemies: 0'; 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 = '

'; 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 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 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 lives = 3; 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 count = 3 + Math.floor(Math.random() * 3); // 3-5 steel walls const cols = Math.floor(WIDTH / TILE); const rows = Math.floor(HEIGHT / TILE); let attempts = 0; while (pieces.length < count && attempts < count * 20) { attempts += 1; const vertical = Math.random() < 0.5; const length = (2 + Math.floor(Math.random() * 2)) * TILE; // 2-3 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); } 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, alive: true, }; } function resetGame() { score = 0; lives = 3; 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 = lives; 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.aiTimer -= dt; if (enemy.aiTimer <= 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) { enemy.dir = randomDirection(); enemy.aiTimer = 0.25; } if (canSeePlayer(enemy) || Math.random() < dt * 0.18) { 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 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 remaining = []; for (const bullet of bullets) { 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)) { lives -= 1; spark(player.x + player.w / 2, player.y + player.h / 2, "#77b255"); if (lives <= 0) { player.alive = false; setState("gameOver"); } else { player.x = 384; player.y = 536; player.dir = "up"; } continue; } remaining.push(bullet); } bullets = remaining; } 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 (lives >= 3) { glowColor = "rgba(132, 238, 212, 0.75)"; glowBlur = 12; outerColor = "#e8fff7"; innerColor = "#102822"; coreColor = entity.color; } else if (lives === 2) { 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 && lives === 2) { 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 && lives <= 1) { 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 && lives <= 1 && 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 (lives <= 1 && 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()); }); 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); 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(); } })();