
做一个你能控制一大群的游戏是我今年玩得最开心的一次开发。不是普通的单人操控而是你指挥几百只、上千个小单位聚散攻守整个屏幕都是你的活物军团——那种从我点了鼠标到它们像鱼群一样涌过去的反馈延迟里藏着一种很难描述的控制感。我把它挂到了 Hacker News 上标题就一句话Show HN: Game where you control a swarm。没想到评论区比代码本身还热闹有人问算法有人问优化也有人问为什么我控制的群体老是撞成一团。这篇文章就围绕这个 swarm 游戏的开发和调参过程把你可能好奇的东西一次说完。游戏的核心概念不复杂你不是扮演某一个角色而是扮演一群。玩家面向的是整个群体的意图输入而不是单个个体的精操。它适合对游戏玩法设计、群体模拟算法有兴趣的开发者也适合想找一个手感奇怪又上头的小项目的玩家。我不会只讲概念后面会拆到一个可以跑起来的原型级别包括我用 Godot 写的那套基础群聚逻辑以及一路踩过来的性能优化和手感坑。1. 为什么控制一群比控制一个更容易上瘾1.1 从蚂蚁搬面包说起这个项目的灵感来源很土。我蹲在院子边上看了几分钟蚂蚁搬面包屑发现它们并没有一个首领在指挥每只蚂蚁往哪儿走每条蚁路都是蚂蚁之间简单交互的涌现结果。但作为旁观者你会忍不住觉得整窝蚂蚁好像有一个大脑。游戏里控制一个 swarm 的爽点其实就是把这种好像有大脑的错觉交给玩家。玩家不关心群体里哪一只是飞在前面的头鸟他只关心这团乌云想去哪。所以我在设计上定的第一个原则是不要把群体做成一群听话的士兵而是做成一个有脾气、有惯性的液体生物。当你控制 800 个单位在场地里游动、撞击敌人、包裹目标的时候玩家会产生一种我在操作一个巨型生物的错觉而不是在操作 800 个像素点。这个错觉是整个玩法成立的核心。如果做成精确到每个单位的 RTS 分队反而没意思。1.2 判断一个 swarm 游戏好坏的关键手感点我在 HN 上被人问得最多的一个问题就是怎么判断一个 swarm 游戏做得行不行我自己的标准有三个响应性玩家下达移动目标后群体整体开始转向的延迟有没有超过 0.2 秒。超过的话玩家觉得卡太少的话又觉得这不是一群是一个拖尾的东西。有机性看群体在移动的时候边缘有没有自然的绒毛感是不是总有一条特别整齐的弧线。越整齐越像军队越像军队越不像 swarm。局部失控但又总体可控单只个体偶尔会被挤出去、落单、绕远路但最终整个群体能到达目标区域。这种半失控感很重要它让群体看起来是活的而不是一个被精确计算的粒子特效。这三条也是我后续调所有参数时的总纲。凡是让群体显得机械的调整不管观察数据多好看我都会砍掉。2. 群体运动算法从 Boids 到可指挥的群集2.1 为什么选 Boids 而不是更高级的方法做群体模拟绕不开 1987 年 Craig Reynolds 提出的 Boids 模型每个个体按照三条局部规则运动分离、对齐、聚拢。这么多年过去很多更复杂的群体模式都用上了神经网络、强化学习甚至玻尔兹曼机但我最后还是选择回到 Boids 做基底。原因很简单可控性和可解释性太强了。网络模型调起来像炼丹Boids 则像调音响——你能直接感受到bass 太重了对应的是分离权重太高单个个体之间排斥太强群体永远形成不了簇拥。你不需要去猜黑箱里面发生了什么。我用的扩展版是在经典 Boids 三规则基础上额外加入两层因素目标场玩家给出的目标点会生成一个全场吸引力但不同距离、不同方位的个体收到的力会有差异避免全体直线飞向鼠标的呆样。避障场检测附近的障碍物生成排斥力让群体懂得绕路而不是硬怼墙。生活化的类比上班高峰期地铁站出来一大群人。每个人都在分离不要踩到别人、对齐跟着出站人流方向、聚拢不要离朋友太远同时每个人都带着自己的目标去 A 口还是 B 口。你作为玩家就是那个临时改变出站口布局的城市规划师。2.2 Boids 三规则的参数和权重到底在调什么经典 Boids 三条规则每一块都有两个关键参数一个影响半径radius一个影响力大小weight。我按自己项目的实际数值说一下经验区间不一定对所有项目适用但方向是对的规则影响半径权重作用感分离Separation12~181.2~1.8防止个体叠加和穿透太大会导致群体稀稀拉拉对齐Alignment30~400.6~1.0让群体保持方向一致太小会像无头苍蝇聚拢Cohesion45~600.4~0.8让群体抱团但大了会中心塌陷群体聚成实心球注意这三个权重的绝对值不重要重要的是比例。我在早期调参时犯的最大错误是单独加大某一个权重测试感觉结果群体要么变成一盘散沙要么变成铁板一块。后来改成只调整比例保持总权重不变手感立刻好多了。2.3 玩家意图怎么映射到 Boids 的力上Boids 本身是给无指挥者的模拟设计的但游戏里玩家要发号施令。我试过几种做法直接把目标点当 cohesion 的输入告诉每个个体往那个点聚拢——结果群体飞到目标点附近后全部堆叠一坨糊在鼠标底下。给每个个体分配目标点附近的独立小区域泊松盘采样让它们各自前往——群体看起来是过去了但阵形僵硬像排队进体育馆。最终方案两层意图层。第一层是目标点第二层是群体行进中的偏移场。目标点会拉动群体重心而偏移场让每个个体的最终位置相对于目标点有一个动态散布既不重叠又不排队。第三种方案最接近我想要的手感群体到达目标后不会聚成一个点而是像墨水晕开一样铺成一个不规则圆斑边缘还带有小幅波动。这个机制虽然听上去多了一层计算但实现起来很简单就是给个体的目标位置加上一个随时间变化的偏移向量。3. 玩家操作层你是在指挥而不是遥控3.1 三种指挥范式对比做 swarm 游戏你绕不开一个问题玩家手上只有鼠标和键盘但他要指挥几百个个体。我前期对比过三种指挥范式目标点移动右键点哪里全体就涌向哪里。最适合原型验证也最容易让新手上手但玩久了会无聊。路径点队列按住 Shift 可以连续布置多个目标点让群体按顺序巡游。适合做解谜玩法比如先把左边的门撞开再去吃右边的资源。行为模式切换按下不同的键整个群体切换为聚拢跟随、分散探查、集中攻击、紧急撤离等模式。每个模式其实是一组 Boids 参数和目标场参数的预设。我最终在第一个版本里把三种全做进去了。不是贪多而是因为 swarm 玩法本身目标模糊——不同玩家想要的爽点完全不同。有人喜欢看着一大群乌云压顶有人喜欢排兵布阵也有人就喜欢点来点去让群体跑来跑去。这三种模式对应的手感差异很大是很好的试探方向。3.2 引导感与缰绳机制即使选定了指挥范式实际操作中最大的坑是群体的响应速度过快或过慢。我把这个问题归纳为缰绳设计。骑马的时候你不可能在转弯的瞬间让马头完全偏转缰绳的作用是传达方向意图马自己会保持步态和平衡。swarm 也是。如果群体瞬间转向玩家会觉得是在操作一颗巨大的光标而不是一群生物如果群体响应太慢玩家又觉得延迟高。我的实现是在个体速度向量上做有限转向角约束。每个个体每秒最多转向一定角度这个角度和它的速度成反比飞得越快越难急转弯。这样在群体高速移动时下达转向命令能看到一条优美的鱼群甩尾轨迹而不是全体瞬间折返。实测下来这个限制让玩家对群体的引导感增强了很多建议做同类游戏的开发者一定要试。3.3 视觉反馈群体状态必须可读操作层不只是把命令发下去还要让玩家读懂群体正在干什么。我踩过一个很深的坑本地测试时我非常清楚群体在做什么但给朋友试玩时他们完全看不懂屏幕上的 800 个点为什么有时候快有时候慢。后来加了三个视觉机制颜色热区群体中每个个体根据当前收到的指令优先级颜色在白色、淡黄色、橙色之间渐变。朝向目标时偏白绕路时偏黄受到强力排斥时偏橙。群体中心光晕画一个半透明的光圈显示当前群体的重心位置玩家不用自己盯着散乱的个体找重心。惯性尾迹个体的拖尾长度随着速度和转向幅度变化。这既是美术效果也是给玩家判断群体状态的辅助线。调试阶段我还做过一个骚操作把每个个体当前的三条 Boids 力向量直接画出来屏幕上全是密密麻麻的箭头。这样能直观看出群体的受力方向是否合理虽然丑但排查群体为什么在局部打转非常有用。4. 实操从零到可玩原型的核心实现4.1 技术栈选择为什么用 Godot 4 而不是 Unity技术上我选了Godot 4理由有三场景树和组织方式非常适合把个体作为节点来管理每个 swarm 成员天然是一个 Node2D内部存速度、加速度、目标偏移等状态。GDScript 写起来快改参数不用等编译特别适合这种参数驱动、需要反复试手感的小项目。做 HTML5 导出非常方便方便发布到网页上给人试玩HN 上的展示场景也适用。Unity 当然也能做只是对一个个人项目来说Godot 的迭代节奏更轻。性能上如果你不写海量高密度个体Godot 足够用。如果你目标是 5000 个单位建议底层用 C# 或者直接考虑纯 GPU 模拟但那是另一个量级的话题不是这个原型阶段需要考虑的。4.2 核心 GDScript个体更新与指令系统下面这段代码是我原型里的核心去掉美术和音效只留逻辑骨架。它不是完整工程但足以说明个体如何响应玩家的最小循环# member.gd - 群体中的单个成员 extends Node2D var velocity : Vector2.ZERO var max_speed : 180.0 var max_steer : 4.5 var weight_sep : 1.4 var weight_align : 0.8 var weight_coh : 0.5 var weight_goal : 3.2 var goal_pos : Vector2.ZERO var separation_radius : 14.0 var alignment_radius : 38.0 var cohesion_radius : 50.0 var target_offset : Vector2.ZERO func _process(delta): var force : Vector2.ZERO # 1. 从空间哈希表中取得邻居成员 var neighbors swarm_grid.get_neighbors(self, max(cohesion_radius, alignment_radius)) # 2. Boids 三规则 force compute_separation(neighbors) * weight_sep force compute_alignment(neighbors) * weight_align force compute_cohesion(neighbors) * weight_coh # 3. 玩家目标指令产生的牵引力 force seek(goal_pos target_offset) * weight_goal # 4. 转向角限制模拟缰绳感 force force.limit_length(max_steer) # 5. 更新速度与位置 velocity (velocity force).limit_length(max_speed) position velocity * delta update_target_offset()seek 函数是经典转向到目标的实现func seek(target: Vector2) - Vector2: var desired target - global_position var dist desired.length() if dist 0: return Vector2.ZERO # 靠近目标时期望速度按比例衰减避免冲过头震荡 var speed min(max_speed, dist * 2.0) desired desired.normalized() * speed return desired - velocity这段代码里target_offset是关键。它是个体会偏离目标点的随机偏移每次到达目标附近后重新滚动避免所有人挤到同一个点上。配合weight_goal调大群体冲向目标时还是散开的。提示limit_length在 Godot 里直接让向量的长度不超过给定值作用就是限制每一帧的转向强度也就是我在 3.2 节说的缰绳。4.3 实测参数与手感校准参数不是一次调完的我给出一组实测里让我感觉像一群活的生物的数值可以作为你的起点参数名初始推荐说明个体数量600少于 200 没有 swarm 感大于 1200 对原型压力大max_speed180太快群体难控制太慢没有冲击力max_steer4.5每秒最大转向力数值越小越肉weight_sep1.4保证个体间不重叠weight_align0.8方向一致性但不用太高weight_coh0.5聚拢强度弱一点让群体更散weight_goal3.2响应玩家指令的力高于其他所有力权重调参技巧先固定 weight_goal 不变只调 Boids 三规则调出飘逸但还算抱团的群体再把 weight_goal 一步步提上来每提一个档位就去感受一下转向响应。你会发现存在一个临界值超过这个值后群体视觉上开始变得像一块被拖着走的磁流体那就说明 override 过强了要退回来一点。4.4 空间哈希让邻居查询不再拖垮性能Boids 最耗时的部分不是移动而是每个个体都要找周围的邻居。如果两两判断复杂度是 O(n²)。600 个体就是 600*600/2 18 万次距离计算每一帧做 18 万次还能接受但到 2000 个就是 200 万次直接卡成幻灯片。我用的是经典的空间哈希把世界划分成固定大小的网格每个个体在 update 时只查询自己所在网格和相邻 8 个网格内的个体。网格大小我就用上面最大感知半径的值50这样一次邻居查询就能覆盖到所有可能的邻居范围。# 一个简化的空间哈希网格接口示例 class Grid: var cell_size : 50 var cells : {} func add(member: Node2D): var key hash_cell(member.global_position) cells[key] cells.get(key, []) cells[key].append(member) func get_neighbors(member: Node2D, radius: float): var result [] var min_key hash_cell(member.global_position - Vector2.ONE * radius) var max_key hash_cell(member.global_position Vector2.ONE * radius) for x in range(min_key.x, max_key.x 1): for y in range(min_key.y, max_key.y 1): var key Vector2i(x, y) for other in cells.get(key, []): if other ! member and other.global_position.distance_squared_to(member.global_position) radius * radius: result.append(other) return result换成空间哈希以后600 个体的邻居查询从每帧 18 万次降到大概 2 万次以内性能和效果立竿见影。5. 性能优化记录与常见问题排查5.1 进一步压榨性能降频更新和距离 LOD空间哈希解决的是邻居查询复杂度但每帧更新仍然需要做完整计算。为了让个体数量能往上拉我还做了两个优化可以给你照着做降低更新频率给个体分组每组轮流在每隔一帧或两帧才重新计算完整 Boids 力中间帧直接沿用上一次速度。由于群体运动视觉上是平滑的混叠效果只要分组方式是空间上的交错玩家几乎察觉不到更新频率的降低。我在 800 个体下测试从每帧更新改成隔帧更新视觉差异极小性能直接提升约 40%。距离 LOD离摄像机近的个体用完整 Boids 计算离得远的个体只做简单的向目标点移动 随机扰动。远处群体本来就看不清细节手工划分等级后依然能呈现大片移动的声势。对变化临界的个体做随机的等级抖动避免出现明显的近处精细、远处僵硬的切换带。5.2 经典 Bug 合集群体抖动、打转、掉队开发过程里遇到的几个问题我觉得值得单独列出来因为它们非常典型群体原地高频抖动原因是 cohesion 权重过高群体重心和目标点重叠后个体在目标附近不断来回穿过重心。解决方法是把 seek 里的减速区间调大我改成dist * 2.0这样靠近目标前就开始减速。群体围着目标打转但不落位原因是转向角限制在高速状态下过强。个体速度太快转向跟不上只能绕着目标画圈。临时解法是把 max_steer 调大但更本质的把 max_speed 或者转向加速度曲线改一下。大片个体卡在障碍物边缘避障力计算是根据当前速度方向做的遇到凹字形障碍就会被困住。我给个体增加了一个沿墙体切向滑行的分量检测到障碍排斥力方向后把排斥力的切向分量保留让个体贴着墙走而不是原地反弹。5.3 Debug 工具是调参神器做 swarm 这类混沌系统项目Debug 工具比日志重要一万倍。我建议至少画三样东西个体的当前速度向量短线个体当前总受力向量长线群体重心和目标点之间的连线这三样画出来后你能立刻看出群体到底是沿着直线集结、绕着目标打转、还是在两个力之间摇摆。我在代码里留了一个debug_members开关按 F3 切换显示所有力向量用不同颜色的线绘制。开发群体模拟游戏没有这个工具调参会变成纯粹的闭眼猜。6. 最后聊一点我自己的开发体会控制一个 swarm 的游戏最让我意外的是它并不需要完美的群体控制。群体里偶尔有成员掉队、冲出边界、反应慢了半拍不但不是坏事反而是让玩家觉得群体活的关键。我一开始试图让所有个体都精准执行指令结果那个版本玩起来像在操作一个非常复杂的粒子画笔完全不像生物。所以后期我反而故意在每个个体身上加了一些随机噪声感知半径的浮动、转向角上限的微小波动、目标偏移的随机滚动。这些噪声加进入之后整个群体的行为反而更丰富玩家能肉眼看到群体在移动过程中不断有各种小分支、小回流观感立刻不一样了。这个项目后续可以扩展的方向还很多比如让群体通过吃地图上的资源自动繁殖、用基因算法调整每个个体的 Boids 参数、或者做一个你控制的 swarm 越来越大最后占领整张地图的生存模式。如果你也在做类似的 swarm 玩法我的建议只有一条先把一个简单的 Boids 原型跑起来再花十倍的时间去调手感别急着加玩法系统。手感对了玩法才能真正立得住。