把一只鸭子画在屏幕上,让它移动和开枪,并不是这个项目最有意思的部分。
真正需要解决的是:它捡到的物资什么时候才属于玩家?打开背包后,敌人还会不会继续攻击?角色躲在墙后,子弹是否真的会被挡住?一次撤离能不能因为重复触发而结算两次?
这次借助 AI 做出的《雾林行动》,就是一个围绕这些问题搭起来的浏览器游戏原型。它用原生 HTML、CSS 和 JavaScript 实现,以《逃离鸭科夫》的“搜、打、撤”玩法为参考,包含探索、战斗、搜刮、撤离和永久升级。
这是原创场景与程序图形的非官方 2D 原型,不是原作的完整移植版,也没有使用原作的地图、美术、音频或游戏代码。 下文按复现这个项目的顺序拆解实现;代码片段是源码节选或保留核心逻辑的缩写,不是彼此独立的完整程序。
1. 先确定一个能反复游玩的最小闭环
原型的核心流程是:
选择装备 → 进入封锁区 → 探索、交火、搜刮
↓
成功撤离 / 行动失败
↓
结算收益 → 永久升级 → 再次出击
单局负责制造风险,局外负责留下成长。只做射击,没有物资和撤离,就缺少“继续搜还是现在走”的取舍;只做搜刮,没有死亡损失,也很难形成撤离的压力。
当前地图为 3000 × 2400 个世界单位,有 5 栋建筑、24 个初始物资容器和 2 个撤离点。休闲、标准、硬核难度分别生成 13、19、24 名初始敌人;休闲限时 12 分钟,标准和硬核限时 8 分钟。[1]
为了控制范围,这一版不做联网、云端账号、复杂改枪、基地建造,也不尝试还原原作的完整 3D 内容。出击时免费补齐基础装备,失败损失本局战利品,已有鸭币和永久升级保留。
2. Canvas 负责世界,DOM 负责界面
运行时没有引入游戏引擎或第三方框架。Canvas 2D 绘制地图、角色、子弹与特效;HTML 和 CSS 实现首页、血条、背包、地图弹窗与结算面板;Web Audio 合成音效;localStorage 保存长期进度。
源码交付和运行交付分开:
mistwood-duck/
├── index.html # 可直接打开的单文件游戏
├── src/
│ ├── template.html # 页面结构和资源占位符
│ ├── style.css # UI 与响应式布局
│ └── game.js # 游戏逻辑与 Canvas 绘制
├── build.py # 将 CSS、JavaScript 内联进 HTML
├── README.md
└── TEST_NOTES.md
这样既可以把成品作为一个 HTML 文件分享,也可以在修改时保留结构、样式和逻辑的分离。它不是一个已经拆成大量模块的工程:地图、AI、碰撞和渲染目前仍集中在 game.js 中,后续规模扩大时才需要继续拆分。
状态主要分为三层:profile 保存跨局数据,world 保存场景数据,game 保存本局玩家、子弹、计时器和结算信息。不要把它们全部塞进同一个存档对象,尤其不要把“本局捡到”提前当作“永久拥有”。[2]
3. 先搭主循环,再统一处理暂停
主循环使用 requestAnimationFrame()。下面保留了时间步长、更新和渲染的关键部分:
function frame(ts) {
const dt = Math.min((ts - lastTime) / 1000 || 0.016, 0.04);
lastTime = ts;
realTime += dt;
update(dt);
render();
requestAnimationFrame(frame);
}
移动速度、换弹、治疗和撤离计时都以 dt 推进,而不是按帧数累加。这里把单帧步长限制在 40 毫秒,是为了限制卡顿后的一次性位移;它不是固定时间步长,也不是补帧系统。持续低帧率时,被截断的时间不会补算,游戏时间可能慢于真实时间。
暂停则统一放在更新入口处理:
function update(dt) {
if (!game || mode !== 'playing' || overlay) return;
game.time += dt;
// 移动、冷却、AI、子弹与交互更新……
}
mode 区分首页、进行中和结算;overlay 表示当前打开的背包、地图、帮助或暂停界面。弹窗存在时,本局计时、敌人行为、子弹运动和操作进度都停止推进,画面仍然可以继续渲染。
打开弹窗时还要清空按键和鼠标按下状态,否则关闭背包后,可能沿用打开前的输入。源码也处理了窗口失焦和页面隐藏,避免玩家切到其他窗口时继续承受战斗风险。[3]
4. 把屏幕坐标、世界坐标和视觉偏移分清楚
鼠标事件给出的是屏幕上的 CSS 像素坐标,角色和障碍物使用的则是世界坐标。摄像机移动或缩放后,不能直接拿鼠标坐标减角色坐标。
不考虑屏幕震动,世界点到屏幕点的关系是:
screenX = (worldX - camera.x) * zoom + width / 2;
screenY = (worldY - camera.y) * zoom + height / 2;
瞄准时做反变换。实际源码还有一个与角色绘制高度对应的 Y 轴补偿:
const wx = (mouse.x - width / 2) / zoom + camera.x;
const wy = (mouse.y - height / 2) / zoom + camera.y + 15;
player.a = Math.atan2(wy - player.y, wx - player.x);
这里的 +15 不是物理高度,而是让视觉瞄准与角色身体位置更协调。碰撞仍发生在俯视平面上。Canvas 的设备像素比单独用于画布分辨率,鼠标反变换不再重复乘以设备像素比。
移动输入也需要处理斜向加速:当方向向量长度大于 1 时,先归一化,再乘速度和 dt。否则同时按 W、D 会比只按一个方向跑得更快。
摄像机采用 1 - Math.exp(-dt * 6) 作为插值系数,平滑跟随角色,并沿瞄准方向偏移一点。它让观察方向参与镜头构图,而不是永远把角色钉在正中央。[4]
5. 固定地图骨架,随机局内内容
这个项目并不是每次出击都生成一张全新的随机地图。
建筑、道路和撤离点来自固定配置;树木、岩石等景物使用固定种子的伪随机数生成。每次出击会为另一组随机逻辑重新设置种子,用于部分战利品、巡逻目标和射击散布。部分物资箱则有固定内容,例如出生点附近的引导补给。[1]
这样的分工保留了可学习的地图:玩家能够记住医务室在哪、哪条路通向撤离点,同时又不能完全预知所有随机容器的收益。
画面上的立体感主要来自绘制,而不是 3D 场景。墙体用顶面和侧面的多边形表现高度,树冠画在树干上方,角色身体画在脚底碰撞点之上。动态物体按照各自选定的纵向深度值排序绘制,让前景遮住后景。
静态地面通过 bakeGround() 预先绘制到一个不挂到页面上的普通 Canvas,包括路面、建筑地板和固定阴影;每帧只复制当前可见区域。这里用的是缓存画布,没有使用 OffscreenCanvas 或 Worker。
玩家接近建筑时屋顶变淡,被树冠遮住时附近树冠降低不透明度。这样既保留遮挡关系,也避免角色走进房间后完全不可见。[5]

