MiniArena 自走棋

MiniArena 自走棋

2026年07月23日
3869 字 · 14 分钟

从一张不会动的棋盘,到一套真正跑起来的自走棋

MiniArena 是南京大学高级程序设计课的期末大作业。

这是一个基于 Qt6 的简易自走棋项目,也是我第一次独立完成一套模块比较多、状态关系比较复杂的游戏系统。

先把一件事说清楚。

这个项目并不是从空白文件开始写的。课程提供了一个基础框架,棋盘能够正常显示,主要 UI 区域也已经搭好,一些类和接口的骨架同样存在。

看上去已经像个游戏了。

但也只是看上去。

棋子站在棋盘上不会动,装备没有真正绑定到单位,羁绊不会计算,拖拽、升星、攻击、技能和存档都需要继续实现。框架给了我棋盘和 UI,我要做的,是让它真正活起来。

整个开发持续了大约一个半月,不过平时课程比较多,主要还是靠周末一点点推进。

我最开始以为,这份作业的难点应该是某个复杂算法。

真正做完以后才发现,最难的其实是让所有模块在同一套状态里正常协作。

先从看不见的规则开始

我的开发顺序有点反直觉。

我没有一上来就让棋子在棋盘上打架,而是先做了羁绊、装备和存档。

原因也很简单。

战斗画面看起来最直观,但如果棋子的属性、职业标签、装备归属和游戏状态都没有理清,后面的自动战斗只会变成一群数据混乱的小人在屏幕上互殴。

羁绊系统由 GameState 统一管理。

每个单位会携带两到三个职业或种族标签,例如战士、法师、精灵。recalculateSynergies() 会遍历场上仍然存活的单位,统计不同标签的数量,再根据二、四、六等阈值激活对应等级的加成。

场上有三个战士时,可以激活战士羁绊,为所有战士增加攻击力。场上有两个法师时,则会触发法师加成,提高技能伤害。

这里有一个很重要的限制。

死亡单位不参与统计。

所以只要一个关键单位死亡,它原本贡献的羁绊数量就会立刻消失。单位进入死亡状态时,会通知棋盘重新检查羁绊,相关加成也要同步撤销。

这意味着羁绊不是开局计算一次就结束了,而是会随着战斗不断变化。

装备则是独立的 Equipment 对象。

每个 Unit 内部维护一个 QVector<Equipment*>,最多可以持有三件装备。棋子的有效攻击力、生命值和速度,不直接修改基础属性,而是在读取时把装备加成计算进去。

绑定装备时,装备会记录自己的拥有者,然后进入棋子的装备列表。操作完成后再发出 statsChanged() 信号,让 UI 刷新对应的数值。

这种设计有一个好处。

棋子的基础属性仍然是干净的。

无论装备怎么穿戴、卸下,或者羁绊怎么变化,都不需要不断修改原始攻击力。最终属性可以根据当前状态重新计算,不容易出现反复叠加的问题。

存档系统反而是这一阶段最顺利的部分。

我需要保存棋子位置、星级、装备、羁绊状态、金币、生命值、回合数、商店和备战席。读取时再按照这些信息重建当前游戏状态。

它没有制造什么戏剧性的 bug,但它逼着我第一次认真思考,什么才是游戏里的真实状态。

屏幕上的贴图不是状态。

某个按钮显示成灰色也不是状态。

真正需要保存的,是棋子是谁、在哪里、属于谁、带了什么装备,以及当前回合走到了哪一步。

让棋子真的能被拿起来

完成规则层以后,我开始做拖拽部署。

Qt 自己提供了拖放机制,但从鼠标按下到棋子真正落到棋盘,中间还是要自己补全一整套流程。

拖拽开始时,我会记录棋子的来源。

它可能来自备战席,也可能来自棋盘上的另一个格子。除了棋子编号,还要把来源类型和原始位置一起写进 QMimeData

等棋子被拖到目标格以后,再做三层判断。

第一层看格子本身是否合法。

障碍物不能放,已经被占用的格子也不能直接覆盖。

第二层看格子里是不是己方棋子。

如果是己方单位,就进一步判断双方是否同名、同星,并尝试触发三合一升星。

第三层看是不是敌方单位。

如果目标格属于敌方,直接拒绝这次拖放。

这里我没有每次都重新计算整个棋盘,而是直接使用格子初始化时保存的 m_terrainm_occupied 元数据。拖拽落下时查一次就能知道是否合法。

真正麻烦的是合成。

