C#猜拳游戏实现:从随机算法到可控输赢的设计模式应用
1. 项目概述一个“有后门”的猜拳游戏最近在带几个刚入门的C#学员发现他们学完基础语法后最大的困惑就是不知道如何将这些零散的知识点串联成一个完整的、能跑起来的程序。理论学了一堆if-else、switch、Random都懂但一让做个东西就无从下手。这让我想起了自己刚学编程时也是从这种“玩具项目”开始的。于是我设计了这个“可控制输赢的猜拳小游戏”。这个项目听起来简单就是个石头剪刀布但“可控制输赢”这个设定让它瞬间从一个单纯的语法练习变成了一个有趣的、带点“黑魔法”的综合性小案例。它不仅能巩固变量、流程控制、随机数等基础还能自然地引入面向对象思想、简单的设计模式雏形甚至是程序与用户交互的“小心机”。对于新手来说完成它会有很强的成就感对于有经验的开发者思考如何优雅地实现“控制输赢”的逻辑也是一个不错的思维体操。接下来我就把这个小项目的设计思路、代码实现和其中埋藏的“坑”与技巧毫无保留地分享出来。2. 核心设计如何优雅地“作弊”一个普通的猜拳游戏核心是随机数。但我们要做的是“可控制输赢”这意味着程序需要具备两种模式公平的随机模式以及我们预设结果的“作弊”模式。如何设计才能让这两种模式切换灵活且代码不至于混乱呢2.1 状态机模式的应用雏形虽然我们不会实现一个完整的状态机模式但其思想非常适用。我们可以认为游戏有一个内部的“裁判”这个裁判有两种状态FairMode公平模式和CheatMode作弊模式。在公平模式下裁判公正无私随机出拳在作弊模式下裁判会根据我们预设的规则来决定出拳确保玩家赢或输。在代码层面这可以通过一个枚举enum和switch语句或者更优雅地用一个策略接口来实现。考虑到这是一个面向新手的案例我们先用枚举switch的方式这样更直观。但我会在代码注释里提示面向对象的改进方向。// 定义游戏模式枚举 public enum GameMode { Fair, // 公平模式随机出拳 PlayerAlwaysWin, // 作弊模式玩家总是赢 PlayerAlwaysLose, // 作弊模式玩家总是输 Custom // 作弊模式自定义规则高级玩法后续扩展 }2.2 出拳逻辑的抽象石头Rock、剪刀Scissors、布Paper三者循环相克这是一个经典的关系。我们可以用数字0、1、2来代表它们并定义好胜负规则。但直接使用数字不利于代码可读性所以同样用一个枚举来表示出拳类型。// 定义出拳类型枚举 public enum HandType { Rock 0, // 石头 Scissors 1, // 剪刀 Paper 2 // 布 }胜负判断的逻辑是固定的石头赢剪刀剪刀赢布布赢石头。如果两者相同则为平局。这个判断逻辑应该独立封装成一个方法。2.3 “控制输赢”的算法核心这是本项目最有趣的部分。在作弊模式下当玩家出拳后程序裁判的出拳不再是随机的而是根据当前模式和玩家出拳计算出来的。玩家总是赢PlayerAlwaysWin程序需要出那个恰好输给玩家的拳。玩家出Rock程序必须出Scissors。玩家出Scissors程序必须出Paper。玩家出Paper程序必须出Rock。这存在一个数学关系程序出拳 (玩家出拳 2) % 3。你可以手动验证一下这个公式。玩家总是输PlayerAlwaysLose程序需要出那个恰好赢过玩家的拳。玩家出Rock程序必须出Paper。玩家出Scissors程序必须出Rock。玩家出Paper程序必须出Scissors。对应的数学关系是程序出拳 (玩家出拳 1) % 3。在公平模式下程序出拳就是简单的Random.Next(0, 3)然后转换为HandType。注意这里的取模运算%是理解的关键。它保证了结果总是在0、1、2这个循环内。这是处理循环关系数据结构如星期、环形队列的常用技巧。3. 分步实现与代码详解我们使用控制台应用程序来实现这样最简洁能聚焦于核心逻辑。3.1 项目结构与基础定义首先创建一个新的C#控制台应用项目。我们将所有逻辑都写在Program.cs的Main方法中但对于稍大的部分我会用局部函数来组织代码保持结构清晰。using System; namespace ControllableRPSGame { class Program { static void Main(string[] args) { // 游戏主逻辑将在这里编写 RunGame(); } static void RunGame() { // 定义枚举和核心变量 GameMode currentMode GameMode.Fair; // 初始为公平模式 Random random new Random(); int playerScore 0; int computerScore 0; int round 1; Console.WriteLine( 可控制输赢的猜拳游戏 ); // ... 后续代码 } // 胜负判断函数 static GameResult Judge(HandType player, HandType computer) { if (player computer) return GameResult.Draw; // 利用枚举值和循环相克规则进行判断 // (player - computer 3) % 3 的结果 // 1: 玩家赢 (例如 Paper(2) - Rock(0) 2 - (23)%32? 等一下这个公式需要调整) // 更清晰的判断直接使用条件语句 if ((player HandType.Rock computer HandType.Scissors) || (player HandType.Scissors computer HandType.Paper) || (player HandType.Paper computer HandType.Rock)) { return GameResult.PlayerWin; } else { return GameResult.ComputerWin; } } // 根据模式和玩家出拳计算电脑出拳 static HandType GetComputerHand(GameMode mode, HandType playerHand, Random rng) { switch (mode) { case GameMode.Fair: // 公平模式完全随机 return (HandType)rng.Next(0, 3); // 生成0,1,2 case GameMode.PlayerAlwaysWin: // 玩家赢模式电脑出能输给玩家的拳 // 公式 (playerHand 2) % 3 return (HandType)(((int)playerHand 2) % 3); case GameMode.PlayerAlwaysLose: // 玩家输模式电脑出能赢玩家的拳 // 公式 (playerHand 1) % 3 return (HandType)(((int)playerHand 1) % 3); // case GameMode.Custom: // 可扩展自定义规则 // return CalculateCustomHand(playerHand); default: // 默认回退到公平模式 return (HandType)rng.Next(0, 3); } } } // 枚举定义放在类外命名空间内 public enum GameMode { Fair, PlayerAlwaysWin, PlayerAlwaysLose /*, Custom */ } public enum HandType { Rock 0, Scissors 1, Paper 2 } public enum GameResult { PlayerWin, ComputerWin, Draw } }3.2 主游戏循环与用户交互游戏需要一个循环让玩家可以持续进行多轮。每轮开始前可以允许玩家切换模式。static void RunGame() { GameMode currentMode GameMode.Fair; Random random new Random(); int playerScore 0; int computerScore 0; int round 1; bool isRunning true; Console.WriteLine( 可控制输赢的猜拳游戏 ); while (isRunning) { Console.WriteLine($\n--- 第 {round} 轮 ---); Console.WriteLine($当前模式: {currentMode}); Console.WriteLine($战绩: 玩家 {playerScore} - {computerScore} 电脑); Console.WriteLine(---------------------); // 1. 模式选择菜单 Console.WriteLine(请选择模式 (输入数字):); Console.WriteLine(1. 公平模式); Console.WriteLine(2. 作弊模式-我总是赢); Console.WriteLine(3. 作弊模式-我总是输); Console.WriteLine(4. 退出游戏); Console.Write(你的选择: ); string modeInput Console.ReadLine(); if (modeInput 4) { isRunning false; Console.WriteLine(游戏结束再见); continue; } // 处理模式切换 switch (modeInput) { case 1: currentMode GameMode.Fair; break; case 2: currentMode GameMode.PlayerAlwaysWin; break; case 3: currentMode GameMode.PlayerAlwaysLose; break; default: Console.WriteLine(输入无效保持当前模式。); break; } // 2. 玩家出拳 Console.WriteLine(\n请出拳 (输入数字):); Console.WriteLine(0. 石头); Console.WriteLine(1. 剪刀); Console.WriteLine(2. 布); Console.Write(你的选择: ); string handInput Console.ReadLine(); HandType playerHand; // 输入验证确保输入是0,1,2 if (!int.TryParse(handInput, out int handValue) || handValue 0 || handValue 2) { Console.WriteLine(输入无效本轮作废。); continue; // 跳过本轮重新开始循环 } playerHand (HandType)handValue; // 3. 电脑裁判出拳 HandType computerHand GetComputerHand(currentMode, playerHand, random); // 4. 裁决并显示结果 Console.WriteLine($\n你出了: {playerHand}); Console.WriteLine($电脑出了: {computerHand}); GameResult result Judge(playerHand, computerHand); switch (result) { case GameResult.PlayerWin: Console.WriteLine( 恭喜你赢了); playerScore; break; case GameResult.ComputerWin: Console.WriteLine( 很遗憾电脑赢了。); computerScore; break; case GameResult.Draw: Console.WriteLine( 平局); break; } round; // 进入下一轮 } }3.3 核心算法函数详解上面代码中调用了两个核心函数Judge和GetComputerHand。它们的实现在前面已经给出这里再强调一下其精妙之处。GetComputerHand函数是“控制输赢”的灵魂。它接收当前模式、玩家出拳和随机数生成器作为参数。通过一个switch语句分流处理公平模式直接利用Random.Next(0,3)生成一个0到2的随机数然后强制转换为HandType枚举。这是最直接的做法。作弊模式利用前面推导出的数学公式。(int)playerHand将玩家的枚举值转为整数0,1,2然后进行加法和取模运算得到我们希望电脑出的拳的整数值最后再转回HandType。这个过程完全由数学保证逻辑清晰且无冗余的if-else判断。默认情况这是一个良好的防御性编程习惯。如果未来枚举增加了新值而switch没有处理或者传入意外的值可以回退到一个安全的行为这里回退到公平随机。Judge函数则采用了最直观的if-else链来判断胜负。虽然也可以用数学公式((int)player - (int)computer 3) % 3来判断结果为1则玩家赢为2则电脑赢但对于初学者清晰的逻辑判断更容易理解和调试。我们可以在注释里写上另一种方法供学有余力的读者探索。实操心得在编写GetComputerHand时我最初试图在一个复杂的if-else里同时处理模式和胜负关系代码很快变得难以维护。后来将其拆分为“模式判断”和“出拳计算”两个清晰的步骤并用数学公式替代条件判断代码立刻简洁了。这教会我们复杂的逻辑先分步骤再找规律。4. 功能扩展与代码优化思考基础版本完成后我们可以从几个方向思考如何让它变得更“像样”甚至更“专业”。4.1 扩展自定义作弊规则目前的作弊模式是“总是赢”或“总是输”。我们可以增加一个GameMode.Custom允许玩家定义更复杂的规则例如“玩家出石头时我赢其他情况我随机”或者“按照一个固定的序列出拳”。这需要设计一个规则配置系统可能用到委托FuncHandType, HandType或简单的规则列表。// 思路示例使用字典定义自定义规则 private static DictionaryHandType, HandType customRuleMap new DictionaryHandType, HandType { {HandType.Rock, HandType.Scissors}, // 玩家出石头电脑出剪刀玩家赢 {HandType.Scissors, HandType.Scissors}, // 玩家出剪刀电脑也出剪刀平局 {HandType.Paper, HandType.Rock} // 玩家出布电脑出石头电脑赢 }; // 在GetComputerHand的switch中添加 case GameMode.Custom: if (customRuleMap.ContainsKey(playerHand)) return customRuleMap[playerHand]; else return (HandType)rng.Next(0, 3); // 默认回退4.2 引入面向对象设计当前所有逻辑都写在静态方法里属于面向过程。我们可以将其重构为面向对象这样更利于扩展和维护。创建Game类封装游戏状态模式、比分、回合数、随机数生成器。创建IRule接口和具体规则类将公平规则、总是赢规则等抽象为不同的规则类通过依赖注入的方式提供给Game类。这是策略模式Strategy Pattern的典型应用。创建Player和ComputerPlayer类虽然这里比较简单但分离出参与者有利于未来扩展比如多个玩家、AI玩家。// 策略接口 public interface IGameRule { HandType GenerateComputerHand(HandType playerHand, Random rng); } // 具体策略 public class FairRule : IGameRule { /* 返回随机出拳 */ } public class AlwaysWinRule : IGameRule { /* 返回 (playerHand 2) % 3 */ } public class AlwaysLoseRule : IGameRule { /* 返回 (playerHand 1) % 3 */ } // Game类 public class RockPaperScissorsGame { private IGameRule _currentRule; private Random _rng; public int PlayerScore { get; private set; } public int ComputerScore { get; private set; } public RockPaperScissorsGame(IGameRule initialRule) { _currentRule initialRule; _rng new Random(); } public void ChangeRule(IGameRule newRule) { _currentRule newRule; } public GameResult PlayRound(HandType playerHand) { var computerHand _currentRule.GenerateComputerHand(playerHand, _rng); var result Judge(playerHand, computerHand); // 更新比分... return result; } }这样主程序就变得非常简洁只需要创建游戏对象、规则对象并管理用户界面循环即可。这种设计使得增加新规则如CustomRule变得极其容易符合开闭原则。4.3 增加持久化与历史记录我们可以将每轮的游戏记录回合、玩家出拳、电脑出拳、结果、模式保存到一个ListRoundRecord中。游戏结束时可以询问玩家是否要保存记录到文件如JSON或纯文本或者显示历史统计信息如各种模式下的胜率。4.4 图形化界面WinForms/WPF对于初学者在掌握控制台版本后尝试用WinForms或WPF做一个简单的图形界面是绝佳的下一步。你会遇到事件驱动编程、控件数据绑定等新概念。把核心的游戏逻辑类如上面面向对象设计的RockPaperScissorsGame作为“业务逻辑层”界面只负责显示和调用这是学习分层架构的好例子。5. 常见问题与调试技巧在实际编写和运行这个小游戏时你可能会遇到以下问题5.1 输入处理与异常问题玩家输入了字母或超出范围的数字程序崩溃或行为异常。解决必须进行输入验证。使用int.TryParse来安全地转换输入并检查其范围。我们在主循环中已经做了处理如果无效则用continue跳过本轮。更健壮的做法可以封装一个GetValidatedInput方法。5.2 随机数的陷阱问题在快速连续的多轮游戏中电脑的出拳看起来不够“随机”或者有规律。解决Random对象不应该在每次需要随机数时都新建。因为Random默认使用系统时间作为种子如果创建得太快种子可能相同导致生成的随机数序列也相同。正确的做法是在程序开始时如Main方法或游戏类构造函数中创建一次Random实例然后在整个游戏过程中重复使用它。我们的代码中Random random new Random();是在RunGame开始时创建的这是正确的。5.3 枚举的使用与转换问题在switch或if中比较枚举时有时会忘记进行类型转换。技巧使用Enum.TryParse可以从字符串安全地解析枚举值。将枚举转换为整数进行计算时使用(int)myEnum。从整数转换回枚举时使用(MyEnum)intValue但要确保intValue在枚举定义的范围内否则会得到一个无效的枚举值虽然不会报错但逻辑可能出错。5.4 胜负判断逻辑的验证问题自己写的胜负判断逻辑可能有误。调试技巧编写一个简单的单元测试或用一个控制台循环来验证所有9种出拳组合玩家3种 * 电脑3种的结果是否符合“石头剪刀布”的规则。这是确保核心逻辑正确的有效方法。// 简易的验证代码 Console.WriteLine(验证所有胜负组合:); foreach (HandType player in Enum.GetValues(typeof(HandType))) { foreach (HandType computer in Enum.GetValues(typeof(HandType))) { var result Judge(player, computer); Console.WriteLine(${player} vs {computer} {result}); } }5.5 代码可读性与维护性问题所有代码都堆在Main或一个大的函数里难以阅读和修改。建议即使不做完整的面向对象重构也尽量将独立的功能抽取成方法比如DisplayMenu,GetPlayerHand,DisplayResult等。给方法和变量起有意义的名字。多写注释解释“为什么”这么做尤其是像取模运算那样的关键算法。这个小项目虽然代码量不大但“麻雀虽小五脏俱全”。它涉及了输入输出、流程控制、枚举、随机数、基本算法、简单的模式设计思想。通过实现它并尝试上述的扩展思考你能切实感受到如何将书本知识转化为解决实际问题的能力。最重要的是编程是快乐的从这个能“作弊”的小游戏开始享受控制代码的乐趣吧。