综合 / 开发工具 · 2026-10-08 12:40 · 1 阅读 · 0 赞
Claude × Three.js:浏览器里的 3D 坦克大战是怎么建成的
Claude of Tanks 是一个用 Three.js 和 WebGL 在浏览器中运行的 3D 装甲战斗项目,包含 126 辆程序化战车、20 张地图、五种玩法、装甲逐层结算与权威联机架构。本文从核心玩法、完整功能与关键代码三个层面拆解它的实现方式。
Claude of Tanks 是一套能够追溯因果的装甲战斗系统。 炮弹从真实炮口飞出,装甲与内构逐层结算;百余辆战车、20 张战场、单机、联机、图鉴与场景工作室共享车辆规格、地图资源与关键模拟规则。本文从核心玩法、完整功能与关键代码三个层面,拆解它是如何做到这一点的。

图 1:Claude of Tanks 浏览器实机启动画面。本文截图均为完整 1600×900 桌面视口,没有裁切画布边缘。
打开 Claude of Tanks,第一反应很容易停留在画面上。车库灯光扫过 M1A1 Abrams 的炮塔,履带和悬挂贴着地面,进入战场后,村庄、道路、电线杆、草木和山脊一直铺到远处。炮口喷出火焰,弹道在空气中划过,命中会激起烟尘,损坏的车辆则留在地图上成为新的掩体。
这是一个不依赖商业游戏引擎、直接用 Three.js 和 WebGL 跑在浏览器里的 3D 装甲战斗项目。
但画面只是入口。让这个项目立住的,是它在向下建立模拟、向外建立内容、向后建立工具、向前建立联机这几件事上都没有偷工。玩家看到的每一辆战车、每一次开火和每一次装甲判定,沿着代码都能找到原因。要理解它,需要先看这套系统怎么定义“什么才算真正发生过”。
一、126 辆战车,20 张地图,五种打法
当前检视版本将 126 辆第一方程序化战车列入生产可见阵容,横跨多个国家、技术等级与年代,可以进入 20 张经过人工战术编排的战场。
“程序化战车”不是自动换一个颜色,也不是从网上下载模型后填入不同数值。每一辆可选车辆都由项目自己的规格与几何系统生成——外形、装甲板、模块、乘员位置、火炮、俯仰限制、弹种、动力、悬挂、迷彩和技术资料,都来自同一份车辆定义。

在车库里,玩家可以选择战车、地图、迷彩、弹药和配件,查看生命值、速度、单位功率、装填、瞄准、伤害、视野与隐蔽。这里没有货币、经验或科技树门槛,设计重心是让车辆差异直接进入战斗,而不是让玩家反复刷数值。
点击“开始战斗”,可选的规则不止常规歼灭:标准战斗围绕侦察、交叉火力和生存进行歼灭战;夺旗战包含拾取、掉落、归还、得分与复活;区域控制围绕三个区域持续争夺,率先达到目标分数获胜;极速战球让坦克推动具有物理行为的球,同时保留火炮、装甲与伤害系统;无尽尸潮则是玩家合作抵抗逐渐增强的机器人波次,争夺补给。




游戏支持单机机器人、浏览器托管私人房间、局域网、房间聊天、观战、复活、重赛,以及由专用服务器负责结算的排位模式。桌面端按键可以重映射;移动端也做了专门适配,摇杆、滑动瞄准、双指缩放、安全区适配与辅助瞄准都齐全,不是把桌面按钮简单缩小。
五种模式共享底层的移动、瞄准、弹药、装甲、损伤、侦察和机器人规则,改变的只是胜利条件,不是战斗世界的物理法则。
二、开火背后的整套逻辑
Claude of Tanks 的基本操作不复杂:驾驶、观察、瞄准、选择弹种、开火。复杂的是系统如何理解这一次开火。
很多轻量射击 Demo 里,准星指向目标,鼠标按下,程序做一次射线检测,给对方扣血。视觉上也能出现火光与爆炸,但炮弹并没有真正经历空间。
Claude of Tanks 的流程要长得多。屏幕中心首先只是玩家“希望瞄准”的世界坐标,炮塔与火炮要按各自转速和俯仰限制去追踪目标;稳定器、瞄准时间、车辆运动与模块损伤会改变散布和最终命中质量。视线看到目标,不代表炮管已经转到那里;摄像机能越过障碍,也不代表炮口拥有同样的射界。
火炮真正击发时,炮弹从车辆当前姿态下的实际炮口生成,拥有位置、上一位置、速度、重力、飞行时间、累计距离、剩余穿深和跳弹次数。每一个固定模拟步,系统都会让炮弹继续飞行,再检查它从上一位置到当前位置之间穿过了什么。


