从一张不会动的棋盘,到一套真正跑起来的自走棋
MiniArena 是南京大学高级程序设计课的期末大作业。
这是一个基于 Qt6 的简易自走棋项目,也是我第一次独立完成一套模块比较多、状态关系比较复杂的游戏系统。
先把一件事说清楚。
这个项目并不是从空白文件开始写的。课程提供了一个基础框架,棋盘能够正常显示,主要 UI 区域也已经搭好,一些类和接口的骨架同样存在。
看上去已经像个游戏了。
但也只是看上去。
棋子站在棋盘上不会动,装备没有真正绑定到单位,羁绊不会计算,拖拽、升星、攻击、技能和存档都需要继续实现。框架给了我棋盘和 UI,我要做的,是让它真正活起来。
整个开发持续了大约一个半月,不过平时课程比较多,主要还是靠周末一点点推进。
我最开始以为,这份作业的难点应该是某个复杂算法。
真正做完以后才发现,最难的其实是让所有模块在同一套状态里正常协作。
先从看不见的规则开始
我的开发顺序有点反直觉。
我没有一上来就让棋子在棋盘上打架,而是先做了羁绊、装备和存档。
原因也很简单。
战斗画面看起来最直观,但如果棋子的属性、职业标签、装备归属和游戏状态都没有理清,后面的自动战斗只会变成一群数据混乱的小人在屏幕上互殴。
羁绊系统由 GameState 统一管理。
每个单位会携带两到三个职业或种族标签,例如战士、法师、精灵。recalculateSynergies() 会遍历场上仍然存活的单位,统计不同标签的数量,再根据二、四、六等阈值激活对应等级的加成。
场上有三个战士时,可以激活战士羁绊,为所有战士增加攻击力。场上有两个法师时,则会触发法师加成,提高技能伤害。
这里有一个很重要的限制。
死亡单位不参与统计。
所以只要一个关键单位死亡,它原本贡献的羁绊数量就会立刻消失。单位进入死亡状态时,会通知棋盘重新检查羁绊,相关加成也要同步撤销。
这意味着羁绊不是开局计算一次就结束了,而是会随着战斗不断变化。
装备则是独立的 Equipment 对象。
每个 Unit 内部维护一个 QVector<Equipment*>,最多可以持有三件装备。棋子的有效攻击力、生命值和速度,不直接修改基础属性,而是在读取时把装备加成计算进去。
绑定装备时,装备会记录自己的拥有者,然后进入棋子的装备列表。操作完成后再发出 statsChanged() 信号,让 UI 刷新对应的数值。
这种设计有一个好处。
棋子的基础属性仍然是干净的。
无论装备怎么穿戴、卸下,或者羁绊怎么变化,都不需要不断修改原始攻击力。最终属性可以根据当前状态重新计算,不容易出现反复叠加的问题。
存档系统反而是这一阶段最顺利的部分。
我需要保存棋子位置、星级、装备、羁绊状态、金币、生命值、回合数、商店和备战席。读取时再按照这些信息重建当前游戏状态。
它没有制造什么戏剧性的 bug,但它逼着我第一次认真思考,什么才是游戏里的真实状态。
屏幕上的贴图不是状态。
某个按钮显示成灰色也不是状态。
真正需要保存的,是棋子是谁、在哪里、属于谁、带了什么装备,以及当前回合走到了哪一步。
让棋子真的能被拿起来
完成规则层以后,我开始做拖拽部署。
Qt 自己提供了拖放机制,但从鼠标按下到棋子真正落到棋盘,中间还是要自己补全一整套流程。
拖拽开始时,我会记录棋子的来源。
它可能来自备战席,也可能来自棋盘上的另一个格子。除了棋子编号,还要把来源类型和原始位置一起写进 QMimeData。
等棋子被拖到目标格以后,再做三层判断。
第一层看格子本身是否合法。
障碍物不能放,已经被占用的格子也不能直接覆盖。
第二层看格子里是不是己方棋子。
如果是己方单位,就进一步判断双方是否同名、同星,并尝试触发三合一升星。
第三层看是不是敌方单位。
如果目标格属于敌方,直接拒绝这次拖放。
这里我没有每次都重新计算整个棋盘,而是直接使用格子初始化时保存的 m_terrain 和 m_occupied 元数据。拖拽落下时查一次就能知道是否合法。
真正麻烦的是合成。
三个同名同星棋子合成时,并不是把其中一个棋子的星级加一就完事了。我的实现方式是创建一个新的高星 Unit,再销毁三个旧对象。
新对象要继承目标格、部分装备和必要状态。旧对象则要断开信号,从 QGraphicsScene 移除,从单位列表删除,最后释放内存。
而且顺序不能反。
必须先记录旧棋子的格子和状态,创建并放入新棋子,再处理旧对象。如果先把旧棋子全部删掉,后面再访问 target->cell,拿到的就可能是一个已经失效的指针。
这部分后来还出现了一个很典型的联调问题。
升星以后,装备消失了。
单独测试装备系统时,穿戴和卸下都正常。单独测试三合一时,新棋子也能正确生成。可两个模块一组合,装备就不见了。
原因并不神秘。
升星创建的是一个全新的 Unit 对象,原来的装备还绑定在旧对象上。旧对象被销毁以后,装备按照析构逻辑退回背包,新棋子自然就变成了空装备。
这也是我第一次真正意识到,模块之间的接口不能只传一个星级和名字。
对象被重建时,哪些状态应该继承,哪些状态应该清空,哪些资源需要转移所有权,都必须明确规定。
单个函数没有写错。
错的是两个模块对于「合成之后还是不是同一个棋子」这件事,理解不一致。
当棋子开始自己做决定
完成部署和合成以后,项目才终于进入最像自走棋的部分。
自动战斗。
战斗由一个全局定时器统一推进。定时器每一帧调用所有存活单位的 update(),但具体要做什么,由每个单位自己决定。
每个棋子内部维护五个状态。
IDLE、MOVING、ATTACKING、CASTING 和 DEAD。
战斗开始时,棋子从空闲状态寻找目标。找到敌人以后进入移动状态,通过 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 骨架。
一个半月以后,我在它上面补上了规则、对象关系、信号槽、状态机和战斗逻辑。它不再只是一张看起来像游戏的界面,而是一套真的能够自己运转起来的系统。
当然,它还有很多可以继续优化的地方。
但对我来说,这个项目最重要的意义,并不是做出了一款多么成熟的自走棋。
而是我第一次把一个复杂系统真正做完整了。
从静止的棋盘,到棋子认准方向,一步一步走向目标。