三个同名同星棋子合成时,并不是把其中一个棋子的星级加一就完事了。我的实现方式是创建一个新的高星 Unit,再销毁三个旧对象。

新对象要继承目标格、部分装备和必要状态。旧对象则要断开信号,从 QGraphicsScene 移除,从单位列表删除,最后释放内存。

而且顺序不能反。

必须先记录旧棋子的格子和状态,创建并放入新棋子,再处理旧对象。如果先把旧棋子全部删掉,后面再访问 target->cell,拿到的就可能是一个已经失效的指针。

这部分后来还出现了一个很典型的联调问题。

升星以后,装备消失了。

单独测试装备系统时,穿戴和卸下都正常。单独测试三合一时,新棋子也能正确生成。可两个模块一组合,装备就不见了。

原因并不神秘。

升星创建的是一个全新的 Unit 对象,原来的装备还绑定在旧对象上。旧对象被销毁以后,装备按照析构逻辑退回背包,新棋子自然就变成了空装备。

这也是我第一次真正意识到,模块之间的接口不能只传一个星级和名字。

对象被重建时,哪些状态应该继承,哪些状态应该清空,哪些资源需要转移所有权,都必须明确规定。

单个函数没有写错。

错的是两个模块对于「合成之后还是不是同一个棋子」这件事,理解不一致。

当棋子开始自己做决定

完成部署和合成以后,项目才终于进入最像自走棋的部分。

自动战斗。

战斗由一个全局定时器统一推进。定时器每一帧调用所有存活单位的 update(),但具体要做什么,由每个单位自己决定。

每个棋子内部维护五个状态。

IDLEMOVINGATTACKINGCASTINGDEAD

战斗开始时,棋子从空闲状态寻找目标。找到敌人以后进入移动状态,通过 BFS 规划路径,并朝下一格逐帧靠近。

单位不会等自己完整走到目标格才重新判断。

只要移动过程中发现敌人进入攻击范围,就会立刻切换到攻击状态。目标死亡后,则重新搜索新的目标。

攻击状态里还要维护攻击冷却。

冷却归零后,棋子不会直接扣除敌人生命值,而是先发出攻击动画信号。等动画播放到命中帧时,伤害才真正结算。

这个处理看起来只是视觉细节,实际上会影响战斗逻辑。

如果发出动画时目标还活着,但命中前已经被另一个单位击杀,就要避免再次对一个死亡对象结算伤害。

技能状态则有另一套独立冷却。

棋子通过攻击命中和受到伤害获得能量。能量满、技能冷却结束,并且单位没有死亡时,就会进入 CASTING

施法时单位不能继续移动。技能动画和效果结束以后,再根据目标距离返回攻击或移动状态。

我实现了几种不同类型的技能。

狂暴可以在短时间内提高攻击速度,结束后进入虚弱状态。暗影步会寻找血量最低的敌人,再尝试瞬移到目标背后的空格。守护光环不进入施法状态,只要单位存活,就会周期性治疗周围友军。亡语自爆则完全绕开普通施法流程,在 onDeath() 中直接触发。

亡语自爆还给我上了一课。

最早的版本里,我先把死亡棋子从场景移除,再读取它所在的格子,计算爆炸范围。

结果当然是空指针。

后来只能把顺序改成先记录位置,完成范围和伤害计算,再移除对象。

做游戏逻辑以后,我越来越觉得,很多 bug 并不是算法不会写,而是对象死得太早,或者状态改得太晚。

六边形棋盘终于开始找我算账

自动战斗里最复杂的部分,还是六边形寻路。

方格棋盘通常有四个或八个方向,六边形棋盘则有六个邻居。不同坐标表示下,奇数列和偶数列的邻接关系还可能不同。

最开始,我用 BFS 搜索可通行区域,再从候选位置里选择下一步。

原始选择逻辑很直接。

// 原始版本:每帧重新计算下一步,选距离最短的邻格
QPoint HexBoard::nextStepTowards(const QPoint& current, const QPoint& target) {
QPoint best = current;
int bestDist = hexDistance(current, target);
for (const QPoint& neighbor : getNeighbors(current)) {
int dist = hexDistance(neighbor, target);
if (dist < bestDist) {
bestDist = dist;
best = neighbor;
}
}
return best; // 当多个 neighbor 的 dist 相等时,返回第一个遍历到的
// 下一次调用可能返回不同的 neighbor → 震荡!
}

这段逻辑在棋子较少时一直表现正常。

