C#控制台骰子游戏开发:从面向对象设计到随机数生成实战
2026/8/5 13:01:42 网站建设 项目流程

1. 项目概述:从零到一构建一个C#控制台骰子游戏

最近在整理硬盘里的老项目,翻到了一个当年学C#时写的投骰子游戏。别看它现在看起来简单,但麻雀虽小五脏俱全,里面包含了从变量定义、流程控制到面向对象思想的初步实践,可以说是新手入门C#编程一个绝佳的练手项目。这个游戏的核心逻辑就是模拟现实中的掷骰子比大小,两个玩家(或一个玩家与电脑)轮流投掷,根据点数决出胜负。它不依赖任何图形界面,完全在控制台(Console)中运行,这让我们可以专注于逻辑本身,而不被复杂的UI代码分散精力。对于刚接触C#的朋友来说,通过实现这样一个完整的、可运行的小游戏,能快速建立起对程序结构、类与对象、随机数生成以及用户交互的直观理解。今天,我就把这个项目的完整实现思路、关键代码以及我踩过的那些“坑”都梳理出来,手把手带你复现这个经典的小游戏。

2. 游戏核心设计与架构思路拆解

在动手写代码之前,我们先得把游戏规则和程序结构想清楚。一个清晰的架构能让你在编码时事半功倍,避免写到一半逻辑混乱。

2.1 游戏规则与需求定义

我们的投骰子游戏规则可以设计得很灵活,这里我采用一个经典的双人对战模式(玩家A vs 玩家B,或玩家 vs 电脑),规则如下:

  1. 每轮游戏,双方各投掷一次骰子。骰子是一个标准的六面体,点数范围为1到6。
  2. 比较双方本轮投掷的点数,点数高者获胜,获得1分。如果点数相同,则为平局,双方均不得分。
  3. 游戏可以进行多轮,直到某一方达到预设的胜利分数(比如先得5分者胜),或者玩家选择退出。
  4. 每一轮结束后,需要显示双方的点数、当前得分,并询问是否继续下一轮。

基于这些规则,我们可以提炼出程序需要处理的核心对象和功能:

  • 骰子 (Dice):一个能随机产生1-6点数的实体。这是游戏的核心“道具”。
  • 玩家 (Player):参与游戏的角色,需要有名字和记录当前得分的属性。
  • 游戏控制器 (GameController):负责协调整个游戏的流程,包括初始化、轮次控制、判定胜负、显示结果等。
  • 用户交互:通过控制台接收玩家的指令(如按任意键投掷、选择是否继续)。

2.2 面向对象的设计思路

虽然是一个小游戏,但我们依然可以用面向对象(OOP)的思想来设计,这会让代码更清晰、更易维护。我们主要设计两个类:

  1. Dice类:代表骰子。

    • 属性:可能包括骰子的面数(Sides),虽然我们默认是6面,但设计成属性可以增加扩展性(比如未来想做20面骰子)。
    • 方法:一个核心的Roll()方法,调用后返回一个1到面数之间的随机整数。
  2. Player类:代表玩家。

    • 属性:玩家姓名(Name)、当前得分(Score)。
    • 方法:一个PlayTurn(Dice dice)方法,表示玩家进行一次投掷。这个方法内部会调用传入的骰子对象的Roll()方法,并返回点数。加分操作可以放在游戏控制器里,也可以封装在Player类中,这里为了职责清晰,我们先放在控制器里。
  3. Program类(主程序):这里将包含Main入口方法,并承担游戏控制器的角色。对于小型项目,不必过度设计,将游戏流程控制逻辑直接写在Main方法或主程序类的其他方法中是完全可以接受的。

2.3 技术选型考量:为什么用控制台(Console)?

你可能会问,为什么不直接用Windows窗体(WinForms)或WPF做个带按钮和图片的图形界面?对于初学者而言,控制台应用有不可替代的优势:

  • 专注逻辑:剥离了复杂的UI事件处理、布局和渲染,让你能100%聚焦在C#语法、程序流程和面向对象编程上。
  • 快速反馈Console.WriteLineConsole.ReadLine能让你立刻看到程序的输出和进行输入,调试和验证逻辑非常直观。
  • 入门门槛低:任何安装了.NET SDK的电脑,用记事本和命令行都能编写运行,环境依赖最小。

