1. 项目概述与核心价值最近在带几个刚入行的C#新人做项目实战发现很多朋友学了一堆语法但一到实际动手就懵圈不知道如何把零散的知识点串联成一个完整的、能跑起来的应用。正好前段时间我用C#完整实现了一个“三公”扑克牌游戏的控制台项目从牌局逻辑、玩家交互到胜负结算麻雀虽小五脏俱全。这个项目特别适合用来作为C#面向对象编程、逻辑控制以及小型项目架构的练手案例。它不涉及复杂的图形界面避免了WPF或WinForms的入门门槛让你能专注于核心的业务逻辑和代码组织。通过这个项目你不仅能巩固类与对象、集合操作、随机数生成等基础更能深刻理解一个游戏从规则抽象到代码实现的完整流程这对于未来开发任何业务系统都是极好的思维训练。无论你是想检验自己的C#基础还是寻找一个有趣的课程设计或毕业设计题目这个“三公游戏”的实现过程都能给你带来实实在在的收获。2. 三公游戏规则与核心逻辑拆解在动手写代码之前我们必须把“三公”这个游戏的规则吃透并将其转化为计算机能理解的逻辑。根据常见的玩法我们可以梳理出以下几个核心规则点这也是我们后续设计类和方法的直接依据。2.1 牌型与点数计算规则这是游戏最核心的部分。一副扑克牌去掉大小王共52张。在“三公”中牌的点数计算有其特殊性数字牌A-10A计为1点2-9按牌面数字计点10计为0点。公仔牌J、Q、K统称为“公”计为0点。牌型大小比较两手牌时遵循“先比点数再比公数”的原则。计算总点数将三张牌的点数相加只取个位数。例如牌面为9、8、6总和23则点数为3。比较点数点数大者胜。9点最大0点最小。点数相同则比“公”的数量公仔牌J、Q、K的数量多者胜。例如一个8点由J、9、9组成有1个公会输给另一个8点由Q、J、10组成有2个公。点数和公数都相同则为和局走盘。这里有一个关键点需要编程时特别注意“公仔牌”虽然点数为0但在比公时是重要的比较要素。而10虽然点数也是0但它不是“公”。所以我们需要为每张牌设计两个属性Point用于计算点数的值和IsGong是否是公仔牌。2.2 特殊牌型与赔率除了常规比较游戏还存在两种特殊牌型它们不参与上述点数比较直接判定为最大并且通常有更高的赔率三公三张牌都是公仔牌J、Q、K的任意组合。这是第二大的牌型。三条豹子三张牌的数字完全相同如三个3、三个8。这是最大的牌型。注意由于公仔牌J、Q、K各自花色不同三张J不属于“三条”而属于“三公”。在赔率设计上我们可以这样设定闲家赢庄家普通赢牌为1赔1如果闲家以“三公”或“三条”牌型赢牌则赔率为1赔2。庄家赢闲家则直接收走闲家的下注额。2.3 游戏流程抽象一个完整的游戏回合局可以抽象为以下步骤这将成为我们Game类或主程序的核心流程准备阶段创建一副洗好的牌。初始化庄家和若干闲家。下注阶段各个闲家向庄家下注在控制台项目中我们可以简化为固定下注或输入下注额。发牌阶段庄家给每位玩家包括自己依次发三张牌。开牌与判定阶段所有玩家亮牌。根据上述规则逐一比较庄家与每个闲家的牌型判定胜负。结算阶段根据胜负结果和赔率更新每位玩家的筹码数量。回合结束回收所有牌准备下一局。将这个流程厘清后我们就能有的放矢地开始设计我们的C#类结构了。3. C#核心类设计与面向对象建模一个好的项目结构是成功的一半。对于这个游戏我们采用经典的面向对象思想将现实世界的实体抽象为类。主要会涉及以下几个核心类3.1 Card单张扑克牌类这是最基本的单元。我们需要用属性来描述一张牌。public class Card { // 花色黑桃(Spade)、红心(Heart)、梅花(Club)、方块(Diamond) public string Suit { get; set; } // 牌面A,2,3,...,10,J,Q,K public string Face { get; set; } // 用于计算点数的值A1, 2-9对应值, 10,J,Q,K0 public int Point { get; set; } // 是否是公仔牌J,Q,K public bool IsGong { get; set; } public Card(string suit, string face) { Suit suit; Face face; // 根据Face计算Point和IsGong switch (face) { case A: Point 1; IsGong false; break; case J: case Q: case K: Point 0; IsGong true; break; case 10: Point 0; IsGong false; break; default: // 2到9 Point int.Parse(face); IsGong false; break; } } // 方便打印牌信息 public override string ToString() { return ${Suit}{Face}; } }注意将Point和IsGong的计算放在构造函数里是一个好习惯保证了对象一旦创建其核心属性就是正确且不可变的除非Face被修改但这里我们不希望它被改。这避免了后续逻辑中重复计算。3.2 Deck一副牌类负责管理一整副52张牌核心职责是洗牌和发牌。using System; using System.Collections.Generic; public class Deck { private ListCard cards; private Random random; public Deck() { random new Random(); cards new ListCard(); string[] suits { 黑桃, 红心, 梅花, 方块 }; string[] faces { A, 2, 3, 4, 5, 6, 7, 8, 9, 10, J, Q, K }; // 生成一副完整的牌 foreach (var suit in suits) { foreach (var face in faces) { cards.Add(new Card(suit, face)); } } } // 洗牌使用Fisher-Yates算法 public void Shuffle() { for (int i cards.Count - 1; i 0; i--) { int j random.Next(i 1); Card temp cards[i]; cards[i] cards[j]; cards[j] temp; } } // 从牌堆顶部发一张牌 public Card DealCard() { if (cards.Count 0) { throw new InvalidOperationException(牌已发完); } Card card cards[0]; cards.RemoveAt(0); return card; } // 回收所有牌并重新洗牌用于新一局 public void ResetAndShuffle(ListCard usedCards) { cards.AddRange(usedCards); Shuffle(); } }实操心得洗牌算法这里采用了经典的Fisher-Yates算法它能保证每种排列出现的概率相等比简单调用OrderBy(_ random.Next())在原理上更清晰性能也通常更好。DealCard方法模拟从牌堆顶抽牌操作的是ListCard注意发牌后要RemoveAt(0)。3.3 Player玩家类代表参与游戏的庄家或闲家。public class Player { public string Name { get; set; } // 玩家手中的三张牌 public ListCard HandCards { get; set; } // 玩家的筹码 public int Chips { get; set; } // 当前局的下注额 public int CurrentBet { get; set; } // 是否是庄家 public bool IsDealer { get; set; } public Player(string name, int initialChips, bool isDealer false) { Name name; Chips initialChips; IsDealer isDealer; HandCards new ListCard(); CurrentBet 0; } // 下注 public bool PlaceBet(int betAmount) { if (betAmount Chips || betAmount 0) { return false; // 下注失败 } Chips - betAmount; CurrentBet betAmount; return true; } // 收赢的筹码 public void WinChips(int amount) { Chips amount; } // 清空手牌和下注记录准备下一局 public void ClearForNewRound() { HandCards.Clear(); CurrentBet 0; } // 计算手牌的点数个位数 public int CalculatePoints() { int total 0; foreach (var card in HandCards) { total card.Point; } return total % 10; // 关键只取个位数 } // 计算手牌中公仔牌的数量 public int CountGongs() { return HandCards.Count(card card.IsGong); } // 判断是否为特殊牌型 public (bool isSpecial, string type) GetSpecialType() { // 检查是否三条三张牌点数相同且不是公仔牌因为公仔牌点数都是0 if (HandCards[0].Point HandCards[1].Point HandCards[1].Point HandCards[2].Point) { // 需要排除三张都是公仔牌的情况那是三公 if (!HandCards[0].IsGong) { return (true, 三条); } } // 检查是否三公三张都是公仔牌 if (HandCards.All(card card.IsGong)) { return (true, 三公); } return (false, ); } }这个Player类承载了丰富的业务逻辑。CalculatePoints和CountGongs是实现比牌规则的关键。GetSpecialType方法返回一个元组高效地判断并返回特殊牌型避免了在外部写复杂的if嵌套。3.4 牌型比较器这是一个工具类专门封装复杂的比牌逻辑遵循“单一职责原则”。public static class HandComparer { public enum CompareResult { Win, Lose, Tie } public static CompareResult Compare(Player dealer, Player player) { var dealerSpecial dealer.GetSpecialType(); var playerSpecial player.GetSpecialType(); // 1. 处理特殊牌型 if (dealerSpecial.isSpecial || playerSpecial.isSpecial) { // 如果都是特殊牌型按“三条 三公”规则比 if (dealerSpecial.isSpecial playerSpecial.isSpecial) { if (dealerSpecial.type playerSpecial.type) return CompareResult.Tie; if (dealerSpecial.type 三条) return CompareResult.Lose; // 庄家三条闲家输 if (playerSpecial.type 三条) return CompareResult.Win; // 闲家三条闲家赢 // 剩下情况是 dealer三公 vs player三公平局 return CompareResult.Tie; } // 只有一方是特殊牌型则该方赢 return playerSpecial.isSpecial ? CompareResult.Win : CompareResult.Lose; } // 2. 处理普通牌型先比点数再比公数 int dealerPoints dealer.CalculatePoints(); int playerPoints player.CalculatePoints(); int dealerGongs dealer.CountGongs(); int playerGongs player.CountGongs(); if (playerPoints dealerPoints) return CompareResult.Win; if (playerPoints dealerPoints) return CompareResult.Lose; // 点数相同比公数 if (playerGongs dealerGongs) return CompareResult.Win; if (playerGongs dealerGongs) return CompareResult.Lose; // 点数和公数都相同和局 return CompareResult.Tie; } }这个比较器是整个游戏裁判逻辑的核心。它将复杂的、多层级的比牌规则封装在一个静态方法里使得主游戏循环中的胜负判定变得异常清晰和简洁。这种设计也便于后续修改或扩展规则。4. 游戏主流程与核心循环实现有了上面这些坚实的“积木”我们现在可以搭建游戏的主程序了。我们将在一个控制台应用程序中实现完整的游戏流程。4.1 程序入口与初始化首先在Program.cs的Main方法中我们进行游戏初始化。using System; using System.Collections.Generic; class Program { static void Main(string[] args) { Console.WriteLine( C# 三公游戏 ); // 1. 初始化一副牌并洗牌 Deck gameDeck new Deck(); gameDeck.Shuffle(); // 2. 初始化玩家 // 通常有一个庄家和多个闲家 Player dealer new Player(庄家, 1000, true); ListPlayer players new ListPlayer { new Player(闲家A, 500), new Player(闲家B, 500), new Player(闲家C, 500) }; int round 1; bool gameRunning true; // 3. 主游戏循环 while (gameRunning) { Console.WriteLine($\n--- 第 {round} 局开始 ---); PlayOneRound(gameDeck, dealer, players, ref gameRunning); round; } Console.WriteLine(游戏结束); }这里我们创建了牌堆、一个庄家和三个闲家并设置了初始筹码。gameRunning变量控制游戏是否继续。4.2 单局游戏流程实现PlayOneRound方法是核心它完整地走完一局游戏的六个步骤。static void PlayOneRound(Deck deck, Player dealer, ListPlayer players, ref bool gameRunning) { // 步骤12: 准备与下注 Console.WriteLine(请闲家下注 (默认下注10筹码直接回车确认输入0退出游戏)...); ListCard usedCardsThisRound new ListCard(); // 用于回收本局用过的牌 foreach (var player in players) { player.ClearForNewRound(); // 清空上局手牌和下注 Console.Write(${player.Name} (当前筹码: {player.Cips}): ); string input Console.ReadLine(); if (input 0) { gameRunning false; return; } int bet string.IsNullOrEmpty(input) ? 10 : int.Parse(input); // 简化固定下注或输入 if (!player.PlaceBet(bet)) { Console.WriteLine(${player.Name} 筹码不足自动退出游戏); players.Remove(player); // 简单处理实际可能需要更复杂的逻辑 if (players.Count 0) { Console.WriteLine(所有闲家都已出局); gameRunning false; return; } break; // 跳出当前循环重新开始遍历 } Console.WriteLine(${player.Name} 下注 {bet}。); } dealer.ClearForNewRound(); // 步骤3: 发牌 Console.WriteLine(\n--- 发牌 ---); // 给每个闲家发三张牌 foreach (var player in players) { for (int i 0; i 3; i) { Card card deck.DealCard(); player.HandCards.Add(card); usedCardsThisRound.Add(card); } // 暂时不显示闲家的牌模拟现实 Console.WriteLine(${player.Name} 获得三张牌。); } // 给庄家发三张牌 for (int i 0; i 3; i) { Card card deck.DealCard(); dealer.HandCards.Add(card); usedCardsThisRound.Add(card); } Console.WriteLine($庄家 获得三张牌。); // 步骤4: 开牌与判定 Console.WriteLine(\n--- 开牌 ---); // 先显示所有闲家的牌和结果 foreach (var player in players) { var specialInfo player.GetSpecialType(); string handDesc specialInfo.isSpecial ? $[{specialInfo.type}] : ; handDesc string.Join( , player.HandCards); Console.WriteLine(${player.Name}: {handDesc} | 点数:{player.CalculatePoints()} 公:{player.CountGongs()}); } // 再显示庄家的牌 var dealerSpecialInfo dealer.GetSpecialType(); string dealerHandDesc dealerSpecialInfo.isSpecial ? $[{dealerSpecialInfo.type}] : ; dealerHandDesc string.Join( , dealer.HandCards); Console.WriteLine($庄家: {dealerHandDesc} | 点数:{dealer.CalculatePoints()} 公:{dealer.CountGongs()}); Console.WriteLine(\n--- 结算 ---); // 步骤5: 结算 foreach (var player in players) { var result HandComparer.Compare(dealer, player); int winAmount 0; switch (result) { case HandComparer.CompareResult.Win: // 判断是否为特殊牌型赢 var playerSpecial player.GetSpecialType(); int multiplier playerSpecial.isSpecial ? 2 : 1; // 特殊牌型1赔2 winAmount player.CurrentBet * multiplier; player.WinChips(winAmount); dealer.Chips - winAmount; // 庄家赔付 Console.WriteLine(${player.Name} 胜赢得 {winAmount} 筹码。); break; case HandComparer.CompareResult.Lose: // 庄家赢闲家下注额归庄家已在PlaceBet中扣除这里只需庄家增加 dealer.WinChips(player.CurrentBet); Console.WriteLine(${player.Name} 负。输掉 {player.CurrentBet} 筹码。); break; case HandComparer.CompareResult.Tie: // 和局退回下注 player.WinChips(player.CurrentBet); Console.WriteLine(${player.Name} 与庄家打和筹码退回。); break; } } // 显示当前筹码状况 Console.WriteLine($\n当前筹码状况); Console.WriteLine($庄家: {dealer.Chips}); foreach (var p in players) { Console.WriteLine(${p.Name}: {p.Chips}); } // 步骤6: 回合结束回收牌并检查牌堆 deck.ResetAndShuffle(usedCardsThisRound); Console.WriteLine(牌已回收并重新洗牌。); // 简单检查游戏是否继续例如庄家或闲家破产 if (dealer.Chips 0) { Console.WriteLine(庄家破产游戏结束); gameRunning false; } bool allPlayersBroke true; foreach (var p in players) { if (p.Chips 0) allPlayersBroke false; } if (allPlayersBroke) { Console.WriteLine(所有闲家破产游戏结束); gameRunning false; } } }这个PlayOneRound方法虽然较长但结构非常清晰完全对应了之前分析的六个步骤。它处理了下注、发牌、展示、比牌、结算和状态检查的完整逻辑。注意其中对筹码增减的处理以及通过HandComparer.Compare进行胜负判定的简洁性。5. 功能扩展与代码优化思路一个基础版本完成后我们可以从多个角度去扩展和优化它使其更健壮、更易用也更像一个真正的项目。5.1 引入游戏配置与异常处理目前的筹码、下注额都是硬编码的。我们可以创建一个GameSettings类来管理配置。public class GameSettings { public int InitialDealerChips { get; set; } 1000; public int InitialPlayerChips { get; set; } 500; public int DefaultBet { get; set; } 10; public int SpecialHandMultiplier { get; set; } 2; // 特殊牌型赔率倍数 // 可以添加更多如玩家人数、局数上限等 }同时在主流程中需要加入大量的异常处理try-catch特别是Console.ReadLine()解析输入、筹码不足判断、牌堆为空等地方避免程序因意外输入而崩溃。5.2 实现更复杂的下注与玩家管理当前的下注逻辑非常简单。可以扩展为多种下注选项除了基础下注可以引入“对子”、“幸运注”等边注玩法。玩家状态管理引入PlayerState枚举如Playing,Broke,Left而不是直接Remove玩家。庄家轮换实现“霸王庄”或“轮流坐庄”的规则让游戏更贴近现实。5.3 从控制台到图形界面WPF/Avalonia这是项目一个非常自然的进阶方向。将核心逻辑Card,Deck,Player,HandComparer封装到一个独立的类库如SangongGameCore.dll中。然后分别创建控制台前端就像我们现在做的用于快速测试核心逻辑。WPF前端使用MVVM模式。Model就是我们的核心类库。ViewModel负责暴露游戏状态玩家列表、当前牌面、筹码信息和命令下注、开牌。View用XAML设计精美的牌面、按钮和动画。Avalonia前端如果你想跨平台Windows, macOS, LinuxAvalonia是一个绝佳选择。其XAML语法与WPF高度相似可以让你几乎无缝地将WPF前端迁移过来实现一套代码多端运行。5.4 加入单元测试为了保证核心逻辑特别是复杂的比牌规则的正确性引入单元测试至关重要。使用像MSTest、NUnit或xUnit这样的框架。[TestClass] public class HandComparerTests { [TestMethod] public void Compare_PlayerThreeGong_DealerNormalWin() { // 模拟玩家三公庄家普通牌 var dealer CreatePlayerWithCards(黑桃A, 红心2, 梅花3); // 6点 var player CreatePlayerWithCards(黑桃J, 红心Q, 梅花K); // 三公 var result HandComparer.Compare(dealer, player); Assert.AreEqual(HandComparer.CompareResult.Win, result); } [TestMethod] public void Compare_BothNinePoints_PlayerMoreGongsWin() { // 玩家9点带1公庄家9点带0公 var dealer CreatePlayerWithCards(黑桃9, 红心10, 方块10); // 9009点0公 var player CreatePlayerWithCards(黑桃J, 红心8, 方块A); // 0819点1公 var result HandComparer.Compare(dealer, player); Assert.AreEqual(HandComparer.CompareResult.Win, result); } // ... 更多测试用例 }编写全面的测试用例能极大增强代码的可靠性和可维护性。6. 常见问题与调试技巧实录在实际编码和教学过程中我总结了一些新手最容易踩坑的地方和调试技巧。6.1 规则实现相关的坑点数计算只取个位数的遗漏这是最经典的错误。忘记在CalculatePoints方法里做total % 10操作会导致10点以上的牌型比较完全错误。调试技巧在开牌阶段不仅打印牌面一定要把计算出的Points和CountGongs也打印出来肉眼核对。特殊牌型判断逻辑不严谨GetSpecialType方法中判断“三条”时必须排除“三公”的情况。因为三张J都是公仔牌Point都是0也满足Point相等的条件但它属于“三公”。调试技巧专门写一组测试牌型如[J,J,J],[3,3,3],[10,10,10]验证其返回的牌型字符串是否正确。“公”的判断错误误将数字10也计入“公”。务必明确只有J、Q、K是“公”。在Card构造函数中IsGong属性必须只在这三种Face时才为true。6.2 面向对象与数据流问题对象引用混淆在发牌时deck.DealCard()返回的Card对象被添加到玩家的HandCards列表同时也被添加到usedCardsThisRound列表用于回收。要理解这是同一个对象的两个引用。修改其中一个列表中的Card属性另一个列表中的也会变虽然本例中Card属性是只读的不会修改。最佳实践对于像扑克牌这样的值语义明显的对象可以考虑设计为不可变对象readonly属性或者实现ICloneable接口进行深拷贝但这对于此游戏来说过于复杂当前设计已足够。集合在遍历时修改在PlayOneRound的下注循环中如果检测到玩家筹码不足我们直接players.Remove(player)这会导致正在进行的foreach循环抛出InvalidOperationException。解决方案一种方法是使用for循环倒序遍历另一种更简单的方法是在循环外先记录要移除的玩家循环结束后再统一移除。示例中为了简化用了break但这并不完美。更健壮的做法是ListPlayer playersToRemove new ListPlayer(); foreach(var player in players) { if (!player.PlaceBet(bet)) { playersToRemove.Add(player); } } foreach(var p in playersToRemove) { players.Remove(p); }6.3 控制台交互与输入验证输入非数字导致程序崩溃int.Parse(Console.ReadLine())是危险操作。必须使用int.TryParse。int bet; while (!int.TryParse(input, out bet) || bet 0 || bet player.Chips) { Console.Write(输入无效或筹码不足请重新下注: ); input Console.ReadLine(); } player.PlaceBet(bet);界面混乱控制台输出信息过多分不清回合。技巧使用Console.WriteLine()输出空行使用Console.WriteLine(“--- 分割线 ---”)甚至可以使用Console.ForegroundColor来改变关键信息如赢钱、输钱的颜色提升可读性。6.4 性能与随机性Random对象重复创建如果在Deck.Shuffle()方法内部每次调用都new Random()而在快速连续发牌时由于系统时钟种子可能相同会导致洗牌结果不随机。解决方案如示例所示将Random实例作为Deck类的私有字段在构造函数中初始化一次。牌堆剩余牌数检查在发牌循环中如果玩家很多或局数很多可能牌会发完。虽然我们加了ResetAndShuffle但在发每张牌前好的习惯是检查deck中是否还有牌或者确保ResetAndShuffle在每局开始时被正确调用。通过这个项目的完整实现你不仅学会了C#的语法更重要的是掌握了如何将一个复杂的现实规则通过面向对象的设计分解成一个个相互协作的类并最终组装成一个可运行的程序。这个过程就是软件开发的核心。你可以在此基础上尽情发挥添加更多功能比如网络对战、数据库记录战绩、更炫的UI让它成为你简历中一个亮眼的个人项目。