BFS 负责找出可走的路径,下一步选择则优先找离目标更近的邻格。几个单位在棋盘上移动时,它们会绕开障碍,也能正常接近敌人。

于是我理所当然地认为,寻路已经完成了。

直到项目验收当天。

助教要求测试满棋盘场景,所有单位同时上场。

战斗开始以后,棋盘立刻变成了大型抽搐现场。

很多棋子在两个格子之间疯狂左右横跳。向左上走一步,下一帧又向右上走回来,再下一帧继续回到左上。

前面的同学还在展示,离轮到我只剩半个小时。

我坐在下面盯着屏幕,冷汗已经下来了。

平时测试时棋子分布稀疏,可移动空间很多,所以问题没有明显暴露。满棋盘以后,大量单位互相阻挡,等长路径一下子多了起来。

六边形棋盘上,最短路径往往不唯一。

从某些位置出发,朝不同方向移动一格以后,到目标的直线距离可能完全相同。当前位置距离目标三格,向左上走完还是三格,向右上走完也可能还是三格。

对算法来说,这两个答案一样好。

但每一帧重新计算时,邻格遍历顺序、占用情况和目标位置都可能发生轻微变化。上一帧选择左上,下一帧又选右上,于是棋子不断推翻自己刚刚作出的决定。

BFS 没有找错路径。

它只是太没有主见。

我最后加入了一个方向一致性惩罚。

当多个格子的距离相同时,优先选择与上一帧移动方向一致的格子。也就是让棋子认准一个方向继续走,不要每一帧都重新纠结。

// 紧急修复:加入方向一致性,避免六边形棋盘上的震荡
QPoint HexBoard::nextStepTowards(const QPoint& current, const QPoint& target) {
QPoint best = current;
int bestScore = INT_MAX;
QPoint lastDir = current - m_lastPosition; // 上一帧的移动方向
for (const QPoint& neighbor : getNeighbors(current)) {
int dist = hexDistance(neighbor, target);
QPoint thisDir = neighbor - current;
// 核心:距离相同时,优先保持原方向
int penalty = (thisDir == lastDir) ? 0 : 1;
int score = dist * 10 + penalty * 3;
if (score < bestScore) {
bestScore = score;
best = neighbor;
}
}
m_lastPosition = current;
return best; // 不会再震荡了
}

距离仍然占主要权重。

如果另一个方向确实更接近目标,棋子照样会转弯。方向惩罚只在距离相同或接近时发挥作用,给多个同样合理的答案排出一个稳定顺序。

改完重新编译,再把棋盘塞满。

棋子终于不抖了。

它们会绕路,会被堵住,也会在目标死亡后重新寻找敌人,但不会再像忘记了上一帧发生过什么一样反复横跳。

前面的同学刚好展示结束。

轮到我了。

真正完成一个系统是什么感觉

回头看 MiniArena,任何一个模块单独拿出来,都算不上特别夸张。

羁绊是统计标签数量。

装备是保存指针并计算属性。

拖拽是 Qt 事件加合法性判断。

战斗是全局定时器推动单位状态机。

寻路是 BFS 加六边形距离。

但把它们放在一起以后,问题就完全不同了。

一个棋子死亡,会影响羁绊统计。

一次合成,会触发对象重建、装备迁移、场景更新和信号重连。

一次攻击,涉及动画、伤害、回蓝、技能触发和目标死亡。

一个单位换了格子,寻路、攻击范围、光环覆盖和 UI 都可能跟着变化。

我以前写程序时,经常把功能完成理解成某个函数能够得到正确结果。

MiniArena 让我第一次意识到,复杂系统里的完成,意味着每个模块都知道自己负责什么,也知道状态变化以后应该通知谁。

课程框架给了我棋盘和 UI 骨架。

一个半月以后,我在它上面补上了规则、对象关系、信号槽、状态机和战斗逻辑。它不再只是一张看起来像游戏的界面,而是一套真的能够自己运转起来的系统。

当然,它还有很多可以继续优化的地方。

但对我来说,这个项目最重要的意义,并不是做出了一款多么成熟的自走棋。

而是我第一次把一个复杂系统真正做完整了。

从静止的棋盘,到棋子认准方向,一步一步走向目标。


🔗 查看源代码 (GitHub)


Thanks for reading!

MiniArena 自走棋

2026年07月23日
3869 字 · 14 分钟
加载中...

评论 (需 GitHub 账号登录)

正在加载评论...