1. 项目概述:从源码到实战,一个C#大富翁游戏能教会我们什么?
最近在整理硬盘里的老项目,翻出来一个几年前用C#写的大富翁游戏。这项目最初是给公司年会活动做的,当时需要一个大屏互动游戏来炒热气氛,时间紧任务重,就自己动手撸了一个。后来陆陆续续又往里加了不少东西,从最开始的单机演示版,到后来支持简单网络对战,再到尝试用Unity重写渲染层,这个项目几乎成了我验证C#各种技术想法的“试验田”。今天不聊那些高大上的框架,就从这个具体的、能跑起来的“大富翁”源码出发,掰开揉碎了讲讲,一个完整的C#游戏项目里到底藏着哪些门道,以及这些代码经验如何迁移到你手头的其他项目里,比如上位机、管理系统甚至是一些自动化工具的开发。
很多人觉得游戏开发离业务软件很远,其实不然。游戏里要处理的状态管理、事件驱动、数据序列化、甚至是简单的AI逻辑,在业务系统里同样会遇到。通过解析这个项目,我希望你能看到的不仅仅是一个游戏的实现,更是一套用C#解决复杂交互逻辑的通用思路。无论你是刚学完C#语法想找个综合项目练手,还是已经工作想深化对异步、委托、序列化等概念的理解,这个源码都能提供一个非常直观的样本。
2. 项目整体架构与核心设计思路
2.1 为什么选择WinForms作为起点?
这个项目最初是基于.NET Framework的WinForms开发的。很多人现在可能看不上WinForms,觉得它“古老”、“界面丑”。但对于快速原型开发、特别是需要复杂自定义控件和直接GDI+绘图交互的场景,WinForms依然有它的优势。大富翁的游戏棋盘本质上就是一个网格地图,上面有地块、玩家棋子、建筑等元素。用WinForms的Panel或自定义UserControl来绘制棋盘,响应鼠标点击事件来选择掷骰子、购买地产等操作,开发效率非常高。
注意:选择WinForms并不意味着技术栈落后。对于工具类、演示类、或对安装部署有严格要求(需兼容老旧Windows系统)的内部项目,WinForms的稳定性和简易性是显著的优点。它的快速开发能力能让你把精力集中在游戏逻辑本身,而不是纠结于UI框架的复杂配置。
整个项目的解决方案结构比较清晰,主要分成了几个核心层:
- Model层(数据模型):定义了游戏中的所有实体,如
Player(玩家)、Land(地块)、Card(机会/命运卡牌)、Dice(骰子)等。这些是纯粹的C#类,只包含属性和简单的行为方法,不涉及任何UI。 - Logic层(游戏逻辑):这是游戏的大脑。包含
GameEngine(游戏引擎)类,它持有一个GameState(游戏状态)对象,负责驱动游戏回合、执行玩家动作(移动、购买、支付过路费)、判定游戏结束等。逻辑层与UI层完全解耦,只通过事件(event)或接口通知外部状态变化。 - View层(用户界面):即WinForms的窗体(
Form)和控件。主窗体MainForm持有GameEngine的实例,并订阅其各种事件(如OnPlayerMoved、OnPlayerMoneyChanged)。当事件触发时,UI层更新棋盘显示、玩家信息面板等。用户的操作(点击按钮)则调用GameEngine的公开方法。 - Utility层(工具类):包含一些辅助功能,比如负责将游戏状态保存到文件或从文件加载的
GameSaveManager(使用JSON序列化),以及一些扩展方法。
这种分层架构虽然简单,但意义重大。它保证了游戏核心逻辑的可测试性。你可以单独为GameEngine编写单元测试,模拟玩家的各种操作,而无需启动UI。这种设计模式在你开发任何C#项目时都值得借鉴,无论是游戏还是业务系统。
2.2 核心游戏循环与状态管理
大富翁是一个典型的基于回合制的游戏。它的核心循环可以抽象为以下步骤:
- 等待当前玩家操作(掷骰子)。
- 根据骰子点数移动玩家棋子。
- 触发玩家落脚地块的事件(如:无主地可购买、有主地需付租金、触发卡片效果等)。
- 结算本回合所有效果(收租、扣款、获得卡片等)。
- 检查游戏是否结束(例如,只剩一名玩家未破产)。
- 切换到下一玩家,回到步骤1。
在代码中,这个循环由GameEngine的ProcessTurn()方法主导。这里最关键的挑战是游戏状态的管理。GameState类是这个状态的中心,它包含了:
- 所有玩家的列表及其当前属性(位置、现金、资产、状态标志如“是否在监狱”)。
- 棋盘地图上所有地块的当前状态(所有者、建筑等级)。
- 牌堆的当前状态。
- 当前回合的玩家索引。
- 游戏历史记录(用于回放或调试)。
任何修改游戏状态的操作,都必须通过GameEngine提供的方法进行,而不是直接修改GameState内部的属性。这保证了状态变更的集中控制和可追溯性。例如,玩家支付金钱不是一个简单的player.Money -= amount,而是调用engine.PlayerPayMoney(playerId, amount, reason),这个方法内部会检查玩家现金是否充足、记录交易日志、并触发OnPlayerMoneyChanged事件通知UI更新。
3. 核心模块源码深度解析
3.1 棋盘与地块系统的数据驱动设计
棋盘是游戏的舞台。最笨的办法是在代码里硬编码每个地块的位置、名称、价格。但更好的做法是数据驱动。在这个项目中,我使用了一个JSON文件来定义棋盘。
// board_config.json (简化示例) { "lands": [ { "id": 0, "name": "起点", "type": "Start", "colorGroup": null, "basePrice": 0, "passingReward": 200 }, { "id": 1, "name": "地中海大道", "type": "Street", "colorGroup": "Brown", "basePrice": 60, "rent": [2, 10, 30, 90, 160, 250], "houseCost": 50 }, { "id": 2, "type": "Chance", "name": "机会" } // ... 更多地块 ] }游戏启动时,Board类会加载这个JSON文件,并反序列化成一个List<Land>对象。每个Land对象根据type字段,在运行时被实例化为具体的子类对象,如StreetLand(街道地产)、RailroadLand(铁路)、UtilityLand(水电公司)、TaxLand(税务)等。这是多态的典型应用。
这样做的好处:
- 易于修改:想调整地图、平衡性?直接改JSON文件,无需重新编译代码。
- 便于扩展:要新增一种特殊地块类型(比如“机场”),只需在代码中定义新的
Land子类,并在JSON的type字段里增加对应的标识,然后在Board类的加载逻辑中添加对这个新类型的处理即可。 - 分离数据与逻辑:策划(即使不懂代码)也能在一定程度上参与内容设计。
在UI绘制时,根据每个Land的id(即顺序索引)计算其在棋盘上的像素坐标。通常采用一个二维布局映射表来完成这个计算。
3.2 玩家行动与事件驱动模型
玩家的每一个操作,如“掷骰子”、“购买地产”、“建造房屋”,都对应GameEngine中的一个公开方法。但这些方法并不直接操作UI。它们的工作流程是:
- 验证操作是否合法(例如,玩家是否有钱买房?是否轮到他的回合?)。
- 修改
GameState(扣钱、改变地产所有权)。 - 触发一个或多个事件。
// GameEngine 类中的部分代码 public class GameEngine { public event Action<Player, int> PlayerMoved; // 玩家移动事件 public event Action<Player, int> PlayerMoneyChanged; // 金钱变化事件 public event Action<Player, Land> LandPurchased; // 购买地产事件 public void PurchaseLand(int playerId, int landId) { var player = GetPlayer(playerId); var land = GetLand(landId); // 1. 验证 if (!CanPurchaseLand(player, land)) { throw new InvalidOperationException("无法购买此地块"); } // 2. 修改状态 player.Money -= land.Price; land.Owner = player; player.OwnedLands.Add(land); // 3. 触发事件 OnPlayerMoneyChanged(player, -land.Price); OnLandPurchased(player, land); } protected virtual void OnLandPurchased(Player player, Land land) { LandPurchased?.Invoke(player, land); } }在WinForms的主窗体中,我们订阅这些事件:
public partial class MainForm : Form { private GameEngine _engine; public MainForm() { InitializeComponent(); _engine = new GameEngine(); _engine.LandPurchased += Engine_LandPurchased; } private void Engine_LandPurchased(Player player, Land land) { // 在主线程上更新UI if (this.InvokeRequired) { this.Invoke(new Action<Player, Land>(Engine_LandPurchased), player, land); return; } // 更新棋盘上该地块的颜色(显示为新主人的颜色) UpdateLandUI(land); // 更新玩家信息面板 UpdatePlayerInfoUI(player); } private void btnPurchase_Click(object sender, EventArgs e) { // UI按钮点击,调用引擎逻辑 try { _engine.PurchaseLand(_currentPlayerId, _selectedLandId); } catch (InvalidOperationException ex) { MessageBox.Show(ex.Message); } } }这种事件驱动模型彻底解耦了逻辑与界面。GameEngine完全不知道UI的存在,它只关心游戏规则和状态。这使得同一套游戏逻辑可以轻松适配不同的表现层,比如未来可以替换成WPF、Avalonia甚至Unity的UI,而游戏核心代码几乎不用改动。这也是很多桌面应用和游戏框架的核心思想。
3.3 卡牌系统与命令模式的应用
“机会”和“命运”卡牌是大富翁游戏的变数来源。每张卡牌效果各异:“前进到XX格”、“获得/失去金钱”、“免费出狱”等。如果用一个巨大的switch语句来处理所有卡牌效果,代码会变得冗长且难以维护。
这里我使用了命令模式(Command Pattern)。为每一种卡牌效果定义一个具体的命令类,它们都实现一个共同的ICardCommand接口。
public interface ICardCommand { string Description { get; } void Execute(GameEngine engine, Player targetPlayer); } public class MovePlayerCommand : ICardCommand { public int Steps { get; set; } public string Description => $"前进 {Steps} 步"; public void Execute(GameEngine engine, Player player) { engine.MovePlayer(player.Id, Steps); } } public class ChangeMoneyCommand : ICardCommand { public int Amount { get; set; } // 正数为得钱,负数为扣钱 public string Description => Amount >= 0 ? $"获得 ${Amount}" : $"损失 ${-Amount}"; public void Execute(GameEngine engine, Player player) { engine.ChangePlayerMoney(player.Id, Amount, "卡牌效果"); } } // 在卡牌配置中 { "cardId": 1, "type": "MovePlayer", "description": "前进3步", "parameters": { "steps": 3 } }当玩家抽到一张卡牌时,系统根据卡牌的type和parameters,动态创建对应的ICardCommand对象(这里可以用反射或一个简单的工厂类),然后调用其Execute方法。这样,增加新的卡牌类型只需要新增一个命令类,并在配置文件中添加对应条目,无需修改任何处理卡牌抽签的核心逻辑。这种设计极大地提升了系统的可扩展性,在需要灵活配置业务规则的系统中(如工作流、促销活动引擎)非常有用。
3.4 数据持久化:JSON序列化的实战
游戏存档/读档是必备功能。我们需要把整个GameState(可能还包括一些游戏设置)保存到硬盘。System.Text.Json(.NET Core 3.0+)或Newtonsoft.Json(Json.NET)是首选。
这里以System.Text.Json为例,有几个关键点:
- 处理循环引用:
Player对象拥有其拥有的Land列表,而Land对象又有一个Owner属性指向Player。直接序列化会形成循环引用,导致栈溢出或序列化失败。解决方案是忽略其中一个方向的引用,或者在序列化时使用特殊的设置。
public class GameSaveManager { public void SaveGame(GameState state, string filePath) { var options = new JsonSerializerOptions { WriteIndented = true, // 美化输出,便于调试 ReferenceHandler = ReferenceHandler.IgnoreCycles // 忽略循环引用 // 或者使用 Preserve 模式,但需要更复杂的模型设计 }; string jsonString = JsonSerializer.Serialize(state, options); File.WriteAllText(filePath, jsonString); } public GameState LoadGame(string filePath) { string jsonString = File.ReadAllText(filePath); var state = JsonSerializer.Deserialize<GameState>(jsonString); // 注意:反序列化后,由于忽略了循环引用,Land.Owner可能为null。 // 需要根据Land.OwnerId等字段,手动重建对象间的引用关系。 RebuildReferences(state); return state; } private void RebuildReferences(GameState state) { // 重建Player和Land之间的引用 var landDict = state.Lands.ToDictionary(l => l.Id); foreach (var player in state.Players) { foreach (var landId in player.OwnedLandIds) // 假设Player只保存Land的ID列表 { if (landDict.TryGetValue(landId, out var land)) { land.Owner = player; // 也可以将land添加到player.OwnedLands列表,如果该列表存在的话 } } } } }实操心得:对于复杂的对象图序列化,我更喜欢只序列化“原始数据”(如ID、基本属性),反序列化后再手动重建对象间的引用关系。这样虽然多了一步,但序列化模型更清晰、更稳定,避免了
JsonSerializer高级特性带来的不可预知行为。Newtonsoft.Json的PreserveReferencesHandling功能更强大,但配置也更复杂,需根据项目复杂度权衡。
- 版本兼容性:游戏更新后,存档数据结构可能变化。一个实用的技巧是在存档元数据中加入一个
SaveVersion字段。读档时,根据版本号执行不同的数据迁移逻辑,将旧版数据结构升级到新版。这是任何需要长期数据持久化的应用都要考虑的问题。
4. 从WinForms到Unity:架构的迁移与思考
后来为了获得更好的图形效果和跨平台潜力,我尝试用Unity重写这个游戏的表示层。这是一个非常有趣的体验,它验证了前期逻辑与表现分离架构的正确性。
在Unity项目中:
- Model层和Logic层几乎原封不动地移植。将原来的
.NET Framework类库项目改为.NET Standard或.NET Core类库,Unity可以完美引用。 - View层完全重写。用Unity的
GameObject、Sprite、UI Canvas来构建棋盘和UI。GameEngine的事件(如PlayerMoved)现在由Unity的MonoBehaviour脚本来订阅,并在回调里驱动GameObject的移动动画、更新UGUI的Text组件显示金钱。
// Unity中的GameController脚本 public class GameController : MonoBehaviour { private GameEngine _engine; public PlayerPiece[] playerPieces; // 关联到场景中的玩家棋子GameObject void Start() { _engine = new GameEngine(); _engine.PlayerMoved += OnPlayerMovedInEngine; // 初始化游戏... } private void OnPlayerMovedInEngine(Player player, int steps) { // 在Unity主线程上执行移动动画 StartCoroutine(AnimatePlayerMove(player.Id, steps)); } IEnumerator AnimatePlayerMove(int playerId, int steps) { var piece = playerPieces[playerId]; for (int i = 0; i < steps; i++) { // 计算下一格的位置 Vector3 nextPos = CalculateBoardPosition(piece.CurrentLandId + 1); // 平滑移动到下一格 yield return piece.MoveToPosition(nextPos, 0.3f); // 假设MoveToPosition是一个返回IEnumerator的协程方法 } // 动画结束后,通知引擎进行落脚点事件处理(可通过另一个事件或直接调用) _engine.ProcessLandingEvent(playerId); } // UI按钮调用 public void OnRollDiceButtonClicked() { _engine.RollDice(_currentPlayerId); } }这次迁移的核心收获是:良好的架构设计让核心代码资产得以复用。游戏规则、数据模型这些最核心、最稳定的部分,不依赖于任何特定的UI框架或图形API。这大大降低了重写或移植的成本。在做任何C#项目时,都应该有意识地将“业务核心”与“交互外壳”分离。
5. 项目中提炼的通用C#编程技巧与避坑指南
5.1 使用async/await处理潜在耗时操作
即使在单机游戏中,也有一些操作可能阻塞主线程,比如加载庞大的地图配置、从网络获取排行榜数据、或者存档时压缩数据。在WinForms中,如果这些操作在UI线程上同步进行,会导致界面“卡住”无响应。
// 错误的做法:同步加载,UI会卡顿 private void LoadGameButton_Click(object sender, EventArgs e) { var saveData = File.ReadAllText("save.json"); // 同步读取,如果文件很大... var gameState = JsonSerializer.Deserialize<GameState>(saveData); // 反序列化也可能耗时 _engine.LoadState(gameState); UpdateUI(); // UI更新被阻塞,直到上面所有操作完成 } // 正确的做法:异步加载,保持UI响应 private async void LoadGameButton_Click(object sender, EventArgs e) { this.Enabled = false; // 禁用按钮,防止重复点击 loadingIndicator.Show(); try { // 使用异步API读取文件 using var stream = File.OpenRead("save.json"); var gameState = await JsonSerializer.DeserializeAsync<GameState>(stream); // 由于反序列化可能在后台线程完成,回到UI线程更新引擎和界面 this.Invoke((MethodInvoker)delegate { _engine.LoadState(gameState); UpdateUI(); }); } catch (Exception ex) { MessageBox.Show($"加载失败: {ex.Message}"); } finally { loadingIndicator.Hide(); this.Enabled = true; } }注意事项:WinForms的UI控件不是线程安全的。任何对控件的属性修改(如
TextBox.Text、ProgressBar.Value)都必须在创建该控件的线程(通常是主UI线程)上进行。Control.Invoke或Control.BeginInvoke方法就是用来将委托封送回UI线程执行的。在.NET Core/.NET 5+的WinForms中,对async/await的支持更好,但线程安全规则不变。
5.2 委托与事件的应用深化
除了用于解耦,委托和事件还能实现更灵活的策略。例如,游戏中的“租金计算规则”可能很复杂,并且未来可能调整。我们可以将其抽象为一个委托。
public delegate int RentCalculationDelegate(Land land, Player owner, Player payer); public class GameEngine { // 默认的租金计算策略 public RentCalculationDelegate RentCalculator { get; set; } = CalculateStandardRent; private static int CalculateStandardRent(Land land, Player owner, Player payer) { if (land is StreetLand street) { int baseRent = street.Rent[street.BuildingLevel]; // 检查是否拥有同色系所有地块 if (owner.OwnsAllInColorGroup(street.ColorGroup)) { baseRent *= 2; // 拥有全套,租金翻倍 } return baseRent; } else if (land is RailroadLand) { // 铁路租金根据拥有铁路数量递增 int railroadCount = owner.GetOwnedRailroads().Count; return 25 * (1 << (railroadCount - 1)); // 25, 50, 100, 200 } // ... 其他类型地块 return 0; } public int CalculateRentDue(Land land, Player payer) { if (land.Owner == null || land.Owner == payer) return 0; return RentCalculator(land, land.Owner, payer); } } // 在游戏中,可以动态替换计算规则,例如开启“双倍租金”活动 _engine.RentCalculator = (land, owner, payer) => { int standardRent = CalculateStandardRent(land, owner, payer); return standardRent * 2; // 活动期间租金翻倍 };这种模式将算法(策略)封装成对象(这里是委托),使其可以相互替换。它比硬编码的if-else或switch语句更符合开闭原则,在需要灵活变更业务规则的系统中非常有用。
5.3 资源管理与内存泄漏预防
在WinForms游戏中,如果动态创建了大量控件(比如每个地块都是一个自定义的LandControl),并且没有正确管理其生命周期,就容易发生内存泄漏。虽然.NET有垃圾回收,但事件订阅是常见的“泄漏”根源。
public partial class LandControl : UserControl { private GameEngine _engine; public LandControl(GameEngine engine) { InitializeComponent(); _engine = engine; // 订阅引擎事件 _engine.LandPurchased += OnLandPurchased; } private void OnLandPurchased(Player player, Land land) { if (land.Id == this.LandId) { this.BackColor = player.Color; } } // 问题:当这个LandControl被移除(如从Panel.Controls中移除)后, // 它仍然被_engine.LandPurchased事件引用,无法被垃圾回收! }解决方案:实现IDisposable接口,在控件被销毁时取消事件订阅。
public partial class LandControl : UserControl, IDisposable { private GameEngine _engine; private bool _disposed = false; public LandControl(GameEngine engine) { InitializeComponent(); _engine = engine; _engine.LandPurchased += OnLandPurchased; } protected override void Dispose(bool disposing) { if (!_disposed) { if (disposing) { // 释放托管资源,取消事件订阅 if (_engine != null) { _engine.LandPurchased -= OnLandPurchased; } } _disposed = true; } base.Dispose(disposing); } }在Unity中,同样要注意。MonoBehaviour脚本中订阅的静态事件或长期存在的对象的事件,如果不在OnDestroy方法中取消订阅,即使GameObject被销毁了,脚本实例也因为被事件持有而无法释放。
5.4 配置管理与依赖注入的雏形
随着项目变大,硬编码的配置(如骰子面数、初始玩家资金、地图尺寸)会散落在各处,难以管理。一个简单的改进是引入一个GameConfig静态类或从appsettings.json文件加载配置。
更进阶的做法是引入一个简单的依赖注入(DI)容器思想。虽然不需要完整的DI框架,但我们可以手动管理核心服务的创建和传递。
// 一个简单的服务定位器(非线程安全,适用于单线程游戏) public static class ServiceLocator { private static Dictionary<Type, object> _services = new Dictionary<Type, object>(); public static void Register<T>(T service) { _services[typeof(T)] = service; } public static T Get<T>() { if (_services.TryGetValue(typeof(T), out var service)) { return (T)service; } throw new InvalidOperationException($"Service of type {typeof(T)} not registered."); } } // 在程序启动时(如MainForm构造函数) var config = new GameConfig { StartingMoney = 1500, BoardSize = 40 }; var dice = new RandomDice(); // 实现IDice接口 var engine = new GameEngine(config, dice); ServiceLocator.Register<IGameConfig>(config); ServiceLocator.Register<IDice>(dice); ServiceLocator.Register<GameEngine>(engine); // 在任何需要的地方获取服务,而不是通过层层构造函数传递 public class SomeSystem { private IGameConfig _config = ServiceLocator.Get<IGameConfig>(); private GameEngine _engine = ServiceLocator.Get<GameEngine>(); }这减少了类之间的直接耦合,使测试更容易(可以在测试中注册模拟服务)。对于中小型项目,这种手动DI已经足够。对于大型项目,可以考虑使用Microsoft.Extensions.DependencyInjection等轻量级容器。
6. 常见问题排查与调试技巧
在开发此类项目时,你肯定会遇到一些典型问题。以下是一些实录的排查经验:
问题:玩家移动动画卡顿,UI响应慢。
- 排查:在
PlayerMoved事件处理程序中使用了耗时操作(如复杂的路径计算、同步文件读写)或直接进行耗时循环。 - 解决:确保事件处理器执行迅速。将耗时操作异步化(
Task.Run),或将其移出UI线程。对于动画,使用Timer或async/await配合Task.Delay进行分帧更新,而不是Thread.Sleep。
- 排查:在
问题:游戏状态在存档/读档后出现错乱,比如地块所有者丢失。
- 排查:检查JSON序列化/反序列化逻辑。最可能的原因是循环引用处理不当,或者反序列化后没有正确重建对象间的引用关系(如前文提到的
RebuildReferences步骤被遗漏)。 - 解决:在序列化前后,添加详细的日志输出,对比关键对象(如玩家、地块)的ID和引用关系。使用
ReferenceHandler.Preserve(System.Text.Json)或PreserveReferencesHandling(Json.NET)可以保持引用,但需理解其工作原理。
- 排查:检查JSON序列化/反序列化逻辑。最可能的原因是循环引用处理不当,或者反序列化后没有正确重建对象间的引用关系(如前文提到的
问题:在Unity中,从
GameEngine事件更新UI时报错“只能在主线程中调用”。- 排查:
GameEngine的逻辑运算可能发生在另一个线程(比如你在一个Task中运行AI计算)。它触发的事件回调也就运行在那个线程。 - 解决:在事件处理器中,使用
UnityEngine.Dispatcher(如果使用类似框架)或更通用的,将更新UI的代码包装在MainThreadDispatcher中。一个简单模式是让事件处理器只将需要执行的操作加入一个主线程执行的队列。
// Unity中的主线程调度器(简化版) public class MainThreadDispatcher : MonoBehaviour { private static readonly Queue<Action> _executionQueue = new Queue<Action>(); public void Update() { lock (_executionQueue) { while (_executionQueue.Count > 0) { _executionQueue.Dequeue().Invoke(); } } } public static void Enqueue(Action action) { lock (_executionQueue) { _executionQueue.Enqueue(action); } } } // 在子线程的事件处理器中 _engine.PlayerMoneyChanged += (player, delta) => { MainThreadDispatcher.Enqueue(() => { // 这里可以安全地更新UGUI moneyText.text = player.Money.ToString(); }); };- 排查:
问题:游戏逻辑在某些边缘情况下出现诡异行为,比如玩家可以购买已拥有地块。
- 排查:核心逻辑
GameEngine的公开方法缺少充分的输入验证和前置条件检查。 - 解决:在每个公开方法开头,使用
Debug.Assert或直接的条件判断,验证参数和游戏状态。这不仅是防御性编程,也是清晰的文档。
public void PurchaseLand(int playerId, int landId) { var player = GetPlayer(playerId); var land = GetLand(landId); // 前置条件断言 Debug.Assert(player != null, "玩家不存在"); Debug.Assert(land != null, "地块不存在"); Debug.Assert(land.Owner == null, "地块已有主人"); Debug.Assert(player.Money >= land.Price, "玩家资金不足"); Debug.Assert(_currentPlayerId == playerId, "不是该玩家的回合"); // ... 执行购买逻辑 }- 排查:核心逻辑
这个C#大富翁项目,从一个简单的年会演示程序,逐渐演变成一个涵盖桌面开发、游戏逻辑、架构设计、数据持久化等多方面技术的综合案例。它告诉我们,即使是一个看似简单的项目,只要深入挖掘,也能成为学习高级编程概念和最佳实践的绝佳载体。希望这次源码解析,能为你自己的C#项目开发带来一些切实可行的思路和启发。