C#面向对象实战:三公扑克游戏控制台项目完整实现

1. 项目概述与核心价值

最近在带几个刚入行的C#新人做项目实战,发现很多朋友学了一堆语法,但一到实际动手就懵圈,不知道如何把零散的知识点串联成一个完整的、能跑起来的应用。正好,前段时间我用C#完整实现了一个“三公”扑克牌游戏的控制台项目,从牌局逻辑、玩家交互到胜负结算,麻雀虽小五脏俱全。这个项目特别适合用来作为C#面向对象编程、逻辑控制以及小型项目架构的练手案例。它不涉及复杂的图形界面(避免了WPF或WinForms的入门门槛),让你能专注于核心的业务逻辑和代码组织。通过这个项目,你不仅能巩固类与对象、集合操作、随机数生成等基础,更能深刻理解一个游戏从规则抽象到代码实现的完整流程,这对于未来开发任何业务系统都是极好的思维训练。无论你是想检验自己的C#基础,还是寻找一个有趣的课程设计或毕业设计题目,这个“三公游戏”的实现过程都能给你带来实实在在的收获。

2. 三公游戏规则与核心逻辑拆解

在动手写代码之前,我们必须把“三公”这个游戏的规则吃透,并将其转化为计算机能理解的逻辑。根据常见的玩法,我们可以梳理出以下几个核心规则点,这也是我们后续设计类和方法的直接依据。

2.1 牌型与点数计算规则

这是游戏最核心的部分。一副扑克牌去掉大小王,共52张。在“三公”中,牌的点数计算有其特殊性:

  • 数字牌(A-10):A计为1点,2-9按牌面数字计点,10计为0点。
  • 公仔牌(J、Q、K):统称为“公”,计为0点。
  • 牌型大小:比较两手牌时,遵循“先比点数,再比公数”的原则。
    1. 计算总点数:将三张牌的点数相加,只取个位数。例如,牌面为9、8、6,总和23,则点数为3。
    2. 比较点数:点数大者胜。9点最大,0点最小。
    3. 点数相同则比“公”的数量:公仔牌(J、Q、K)的数量多者胜。例如,一个8点(由J、9、9组成,有1个公)会输给另一个8点(由Q、J、10组成,有2个公)。
    4. 点数和公数都相同:则为和局(走盘)。

这里有一个关键点需要编程时特别注意:“公仔牌”虽然点数为0,但在比公时是重要的比较要素。而10虽然点数也是0,但它不是“公”。所以,我们需要为每张牌设计两个属性:Point(用于计算点数的值)和IsGong(是否是公仔牌)。

2.2 特殊牌型与赔率

除了常规比较,游戏还存在两种特殊牌型,它们不参与上述点数比较,直接判定为最大,并且通常有更高的赔率:

  • 三公:三张牌都是公仔牌(J、Q、K的任意组合)。这是第二大的牌型。
  • 三条(豹子):三张牌的数字完全相同(如三个3、三个8)。这是最大的牌型。注意,由于公仔牌(J、Q、K)各自花色不同,三张J不属于“三条”,而属于“三公”。

在赔率设计上,我们可以这样设定:闲家赢庄家,普通赢牌为1赔1;如果闲家以“三公”或“三条”牌型赢牌,则赔率为1赔2。庄家赢闲家,则直接收走闲家的下注额。

2.3 游戏流程抽象

一个完整的游戏回合(局)可以抽象为以下步骤,这将成为我们Game类或主程序的核心流程:

  1. 准备阶段:创建一副洗好的牌。初始化庄家和若干闲家。
  2. 下注阶段:各个闲家向庄家下注(在控制台项目中,我们可以简化为固定下注或输入下注额)。
  3. 发牌阶段:庄家给每位玩家(包括自己)依次发三张牌。
  4. 开牌与判定阶段:所有玩家亮牌。根据上述规则,逐一比较庄家与每个闲家的牌型,判定胜负。
  5. 结算阶段:根据胜负结果和赔率,更新每位玩家的筹码数量。
  6. 回合结束:回收所有牌,准备下一局。

将这个流程厘清后,我们就能有的放矢地开始设计我们的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; } // 用于计算点数的值:A=1, 2-9=对应值, 10,J,Q,K=0 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}"; } }

注意:将PointIsGong的计算放在构造函数里是一个好习惯,保证了对象一旦创建,其核心属性就是正确且不可变的(除非Face被修改,但这里我们不希望它被改)。这避免了后续逻辑中重复计算。

3.2 Deck(一副牌)类

负责管理一整副52张牌,核心职责是洗牌和发牌。