这种“连续线段检测”解决了一个实际问题:现代尾翼稳定脱壳穿甲弹速度极高,如果只检查每一帧结束时炮弹所在的点,它可能直接从薄目标的一侧跳到另一侧,出现穿模。检查完整的 prevPos → pos 线段,才能知道这一小段时间里最先撞到的是树、墙、地形,还是坦克。
游戏目前建模了 AP、APCR、APFSDS、HEAT、HE 等弹药家族,初速、重力、距离衰减、转正、跳弹、爆炸和穿后行为各不相同。制导导弹也没有走“播放一段动画再结算伤害”的捷径,而是权威弹药系统中的真实飞行实体。
玩家实际管理的是这样一条链:发现目标,判断距离与姿态,选择弹种,等待火炮完成物理指向,炮弹从炮口飞出,首个碰撞体被确认,装甲与内构逐层结算,结果再反馈到车辆能力上。好的装甲游戏让人觉得“每一炮都有重量”,通常不是因为爆炸更大,而是因为这一炮真的有前因、有过程,结果还会持续影响后续决策。
三、装甲判定靠坐标,没有靠血条
炮弹碰到坦克,只是计算的开始。
系统会把世界空间中的弹道转换到车辆当前姿态下的局部坐标。车体、炮塔、随火炮俯仰的部件和炮管分别拥有相应坐标框架,因此坦克正在爬坡、车体侧倾、炮塔转向或火炮俯下时,命中区域依然能与画面中的真实姿态对应。
traceTank 不只回答“有没有碰到”,而是返回沿弹道排序的交点:外部装甲板、爆炸反应装甲、间隙装甲、履带、炮管、内部模块与乘员。

损伤结算依次考虑:炮弹以什么角度命中;是否超过该弹种的跳弹角;口径能否形成碾压;倾斜后等效装甲有多厚;当前面对的是动能防护还是化学能防护;爆炸反应装甲是否尚未被消耗;聚能射流穿过间隙后还剩多少能力;主装甲被击穿后,弹道又经过了哪些模块和乘员。
这让“摆角度”不再是一句经验提示。转动车体会改变弹道与装甲面的夹角,既可能增加等效厚度,也可能把原本安全的侧面暴露出去。远距离射击不只是更难瞄准,弹丸本身的穿深还会继续衰减。玩家需要在 APFSDS 与 HEAT 之间,依据目标的动能/化学能防护、间隙层与 ERA 状态做选择;瞄准履带制造侧击窗口,同样有可解释的规则。
击穿也不是统一扣除一段生命值。弹药架、发动机、油箱、火炮、炮塔座圈、观瞄设备、无线电和左右履带都有各自状态,乘员也以空间位置参与穿后结算。



