Compare commits

..

No commits in common. 'main' and 'agent/zcode/gameplay-issue-1' have entirely different histories.

2
.gitignore vendored

@ -1,3 +1 @@
tank-battle-*.png
.qoder/
.vscode/

@ -23,8 +23,8 @@
<strong id="score">0</strong>
</div>
<div class="hud-item">
<span>Health</span>
<strong id="lives">100%</strong>
<span>Lives</span>
<strong id="lives">3</strong>
</div>
<div class="hud-item">
<span>Enemies</span>
@ -34,12 +34,6 @@
</div>
</header>
<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 class="stage-wrap">
<canvas id="game" width="800" height="600" aria-label="Tank Battle canvas"></canvas>
<div id="overlay" class="overlay">
@ -56,19 +50,16 @@
</div>
<footer class="controls">
<div class="touch-controls" aria-label="触摸操作">
<div id="moveStick" class="move-stick" aria-label="方向摇杆" role="application">
<div id="moveKnob" class="move-knob" aria-hidden="true"></div>
</div>
<button id="fireAction" class="fire-action" type="button" aria-label="开火">开火</button>
</div>
<div class="key-hints" aria-label="键盘快捷键">
<div class="key-hint"><kbd>WASD</kbd><kbd>↑↓←→</kbd><span>Move</span></div>
<div class="key-hint"><kbd>Space</kbd><span>Fire</span></div>
<div class="key-hint"><kbd>P</kbd><span>Pause</span></div>
<div class="key-hint"><kbd>Esc</kbd><span>End</span></div>
<div class="key-hint"><kbd>Enter</kbd><span>Start / Restart</span></div>
<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>Esc</kbd><span>End</span></div>
<div><kbd>Enter</kbd><span>Start / Restart</span></div>
</footer>
</section>
</main>