using System; using System.Collections.Generic; public class Deck { private List<Card> cards; private Random random; public Deck() { random = new Random(); cards = new List<Card>(); 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(List<Card> usedCards) { cards.AddRange(usedCards); Shuffle(); } }

实操心得:洗牌算法这里采用了经典的Fisher-Yates算法,它能保证每种排列出现的概率相等,比简单调用OrderBy(_ => random.Next())在原理上更清晰,性能也通常更好。DealCard方法模拟从牌堆顶抽牌,操作的是List<Card>,注意发牌后要RemoveAt(0)

3.3 Player(玩家)类

代表参与游戏的庄家或闲家。

public class Player { public string Name { get; set; } // 玩家手中的三张牌 public List<Card> 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 List<Card>(); 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类承载了丰富的业务逻辑。CalculatePointsCountGongs是实现比牌规则的关键。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.csMain方法中,我们进行游戏初始化。

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); List<Player> players = new List<Player> { 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, List<Player> players, ref bool gameRunning) { // 步骤1&2: 准备与下注 Console.WriteLine("请闲家下注 (默认下注10筹码,直接回车确认,输入0退出游戏)..."); List<Card> usedCardsThisRound = new List<Card>(); // 用于回收本局用过的牌 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, Linux),Avalonia是一个绝佳选择。其XAML语法与WPF高度相似,可以让你几乎无缝地将WPF前端迁移过来,实现一套代码多端运行。

5.4 加入单元测试

为了保证核心逻辑(特别是复杂的比牌规则)的正确性,引入单元测试至关重要。使用像MSTestNUnitxUnit这样的框架。

[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"); // 9+0+0=9点,0公 var player = CreatePlayerWithCards("黑桃J", "红心8", "方块A"); // 0+8+1=9点,1公 var result = HandComparer.Compare(dealer, player); Assert.AreEqual(HandComparer.CompareResult.Win, result); } // ... 更多测试用例 }

编写全面的测试用例能极大增强代码的可靠性和可维护性。

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

在实际编码和教学过程中,我总结了一些新手最容易踩坑的地方和调试技巧。

6.1 规则实现相关的坑

  1. 点数计算只取个位数的遗漏:这是最经典的错误。忘记在CalculatePoints方法里做total % 10操作,会导致10点以上的牌型比较完全错误。调试技巧:在开牌阶段,不仅打印牌面,一定要把计算出的PointsCountGongs也打印出来,肉眼核对。
  2. 特殊牌型判断逻辑不严谨GetSpecialType方法中,判断“三条”时必须排除“三公”的情况。因为三张J(都是公仔牌,Point都是0)也满足Point相等的条件,但它属于“三公”。调试技巧:专门写一组测试牌型,如[J,J,J],[3,3,3],[10,10,10],验证其返回的牌型字符串是否正确。
  3. “公”的判断错误:误将数字10也计入“公”。务必明确,只有J、Q、K是“公”。在Card构造函数中,IsGong属性必须只在这三种Face时才为true

6.2 面向对象与数据流问题

  1. 对象引用混淆:在发牌时,deck.DealCard()返回的Card对象被添加到玩家的HandCards列表,同时也被添加到usedCardsThisRound列表用于回收。要理解这是同一个对象的两个引用。修改其中一个列表中的Card属性,另一个列表中的也会变(虽然本例中Card属性是只读的,不会修改)。最佳实践:对于像扑克牌这样的值语义明显的对象,可以考虑设计为不可变对象(readonly属性),或者实现ICloneable接口进行深拷贝,但这对于此游戏来说过于复杂,当前设计已足够。
  2. 集合在遍历时修改:在PlayOneRound的下注循环中,如果检测到玩家筹码不足,我们直接players.Remove(player),这会导致正在进行的foreach循环抛出InvalidOperationException解决方案:一种方法是使用for循环倒序遍历;另一种更简单的方法是在循环外先记录要移除的玩家,循环结束后再统一移除。示例中为了简化,用了break,但这并不完美。更健壮的做法是:
    List<Player> playersToRemove = new List<Player>(); foreach(var player in players) { if (!player.PlaceBet(bet)) { playersToRemove.Add(player); } } foreach(var p in playersToRemove) { players.Remove(p); }

6.3 控制台交互与输入验证

  1. 输入非数字导致程序崩溃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);
  2. 界面混乱:控制台输出信息过多,分不清回合。技巧:使用Console.WriteLine()输出空行,使用Console.WriteLine(“--- 分割线 ---”),甚至可以使用Console.ForegroundColor来改变关键信息(如赢钱、输钱)的颜色,提升可读性。

6.4 性能与随机性

  1. Random对象重复创建:如果在Deck.Shuffle()方法内部每次调用都new Random(),而在快速连续发牌时,由于系统时钟种子可能相同,会导致洗牌结果不随机。解决方案:如示例所示,将Random实例作为Deck类的私有字段,在构造函数中初始化一次。
  2. 牌堆剩余牌数检查:在发牌循环中,如果玩家很多或局数很多,可能牌会发完。虽然我们加了ResetAndShuffle,但在发每张牌前,好的习惯是检查deck中是否还有牌,或者确保ResetAndShuffle在每局开始时被正确调用。

通过这个项目的完整实现,你不仅学会了C#的语法,更重要的是掌握了如何将一个复杂的现实规则,通过面向对象的设计,分解成一个个相互协作的类,并最终组装成一个可运行的程序。这个过程,就是软件开发的核心。你可以在此基础上尽情发挥,添加更多功能,比如网络对战、数据库记录战绩、更炫的UI,让它成为你简历中一个亮眼的个人项目。