6. 碰撞要让“看起来有墙”和“真的有墙”一致
角色移动:简单几何体与分段检测
角色使用圆形碰撞体,墙壁和部分道具使用轴对齐矩形,树干和岩石使用圆形。场景障碍物放进大小为 128 个世界单位的空间网格,检测时先找附近格子,再检查其中的障碍物。
角色每帧的位移还会继续拆成小段:
function moveEntity(e, dx, dy) {
const steps = Math.max(1, Math.ceil(Math.hypot(dx, dy) / 9));
const startX = e.x;
const startY = e.y;
for (let i = 0; i < steps; i++) {
if (!isSolid(e.x + dx / steps, e.y, e.r)) {
e.x += dx / steps;
}
if (!isSolid(e.x, e.y + dy / steps, e.r)) {
e.y += dy / steps;
}
}
return Math.hypot(e.x - startX, e.y - startY);
}
小步检测降低了快速移动时跳过薄障碍物的风险。X、Y 两个方向分别尝试移动,则允许角色沿墙滑动,而不是斜着碰到墙就完全停住。
子弹:检查这一帧走过的线段
子弹速度高,不能只在下一帧落点检查碰撞。实现中会取旧位置和新位置构成线段,分别与障碍物和目标求交。
关键不是“有没有碰到”,而是“先碰到谁”。交点用线段参数 t 表示,越小意味着越早命中:
let hitT = traceWall(b.x, b.y, nx, ny, 2);
let target = null;
for (const enemy of world.enemies) {
if (!enemy.alive) continue;
const t = segmentCircle(
b.x, b.y, nx, ny,
enemy.x, enemy.y, 18
);
if (t < hitT) {
hitT = t;
target = enemy;
}
}
这是玩家子弹目标选择的核心节选。如果墙比敌人更早被命中,就保留墙的交点,不能因为线段另一头也碰到了敌人而直接扣血。
还有一个容易忽略的边界:视觉枪口不是子弹的碰撞起点。 当前子弹从角色脚底对应的平面坐标开始检测,枪口火光只是表现。否则枪管伸到墙另一侧时,子弹可能直接从墙外生成,绕过掩体。
同一套线段检测还被用于敌人视线和物资交互。搜索容器不只判断距离,也判断中间有没有障碍物,避免隔墙拿物资。[6]
7. 敌人 AI:状态机加按需寻路
敌人的主要状态是巡逻 patrol、调查 investigate 和战斗 combat。
巡逻时在出生点附近选择目标;听到一定范围内的枪声后,转向声源位置调查;满足距离和无遮挡条件后进入战斗;警戒时间耗尽且没有再次发现玩家时,回到巡逻。这里的发现逻辑基于距离、警戒状态和遮挡,不是一套完整的朝向视锥或潜行感知系统。
敌人不会永远向玩家贴脸冲刺。距离较远时接近,过近时后退,部分时机会横向移动。开火前有可见预警,射击方向使用有限的移动预判和随机偏差。
移动时先检查能否直达目标;不能直达才调用网格寻路。导航网格为 60 × 48,每格 50 个世界单位;寻路采用 A*,允许八方向移动,并禁止从两个相邻阻挡格的夹角斜穿过去。
这一版的 A* 开放集合仍是普通数组,寻找最低代价节点时线性扫描;一次搜索最多扩展约 1400 次循环,达到上限会返回空路径。因此它有明确的规模和完备性限制,不能把它描述为已经具备大型场景导航系统的能力。
源码还通过路径重算间隔,以及远距离敌人跳过后续行为更新,控制每帧工作量,而不是让每个敌人每帧都重新寻路。[7]