@ -1,381 +0,0 @@
# 多 Agent 协同工作流CLI 作为关键基础设施
> **核心理念**:多个真实 Agent 协作开发同一个项目时,不让 Agent 彼此直连,也不依赖共享服务进程。所有协作事实都落在 Git 和代码托管平台上,平台 CLI 只作为共同操作界面。
---
## 一、测试目标
本文件不仅描述架构,也作为一次真实多 Agent 协作测试的 runbook。
本轮测试判定标准:
| 目标 | 判定方式 |
| --- | --- |
| **真实多 Agent** | 至少一个外部 Agent 参与。Codex 内部子 agent 不计为外部 Agent。 |
| **可追踪** | 每个 Agent 的动作必须留下 issue comment、branch、commit、PR 或 review comment。 |
| **边界清楚** | 每个任务明确允许修改和禁止修改的文件范围。 |
| **可集成** | Codex 只做统筹、集成、冲突处理和验收,不替代外部 Agent 完成任务。 |
| **可验证** | 最终结果必须能通过本地和 `serve-docs` 公网链接验证。 |
---
## 二、设计目标
| 目标 | 说明 |
| --- | --- |
| **解耦** | 各 Agent 之间不直接通信,避免任何一个 Agent 成为单点或瓶颈。 |
| **可独立运行** | 无需共享服务进程,每个 Agent 独立调用 CLI 或通过人工转交任务。 |
| **可观测** | 所有协作动作都落在 Git/Gitee 上issue、branch、commit、PR、comment。 |
| **可替换** | Codex、ZCode、Qoder、GitHub Copilot CLI 等实体都可以替换。 |
| **可降级** | 某个 Agent 没有 CLI/headless 接口时,允许用人工任务包转交,但必须保留记录。 |
---
## 三、整体架构
```text
┌────────────────────┐
│ Agent A: Gameplay │
│ ZCode / other coder │
└─────────┬──────────┘
│ issue / branch / PR / comment
┌────────────────────┐ ┌────────────────────┐
│ platform CLI │───────▶│ Gitee / Gitea │
│ shared operation │ │ issue / branch / PR │
└────────────────────┘ └────────────────────┘
│ issue / branch / PR / comment
┌─────────┴──────────┐
│ Agent C: UI / CI │
│ Qoder / other agent │
└────────────────────┘
Codex: coordinator, reviewer, integrator, final verifier.
```
**关键点**Git + 平台 CLI 是唯一的协作总线。Agent 之间不握手、不传私有消息,而是通过 issue、branch、PR、comment 对话。
---
## 四、Preflight 检查
开始测试前Codex 先执行并记录结果:
```powershell
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
chcp 65001
git status --short
git branch --show-current
git remote -v
tea login list
tea issues list -r laidaixi/tank-battle
qodercli --help
qodercli -p "ping" -w .
```
本轮针对 `gitea.laidaixi.com/laidaixi/tank-battle` 的实测结论:
- `ge` 已登录 gitee.com但不适配当前 Gitea 实例的 API 形态。
- `tea` 已登录 `https://gitea.laidaixi.com`,适合当前仓库的 issue、label、PR、merge 操作。
- 后续在 Gitea 仓库上统一使用 `tea`;只有目标平台是 gitee.com 时才使用 `ge`
ZCode 已发现可脚本化入口:
```powershell
node "D:\Program Files\ZCode\resources\glm\zcode.cjs" --help
node "D:\Program Files\ZCode\resources\glm\zcode.cjs" --prompt "..." --cwd "E:\Documents\ai_share" --mode plan --no-color
```
当前注意事项:
- `ZCode.exe` 本身是 Electron 桌面入口,不适合作为 headless CLI。
- `resources\glm\zcode.cjs` 是实际 CLI版本可用 `version` 查看。
- 当前 CLI 可通过环境变量注入 `ZCODE_MODEL`、`ZCODE_BASE_URL`、`ZCODE_API_KEY` 调用;不要把 token 写入仓库或 issue。
- 桌面端配置在 `C:\Users\Administrator\.zcode\v2\config.json`,同步 CLI 配置时必须避免泄露 token。
如果 ZCode CLI 配置未就绪,本轮按半自动 Agent 处理:
- Codex 生成 ZCode 任务包。
- 用户把任务包贴给 ZCode GUI。
- ZCode 产出 patch、commit 或分支后Codex 再集成。
- 这仍可计入真实外部 Agent但必须在 issue comment 中记录“人工转交”。
---
## 五、Agent 角色和边界
| 角色 | 实体候选 | 职责 | 允许修改 | 禁止修改 |
| --- | --- | --- | --- | --- |
| Agent A: Gameplay | ZCode (glm-5.2) | 核心玩法、碰撞、敌人 AI、胜负状态 | `src/game.js` | `index.html`、`src/styles.css`、Git 配置 |
| Agent B: Review/Integration | Codex | issue 编排、review、冲突合并、验收 | 全仓,但只做集成和必要修复 | 代替外部 Agent 完成其任务 |
| Agent C: UI/CI | Qoder CLI (`qodercli`) | 页面结构、HUD、响应式、视觉体验、部署检查 | `index.html`、`src/styles.css` | `src/game.js` 核心玩法算法 |
---
## 六、平台 CLI 接口契约
所有 Agent 尽量通过同一个平台 CLI 与托管平台交互;当前 Gitea 仓库使用 `tea`。最小命令集:
| 命令 | 作用 | 典型调用方 |
| --- | --- | --- |
| `tea issues create` | 创建任务单,包含验收标准和文件边界 | Codex / 统筹 |
| `tea issues list` | 拉取待处理 issue | 任意 Agent |
| `tea comment` | 记录领取、进度、阻塞、交接信息 | 任意 Agent |
| `tea issues edit --add-labels/--remove-labels` | 更新任务状态和归属 | 任意 Agent |
| `tea pulls create` | 从 feature 分支发起 PR | Coder Agent |
| `tea comment` | 记录 review 结论或验证结果 | Codex / Review Agent |
| `tea pulls merge` | 合并通过审查和验证的 PR | Codex / CI Agent |
**约束**CLI 只做对托管平台的原子动作,不包含业务编排。编排逻辑放在 Agent prompt、脚本或本 runbook 中。
---
## 七、Label 和状态协议
不要只依赖中文状态文本。所有状态必须落成 label。
推荐 label
| 类型 | Label |
| --- | --- |
| 状态 | `status:todo`、`status:doing`、`status:review`、`status:changes-requested`、`status:done`、`status:deployed` |
| Agent | `agent:zcode`、`agent:qoder`、`agent:codex` |
| 领域 | `area:gameplay`、`area:ui`、`area:integration` |
| 风险 | `risk:conflict`、`risk:manual-handoff`、`risk:needs-verification` |
状态机:
```text
issue: status:todo -> status:doing -> status:review -> status:done -> status:deployed
└──-> status:changes-requested -> status:doing
pr: draft -> review -> approved -> merged
└-> changes-requested -> draft/review
```
规则:
- 谁改状态谁写 comment说明为什么改。
- 合并前必须重新拉取 issue/PR 最新状态,避免覆盖他人结论。
- `status:changes-requested` 未清除前不能合并。
---
## 八、领取任务协议
Agent 领取任务时必须写入 comment并更新 label。
示例:
```bash
tea comment -r laidaixi/tank-battle 12 "CLAIM agent=qoder scope=index.html,src/styles.css"
tea issues edit 12 --remove-labels status:todo --add-labels status:doing,agent:qoder,area:ui -r laidaixi/tank-battle
```
如果 Agent 无法继续:
```bash
tea comment -r laidaixi/tank-battle 12 "BLOCKED agent=qoder reason='缺少设计边界或命令失败' next='等待 Codex 补充说明'"
```
完成任务并准备 review
```bash
tea comment -r laidaixi/tank-battle 12 "READY_FOR_REVIEW agent=qoder branch=agent/qoder/ui-issue-12 checks='manual UI smoke test passed'"
tea issues edit 12 --remove-labels status:doing --add-labels status:review -r laidaixi/tank-battle
```
---
## 九、分支和 Worktree 规范
每个 Agent 使用独立分支,避免直接改 `main`
| 用途 | 分支 |
| --- | --- |
| ZCode gameplay | `agent/zcode/gameplay-issue-N` |
| Qoder UI | `agent/qoder/ui-issue-N` |
| Codex integration | `integration/codex-issue-N` |
建议 Codex 集成时使用独立 worktree
```bash
git fetch --all
git worktree add ../ai_share-integration integration/codex-issue-N
```
如果多个 PR 修改同一文件Codex 负责集成分支里的冲突解决,并把冲突原因写回 PR comment。
---
## 十、Issue 模板
创建 issue 时使用固定结构:
```markdown
## Goal
## Scope
## Files allowed
- `src/game.js`
## Files forbidden
- `index.html`
- `src/styles.css`
- `.gitignore`
## Acceptance checks
- [ ] ...
## Handoff notes
- Agent: zcode
- Branch: agent/zcode/gameplay-issue-N
- Integration owner: codex
```
---
## 十一、PR 模板
每个 PR 必须包含:
```markdown
## Changed files
## Commands run
## Manual verification
## Known risks
## Linked issue
Closes #N
```
Review comment 建议结构:
```text
REVIEW agent=codex result=changes-requested
findings:
- ...
required:
- ...
```
或:
```text
REVIEW agent=codex result=approved
checks:
- local smoke test passed
- docs preview reachable
```
---
## 十二、Review Gate
合并前必须满足:
- PR 不是合并者自己创建的,除非用户明确授权。
- 没有未解决的 `changes-requested`
- PR head 是最新的,合并前已 `git fetch`
- 任务 issue 有明确验收标准。
- PR comment 中记录了测试或人工验证结果。
- 本地工作区没有无关脏改被混入。
推荐合并前检查:
```bash
git status --short
git fetch --all
tea pulls <id> -r laidaixi/tank-battle
tea issues <issue-id> -r laidaixi/tank-battle
```
---
## 十三、协作流程
```mermaid
sequenceDiagram
autonumber
participant U as 用户
participant Codex as Codex(统筹/集成/验收)
participant Z as ZCode(Gameplay)
participant Q as Qoder(UI/CI)
participant CLI as platform CLI
participant G as Gitee/Gitea
participant Web as serve-docs
U->>Codex: 定义目标和验收标准
Codex->>Codex: Preflight 检查
Codex->>CLI: 创建 gameplay issue
CLI->>G: issue + labels
Codex->>CLI: 创建 UI issue
CLI->>G: issue + labels
par 外部 Agent 并行工作
Codex-->>Z: 任务包或 CLI issue
Z->>G: branch/commit/PR/comment
Codex->>Q: qodercli -p 发送 UI 任务
Q->>G: branch/commit/PR/comment
end
Codex->>G: 拉取分支和 PR
Codex->>Codex: 集成、冲突处理、review
Codex->>Web: 本地和公网验证
Web-->>Codex: 验证结果
Codex->>G: comment 验证结果,合并通过的 PR
Codex->>U: 交付链接、提交记录、风险说明
```
---
## 十四、serve-docs 验证
最终验收必须检查:
```text
http://localhost:18080
https://splan.laidaixi.com/docs
```
验收记录写入 PR 或 issue comment
```text
VERIFY agent=codex
local=http://localhost:18080 status=200
public=https://splan.laidaixi.com/docs status=ok
checks='movement, fire, collision, HUD, pause/restart'
```
---
## 十五、本项目测试计划tank-battle
推荐最小闭环:
1. Codex 创建两个 issue
- `area:gameplay`:交给 ZCode范围 `src/game.js`
- `area:ui`:交给 Qoder范围 `index.html`、`src/styles.css`。
2. Qoder 通过 `qodercli -p "..." -w E:\Documents\ai_share` 接收任务并产出分支或 patch。
3. ZCode 通过 `resources\glm\zcode.cjs --prompt "..." --cwd ... --mode yolo` 接收任务并产出 patch。
4. Codex 拉取两个结果到 `integration/codex-issue-N`
5. Codex 解决冲突、review、写回 comment。
6. Codex 通过 `serve-docs` 验证本地和公网链接。
7. Codex 合并并向用户交付验证结果。
---
## 十六、当前限制与后续
1. **ZCode CLI 配置需受控**:可通过环境变量注入模型和 API 配置,禁止把 token 写入仓库、issue 或 PR。
2. **平台 CLI 要按仓库选择**:当前 Gitea 仓库使用 `tea`gitee.com 仓库才使用 `ge`
3. **并发冲突仍需集成者处理**CLI 不自动解决代码冲突。
4. **中文编码需固定为 UTF-8**:否则 Agent 读取 issue/comment 可能出现乱码。
5. **后续可扩展 Agent**:文档、安全、性能 Agent 只需声明 label、文件边界和可用命令不需要改动整体协议。

