五子棋这东西,看起来简单,十六根线、三百来个交叉点,规则两句话就能说完。但真要在游戏引擎里把它做成一个能玩的实例,从棋盘坐标映射到落子判胜,再到AI对战甚至网络对战,每一环都有自己的门道。我这次用开维游戏引擎完整实现了一局五子棋,踩了不少坑,也整理出一套比较顺手的写法,正好拿出来聊聊。
这个实例适合刚接触开维引擎的开发者,也适合想用一个小项目验证引擎能力的团队。通过完整走一遍“数据模型 — 场景渲染 — 输入交互 — 逻辑判定”这条链路,你会发现引擎提供的高层抽象帮我们省了大量脏活,但真正容易翻车的地方,反而是棋盘坐标和胜负判定这些看似基础的逻辑。
1. 项目设计与技术选型——为什么用开维引擎做五子棋
1.1 核心需求拆解
动手之前,先把五子棋需要的能力拆开。最基础的是棋盘渲染,15x15的交叉点要画得横平竖直,棋子落点准确落在交叉点中心。接着是交互,鼠标点下去,系统要能判断点到了哪个点,并且此刻该不该落子。然后是规则,黑先白后、不能下到已有棋子的位置、五连即胜,这些逻辑必须独立于渲染层,否则后续想加人机AI会非常痛苦。
我把这套拆成四个模块:棋盘数据模型、棋盘渲染、输入控制、胜负判定。其中输入控制和胜负判定是重头戏,而开维引擎的事件分发和场景节点机制,恰好能帮我们把每一块都封装得很干净。
1.2 开维引擎的适用特性
选开维引擎做这个实例,不是因为它的渲染多华丽,而是它的架构对这类“逻辑+渲染”混合的小游戏非常友好。它用场景树管理所有节点,棋盘是节点、棋子是节点、UI提示也是节点,节点之间的父子关系天然符合现实逻辑。比如我把所有棋子节点挂在一个pieceRoot下,要清盘时直接销毁这个节点下的所有子节点就行,不用逐个遍历自己对棋子对象的引用。
另外,它的输入系统支持监听屏幕坐标点击,并且提供了hitTest这类拾取接口。虽然五子棋的棋子不是必须用碰撞体,但我在开发中发现,把棋盘网格的“交叉点”用不可见的透明碰撞体挂上,就能把“点击到误差范围内”的逻辑交给引擎处理,代码会干净很多。
提示:引擎的架构决定了代码组织方式。如果你是第一次用这个引擎,建议先花一小时跑一遍官方示例,理解场景树节点生命周期和事件绑定方式,再动工写五子棋。
2. 棋盘数据模型与渲染方案——先把棋盘变成代码
2.1 二维数组与坐标映射
五子棋棋盘逻辑上就是二维数组,15行15列,我用一个board[15][15]保存状态,0表示空,1表示黑棋,2表示白棋。这个数据结构是整个游戏的“真相”,所有渲染、判胜都必须以它为准,绝不允许多套数据源。
真正需要花心思的是屏幕坐标和逻辑坐标的映射。开维引擎的UI坐标或者世界坐标,我习惯统一用浮点数,棋盘左上角第一个交叉点定在(startX, startY),格子间距为gridSize。那么第row行、第col列的交叉点世界坐标就是:
float x = startX + col * gridSize; float y = startY + row * gridSize;反过来,点击时把屏幕坐标转成世界坐标,再计算最近交叉点索引:
int col = (int)std::round((worldX - startX) / gridSize); int row = (int)std::round((worldY - startY) / gridSize);这里的round很关键。我一开始用的int强转,导致棋子在点击线以上的区域时老是落到上一格,整局棋歪歪扭扭。后来改成四舍五入,配合落点距离校验,基本就稳了。
2.2 棋盘线条与背景渲染
棋盘的视觉层我用的是引擎的Shape组件,直接画15条横线和15条竖线。这里的线条宽度和颜色要注意,我用深褐色#704214,宽度2像素,在浅黄背景上看着很舒服。棋盘外围留一圈边距,中心点画一个小圆标记,这些都是纯静态节点,只需要渲染一次。
棋子我用圆形的Shape节点,黑棋填充纯黑,白棋填充纯白,边缘加一层淡淡的灰色描边,这样落子后棋子边缘和棋盘线之间会有细微的立体感。很多初学者忽略棋子半径和格子间距的比例,导致棋子过大互相压到,或者过小显得稀疏。我实测下来,棋子直径设为gridSize * 0.86最合适,两颗子之间能看清间隔,又显得棋盘紧凑。
2.3 为什么把棋盘和棋子都放进场景树
开维引擎的渲染顺序和场景树深度有关。我把棋盘画布节点放在最底层,棋子节点放在它上面pieceRoot层,UI提示标签放在最顶层。这样每一次落子都是往pieceRoot里挂一个新的子节点,层级天然不会乱。
如果你直接用引擎的全局绘制接口在每一帧重画所有棋子,不仅效率低,而且很容易出现闪屏。反过来,每个棋子作为一个独立节点,引擎只会在创建时渲染一次,后续无需频繁刷新,这对五子棋这类低交互频率的棋类游戏来说是最合理的方案。
3. 交互与落子流程实现——让鼠标点得准,落得稳
3.1 鼠标拾取与坐标换算的完整链路
开维引擎的输入回调会给你屏幕坐标screenX, screenY,第一步先把屏幕坐标转成世界坐标。如果你用的是相机,需要调用相机的screenToWorld;如果像我一样直接用UI画布,坐标本身就是画布坐标,少一层转换。
拿到世界坐标后,先按上面的公式算出候选索引row, col。这时候千万别急着落子,要做距离校验。因为玩家可能点在两个交叉点中间,我们需要容忍一定的误差。我设置的最大容忍距离是gridSize * 0.4,也就是格子间距的40%。如果实际点击点与最近交叉点中心的距离超过这个值,就不响应,避免玩家稍微点歪一点就落到不合预期的地方。
bool tryPlace(int row, int col, float worldX, float worldY) { float centerX = startX + col * gridSize; float centerY = startY + row * gridSize; if (distance(worldX, worldY, centerX, centerY) > gridSize * 0.4f) return false; if (board[row][col] != 0) return false; placePiece(row, col, currentPlayer); return true; }3.2 回合控制与非法落子拦截
五子棋必须有明确的回合状态。我用一个currentPlayer变量,1代表黑方先手,2代表白方。每次成功落子后立刻切换:
currentPlayer = (currentPlayer == 1) ? 2 : 1;这里有个容易被忽略的细节:切换回合的逻辑必须紧跟落子,不能放在胜负判断之后。因为玩家第五子落下去形成五连时,我们仍然要先确定“这局结束了”,然后显示胜者,这时候currentPlayer应该是刚落子的那一方,而不是切换后的下一方。我通常是落子后先切回合,再用落子前的玩家去判胜:
void onPlacePiece(int row, int col, int player) { board[row][col] = player; currentPlayer = (player == 1) ? 2 : 1; if (checkWin(row, col, player)) { showWinner(player); } }3.3 落子反馈与视觉动画
落子之后,我在pieceRoot下动态创建一个棋子节点。如果引擎支持补间动画,我建议加一个非常轻量的缩放效果:棋子从0.6倍缩放到1.0倍,时长100毫秒。这个效果成本极低,但会让操作手感变得非常跟手,玩家能明确感知“这颗子已经放上去了”。
我还放了一个小标签显示当前轮到谁,背景用一个半透明面板。这个标签的渲染层级要高于棋盘,但不能高过落子动画,否则动画过程会被UI遮住。
注意:不要在
update循环里每帧判断输入。开维引擎更推荐你仅监听点击事件回调,在回调里处理一次落子逻辑。如果你在每帧轮询鼠标状态,容易造成一帧内重复触发的bug。
4. 胜负判定算法与细节——五连棋形的正确姿势
4.1 四方向扫描,而不是全盘扫描
最直观的判胜方法是全局扫描每个位置,看有没有五连。但这样做既浪费,又容易出边界问题。我采用的策略是:每一子落下后,只从该子出发,朝四个方向各检查一遍。四个方向分别是水平、垂直、两条对角线,每个方向又分正负两个走向,所以实际上是八次线性扫描。
方向向量如下:
- 水平:(0, 1)
- 垂直:(1, 0)
- 主对角线:(1, 1)
- 副对角线:(1, -1)
检查函数很直白:
bool checkDirection(int row, int col, int dr, int dc, int player) { int count = 1; for (int i = 1; i < 5; i++) { int r = row + dr * i, c = col + dc * i; if (r < 0 || r >= 15 || c < 0 || c >= 15 || board[r][c] != player) break; count++; } for (int i = 1; i < 5; i++) { int r = row - dr * i, c = col - dc * i; if (r < 0 || r >= 15 || c < 0 || c >= 15 || board[r][c] != player) break; count++; } return count >= 5; }注意,这里的dr, dc是方向步长,正负方向都搜,count从自身1开始累加。只要四组方向里有一组count达到5,就说明赢了。
4.2 边界处理与数组越界的坑
这段代码最容易出错的其实是边界。很多初学者会认为,只要从落子点出发,沿四个方向搜索,每方向最多查4次,就不会越界。但实际上,如果棋子靠近棋盘边缘,例如第14行,那么row + i可能等于15,数组索引就越界了。必须在访问board[r][c]之前先检查行列范围。
另外要注意,count >= 5而非count == 5。因为如果棋盘上出现六连,从中间落子角度扫出的数量会超过5,用等于5就会漏判。虽然规则上五连即胜,但六连也应当被正确识别为胜利。
4.3 和棋判断与“活四”“冲四”无关
很多人写五子棋AI时会把活四、冲四、活三这些棋形概念带入胜负判定,这是误区。胜负判定只认“是否出现五枚连续同色棋子”,至于这个五连是被堵住还是开放,完全不用管。我在初版也做了一套棋形分析函数,后来发现纯属炫技,还容易引入bug。对于一个人人对战的五子棋实例,简单线性扫描足够。
和棋的判断独立于胜负。每一手落完后,检查当前落子数,如果棋盘已满而没人获胜,就宣布和棋。我维护了一个moveCount变量,每次落子加一,moveCount == 225时进行和棋判断。
5. 常见问题与调试技巧实录——我踩过的那些坑
5.1 棋子为什么总是错位半个格
如果你发现棋子落点偏移,九成是坐标映射用了截断而不是四舍五入。int col = (worldX - startX) / gridSize得到的是向下取整,点击点在格子中间偏左时,会落到前一格。改成std::round之前,我一度以为棋盘间距计算错了,后来又怀疑是引擎坐标系原点问题,排查了好久,最后还是对照输出了两次坐标换算结果才定位到。
这里有个调试技巧:在点击回调里打印“世界坐标、计算出的行列、交叉点中心坐标”三者。正常情况差值应该在0.1以内。如果误差稳定在半个格子左右,果断检查取整方式。
5.2 一次点击落了两个子
这个问题通常来自事件重复触发。开维引擎的鼠标事件在鼠标抬起和按下都会回调,如果你同时监听了按下和抬起,又各执行了一次落子逻辑,自然就会一下落两颗。
我的解决策略是只监听click事件,或者在回调中加一个lastPlaceTime记录,两次落子间隔低于50毫秒就忽略。对于五子棋这种对即时性要求不高的游戏,最简单直接的方案就是只监听一次点击事件,并且把落子逻辑做成幂等操作——先判断board[row][col] == 0再落子。
5.3 先手标记与悔棋功能
我还加了悔棋功能。引擎场景树的好处在这里体现得特别明显。悔棋时,我从pieceRoot的子节点列表中取出最后一个节点删除,同时把board[lastRow][lastCol]恢复为0,并把currentPlayer回退到上一手。这一步看似简单,但需要注意:pieceRoot的子节点顺序就是我们落子的顺序,前提是你创建棋子节点后不要做排序操作。如果你不小心在其它渲染层逻辑中重排了节点,悔棋的顺序就会错乱。
我给每个棋子节点额外挂了一个自定义数据字段,保存它的行号和列号。悔棋时,先读最后一个节点的行列信息,再删节点,再更新数组,这样不会出现“删了节点但数组还留着”的状态。
5.4 帧率与逻辑频率的取舍
五子棋不需要高帧率,每秒30帧完全足够。但我在做动画时发现,开维引擎的默认帧率是60,导致100毫秒的补间动画经常出现跳帧。后来我检查引擎文档,把渲染帧率调成60,逻辑固定更新间隔固定为1/30秒,动画反而更平滑。这其实是因为逻辑更新和渲染更新解耦后,动画插值更精确了。
如果你做的是纯逻辑游戏,建议把逻辑堆在固定更新回调fixedUpdate里,渲染动画放在update里。这样即使某几帧渲染卡顿,逻辑依然按固定步调走,不会因为操作间隔不一致导致误判。
6. 玩法扩展——从人人对战到接上AI
6.1 让电脑会下棋的最低成本方案
做完人人对战,下一步通常是接一个AI对手。最简单也够用的是“贪心打分法”:遍历棋盘每个空点,对每个点分别计算四个方向上的棋形分数,选择分数最高的点落子。不需要搜索博弈树,一个函数就能跑起来。
打分表可以参考:活三得100分,冲四得5000分,活四直接10000分,如果对方在这点能连五则要优先堵。实际操作时,我会用两个分数值,先算电脑自己的进攻分,再算玩家的防守分,总分为两个分数之和,并适当给进攻分加权。
int evaluatePoint(int row, int col, int me, int opponent) { int attackScore = calculateScore(row, col, me); int defendScore = calculateScore(row, col, opponent); return attackScore * 1.1f + defendScore; }这样写出来的AI棋力虽然打不过高手,但足够撑起“人机对战”的初级关卡,而且完全跑在逻辑层,不需要动渲染层。
6.2 做AI时重新审视数据模型
写AI的过程中,我越发体会到最初用二维数组的好处。开维引擎的节点系统虽然方便渲染,但AI算法需要频繁扫描棋盘状态,如果用场景节点来遍历棋子,性能和代码复杂度都会爆炸。棋盘数组是逻辑核心,渲染节点只是它的投影,这个边界一定要守住。
后面我还扩展了“复盘回放”功能,把所有落子记录存成一组(row, col, player)数组,回放时逐条删除原来棋子节点,再按新棋谱重新创建。因为渲染节点始终以数组为准,回放只是一次数据的重新“注入”。
6.3 局终结算与皮肤切换
局终显示我用了一个简单的全屏半透明面板,上面显示“黑方胜利”或“白方胜利”,下面两个按钮“再来一局”和“返回大厅”。面板作为UI节点挂在场景树最上层,点击时吞掉所有下层的输入事件,避免玩家在结算时还误点到棋盘。
另外代码里我把棋盘格子间距gridSize抽成了全局常量,后来想换棋盘皮肤时,只需要改颜色参数和背景贴图,完全不用动落子逻辑。很多项目做到后期才发现这些硬编码参数有多痛苦,不如一开始就把棋盘尺寸、边距、间距、棋子半径全部做成配置项。
7. 实例复盘与个人心得
这套五子棋实例从零到完整跑通,我一个人大概用了两天,其中半天在调坐标映射,半天在调事件重复触发。回过头看,最大的体会不是引擎功能多强大,而是“逻辑层和渲染层分离”这件事在做游戏时多么重要。五子棋算是规则极简的游戏,但如果一开始就把棋盘状态直接做成场景节点,后面加悔棋、AI和回放都会寸步难行。
开维引擎在这个实例里表现得很稳,场景树管理棋子和UI非常顺手,动态创建和销毁节点的开销也完全不用担心。唯一要留神的是坐标换算和输入事件的细节,这些东西官方文档通常不会写得太细,只能靠自己实测。
最后分享一个小技巧:每次落子后,无论是否判胜,都在控制台输出一行[落子] row=7 col=7 player=BLACK这类的日志。看起来不起眼,但在排查随机bug时,这份日志就是你的时间线。主线逻辑越简单,越容易在日志里发现异常。我做AI扩展时,就是靠这行日志定位到了一次回调事件重入导致的重复落子。五子棋虽小,流程五脏俱全,做完这一遍,你对引擎的掌握程度会上升一个台阶。