发动机受损影响机动,火炮与炮塔座圈受损影响瞄准,观瞄与无线电改变侦察和信息共享,油箱可能起火,弹药架则可能直接终结车辆。损伤会反过来改变下一秒该怎么打,不只是战斗结束后的一份统计。
项目的 X-Ray 击杀回放也建立在同一结果之上,展示真实结算产生的炮弹路径、命中装甲、等效防护和受损内构——表现层可以呈现权威状态,但不会临时伪造一次近似穿透去凑一段好看的动画。
四、驾驶手感、侦察规则与地图细节
一辆几十吨的履带车辆,不应该像贴在地面上的相机一样移动。
项目的坦克运动求解器在固定步长中处理动力、转向、制动、倒车转向、地面阻力、坡度、碰撞、冲撞、离地、落地、履带支撑以及车体俯仰和侧倾。履带滚动与悬挂姿态来自真实位移和地形接触,不是独立播放的计时器动画。
某些特殊车辆机制也进入了同一物理状态。例如 Strv 103 依靠车体与液气悬挂调整固定火炮,这个姿态会同步进入碰撞、炮口、装甲、摄像机和网络快照。弹匣式自动装填、手动弹匣补充和制导武器则各有明确状态机,作为权威状态参与战斗与同步,不只是播放视觉动作。
侦察系统综合观察距离、车辆隐蔽、移动与开火惩罚、植被遮蔽、无线电共享等因素。这里最值得注意的是联机时的信息边界:未被侦察到的敌方坐标,在权威端生成客户端快照之前就会被剔除。这比“权威端把所有敌人坐标都发下来,客户端决定不画”安全得多——后者只需要修改客户端就可能获得全图透视,前者让客户端从数据层面根本拿不到不该知道的信息。
20 张地图也不是无限随机生成的地形皮肤,而是代码生成基础、人工进行战术编排的战场。每张地图都包含自己的高度、道路、地标、植被、建筑、碰撞、隐蔽体积、破坏状态、天空、灯光、雾和小地图。部分树木、道具与建筑可以被撞毁或击毁,残骸还能继续参与遮挡和战术判断。


机器人在核心移动、侦察、弹药、装甲与损伤上不会绕过同一套规则,会规划路线、寻找目标、估算弱点,还会预测弹道走廊中的友军误伤风险。电脑对手服从同一世界,战斗才会产生可信的相互作用。
五、图鉴与摄影棚
Claude of Tanks 最能体现完成度的功能,反而不在普通对局里。
Tank Gallery 是一套可以直接检查生产车辆的技术展厅。玩家能够搜索当前检视版本中的 126 辆生产可见车辆,切换观察角度,操作炮塔和火炮,并单独显示外观、装甲、模块、乘员与精确表面。右侧档案展示车辆的火力、防护、机动、生存能力和技术摘要。

Scene Studio 则像嵌在游戏内部的小型摄影棚。它可以在真实战场上布置任意车辆,调整位置、方向、炮塔、火炮、迷彩和损伤状态,添加开火、曳光、爆炸、烟尘与毁伤事件,再通过确定性的时间线设置镜头并输出图片或视频。

这里没有另外做一套“只为截图服务”的假模型或假爆炸。Studio 使用真实地图、真实车辆和真实渲染器;项目公开展示的宣传图也来自可复现的游戏场景,而不是脱离运行时的概念图。
这两个工具说明,作者考虑的已经不只是玩家怎么玩,还有内容如何被检查、调试、展示和生产。单次演示可以只验证一个可见结果,持续演进的项目还要不断生产内容、发现问题、定位问题和验证问题——Gallery 和 Studio 正是从“作品”走向“生产系统”的分界线。
六、126 辆战车如何靠单一数据源维持一致
答案不是复制 126 份代码,而是建立单一事实来源。
同一份车辆规格同时服务于程序化模型与材质、装甲板与 ERA 与模块与乘员、弹药与机动与悬挂与火炮限制、机器人选择与弱点判断、车库卡片与技术图与小图标、Tank Gallery 与 Scene Studio,以及单机模拟与联机权威。
如果车库说一辆车有 120 毫米火炮,战斗系统却从另一张表读取 105 毫米;如果外观模型转动了炮塔,命中盒仍停在旧位置;如果图鉴展示一种装甲结构,权威端却按另一组数值结算——内容越多,错误就会越快累积。
Claude of Tanks 通过共享规格,把这些分叉重新合并。修改一辆战车的炮口位置、俯角、装甲板或内部模块,所有消费端都必须面对同一变化。
项目同时为车辆几何、装甲、乘员、模块、地图、联机、性能、公共构建和隔离素材设置了自动化检查。随机性通过种子注入,关键随机数的消费顺序保持固定,使一发炮弹的穿深、伤害、模块命中与起火能够复现并测试。规模化开发最容易快速制造不一致,这个项目把新增内容放进契约、测试和验证门槛里,而不是依靠开发者记住所有隐含关系。
七、联机架构:客户端请求,服务器裁定
联机游戏最难的从来不是让两台电脑看见彼此,而是决定哪一台电脑有资格定义事实。

