C#面向对象编程实战:从掷骰子游戏掌握核心开发技巧
1. 项目概述从“掷骰子”到“面向对象”的实战演练如果你正在学习C#或者想找一个能串联起基础语法、面向对象思想和简单UI交互的练手项目那这个“投骰子游戏”的源码绝对是个宝藏。它听起来简单一个骰子扔一下显示个点数似乎没什么技术含量。但恰恰是这种简单的场景最能考验一个开发者对基础概念的掌握程度和代码的组织能力。我见过太多新手写的“骰子程序”代码全堆在Main方法里逻辑混乱骰子点数用随机数一生成就完事毫无扩展性可言。而这个完整的源码项目它不仅仅是一个能运行的游戏更是一个微缩的、标准的C#应用程序开发范例。这个项目能帮你解决什么问题首先它帮你摆脱“控制台黑框”的单一感通过图形界面可能是WinForms或WPF让你直观地看到骰子的“滚动”和“定格”动画提升学习趣味性。其次它强迫你思考“骰子”这个实体在代码中如何表示——它是一个Dice类有“面数”、“当前点数”等属性有“投掷”这个方法。这就引入了面向对象编程OOP的核心思想。再者游戏通常有规则比如多个骰子比大小、计算总和、判断胜负这又涉及到逻辑控制、集合操作和事件处理。最后完整的源码必然包含异常处理比如骰子面数不能小于1、资源管理如图片加载和基本的UI线程知识。所以这一个小小的游戏实际上是一个涵盖C#语法基础、类与对象、事件驱动、简单算法和UI基础的综合性学习载体。2. 核心设计思路如何构建一个“活”的骰子拿到一个项目先别急着看代码理解设计者的思路更重要。一个设计良好的“投骰子游戏”其核心在于将现实世界的实体和过程抽象为代码中的类和交互。我们来拆解一下它的设计骨架。2.1 领域模型抽象骰子不止是Random.Next最核心的模型无疑是Dice骰子类。一个骰子应该有哪些状态和行为属性状态Sides面数。默认是6但设计成可配置的就能支持四面骰、八面骰等体现了扩展性。CurrentValue当前朝上的点数。这是骰子的核心状态。IsRolling是否正在投掷中。这个状态对于控制动画和防止连续无效操作非常关键。方法行为Roll()投掷。这是最主要的方法其内部会利用Random类生成一个[1, Sides]范围内的随机整数并赋值给CurrentValue。Reset()重置。将CurrentValue设为初始值如1IsRolling设为false。这里有一个初学者常踩的坑Random对象的创建。如果每次投掷都new Random()在极短时间内连续调用由于系统时钟作为种子变化不大可能导致生成的随机数序列高度相似甚至相同。正确的做法是在类内部持有一个静态的Random实例或者通过依赖注入的方式传入一个共享的Random对象。// 不佳的做法可能在快速连续调用时产生相同结果 public int RollBad() { Random rnd new Random(); return rnd.Next(1, Sides 1); } // 推荐的做法使用静态实例或通过构造函数注入 public class Dice { private static readonly Random _globalRnd new Random(); private readonly Random _rnd; public Dice() { // 使用一个全局种子来初始化实例随机数生成器避免重复 lock(_globalRnd) { _rnd new Random(_globalRnd.Next()); } } public int Roll() { return _rnd.Next(1, Sides 1); } }2.2 游戏规则与状态管理单个骰子只是零件游戏规则才是灵魂。我们需要一个Game或GameEngine类来管理游戏流程和状态。游戏状态通常包括NotStarted未开始、PlayerTurn玩家回合、GameOver结束等。使用枚举enum来定义这些状态是清晰的做法。玩家与回合游戏可能有多个玩家Player类每个玩家有自己的回合可以投掷一个或多个骰子。Player类可能包含姓名、总分、本轮投掷结果等属性。规则判定根据游戏类型比如比大小、接近目标点数、扑克骰等在每轮投掷后GameEngine需要根据所有骰子的结果计算得分、判断胜负、决定下一个回合。这里的逻辑是算法练习的好地方。2.3 用户界面与业务逻辑分离这是本项目从“玩具代码”升级为“学习项目”的关键。务必遵循关注点分离原则。UI层WinForms的Form或WPF的Window只负责接收用户输入点击“投掷”按钮。显示游戏状态和骰子图像。触发业务逻辑层的相应操作。业务逻辑层Dice,GameEngine,Player则完全不知道UI的存在它只处理核心数据和规则。两者通过事件event或直接方法调用进行通信。例如点击按钮后UI层调用gameEngine.RollDiceForCurrentPlayer()然后GameEngine更新状态并通过事件如DiceRolled或返回值通知UI层更新显示。// 在GameEngine中定义事件 public event ActionPlayer, ListDice OnRoundCompleted; // 在UI窗体中订阅事件 public MainForm() { InitializeComponent(); _gameEngine new GameEngine(); _gameEngine.OnRoundCompleted UpdateUIAfterRound; } private void btnRoll_Click(object sender, EventArgs e) { // UI层只触发动作 _gameEngine.RollDicesForCurrentPlayer(); } private void UpdateUIAfterRound(Player player, ListDice dices) { // 在UI线程上更新控件 if (this.InvokeRequired) { this.Invoke(new Action(() UpdateUIAfterRound(player, dices))); return; } // 更新骰子图片、玩家分数显示等 lblPlayerScore.Text player.TotalScore.ToString(); // ... 更新骰子PictureBox的图片为对应点数的图片 }这种分离使得代码易于测试可以单独测试GameEngine的逻辑和维护修改UI不影响游戏规则。3. 关键代码模块深度解析理解了设计思路我们深入到代码内部看看几个关键模块是如何实现的并解读其中的技巧和陷阱。3.1 Dice类的完整实现与随机数陷阱规避一个健壮的Dice类远不止一个Roll方法。我们来看一个更工业化的版本using System; namespace DiceGame.Core { /// summary /// 表示一个多面骰子。 /// /summary public class Dice { private readonly int _sides; private readonly Random _randomizer; private int _currentValue; private bool _isRolling; /// summary /// 获取骰子的面数。 /// /summary public int Sides _sides; /// summary /// 获取或设置骰子当前朝上的点数。 /// /summary public int CurrentValue { get _currentValue; private set { if (value 1 || value _sides) throw new ArgumentOutOfRangeException(nameof(value), $点数必须在1到{_sides}之间。); _currentValue value; OnValueChanged?.Invoke(this, EventArgs.Empty); } } /// summary /// 获取一个值指示骰子是否正在投掷动画中。 /// /summary public bool IsRolling _isRolling; /// summary /// 当骰子点数发生变化时触发。 /// /summary public event EventHandler OnValueChanged; /// summary /// 初始化指定面数的新骰子实例。 /// /summary /// param namesides面数必须大于等于1。/param /// param namerandomizer随机数生成器实例。如果为null将创建新的。/param /// exception crefArgumentException当面数小于1时抛出。/exception public Dice(int sides 6, Random randomizer null) { if (sides 1) throw new ArgumentException(骰子面数必须至少为1。, nameof(sides)); _sides sides; _randomizer randomizer ?? new Random(Guid.NewGuid().GetHashCode()); // 使用Guid生成更随机的种子 _currentValue 1; // 默认初始点数 _isRolling false; } /// summary /// 投掷骰子并返回新的点数。 /// /summary /// returns投掷结果。/returns public int Roll() { if (_isRolling) throw new InvalidOperationException(骰子正在投掷中请等待完成。); _isRolling true; try { // 模拟投掷延迟在实际UI中这里会触发动画 // System.Threading.Thread.Sleep(50); // 仅用于模拟UI中应用异步方式 int newValue _randomizer.Next(1, _sides 1); // Random.Next 的 maxValue 是独占的 CurrentValue newValue; return newValue; } finally { _isRolling false; } } /// summary /// 重置骰子到初始状态点数为1。 /// /summary public void Reset() { CurrentValue 1; _isRolling false; } /// summary /// 返回表示当前骰子的字符串。 /// /summary public override string ToString() { return $[{Sides}面骰] 当前点数: {CurrentValue}; } } }代码解析与心得构造函数注入Random通过参数传入Random对象允许外部控制随机数源。这在单元测试中非常有用你可以传入一个模拟Mock对象来产生确定的序列从而测试特定点数下的游戏逻辑。如果传入null则使用Guid.NewGuid().GetHashCode()作为种子这比单纯使用new Random()基于时间的种子冲突概率更低。属性封装与验证CurrentValue的setter是私有的只能通过Roll或Reset方法修改保证了状态变更的合法性。在setter中进行了范围校验并触发了OnValueChanged事件这是实现UI绑定的基础。状态锁_isRolling这是一个简单的防重入机制防止在投掷动画未完成时再次调用Roll方法。在更复杂的异步动画场景中可能需要使用更强大的SemaphoreSlim或CancellationToken。try...finally块确保无论投掷过程是否发生异常_isRolling标志都能被正确重置避免对象进入“卡死”状态。3.2 游戏引擎GameEngine的流程控制游戏引擎是大脑。我们设计一个简单的两人比大小游戏引擎using System; using System.Collections.Generic; using System.Linq; namespace DiceGame.Core { public enum GameState { WaitingForPlayers, Player1Turn, Player2Turn, RoundEnded, GameOver } public class SimpleDiceGameEngine { private GameState _currentState; private readonly Player _player1; private readonly Player _player2; private readonly ListDice _dices; private Player _currentPlayer; private int _roundsPlayed; public const int MaxRounds 5; public GameState CurrentState _currentState; public Player CurrentPlayer _currentPlayer; public Player Player1 _player1; public Player Player2 _player2; public IReadOnlyListDice Dices _dices.AsReadOnly(); public int RoundsPlayed _roundsPlayed; public event ActionGameState OnGameStateChanged; public event ActionPlayer, int OnPlayerScoreUpdated; // 玩家本轮得分 public event ActionPlayer OnGameWinnerDeclared; public SimpleDiceGameEngine(string player1Name, string player2Name, int numberOfDices 2) { _player1 new Player(player1Name); _player2 new Player(player2Name); _dices new ListDice(); var sharedRandom new Random(); for (int i 0; i numberOfDices; i) { _dices.Add(new Dice(6, sharedRandom)); // 共享Random实例 } _currentState GameState.WaitingForPlayers; _roundsPlayed 0; } public void StartGame() { if (_currentState ! GameState.WaitingForPlayers) throw new InvalidOperationException($游戏无法在{_currentState}状态下开始。); _currentPlayer _player1; _currentState GameState.Player1Turn; OnGameStateChanged?.Invoke(_currentState); } public void RollForCurrentPlayer() { // 状态校验只有在玩家回合才能投掷 if (_currentState ! GameState.Player1Turn _currentState ! GameState.Player2Turn) throw new InvalidOperationException(现在不是投掷时机。); int roundScore 0; Listint results new Listint(); // 投掷所有骰子 foreach (var dice in _dices) { results.Add(dice.Roll()); } roundScore results.Sum(); // 简单规则点数总和 // 更新当前玩家分数 _currentPlayer.AddScore(roundScore); OnPlayerScoreUpdated?.Invoke(_currentPlayer, roundScore); // 切换状态判断回合/游戏结束 _currentState GameState.RoundEnded; OnGameStateChanged?.Invoke(_currentState); // 延迟一下然后进入下一回合或结束游戏 // 在实际UI中这里会等待一个动画时间然后调用AdvanceToNextTurn } public void AdvanceToNextTurn() { if (_currentState ! GameState.RoundEnded) return; _roundsPlayed; // 检查游戏是否结束达到最大回合数 if (_roundsPlayed MaxRounds * 2) // 每人每轮一次总回合数轮数*2 { _currentState GameState.GameOver; OnGameStateChanged?.Invoke(_currentState); DeclareWinner(); return; } // 切换当前玩家 _currentPlayer (_currentPlayer _player1) ? _player2 : _player1; _currentState (_currentPlayer _player1) ? GameState.Player1Turn : GameState.Player2Turn; OnGameStateChanged?.Invoke(_currentState); } private void DeclareWinner() { Player winner null; if (_player1.TotalScore _player2.TotalScore) winner _player1; else if (_player2.TotalScore _player1.TotalScore) winner _player2; // 平局则winner为null OnGameWinnerDeclared?.Invoke(winner); } } public class Player { public string Name { get; } public int TotalScore { get; private set; } public Player(string name) { Name name ?? throw new ArgumentNullException(nameof(name)); TotalScore 0; } public void AddScore(int score) { if (score 0) throw new ArgumentException(分数不能为负。, nameof(score)); TotalScore score; } public override string ToString() ${Name} (总分: {TotalScore}); } }设计要点状态驱动整个游戏流程由GameState枚举驱动。UI根据当前状态决定显示什么如哪个玩家的回合、按钮是否可用。这是一种清晰的状态机模式。事件驱动通信引擎通过OnGameStateChanged、OnPlayerScoreUpdated等事件通知UI而不是直接操作UI控件。这彻底解耦了业务逻辑和UI。回合与轮次管理AdvanceToNextTurn方法封装了回合切换和游戏结束的逻辑使得主流程清晰。玩家管理单独的Player类管理玩家状态未来可以轻松扩展为多个玩家。3.3 UI层实现WinForms与WPF的选择与技巧源码可能会提供WinForms或WPF版本。两者都是学习C# GUI的好选择但理念不同。WinForms 实现要点快速、简单骰子显示通常使用PictureBox控件准备6张或对应面数骰子点数的图片如dice1.png, dice2.png...根据Dice.CurrentValue切换Image属性。投掷动画在Roll按钮的点击事件中启动一个Timer控件。在Timer的Tick事件里快速循环切换骰子图片模拟旋转持续若干次后停止Timer并显示最终点数对应的图片同时调用GameEngine.RollForCurrentPlayer()。数据绑定WinForms的数据绑定相对简单。通常手动在事件处理程序中更新Label显示分数、Button.Enabled控制按钮状态等。// WinForms中按钮点击事件示例 private async void btnRoll_Click(object sender, EventArgs e) { btnRoll.Enabled false; // 1. 启动视觉动画 await PlayDiceRollAnimationAsync(pictureBoxDice1, _gameEngine.Dices[0]); await PlayDiceRollAnimationAsync(pictureBoxDice2, _gameEngine.Dices[1]); // 2. 执行核心业务逻辑 _gameEngine.RollForCurrentPlayer(); // 注意RollForCurrentPlayer会触发OnPlayerScoreUpdated事件我们在事件处理程序中更新UI lblPlayer1Score.Text _gameEngine.Player1.TotalScore.ToString(); // ... 其他UI更新 // 3. 短暂延迟后进入下一回合 await Task.Delay(800); // 给玩家看结果的时间 _gameEngine.AdvanceToNextTurn(); UpdateUIState(); // 根据新的GameState更新界面 btnRoll.Enabled (_gameEngine.CurrentState GameState.Player1Turn || _gameEngine.CurrentState GameState.Player2Turn); } private async Task PlayDiceRollAnimationAsync(PictureBox picBox, Dice dice) { int animationSteps 15; for (int i 0; i animationSteps; i) { // 快速显示一个随机点数图片非最终结果 int tempValue _random.Next(1, 7); picBox.Image _diceImages[tempValue - 1]; await Task.Delay(50); // 使用异步延迟不阻塞UI线程 } // 动画结束显示真实点数 picBox.Image _diceImages[dice.CurrentValue - 1]; }WPF 实现要点现代、强大数据绑定与MVVM这是WPF的精髓。你会看到ViewModel类例如DiceGameViewModel它包含ObservableCollectionDiceViewModel、CurrentPlayer等属性并实现INotifyPropertyChanged接口。UI控件XAML直接绑定到这些属性上。当ViewModel属性变化时UI自动更新。命令Command代替事件处理程序。RollCommand是一个ICommand实现绑定到按钮的Command属性。它更易于进行启用/禁用状态的管理和单元测试。骰子动画使用WPF的Storyboard和DoubleAnimation来实现更流畅、更复杂的旋转、弹跳等动画效果直接在XAML中声明与逻辑代码分离。模板与样式可以使用DataTemplate为DiceViewModel定义视觉呈现轻松更换骰子的外观风格。注意UI线程与异步操作无论在WinForms还是WPF中从非UI线程如Task.Run内部更新UI控件都会引发跨线程异常。WinForms中使用Control.InvokeWPF中使用Dispatcher.Invoke。现代做法是广泛使用async/await模式确保在UI线程上恢复执行。上面的PlayDiceRollAnimationAsync方法就是一个例子。4. 项目扩展与高级技巧掌握了基础版本后你可以尝试以下扩展这会让你的学习更上一层楼。4.1 实现更复杂的游戏规则基础的比大小太简单了。尝试实现以下规则“骰子扑克”投掷5个骰子判断牌型如四条、葫芦、顺子等。这需要你编写复杂的算法来分析和比较骰子集合。“接近目标”设定一个目标点数如21点玩家轮流投掷可以选择“继续”或“停止”目标是使总点数最接近但不超过目标。这引入了策略元素。多人联网对战这是质的飞跃。你需要引入网络通信层如使用SignalR或WebSocket设计消息协议如RollDiceMessage、GameStateMessage并处理客户端状态同步、回合锁定等并发问题。4.2 引入依赖注入与单元测试要让项目结构更专业可以引入一个轻量级的依赖注入容器如Microsoft.Extensions.DependencyInjection。为什么用DI将Random、GameEngine、Dice的创建和管理交给容器降低类之间的耦合度。例如GameEngine不再自己new Dice()而是通过构造函数接收一个IDiceFactory接口。如何做// 定义接口 public interface IDiceFactory { Dice CreateDice(int sides); } public interface IRandomNumberGenerator { int Next(int min, int max); } // 实现 public class DefaultDiceFactory : IDiceFactory { private readonly IRandomNumberGenerator _rng; public DefaultDiceFactory(IRandomNumberGenerator rng) { _rng rng; } public Dice CreateDice(int sides) new Dice(sides, _rng); } // 在Program.cs或App.xaml.cs中配置服务 var services new ServiceCollection(); services.AddSingletonIRandomNumberGenerator, SystemRandomNumberGenerator(); services.AddTransientIDiceFactory, DefaultDiceFactory(); services.AddSingletonSimpleDiceGameEngine(); var serviceProvider services.BuildServiceProvider(); // 然后从serviceProvider获取GameEngine实例单元测试有了接口测试变得极其简单。你可以创建一个MockRandomNumberGenerator让它返回你预设的序列从而精确测试“当玩家连续掷出6和6时总分是否正确”这样的场景。使用xUnit或NUnit框架。4.3 性能优化与资源管理图片资源如果骰子面数多或图片质量高频繁从磁盘加载图片会卡顿。应在程序启动时将所有骰子图片加载到内存中的Dictionaryint, Bitmap或ImageList中使用时直接读取。对象池如果游戏涉及大量、频繁创建和销毁的临时对象比如飞溅的粒子效果可以考虑对象池模式复用对象减少GC压力。对于骰子游戏本身对象池可能杀鸡用牛刀但了解这个概念很有价值。异步与响应式确保所有耗时的操作如模拟网络延迟、复杂计算都使用async/await保持UI响应流畅。在WPF中还可以考虑使用Reactive Extensions (Rx.NET) 来处理复杂的事件流。5. 常见问题与调试实录在实际编码和运行中你肯定会遇到各种问题。这里记录几个典型场景和解决思路。5.1 问题一骰子动画卡顿UI无响应现象点击“投掷”按钮后程序好像卡住了界面不动过一会儿直接显示结果。原因你在UI线程上执行了耗时操作比如用Thread.Sleep进行动画延迟阻塞了消息循环。解决方案绝对不要在UI线程中使用Thread.Sleep。使用异步编程将事件处理程序标记为async使用await Task.Delay(milliseconds)进行异步等待。在WinForms中使用async/await配合Task.Run来处理CPU密集型计算但更新UI控件时仍需回到UI线程通常await后会自动回到原上下文。在WPF中同样使用async/awaitWPF的Dispatcher能很好地处理同步上下文。5.2 问题二随机数不“随机”总是出现相同序列现象每次重启游戏前几次投掷的点数序列都一样。原因如前所述在循环或快速连续调用中错误地创建了多个Random实例且使用了默认的基于时间的种子。解决方案使用静态的Random实例注意线程安全可以用lock或ThreadLocalRandom。更好的方法是在应用程序启动时创建一个主Random实例用它来为各个需要随机数的组件生成种子或者通过依赖注入共享这个实例。对于需要高安全性的随机数如赌博游戏应使用System.Security.Cryptography.RandomNumberGenerator。5.3 问题三在多玩家或网络模式下回合逻辑混乱现象玩家A还没操作完玩家B的界面就更新了或者状态判断错误。原因状态管理不严谨可能存在竞态条件。在网络模式下还可能因为消息延迟和乱序导致。解决方案强化状态机任何操作前都严格检查CurrentState。只有在PlayerXTurn状态下才允许该玩家执行Roll操作。使用锁或并发集合如果状态可能在多个线程中被修改例如网络消息处理线程和UI线程使用lock关键字或并发集合如ConcurrentDictionary保护共享状态。设计幂等操作网络消息可能重复到达。确保处理“投掷”等关键操作的方法是幂等的即多次执行与一次执行效果相同。可以为每个操作分配唯一ID服务器记录已处理的操作ID。客户端预测与服务器校正在实时性要求高的游戏中客户端可以先乐观地执行操作并显示结果预测然后等待服务器权威状态同步过来后进行校正。对于回合制骰子游戏通常采用“锁步”模式即完全由服务器驱动回合客户端只发送指令等待服务器广播结果。5.4 问题四在WPF中绑定不更新UI现象ViewModel的属性值已经改变但界面上的TextBlock或Image没有更新。原因ViewModel没有正确实现INotifyPropertyChanged接口或者属性是在非UI线程上修改的虽然WPF绑定在某些情况下能跨线程但不推荐。解决方案确保实现INPC属性setter中必须调用PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(PropertyName)))。可以使用CommunityToolkit.Mvvm等库的[ObservableProperty]特性简化。在UI线程上修改属性如果是在后台线程中计算出了新值使用Application.Current.Dispatcher.Invoke(() { Score newValue; })来更新绑定属性。检查绑定模式确认XAML中的绑定路径Path正确并且DataContext已设置。通过这个“投骰子游戏”项目你学到的绝不仅仅是生成一个随机数。你实践了面向对象设计、事件驱动编程、状态管理、UI线程安全、异步操作甚至触及了简单的游戏引擎架构和软件工程原则。把这些点都吃透再去看更复杂的C#项目你会发现很多概念都是相通的。这个项目的源码就是你从C#语法学习者迈向初级应用开发者的一个坚实跳板。我建议你在理解的基础上不要满足于运行它而是动手去修改规则、美化UI、增加功能甚至用不同的技术比如用Blazor做网页版重写一遍这才是真正的学习。