commit
c1bb6ee77c
@ -0,0 +1 @@
|
|||||||
|
tank-battle-*.png
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
# Tank Battle
|
||||||
|
|
||||||
|
Static browser tank battle game for testing a multi-agent workflow.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
Open `index.html` directly or visit the mapped URL:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://splan.laidaixi.com/docs/
|
||||||
|
```
|
||||||
|
|
||||||
|
No build step or server dependency is required.
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
- Move: `WASD` or arrow keys
|
||||||
|
- Fire: `Space`
|
||||||
|
- Pause: `P`
|
||||||
|
- Start / restart: `Enter`
|
||||||
|
|
||||||
|
## Multi-agent workflow
|
||||||
|
|
||||||
|
- `codex`: project scaffold, integration, Git/Gitea setup, final verification
|
||||||
|
- `zcode(glm-5.2)`: gameplay logic, collision, enemy AI, win/loss state
|
||||||
|
- `qoder(qwen3.7-max)`: UI, HUD, responsive layout, visual polish
|
||||||
|
|
||||||
|
Suggested branches:
|
||||||
|
|
||||||
|
- `feature/scaffold-codex`
|
||||||
|
- `feature/gameplay-zcode`
|
||||||
|
- `feature/ui-qoder`
|
||||||
|
|
||||||
|
## Acceptance checklist
|
||||||
|
|
||||||
|
- Player moves and shoots.
|
||||||
|
- Tanks cannot pass through walls or leave the field.
|
||||||
|
- Enemy tanks move, aim, and fire.
|
||||||
|
- Bullets collide with walls and tanks.
|
||||||
|
- Score, lives, enemies, victory, defeat, pause, and restart states update correctly.
|
||||||
|
- The game works through `https://splan.laidaixi.com/docs/`.
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>坦克大战</title>
|
||||||
|
<link rel="stylesheet" href="./src/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<section class="game-panel" aria-label="坦克大战游戏">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="rank-mark" aria-hidden="true"></span>
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Web Arcade</p>
|
||||||
|
<h1>坦克大战</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="hud" class="hud" aria-live="polite">
|
||||||
|
<div class="hud-item">
|
||||||
|
<span>Score</span>
|
||||||
|
<strong id="score">0</strong>
|
||||||
|
</div>
|
||||||
|
<div class="hud-item">
|
||||||
|
<span>Lives</span>
|
||||||
|
<strong id="lives">3</strong>
|
||||||
|
</div>
|
||||||
|
<div class="hud-item">
|
||||||
|
<span>Enemies</span>
|
||||||
|
<strong id="enemies">0</strong>
|
||||||
|
</div>
|
||||||
|
<div class="status-chip" id="statusChip" aria-live="polite">待命</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="stage-wrap">
|
||||||
|
<canvas id="game" width="800" height="600" aria-label="Tank Battle canvas"></canvas>
|
||||||
|
<div id="overlay" class="overlay">
|
||||||
|
<div class="overlay-content">
|
||||||
|
<p class="status-label" id="statusLabel">Ready</p>
|
||||||
|
<h2 id="statusTitle">Defend the field</h2>
|
||||||
|
<p id="statusText">Press Enter or tap Start to roll out.</p>
|
||||||
|
<div class="overlay-actions">
|
||||||
|
<button id="primaryAction" type="button">Start</button>
|
||||||
|
<button id="endAction" class="secondary-action" type="button">结束</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="controls">
|
||||||
|
<div class="action-group" aria-label="游戏操作">
|
||||||
|
<button id="startAction" type="button">开始</button>
|
||||||
|
<button id="pauseAction" class="secondary-action" type="button">暂停</button>
|
||||||
|
<button id="stopAction" class="danger-action" type="button">结束</button>
|
||||||
|
</div>
|
||||||
|
<div><kbd>WASD</kbd><kbd>Arrow</kbd><span>Move</span></div>
|
||||||
|
<div><kbd>Space</kbd><span>Fire</span></div>
|
||||||
|
<div><kbd>P</kbd><span>Pause</span></div>
|
||||||
|
<div><kbd>Enter</kbd><span>Start / Restart</span></div>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="./src/game.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,518 @@
|
|||||||
|
(() => {
|
||||||
|
"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> Lives: <span id="lives">3</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 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 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;
|
||||||
|
|
||||||
|
const walls = [
|
||||||
|
rect(120, 80, TILE, TILE * 3),
|
||||||
|
rect(240, 120, TILE * 4, TILE),
|
||||||
|
rect(520, 80, TILE, TILE * 3),
|
||||||
|
rect(640, 160, TILE * 2, TILE),
|
||||||
|
rect(80, 320, TILE * 3, TILE),
|
||||||
|
rect(280, 280, TILE, TILE * 3),
|
||||||
|
rect(400, 360, TILE * 4, TILE),
|
||||||
|
rect(640, 360, TILE, TILE * 3),
|
||||||
|
rect(160, 480, TILE * 2, TILE),
|
||||||
|
rect(480, 500, TILE * 3, TILE),
|
||||||
|
];
|
||||||
|
|
||||||
|
function rect(x, y, w, h) {
|
||||||
|
return { x, y, w, h };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = [];
|
||||||
|
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;
|
||||||
|
if (walls.some((wall) => intersects(bullet, wall))) {
|
||||||
|
spark(bullet.x, bullet.y, "#f0c451");
|
||||||
|
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) {
|
||||||
|
ctx.fillStyle = "#5d5540";
|
||||||
|
ctx.fillRect(wall.x, wall.y, wall.w, wall.h);
|
||||||
|
ctx.fillStyle = "rgba(255,255,255,0.08)";
|
||||||
|
for (let y = wall.y + 4; y < wall.y + wall.h; y += 12) {
|
||||||
|
ctx.fillRect(wall.x + 4, y, wall.w - 8, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTank(entity) {
|
||||||
|
const cx = entity.x + entity.w / 2;
|
||||||
|
const cy = entity.y + entity.h / 2;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(cx, cy);
|
||||||
|
ctx.rotate(directionAngle(entity.dir));
|
||||||
|
ctx.fillStyle = "#10140f";
|
||||||
|
ctx.fillRect(-17, -17, 34, 34);
|
||||||
|
ctx.fillStyle = entity.color;
|
||||||
|
ctx.fillRect(-13, -13, 26, 26);
|
||||||
|
ctx.fillStyle = "rgba(255,255,255,0.18)";
|
||||||
|
ctx.fillRect(-7, -8, 14, 16);
|
||||||
|
ctx.fillStyle = "#15160f";
|
||||||
|
ctx.fillRect(-4, -25, 8, 19);
|
||||||
|
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);
|
||||||
|
|
||||||
|
player = tank(384, 536, "#77b255", "up", "player");
|
||||||
|
updateHud();
|
||||||
|
setState("menu");
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", initGame, { once: true });
|
||||||
|
} else {
|
||||||
|
initGame();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@ -0,0 +1,360 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #11150f;
|
||||||
|
--panel: #1d2319;
|
||||||
|
--panel-strong: #262d20;
|
||||||
|
--line: #48533b;
|
||||||
|
--line-bright: #879365;
|
||||||
|
--text: #f1f3dc;
|
||||||
|
--muted: #b4ba9b;
|
||||||
|
--olive: #8da04f;
|
||||||
|
--amber: #f0b646;
|
||||||
|
--danger: #d45d45;
|
||||||
|
--shadow: rgba(0, 0, 0, 0.45);
|
||||||
|
--radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
min-height: 100%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 10%, rgba(141, 160, 79, 0.18), transparent 28rem),
|
||||||
|
linear-gradient(135deg, #161b13, #0c0f0b 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-family:
|
||||||
|
"Microsoft YaHei", "PingFang SC", "Segoe UI", Arial, sans-serif;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
canvas,
|
||||||
|
kbd {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 44px;
|
||||||
|
border: 1px solid #c6d87b;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.72rem 1rem;
|
||||||
|
background: linear-gradient(180deg, #a0b85a, #617434);
|
||||||
|
color: #10150d;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.1;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 8px 18px var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-action {
|
||||||
|
border-color: var(--line-bright);
|
||||||
|
background: #27301f;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-action {
|
||||||
|
border-color: #f09d7e;
|
||||||
|
background: linear-gradient(180deg, #dc6d50, #883925);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 3px solid rgba(240, 182, 70, 0.65);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
width: min(1180px, calc(100vw - 32px));
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 18px 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-panel {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 1fr) minmax(420px, 620px);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand,
|
||||||
|
.hud-item,
|
||||||
|
.controls div {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent),
|
||||||
|
var(--panel);
|
||||||
|
box-shadow: 0 16px 36px var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-mark {
|
||||||
|
width: 38px;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, transparent 42%, #11150f 42% 58%, transparent 58%),
|
||||||
|
linear-gradient(#a0b85a 0 0) center / 100% 32% no-repeat,
|
||||||
|
#53672f;
|
||||||
|
border: 2px solid #c6d87b;
|
||||||
|
clip-path: polygon(50% 0, 100% 28%, 82% 100%, 18% 100%, 0 28%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow,
|
||||||
|
.status-label {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--amber);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(1.6rem, 4vw, 3rem);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-item {
|
||||||
|
min-height: 70px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-item span {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-item strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--amber);
|
||||||
|
font-family: Consolas, "Courier New", monospace;
|
||||||
|
font-size: clamp(1.3rem, 2.5vw, 2rem);
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip {
|
||||||
|
min-height: 70px;
|
||||||
|
border: 1px solid #d6c06c;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: linear-gradient(180deg, #f0b646, #b9842a);
|
||||||
|
color: #10150d;
|
||||||
|
font-weight: 900;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 16px 36px var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
max-height: calc(100vh - 185px);
|
||||||
|
min-height: 280px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid var(--line-bright);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px) 0 0 / 28px 28px,
|
||||||
|
linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px) 0 0 / 28px 28px,
|
||||||
|
#0b0e0a;
|
||||||
|
box-shadow: inset 0 0 0 4px #171d13, 0 18px 42px var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 4;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 18px;
|
||||||
|
background: rgba(7, 9, 7, 0.72);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay.is-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-content {
|
||||||
|
width: min(420px, 100%);
|
||||||
|
border: 1px solid var(--line-bright);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: clamp(18px, 4vw, 30px);
|
||||||
|
text-align: center;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(240, 182, 70, 0.1), transparent 42%),
|
||||||
|
#181e14;
|
||||||
|
box-shadow: 0 22px 50px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-content h2 {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: clamp(1.75rem, 5vw, 3rem);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#statusText {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-actions {
|
||||||
|
margin-top: 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-actions button {
|
||||||
|
width: min(180px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls div {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls .action-group {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls .action-group button {
|
||||||
|
width: 100%;
|
||||||
|
padding-inline: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
min-width: 30px;
|
||||||
|
min-height: 28px;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid #727b5a;
|
||||||
|
border-bottom-width: 3px;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0 7px;
|
||||||
|
background: #10150d;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: Consolas, "Courier New", monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 880px) {
|
||||||
|
.shell {
|
||||||
|
width: min(100vw - 20px, 760px);
|
||||||
|
padding: 10px 0;
|
||||||
|
place-items: start center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-wrap {
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.hud {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-mark {
|
||||||
|
width: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-item {
|
||||||
|
min-height: 62px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip {
|
||||||
|
min-height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls div {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls .action-group {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
max-width: 100%;
|
||||||
|
padding-inline: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in new issue