当你熟练掌握了控制台编程的核心后,再将这套游戏逻辑迁移到图形界面,会变得水到渠成。那时你只需要关心如何将“投掷”这个动作绑定到按钮点击事件,以及如何将点数结果显示在图片或Label上,核心的DicePlayer类几乎可以原封不动地复用。

3. 核心类实现与关键技术点解析

接下来,我们进入具体的代码实现环节。我会分模块讲解,并附上完整的代码和详细注释。

3.1 Dice类的实现:随机数的艺术

骰子的核心是随机数。在C#中,我们使用System.Random类来生成伪随机数。

using System; namespace DiceGame { /// <summary> /// 表示一个多面骰子。 /// </summary> public class Dice { // 随机数生成器实例。声明为readonly,因为一旦创建就不需要更换。 private readonly Random _random; // 骰子的面数,默认为6。 public int Sides { get; } /// <summary> /// 初始化骰子,可以指定面数。 /// </summary> /// <param name="sides">骰子面数,必须大于1。</param> public Dice(int sides = 6) { if (sides <= 1) throw new ArgumentException("骰子面数必须大于1", nameof(sides)); Sides = sides; // 初始化Random实例。注意:在短时间内快速创建多个Random对象可能导致随机数序列相同。 _random = new Random(); } /// <summary> /// 投掷骰子,返回一个1到Sides之间的随机整数。 /// </summary> /// <returns>投掷结果点数。</returns> public int Roll() { // Next(minValue, maxValue) 返回一个大于等于minValue,小于maxValue的整数。 // 所以这里参数是1和Sides+1,才能得到1到Sides(包含)的值。 return _random.Next(1, Sides + 1); } } }

关键技术点与避坑指南:

  1. Random的陷阱Random类的默认构造函数使用系统时间作为种子。如果在极短的时间内(比如毫秒级)连续创建多个Random实例,它们可能会获得相同的时间种子,从而导致生成完全相同的“随机”序列。这就是为什么我们将_random作为类的成员变量,只初始化一次,然后在每次Roll时重复使用同一个实例。
  2. Next方法的范围Random.Next(minValue, maxValue)方法返回值的范围是[minValue, maxValue),即包含下限,不包含上限。为了得到1到6(包含6)的整数,我们必须传入Next(1, 7)。代码中我们用Sides + 1来保证通用性。
  3. 参数校验:在构造函数中对sides参数进行校验是一个好习惯,可以避免程序在后续运行中出现非预期的错误(比如面数为0或负数)。

3.2 Player类的实现:游戏参与者的抽象

Player类相对简单,主要目的是封装与玩家相关的数据和行为。

namespace DiceGame { /// <summary> /// 表示一个游戏玩家。 /// </summary> public class Player { // 玩家姓名 public string Name { get; } // 玩家当前得分 public int Score { get; private set; } /// <summary> /// 初始化玩家。 /// </summary> /// <param name="name">玩家名称。</param> public Player(string name) { // 简单的空值或空白检查 if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("玩家名称不能为空或空白", nameof(name)); Name = name; Score = 0; // 初始分数为0 } /// <summary> /// 玩家进行一回合操作:投掷指定的骰子。 /// </summary> /// <param name="dice">要投掷的骰子对象。</param> /// <returns>本次投掷的点数。</returns> public int PlayTurn(Dice dice) { if (dice == null) throw new ArgumentNullException(nameof(dice), "骰子对象不能为空"); // 调用骰子的Roll方法,并返回结果 return dice.Roll(); } /// <summary> /// 为玩家增加分数。 /// </summary> /// <param name="points">要增加的分数,默认为1。</param> public void AddScore(int points = 1) { if (points < 0) throw new ArgumentException("增加的分数不能为负数", nameof(points)); Score += points; } /// <summary> /// 重置玩家分数为0。 /// </summary> public void ResetScore() { Score = 0; } } }

设计思考:

  1. 属性与字段NameScore被设计为属性(Property)。Name只有getter,因为名字在创建后不应更改。Score有getter和private setter,意味着外部只能读取分数,修改分数必须通过类提供的AddScoreResetScore方法,这体现了封装性,可以确保分数修改的规则由类内部控制。
  2. 方法职责PlayTurn方法接收一个Dice对象作为参数,而不是在内部创建。这是一种“依赖注入”的简单形式,使得Player类不依赖于特定的Dice实例,更灵活也更易于测试(比如可以传入一个测试用的、固定点数的骰子)。
  3. 输入验证:对传入的参数进行合法性检查是编写健壮代码的基础。这里检查了名字是否为空以及骰子对象是否为null。

4. 游戏主流程与控制器实现

有了DicePlayer这两个“零件”,我们现在需要把它们组装起来,编写游戏运行的“流水线”。这部分逻辑我们放在Program.csMain方法中。

4.1 游戏初始化与配置

using System; namespace DiceGame { class Program { static void Main(string[] args) { Console.WriteLine("=== 欢迎来到C#掷骰子对战游戏 ==="); Console.WriteLine(); // 1. 游戏配置 int winningScore = 5; // 胜利所需分数 Dice gameDice = new Dice(); // 创建一颗6面骰子 // 2. 玩家初始化 Console.Write("请输入玩家1的名字: "); string player1Name = Console.ReadLine()?.Trim() ?? "玩家1"; Player player1 = new Player(player1Name); Console.Write("请输入玩家2的名字 (直接回车则与电脑对战): "); string player2Input = Console.ReadLine()?.Trim(); Player player2; bool vsComputer = string.IsNullOrEmpty(player2Input); if (vsComputer) { player2 = new Player("电脑"); Console.WriteLine($"玩家【{player1.Name}】将对战【{player2.Name}】!"); } else { player2 = new Player(player2Input); Console.WriteLine($"玩家【{player1.Name}】将对战玩家【{player2.Name}】!"); } Console.WriteLine($"游戏规则:率先获得{winningScore}分者获胜!"); Console.WriteLine("按任意键开始游戏..."); Console.ReadKey(); Console.Clear();

这部分代码完成了游戏的准备工作:

  • 定义了胜利条件winningScore
  • 创建了游戏中共用的骰子对象gameDice
  • 通过控制台交互获取玩家姓名,并实现了“人机对战”的选项。这里用了一个小技巧:Console.ReadLine()?.Trim() ?? “玩家1”,这是C#中的空值合并运算符,如果输入为空或空白,则使用默认值。
  • Console.ReadKey()Console.Clear()用于控制流程节奏和清屏,让界面更清爽。

4.2 核心游戏循环与胜负判定

这是游戏最核心的部分,用一个while循环来控制整个对局流程。

// 3. 游戏主循环 int round = 1; bool gameOver = false; Player? currentWinner = null; // 用于记录最终的胜者 while (!gameOver) { Console.WriteLine($"\n--- 第 {round} 轮 ---"); // 玩家1回合 Console.WriteLine($"{player1.Name}的回合,按任意键投掷骰子..."); Console.ReadKey(true); // true参数表示不显示按下的键 int roll1 = player1.PlayTurn(gameDice); Console.WriteLine($"{player1.Name}投出了: {roll1}点"); // 玩家2回合 if (vsComputer) { Console.WriteLine($"{player2.Name}的回合,正在思考..."); System.Threading.Thread.Sleep(800); // 模拟电脑“思考”时间,增加趣味性 } else { Console.WriteLine($"{player2.Name}的回合,按任意键投掷骰子..."); Console.ReadKey(true); } int roll2 = player2.PlayTurn(gameDice); Console.WriteLine($"{player2.Name}投出了: {roll2}点"); // 胜负判定 Console.WriteLine(); if (roll1 > roll2) { player1.AddScore(); Console.WriteLine($"本轮胜者:{player1.Name}! +1分"); } else if (roll2 > roll1) { player2.AddScore(); Console.WriteLine($"本轮胜者:{player2.Name}! +1分"); } else { Console.WriteLine("本轮平局!双方不得分。"); } // 显示当前比分 Console.WriteLine($"当前比分:{player1.Name} {player1.Score} : {player2.Score} {player2.Name}"); // 检查是否达到胜利条件 if (player1.Score >= winningScore) { gameOver = true; currentWinner = player1; } else if (player2.Score >= winningScore) { gameOver = true; currentWinner = player2; } // 如果游戏未结束,询问是否继续 if (!gameOver) { Console.WriteLine("\n是否继续下一轮?(按 Y/y 继续,其他任意键退出)"); var key = Console.ReadKey(true).Key; if (key != ConsoleKey.Y) { Console.WriteLine("游戏提前结束。"); break; // 跳出循环,结束游戏 } } Console.Clear(); round++; }

流程详解与技巧:

  1. 回合制流程:每一轮,双方依次投掷。代码清晰展示了“提示 -> 等待用户输入 -> 执行动作 -> 显示结果”的标准交互模式。
  2. 人机对战差异化处理:通过vsComputer布尔变量,在玩家2回合时做了区分。对人机模式,添加了Thread.Sleep来制造延迟,模拟电脑思考,让游戏体验更自然。Console.ReadKey(true)中的true参数非常有用,它能让按键不被显示在控制台上,保持界面整洁。
  3. 胜负判定逻辑:简单的if-else if-else结构,清晰易懂。注意这里调用了Player.AddScore()方法来更新分数,而不是直接操作Score属性。
  4. 循环终止条件:循环由gameOver布尔变量控制。当有任何一方分数达到winningScore时,gameOver被设为true,并在本轮循环末尾退出。同时,还提供了中途退出的选项,增加了程序的友好性。

4.3 游戏结束与结果展示

游戏循环结束后,我们需要向玩家展示最终结果。

// 4. 游戏结束,显示结果 Console.WriteLine("\n========== 游戏结束 =========="); if (currentWinner != null) { Console.WriteLine($"恭喜!【{currentWinner.Name}】以 {currentWinner.Score} 分获胜!"); } else { Console.WriteLine($"最终比分:{player1.Name} {player1.Score} : {player2.Score} {player2.Name}"); } Console.WriteLine($"总共进行了 {round - 1} 轮对战。"); Console.WriteLine("\n感谢游玩!按任意键退出..."); Console.ReadKey(); } } }

这部分代码简单明了,根据currentWinner是否为空来判断是产生了胜者还是中途退出,并输出相应的总结信息。

5. 功能扩展与代码优化实战

一个基础版本完成后,我们可以思考如何让它变得更好玩、更健壮。这里提供几个扩展方向,并附上实现代码。

5.1 扩展一:支持多种骰子与投掷规则

现实中的桌游往往不止一颗骰子,或者有特殊的投掷规则(如“掷两次取最高”)。我们可以轻松扩展DicePlayer类来支持。

首先,增强Player.PlayTurn方法,使其支持投掷多颗骰子并应用规则:

// 在Player类中添加新方法 public int PlayTurn(Dice dice, int numberOfDice = 1, string rule = "sum") { if (dice == null) throw new ArgumentNullException(nameof(dice)); if (numberOfDice <= 0) throw new ArgumentException("骰子数量必须大于0", nameof(numberOfDice)); int total = 0; int maxRoll = 0; int minRoll = dice.Sides + 1; // 初始化为一个不可能的最大值 for (int i = 0; i < numberOfDice; i++) { int roll = dice.Roll(); total += roll; if (roll > maxRoll) maxRoll = roll; if (roll < minRoll) minRoll = roll; } return rule.ToLower() switch { "max" => maxRoll, // 规则:取最大值 "min" => minRoll, // 规则:取最小值 _ => total, // 默认规则:求和 }; }

然后在主程序中,修改游戏配置和投掷调用:

// 在Main方法初始化部分 int diceCount = 2; // 每次投掷2颗骰子 string diceRule = "sum"; // 规则:求和。可改为"max"或"min" // 在游戏循环中,修改投掷调用 int roll1 = player1.PlayTurn(gameDice, diceCount, diceRule); int roll2 = player2.PlayTurn(gameDice, diceCount, diceRule);

优化点:这里使用了C# 8.0引入的switch表达式,让多规则返回的代码非常简洁。通过增加参数,我们极大地增强了游戏的策略性和可玩性,而改动却很小,这得益于前期良好的类设计。

5.2 扩展二:引入游戏历史记录

为了让游戏更有趣,我们可以记录每一轮的结果,并在最后展示统计信息。

// 首先,定义一个简单的历史记录类 public class RoundRecord { public int RoundNumber { get; set; } public int Player1Roll { get; set; } public int Player2Roll { get; set; } public string Winner { get; set; } // 胜者名字,或“平局” } // 在Program类的Main方法中,初始化一个列表来存储记录 List<RoundRecord> gameHistory = new List<RoundRecord>(); // 在每一轮胜负判定后,添加记录 var record = new RoundRecord { RoundNumber = round, Player1Roll = roll1, Player2Roll = roll2, Winner = roll1 > roll2 ? player1.Name : (roll2 > roll1 ? player2.Name : "平局") }; gameHistory.Add(record); // 在游戏结束后,展示历史记录 Console.WriteLine("\n========== 对战历史 =========="); foreach (var r in gameHistory) { Console.WriteLine($"第{r.RoundNumber}轮: {player1.Name}[{r.Player1Roll}] vs {player2.Name}[{r.Player2Roll}] -> 胜者: {r.Winner}"); } // 可以做一些简单的统计 var player1Wins = gameHistory.Count(r => r.Winner == player1.Name); var player2Wins = gameHistory.Count(r => r.Winner == player2.Name); var draws = gameHistory.Count(r => r.Winner == "平局"); Console.WriteLine($"统计:{player1.Name}胜{player1Wins}轮,{player2.Name}胜{player2Wins}轮,平局{draws}轮。");

这个功能不仅增加了数据的可回顾性,也为后续可能的数据分析(比如胜率统计)打下了基础。

5.3 扩展三:异常处理与输入验证强化

基础版本对用户输入的处理比较简陋。我们需要让它更健壮。

// 示例:强化获取胜利分数的代码 int winningScore = 0; bool validInput = false; while (!validInput) { Console.Write("请输入胜利所需分数(正整数): "); string input = Console.ReadLine(); if (int.TryParse(input, out winningScore) && winningScore > 0) { validInput = true; } else { Console.WriteLine("输入无效,请输入一个大于0的整数。"); } } // 示例:在游戏循环的“是否继续”环节,提供更明确的提示 if (!gameOver) { Console.WriteLine("\n是否继续下一轮?(按 Y/y 继续,按 N/n 结束,其他键查看帮助)"); var key = Console.ReadKey(true).Key; switch (key) { case ConsoleKey.Y: // 继续游戏 break; case ConsoleKey.N: Console.WriteLine("游戏提前结束。"); return; // 直接退出Main方法 default: Console.WriteLine("帮助:Y-继续,N-退出游戏。"); // 可以在这里不执行任何操作,循环会继续,并再次询问 continue; // 跳过本轮循环剩余部分,直接开始下一轮询问 } }

经验之谈:对于所有来自外部(用户、文件、网络)的输入,都必须进行验证。int.TryParse是处理数字输入的利器。对于选项菜单,使用switch语句比一连串的if-else更清晰。return可以用于在深层嵌套中直接退出方法,而continue可以跳过本轮循环的后续代码。

6. 常见问题排查与调试技巧实录

即使代码逻辑清晰,在实际编写和运行中,你依然可能会遇到一些问题。下面是我在开发和教学过程中总结的一些常见坑点及解决方法。

6.1 随机数不“随机”,总是生成相同序列

问题现象:每次运行程序,骰子投出的点数序列都一样。问题根源:在Dice类的Roll方法内部,或者循环中多次new Random()错误示例

public int Roll() { Random rnd = new Random(); // 错误!每次调用都新建实例 return rnd.Next(1, 7); }

解决方案:正如我们在正确示例中所做,将Random实例作为类的readonly字段,在构造函数中初始化一次。更深层原因Random的默认种子基于系统时间。如果代码执行速度极快,连续两次new Random()可能获得相同的种子值。在循环中,这会导致整个循环产生相同的数字。更安全的做法是使用一个静态的Random实例,但要注意多线程问题。对于本例,每个游戏一个Dice实例(内含一个Random)是完全足够的。

6.2 控制台输出混乱或闪烁

问题现象:文字重叠,或者屏幕频繁清屏导致闪烁。解决方案与技巧

  1. 合理使用Console.Clear():不要在每个WriteLine后面都清屏。通常在每一轮开始前或玩家做出重要选择后清屏一次即可,保持界面阶段性刷新。
  2. 使用Console.SetCursorPosition进行局部更新:对于需要频繁更新的信息(比如倒计时、动态分数),可以计算好位置,只更新那一行,而不是重绘整个屏幕。
    // 示例:在固定位置更新分数 int scoreLeft = 10; int scoreTop = 2; Console.SetCursorPosition(scoreLeft, scoreTop); Console.Write($"分数: {score} "); // 多写几个空格覆盖旧内容
  3. 考虑关闭输入回显:使用Console.ReadKey(true)可以避免按键字符出现在屏幕上。

6.3 游戏逻辑错误:平局处理或分数判定异常

问题排查步骤

  1. 使用断点调试:在Visual Studio或VS Code中,在if (roll1 > roll2)这一行设置断点。运行程序,当断点触发时,将鼠标悬停在roll1roll2变量上,查看它们的值是否符合预期。
  2. 添加日志输出:在关键步骤后添加临时的Console.WriteLine,输出中间状态。例如,在PlayTurn方法里输出Roll()的结果,确保随机数生成正确。
  3. 检查边界条件:特意测试平局的情况,看是否执行了正确的分支。测试当一方分数恰好等于winningScore时,游戏是否能正确结束。
  4. 单元测试(进阶):为DicePlayer类编写简单的单元测试。例如,创建一个返回固定值的“模拟骰子”来测试PlayTurn和胜负判定逻辑。
    // 一个用于测试的“假骰子” public class FixedDice : Dice { private int _fixedValue; public FixedDice(int fixedValue) : base(6) { _fixedValue = fixedValue; } public new int Roll() // 隐藏基类方法(注意,这仅是测试的权宜之计) { return _fixedValue; } } // 测试代码 var testDice = new FixedDice(5); var player = new Player("Test"); int result = player.PlayTurn(testDice); // result应该永远是5

6.4 代码结构臃肿,Main方法过长

问题现象:随着功能增加,Main方法变得非常长,难以阅读和维护。重构方案:将不同的功能模块抽取成独立的方法。

  • InitializeGame(): 负责玩家初始化、规则设置。
  • PlaySingleRound(Player p1, Player p2, Dice dice): 执行一轮对战,返回获胜者或null(平局)。
  • DisplayRoundResult(...): 显示一轮的结果。
  • CheckGameOver(...): 检查游戏是否结束。
  • DisplayFinalResult(...): 显示最终结果和历史。

这样,Main方法就会变得非常简洁,就像一个总指挥:

static void Main() { InitializeGame(out var player1, out var player2, out var dice, out var winningScore); var history = new List<RoundRecord>(); while (true) { var result = PlaySingleRound(player1, player2, dice, roundNumber); history.Add(result); DisplayRoundResult(result); if (CheckGameOver(player1, player2, winningScore)) break; if (!AskToContinue()) break; } DisplayFinalResult(player1, player2, history); }

这种重构不仅使代码更清晰,也使得每个小功能更容易被单独测试和理解。从这个小项目开始,就养成编写整洁、模块化代码的习惯,对未来开发大型项目至关重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询