ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C#五子棋课程设计:WinForms+ADO.NET+SQL Server完整源码与数据库实战

2026/10/8 3:46:27 拓冰建站 浏览量
C#五子棋课程设计:WinForms+ADO.NET+SQL Server完整源码与数据库实战 简介这份资源是面向高校计算机专业学生的C#五子棋课程项目完整源码包适合正在准备课程设计、期末大作业或想通过实战巩固面向对象编程的学习者。项目覆盖人机对战与局域网联机两种模式涉及Minimax或Alpha-Beta剪枝AI算法、TCP/IP网络通信、SQLite数据库存储玩家信息与对战记录并包含BGM播放、悔棋、Windows Forms界面交互等实用功能。压缩包共200个文件以53个cs源码、47个png界面素材、33个resx与30个resources资源文件为主另含9个dll依赖、4个mp3音频及mdf/ldf数据库文件整体约53.01MB结构完整可直接编译运行。目前已有615人学习下载读者可参照源码理解棋盘状态同步、心跳机制、ADO.NET数据操作与游戏逻辑分层设计是提升综合编程能力的实战参考。1. C# 五子棋大学项目从源码到数据库一套能跑通的完整方案很多同学做 C# 五子棋课程设计时卡住的地方往往不是棋盘绘制而是「源码写完了数据库怎么接进去」。我见过太多版本棋盘用 Panel 画得挺漂亮胜负判断也能跑但一提到数据库就变成把数据写进 txt 文件或者干脆用内存里的 List 假装持久化。这个标题里的「源码数据库」其实点出了一个真实需求——你需要一套能演示完整数据链路的五子棋项目棋盘逻辑、胜负判定、玩家信息、对局记录、排行榜全部落到数据库里。这篇文章面向正在做 C# 课程设计、想拿一个能讲清楚架构的项目去答辩的同学也面向想用五子棋练手 WinForms ADO.NET 的初学者。我会把项目拆成可复现的步骤先定技术选型再建数据库表然后写棋盘和胜负判断接着把数据存取接进去最后讲几个我踩过的坑。整套方案用 WinForms SQL Server LocalDB 或 SQLite 都能跑代码结构清晰答辩时能讲出「为什么这么设计」。2. 技术选型与数据库设计为什么用 WinForms ADO.NET 而不是 EF Core2.1 大学项目里 WinForms 仍然是性价比最高的选择做课程设计时间通常只有两三周答辩时老师看的是「功能完整、代码能讲清楚、数据能存下来」。WPF 的 MVVM 架构更现代但学习曲线陡绑定写错一个属性名就白屏调试成本高。WinForms 虽然老但拖控件、写事件、直接调 GDI 画棋盘对新手最友好。我一般建议主界面用 WinForms棋盘用 Panel 的 Paint 事件画鼠标点击用 MouseClick 事件换算坐标这样代码量可控逻辑也直观。数据库方面SQL Server LocalDB 是 Visual Studio 自带的不需要额外装服务连接字符串写(localdb)\MSSQLLocalDB就能用。如果学校机房不让装 SQL Server退而求其次用 SQLite只需要一个 NuGet 包System.Data.SQLite数据库就是一个文件拷贝走就能演示。两种方案在代码层面差别不大主要是连接字符串和参数占位符不同。提示如果答辩环境不能联网提前把 NuGet 包离线安装好或者直接用 LocalDB避免现场装包翻车。2.2 数据库表设计三张表撑起玩家、对局、落子记录很多同学只建一张Users表就完事结果对局记录没地方存排行榜只能靠内存。我建议至少三张表表名字段用途PlayersId, Name, CreatedAt玩家基本信息GamesId, BlackPlayerId, WhitePlayerId, WinnerId, StartedAt, EndedAt每局对战记录MovesId, GameId, PlayerId, X, Y, MoveOrder每一步落子Players表存玩家Games表存对局Moves表存每一步。这样排行榜可以按WinnerId统计胜场复盘可以按MoveOrder回放。建表 SQL 如下CREATE TABLE Players ( Id INT IDENTITY(1,1) PRIMARY KEY, Name NVARCHAR(50) NOT NULL UNIQUE, CreatedAt DATETIME DEFAULT GETDATE() ); CREATE TABLE Games ( Id INT IDENTITY(1,1) PRIMARY KEY, BlackPlayerId INT NOT NULL, WhitePlayerId INT NOT NULL, WinnerId INT NULL, StartedAt DATETIME DEFAULT GETDATE(), EndedAt DATETIME NULL, FOREIGN KEY (BlackPlayerId) REFERENCES Players(Id), FOREIGN KEY (WhitePlayerId) REFERENCES Players(Id), FOREIGN KEY (WinnerId) REFERENCES Players(Id) ); CREATE TABLE Moves ( Id INT IDENTITY(1,1) PRIMARY KEY, GameId INT NOT NULL, PlayerId INT NOT NULL, X INT NOT NULL, Y INT NOT NULL, MoveOrder INT NOT NULL, FOREIGN KEY (GameId) REFERENCES Games(Id), FOREIGN KEY (PlayerId) REFERENCES Players(Id) );WinnerId允许为 NULL表示平局或未结束。Moves表的MoveOrder从 1 开始递增回放时按这个字段排序。注意X和Y存的是棋盘坐标0 到 14不是像素坐标这样跟界面解耦。2.3 用 ADO.NET 还是 EF Core课程设计的取舍EF Core 写起来快db.Players.Add(player)就完事但课程设计答辩时老师常问「SQL 语句在哪」用 EF Core 反而不好展示。ADO.NET 虽然要手写SqlCommand但每一步都透明适合教学场景。我一般用 ADO.NET封装一个DatabaseHelper类把连接字符串和常用操作包起来。public static class DatabaseHelper { private static readonly string ConnStr Data Source(localdb)\MSSQLLocalDB;Initial CatalogGomokuDB;Integrated SecurityTrue; public static SqlConnection GetConnection() { var conn new SqlConnection(ConnStr); conn.Open(); return conn; } public static int ExecuteNonQuery(string sql, params SqlParameter[] parameters) { using (var conn GetConnection()) using (var cmd new SqlCommand(sql, conn)) { if (parameters ! null) cmd.Parameters.AddRange(parameters); return cmd.ExecuteNonQuery(); } } public static object ExecuteScalar(string sql, params SqlParameter[] parameters) { using (var conn GetConnection()) using (var cmd new SqlCommand(sql, conn)) { if (parameters ! null) cmd.Parameters.AddRange(parameters); return cmd.ExecuteScalar(); } } }GetConnection每次新建连接ADO.NET 有连接池不会真的反复打开关闭。ExecuteNonQuery用于 INSERT/UPDATE/DELETEExecuteScalar用于 SELECT 单值比如刚插入的 Id。参数用SqlParameter传不要拼字符串否则名字里带单引号就翻车。3. 棋盘绘制与胜负判断GDI 画格子和五连检测的完整实现3.1 用 Panel 的 Paint 事件画 15x15 棋盘棋盘不用 PictureBox 贴图直接用 GDI 画代码可控缩放也方便。核心是算好格子大小和边距让棋盘居中。private const int BoardSize 15; private const int CellSize 36; private const int Margin 24; private int[,] board new int[BoardSize, BoardSize]; // 0空 1黑 2白 private void panelBoard_Paint(object sender, PaintEventArgs e) { Graphics g e.Graphics; g.SmoothingMode SmoothingMode.AntiAlias; // 画网格线 using (var pen new Pen(Color.Black, 1)) { for (int i 0; i BoardSize; i) { int x Margin i * CellSize; int y Margin i * CellSize; g.DrawLine(pen, Margin, y, Margin (BoardSize - 1) * CellSize, y); g.DrawLine(pen, x, Margin, x, Margin (BoardSize - 1) * CellSize); } } // 画棋子 for (int r 0; r BoardSize; r) { for (int c 0; c BoardSize; c) { if (board[r, c] 0) continue; int cx Margin c * CellSize; int cy Margin r * CellSize; var brush board[r, c] 1 ? Brushes.Black : Brushes.White; g.FillEllipse(brush, cx - CellSize / 2 2, cy - CellSize / 2 2, CellSize - 4, CellSize - 4); if (board[r, c] 2) g.DrawEllipse(Pens.Black, cx - CellSize / 2 2, cy - CellSize / 2 2, CellSize - 4, CellSize - 4); } } }CellSize是格子间距Margin是棋盘边距。棋子画在交叉点上所以圆心坐标是Margin c * CellSize。白棋要额外描边否则在浅色背景上看不清。每次落子后调panelBoard.Invalidate()触发重绘。3.2 鼠标点击换算棋盘坐标点击事件里把像素坐标转成行列索引注意四舍五入到最近的交叉点。private void panelBoard_MouseClick(object sender, MouseEventArgs e) { int col (int)Math.Round((e.X - Margin) / (double)CellSize); int row (int)Math.Round((e.Y - Margin) / (double)CellSize); if (row 0 || row BoardSize || col 0 || col BoardSize) return; if (board[row, col] ! 0) return; board[row, col] currentPlayer; panelBoard.Invalidate(); // 记录落子到数据库 SaveMove(currentGameId, currentPlayerId, row, col, moveOrder); if (CheckWin(row, col, currentPlayer)) { MessageBox.Show(currentPlayer 1 ? 黑棋胜 : 白棋胜); EndGame(currentPlayerId); return; } currentPlayer currentPlayer 1 ? 2 : 1; }Math.Round保证点在线附近也能落到最近交叉点。SaveMove和EndGame是数据库操作后面讲。moveOrder是全局计数器每局开始重置为 1。3.3 五连检测四个方向扫描注意边界胜负判断是五子棋的核心思路是从落子点向四个方向延伸统计连续同色棋子数。private bool CheckWin(int row, int col, int player) { int[][] dirs new int[][] { new int[] { 0, 1 }, // 横 new int[] { 1, 0 }, // 竖 new int[] { 1, 1 }, // 右下 new int[] { 1, -1 } // 左下 }; foreach (var dir in dirs) { int count 1; for (int step 1; step 5; step) { int r row dir[0] * step; int c col dir[1] * step; if (r 0 || r BoardSize || c 0 || c BoardSize) break; if (board[r, c] ! player) break; count; } for (int step 1; step 5; step) { int r row - dir[0] * step; int c col - dir[1] * step; if (r 0 || r BoardSize || c 0 || c BoardSize) break; if (board[r, c] ! player) break; count; } if (count 5) return true; } return false; }四个方向分别向正负两个方向扫count初始为 1落子本身。边界检查不能省否则数组越界直接崩。count 5而不是 5因为长连也算赢除非你规则里禁长连那另说。4. 把对局写进数据库玩家、落子、胜负的持久化4.1 玩家登录与注册用参数化查询防注入启动时先让玩家输入名字存在就查 Id不存在就插入。public static int GetOrCreatePlayer(string name) { var result DatabaseHelper.ExecuteScalar( SELECT Id FROM Players WHERE Name name, new SqlParameter(name, name)); if (result ! null) return (int)result; DatabaseHelper.ExecuteNonQuery( INSERT INTO Players (Name) VALUES (name), new SqlParameter(name, name)); return (int)DatabaseHelper.ExecuteScalar( SELECT Id FROM Players WHERE Name name, new SqlParameter(name, name)); }先查后插避免重复。name是参数占位符SQL Server 用SQLite 用或?都行。不要用字符串拼接否则名字叫OBrien就报错。4.2 开局时插入 Games 记录并拿到 GameId每局开始先插入一条 Games 记录拿到自增 Id 作为currentGameId。public static int StartGame(int blackId, int whiteId) { DatabaseHelper.ExecuteNonQuery( INSERT INTO Games (BlackPlayerId, WhitePlayerId) VALUES (b, w), new SqlParameter(b, blackId), new SqlParameter(w, whiteId)); return (int)DatabaseHelper.ExecuteScalar(SELECT SCOPE_IDENTITY()); }SCOPE_IDENTITY()返回当前会话最后插入的自增 Id比IDENTITY安全不会拿到触发器插入的 Id。SQLite 用last_insert_rowid()。4.3 每步落子写入 Moves 表落子时调SaveMove把 GameId、PlayerId、坐标、顺序写进去。public static void SaveMove(int gameId, int playerId, int x, int y, int order) { DatabaseHelper.ExecuteNonQuery( INSERT INTO Moves (GameId, PlayerId, X, Y, MoveOrder) VALUES (g, p, x, y, o), new SqlParameter(g, gameId), new SqlParameter(p, playerId), new SqlParameter(x, x), new SqlParameter(y, y), new SqlParameter(o, order)); }MoveOrder从 1 递增回放时ORDER BY MoveOrder就能还原对局。注意X和Y存的是行列索引不是像素。4.4 结束时更新 WinnerId 和 EndedAt胜负判定后更新 Games 表。public static void EndGame(int gameId, int? winnerId) { DatabaseHelper.ExecuteNonQuery( UPDATE Games SET WinnerId w, EndedAt GETDATE() WHERE Id id, new SqlParameter(w, (object)winnerId ?? DBNull.Value), new SqlParameter(id, gameId)); }winnerId为 null 时传DBNull.Value不能直接传 null否则 SqlParameter 会报错。平局就传 null。4.5 排行榜查询按胜场排序排行榜用一条 SQL 搞定。SELECT p.Name, COUNT(*) AS Wins FROM Games g JOIN Players p ON g.WinnerId p.Id WHERE g.WinnerId IS NOT NULL GROUP BY p.Name ORDER BY Wins DESC;在 C# 里用SqlDataReader读出来填到 ListView 或 DataGridView。注意COUNT(*)统计的是胜场不是对局数。如果要显示总对局数再 join 一次。5. 避坑与排查课程设计里最容易翻车的五个地方5.1 连接字符串写错LocalDB 实例名对不上现象运行时报「无法连接到 SQL Server」或者「找不到 LocalDB 实例」。原因连接字符串里实例名写成了(localdb)\v11.0或.\SQLEXPRESS但机器上装的是MSSQLLocalDB。解决在 Visual Studio 的「SQL Server 对象资源管理器」里看实际实例名或者用sqllocaldb info命令列出所有实例。连接字符串统一用(localdb)\MSSQLLocalDB这是 VS 2019/2022 默认的。5.2 棋盘重绘闪烁棋子画一半消失现象落子后棋盘闪一下棋子有时显示不全。原因Panel默认没开双缓冲Invalidate触发重绘时先擦背景再画中间态被看到。解决自定义一个继承 Panel 的类构造函数里设DoubleBuffered true和SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true)。或者直接在 Form 的构造函数里对 panel 设DoubleBuffered但 Panel 的该属性是 protected得用反射或子类。5.3 落子顺序错乱回放时棋子位置对不上现象回放对局时棋子出现的顺序和实际下棋顺序不一致。原因MoveOrder用了全局静态变量新开一局没重置或者多局并发时互相覆盖。解决每局开始时把moveOrder重置为 1并且把它作为Game对象的字段不要用 static。插入时严格按当前局递增查询时ORDER BY MoveOrder。5.4 参数化查询里 DBNull 和 null 混用现象平局时更新WinnerId报「参数化查询未提供参数值」。原因C# 的null和数据库的DBNull.Value不是一回事SqlParameter不接受 null。解决统一写(object)value ?? DBNull.Value或者判断if (value null) param.Value DBNull.Value;。这个坑在WinnerId、EndedAt这类可空字段上特别常见。5.5 数据库文件没跟着项目走换台机器就找不到现象在自己电脑上跑得好好的拷到同学电脑或答辩机房就报「数据库不存在」。原因用了 SQLite 但数据库文件路径写的是绝对路径或者用了 LocalDB 但没导出建表脚本。解决SQLite 用相对路径|DataDirectory|把 .db 文件放在输出目录LocalDB 则把建表 SQL 存成init.sql程序启动时检查表是否存在不存在就执行建表。这样换机器只要跑一次程序就能自动建库。6. 进阶技巧用 Moves 表做对局回放和 AI 落子分析6.1 从 Moves 表回放整局对弈有了Moves表回放就是按MoveOrder顺序重放落子。写一个ReplayGame(int gameId)方法先清空棋盘再逐步读取。public static ListMoveRecord GetMoves(int gameId) { var list new ListMoveRecord(); using (var conn DatabaseHelper.GetConnection()) using (var cmd new SqlCommand( SELECT PlayerId, X, Y, MoveOrder FROM Moves WHERE GameId g ORDER BY MoveOrder, conn)) { cmd.Parameters.AddWithValue(g, gameId); using (var reader cmd.ExecuteReader()) { while (reader.Read()) { list.Add(new MoveRecord { PlayerId reader.GetInt32(0), X reader.GetInt32(1), Y reader.GetInt32(2), Order reader.GetInt32(3) }); } } } return list; }拿到列表后用一个Timer每隔 500ms 重放一步调board[x, y] playerId然后Invalidate()。这样答辩时能演示「历史对局回放」比单纯下棋更有说服力。注意PlayerId要映射回黑棋1还是白棋2可以在Games表里查BlackPlayerId和WhitePlayerId对比。6.2 用落子数据做简单的 AI 评分如果想再加点亮点可以用Moves表统计每个位置的落子频率做一个最简 AI在空位里选一个「历史上下得最多」的位置。虽然粗糙但能讲出「数据驱动」的思路。public static (int x, int y) SuggestMove(int gameId) { var sql SELECT TOP 1 X, Y, COUNT(*) AS Freq FROM Moves WHERE GameId g GROUP BY X, Y ORDER BY Freq DESC; using (var conn DatabaseHelper.GetConnection()) using (var cmd new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue(g, gameId); using (var reader cmd.ExecuteReader()) { if (reader.Read()) return (reader.GetInt32(0), reader.GetInt32(1)); } } return (7, 7); // 默认天元 }这个查询统计当前对局里出现次数最多的坐标作为推荐落子点。实际 AI 当然要用极大极小或蒙特卡洛但课程设计里能展示「从数据库读数据、做简单统计、给出建议」这条链路已经比纯随机落子强很多。注意TOP 1是 SQL Server 语法SQLite 用LIMIT 1。6.3 导出对局记录到 CSV 做答辩材料答辩时老师可能要看数据把Moves表导成 CSV 很实用。public static void ExportMovesToCsv(int gameId, string path) { var moves GetMoves(gameId); using (var writer new StreamWriter(path)) { writer.WriteLine(Order,PlayerId,X,Y); foreach (var m in moves) writer.WriteLine(${m.Order},{m.PlayerId},{m.X},{m.Y}); } }导出后用 Excel 打开能画个落子热力图答辩时放 PPT 里很直观。路径用Path.Combine(Application.StartupPath, moves.csv)避免权限问题。6.4 我自己的习惯先跑通数据链路再优化界面我做这类课程设计有个固定顺序先建库建表写一个控制台程序把增删改查跑通确认数据能进能出然后再做 WinForms 界面把按钮事件接到已经验证过的数据库方法上最后才调界面细节和动画。这样如果数据库连不上问题一定在连接字符串或 SQL不会跟界面代码混在一起。血泪经验是别一上来就拖控件画界面等界面画好了才发现数据库连不上回头改代码更痛苦。希望帮到你。本文还有配套的精品资源点击获取