8. 搜刮和背包:先把物品语义分清
武器和物品都采用配置表。武器配置包含弹匣容量、伤害、开火间隔、散布、弹速、射程和弹丸数;物品配置包含名称、价值、稀有度和用途。霰弹枪通过一次生成多枚子弹实现,而不是另写一整套命中系统。
搜索普通未搜索容器时,需要接近、无遮挡、停下并按住 E,持续约 0.9 秒。移动、治疗或换弹期间不能推进搜索。搜索完成后打开结果面板,战斗随之暂停。[8]
背包使用固定格数,每件非弹药物品占一格,没有实现物品旋转或多格拼图。弹药走单独分支,直接补充主武器备弹,不占格子;背包满时,未能拿取的物资留在容器中。
“全部拿取”倒序遍历容器物品,是因为拿取会立即修改数组,倒序可以避免删除元素后跳过下一项。放下物资也不是直接删除:搜索时可以放回当前容器,不在容器界面时则生成或复用地面物资堆。
基础急救包和背包中的急救包是两个来源。每局免费携带的基础急救包用于生存,但不计入“带回医疗物资”的委托条件。这个区别很重要,否则出门就可能自动满足本应通过探索完成的目标。
治疗也不是按键后立刻回血,而是推进 1.4 秒的动作;移动或受伤会中断。门禁卡则是开锁条件,开箱不消耗卡片。[8]

