资讯动态

Blazor实战:从零实现五子棋小游戏(C# + WebAssembly)

发布时间:2026/10/14 19:58:41 来源:尧图企业网站定制
如果你是个平时写 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 classcell 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 classgame-wrapper div classgame-header span classturn-info当前回合CurrentPlayerName/span button classbtn-restart onclickRestartGame重新开始/button /div div classboard 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 classcell onclick() HandleClick(currentRow, currentCol) if (piece Player.Black) { span classstone stone-black/span } else if (piece Player.White) { span classstone stone-white/span } /div } } /div if (game.GameOver) { div classgame-over pWinnerText 获胜/p button classbtn-restart onclickRestartGame再来一局/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 PublishTrimmedtrue/PublishTrimmed BlazorEnableTimeZoneSupportfalse/BlazorEnableTimeZoneSupport InvariantGlobalizationtrue/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 项目的代码结构会发现它们本质上也是在重复这些基础动作只是规模更大、抽象层更多。技术框架的入门与其零散地刷文档不如先做个这样的小项目把主干走通后面扩展起来自然有底气。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