尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

C#实现工程级黑杰克游戏:从领域建模到状态机实战

C#实现工程级黑杰克游戏:从领域建模到状态机实战 1. 项目概述与核心价值最近在社区里看到不少朋友在尝试用C#复刻经典的黑杰克21点游戏这确实是一个绝佳的编程进阶练习。它远不止是简单的“发牌”和“比大小”而是对面向对象设计、状态管理、事件驱动和算法逻辑的一次综合性考验。我自己也曾在带新人团队时把这个项目作为中级C#开发者的“试金石”。一个看似简单的游戏背后隐藏着诸如牌堆管理、玩家决策、庄家自动化、胜负判定、UI交互等一系列需要精心设计的模块。通过亲手打造它你能深刻理解如何将业务规则游戏规则转化为清晰、健壮且可维护的代码结构这正是从“会写代码”到“会设计软件”的关键一跃。市面上很多教程止步于一个能运行的Demo但我们要做的是打造一个工程级的黑杰克游戏。这意味着我们需要考虑牌堆的无限可复用性、游戏状态机的严谨性、以及代码对未来功能扩展比如多玩家、下注系统、游戏记录的开放性。本次我们将以Windows Forms应用为载体但核心的类库设计是跨平台的你可以轻松迁移到WPF、ASP.NET Core Blazor甚至Unity中。我们将从最核心的领域模型开始逐步构建出完整的游戏逻辑并解决那些实际开发中必然会遇到的“坑”比如网络热词中提到的“重复弹窗”问题其本质就是状态判断逻辑的缺陷。让我们开始吧。2. 游戏核心领域模型设计在动手写第一行UI代码之前我们必须先搭建好游戏的“大脑”——领域模型。好的模型设计能让后续的逻辑编码事半功倍也避免了UI和业务逻辑的纠缠不清。2.1 扑克牌与牌堆的抽象一张扑克牌需要哪些信息花色Suit和点数Rank是最基本的。点数Ace的特殊性可计为1或11以及J、Q、K计为10点的规则也需要在模型中体现。namespace Blackjack.Core { public enum Suit { Hearts, Diamonds, Clubs, Spades } public enum Rank { Ace 1, Two, Three, Four, Five, Six, Seven, Eight, Nine, Ten, Jack, Queen, King } public class Card { public Suit Suit { get; } public Rank Rank { get; } public Card(Suit suit, Rank rank) { Suit suit; Rank rank; } // 关键属性获取牌的面值 public int Value { get { if (Rank Rank.Ace) { // Ace的值由Hand类在计算总分时动态决定 return 1; // 基础值实际计算时会考虑11的情况 } if (Rank Rank.Jack Rank Rank.King) { return 10; } return (int)Rank; } } public override string ToString() { return ${Rank} of {Suit}; } } }接下来是牌堆Deck。一个专业的牌堆不应该只是一次性的它应该支持洗牌、发牌并且在牌快用完时能够自动重新生成并洗牌模拟现实中用完多副牌后重新洗牌的行为。using System; using System.Collections.Generic; using System.Linq; namespace Blackjack.Core { public class Deck { private StackCard _cards; private readonly Random _random; public Deck(int numberOfDecks 1) { _random new Random(); _cards new StackCard(); InitializeAndShuffle(numberOfDecks); } private void InitializeAndShuffle(int numberOfDecks) { var cards new ListCard(); for (int i 0; i numberOfDecks; i) { foreach (Suit suit in Enum.GetValues(typeof(Suit))) { foreach (Rank rank in Enum.GetValues(typeof(Rank))) { cards.Add(new Card(suit, rank)); } } } // Fisher-Yates 洗牌算法高效且随机性好 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; } _cards new StackCard(cards); } public Card DrawCard() { if (_cards.Count 10) // 当剩余牌数较少时重新洗牌 { InitializeAndShuffle(1); // 这里简化处理重新生成一副牌。实际赌场会使用多副牌。 } return _cards.Pop(); } public int CardsRemaining _cards.Count; } }注意这里我使用了StackCard来存储牌因为发牌就是一个典型的“后进先出”操作。DrawCard方法中的阈值检查10是一个经验值确保了游戏不会因无牌可发而中断。在更复杂的实现中你可以引入一个“弃牌堆”将用过的牌放入弃牌堆只有当整副牌快用完时才将弃牌堆重新洗牌放入发牌堆。2.2 玩家与庄家手牌管理与计分逻辑玩家和庄家都是游戏的参与者他们都有手牌Hand并且都需要计算点数。因此我们可以创建一个Participant基类。using System.Collections.Generic; using System.Linq; namespace Blackjack.Core { public abstract class Participant { public ListCard Hand { get; protected set; } new ListCard(); public string Name { get; protected set; } public void AddCard(Card card) { Hand.Add(card); } public void ClearHand() { Hand.Clear(); } // 核心方法计算手牌总点数处理Ace的1/11逻辑 public int CalculateHandValue() { int total 0; int aceCount 0; // 第一轮计算将Ace暂时计为11 foreach (var card in Hand) { if (card.Rank Rank.Ace) { aceCount; total 11; // 先按11算 } else { total card.Value; } } // 如果总分超过21且手中有Ace则将Ace从11调整为1 while (total 21 aceCount 0) { total - 10; // 将一个Ace从11分调整为1分即总分减少10 aceCount--; } return total; } // 判断是否为“黑杰克”首两张牌为Ace和10点牌 public bool HasBlackjack() { return Hand.Count 2 CalculateHandValue() 21; } // 判断是否“爆牌”超过21点 public bool IsBusted() { return CalculateHandValue() 21; } } }有了基类创建Player和Dealer类就非常简单了。庄家Dealer有特殊的游戏规则必须持续要牌直到点数达到17或以上。namespace Blackjack.Core { public class Player : Participant { public Player(string name) { Name name; } // 玩家可以有自己的属性如筹码数、下注额等便于后续扩展 public int Chips { get; set; } 1000; public int CurrentBet { get; set; } } public class Dealer : Participant { public Dealer() : base() { Name Dealer; } // 庄家是否必须继续要牌根据规则点数小于17必须继续 public bool MustHit { get { int value CalculateHandValue(); return value 17; } } } }实操心得CalculateHandValue方法是黑杰克逻辑的核心。将Ace先计为11再根据总分情况向下调整的策略是最高效且准确的实现方式。避免在循环中做复杂的条件判断。另外将HasBlackjack和IsBusted作为属性或方法放在Participant中使得胜负判定的逻辑可以统一处理代码更清晰。3. 游戏流程与状态机实现游戏不是一堆方法的随意调用它应该有明确的状态和流程。使用状态机State Machine是管理复杂游戏逻辑的黄金标准。我们定义几个核心的游戏状态。namespace Blackjack.Core { public enum GameState { Betting, // 下注阶段 Dealing, // 发牌阶段 PlayerTurn, // 玩家行动阶段 DealerTurn, // 庄家行动阶段 RoundOver // 回合结束判定胜负 } }接下来创建Game类作为游戏的总控制器。它持有牌堆、玩家、庄家实例并管理当前状态。using System; namespace Blackjack.Core { public class Game { public Deck Deck { get; private set; } public Player Player { get; private set; } public Dealer Dealer { get; private set; } public GameState CurrentState { get; private set; } // 事件用于通知UI更新 public event ActionGameState GameStateChanged; public event Actionstring MessageLogged; // 用于记录游戏日志 public Game(string playerName Player) { Player new Player(playerName); Dealer new Dealer(); StartNewRound(); } public void StartNewRound() { // 清空手牌 Player.ClearHand(); Dealer.ClearHand(); // 使用一副新牌或重新洗牌 Deck new Deck(1); // 初始状态发牌 CurrentState GameState.Dealing; GameStateChanged?.Invoke(CurrentState); MessageLogged?.Invoke(--- 新回合开始 ---); DealInitialCards(); } private void DealInitialCards() { // 玩家和庄家各发两张牌 Player.AddCard(Deck.DrawCard()); Dealer.AddCard(Deck.DrawCard()); Player.AddCard(Deck.DrawCard()); Dealer.AddCard(Deck.DrawCard()); MessageLogged?.Invoke($玩家手牌: {string.Join(, , Player.Hand)} 当前点数: {Player.CalculateHandValue()}); MessageLogged?.Invoke($庄家明牌: {Dealer.Hand[0]} 一张暗牌); // 检查初始黑杰克 if (Player.HasBlackjack() || Dealer.HasBlackjack()) { CurrentState GameState.RoundOver; GameStateChanged?.Invoke(CurrentState); DetermineWinner(); // 立即判定胜负 return; } // 否则进入玩家回合 CurrentState GameState.PlayerTurn; GameStateChanged?.Invoke(CurrentState); MessageLogged?.Invoke(玩家回合开始。); } // 玩家行动要牌 public void PlayerHit() { if (CurrentState ! GameState.PlayerTurn) return; Card newCard Deck.DrawCard(); Player.AddCard(newCard); MessageLogged?.Invoke($玩家要牌得到: {newCard}。 当前手牌: {string.Join(, , Player.Hand)} 点数: {Player.CalculateHandValue()}); if (Player.IsBusted()) { MessageLogged?.Invoke(玩家爆牌); CurrentState GameState.RoundOver; GameStateChanged?.Invoke(CurrentState); DetermineWinner(); } // 如果没爆牌则继续停留在PlayerTurn状态等待玩家下一次决策 } // 玩家行动停牌 public void PlayerStand() { if (CurrentState ! GameState.PlayerTurn) return; MessageLogged?.Invoke($玩家选择停牌。最终点数: {Player.CalculateHandValue()}); CurrentState GameState.DealerTurn; GameStateChanged?.Invoke(CurrentState); DealerPlay(); } // 庄家自动化行动 private void DealerPlay() { MessageLogged?.Invoke(庄家回合开始。); // 庄家必须展示暗牌 MessageLogged?.Invoke($庄家亮出暗牌: {Dealer.Hand[1]}。 庄家全部手牌: {string.Join(, , Dealer.Hand)} 点数: {Dealer.CalculateHandValue()}); while (Dealer.MustHit !Dealer.IsBusted()) { Card newCard Deck.DrawCard(); Dealer.AddCard(newCard); MessageLogged?.Invoke($庄家要牌得到: {newCard}。 当前点数: {Dealer.CalculateHandValue()}); } CurrentState GameState.RoundOver; GameStateChanged?.Invoke(CurrentState); DetermineWinner(); } // 核心胜负判定逻辑 private void DetermineWinner() { int playerValue Player.CalculateHandValue(); int dealerValue Dealer.CalculateHandValue(); string resultMessage; // 使用 else if 链确保逻辑互斥解决“重复弹窗”问题 if (Player.HasBlackjack() !Dealer.HasBlackjack()) { resultMessage 玩家黑杰克玩家获胜; } else if (Dealer.HasBlackjack() !Player.HasBlackjack()) { resultMessage 庄家黑杰克庄家获胜; } else if (Player.IsBusted()) { resultMessage 玩家爆牌庄家获胜。; } else if (Dealer.IsBusted()) { resultMessage 庄家爆牌玩家获胜; } else if (playerValue dealerValue) { resultMessage $玩家{playerValue}点 vs 庄家{dealerValue}点玩家获胜; } else if (dealerValue playerValue) { resultMessage $玩家{playerValue}点 vs 庄家{dealerValue}点庄家获胜。; } else { resultMessage $双方都是{playerValue}点平局; } MessageLogged?.Invoke($*** 回合结果: {resultMessage} ***); } } }深度解析“重复弹窗”问题网络热词中提到的Bug根源在于使用了多个独立的if语句进行胜负判断。假设庄家爆牌dealerValue 21同时玩家点数更低playerValue dealerValue那么if (dealerValue 21)和if (dealerValue playerValue)两个条件会同时成立导致两条获胜信息都被触发。我们的DetermineWinner方法使用了else if链这是一个互斥的判断结构。一旦某个条件被满足如庄家爆牌后续的else if分支将完全跳过从而彻底杜绝了逻辑冲突和重复提示。这是编写健壮业务逻辑的基本功。4. Windows Forms UI实现与数据绑定有了强大的游戏引擎Game类UI层的工作就变得非常轻量化和清晰。我们的目标是让UI只负责显示和用户输入所有逻辑都委托给Game类。4.1 主窗体设计与控件布局创建一个Windows Forms项目设计主窗体MainForm。主要控件包括Label控件用于显示玩家和庄家的手牌、点数。PictureBox控件列表用于动态显示扑克牌图片。Button控件Hit要牌、Stand停牌、New Game新游戏。ListBox或TextBox用于显示游戏日志信息。首先我们需要将扑克牌资源52张图片添加到项目的Resources中命名规则如hearts_ace.png、spades_king.png。4.2 将UI与游戏引擎绑定在MainForm的代码中我们初始化Game实例并订阅其事件。using Blackjack.Core; using System; using System.Drawing; using System.Windows.Forms; namespace Blackjack.UI { public partial class MainForm : Form { private Game _game; private const int CARD_START_X 20; private const int CARD_OFFSET_X 75; private const int PLAYER_CARDS_Y 200; private const int DEALER_CARDS_Y 50; public MainForm() { InitializeComponent(); SetupGame(); } private void SetupGame() { _game new Game(You); _game.GameStateChanged OnGameStateChanged; _game.MessageLogged OnMessageLogged; UpdateUI(); btnHit.Enabled false; btnStand.Enabled false; btnNewGame.Enabled true; } private void OnGameStateChanged(GameState newState) { // 在UI线程上更新控件状态 this.Invoke(new Action(() { UpdateButtonsState(newState); })); } private void OnMessageLogged(string message) { this.Invoke(new Action(() { lstLog.Items.Add($[{DateTime.Now:HH:mm:ss}] {message}); lstLog.TopIndex lstLog.Items.Count - 1; // 自动滚动到底部 })); } private void UpdateButtonsState(GameState state) { switch (state) { case GameState.PlayerTurn: btnHit.Enabled true; btnStand.Enabled true; btnNewGame.Enabled false; break; case GameState.DealerTurn: case GameState.RoundOver: btnHit.Enabled false; btnStand.Enabled false; btnNewGame.Enabled true; break; default: btnHit.Enabled false; btnStand.Enabled false; btnNewGame.Enabled true; break; } } } }4.3 动态更新手牌显示这是UI层最有趣的部分。我们需要根据Participant.Hand集合动态创建并排列PictureBox控件来显示牌。private void DisplayHand(Participant participant, int startY, bool hideSecondCard false) { // 清除之前可能存在的牌图片控件可以给它们加上特定Tag来识别 ClearCardPictures(participant is Dealer ? Dealer : Player); for (int i 0; i participant.Hand.Count; i) { Card card participant.Hand[i]; PictureBox pic new PictureBox(); pic.Width 70; pic.Height 100; pic.Location new Point(CARD_START_X (i * CARD_OFFSET_X), startY); pic.SizeMode PictureBoxSizeMode.StretchImage; pic.BorderStyle BorderStyle.FixedSingle; // 关键逻辑如果是庄家且需要隐藏第二张牌游戏初期 if (participant is Dealer hideSecondCard i 1) { pic.Image Properties.Resources.card_back; // 一张牌背图片 } else { // 根据花色和点数加载对应的图片 string imageName ${card.Suit.ToString().ToLower()}_{card.Rank.ToString().ToLower()}; pic.Image (Image)Properties.Resources.ResourceManager.GetObject(imageName); } pic.Tag participant is Dealer ? Dealer : Player; // 用Tag标记归属 this.Controls.Add(pic); pic.BringToFront(); } } private void ClearCardPictures(string tag) { ListControl controlsToRemove new ListControl(); foreach (Control control in this.Controls) { if (control is PictureBox (string)control.Tag tag) { controlsToRemove.Add(control); } } foreach (Control control in controlsToRemove) { this.Controls.Remove(control); control.Dispose(); } }4.4 按钮事件处理最后将按钮点击事件连接到游戏引擎的对应方法。private void btnHit_Click(object sender, EventArgs e) { _game.PlayerHit(); UpdateUI(); } private void btnStand_Click(object sender, EventArgs e) { _game.PlayerStand(); UpdateUI(); } private void btnNewGame_Click(object sender, EventArgs e) { _game.StartNewRound(); UpdateUI(); } private void UpdateUI() { // 显示玩家手牌和点数 lblPlayerHand.Text $玩家手牌: {string.Join(, , _game.Player.Hand)}; lblPlayerValue.Text $点数: {_game.Player.CalculateHandValue()}; // 显示庄家手牌和点数。在庄家回合前隐藏第二张牌。 bool hideDealerCard _game.CurrentState GameState.PlayerTurn; lblDealerHand.Text hideDealerCard ? $庄家手牌: {_game.Dealer.Hand[0]}, [暗牌] : $庄家手牌: {string.Join(, , _game.Dealer.Hand)}; lblDealerValue.Text hideDealerCard ? 点数: ? : $点数: {_game.Dealer.CalculateHandValue()}; // 更新牌桌显示 DisplayHand(_game.Player, PLAYER_CARDS_Y); DisplayHand(_game.Dealer, DEALER_CARDS_Y, hideDealerCard); }UI设计心得使用PictureBox动态加载图片时务必管理好控件的生命周期。在每次更新手牌前通过Tag属性清理旧的牌图片控件避免内存泄漏和控件堆积。将游戏状态如是否隐藏庄家牌的判断逻辑放在UpdateUI方法中确保UI显示与游戏核心状态严格同步。这种“状态驱动UI”的模式比在事件处理中直接操作UI要清晰和健壮得多。5. 进阶功能与代码优化一个基础版本完成后我们可以从工程化和用户体验角度进行增强。5.1 引入依赖注入与配置为了让代码更可测试、更灵活我们可以引入一个简单的服务容器来管理Game、Deck等依赖。同时将游戏规则如庄家停牌点数、使用的牌副数提取为配置。// IGameRules.cs public interface IGameRules { int DealerMustStandThreshold { get; } // 庄家停牌阈值默认为17 int NumberOfDecks { get; } // 使用的牌副数 } // AppSettings.cs (可以从配置文件加载) public class AppSettings : IGameRules { public int DealerMustStandThreshold 17; public int NumberOfDecks 4; // 使用4副牌以降低算牌优势 } // 修改Game构造函数 public Game(string playerName, IGameRules rules) { _rules rules; Deck new Deck(_rules.NumberOfDecks); // ... 其他初始化 } // 修改Dealer.MustHit属性 public bool MustHit CalculateHandValue() _rules.DealerMustStandThreshold;5.2 实现游戏历史记录与回放这对于调试和数据分析很有用。我们可以创建一个GameRound类来记录每一回合的详细数据。public class GameRound { public int RoundNumber { get; set; } public ListCard PlayerInitialHand { get; set; } public ListCard DealerInitialHand { get; set; } public ListPlayerAction PlayerActions { get; set; } // Hit, Stand public ListCard DealerDrawnCards { get; set; } public GameResult Result { get; set; } public DateTime Timestamp { get; set; } } // 在Game类中每一回合结束时将数据保存到ListGameRound中。5.3 使用异步编程优化UI响应庄家要牌的过程可能涉及短暂的延迟为了模拟思考如果同步进行会阻塞UI线程。我们可以使用async/await来优化。private async void DealerPlay() { MessageLogged?.Invoke(庄家回合开始。); // ... 展示暗牌 while (Dealer.MustHit !Dealer.IsBusted()) { await Task.Delay(1000); // 等待1秒模拟庄家思考 Card newCard Deck.DrawCard(); Dealer.AddCard(newCard); MessageLogged?.Invoke($庄家要牌得到: {newCard}。 当前点数: {Dealer.CalculateHandValue()}); // 触发UI更新事件让主窗体刷新庄家手牌显示 OnDealerCardDrawn?.Invoke(newCard); } // ... 进入结束状态 }在UI层订阅OnDealerCardDrawn事件并在事件处理程序中更新图片显示这样用户就能看到庄家一张张要牌的动画效果体验更佳。5.4 单元测试确保逻辑正确性核心的游戏逻辑特别是CalculateHandValue和DetermineWinner必须经过严格测试。使用像MSTest或xUnit这样的框架创建单元测试项目。[TestClass] public class BlackjackLogicTests { [TestMethod] public void CalculateHandValue_AceAndTen_IsBlackjack() { var hand new ListCard { new Card(Suit.Hearts, Rank.Ace), new Card(Suit.Spades, Rank.Ten) }; var participant new MockParticipant(hand); Assert.AreEqual(21, participant.CalculateHandValue()); Assert.IsTrue(participant.HasBlackjack()); } [TestMethod] public void CalculateHandValue_TwoAces_Returns12() { var hand new ListCard { new Card(Suit.Hearts, Rank.Ace), new Card(Suit.Clubs, Rank.Ace) }; var participant new MockParticipant(hand); // Ace(11) Ace(1) 12 Assert.AreEqual(12, participant.CalculateHandValue()); } [TestMethod] public void DetermineWinner_PlayerBusts_DealerWins() { var game new Game(); // 通过反射或其他方式设置玩家和庄家的手牌... // 模拟玩家爆牌 // 断言结果消息包含“庄家获胜” } } // MockParticipant是一个用于测试的Participant派生类。6. 常见问题排查与性能优化在实际开发和运行中你可能会遇到以下问题问题1游戏运行一段时间后变卡顿内存占用持续上升。排查很可能是因为动态创建的PictureBox控件没有正确销毁。每次调用DisplayHand时都必须彻底清理之前属于同一参与者的旧控件。解决确保ClearCardPictures方法被正确调用并且清理后的控件被Dispose()。可以使用using语句块或在窗体FormClosing事件中集中清理。问题2庄家AI有时会在点数等于17时继续要牌。排查检查Dealer.MustHit属性的逻辑。标准规则是“软17”即包含Ace且计为11的17点时有些规则要求庄家继续要牌有些则停牌。你的规则是否明确解决如果需要实现“软17”规则MustHit的判断会复杂一些需要能识别手牌是否为“软17”。public bool MustHit { get { int value CalculateHandValue(); bool isSoft17 value 17 Hand.Any(c c.Rank Rank.Ace) (value - 10) 7; // 简化判断 // 规则小于17必须Hit等于软17根据规则可能也要Hit return value 17 || (_rules.HitOnSoft17 isSoft17); } }问题3在多线程环境下如使用异步庄家动画UI更新报“跨线程操作无效”错误。排查从非UI线程如Task.Run或事件回调直接修改UI控件属性。解决始终使用Control.Invoke或Control.BeginInvoke方法或者在.NET Core/.NET 5的WinForms中使用SynchronizationContext来将更新操作封送到UI线程。我们在OnMessageLogged和OnGameStateChanged事件处理中已经做了示范。问题4图片加载慢第一次显示牌时会有延迟。优化使用图片缓存。在程序启动时将52张牌和牌背图片预加载到一个Dictionarystring, Image中。private static Dictionarystring, Image _cardImageCache; static MainForm() { _cardImageCache new Dictionarystring, Image(); // 预加载所有图片到缓存 foreach (Suit suit in Enum.GetValues(typeof(Suit))) { foreach (Rank rank in Enum.GetValues(typeof(Rank))) { string key ${suit.ToString().ToLower()}_{rank.ToString().ToLower()}; _cardImageCache[key] LoadImageFromResources(key); } } _cardImageCache[card_back] LoadImageFromResources(card_back); } // 然后在DisplayHand中直接从缓存取图pic.Image _cardImageCache[imageName];打造一个完整的黑杰克游戏项目就像完成一次小型的软件工程实践。从领域建模、状态管理、UI分离到后期的测试优化每一步都考验着开发者对C#语言特性、面向对象原则和常见设计模式的理解。当你把这个项目流畅地运行起来并且能清晰地解释每一段代码的意图和背后的设计考量时你的C#编程能力就已经迈上了一个坚实的台阶。这个项目代码结构清晰你可以很容易地为其添加图形主题、音效、下注系统、多人联网对战等功能成为一个功能丰富的游戏作品。
返回列表