Claude of Tanks 的权威战斗逻辑不依赖 DOM、相机或 WebGL,可以运行在浏览器主机,也可以运行在 Node 专用服务器。客户端上传的是油门、转向、制动、瞄准方向、弹种和动作位,权威端会再次校验并限制这些输入。客户端不能上传“我击中了谁”“我造成了多少伤害”或“这场比赛我赢了”。
权威端以固定 60 Hz 推进移动、碰撞、装填、开火、炮弹、火灾、维修、侦察与胜负,再以较低频率向客户端发送状态快照。位置等持续状态可以被更新的快照替换;命中、模块损伤、击毁和比赛结束等一次性事实,则通过可靠事件通道传输。
具体的边界划分是:本地车辆移动会预测并重放未确认输入,炮塔动作可以即时呈现;但伤害、侦察、障碍破坏与比赛结果不能预测,必须等权威端算出来。下一份快照很快会覆盖的位置状态可以丢失,但一次命中、一次击毁或一场比赛的结束不能丢失。权威状态允许客户端知道的内容可以展示,尚未被侦察发现的敌方坐标则不会下发。这已经不只是加一个多人房间按钮那么简单,而是一套完整的信任模型。
八、核心代码看看
以下代码是依据真实架构压缩后的等价示意,保留了核心思想,方便在文章中阅读;并非逐字复制全部源码。四段代码分别对应 src/game/battleFrameRuntime.ts、src/sim/ballistics.ts 与战斗集成、src/sim/armor.ts 和 src/sim/damage.ts,以及 src/net/matchRuntime.ts 与 src/sim/authoritativeMatch.ts。

