1. Blazor五子棋游戏开发全记录
最近用Blazor WebAssembly开发了一个五子棋游戏,过程中遇到了不少有趣的技术问题,也积累了一些实战经验。这个项目从最初的AI生成代码到逐步完善功能,最终实现了一个支持人机对战、人人对战和三种难度级别的完整游戏。下面详细记录整个开发过程和关键技术点。
1.1 项目初始化与环境准备
首先创建一个新的Blazor WebAssembly项目,选择.NET 6作为目标框架。在Visual Studio中创建项目时,我选择了"Blazor WebAssembly App"模板,并勾选了"ASP.NET Core hosted"选项,这样可以获得完整的前后端分离架构。
项目创建完成后,需要调整一些基础配置:
bash复制# 创建新项目
dotnet new blazorwasm -n GomokuGame --hosted
cd GomokuGame
在App.razor文件中,将渲染模式从默认的InteractiveAuto改为InteractiveServer,这样可以获得更稳定的调试体验:
html复制<!-- 修改前 -->
<Router AppAssembly="@typeof(App).Assembly" @rendermode="InteractiveAuto">
<!-- 修改后 -->
<Router AppAssembly="@typeof(App).Assembly" @rendermode="InteractiveServer">
提示:InteractiveServer模式在开发阶段可以提供更快的热重载和更详细的错误信息,适合调试复杂交互逻辑。
1.2 基础棋盘实现
五子棋的核心是一个15×15的棋盘,使用二维数组来存储每个格子的状态:
csharp复制@code {
const int BoardSize = 15;
int[,] board = new int[BoardSize, BoardSize]; // 0:空 1:黑子 2:白子
int currentPlayer = 1; // 当前玩家
int winner = 0; // 获胜方
void PlacePiece(int x, int y) {
if (x < 0 || x >= BoardSize || y < 0 || y >= BoardSize) return;
if (winner != 0 || board[x, y] != 0) return;
board[x, y] = currentPlayer;
if (CheckWin(x, y, currentPlayer)) {
winner = currentPlayer;
} else {
currentPlayer = 3 - currentPlayer; // 切换玩家
}
}
}
棋盘UI使用嵌套循环渲染,每个格子绑定点击事件:
html复制@for (int y = 0; y < BoardSize; y++) {
<div class="board-row">
@for (int x = 0; x < BoardSize; x++) {
int _x = x; // 闭包问题修复
int _y = y;
<div class="board-cell"
style="background:@GetCellBg(x,y)"
@onclick="() => PlacePiece(_x, _y)">
@if (board[x, y] == 1) { ● }
else if (board[x, y] == 2) { ○ }
</div>
}
</div>
}
注意事项:Blazor中循环变量在事件处理程序中会出现闭包问题,必须先在循环内创建局部变量再使用。
1.3 胜负判定算法
五子棋的胜负判定需要检查四个方向(水平、垂直、两个对角线)是否有连续五个同色棋子:
csharp复制bool CheckWin(int x, int y, int player) {
int[][] directions = new int[][] {
new int[]{1,0}, // 水平
new int[]{0,1}, // 垂直
new int[]{1,1}, // 主对角线
new int[]{1,-1} // 副对角线
};
foreach (var dir in directions) {
int count = 1; // 当前落子点
count += CountDirection(x, y, dir[0], dir[1], player);
count += CountDirection(x, y, -dir[0], -dir[1], player);
if (count >= 5) return true;
}
return false;
}
int CountDirection(int x, int y, int dx, int dy, int player) {
int count = 0;
for (int step = 1; step < 5; step++) {
int nx = x + dx * step;
int ny = y + dy * step;
if (nx < 0 || nx >= BoardSize || ny < 0 || ny >= BoardSize) break;
if (board[nx, ny] != player) break;
count++;
}
return count;
}
这个算法从落子点向四个方向延伸检查,效率很高,最坏情况下也只需要检查8个方向各4个点,共32次判断。
2. AI对战功能实现
2.1 基础AI:随机落子
最简单的AI实现是随机选择一个空位落子:
csharp复制async Task AITurnAsync() {
await Task.Delay(500); // 模拟思考时间
var empty = new List<(int x, int y)>();
for (int i = 0; i < BoardSize; i++)
for (int j = 0; j < BoardSize; j++)
if (board[i, j] == 0)
empty.Add((i, j));
if (empty.Count > 0) {
var (aiX, aiY) = empty[rand.Next(empty.Count)];
board[aiX, aiY] = 2; // 白子
if (CheckWin(aiX, aiY, 2)) {
winner = 2;
} else {
currentPlayer = 1; // 切换回玩家
}
}
}
这种AI虽然简单,但作为基础功能已经足够,特别是对于新手玩家来说难度适中。
2.2 进阶AI:评分算法
为了让AI更有挑战性,我实现了一个基于评分的算法。这个算法会评估每个空位的价值,选择最优位置落子:
csharp复制(int, int) FindBestMove() {
int maxScore = int.MinValue;
var bestMoves = new List<(int x, int y)>();
for (int x = 0; x < BoardSize; x++) {
for (int y = 0; y < BoardSize; y++) {
if (board[x, y] != 0) continue;
// 评估该位置对AI的价值
int score = EvaluatePoint(x, y, 2);
// 评估该位置对玩家的威胁
score = Math.Max(score, EvaluatePoint(x, y, 1));
if (score > maxScore) {
maxScore = score;
bestMoves.Clear();
bestMoves.Add((x, y));
}
else if (score == maxScore) {
bestMoves.Add((x, y));
}
}
}
return bestMoves[rand.Next(bestMoves.Count)];
}
评分函数EvaluatePoint是关键,它需要考虑多种棋型:
csharp复制int EvaluatePoint(int x, int y, int player) {
int score = 0;
int opponent = player == 1 ? 2 : 1;
foreach (var dir in new[] { (1,0), (0,1), (1,1), (1,-1) }) {
int count = 1 + CountDirection(x, y, dir.Item1, dir.Item2, player)
+ CountDirection(x, y, -dir.Item1, -dir.Item2, player);
if (count >= 5) score += 10000; // 五连
else if (count == 4) score += 1000; // 活四
else if (count == 3) score += 100; // 活三
else if (count == 2) score += 10; // 活二
// 防守评分
int oppCount = 1 + CountDirection(x, y, dir.Item1, dir.Item2, opponent)
+ CountDirection(x, y, -dir.Item1, -dir.Item2, opponent);
if (oppCount >= 5) score += 9000; // 阻挡五连
else if (oppCount == 4) score += 900;// 阻挡活四
else if (oppCount == 3) score += 90; // 阻挡活三
else if (oppCount == 2) score += 9; // 阻挡活二
}
return score;
}
2.3 多难度级别实现
通过调整评分权重,可以实现不同难度级别的AI:
csharp复制int EvaluatePoint(int x, int y, int player) {
int score = 0;
int opponent = player == 1 ? 2 : 1;
// 不同难度权重
int[] winWeight = { 100, 10000, 10000 };
int[] fourWeight = { 10, 1000, 3000 };
int[] threeWeight = { 5, 100, 500 };
int[] twoWeight = { 2, 10, 50 };
int[] blockWinWeight = { 90, 9000, 9000 };
int[] blockFourWeight = { 9, 900, 2500 };
int[] blockThreeWeight = { 4, 90, 400 };
int[] blockTwoWeight = { 1, 9, 40 };
foreach (var dir in new[] { (1,0), (0,1), (1,1), (1,-1) }) {
int count = 1 + CountDirection(x, y, dir.Item1, dir.Item2, player)
+ CountDirection(x, y, -dir.Item1, -dir.Item2, player);
if (count >= 5) score += winWeight[aiLevel];
else if (count == 4) score += fourWeight[aiLevel];
else if (count == 3) score += threeWeight[aiLevel];
else if (count == 2) score += twoWeight[aiLevel];
int oppCount = 1 + CountDirection(x, y, dir.Item1, dir.Item2, opponent)
+ CountDirection(x, y, -dir.Item1, -dir.Item2, opponent);
if (oppCount >= 5) score += blockWinWeight[aiLevel];
else if (oppCount == 4) score += blockFourWeight[aiLevel];
else if (oppCount == 3) score += blockThreeWeight[aiLevel];
else if (oppCount == 2) score += blockTwoWeight[aiLevel];
}
return score;
}
在UI中添加难度选择按钮:
html复制<div class="difficulty-controls">
<button @onclick="() => SetAILevel(0)" class="@(aiLevel == 0 ? "active" : "")">简单</button>
<button @onclick="() => SetAILevel(1)" class="@(aiLevel == 1 ? "active" : "")">中等</button>
<button @onclick="() => SetAILevel(2)" class="@(aiLevel == 2 ? "active" : "")">困难</button>
</div>
3. 功能扩展与优化
3.1 计分系统
为了增加游戏的可玩性,我添加了计分功能:
csharp复制@code {
int blackScore = 0;
int whiteScore = 0;
void PlacePiece(int x, int y) {
// ...原有逻辑...
if (CheckWin(x, y, currentPlayer)) {
winner = currentPlayer;
if (winner == 1) blackScore++;
else if (winner == 2) whiteScore++;
}
// ...其余逻辑...
}
void ResetScore() {
blackScore = 0;
whiteScore = 0;
}
}
在页面上显示分数:
html复制<div class="score-display">
<span>黑子得分: <strong>@blackScore</strong></span>
<span>白子得分: <strong>@whiteScore</strong></span>
<button @onclick="ResetScore">重置得分</button>
</div>
3.2 人机/人人模式切换
通过一个布尔值控制游戏模式:
csharp复制@code {
bool isHumanVsAI = true;
void ToggleMode() {
isHumanVsAI = !isHumanVsAI;
Restart();
}
}
在UI中添加切换按钮:
html复制<button @onclick="ToggleMode">
@(isHumanVsAI ? "切换为人人对战" : "切换为人机对战")
</button>
3.3 棋盘视觉效果优化
为了让棋盘更美观,我添加了棋盘格背景色交替和棋子样式:
css复制.board-row {
display: flex;
}
.board-cell {
width: 30px;
height: 30px;
border: 1px solid #ccc;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.board-cell:hover {
background-color: #f0f0f0;
}
/* 棋子样式 */
.board-cell > span {
font-size: 24px;
line-height: 1;
}
/* 黑子 */
.board-cell > span:first-child {
color: black;
}
/* 白子 */
.board-cell > span:last-child {
color: white;
text-shadow: 0 0 2px rgba(0,0,0,0.5);
}
4. 开发中的问题与解决方案
4.1 Blazor闭包问题
最初实现点击事件时遇到了经典的闭包问题:
html复制<!-- 错误实现 -->
@for (int x = 0; x < BoardSize; x++) {
@for (int y = 0; y < BoardSize; y++) {
<div @onclick="() => PlacePiece(x, y)"></div>
}
}
这样会导致所有点击事件中的x和y都是循环结束后的最终值。解决方案是在循环内创建局部变量:
html复制@for (int x = 0; x < BoardSize; x++) {
@for (int y = 0; y < BoardSize; y++) {
int _x = x;
int _y = y;
<div @onclick="() => PlacePiece(_x, _y)"></div>
}
}
4.2 数组越界检查
最初没有在PlacePiece方法中检查坐标边界,导致可能出现数组越界异常。添加了边界检查后问题解决:
csharp复制void PlacePiece(int x, int y) {
if (x < 0 || x >= BoardSize || y < 0 || y >= BoardSize) return;
// ...其余逻辑...
}
4.3 AI响应延迟
为了让AI落子看起来更自然,添加了500毫秒的延迟:
csharp复制async Task AITurnAsync() {
await Task.Delay(500); // 模拟思考时间
// ...AI落子逻辑...
}
同时需要在玩家落子后调用StateHasChanged()强制刷新UI,否则延迟期间界面不会更新:
csharp复制currentPlayer = 2; // 切换到AI
StateHasChanged(); // 立即更新UI
_ = AITurnAsync(); // 启动AI回合
5. 完整代码实现
以下是最终的Gomoku.razor完整代码:
html复制@page "/gomoku"
<div class="game-container">
<h1>五子棋</h1>
<div class="controls">
<div class="difficulty">
<span>难度:</span>
<button @onclick="() => SetAILevel(0)" class="@(aiLevel == 0 ? "active" : "")">简单</button>
<button @onclick="() => SetAILevel(1)" class="@(aiLevel == 1 ? "active" : "")">中等</button>
<button @onclick="() => SetAILevel(2)" class="@(aiLevel == 2 ? "active" : "")">困难</button>
</div>
<div class="mode">
<button @onclick="ToggleMode">
@(isHumanVsAI ? "切换为人人对战" : "切换为人机对战")
</button>
</div>
<div class="scores">
<span>黑子: @blackScore</span>
<span>白子: @whiteScore</span>
<button @onclick="ResetScore">重置分数</button>
</div>
<div class="status">
<p>当前玩家: <strong>@CurrentPlayerName</strong></p>
@if (winner != 0) {
<p>胜者: <strong>@WinnerName</strong></p>
<button @onclick="Restart">重新开始</button>
}
</div>
</div>
<div class="board">
@for (int y = 0; y < BoardSize; y++) {
<div class="board-row">
@for (int x = 0; x < BoardSize; x++) {
int _x = x;
int _y = y;
<div class="board-cell"
style="background:@GetCellBg(x,y)"
@onclick="() => PlacePiece(_x, _y)">
@if (board[x, y] == 1) { ● }
else if (board[x, y] == 2) { ○ }
</div>
}
</div>
}
</div>
</div>
@code {
const int BoardSize = 15;
int[,] board = new int[BoardSize, BoardSize];
int currentPlayer = 1; // 1:黑子 2:白子
int winner = 0;
int blackScore = 0;
int whiteScore = 0;
bool isHumanVsAI = true;
int aiLevel = 1; // 0:简单 1:中等 2:困难
Random rand = new();
string CurrentPlayerName => currentPlayer == 1 ? "黑子" :
(isHumanVsAI ? "白子(电脑)" : "白子");
string WinnerName => winner == 1 ? "黑子" :
(isHumanVsAI ? "白子(电脑)" : "白子");
void PlacePiece(int x, int y) {
if (x < 0 || x >= BoardSize || y < 0 || y >= BoardSize) return;
if (winner != 0 || board[x, y] != 0) return;
if (isHumanVsAI && currentPlayer == 2) return;
board[x, y] = currentPlayer;
if (CheckWin(x, y, currentPlayer)) {
winner = currentPlayer;
if (winner == 1) blackScore++;
else if (winner == 2) whiteScore++;
} else {
currentPlayer = 3 - currentPlayer;
if (isHumanVsAI && currentPlayer == 2) {
StateHasChanged();
_ = AITurnAsync();
}
}
}
async Task AITurnAsync() {
await Task.Delay(500);
if (winner != 0) return;
var (aiX, aiY) = FindBestMove();
board[aiX, aiY] = 2;
if (CheckWin(aiX, aiY, 2)) {
winner = 2;
whiteScore++;
} else {
currentPlayer = 1;
}
StateHasChanged();
}
(int, int) FindBestMove() {
if (aiLevel == 0) {
var empty = new List<(int, int)>();
for (int x = 0; x < BoardSize; x++)
for (int y = 0; y < BoardSize; y++)
if (board[x, y] == 0)
empty.Add((x, y));
return empty[rand.Next(empty.Count)];
}
int maxScore = int.MinValue;
var bestMoves = new List<(int, int)>();
for (int x = 0; x < BoardSize; x++) {
for (int y = 0; y < BoardSize; y++) {
if (board[x, y] != 0) continue;
int score = EvaluatePoint(x, y, 2);
score = Math.Max(score, EvaluatePoint(x, y, 1));
if (score > maxScore) {
maxScore = score;
bestMoves.Clear();
bestMoves.Add((x, y));
} else if (score == maxScore) {
bestMoves.Add((x, y));
}
}
}
return bestMoves[rand.Next(bestMoves.Count)];
}
int EvaluatePoint(int x, int y, int player) {
int score = 0;
int opponent = player == 1 ? 2 : 1;
int[] winWeight = { 100, 10000, 10000 };
int[] fourWeight = { 10, 1000, 3000 };
int[] threeWeight = { 5, 100, 500 };
int[] twoWeight = { 2, 10, 50 };
int[] blockWinWeight = { 90, 9000, 9000 };
int[] blockFourWeight = { 9, 900, 2500 };
int[] blockThreeWeight = { 4, 90, 400 };
int[] blockTwoWeight = { 1, 9, 40 };
foreach (var dir in new[] { (1,0), (0,1), (1,1), (1,-1) }) {
int count = 1 + CountDirection(x, y, dir.Item1, dir.Item2, player)
+ CountDirection(x, y, -dir.Item1, -dir.Item2, player);
if (count >= 5) score += winWeight[aiLevel];
else if (count == 4) score += fourWeight[aiLevel];
else if (count == 3) score += threeWeight[aiLevel];
else if (count == 2) score += twoWeight[aiLevel];
int oppCount = 1 + CountDirection(x, y, dir.Item1, dir.Item2, opponent)
+ CountDirection(x, y, -dir.Item1, -dir.Item2, opponent);
if (oppCount >= 5) score += blockWinWeight[aiLevel];
else if (oppCount == 4) score += blockFourWeight[aiLevel];
else if (oppCount == 3) score += blockThreeWeight[aiLevel];
else if (oppCount == 2) score += blockTwoWeight[aiLevel];
}
return score;
}
bool CheckWin(int x, int y, int player) {
int[][] directions = new int[][] {
new int[]{1,0}, new int[]{0,1}, new int[]{1,1}, new int[]{1,-1}
};
foreach (var dir in directions) {
int count = 1;
count += CountDirection(x, y, dir[0], dir[1], player);
count += CountDirection(x, y, -dir[0], -dir[1], player);
if (count >= 5) return true;
}
return false;
}
int CountDirection(int x, int y, int dx, int dy, int player) {
int count = 0;
for (int step = 1; step < 5; step++) {
int nx = x + dx * step;
int ny = y + dy * step;
if (nx < 0 || nx >= BoardSize || ny < 0 || ny >= BoardSize) break;
if (board[nx, ny] != player) break;
count++;
}
return count;
}
void Restart() {
board = new int[BoardSize, BoardSize];
currentPlayer = 1;
winner = 0;
if (isHumanVsAI && currentPlayer == 2) {
_ = AITurnAsync();
}
}
void ResetScore() {
blackScore = 0;
whiteScore = 0;
}
void SetAILevel(int level) {
aiLevel = level;
Restart();
}
void ToggleMode() {
isHumanVsAI = !isHumanVsAI;
Restart();
}
string GetCellBg(int x, int y) {
return (x + y) % 2 == 0 ? "#f9d77e" : "#eac066";
}
}
配套的CSS样式:
css复制.game-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.controls {
margin-bottom: 20px;
padding: 15px;
background-color: #f5f5f5;
border-radius: 5px;
}
.difficulty, .mode, .scores, .status {
margin-bottom: 10px;
}
button {
padding: 5px 10px;
margin: 0 5px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
}
button:hover {
background-color: #45a049;
}
button.active {
background-color: #2196F3;
}
.board {
margin-top: 20px;
}
.board-row {
display: flex;
}
.board-cell {
width: 30px;
height: 30px;
border: 1px solid #999;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 20px;
font-weight: bold;
}
.board-cell:hover {
background-color: #f0f0f0;
}
.status {
font-size: 18px;
margin-top: 10px;
}
.scores span {
margin-right: 15px;
font-weight: bold;
}
6. 项目总结与经验分享
通过这个Blazor五子棋项目的开发,我获得了几个重要的经验:
-
Blazor组件生命周期:理解组件生命周期方法如OnInitializedAsync、OnParametersSetAsync等对于处理异步操作非常重要。特别是在AI回合中,需要确保UI在状态变更时能正确更新。
-
状态管理:对于游戏这类有复杂状态的应用,保持状态的一致性和可预测性很关键。所有修改游戏状态的操作都应该集中处理,避免分散的状态修改。
-
性能考量:虽然五子棋的算法复杂度不高,但在Blazor中频繁更新UI时仍需注意性能。应该尽量减少不必要的StateHasChanged调用,只在真正需要更新UI时调用。
-
AI算法优化:目前的评分算法虽然效果不错,但仍有优化空间。可以考虑引入更高级的算法如极小化极大算法(Minimax)配合Alpha-Beta剪枝,或者预计算常见棋型的评分表。
-
响应式设计:棋盘使用固定像素尺寸可能在小屏幕上显示不全。下一步可以考虑使用响应式单位如vw/vh或者CSS Grid布局,使游戏能适配不同屏幕尺寸。
这个项目展示了Blazor WebAssembly在开发复杂交互式Web应用方面的强大能力。与传统JavaScript框架相比,Blazor允许我们使用C#和.NET生态系统的工具开发全栈应用,同时还能获得良好的性能表现。