
五子棋这东西看起来简单十六根线、三百来个交叉点规则两句话就能说完。但真要在游戏引擎里把它做成一个能玩的实例从棋盘坐标映射到落子判胜再到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. 玩法扩展——从人人对战到接上AI6.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非常顺手动态创建和销毁节点的开销也完全不用担心。唯一要留神的是坐标换算和输入事件的细节这些东西官方文档通常不会写得太细只能靠自己实测。最后分享一个小技巧每次落子后无论是否判胜都在控制台输出一行[落子] row7 col7 playerBLACK这类的日志。看起来不起眼但在排查随机bug时这份日志就是你的时间线。主线逻辑越简单越容易在日志里发现异常。我做AI扩展时就是靠这行日志定位到了一次回调事件重入导致的重复落子。五子棋虽小流程五脏俱全做完这一遍你对引擎的掌握程度会上升一个台阶。