如果你是个平时写 C# 的开发者,想快速上手一套不用碰 JavaScript 的 Web 前端方案,Blazor 是目前最顺手的选项之一。这个五子棋小游戏是我拿 Blazor 练手的第一个完整项目,棋盘不大、规则清晰,但组件传参、事件绑定、状态刷新、胜负算法这些核心环节一个不少,拿来入门特别合适。
这篇博文会把整个项目从头拆一遍:为什么选 Blazor、棋盘数据怎么设计、胜负判定怎么写效率更高、事件绑定有哪些隐蔽的坑、发布体积怎么优化。无论你是刚接触 .NET 的前端转行者,还是想找个项目验证 Blazor 能力的后端老手,照着这篇都能直接动手做出来。
1. 项目整体设计与思路拆解
1.1 为什么选 Blazor 写五子棋
Blazor 最大的特点是让 C# 能直接跑在浏览器里,这对 .NET 技术栈的团队来说意义很大。传统的前后端分离方案意味着后端写 C#、前端写 JavaScript/TypeScript,两套语言两套心智模型,一个人很难兼顾。Blazor 把这条缝补上了一大半,从 API 层到 UI 组件、从状态管理到业务逻辑,全部用同一套 C# 代码表达,代码可以复用给后端服务,也可以在客户端直接跑。
用五子棋来练手 Blazor,是因为这个项目的负载特征太典型了。一个 15×15 的棋盘一共只有 225 个格子,交互是低频的点击事件,没有高频的画面重绘需求,不需要 Canvas、不需要 WebGL,用 DOM 渲染就足够流畅。这种场景下,Blazor 每次事件后自动触发组件渲染的机制反而成了优势,完全不用手动控制刷新节奏。
还有托管模型的选择。Blazor 有两种:Server 模式保持 SignalR 长连接,组件的状态在服务端维护,适合对数据安全要求高、需要对内部系统做快速界面化的场景;WebAssembly 模式把 .NET 运行时和程序集下载到浏览器里执行,UI 响应更快,部署也更简单,就是一个静态站点。五子棋这种纯客户端交互的游戏,选 WebAssembly 模式是更合适的,把项目发布到任意静态文件服务就能玩。
1.2 五子棋需求拆解
动手写代码前,先把需求列清楚。标准五子棋的规则:15×15 棋盘、黑白双方轮流落子、黑棋先手、任意一方在水平/垂直/两个斜方向上连成五子即获胜。对于这个练手项目,我确定的核心功能有四块:
- 棋盘显示与落子点击:玩家点击空位,在对应位置放置当前玩家的棋子。
- 回合切换:每次有效落子后交换玩家,并在界面上显示当前轮到谁。
- 胜负判定:每次落子后立刻检查是否形成五子连珠,发现胜局则结束游戏。
- 重新开始:游戏结束后或对局中途,可以一键重置棋盘。
至于进阶功能,比如悔棋、计时、人机对战、禁手规则,全部放到扩展阶段去考虑。第一版先把基础流程跑通,避免一开始就把项目搞复杂。实际做下来,这个取舍是对的,核心路径通了,后续加功能都很顺。
这个项目适合用来练什么?组件拆分、父子组件通信、参数传递、CSS 样式绑定、纯 C# 业务类的单元测试能力。这些是 Blazor 开发中最高频的能力点,通过一个小游戏全部覆盖,比单纯看文档学得快得多。
1.3 组件与逻辑分离的设计
我一开始就把代码分成两层:纯逻辑层和 UI 层。逻辑层是一个独立的 C# 类GameState,它只负责维护棋盘数组、落子、判胜,不依赖任何 Blazor API。UI 层则是 Razor 组件,负责把状态渲染成页面,接收用户点击后调用逻辑层的方法。
这样设计有几个实际收益。第一,GameState可以脱离浏览器环境做单元测试,把棋盘放进去、模拟落子、断言胜负状态,验证起来非常快。第二,UI 层始终保持很薄,每个方法体只有几行,出问题一眼就能定位。第三,如果以后想把游戏扩展成服务端对战,比如用 SignalR 同步双方棋盘,GameState可以原封不动搬到服务端用,只是把落子入口从本地点击换成网络消息。
页面上组件怎么划分?我选择了一个页面组件Gomoku.razor承载全部 UI。棋盘本身没有拆成独立的Cell子组件,因为一个格子只是“位置 + 棋子状态”,拆成子组件会增加大量的参数传递代码,而棋盘只有 225 个格子,单组件循环渲染更直接。如果以后要做移动端的棋子拖拽、动画效果,再拆也不迟。
2. 核心逻辑细节与胜负判定
2.1 棋盘状态的数据结构
棋盘用什么数据结构?这是整个项目最基础的决定。常见方案有三种:二维数组、字典、一维数组。
二维数组最直观:Player[,] Board = new Player[15, 15],用Board[row, col]直接索引坐标,语义和棋盘完全对应。缺点是被Array.Clear重置时比较方便,而且固定尺寸下不需要动态扩容。字典Dictionary<(int, int), Player>的优势是稀疏存储,但五子棋每个格子都可能落子,根本不存在稀疏问题,反而每次取值要算哈希,代码也更绕。一维数组性能最好,但要把二维坐标换算成一维下标,读代码时心智负担重。
对一个 15×15 的固定棋盘,二维数组是最平衡的方案。定义一个枚举表示棋子状态:
public enum Player { None, Black, White }用None而不是null,好处是判空语义清晰,而且枚举在 switch、比较操作里比可空类型好处理得多。在 UI 层判断时,piece == Player.None一眼就能看出这里还没落子。
2.2 落子与回合切换
落子动作封装在GameState.PlaceStone方法里。这个方法要做四件事:坐标范围校验、位置占用校验、游戏是否结束校验、写入棋子并切换状态。
先看坐标校验。棋盘是 15×15,行和列的范围都是 0 到 14。有的玩家会把越界检查放在 UI 层,只在点击格子时传入合法坐标,这其实有隐患——如果以后接入网络同步或者 AI 落子,直接调GameState就可能传出非法坐标。把校验放在逻辑层,相当于对所有入口统一把关。
占用校验也很关键。落子前必须确认目标位置是Player.None,否则会出现“吃子”的诡异效果。游戏结束校验是防止胜负已分后继续落子。如果这些校验全放在 UI 层,逻辑类就是不安全的,任何调用方都可能绕过限制。
最后是回合切换。关键设计是:当前玩家落子后,如果此子直接获胜,就不要切换CurrentPlayer,这样CurrentPlayer恰好就是胜方,UI 直接读它就能显示胜利者。否则把玩家从 Black 切到 White、White 切到 Black。
2.3 胜负判定算法拆解
五子棋判胜最容易想到的方案是“每次落子后全盘扫描”,遍历 15×15 的每个格子,检查以它为起点是否有五连。这在 225 个格子的小棋盘上也不是不行,但完全没有必要。
更聪明的做法是只检查最后落下的那颗棋子。因为新棋子的落点就是形成连珠的潜在关键点,如果和局没有产生新棋子,盘面状态没有变化,不可能凭空多出一组五连。所以算法从落子点出发,向四个方向分别数连子数量。
四个方向分别是:水平方向(0, 1)、垂直方向(1, 0)、主对角线方向(1, 1)、副对角线方向(1, -1)。每个方向要同时往正反两边延伸。比如水平方向,从落子点先向右数同色棋子,再向左数同色棋子,加上落子点本身,得到这个方向上的连续长度。如果任一方向长度达到 5,就判定获胜。
代码实现上,两层循环处理,外层遍历四个方向,内层用偏移量在正反两个方向累计数量。边界判断是重点,延伸时不能超出 15×15 的范围,否则数组越界。因为只需要判断五连,每个方向向外最多延伸 4 格,所以算法是常数级复杂度,性能上完全不是问题。
一个细节值得一提:这版算法判断的是“同色连续 >=5”,也就是六连甚至七连也会被判胜。正式的竞技五子棋是禁止长连的,但作为休闲小游戏,长连判胜反而符合大多数人的直觉。如果要引入正式规则,需要在获胜判定时区分“恰好五连”和“超过五连”。
2.4 事件绑定的闭包陷阱
写棋盘 UI 时最容易被坑的地方是事件绑定里的循环变量捕获。用 Razor 的@for循环生成格子时,很多人会直接写:
<div @onclick="() => HandleClick(row, col)">这在 C# 的闭包语义下是错的。for循环里的循环变量是同一个变量,所有闭包捕获的都是同一个引用。当点击事件真正触发时,循环早就结束,变量已经变成循环终止值,于是你点任何格子,传进HandleClick的坐标可能全是(15, 15)之类的越界值,表现就是“点哪都落不进去”或者“老是落在同一个位置”。
解决办法很简单:每次循环体内复制一个局部变量,让每个闭包捕获不同的实例。
@for (int row = 0; row < BoardSize; row++) { var currentRow = row; @for (int col = 0; col < BoardSize; col++) { var currentCol = col; <div class="cell" @onclick="() => HandleClick(currentRow, currentCol)"> ... </div> } }这个坑我见过太多人踩过,包括我自己第一次写 Blazor 列表时也中过招。只要记住一个原则:事件绑定的 lambda 里要用循环体内的局部副本,不要直接用循环变量,基本就不会再犯。
3. 实操过程与关键代码实现
3.1 环境准备与项目创建
开发环境需要先装好 .NET SDK。打开命令行执行以下命令创建项目:
dotnet new blazorwasm -n GomokuGame -o GomokuGame cd GomokuGameblazorwasm模板生成的是独立的 WebAssembly 项目,包含Program.cs、App.razor、MainLayout.razor、Pages/目录等标准结构。模板自带的Pages/Index.razor是入口页面,我这里直接在Pages/下新建一个Gomoku.razor作为游戏页面,并把路由设为@page "/gomoku",这样浏览器访问/gomoku就能打开。
如果不想用 CLI,直接在 Visual Studio 里新建 Blazor WebAssembly 应用也是一样的。模板选对之后,核心的 C# 代码都是通用的。
运行项目用dotnet run,浏览器打开后会自动加载 .NET 运行时,首次启动可能要等几秒,这是 WebAssembly 模式加载 runtime 的正常开销,后续刷新会走缓存,快很多。
3.2 编写 GameState 核心类
在项目里新建Models/GameState.cs,把枚举和棋盘逻辑放在一起。这个类不依赖 Blazor,纯 C#,是整套逻辑的核心。完整代码如下:
namespace GomokuGame.Models; public enum Player { None, Black, White } public class GameState { public const int BoardSize = 15; public Player[,] Board { get; } = new Player[BoardSize, BoardSize]; public Player CurrentPlayer { get; private set; } = Player.Black; public bool GameOver { get; private set; } // 落子入口:返回落子是否成功 public bool PlaceStone(int row, int col) { if (row < 0 || row >= BoardSize || col < 0 || col >= BoardSize) return false; if (GameOver) return false; if (Board[row, col] != Player.None) return false; Board[row, col] = CurrentPlayer; if (CheckWin(row, col, CurrentPlayer)) { GameOver = true; return true; } CurrentPlayer = CurrentPlayer == Player.Black ? Player.White : Player.Black; return true; } private bool CheckWin(int row, int col, Player player) { int[][] directions = { new[] { 0, 1 }, // 水平 new[] { 1, 0 }, // 垂直 new[] { 1, 1 }, // 主对角线 new[] { 1, -1 } // 副对角线 }; foreach (var dir in directions) { int count = 1; // 正方向延伸 for (int i = 1; i < 5; i++) { int r = row + dir[0] * i; int c = col + dir[1] * i; if (r < 0 || r >= BoardSize || c < 0 || c >= BoardSize) break; if (Board[r, c] != player) break; count++; } // 反方向延伸 for (int i = 1; i < 5; i++) { int r = row - dir[0] * i; int c = col - dir[1] * i; if (r < 0 || r >= BoardSize || c < 0 || c >= BoardSize) break; if (Board[r, c] != player) break; count++; } if (count >= 5) return true; } return false; } public void Reset() { Array.Clear(Board, 0, Board.Length); CurrentPlayer = Player.Black; GameOver = false; } }这段代码里有几个值得展开说的点。PlaceStone的返回值为bool,可以让调用方知道落子到底成没成功。比如玩家点击一个已经落过子的格子,UI 可以据此给出“这里不能落子”的提示,而不是默默忽略。
CheckWin里正反两个循环是分开写的,为什么不合并?因为用同一个偏移方向、通过乘数来控制正反,只会让代码更绕。分两个循环,每个循环从 1 延伸到 4,语义清楚,逻辑也不重复。等以后要加禁手规则或更复杂的胜局判断时,在这个结构上加判断条件也方便。
Array.Clear(Board, 0, Board.Length)对二维数组同样有效,它会把整个数组的元素都设为0,对应枚举None,一行的代码就完成棋盘重置。
3.3 棋盘 UI 与样式
页面组件的任务是渲染棋盘并把玩家点击转到GameState。我选择 CSS Grid 布局来实现 15×15 的棋盘,比嵌套board-row的方式代码更短。
Gomoku.razor的完整实现如下:
@page "/gomoku" @using GomokuGame.Models <div class="game-wrapper"> <div class="game-header"> <span class="turn-info">当前回合:@CurrentPlayerName</span> <button class="btn-restart" @onclick="RestartGame">重新开始</button> </div> <div class="board"> @for (int row = 0; row < GameState.BoardSize; row++) { var currentRow = row; @for (int col = 0; col < GameState.BoardSize; col++) { var currentCol = col; var piece = game.Board[currentRow, currentCol]; <div class="cell" @onclick="() => HandleClick(currentRow, currentCol)"> @if (piece == Player.Black) { <span class="stone stone-black"></span> } else if (piece == Player.White) { <span class="stone stone-white"></span> } </div> } } </div> @if (game.GameOver) { <div class="game-over"> <p>@WinnerText 获胜!</p> <button class="btn-restart" @onclick="RestartGame">再来一局</button> </div> } </div> @code { private GameState game = new(); private string CurrentPlayerName => game.CurrentPlayer == Player.Black ? "黑棋" : "白棋"; private string WinnerText => game.CurrentPlayer == Player.Black ? "黑棋" : "白棋"; private void HandleClick(int row, int col) { game.PlaceStone(row, col); } private void RestartGame() { game.Reset(); } }注意几个设计上的细节。
第一,循环里var currentRow = row;和var currentCol = col;这两行,就是前面说的闭包隐患的解法。没有这两行,点击坐标会全部错乱。
第二,CurrentPlayerName和WinnerText都用表达式属性,它们会根据game.CurrentPlayer动态计算。由于GameOver时不再切换玩家,获胜方恰好就是最后一个落子的玩家,这个设计省掉了单独的Winner字段。
第三,RestartGame调用game.Reset()后,Blazor 会自动触发重新渲染,不需要手动刷新。这一点在 Server 模式下也是自动的,框架会在事件处理器执行完后自动调用组件的刷新机制。
棋盘样式用 CSS 实现,放在Gomoku.razor.css里实现 CSS 隔离,或者放到全局样式文件里:
.board { display: grid; grid-template-columns: repeat(15, 32px); grid-template-rows: repeat(15, 32px); width: fit-content; background-color: #d9a666; padding: 12px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); } .cell { width: 32px; height: 32px; border: 1px solid #8b6f47; display: flex; align-items: center; justify-content: center; cursor: pointer; position: relative; } .stone { width: 26px; height: 26px; border-radius: 50%; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); } .stone-black { background: radial-gradient(circle at 35% 35%, #666, #000); } .stone-white { background: radial-gradient(circle at 35% 35%, #fff, #e0e0e0); border: 1px solid #c0c0c0; }棋盘网格直接用单元格边框实现,每个格子 32×32 像素,棋子和格子之间留了间距,视觉效果接近真实棋盘。落子后的棋子用radial-gradient模拟高光立体感,黑棋白棋一眼可辨。
3.4 胜负展示与重开逻辑
胜负展示用了覆盖层的方式,当GameOver为true时,页面底部会渲染一个浮层,显示获胜方和“再来一局”按钮。因为游戏结束时CurrentPlayer就是胜方,所以直接读它。
值得强调的是,这个简易版本没有做“平局”判断。理论上 15×15 棋盘有 225 个落子点,双方一共下 225 手一定会出现胜局或满盘。满盘平局在休闲玩法中极少出现,如果需要,可以在PlaceStone里增加一步判断:如果棋盘已满且没有胜者,则平局。这也是一个扩展点。
4. 常见问题、性能与发布排查实录
4.1 点击格子没有反应
这个现象通常是事件没有正确绑定或者逻辑层校验拦截。先打开浏览器 F12 控制台看有没有报错,再看@onclick表达式是不是拼错了。代码层面最常见的原因是PlaceStone的三个校验条件把请求挡掉了:坐标越界、游戏已结束、格子已占用。
调试技巧:在HandleClick里临时加一行Console.WriteLine($"Click at ({row}, {col}), player={game.CurrentPlayer}"),然后在浏览器控制台看输出。如果点击有输出但没有落子,说明是PlaceStone的校验返回了false;如果连输出都没有,就要检查事件绑定本身。
4.2 胜负判定不准,明明连成五子却不判胜
这种问题多半出在边界处理上。比如落子在棋盘最边上,往负方向延伸时如果越界,应该break而不是继续访问数组。只要有一步越界没拦住,就会抛异常或者跳过计数。
另一种情况是判断方向遗漏。四个方向必须完整:水平、垂直、主对角线、副对角线。我第一次实现时忘了副对角线,结果左上到右下方向的五连永远检测不到。可以专门写几个测试用例,比如第一行连续五个黑子、最后一列连续五个白子、斜线中间被空格断开,逐一验证。
4.3 关于渲染开销的实话
有不少人上来就担心 Blazor 的 DOM diff 性能不行,觉得棋盘每次点击都会重新渲染全部 225 个格子。实际跑一下就会发现,225 个轻量<div>节点的 diff 根本算不上性能瓶颈,点击一次后浏览器帧间隔依然稳定在个位数毫秒级别。
真正需要注意的不是渲染数量,而是渲染过程中创建的对象量。比如在循环里频繁new临时数组、字符串拼接,这些才是容易引发 GC 压力的地方。五子棋项目里完全没有高频操作,正常写根本卡不起来,没必要做虚拟滚动或者 Canvas 替换这种过度优化。
如果想观察实际渲染效果,可以在HandleClick里记录时间,或者用浏览器 Performance 面板看点击事件的耗时。实测下来每次落子从事件触发到 UI 更新完成在 1ms 上下,完全够用。
4.4 发布时如何把体积压到最小
WebAssembly 模式发布后,浏览器要下载 .NET 运行时以及项目依赖的所有程序集,默认配置下体积可能到 5MB 左右。对本地网速无所谓,但如果要部署到公网,首次加载体验就得优化。
常用做法有三个。
第一,启用压缩。静态文件托管时开启 Brotli 压缩,.dll文件可以压掉不少体积。
第二,启用裁剪(Trim)。在.csproj里加:
<PropertyGroup> <PublishTrimmed>true</PublishTrimmed> <BlazorEnableTimeZoneSupport>false</BlazorEnableTimeZoneSupport> <InvariantGlobalization>true</InvariantGlobalization> </PropertyGroup>裁剪会移除没用到的程序集代码,时间区域和全球化数据在这个小游戏里也用不上,关掉能省不少字节。注意,裁剪如果遇到反射等动态特性可能会出问题,但这个项目是纯静态代码路径,实测很稳。
第三,发布时用dotnet publish -c Release -o publish,然后把整个publish/wwwroot目录部署到任意静态站点服务。WebAssembly 项目的产物就是静态文件,不需要后端服务器,部署成本几乎为零。
5. 扩展方向与个人实操体会
5.1 从这个项目往下走能做什么
基础版本跑通后,可以按难度梯度扩展。
最简单的扩展是悔棋功能。核心思路是维护一个Stack<(int Row, int Col)>记录每一步落子坐标,悔棋时弹出栈顶坐标并清空对应棋盘位置。需要注意的是栈只记录坐标,因为从坐标可以推算出棋子颜色:栈的深度为奇数时那一步是黑棋下的,偶数时是白棋下的。这个逻辑可以封装进GameState,对 UI 只暴露一个Undo()方法。
再进一步可以加简单的人机对战。一个基础方案是评分算法:对每个空位,分别计算放上黑棋或白棋后会形成的连子数,包括双三、活四、冲四等常见棋型,赋上权重,找总分最高的位置落子。这个算法虽然打不过专业 AI,但作为入门级别的“我和电脑下五子棋”已经足够有趣。
高级一点可以做房间对战,用 SignalR 把两个玩家的落子动作同步到对方浏览器,GameState不需要大改,落子入口从本地点击变成远程消息,状态同步由服务端集中管理。
5.2 我做完这个项目的几点真实感受
做完这个项目后,我对 Blazor 的渲染模型有了整体感知。以前看文档总在纠结“什么时候触发重渲染”“组件参数变了会不会刷新”,这些疑问在实际项目里会自然找到答案——事件处理器结束后自动StateHasChanged,父子组件的参数传递在单向数据流里非常清晰,不像手动操作 DOM 那样随时要担心状态和画面不同步。
最让我意外的是整个项目的调试体验。逻辑层是纯 C# 类,可以直接写单元测试覆盖落子、判胜、重置这些场景;UI 层的事件处理代码量很少,所有业务规则都在GameState里,出 bug 时先怀疑逻辑类,再排查事件绑定,定位路径非常短。
这个项目最大的价值在于:它用很低的成本覆盖了 Blazor 开发的全部关键动作。当你把棋盘、落子、胜负、重置这套流程完整跑通之后,再去看那些大型 Blazor 项目的代码结构,会发现它们本质上也是在重复这些基础动作,只是规模更大、抽象层更多。技术框架的入门,与其零散地刷文档,不如先做个这样的小项目把主干走通,后面扩展起来自然有底气。