C#游戏开发入门:从零实现石头剪刀布游戏,掌握核心编程思想

📅 2026/8/5 5:00:03
C#游戏开发入门:从零实现石头剪刀布游戏,掌握核心编程思想
1. 项目概述与核心价值最近在社区里看到不少朋友对C#游戏开发感兴趣但往往被一些复杂的框架和引擎吓退觉得入门门槛太高。其实游戏开发的乐趣在于从简单到复杂的过程而“石头剪刀布”这个看似简单的游戏恰恰是理解游戏开发核心循环、状态管理和用户交互的绝佳起点。它不依赖复杂的图形渲染和物理引擎却能让你完整地走一遍从需求分析、逻辑设计、代码实现到交互测试的全流程。对于刚接触C#或者想从控制台应用转向更结构化编程的开发者来说这是一个成本极低、反馈极快的练手项目。这个项目能帮你解决几个实际问题第一理解事件驱动的编程思想如何处理用户的输入并做出响应第二掌握基本的游戏状态机设计如何清晰地定义游戏规则和胜负判定第三实践面向对象的设计将玩家、游戏逻辑、界面展示进行合理的分离。无论你是想为学习更复杂的Unity开发打基础还是单纯想提升自己的C#实战能力亲手实现一个可玩的“石头剪刀布”游戏都会让你获益匪浅。接下来我会带你从零开始一步步构建这个游戏并深入每个环节背后的设计考量。2. 游戏核心逻辑与类结构设计在动手写代码之前花点时间设计好程序的骨架至关重要。一个好的设计能让代码更清晰、更容易维护和扩展。对于“石头剪刀布”游戏我们需要抽象出几个核心概念。2.1 枚举Enum定义游戏元素首先游戏中的“石头”、“剪刀”、“布”是三种固定的出拳选项。在C#中使用枚举enum来定义它们是最佳选择这比直接用整数012或字符串“Rock”, “Scissors”, “Paper”更安全、可读性更强。public enum Move { Rock, // 石头 Scissors, // 剪刀 Paper // 布 }这里有个细节枚举成员的顺序本身没有逻辑意义但我们在后续比较逻辑中会依赖它们。一种常见的比较逻辑是“循环相克”石头赢剪刀剪刀赢布布赢石头。我们可以通过为枚举赋值特定的整数值或者直接在比较逻辑中处理这种关系。我推荐后者因为它更清晰不依赖于枚举的底层整数值。2.2 游戏规则引擎的设计胜负判定是游戏的核心。我们需要一个独立的“裁判”模块它接收玩家和电脑的出拳然后根据规则返回结果。这符合“单一职责原则”。我们可以定义一个GameRule类里面包含一个静态方法来判断胜负public static class GameRule { public enum Result { Win, Lose, Draw } public static Result Judge(Move playerMove, Move computerMove) { if (playerMove computerMove) return Result.Draw; // 核心胜负逻辑石头赢剪刀剪刀赢布布赢石头 if ((playerMove Move.Rock computerMove Move.Scissors) || (playerMove Move.Scissors computerMove Move.Paper) || (playerMove Move.Paper computerMove Move.Rock)) { return Result.Win; } // 其他情况均为玩家输 return Result.Lose; } }为什么要把Judge方法设计成静态的因为游戏规则是固定的、无状态的不需要创建GameRule的实例。使用静态类和方法调用起来非常方便GameRule.Judge(playerChoice, computerChoice)。注意这里的胜负逻辑是硬编码的。如果你想让游戏支持“蜥蜴史波克”这种扩展规则就需要重构这里的设计。一个更灵活的方式是使用一个DictionaryMove, ListMove来定义每个Move能战胜哪些Move。这是后续扩展性的一个思考点。2.3 玩家与电脑角色的抽象接下来是参与者。我们有两个角色人类玩家和电脑对手。他们都有“出拳”这个行为但行为方式不同。玩家通过输入选择电脑则随机生成。这正好可以用面向对象的“多态”特性来实现。首先定义一个抽象的Player基类public abstract class Player { public string Name { get; protected set; } public Move CurrentMove { get; protected set; } // 抽象方法要求子类必须实现如何做出选择 public abstract void MakeMove(); }然后创建两个子类public class HumanPlayer : Player { public HumanPlayer(string name) { Name name; } public override void MakeMove() { Console.WriteLine(\n请出拳1. 石头 2. 剪刀 3. 布); string input Console.ReadLine(); switch (input) { case 1: CurrentMove Move.Rock; break; case 2: CurrentMove Move.Scissors; break; case 3: CurrentMove Move.Paper; break; default: Console.WriteLine(输入无效请重新输入。); MakeMove(); // 递归调用直到输入正确 break; } } } public class ComputerPlayer : Player { private static Random _random new Random(); public ComputerPlayer() { Name 电脑; } public override void MakeMove() { // 随机生成0,1,2对应枚举的三个值 int randomIndex _random.Next(0, 3); CurrentMove (Move)randomIndex; } }这里有几个关键点基类Player它定义了所有玩家共有的属性Name,CurrentMove和行为契约MakeMove方法。这保证了无论是人还是电脑游戏主循环都可以用统一的方式调用player.MakeMove()。HumanPlayer.MakeMove()这里处理了用户输入。注意我使用了switch和递归。当输入无效时递归调用自身重新获取输入这是一个简单有效的输入验证循环。在实际项目中你可能需要限制递归深度或改用循环但对于这个简单游戏足够了。ComputerPlayer.MakeMove()使用Random.Next(0, 3)生成一个0到2包含0不包含3的随机整数然后强制转换为Move枚举。这里_random是静态的避免在短时间内多次创建Random实例导致生成相同随机数序列的问题。命名与随机数种子ComputerPlayer的Name属性在构造函数中直接赋值为“电脑”。Random实例是静态的这在整个程序生命周期内通常没问题。但如果你的游戏需要可重现的随机序列例如用于测试则需要传入一个种子。这样的设计将变化的逻辑人类输入和电脑随机封装在各自的具体类中而游戏主流程依赖于稳定的抽象Player基类这就是面向对象设计中“依赖倒置”原则的一个简单体现。3. 游戏主循环与状态管理实现有了核心的类结构我们就可以搭建游戏的运行框架了。游戏主循环是驱动整个游戏进程的引擎它负责协调各个模块管理游戏状态开始、进行中、单局结束、全局结束并处理用户交互。3.1 控制台交互与游戏初始化我们选择控制台应用作为起点因为它能让我们专注于游戏逻辑本身而不被复杂的UI干扰。首先在Main方法中我们进行一些初始化工作。class Program { static void Main(string[] args) { Console.Title C# 石头剪刀布游戏; Console.WriteLine( 欢迎来到石头剪刀布游戏 \n); // 初始化玩家 Console.Write(请输入你的名字); string playerName Console.ReadLine() ?? 玩家; HumanPlayer human new HumanPlayer(playerName); ComputerPlayer computer new ComputerPlayer(); // 游戏主循环 RunGameLoop(human, computer); Console.WriteLine(\n游戏结束按任意键退出...); Console.ReadKey(); } }这里做了几件事设置控制台标题、打印欢迎信息、创建人类玩家和电脑玩家实例。注意在获取玩家名字时我们使用了空值合并运算符??如果用户直接按回车导致输入为空则赋予一个默认名“玩家”避免后续出现空引用异常。3.2 核心游戏循环的构建RunGameLoop方法是游戏的心脏。它需要处理多局游戏记录比分并在每局结束后询问玩家是否继续。static void RunGameLoop(HumanPlayer human, ComputerPlayer computer) { int playerScore 0; int computerScore 0; int round 1; bool playAgain true; while (playAgain) { Console.WriteLine($\n--- 第 {round} 局 ---); // 1. 双方出拳 human.MakeMove(); computer.MakeMove(); Console.WriteLine(${human.Name} 出了{human.CurrentMove}); Console.WriteLine(${computer.Name} 出了{computer.CurrentMove}); // 2. 判定胜负 GameRule.Result result GameRule.Judge(human.CurrentMove, computer.CurrentMove); // 3. 显示结果并更新比分 switch (result) { case GameRule.Result.Win: Console.WriteLine($恭喜{human.Name} 赢了); playerScore; break; case GameRule.Result.Lose: Console.WriteLine($很遗憾{computer.Name} 赢了。); computerScore; break; case GameRule.Result.Draw: Console.WriteLine(平局); break; } Console.WriteLine($当前比分{human.Name} {playerScore} - {computerScore} {computer.Name}); // 4. 询问是否继续 Console.Write(\n是否继续游戏(y/n): ); string continueInput Console.ReadLine()?.ToLower(); playAgain (continueInput y || continueInput yes); if (playAgain) { round; } } // 5. 游戏结束显示最终结果 Console.WriteLine(\n 最终战绩 ); Console.WriteLine($总局数{round - 1}); Console.WriteLine(${human.Name}: {playerScore} 胜); Console.WriteLine(${computer.Name}: {computerScore} 胜); Console.WriteLine($平局{round - 1 - playerScore - computerScore}); if (playerScore computerScore) Console.WriteLine($\n {human.Name} 获得最终胜利); else if (playerScore computerScore) Console.WriteLine($\n{computer.Name} 获得了最终胜利。); else Console.WriteLine($\n最终战成平手); }这个循环包含了游戏的一个完整回合出拳阶段依次调用玩家和电脑的MakeMove方法。注意顺序通常是玩家先出电脑后出虽然对于随机来说没区别。判定阶段调用GameRule.Judge方法传入双方的出拳。反馈阶段根据判定结果输出对战信息并更新比分。这里使用switch语句清晰地处理三种情况。循环控制询问玩家是否继续。这里对输入做了简单处理只识别“y”或“yes”不区分大小写为继续其他任何输入都视为结束。这是一个简化的设计在实际应用中你可能需要更健壮的输入验证。终局统计当玩家选择不再继续后跳出循环打印最终的比赛统计信息。这个结构清晰地将游戏流程分成了几个阶段每个阶段职责明确。这种“初始化 - 循环处理 - 判断 - 更新- 结束”的模式是绝大多数游戏开发的基础框架。3.3 异常处理与输入验证的强化上面的代码在输入处理上还比较脆弱。如果玩家在应该输入数字时输入了字母或者在是否继续时输入了乱七八糟的内容程序可能会表现异常或做出非预期的行为。让我们来加强它。首先改进HumanPlayer.MakeMove方法public override void MakeMove() { bool validInput false; while (!validInput) { Console.WriteLine(\n请出拳1. 石头 2. 剪刀 3. 布); string input Console.ReadLine(); if (int.TryParse(input, out int choice) choice 1 choice 3) { CurrentMove (Move)(choice - 1); // 将1/2/3映射为0/1/2 validInput true; } else { Console.WriteLine(输入无效请输入数字 1, 2 或 3。); } } }这里的关键改进是使用了int.TryParse来尝试将输入转换为整数并且检查其范围是否在1到3之间。这样能有效过滤非数字和越界的输入。同时我们将递归改为了while循环这是处理此类验证更标准、更安全的方式避免了递归可能导致的栈溢出风险虽然在这个场景下风险极低。其次改进主循环中“是否继续”的输入验证// 4. 询问是否继续 bool validResponse false; while (!validResponse) { Console.Write(\n是否继续游戏(y/n): ); string continueInput Console.ReadLine()?.ToLower().Trim(); if (continueInput y || continueInput yes) { playAgain true; validResponse true; } else if (continueInput n || continueInput no) { playAgain false; validResponse true; } else { Console.WriteLine(请输入 y (是) 或 n (否)。); } }这个循环会一直持续直到玩家输入一个明确的是y/yes或否n/no指令。.Trim()方法用于去除输入前后可能存在的空格使判断更准确。这种“验证循环”是确保程序健壮性的重要模式。4. 功能扩展与代码优化实践一个基础版本的游戏完成后我们可以思考如何让它变得更有趣、更健壮或者代码结构更优美。这里提供几个扩展方向和优化点。4.1 扩展一实现游戏历史记录与回放想象一下游戏结束后玩家可以查看刚才每一局的详细出拳和结果。这需要我们在游戏过程中保存历史数据。首先定义一个类来表示单局记录public class GameRoundRecord { public int RoundNumber { get; set; } public Move PlayerMove { get; set; } public Move ComputerMove { get; set; } public GameRule.Result Result { get; set; } public DateTime Timestamp { get; set; } DateTime.Now; }然后在Program类中维护一个记录列表static void RunGameLoop(HumanPlayer human, ComputerPlayer computer) { ListGameRoundRecord history new ListGameRoundRecord(); // ... 其他初始化代码 ... while (playAgain) { // ... 每局游戏过程 ... // 在判定胜负后保存记录 var record new GameRoundRecord { RoundNumber round, PlayerMove human.CurrentMove, ComputerMove computer.CurrentMove, Result result }; history.Add(record); // ... 询问是否继续 ... } // 游戏结束后提供查看历史的选项 Console.Write(\n是否查看对局历史(y/n): ); if (Console.ReadLine()?.ToLower() y) { Console.WriteLine(\n 对局历史 ); foreach (var record in history) { Console.WriteLine($第{record.RoundNumber}局: ${human.Name}[{record.PlayerMove}] vs ${computer.Name}[{record.ComputerMove}] - ${record.Result} ({record.Timestamp:HH:mm:ss})); } } }这个功能不仅增加了实用性也演示了如何用集合ListT来管理游戏中的动态数据。你还可以进一步扩展比如将历史记录保存到文件或数据库实现真正的持久化。4.2 扩展二引入胜率统计与电脑AI策略目前电脑是完全随机的我们可以让它“聪明”一点比如根据玩家的历史出拳习惯来调整策略。一个最简单的策略是统计玩家出石头、剪刀、布的频率然后电脑出能克制玩家最常出那个选项的拳。首先在ComputerPlayer类中添加统计和策略逻辑public class ComputerPlayer : Player { private Random _random new Random(); // 用于统计玩家历史出拳 private DictionaryMove, int _playerHistory new DictionaryMove, int(); public ComputerPlayer() { Name 电脑AI; // 初始化统计字典 foreach (Move move in Enum.GetValues(typeof(Move))) { _playerHistory[move] 0; } } // 这个方法由游戏主循环在每局后调用用于更新统计 public void RecordPlayerMove(Move playerMove) { _playerHistory[playerMove]; } public override void MakeMove() { // 简单AI策略找出玩家出得最多的拳然后出能克制它的拳 Move playersMostFrequentMove _playerHistory.OrderByDescending(kv kv.Value).First().Key; Move counterMove GetCounterMove(playersMostFrequentMove); // 为了不让AI太死板可以加入一定的随机性比如70%按策略30%随机 if (_random.NextDouble() 0.7) { CurrentMove counterMove; } else { // 随机出拳 CurrentMove (Move)_random.Next(0, 3); } Console.WriteLine($[AI提示] 检测到玩家常出 {playersMostFrequentMove} 我准备出 {CurrentMove} 应对。); } private Move GetCounterMove(Move move) { // 根据游戏规则反推如果对方常出A我就出能赢A的拳 // 即如果对方出石头(赢剪刀)我就出布(赢石头) // 注意这里的逻辑是“我出什么能赢对方出的这个move” switch (move) { case Move.Rock: return Move.Paper; // 布赢石头 case Move.Scissors: return Move.Rock; // 石头赢剪刀 case Move.Paper: return Move.Scissors; // 剪刀赢布 default: return Move.Rock; } } }然后在主循环中每局结束后调用RecordPlayerMove方法// 在主循环中判定胜负后... computer.RecordPlayerMove(human.CurrentMove);这个AI虽然简单但引入了状态记忆和策略选择的概念。它让游戏不再是纯粹的随机增加了可玩性和挑战性。你可以调整随机因子0.7来改变AI的“聪明”程度或不可预测性。4.3 代码优化使用更优雅的判定逻辑与配置化回顾我们最初的GameRule.Judge方法它使用了硬编码的if-else逻辑。如果我们想支持“石头剪刀布蜥蜴史波克”这种五元素的扩展规则修改起来会很麻烦。我们可以设计一个更灵活的规则系统。一种方法是使用一个“胜负关系表”public static class FlexibleGameRule { // Key: 出拳A, Value: 出拳A能战胜的出拳列表 private static readonly DictionaryMove, ListMove _winningRules new DictionaryMove, ListMove { { Move.Rock, new ListMove { Move.Scissors /*, Move.Lizard */ } }, { Move.Scissors, new ListMove { Move.Paper /*, Move.Lizard */ } }, { Move.Paper, new ListMove { Move.Rock /*, Move.Spock */ } }, // 可以在此扩展 Lizard 和 Spock }; public static Result Judge(Move playerMove, Move opponentMove) { if (playerMove opponentMove) return Result.Draw; // 如果对手的出拳在玩家的“能战胜列表”里则玩家赢 if (_winningRules[playerMove].Contains(opponentMove)) return Result.Win; // 否则玩家输因为规则是循环相克的不存在第三种关系 return Result.Lose; } }这种设计的优势在于规则被数据化了。要添加新元素如Lizard只需要在枚举Move中添加并在_winningRules字典中为其配置它能战胜和输给的元素即可。游戏的核心判定逻辑Judge方法完全不需要改动这符合“开闭原则”对扩展开放对修改关闭。更进一步我们可以将这个规则字典放到一个配置文件如JSON中在游戏启动时加载。这样甚至可以让玩家自定义游戏规则极大地提升了程序的灵活性和可维护性。// 假设有一个 rules.json 文件 // { // Rock: [Scissors, Lizard], // Scissors: [Paper, Lizard], // Paper: [Rock, Spock], // Lizard: [Paper, Spock], // Spock: [Scissors, Rock] // } // 程序启动时读取并解析这个文件构建 _winningRules 字典。5. 常见问题排查与调试技巧即使是这样一个简单的项目在开发过程中也可能遇到各种问题。下面我总结了一些常见坑点和调试方法。5.1 随机数总是生成相同序列问题描述在快速连续的多局游戏中发现电脑的出拳模式有规律甚至每次运行游戏前几局电脑的出拳顺序都一样。原因分析Random类在初始化时如果不提供种子会使用系统时间作为种子。如果在极短的时间内比如一个循环中多次创建new Random()由于系统时钟精度问题这些Random实例可能会使用相同的种子从而生成相同的随机数序列。解决方案将Random实例设为静态成员如前文ComputerPlayer中的做法。这样整个程序共享一个实例。使用更安全的随机数生成器对于.NET Core/.NET 5可以考虑使用System.Security.Cryptography.RandomNumberGenerator来生成密码学强度的随机数但这通常杀鸡用牛刀。确保单例如果不能在类中使用静态成员确保在一个作用域内如一次游戏会话只创建一个Random实例并重复使用。// 推荐做法在类级别声明一个静态的Random实例 public class ComputerPlayer { private static readonly Random _globalRandom new Random(); // 如果需要线程安全可以考虑使用 [ThreadStatic] 或使用锁但本例中不需要。 private Random _localRandom; public ComputerPlayer() { // 使用全局随机实例来生成一个种子为本地实例创建 lock (_globalRandom) { _localRandom new Random(_globalRandom.Next()); } } public override void MakeMove() { CurrentMove (Move)_localRandom.Next(0, 3); } }5.2 枚举转换时的异常问题描述在将整数或字符串转换为Move枚举时可能会抛出InvalidCastException或得到未定义的枚举值。原因分析直接使用强制转换(Move)someInt如果someInt不在枚举定义的范围内比如4转换虽然不会抛出异常但会得到一个整数值为4的Move类型变量它在逻辑上是未定义的。使用Enum.Parse时如果字符串不匹配则会抛出异常。解决方案使用Enum.IsDefined进行检查在转换前先验证值是否有效。使用Enum.TryParse这是最安全的方法它不会抛出异常。// 安全地从字符串转换 string input Rock; if (Enum.TryParseMove(input, true, out Move move)) // true 表示忽略大小写 { // 转换成功move 现在是 Move.Rock } else { // 转换失败处理无效输入 } // 安全地从整数转换 int valueFromUser 1; if (Enum.IsDefined(typeof(Move), valueFromUser)) { Move move (Move)valueFromUser; }5.3 控制台输入导致的程序阻塞或意外结束问题描述在等待用户输入时程序似乎“卡住”了或者用户不小心按了CtrlC导致程序直接退出。原因分析Console.ReadLine()是同步阻塞调用会一直等待直到用户按下回车。这是预期行为。CtrlC会发送中断信号默认导致控制台程序终止。解决方案与技巧输入提示清晰在ReadLine之前给出明确、友好的提示告诉用户需要输入什么。处理取消请求可以尝试捕获Console.CancelKeyPress事件来优雅地处理CtrlC。static void Main(string[] args) { // 设置CtrlC处理 Console.CancelKeyPress (sender, eventArgs) { Console.WriteLine(\n\n检测到中断请求正在保存游戏状态...); // 这里可以加入保存历史记录到文件的逻辑 Console.WriteLine(游戏状态已保存。再见); eventArgs.Cancel true; // 阻止进程立即终止让我们的代码执行完 Environment.Exit(0); // 然后正常退出 }; // ... 游戏主逻辑 ... }超时处理高级如果需要实现“限时出拳”ReadLine就不适用了。这需要用到异步编程和更底层的控制台输入处理复杂度会大大增加对于初学者项目不建议尝试。5.4 代码结构混乱难以维护问题描述所有代码都写在Main方法里或者类职责不清晰想加新功能时无从下手。原因分析这是初学者常犯的问题急于实现功能而忽略了设计。解决策略遵循我们前面所做的关注点分离。Move枚举只负责定义游戏元素。GameRule类只负责胜负判定规则。Player基类及派生类只负责参与者的行为和状态。Program主类负责协调所有模块管理游戏流程和用户界面。当你想添加新功能时先问自己这个功能属于哪个模块的职责要加新规则修改GameRule或创建新的RuleSet类。要加新玩家类型比如网络玩家创建新的NetworkPlayer : Player类。要换一种界面如图形界面保留核心的Player,GameRule等类重写Program中的界面交互部分或者将其抽象为一个IGameRenderer接口。这种结构使得你的代码像乐高积木可以相对独立地替换或扩展其中一块而不影响其他部分。这是从这个小项目中学到的最有价值的软件设计思想。