@ -34,7 +34,7 @@
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>';
'Score: <span id="score">0</span> Lives: <span id="lives">3</span> Enemies: <span id="enemies">0</span>';
canvas.parentNode.insertBefore(hud, canvas.nextSibling);
}
@ -68,9 +68,6 @@
const pauseAction = document.querySelector("#pauseAction");
const stopAction = document.querySelector("#stopAction");
const endAction = document.querySelector("#endAction");
const fireAction = document.querySelector("#fireAction");
const moveStick = document.querySelector("#moveStick");
const moveKnob = document.querySelector("#moveKnob");
const WIDTH = canvas.width;
const HEIGHT = canvas.height;
@ -83,8 +80,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 },
@ -95,92 +90,27 @@
const keys = new Set();
let state = "menu";
let score = 0;
let health = PLAYER_MAX_HEALTH;
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],
const walls = [
...brickWall(120, 80, TILE, TILE * 3),
...brickWall(240, 120, TILE * 4, TILE),
...steelWall(520, 80, TILE, TILE * 3),
...brickWall(640, 160, TILE * 2, TILE),
...brickWall(80, 320, TILE * 3, TILE),
...brickWall(280, 280, TILE, TILE * 3),
...brickWall(400, 360, TILE * 4, TILE),
...steelWall(640, 360, TILE, TILE * 3),
...steelWall(560, 240, TILE * 2, TILE),
...brickWall(160, 480, TILE * 2, TILE),
...brickWall(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" };
}
@ -214,17 +144,15 @@
team,
fireTimer: 0,
aiTimer: 0.4 + Math.random() * 1.2,
avoidTimer: 0,
alive: true,
};
}
function resetGame() {
score = 0;
health = PLAYER_MAX_HEALTH;
lives = 3;
bullets = [];
particles = [];
regenerateWalls();
player = tank(384, 536, "#77b255", "up", "player");
enemies = [
tank(84, 64, "#db5a42", "down"),
@ -286,7 +214,7 @@
function updateHud() {
scoreEl.textContent = score;
livesEl.textContent = `${health}%`;
livesEl.textContent = lives;
enemiesEl.textContent = enemies.filter((enemy) => enemy.alive).length;
}
@ -349,10 +277,9 @@
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) {
if (enemy.aiTimer <= 0) {
enemy.dir = chooseEnemyDirection(enemy);
enemy.aiTimer = 0.45 + Math.random() * 1.25;
}
@ -360,16 +287,12 @@
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);
enemy.dir = randomDirection();
enemy.aiTimer = 0.25;
}
if (canSeePlayer(enemy) || Math.random() < dt * 0.18) {
if (enemy.avoidTimer <= 0) facePlayer(enemy);
facePlayer(enemy);
fire(enemy);
}
}
@ -390,35 +313,6 @@
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;
@ -461,24 +355,8 @@
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];
for (const bullet of bullets) {
const out =
bullet.x < -20 || bullet.y < -20 || bullet.x > WIDTH + 20 || bullet.y > HEIGHT + 20;
if (out) continue;
@ -503,9 +381,9 @@
continue;
}
} else if (intersects(bullet, player)) {
health = Math.max(0, health - PLAYER_HIT_DAMAGE);
lives -= 1;
spark(player.x + player.w / 2, player.y + player.h / 2, "#77b255");
if (health <= 0) {
if (lives <= 0) {
player.alive = false;
setState("gameOver");
} else {
@ -520,16 +398,6 @@
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) => ({
@ -615,85 +483,20 @@
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.55)";
glowBlur = 7;
outerColor = "#e8fff7";
innerColor = "#102822";
coreColor = entity.color;
} else if (health > 33) {
glowColor = "rgba(240, 182, 70, 0.45)";
glowBlur = 6;
outerColor = "#c8c0a0";
innerColor = "#1e2818";
coreColor = "#9a9850";
} else {
const pulse = 0.5 + 0.5 * Math.sin(Date.now() / 180);
glowColor = `rgba(212, 93, 69, ${0.42 + 0.32 * pulse})`;
glowBlur = 7 + 4 * pulse;
outerColor = "#3a2020";
innerColor = "#1a0c0c";
coreColor = "#6b2828";
}
ctx.shadowColor = glowColor;
ctx.shadowBlur = glowBlur;
ctx.shadowColor = "rgba(132, 238, 212, 0.75)";
ctx.shadowBlur = 12;
}
ctx.fillStyle = isPlayer ? outerColor : "#2c0f0a";
ctx.fillStyle = isPlayer ? "#e8fff7" : "#2c0f0a";
ctx.fillRect(-18, -18, 36, 36);
ctx.shadowBlur = 0;
ctx.fillStyle = isPlayer ? innerColor : "#10140f";
ctx.fillStyle = isPlayer ? "#102822" : "#10140f";
ctx.fillRect(-15, -15, 30, 30);
ctx.fillStyle = coreColor || entity.color;
ctx.fillStyle = 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";
@ -701,15 +504,6 @@
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();
@ -719,8 +513,6 @@
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.restore();
}
@ -767,83 +559,12 @@
keys.delete(event.key.toLowerCase());
});
const stickKeys = ["arrowup", "arrowdown", "arrowleft", "arrowright"];
let activeStickKey = null;
function clearStickDirection() {
if (activeStickKey) keys.delete(activeStickKey);
activeStickKey = null;
if (moveKnob) moveKnob.style.transform = "translate(-50%, -50%)";
}
function setStickDirection(key) {
if (activeStickKey === key) return;
if (activeStickKey) keys.delete(activeStickKey);
activeStickKey = key;
if (key) keys.add(key);
}
function updateStick(event) {
if (!moveStick) return;
event.preventDefault();
const rect = moveStick.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const dx = event.clientX - centerX;
const dy = event.clientY - centerY;
const distance = Math.hypot(dx, dy);
const deadZone = rect.width * 0.12;
if (distance < deadZone) {
clearStickDirection();
return;
}
const key = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? "arrowright" : "arrowleft") : dy > 0 ? "arrowdown" : "arrowup";
setStickDirection(key);
if (moveKnob) {
const max = rect.width * 0.24;
const scale = Math.min(max, distance) / distance;
moveKnob.style.transform = `translate(calc(-50% + ${dx * scale}px), calc(-50% + ${dy * scale}px))`;
}
canvas.focus({ preventScroll: true });
}
if (moveStick) {
moveStick.addEventListener("pointerdown", (event) => {
try {
moveStick.setPointerCapture?.(event.pointerId);
} catch {
// Synthetic or interrupted pointer events may not be capturable.
}
updateStick(event);
});
moveStick.addEventListener("pointermove", updateStick);
for (const type of ["pointerup", "pointercancel", "pointerleave"]) {
moveStick.addEventListener(type, (event) => {
event.preventDefault();
clearStickDirection();
try {
moveStick.releasePointerCapture?.(event.pointerId);
} catch {
// Some browsers release capture automatically.
}
});
}
}
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");

