花了一个周末,把“五子棋”这种几乎人人都会玩的小游戏,用 Blazor 从零搭了出来,顺便把这两年攒的 UI 交互经验全砸了进去。这个项目的成品不复杂,也没有用什么黑科技,就是用纯 C# 写完一整条交互链路:棋子状态、落子规则、胜负判定、悔棋,甚至还能开一局简单的电脑对战。对于已经写过不少业务表单的 .NET 开发者来说,这类项目是最好的“技术体检” —— 你会一下子接触到组件通信、生命周期、异步渲染、算法边界这类平时写 CRUD 根本不会碰到的东西。不管是想练手 Blazor,还是准备在公司内部分享一个完整 Demo,这个五子棋小游戏都算是很稳的选择。
1. 项目拆解:五子棋到底需要哪些核心能力
动手写代码之前,我先把需求从头到尾捋了一遍。五子棋看着简单,真要实现成网页版,它背后至少涉及四个独立又互相咬合的模块。
1.1 规则本身比想象的更吃建模
五子棋的规则是“横竖斜任意方向先连成五子者胜”。因为这四个字,棋盘必须是一个二维网格,默认 15×15 比较标准。游戏要有两个玩家,轮流落子,黑先白后。棋盘上每个交叉点有三种状态:空、黑子、白子。这个建模直接用枚举就能搞定,干净利落:
public enum ChessPiece { None = 0, Black = 1, White = 2 }真正的复杂度不在于数据长什么样,而在于“落子之后系统要怎么知道这局结束了”。新手最容易犯的错误是把胜负判定放在每次点击时全盘扫描,这样不是不行,但效率低、代码绕。正确的做法是只检查“刚刚落下那颗棋子”的四个方向:横、竖、左上到右下、右上到左下。每个方向往两头延伸数连续同色棋子,总数大于等于 5 就结束。这个优化能让你把判定逻辑从 O(N²) 降到 O(1) 量级,对 15×15 的小棋盘来说差别可能不明显,但思路值得养成习惯。
1.2 交互体验是网页游戏的隐形门槛
桌面棋盘游戏变成网页应用之后,交互的细节反而比算法更影响观感。落子要落到交叉点上,棋子要区分黑白,点击要反馈到正确的格子上,最后一手最好有个标记方便玩家复盘。这些全是 UI 层的功夫。Blazor 的组件模型很适合应对这类交互,因为每一次点击都对应一个组件的状态变化,不需要像传统前端那样手动操作 DOM。
1.3 单机版一定要有“电脑”才完整
如果只做双人同屏对战,这个项目的受众会很窄。加一个简单的 AI 对手,整个项目的趣味性和技术含量都会上一个台阶。实现 AI 有很多层次,最复杂的是蒙特卡洛树搜索和深度神经网络,但那明显超出小游戏范畴。我选择的是经典的启发式评估函数,核心逻辑是给每个空位置打分,分数由“这个位置对己方的价值”和“这个位置对敌方的威胁”两部分加权组成。这个方案代码量不大,但已经足够让普通玩家感觉到“电脑会堵我”。
2. 棋盘渲染与组件化设计:让 UI 层可读又可维护
UI 层如果全部堆在一个页面里,代码会迅速腐化。我花了不少时间思考组件怎么拆分,最终确定成三层结构:页面组件管整体状态,棋盘组件管布局,格子组件管单点渲染。
2.1 数据模型与渲染模型要分开
先用一个二维数组存棋盘状态,这是“模型”;再考虑怎么把这个数组展示到浏览器上,这是“视图”。两者不能搅在一起。我定义了一个 GameState 类来承载所有数据相关的逻辑:
public class GameState { public ChessPiece[,] Board { get; private set; } public ChessPiece CurrentPlayer { get; private set; } public bool GameOver { get; private set; } public ChessPiece Winner { get; private set; } public Stack<(int Row, int Col, ChessPiece Piece)> MoveHistory { get; private set; } public GameState() { Board = new ChessPiece[15, 15]; CurrentPlayer = ChessPiece.Black; MoveHistory = new Stack<(int, int, ChessPiece)>(); } }这个类专门负责“状态怎么变”,完全不关心界面。比如 PlacePiece 方法里只做三件事:判断位置是否合法、落子、切换当前玩家。至于界面要不要高亮最后一手、要不要弹结束提示,那是组件层的事。这种分层的好处是,将来如果要从双人模式扩展到联机对战,逻辑层几乎不用动。
2.2 棋盘 UI 用 CSS Grid 最合适
渲染棋盘时,我第一反应是用 HTML 表格,但表格的边框、间距处理起来太麻烦。后来改用 CSS Grid,一行代码就能生成整齐的 15×15 网格:
.board { display: grid; grid-template-columns: repeat(15, 32px); grid-template-rows: repeat(15, 32px); gap: 0; width: fit-content; background: #c8a876; border: 4px solid #7a5230; user-select: none; }每个格子是一个 div,尺寸固定 32 像素,内部再用伪元素画棋子。这种方案的扩展性很好,想调棋盘大小,改 grid-template 的数值就行。棋子的样式用了一个小技巧:把黑子和白子的背景色、内阴影写到 CSS 类里,切换状态时只需要切换 class,不用频繁操作样式。这在 Blazor 里就是一行代码的事:
<button class="cell @GetPieceClass()" @onclick="HandleClick" disabled="@_isDisabled"></button>2.3 组件层级不能太深,也不能太浅
Blazor 组件拆得太细,参数传递会变得像踢皮球一样绕来绕去;拆得太粗,一个文件里几百行代码又没人愿意维护。我的方案是三层:
最外层是 Game.razor,负责持有 GameState 实例,处理“新建游戏”“悔棋”“切换模式”这些全局操作。中间层是 BoardView.razor,接收二维数组和回调方法,渲染出整个网格。最内层是 CellView.razor,只接收一个格子的状态,处理单次点击。这样每一层职责单一,调试的时候脑子也清楚。唯一要注意的是回调参数类型:内层组件不能直接引用外层组件实例,而是应该通过 EventCallback 把事件抛出去。EventCallback 在 Blazor 里是专门用来做跨层通信的机制,它能安全地触发外层组件的状态刷新,比手动传委托省心得多。
2.4 组件少不代表不需要关心渲染性能
15×15 也就 225 个格子,对 Blazor 来说压力不大,但这并不意味着可以横行无忌。我见过有人把每个格子都做成独立组件,再给每个组件传一个复杂的对象做参数,结果每次落子时 225 个组件全都要走一遍 diff。这里关键的优化点是:让格子组件的参数尽量简单,最好是枚举、布尔、字符串这种轻量级值类型,并且实现 IComponent 接口的 ShouldRender 方法,让没有变化的格子直接跳过渲染。实践下来,落子响应速度能保持在非常流畅的水平。
3. 核心逻辑实现:落子、胜负判断、悔棋一鼓作气
逻辑层是这个小游戏的灵魂,代码量不大,但每一段都要想清楚边界条件。我按功能拆成四个方法,分别处理落子、判胜、悔棋和重置。
3.1 落子逻辑不能只做“填坑”
落子看起来就是“把数组某个位置改成当前玩家”,实际操作时要做三件事:第一,判断这个位置是否还在棋盘范围内;第二,判断这个位置是不是已经被占;第三,判断当前是否轮到玩家操作。前两个是显而易见的边界,第三个经常被忽略。比如游戏结束后如果还允许点格子,界面上就会出现“胜者已定但棋盘还在变”的诡异状态。我在 HandleClick 方法里把所有校验先做掉,任何一个不满足就直接 return,不产生任何副作用:
public bool TryPlacePiece(int row, int col) { if (row < 0 || row >= BoardSize || col < 0 || col >= BoardSize) return false; if (Board[row, col] != ChessPiece.None) return false; if (GameOver) return false; Board[row, col] = CurrentPlayer; MoveHistory.Push((row, col, CurrentPlayer)); return true; }这个方法返回 bool,组件层调用后再决定要不要切换玩家、检查胜负。把纯逻辑和界面刷新拆开,后面写单元测试会非常舒服。
3.2 胜负判定怎么做到又快又稳
判定胜负是整个项目里最需要抠细节的地方。我的实现思路是:拿到最后一手棋的行列坐标,沿着四个方向分别扫描。每个方向都从当前位置出发,先往一个方向数,再往反方向数,把两端连起来的数量相加。这个数量包含当前位置本身,所以只要连续同色棋子数大于等于 5,就判定该颜色获胜。
private bool CheckWin(int row, int col, ChessPiece piece) { var directions = new (int Dr, int Dc)[] { (0, 1), // 横向 (1, 0), // 纵向 (1, 1), // 右下 (1, -1) // 左下 }; foreach (var (dr, dc) in directions) { int count = 1; for (int i = 1; ; i++) { int nr = row + dr * i; int nc = col + dc * i; if (!InBounds(nr, nc) || Board[nr, nc] != piece) break; count++; } for (int i = 1; ; i++) { int nr = row - dr * i; int nc = col - dc * i; if (!InBounds(nr, nc) || Board[nr, nc] != piece) break; count++; } if (count >= 5) return true; } return false; }这里我特意只写了四个方向而不是八个,因为一个方向的“正向”和“反向”已经由两次循环覆盖了。用元组数组保存方向向量,代码结构清晰,以后想扩展成“六子棋”也只要往数组里加方向。
3.3 悔棋的复杂之处在“历史记录”怎么设计
悔棋功能难在“悔棋之后还能不能悔多次”以及“悔棋后当前玩家该怎么切换”。我的方案是直接用 Stack 保存每一步的信息,悔棋时弹出栈顶,把对应的格子清空,再把当前玩家设置为被弹出的那一步的落子方。这样无论悔多少次,逻辑都不会乱。唯一需要额外处理的是:如果棋局已经结束,悔棋要把“游戏结束”状态撤销,让玩家能继续操作。
public bool Undo() { if (MoveHistory.Count == 0) return false; var (row, col, piece) = MoveHistory.Pop(); Board[row, col] = ChessPiece.None; CurrentPlayer = piece; GameOver = false; Winner = ChessPiece.None; return true; }3.4 重置游戏要清理所有状态
重置没有技术难点,但要防止遗漏。需要重置的不只是棋盘数组,还有当前玩家、游戏结束标志、胜者、历史栈。我一开始漏了清空 MoveHistory,结果新对局一悔棋,直接把上一局的棋子弹出来了,排查了半天才反应过来。建议重置逻辑不要手动逐字段赋值,而是直接 new 一个新的 GameState 实例赋值给原变量,一劳永逸,还能顺手把内存里旧数据回收掉。
4. 电脑对手:用一个“评分函数”撑起简单 AI
AI 部分是我觉得最有意思,也最容易越做越上头的环节。真正能用、又有学习价值的方案不是堆深度搜索,而是设计一个棋型评估函数。
4.1 从“人怎么下棋”倒推 AI 策略
人玩五子棋时会思考两件事:我这手棋能不能形成威胁,这手棋能不能挡住对方的威胁。落到程序里,就是给每个空位打分。我设计了两个分值表:攻击分和防守分。攻击分表示“如果我下在这个位置,能帮自己形成多有利的棋型”;防守分表示“如果我下在这个位置,能多大程度破坏对方的棋型”。最终得分是两者加权求和。防守分通常要略高于攻击分,因为五子棋里“防守”稍慢一步就会输掉全局。
评分的时候不能只看紧挨着的几个棋子,要沿着四个方向分别统计。每个方向的统计方法是:从当前空位向某个方向走,记录连续有多少个己方棋子,再看末端是否为空(也就是还有没有延伸空间)。这个统计结果可以生成一个棋型描述,比如“活三”“冲四”“眠三”。不同的棋型对应不同的分值:
| 棋型 | 含义 | 参考分值 |
|---|---|---|
| 五连 | 已经形成五子 | 100000 |
| 活四 | 两端都开放的四连 | 10000 |
| 冲四 | 一端封闭的四连 | 1000 |
| 活三 | 两端开放的连续三子 | 500 |
| 眠三 | 一端封闭的三连 | 100 |
| 活二 | 两端开放的连续两子 | 50 |
4.2 让 AI 既会进攻又会防守
如果 AI 只给自己打分,而完全不看对方的棋型,它就会一直傻乎乎地构建自己的连线,对敌人的“冲四”视而不见。所以我在 AI 决策函数里做了两步:先遍历所有空位,计算每个位置对当前玩家的价值;再遍历一次,计算每个位置对对方玩家的价值。最终的决策分是“己方得分加上 1.2 倍的对方得分”。也就是说,AI 看到两步之外有一手能挡住敌方四连的棋,即使威力一般也会优先去堵。
public (int Row, int Col) GetAiMove() { int bestScore = int.MinValue; var bestMove = (-1, -1); for (int r = 0; r < size; r++) { for (int c = 0; c < size; c++) { if (board[r, c] != ChessPiece.None) continue; int attack = EvaluatePosition(r, c, ChessPiece.Black); int defense = EvaluatePosition(r, c, ChessPiece.White); int total = attack + (int)(defense * 1.2); if (total > bestScore) { bestScore = total; bestMove = (r, c); } } } return bestMove; }这段代码把“攻守平衡”直接显式化。如果想让 AI 更难对付,可以尝试把“己方得分”和“对方得分”的计算方式分开,给“活三”之类的棋型设更高权重,让 AI 更倾向于主动进攻。但从实际体验来看,简单模型已经足够打败大多数随手玩玩的玩家。
4.3 为什么这个 AI 不搜索未来棋局
很多人第一反应是做 Minimax 或 Alpha-Beta 剪枝,让 AI 模拟未来几步的棋局。这个方案的效果确实更好,但复杂度也高得多。评估函数我已经写出来了,再嵌套搜索树就要同时处理搜索深度、走法生成、剪枝条件、评估时机四个问题,对小项目来说很容易失控。如果真想升级 AI,我建议不要推翻重写,而是在现有评分函数外面套一层有限深度的搜索,比如搜索两步,这样既保留现有逻辑,又能让 AI 更有前瞻性。“小步快跑”比“一步到位”更容易做出稳定的产品。
5. 常见问题与排查技巧实录
项目里踩过的坑,我整理成了一份“避坑清单”,每一条都是真实发生过的。
5.1 落子后界面不刷新,怎么回事
这是新手遇见的第一个 Blazor 问题。原因几乎都是忘了在事件处理函数里调用 StateHasChanged,或者组件位于不应刷新的分支里。Blazor 的自动刷新机制只对组件内部的事件生效,如果你手动从外部改变了状态,必须显式触发刷新。排查路径是先确认事件确实被触发了,再确认状态确实变了,最后确认渲染分支没被 if 遮挡。我建议所有状态变更统一走方法调用,不要各处直接改属性,这样排查时思路会很清晰。
5.2 点击事件绑定在 button 上,却经常触发两次
这个问题大多是因为 button 嵌在了另一个带点击事件的父元素里,事件冒泡导致父子和子组件各触发一次。Blazor 的 @onclick 默认是冒泡的,如果你想只处理子元素的点击,可以给父元素的事件加上 @onclick:stopPropagation="true"。还有一种情况是按钮没有设置 type="button",在表单里默认成了 submit,每次点击都会触发表单提交,连带页面刷新。
5.3 高亮最后一手总是对不准格子
最后一手的高亮圆圈,位置是基于棋子坐标算出来的,看起来“歪了”通常是因为 CSS 里棋子的定位和格子的 padding、边框没有对齐。最保险的做法是:格子本身的 padding 设为 0,棋子绝对定位在格子的正中心,用 transform: translate(-50%, -50%) 偏移。这样无论棋盘怎么缩放,标记都能精准贴合。
5.4 Blazor Server 模式下的“连接中断”提示
如果选的是 Blazor Server 模式,网络波动时浏览器会出现重新连接的提示条,游戏状态可能暂时不可用。这不是代码 bug,但体验确实有点突兀。处理办法是在 appsettings 里调大 KeepAliveInterval 的值,同时在界面监听 circuit 事件,显示一个友好的“正在连接”遮罩。如果只是做内部演示,更省事的方案是直接切换到 Blazor WebAssembly 模式,状态全在浏览器本地,天然离线可用。
5.5 用索引循环渲染格子,更新时错乱
在 Razor 里用 for 循环渲染一组带状态的子组件,并且列表中某项状态变化时,整个列表的渲染顺序可能和预期不一致。原因是 Blazor 的 diff 算法默认按位置匹配变化,而循环的 key 又是索引,数据一旦变更就容易让组件错位。解决办法是给循环项加一个稳定的 @key,比如行和列的拼接值,让 Blazor 能精确识别每一个格子的身份。这个小改动对渲染优化非常有效。
6. 从“能用”到“好用”:我后续想继续做的扩展
项目做到能玩的程度只算完成了 60%,剩下的是体验打磨和扩展空间。
6.1 联机对战让同一套代码产生双倍价值
现在的双人模式是共用一台电脑轮流操作,如果再加上联机功能,五子棋的项目价值立刻翻倍。Blazor Server 对这件事极其友好,因为服务端天然保存着所有客户端连接的状态,你只需要把落子消息广播给房间里的另一个玩家就行。实现上用 SignalR 广播一个包含行、列、玩家的消息对象,另一个客户端收到后调用同一个“落子”逻辑即可。核心逻辑层几乎不用改动,这是一个成本极低的扩展。
6.2 保存棋谱和复盘功能能显著提升体验
下了棋总想回看自己输在哪一步。实现复盘功能,核心思路是记录整局棋的所有落子坐标,每步后再额外记一个时间戳。复盘时按时间轴逐步回放,回放逻辑和悔棋逻辑共用一套数据,只是把“改数组”换成了“显示某一步的棋盘”。这类功能的好处是不需要新增任何算法,纯靠数据驱动,还是很适合作为练手内容的。
6.3 给 AI 加不同难度级别
现在的 AI 是单一难度,想让它更贴近不同玩家的水平,可以给评分函数加入“搜索深度”和“随机扰动”两个参数。低难度时 AI 以固定比例随机选一个次优解落子;高难度时增加搜索深度,并且在分值相差不大的情况下选择靠近中心的位置。这种实现方式比准备两套模型简单得多,效果却很直观。
我在实际开发中最大的感受是:五子棋这个项目虽然叫“小游戏”,但几乎包含了做任何交互式 Web 应用都会遇到的核心问题:状态管理、组件通信、渲染性能、边界条件、策略算法。把这些问题在一个项目里过一遍,比刷几遍文档有用得多。如果你也想练手,可以先从双人对战模式开始,做完再逐步加 AI、加联网。每加一个功能,都会逼着你重新审视现有的架构是否还能经得起折腾,这种“边做边改进”的体验,本身就是进阶最快的路径。