1. 固定步长:屏幕刷新率不能改变战斗规则
const SIM_DT = 1 / 60;
const MAX_STEPS = 4;
let accumulator = 0;
function updateFrame(frameDt: number) {
// 慢帧可以补算,但限制单帧最多追赶 4 个模拟步
accumulator = Math.min(
accumulator + frameDt,
SIM_DT * MAX_STEPS,
);
while (accumulator >= SIM_DT) {
stepSimulation(SIM_DT);
captureCurrentPose();
accumulator -= SIM_DT;
}
// 剩余时间只用于画面插值,不参与修改战斗事实
renderInterpolatedPose(accumulator / SIM_DT);
}
这段结构把游戏规则和屏幕刷新分开。60 Hz、120 Hz 或 240 Hz 显示器可以看到不同数量的画面,却不能让坦克跑得更快、装填更短、火灾多跳一次伤害。
如果页面短暂卡顿,程序会补算有限数量的固定步骤,而不是把一个巨大的不稳定时间差直接塞进物理系统。暂停恢复时也只允许有限推进,避免把暂停期间的墙上时间当成战斗时间一次性重放。
2. 实体炮弹:从炮口到内构是一条连续路径
function stepProjectile(shell: Shell, dt: number) {
shell.prevPos.copy(shell.pos);
shell.pos.addScaledVector(shell.velocity, dt);
shell.pos.y -= 0.5 * 9.81 * dt * dt;
shell.velocity.y -= 9.81 * dt;
shell.distance += shell.pos.distanceTo(shell.prevPos);
const worldHit = traceWorld(shell.prevPos, shell.pos);
const tankHit = traceNearestTank(shell.prevPos, shell.pos);
const firstHit = nearest(worldHit, tankHit);
if (!firstHit) return;
if (firstHit.kind === "world") {
resolveWorldImpact(shell, firstHit);
return;
}
const intersections = traceTank(
shell.prevPos,
shell.pos,
tankPoseFromState(tankHit.target.state),
tankHit.target.spec.armor,
tankHit.target.combat.eraSpent,
);
resolveShellHit(
shell,
tankHit.target,
intersections,
seededRng,
);
}
关键不在重力公式有多复杂,而在数据结构是否保留了完整因果:实际炮口姿态生成炮弹实体,炮弹沿连续路径飞行,找到最近碰撞,得到有序的装甲交点,最后结算穿后内构。只有先保留这条链,距离衰减、跳弹、间隙装甲、ERA、模块和击杀回放才有可靠的附着点。
3. 损伤结算:一条顺序敏感的流水线
function resolveArmorImpact(
shell: Shell,
target: Tank,
hits: Intersection[],
rng: RNG,
) {
let remainingPen = rollPenetration(shell, rng);
const hullDamage = rollHullDamageOnce(shell, rng);
let penetrated = false;
for (const hit of hits) {
if (hit.kind === "plate") {
const plateKind = hit.plate.kind ?? "main";
if (plateKind === "era" && isSpentERA(hit, target)) {
continue;
}
if (!penetrated && wouldRicochet(shell, hit)) {
return deflect(shell, hit);
}
if (plateKind === "era") {
consumeERA(target, hit);
remainingPen = applyERA(remainingPen, shell, hit);
continue;
}
if (plateKind === "external") {
resolveExternalPartHit(target, shell, hit, rng);
continue;
}
if (plateKind === "spaced") {
remainingPen = crossSpacedArmor(
remainingPen,
shell,
hit,
);
continue;
}
const effective = effectiveThickness(shell, hit);
if (remainingPen < effective) return stop(shell, hit);
remainingPen -= effective;
if (!penetrated) {
penetrated = true;
applyHullDamage(target, hullDamage);
}
continue;
}
// 内部模块和乘员只有在主装甲被击穿后才参与结算;
// 外部履带、炮管等还有单独分支,此处省略。
if (!penetrated) continue;
if (hit.kind === "module") {
rollModuleDamage(target, hit, rng);
} else if (hit.kind === "crew") {
rollCrewDamage(target, hit, rng);
}
}
}
真实实现还会处理不同弹种的转正与倾斜系数、三倍口径碾压、复合防护、外部炮管与履带、火灾、弹药架、乘员死亡、贯穿与二次命中。顺序在这里尤其重要:炮弹必须先遇到外层 ERA,才可能进入主装甲;必须先击穿主装甲,内部模块才有受损资格。固定随机数消费顺序,则保证同一种输入和种子能够重演同一种结果。
4. 权威联机:输入是请求,结果才是事实
const latestInputs = new Map<string, PlayerInput>();
function receiveInput(
authenticatedPeerId: string,
untrustedPacket: unknown,
) {
const input = normalizeAndClampInput(untrustedPacket);
// 身份来自已认证连接,绝不信任数据包自报的 playerId
latestInputs.set(authenticatedPeerId, input);
}
// 由独立的 60 Hz 权威时钟调用,而不是由某个玩家的数据包触发
function authorityTick() {
simulation.step({
dt: 1 / 60,
inputs: latestInputs,
});
for (const viewer of players) {
const snapshot = simulation.snapshot({
viewerId: viewer.id, // 在序列化前过滤未侦察敌人
});
const { events, ...replaceableState } = snapshot;
sendReplaceableSnapshot(
viewer,
createDelta(replaceableState, viewer.ackedBaseline),
);
sendReliableEvents(viewer, events);
}
}
客户端可以说“我按下了开火”,却不能说“我打穿了敌人的弹药架”——后一句必须由权威模拟自己算出来。
九、最后
最后说一下创作方式。项目由 Kevin B. Liu 创建、设计并主导;Claude 和 Codex 参与研究、车辆制作、模拟、网络、设计、性能优化、测试、文档和部署,但作者声明明确把它们定义为开发工具,而非共同作者或版权持有人。工具可以提高实现速度,项目的可信度还是来自清晰的边界、统一的数据、可复现的结果和持续的验收。
在线体验: https://cot.kevinliu.studio/
代码仓库: https://github.com/Kevin-Liu-01/Claude-of-Tanks
说明:仓库默认采用 MIT 许可,但车辆与战场源码、相关数据、生成资产、媒体及品牌等存在 Reserved Content 例外;复用前应阅读项目的 LICENSE、LICENSE-POLICY 与 NOTICE。