@ -32,7 +32,6 @@ body {
font-family:
"Microsoft YaHei", "PingFang SC", "Segoe UI", Arial, sans-serif;
letter-spacing: 0;
overscroll-behavior: none;
}
button,
@ -86,7 +85,6 @@ button:focus-visible {
width: 100%;
display: grid;
gap: 14px;
justify-items: stretch;
}
.topbar {
@ -97,7 +95,8 @@ button:focus-visible {
}
.brand,
.hud-item {
.hud-item,
.controls div {
border: 1px solid var(--line);
border-radius: var(--radius);
background:
@ -156,28 +155,22 @@ h1 {
.hud-item {
min-height: 70px;
padding: 10px 12px;
border-left: 3px solid var(--olive);
display: flex;
flex-direction: column;
justify-content: center;
}
.hud-item span {
display: block;
color: var(--muted);
font-size: 0.72rem;
font-size: 0.76rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.06em;
line-height: 1;
}
.hud-item strong {
display: block;
margin-top: 6px;
margin-top: 4px;
color: var(--amber);
font-family: Consolas, "Courier New", monospace;
font-size: clamp(1.4rem, 2.5vw, 2.1rem);
font-size: clamp(1.3rem, 2.5vw, 2rem);
font-weight: 900;
line-height: 1;
overflow-wrap: anywhere;
@ -218,7 +211,6 @@ canvas {
width: 100%;
height: 100%;
image-rendering: pixelated;
touch-action: none;
}
.overlay {
@ -274,129 +266,31 @@ canvas {
.controls {
display: flex;
flex-direction: column;
gap: 10px;
flex-wrap: wrap;
gap: 8px;
color: var(--muted);
}
.controls div {
min-height: 38px;
padding: 7px 10px;
display: flex;
align-items: center;
gap: 7px;
}
.action-group {
.controls .action-group {
width: 100%;
justify-self: stretch;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.action-group button {
.controls .action-group button {
width: 100%;
padding-inline: 0.6rem;
}
.touch-controls {
display: grid;
grid-template-columns: minmax(150px, 190px) minmax(112px, 1fr);
column-gap: clamp(20px, 5vw, 44px);
row-gap: 12px;
align-items: center;
}
.move-stick {
position: relative;
width: min(190px, 48vw);
aspect-ratio: 1;
border: 1px solid var(--line-bright);
border-radius: 50%;
background:
radial-gradient(circle at 50% 50%, rgba(240, 182, 70, 0.1) 0 24%, transparent 25%),
radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.07), transparent 58%),
var(--panel);
box-shadow: inset 0 0 0 8px rgba(16, 21, 13, 0.72), 0 12px 28px var(--shadow);
touch-action: none;
user-select: none;
}
.move-stick::before,
.move-stick::after {
content: "";
position: absolute;
inset: 50% auto auto 50%;
background: rgba(198, 216, 123, 0.45);
transform: translate(-50%, -50%);
pointer-events: none;
}
.move-stick::before {
width: 70%;
height: 2px;
}
.move-stick::after {
width: 2px;
height: 70%;
}
.move-knob {
position: absolute;
left: 50%;
top: 50%;
width: 42%;
aspect-ratio: 1;
border: 1px solid #d8e58c;
border-radius: 50%;
background: linear-gradient(180deg, #adc764, #718536);
box-shadow: 0 8px 18px var(--shadow);
transform: translate(-50%, -50%);
pointer-events: none;
}
.fire-action {
min-height: 54px;
padding: 0;
display: grid;
place-items: center;
touch-action: none;
-webkit-user-select: none;
}
.fire-action {
min-height: 86px;
border-color: #ffd36a;
background: linear-gradient(180deg, #f1c55d, #b97127);
color: #151007;
font-size: 1.1rem;
}
.key-hints {
border: 1px solid var(--line);
border-radius: var(--radius);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent),
var(--panel);
padding: 10px 12px;
display: flex;
flex-wrap: wrap;
gap: 6px 14px;
align-items: center;
color: var(--muted);
font-size: 0.82rem;
}
.key-hint {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.key-hint + .key-hint::before {
content: "";
display: block;
width: 1px;
height: 16px;
background: var(--line);
margin-right: 8px;
flex-shrink: 0;
}
kbd {
min-width: 30px;
min-height: 28px;
@ -443,46 +337,27 @@ kbd {
}
.hud-item {
min-height: 58px;
padding: 8px 10px;
border-left-width: 2px;
min-height: 62px;
padding: 8px;
}
.status-chip {
min-height: 52px;
}
.action-group {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.touch-controls {
grid-template-columns: minmax(144px, 40vw) minmax(104px, 1fr);
column-gap: 36px;
row-gap: 10px;
}
.move-stick {
width: 100%;
}
.fire-action {
min-height: 50px;
}
.fire-action {
min-height: 82px;
}
.key-hints {
.controls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 10px;
padding: 8px 10px;
}
.key-hint + .key-hint::before {
display: none;
.controls div {
width: auto;
min-width: 0;
}
.controls .action-group {
grid-column: 1 / -1;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
button {

Loading…
Cancel
Save