之前在做同人战斗概念验证时,很多想法只停留在脑补阶段:比如把《火柴人战争遗产》的简笔画战斗风格,和 SCP 基金会里 SCP-096 的“见脸必追”机制放在一起,能碰撞出什么样的玩法?光想没有意义,干脆用 Canvas 从零写了一个可交互的“预告”原型。
这篇文章不是 SCP 基金会的设定科普,也不是《火柴人战争遗产》的模组安装指南,而是一次完整的同人战斗原型开发记录:如何用原生 JavaScript + Canvas 实现一个“SCP-096 VS 量士”的 2D 对战 Demo。代码不依赖任何框架,复制到本地就能运行。适合对 Canvas 游戏开发感兴趣、想自己做同人战斗演示、或者想学习状态机与碰撞检测的开发者阅读。
1. 背景与核心概念
1.1 这个“预告”项目想表达什么
同人创作里经常有“两个不同世界观角色打起来”的趣味设定。项目标题里的 three 个元素分别对应:
dc2:这里作为同人宇宙的代号,不代表任何官方作品。- 《火柴人战争遗产》:以简笔画角色、数量化单位对战为核心卖点。
- SCP 基金会:一种开放式的虚构创作框架,SCP-096 是其中典型的“异常实体”。
SCP-096 的特点是:正常情况下会安静地待在角落,捂住脸哭泣;一旦有人“看到了它的脸”,无论是亲眼看到还是通过照片、视频,它都会进入不可阻止的狂暴状态,并追杀目击者。
“量士”则是本文设定中的原创角色,定位为一名远程战斗单位,擅长用“量测”的方式发射可预测轨迹的投射物,并利用距离差进行拉扯作战。
本项目的核心不是复刻原设定,而是把 SCP-096 的行为规则抽象成一个可玩的 AI 状态机,再放到一个简笔画风格的对战场景里,做成一个能跑、能看、能调的预告 Demo。
1.2 核心玩法闭环
为了让 Demo 有“可玩性”,没有完全照搬 SCP-096 的秒杀机制,而是做了数值化处理:
- 量士开局只有 100 点生命值。
- SCP-096 处于“平静”状态时,不会主动攻击,但也不会受到伤害。
- 量士必须用子弹射击 SCP-096,累积“目击值/怒气值”。
- 当怒气值达到 100,SCP-096 进入“狂暴”状态,此时子弹才会造成真实伤害。
- 狂暴后的 SCP-096 移速提升、接触伤害极高,量士需要边退边打。
这个设计让单场景 Demo 有了简单的“先控温、再输出、最后拉扯”的战斗节奏。
1.3 为什么用 Canvas 而不是游戏引擎
同人预告类 Demo 最常见的问题是“从想法到画面”的成本太高。如果一开始就引入 Unity、Godot、Phaser,光安装环境就要劝退一半人。Canvas 作为浏览器原生能力,有以下优势:
- 不需要安装额外环境,只要有一个浏览器即可。
- 用 JavaScript 直接控制绘制,理解角色动画和碰撞原理更直观。
- 适合快速验证状态机、攻击判定、数值手感。
- 代码量可控,单文件也能完整呈现。
等原型验证完毕,再迁移到游戏引擎也不迟。
2. 环境准备与版本说明
2.1 运行环境
本文示例只需要现代浏览器,理论上 Chrome、Edge、Firefox 都能运行。开发阶段推荐使用 VS Code + Live Server 插件启动,避免直接双击 HTML 时因为模块加载策略产生一些本地文件访问限制。
版本说明:代码使用 ES6 语法,没有使用任何第三方库,因此不依赖特定框架版本。如果你使用的是较旧的浏览器,需要把const、class、箭头函数等语法改成 ES5。建议使用 2021 年之后的浏览器版本体验最佳。
2.2 项目结构
文章为了便于阅读,把代码拆成了index.html和game.js两个文件:
scp096-vs-liangshi/ ├── index.html └── game.js其中index.html负责页面骨架和样式,game.js负责全部游戏逻辑。实际项目中如果继续扩展,建议再拆分player.js、scp096.js、bullet.js、utils.js等模块,但原型阶段保持两个文件足够。
3. 核心机制拆解
3.1 状态机设计
SCP-096 是本项目的核心 AI,因此必须把它拆成状态机。状态机可以简单理解成“角色在某一时刻只能处于一种固定状态,并且通过条件触发切换到另一种状态”。
| 状态 | 触发条件 | 行为表现 | 结局条件 |
|---|---|---|---|
| calm 平静 | 初始状态 | 蹲坐不动,捂脸哭泣 | 怒气值达到 30 |
| provoked 警觉 | 怒气值 >= 30 | 开始缓慢靠近玩家,接触造成少量伤害 | 怒气值达到 100 |
| enraged 狂暴 | 怒气值 >= 100 | 高速追击玩家,接触造成大量伤害,子弹可削减生命值 | 生命值归零 |
| dead 倒地 | 生命值归零 | 停止行动,显示胜利 | 无 |
这种抽象方式很实用。以后你想新增“逃跑”“眩晕”“被控制”等行为,只需要在状态机里加状态和转换条件,不必重写整段逻辑。
3.2 玩家控制的“量士”
量士作为玩家单位,设计目标有三个:
- 移动:左右移动,拉开距离。
- 射击:向 SCP-096 方向发射子弹。
- 视线暴露:面朝 SCP-096 时,会加速提升它的怒气值。
这里的“视线暴露”是为了保留原设定味道。玩家不可能一边看 SCP-096 的脸一边安心输出,必须承担“看它一眼就会让它更狂躁”的代价。
射击子弹不是直接造成伤害,而是会累积 SCP-096 的怒气。这个设计非常关键:它让“挑起狂暴”本身变成一种资源操作,而不是无脑站桩输出。
3.3 碰撞与伤害判定
碰撞判定使用的是最简单的 AABB 矩形检测。每一个可交互单位都有x、y、width、height四个属性,子弹只需要判断自己的坐标是否落在目标矩形范围内即可。
对于这种固定横版视角的简笔画场景,不需要精确到像素级碰撞。用矩形会导致判定略宽松,但手感更友好,也更容易实现。
3.4 帧率与数值
游戏主循环使用requestAnimationFrame,以浏览器刷新率运行,默认 60 FPS。所有倒计时如“射击冷却”“接触伤害冷却”都用“帧数”来表示,方便在同一套帧率体系下调整。
例如:
- 射击冷却:16 帧,意味着约 0.27 秒可以射击一次。
- 狂暴接触伤害:30 帧,意味着每 0.5 秒最多造成一次近战伤害。
这种表示方式的优点是逻辑直观,缺点是如果显示器刷新率不是 60Hz,会轻微影响手感。原型阶段可以接受,生产项目则建议使用deltaTime做帧率无关计算。
4. 完整实战案例
下面开始搭建完整项目。你可以直接复制代码到本地,按第二部分的目录结构保存。
4.1 创建 index.html
index.html负责搭建游戏画布和页面布局。这里特意把提示文字放在画布下方,方便初次运行时快速理解操作方式。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <title>SCP-096 VS 量士 - 预告原型</title> <style> body { margin: 0; background: #0a0c12; min-height: 100vh; display: flex; align-items: center; justify-content: center; font-family: "Courier New", monospace; } #stage { text-align: center; } canvas { background: radial-gradient(circle at 20% 20%, #223044, #101822); border-radius: 12px; box-shadow: 0 0 30px rgba(0, 180, 255, 0.15); display: block; } #tips { color: #8da2b8; text-align: center; margin-top: 12px; font-size: 14px; line-height: 1.8; } </style> </head> <body> <div id="stage"> <canvas id="game" width="960" height="540"></canvas> <div id="tips"> A/D 或 ←/→ 移动 | 空格 / J 射击 | 无需直视 SCP-096,它会自己注意到你的攻击 </div> </div> <script src="game.js"></script> </body> </html>这里的关键点是:
canvas的宽高直接写在 HTML 属性上,避免 CSS 拉伸导致坐标错乱。- 提示文字解释了操作方式,但实际游戏逻辑中,子弹会自动朝 SCP-096 方向发射,方便玩家把注意力放在走位和拉扯上。
- 整体背景使用渐变,模拟一种压抑的收容场景氛围。
4.2 创建 game.js 基础结构
game.js是核心逻辑文件。先定义工具函数和子弹类,这些属于最底层的基础模块。
(() => { const canvas = document.getElementById('game'); const ctx = canvas.getContext('2d'); const W = canvas.width; const H = canvas.height; function dist(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); } function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); } class Bullet { constructor(x, y, vx, damage) { this.x = x; this.y = y; this.vx = vx; this.damage = damage; this.dead = false; } update() { this.x += this.vx; if (this.x < -20 || this.x > W + 20) { this.dead = true; } } } })();这里的 IIFE 写法可以避免全局变量污染。Math.hypot用于计算两点距离,比手动开平方更简洁。
4.3 实现玩家类
玩家类需要处理移动、射击、生命值、冷却时间、子弹数组等逻辑。
class Player { constructor() { this.x = 180; this.y = 470; this.width = 30; this.height = 60; this.hp = 100; this.maxHp = 100; this.speed = 4.6; this.facing = 1; this.coolDown = 0; this.bullets = []; } update() { if (keys['ArrowLeft'] || keys['KeyA']) { this.x -= this.speed; this.facing = -1; } if (keys['ArrowRight'] || keys['KeyD']) { this.x += this.speed; this.facing = 1; } this.x = clamp(this.x, 40, W - 40); this.y = clamp(this.y, 360, H - 40); if (this.coolDown > 0) { this.coolDown -= 1; } for (const bullet of this.bullets) { bullet.update(); } this.bullets = this.bullets.filter((bullet) => !bullet.dead); } shoot(targetX, targetY) { if (this.coolDown > 0) { return false; } this.coolDown = 16; const dir = targetX >= this.x ? 1 : -1; this.facing = dir; const bx = this.x + dir * 26; const by = this.y - 24; const damage = 15; this.bullets.push(new Bullet(bx, by, dir * 7, damage)); return true; } draw(ctx) { ctx.save(); ctx.translate(this.x, this.y); ctx.lineWidth = 4; ctx.lineCap = 'round'; ctx.strokeStyle = '#4aaaff'; // 头 ctx.beginPath(); ctx.arc(0, -22, 6, 0, Math.PI * 2); ctx.stroke(); // 身体 ctx.beginPath(); ctx.moveTo(0, -14); ctx.lineTo(0, 12); ctx.stroke(); // 腿 ctx.beginPath(); ctx.moveTo(0, 12); ctx.lineTo(-7, 25); ctx.moveTo(0, 12); ctx.lineTo(7, 25); ctx.stroke(); // 武器:量测发射器 ctx.strokeStyle = '#8ab4ff'; ctx.beginPath(); ctx.moveTo(0, -6); ctx.lineTo(this.facing * 18, -16); ctx.lineTo(this.facing * 28, -12); ctx.stroke(); ctx.fillStyle = '#b8dcff'; ctx.beginPath(); ctx.arc(this.facing * 30, -10, 4, 0, Math.PI * 2); ctx.fill(); ctx.restore(); // 绘制子弹 ctx.fillStyle = '#ffd76a'; for (const bullet of this.bullets) { ctx.beginPath(); ctx.arc(bullet.x, bullet.y, 4, 0, Math.PI * 2); ctx.fill(); } } }这里有几个细节值得注意:
- 玩家坐标
y被限制在 360 到 500 之间,防止角色跑到画布外面。 - 射击冷却用
coolDown实现:每次射击后设置为 16,每一帧减 1,降为 0 后才能再次射击。 - 角色绘制采用线条画法,保留《火柴人战争遗产》的简笔风格。
- 子弹方向由目标坐标决定,保证玩家只需要控制位置,不需要精确瞄准。
4.4 实现 SCP-096 类
SCP-096 是本项目的重头戏,状态机全部集中在这个类里。
class SCP096 { constructor() { this.x = 720; this.y = 460; this.width = 48; this.height = 90; this.hp = 320; this.maxHp = 320; this.state = 'calm'; this.anger = 0; this.speed = 0; this.enragedSpeed = 4.0; this.touchCD = 0; this.animTick = 0; } getBox() { return { x: this.x - this.width / 2, y: this.y - this.height, w: this.width, h: this.height }; } takeDamage(damage) { if (this.state === 'calm' || this.state === 'provoked') { this.anger += damage * 0.2; if (this.anger > 100) { this.anger = 100; } } else if (this.state === 'enraged') { this.hp -= damage; if (this.hp <= 0) { this.hp = 0; this.state = 'dead'; } } } update(player) { if (this.state === 'dead') { return; } this.animTick += 1; if (this.touchCD > 0) { this.touchCD -= 1; } const d = dist(this, player); if (this.state === 'calm' || this.state === 'provoked') { // 玩家面朝 SCP-096 会暴露视线,加速怒气累积 const faceToScp = player.x < this.x ? -1 : 1; if (player.facing === faceToScp && d < 320) { this.anger += 0.4; } // 子弹命中时在 takeDamage 中也会累积怒气 if (this.anger >= 100) { this.anger = 100; this.state = 'enraged'; this.speed = this.enragedSpeed; } else if (this.anger >= 30) { this.state = 'provoked'; this.speed = 2.2; } else { this.state = 'calm'; this.speed = 0; } // 警觉状态会缓慢靠近玩家 if (this.state === 'provoked') { if (this.x > player.x) { this.x -= this.speed; } else { this.x += this.speed; } this.x = clamp(this.x, 40, W - 40); // 警觉状态接触伤害 if (d < 52 && this.touchCD <= 0) { player.hp -= 6; this.touchCD = 30; } } } else if (this.state === 'enraged') { // 狂暴状态高速追击 if (this.x > player.x) { this.x -= this.speed; } else { this.x += this.speed; } this.x = clamp(this.x, 40, W - 40); if (d < 52 && this.touchCD <= 0) { player.hp -= 16; this.touchCD = 30; } } } draw(ctx) { ctx.save(); ctx.translate(this.x, this.y); if (this.state === 'dead') { ctx.strokeStyle = '#555'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(0, -30); ctx.lineTo(0, 10); ctx.lineTo(-8, 25); ctx.moveTo(0, 10); ctx.lineTo(8, 25); ctx.stroke(); ctx.restore(); return; } const bodyColor = this.state === 'enraged' ? '#e8e2f0' : '#b9b6c2'; const lineColor = this.state === 'enraged' ? '#ff3b3b' : '#5b5b6b'; // 头 ctx.fillStyle = bodyColor; ctx.strokeStyle = lineColor; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(0, -80, 12, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); // 身体 ctx.beginPath(); ctx.moveTo(0, -66); ctx.lineTo(0, -6); ctx.stroke(); // 腿 ctx.beginPath(); ctx.moveTo(0, -6); ctx.lineTo(-9, 12); ctx.moveTo(0, -6); ctx.lineTo(9, 12); ctx.stroke(); // 手臂 if (this.state === 'calm') { // 平静:双手捂脸 ctx.strokeStyle = '#777'; ctx.beginPath(); ctx.moveTo(-8, -78); ctx.lineTo(-20, -66); ctx.moveTo(8, -78); ctx.lineTo(20, -66); ctx.stroke(); // 眼泪 ctx.fillStyle = '#6aa9ff'; ctx.beginPath(); ctx.ellipse(-5, -70, 1.5, 4, 0, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.ellipse(5, -70, 1.5, 4, 0, 0, Math.PI * 2); ctx.fill(); } else { // 狂暴:红眼,张嘴 ctx.fillStyle = '#ff2222'; ctx.beginPath(); ctx.arc(-4, -82, 2, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(4, -82, 2, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#1d1020'; ctx.beginPath(); ctx.ellipse(0, -72, 6, 3, 0, 0, Math.PI * 2); ctx.fill(); // 前伸手臂 ctx.strokeStyle = '#e8e2f0'; ctx.lineWidth = 4; const shake = Math.sin(this.animTick * 0.2) * 3; ctx.beginPath(); ctx.moveTo(0, -30); ctx.lineTo(-18 + shake, -40); ctx.moveTo(0, -30); ctx.lineTo(18 - shake, -40); ctx.stroke(); } ctx.restore(); } }这里实现了几个关键逻辑:
takeDamage在 calm/provoked 状态只增加怒气,在 enraged 状态才扣血。- 玩家面朝 SCP-096 时,每帧增加 0.4 怒气,这意味着长时间“盯着它看”也会触发狂暴。
- 狂暴后的手臂动画用
Math.sin制造左右摇摆效果,让画面更生动。 - 死亡绘制简化为倒地的线稿,省去复杂动画。
4.5 主循环、碰撞检测与 HUD
现在把玩家和 SCP-096 组装起来,并处理输入、碰撞、游戏结束。
const keys = Object.create(null); window.addEventListener('keydown', (e) => { keys[e.code] = true; if (e.code === 'Space') { e.preventDefault(); } if (e.code === 'KeyR') { resetGame(); } }); window.addEventListener('keyup', (e) => { keys[e.code] = false; }); const player = new Player(); const scp = new SCP096(); let gameOver = false; let resultText = ''; let resultSubText = ''; function resetGame() { player.x = 180; player.y = 470; player.hp = 100; player.bullets = []; player.coolDown = 0; scp.x = 720; scp.y = 460; scp.hp = 320; scp.anger = 0; scp.state = 'calm'; scp.speed = 0; scp.touchCD = 0; gameOver = false; resultText = ''; resultSubText = ''; } function checkBullets() { const box = scp.getBox(); for (const bullet of player.bullets) { if ( !bullet.dead && bullet.x >= box.x && bullet.x <= box.x + box.w && bullet.y >= box.y && bullet.y <= box.y + box.h ) { bullet.dead = true; scp.takeDamage(bullet.damage); } } player.bullets = player.bullets.filter((bullet) => !bullet.dead); } function update() { if (gameOver) { return; } player.update(); if (keys['Space'] || keys['KeyJ']) { player.shoot(scp.x, scp.y); } scp.update(player); checkBullets(); if (player.hp <= 0) { player.hp = 0; gameOver = true; resultText = 'SCP-096 突破了防线'; resultSubText = '量士阵亡,按 R 重新挑战'; } if (scp.hp <= 0) { gameOver = true; resultText = 'SCP-096 倒地'; resultSubText = '量士胜利,按 R 重新挑战'; } } function stateName(state) { if (state === 'calm') return '平静'; if (state === 'provoked') return '警觉'; if (state === 'enraged') return '狂暴'; if (state === 'dead') return '倒地'; return state; } function drawHUD(ctx) { ctx.fillStyle = 'rgba(0, 0, 0, 0.35)'; ctx.fillRect(0, 0, W, 42); ctx.font = '14px "Courier New", monospace'; ctx.fillStyle = '#dfeaf5'; ctx.fillText('量士 HP: ' + Math.max(0, Math.ceil(player.hp)) + ' / ' + player.maxHp, 14, 27); ctx.fillText('SCP-096: ' + stateName(scp.state), 240, 27); ctx.fillText('怒气: ' + Math.min(100, Math.floor(scp.anger)) + '%', 400, 27); ctx.fillText('射击冷却: ' + (player.coolDown > 0 ? player.coolDown : 0), 560, 27); if (!gameOver) { ctx.fillStyle = '#7d9db5'; ctx.font = '12px "Courier New", monospace'; ctx.fillText('提示:子弹命中会累积怒气,怒气满 100 后才能真正造成伤害', 14, 58); } } function draw() { ctx.clearRect(0, 0, W, H); // 地面 ctx.strokeStyle = '#24364a'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, 512); ctx.lineTo(W, 512); ctx.stroke(); drawHUD(ctx); player.draw(ctx); scp.draw(ctx); if (gameOver) { ctx.fillStyle = 'rgba(5, 8, 14, 0.7)'; ctx.fillRect(0, H / 2 - 80, W, 160); ctx.fillStyle = '#ffb454'; ctx.font = 'bold 28px "Courier New", monospace'; ctx.textAlign = 'center'; ctx.fillText(resultText, W / 2, H / 2 - 12); ctx.fillStyle = '#aebed1'; ctx.font = '16px "Courier New", monospace'; ctx.fillText(resultSubText, W / 2, H / 2 + 24); ctx.textAlign = 'left'; } } function loop() { update(); draw(); requestAnimationFrame(loop); } loop(); })();这段代码需要注意的地方:
- 每次
update中先更新玩家,再处理射击,最后更新 SCP-096,顺序不能颠倒。 - 子弹碰撞使用
SCP096.getBox()获取矩形范围,配合子弹坐标判断。 - 当玩家和 SCP-096 任意一方生命值归零,
gameOver置为 true,主循环仍然绘制但不再更新逻辑。 resetGame负责按 R 重新开始,方便反复测试。
5. 运行与验证
5.1 运行步骤
- 在本地创建
scp096-vs-liangshi目录。 - 将
index.html和game.js两个文件放到该目录下。 - 用 VS Code 打开目录,右键
index.html选择 “Open with Live Server”。 - 浏览器打开后,按 A/D 或方向键移动,按空格或 J 射击。
5.2 预期表现
一个正常战斗过程大致如下:
- 开局 SCP-096 出现在画面右侧,安静站立。
- 玩家连续射击,子弹命中后怒气值从 0 慢慢上涨。
- 怒气值超过 30 后,状态变为“警觉”,SCP-096 开始缓慢靠近。
- 怒气值达到 100 后,状态变为“狂暴”,移动速度明显增加,红色眼睛和张开的手臂动画出现。
- 玩家需要保持安全距离,在 SCP-096 追击时不断射击。
- SCP-096 生命值归零后,倒地并显示胜利;玩家生命值归零则显示失败。
6. 常见问题与排查思路
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
| 页面空白,控制台报错 | game.js路径错误或语法错误 | 确认两个文件在同一目录,打开 F12 控制台查看具体报错行 |
| 按方向键无反应 | 键盘事件没绑定成功 | 检查keydown和keyup事件是否在代码最外层绑定 |
| 按空格时页面滚动 | 空格默认行为未阻止 | 在keydown事件中调用e.preventDefault() |
| 子弹打中但怒气不涨 | 碰撞判定矩形和绘制位置不一致 | 检查SCP096.getBox()的坐标基准,绘制时translate后要分清本地坐标和世界坐标 |
| 狂暴后子弹不扣血 | 状态判断顺序错误 | 确认takeDamage中只有enraged状态才扣血 |
| 画面模糊或坐标错乱 | Canvas 宽高被 CSS 拉伸 | 直接在 HTML 属性中设置width和height,不要依赖 CSS 缩放 |
| 游戏运行过快或过慢 | 没有考虑帧率差异 | 原型可先用帧数制,后续改用deltaTime |
最常出现的问题就是“绘制坐标”和“碰撞坐标”不一致。因为角色在draw方法中使用了ctx.translate,但碰撞计算用的是世界坐标this.x、this.y。只要代码里始终基于this.x、this.y做碰撞,通常不会出错。
7. 最佳实践与工程建议
7.1 状态机与代码解耦
当前 SCP-096 的逻辑全部写在update里,状态多了以后会越来越难读。后续扩展时,推荐把每个状态拆成单独函数,甚至使用状态模式:
const states = { calm: updateCalm, provoked: updateProvoked, enraged: updateEnraged, dead: updateDead, };这样每个状态的行为互不干扰,切换状态时只需要改变state变量的值。
7.2 数值配置集中管理
战斗数值不要散落在各个类中。建议抽出一个配置对象:
const CONFIG = { player: { hp: 100, speed: 4.6, shootCooldown: 16, bulletDamage: 15, }, scp096: { hp: 320, enragedSpeed: 4.0, contactDamageEnraged: 16, }, };这样便于调整战斗节奏,也方便以后做多关卡、多敌人。
7.3 同人创作注意版权边界
SCP 基金会和《火柴人战争遗产》都涉及第三方世界观和素材。作为个人学习、同人预告、技术演示没有问题,但如果要发布或商用,必须注意原始 IP 的授权规则。尤其是 SCP 基金会虽然采用“知识共享”式创作方式,但不同衍生设定也有不同要求。项目中的原创角色“量士”和代码逻辑可以继续使用,但引用原设定的文案、名称、美术风格尽量做“借鉴式表达”。
7.4 后续扩展方向
- 增加攻击动画:把线条画改成三段式骨骼动画。
- 增加音效:用 AudioContext 生成低沉的追击音效。
- 增加多单位:参考《火柴人战争