9. 撤离和结算:统一处理收益,防止重复入账
撤离不是与一个点碰撞后立刻切换页面。玩家需要进入已开放的撤离范围,并累计 6 秒进度。
由于画面中的区域画成椭圆,实际判定也使用椭圆,并向内收缩一点。离开范围、进入不满足条件的状态或开始翻滚,会清空当前进度;在可用区域内普通移动不会自动中断。背包或地图暂停期间,撤离计时同样不会推进。
铁路检查站从本局开始即可使用,林间渡口在本局时间达到 60 秒后开放。额外委托不是撤离前置条件。[1][8]
所有成功或失败入口最终汇入 finishRaid()。函数先检查当前模式,并立即切换到结算模式:
function finishRaid(success, reason = '') {
if (mode !== 'playing') return;
mode = 'result';
// 收集背包快照、计算收益、更新长期档案、显示结果……
}
这样即使多个事件尝试结束同一局,后续调用也不会再次增加收益。
实际奖励公式是:
const reward = success
? value + 200 + game.kills * 40 + (quest ? 1200 : 0)
: 0;
value 是撤离时背包物资的总价值。委托要求当前背包中仍有至少 2 块电路板、1 个急救包,并且本局击退至少 3 名敌人。不是“曾经捡到过”就算完成:在撤离前用掉或放下相关物资,最终条件就可能不成立。
成功后把收益加入长期档案;死亡、放弃和超时没有鸭币收益。累计击退统计仍会更新,但统计更新不等于失败也能领取击退奖励。[9]
10. 本地存档:只保存值得跨局保留的状态
存档使用 mistwood-duck-v1 作为键,保存鸭币、永久升级、累计数据、物资发现记录、所选武器和难度。
进行中的战局不会保存。 刷新页面不能从刚才的坐标继续战斗,背包里尚未撤出的物资也不会变成长期资产。
读取存档时,loadSave() 对数值、升级范围和配置选项进行检查;解析失败则回退到默认档案。写入也通过 try/catch 处理,存储不可用时保留当前页面内的进度,并在藏身处提示用户。
不要把它当成云存档或防作弊方案。真实持久性仍取决于浏览器及页面来源;更换浏览器、清理数据或改变本地文件位置,都不能假设还能读到原档案。当前交付没有经过所有真实浏览器和文件打开方式的持久性验证。[2][11]
11. 反馈与性能:把有限的计算用在玩家能感受到的地方
命中反馈由准星变化、伤害数字、短促音效和少量粒子叠加;开火带来枪口闪光和镜头震动;受伤有屏幕边缘提示;撤离进度带有提示音。它们共同表达动作发生了什么,而不只是让场景“更热闹”。
音效来自 Web Audio 的振荡器、噪声、滤波器和增益包络,不需要下载音频文件。音频初始化放在出击或输入等用户交互路径中;初始化失败时,逻辑允许继续无声游玩。[10]
性能方面,当前实现主要采用静态地面缓存、屏幕外物体跳过绘制、障碍物空间网格、寻路重算节流和粒子数量限制。画布设备像素比上限设为 2,HUD 和小地图的常规刷新间隔约为 0.1 秒;交互触发时也会立即更新相关界面。
这些是源码中的优化措施,不是性能测试结论。没有实测数据,就不应该写“所有设备稳定 60 FPS”或“移动端已经全面适配”。[3][5][7][10]
12. 构建、测试,以及下一步
修改源码后,在项目目录执行:
python3 build.py
构建脚本检查模板中的 CSS、JavaScript 占位符是否各出现一次,再读入源码并替换,生成可独立打开的 index.html。运行游戏本身不依赖 Python,它只用于重新构建。[12]
需要通过本地 HTTP 访问时,也可以在项目目录启动静态服务器:
python3 -m http.server 8000
然后在本机浏览器访问 http://localhost:8000。这是可选访问方式,不是给外部读者准备好的公开试玩地址。
测试不能只确认首页能显示。更重要的是检查动作和状态之间的交接:换弹是否正确扣除备弹、满背包是否留下未取物资、弹窗是否冻结战斗、死亡是否没有收益,以及真实经过撤离进度后是否只结算一次。
随源码附带的 TEST_NOTES.md 记录了无头 Chromium 与 Playwright 的功能和布局检查。但记录也明确了边界:浏览器测试通过加载 HTML 内容进行,不是直接双击文件;存档序列化和读取使用模拟 localStorage 验证;触屏部分覆盖布局与控件显示,没有完成真实手机长时间性能和多点触摸兼容性测试。[11]
后续可以先拆分 game.js 中的输入、场景、物理、AI、渲染和 UI 模块,补齐真实浏览器的存档测试与自动化回归,再考虑更复杂的内容。新增联网模式则需要重新设计权威状态、同步和结算,不能只在当前单机逻辑外套一层请求。
最后:先保证规则成立,再增加内容
这个原型最值得复用的,不是某个画鸭子的函数,而是一条实现顺序:先把移动和碰撞跑通,再接入射击与反馈,随后加入 AI、搜刮和撤离,最后把成功收益连接到长期成长。
每一步都要能够独立验证。只有“墙真的能挡子弹”“物资只有撤离后才入账”“暂停真的停止战斗”这些规则成立,增加武器、敌人和地图才有意义。
做完一个可验证、可重复游玩的闭环,再扩大范围,比一开始追求“完整复刻”更适合这个浏览器原型。
源码对照
本文描述依据本项目当前交付源码与随附测试记录,以下是对应入口,不代表原作内部实现。
[1] src/game.js:buildWorld()、rollLoot()、startRaid()。
[2] src/game.js:loadSave()、save()、profile、world、game;README.md 的存档范围。
[3] src/game.js:frame()、update()、openOverlay()、closeOverlay()、窗口失焦和页面隐藏事件。
[4] src/game.js:resize()、update() 的输入与摄像机更新、render() 的坐标变换。
[5] src/game.js:bakeGround()、render()、drawWall()、drawRoof()、drawTree()。
[6] src/game.js:buildSpatial()、isSolid()、moveEntity()、segmentCircle()、segmentRect()、traceWall()、shoot()、updateBullets()。
[7] src/game.js:updateEnemies()、enemyShoot()、buildNav()、pathfind()。
[8] src/game.js:updateInteraction()、takeItem()、takeAllItems()、dropItem()、heal()、update()。
[9] src/game.js:finishRaid()。
[10] src/game.js:initAudio()、tone()、noise()、sound()、particle()。
[11] TEST_NOTES.md:随源码附带的测试范围与明确限制;本文没有将这些记录表述为新一轮全设备实测。
[12] build.py:main()。