C#手写飞行棋:从零构建游戏逻辑与面向对象实战
1. 为什么我劝你用C#手写一个飞行棋而不是直接找个成品很多人第一次听到“用C#写飞行棋”第一反应是这玩意儿不是网上到处都是吗随便下载一个不就行了我一开始也这么想直到我真正动手写了一遍才发现这件事的价值根本不在“做出一个能玩的游戏”而在于它几乎把C#入门到进阶的所有核心知识点用一条极其自然的线索串了起来。你不需要硬啃语法不需要背设计模式写着写着面向对象、集合、事件、委托、状态管理这些东西自己就冒出来了。飞行棋这个题材选得特别妙。它的规则足够简单简单到你可以在一两天内跑通一个可玩版本但它的状态又足够复杂复杂到如果你不用心设计代码很快就会变成一坨几百行的if-else。棋盘上有四色棋子、有起飞点、有跳跃格、有撞子回退、有终点判定每一步都涉及状态变更和规则判断。这正好是一个“麻雀虽小五脏俱全”的练手项目。这篇文章面向的是已经学过C#基础语法、但还没真正独立做过一个完整项目的人。我会从零开始把整个飞行棋的设计思路、核心代码结构、踩过的坑、以及那些“教科书不会告诉你但实际写的时候一定会遇到”的细节全部摊开讲清楚。你跟着走一遍收获的绝对不只是一个飞行棋。先说结论我最终写出来的版本大概八百多行核心代码分成六个类跑起来是一个控制台程序但逻辑层完全可以复用到WinForms或者Unity里。下面我把整个思路拆开讲。2. 动手之前先把棋盘和规则想明白别急着敲代码2.1 飞行棋的规则到底该怎么抽象成数据很多人写游戏最容易犯的错就是一边想规则一边写代码结果规则改一次代码就要大改。我的做法是先拿一张纸把飞行棋的规则用“数据语言”重新描述一遍。标准飞行棋的棋盘是一个方形环路通常有52个外圈格子每个玩家有4架飞机。但实际实现的时候我不建议你一开始就追求完全还原实体棋盘的每一个细节。我采用的是简化版规则外圈共52格四个角落各有一个起飞点每个玩家4架飞机掷骰子走步数走到对方棋子所在格就把对方撞回起飞点走到自己颜色的跳跃格可以额外前进4格四架飞机全部到达终点即获胜。把这些规则翻译成数据结构其实就三样东西格子、棋子、玩家。格子有编号和类型棋子有归属玩家和当前位置玩家有颜色和棋子集合。听起来很简单对吧但真正写的时候你会发现“位置”这个概念特别容易出问题。2.2 位置编号的坑为什么我用0到51而不是1到52这里我要专门讲一个细节因为它坑了我整整一个下午。最开始我用1到52给格子编号觉得这样更符合人的直觉。结果写移动逻辑的时候每次算新位置都要处理“超过52怎么办”的问题代码里到处都是if (newPos 52) newPos - 52;这种判断又丑又容易出错。后来我改成用0到51编号取模运算一下就清爽了newPos (currentPos steps) % 52;。一行搞定不需要任何条件判断。这个改动看起来很小但它让后面所有涉及位置计算的代码都变得干净了。提示凡是涉及环形结构的游戏位置编号一律从0开始用取模处理越界这是通用经验不只是飞行棋。2.3 用枚举定义格子类型比用数字硬编码强一百倍棋盘上的格子有好几种类型普通格、起飞点、跳跃格、终点通道入口。我一开始用整数0、1、2、3来表示写了两天之后自己都记不住哪个数字对应哪种格子。后来改成枚举public enum CellType { Normal, Takeoff, Jump, FinishEntry }代码可读性立刻上了一个台阶。if (cell.Type CellType.Jump)比if (cell.Type 2)清楚太多了。而且枚举配合switch表达式处理不同格子逻辑的时候特别顺手。2.4 玩家和颜色的映射关系要提前定死飞行棋有四个玩家红黄蓝绿。我见过有人用字符串red、yellow来标识也见过用0、1、2、3的。我的建议是用枚举public enum PlayerColor { Red, Yellow, Blue, Green }然后在玩家类里持有一个PlayerColor属性。这样做的好处是当你需要根据颜色查跳跃格位置、查起飞点位置的时候可以直接用枚举做字典的key不需要任何转换。而且枚举的ToString()天然适合做显示。3. 核心类的职责划分六个类各管一摊3.1 Cell类只存数据不做判断Cell类是我整个项目里最简单的类它只做一件事描述一个格子是什么。包含三个属性格子编号、格子类型、所属颜色如果是起飞点或跳跃格的话。public class Cell { public int Index { get; set; } public CellType Type { get; set; } public PlayerColor? Owner { get; set; } }注意Owner我用了可空类型PlayerColor?因为普通格没有归属颜色。这个细节很重要如果你用非可空类型就得给普通格随便赋一个颜色值后面判断的时候就会产生歧义。Cell类里我坚决不放任何逻辑方法。判断“这个格子能不能跳”“这个格子是谁的起飞点”这类逻辑全部放到规则引擎里。这样做的好处是Cell类极其稳定不管规则怎么改它都不需要动。3.2 Piece类棋子的状态容器Piece类代表一架飞机属性包括归属玩家、当前位置索引、是否在起飞点、是否已到达终点。public class Piece { public PlayerColor BelongTo { get; set; } public int Position { get; set; } public bool IsAtTakeoff { get; set; } true; public bool IsFinished { get; set; } false; }这里有个设计决策值得说一下我用IsAtTakeoff和IsFinished两个布尔值来表示棋子的特殊状态而不是用枚举。原因是这两个状态在逻辑上是互斥的而且判断频繁用布尔值写起来更直接。如果你追求更严谨的状态管理也可以用状态枚举但对飞行棋这个规模的项目来说布尔值足够了。3.3 Player类管理自己的棋子集合Player类持有玩家颜色和四架棋子public class Player { public PlayerColor Color { get; set; } public ListPiece Pieces { get; set; } new ListPiece(); public bool HasWon Pieces.All(p p.IsFinished); }HasWon这个属性用LINQ一行就能判断是否获胜比写循环清爽得多。这也是C#相比其他语言的一个优势集合操作真的很方便。3.4 Board类负责初始化棋盘Board类的工作是在构造的时候把52个格子按规则摆好。哪些格子是起飞点哪些是跳跃格都在这里定义。public class Board { public ListCell Cells { get; set; } new ListCell(); public Board() { for (int i 0; i 52; i) { Cells.Add(new Cell { Index i, Type CellType.Normal }); } // 设置四个起飞点 Cells[0].Type CellType.Takeoff; Cells[0].Owner PlayerColor.Red; Cells[13].Type CellType.Takeoff; Cells[13].Owner PlayerColor.Yellow; // ... 蓝绿同理 } }起飞点的位置我选在0、13、26、39正好把52个格子四等分。跳跃格我放在每个起飞点前面4格的位置这样棋子起飞后走几步就能踩到跳跃格游戏节奏比较快。3.5 Dice类别小看掷骰子随机数也有讲究掷骰子看起来就是Random.Next(1, 7)的事但这里有个经典陷阱如果你每次掷骰子都new Random()在快速连续调用的时候会得到相同的值。因为Random默认用系统时间做种子而系统时间的精度有限。正确做法是在整个游戏生命周期内只用一个Random实例public class Dice { private static readonly Random _random new Random(); public int Roll() { return _random.Next(1, 7); } }我用static readonly确保全局只有一个实例。这个坑我在早期项目里踩过当时调试了半天才发现是随机数种子的问题。3.6 GameEngine类所有规则判断的集中地GameEngine是整个项目最核心也最复杂的类。它负责轮流掷骰子、计算移动、判断撞子、判断跳跃、判断获胜。我把所有规则逻辑都集中在这一个类里而不是分散到各个类中。这样做的好处是规则修改只需要改一个地方坏处是这个类会比较长。但对飞行棋这个规模来说集中管理利大于弊。4. 移动逻辑怎么写才不出bug4.1 从起飞点出发和从棋盘上移动是两套逻辑这是飞行棋最容易写错的地方。当棋子还在起飞点时掷到6才能起飞而且起飞后的位置是起飞点本身不是起飞点加6。当棋子已经在棋盘上时掷任何数字都可以移动。我一开始把这两种情况混在一起写结果出现了“棋子从起飞点起飞后直接跑了6步”的bug。后来拆成两个分支public void MovePiece(Piece piece, int steps) { if (piece.IsAtTakeoff) { if (steps 6) { piece.IsAtTakeoff false; piece.Position GetTakeoffPosition(piece.BelongTo); } return; } // 正常移动逻辑 int newPos (piece.Position steps) % 52; piece.Position newPos; }注意起飞后棋子的位置就是起飞点格子的编号而不是起飞点加步数。这个规则和实体飞行棋是一致的。4.2 撞子判断要在移动完成后立即执行棋子移动到一个新格子后要检查这个格子上有没有其他玩家的棋子。如果有把那些棋子全部送回起飞点。private void CheckCollision(Piece movedPiece) { foreach (var player in _players) { if (player.Color movedPiece.BelongTo) continue; foreach (var piece in player.Pieces) { if (!piece.IsAtTakeoff !piece.IsFinished piece.Position movedPiece.Position) { piece.IsAtTakeoff true; piece.Position -1; } } } }这里有个细节我只撞其他玩家的棋子不撞自己的。实体飞行棋的规则里自己的棋子叠在一起是允许的这个设计我保留了下来。4.3 跳跃格的触发条件要加上“非起飞状态”跳跃格的逻辑是棋子走到自己颜色的跳跃格时额外前进4格。但这里有个隐藏条件如果棋子是刚从起飞点起飞落到跳跃格上的不应该触发跳跃。为什么因为实体飞行棋的规则里起飞后落在跳跃格上是不跳的。我一开始没注意这个细节导致棋子起飞后直接跳到前面去了游戏平衡完全崩了。if (cell.Type CellType.Jump cell.Owner piece.BelongTo !justTookOff) { piece.Position (piece.Position 4) % 52; // 跳跃后还要再检查一次撞子 CheckCollision(piece); }我用了一个justTookOff标志来区分这两种情况。这个标志在移动方法内部传递不暴露给外部。4.4 终点判定的精确条件飞行棋的终点判定不是简单的“走到最后一格”。我的实现是每个玩家有一个终点通道入口棋子必须精确走到入口位置才算到达终点。如果骰子点数超过了需要的步数棋子要往回退。private void CheckFinish(Piece piece, int steps) { int finishEntry GetFinishEntry(piece.BelongTo); int distance (finishEntry - piece.Position 52) % 52; if (distance steps) { piece.IsFinished true; } else if (distance steps) { // 超过终点往回退 int overshoot steps - distance; piece.Position (finishEntry - overshoot 52) % 52; } }这个“超过就回退”的规则是我自己加的实体飞行棋里有些版本是“必须精确到达”有些是“超过就到达”。我选了回退版本因为这样更有策略性玩家需要计算精确步数。5. 游戏主循环和回合管理5.1 用状态机管理游戏阶段游戏主循环看起来简单轮流让玩家掷骰子、移动棋子、判断胜负。但实际写的时候我发现需要区分好几个阶段等待掷骰子、等待选择棋子、执行移动动画、判断胜负。我用了一个简单的状态枚举public enum GameState { WaitingForRoll, WaitingForSelect, Moving, GameOver }主循环根据当前状态决定接受什么输入。这样做的好处是逻辑清晰不会出现“在等待掷骰子的时候却接受了选择棋子的输入”这种问题。5.2 掷到6可以再掷一次但要防止无限连飞行棋的规则里掷到6可以再掷一次。但如果运气爆棚连续掷到6呢实体规则里通常是连续三次6就要受罚。我简化了一下连续掷到6最多三次第四次强制结束回合。private int _consecutiveSixes 0; public void OnDiceRolled(int value) { if (value 6) { _consecutiveSixes; if (_consecutiveSixes 3) { _consecutiveSixes 0; NextPlayer(); return; } // 可以再掷一次 } else { _consecutiveSixes 0; } }这个计数器我放在GameEngine里每次换玩家的时候重置。5.3 选择棋子的交互逻辑当玩家掷出点数后如果有多个棋子可以移动需要让玩家选择。这里有个判断哪些棋子可以移动在起飞点的棋子只有掷到6才能移动在棋盘上的棋子任何点数都可以移动已到达终点的棋子不能移动public ListPiece GetMovablePieces(Player player, int diceValue) { return player.Pieces.Where(p { if (p.IsFinished) return false; if (p.IsAtTakeoff) return diceValue 6; return true; }).ToList(); }如果可移动棋子只有一个就自动选择不需要玩家输入。这个细节能提升不少操作体验。6. 那些只有真正写过才知道的坑6.1 控制台程序的输入处理比想象中麻烦我第一版做的是控制台程序用Console.ReadLine()读玩家输入。结果发现玩家如果输入了非数字字符程序直接崩溃。后来加了int.TryParse做校验int choice; while (!int.TryParse(Console.ReadLine(), out choice) || choice 0 || choice movablePieces.Count) { Console.WriteLine(输入无效请重新选择); }这个循环会一直等到玩家输入合法值为止。虽然简单但这是保证程序不崩溃的基本功。6.2 显示棋盘的时候对齐是个大问题控制台里显示52个格子的环形棋盘对齐特别难搞。我试过用二维数组画方形但计算每个格子的坐标非常繁琐。后来我放弃了画环形改成用一行文字列出每个玩家的棋子位置红方棋子位置起飞点, 5, 12, 终点 黄方棋子位置3, 起飞点, 起飞点, 28这样虽然不直观但信息清晰而且实现简单。如果你要做图形界面这部分逻辑可以直接复用只是把文字输出换成图形绘制。6.3 回合切换的时机很容易搞错回合什么时候结束是掷完骰子就结束还是移动完棋子才结束答案是移动完棋子、处理完所有后续效果撞子、跳跃之后才切换回合。我一开始在掷完骰子后就切换了回合导致玩家还没来得及选棋子就轮到下一个人了。这个bug很隐蔽因为程序不会报错只是逻辑不对。正确的流程是掷骰子 → 选择棋子 → 移动 → 撞子判断 → 跳跃判断 → 终点判断 → 切换回合。6.4 用属性还是用字段影响代码的可维护性C#里字段和属性的选择新手经常搞混。我的原则是所有对外暴露的数据一律用属性只有类内部临时使用的变量才用字段。比如Piece的Position我用的是属性public int Position { get; set; }而不是public int position;属性可以在未来需要的时候加上验证逻辑比如限制Position的范围。字段做不到这一点。这个习惯从一开始就养成后面会省很多事。6.5 调试游戏逻辑的时候日志比断点好用游戏逻辑涉及大量状态变更用断点一步步调试效率很低。我的做法是在关键位置加日志输出Console.WriteLine($[调试] 玩家{player.Color}掷出{diceValue}点); Console.WriteLine($[调试] 棋子从{oldPos}移动到{newPos}); Console.WriteLine($[调试] 触发撞子{hitCount}架棋子被送回);这些日志在开发阶段打开发布的时候注释掉。虽然原始但真的管用。特别是撞子和跳跃这种连锁逻辑看日志比单步调试快得多。7. 从控制台到图形界面逻辑层怎么复用7.1 把规则逻辑和界面彻底分离如果你后面想把这个飞行棋做成WinForms或者Unity版本最关键的是逻辑层不能依赖任何界面相关的代码。我的做法是GameEngine、Board、Player、Piece、Dice、Cell这六个类完全不引用Console也不引用任何UI库。界面层只做三件事显示当前状态、获取用户输入、调用GameEngine的方法。这样换界面的时候逻辑层一行都不用改。7.2 用事件通知界面更新逻辑层怎么通知界面“该刷新了”我用的是C#的事件机制public event Actionstring OnMessage; public event Action OnBoardChanged; private void NotifyBoardChanged() { OnBoardChanged?.Invoke(); }界面层订阅这些事件收到通知就刷新显示。这样逻辑层不需要知道界面的存在耦合度极低。7.3 如果要加AI玩家扩展点在哪里飞行棋加AI其实很简单因为决策空间很小。AI只需要做一件事在可移动的棋子中选一个最优的。最优的判断标准可以是优先选择能撞子的棋子其次选择能跳跃的棋子最后随便选。这个逻辑可以写成一个独立的AIPlayer类实现和人类玩家一样的接口。GameEngine不需要知道当前玩家是人还是AI只需要调用GetChoice()方法就行。这就是面向接口编程的好处。8. 我最终整理出来的代码结构和一些数字整个项目最终是六个类文件核心代码大约八百多行。其中GameEngine占了将近三百行是最复杂的部分。Board和Cell加起来不到一百行非常简单。Dice只有十几行。运行起来的效果是四个玩家轮流操作控制台显示每一步的详细过程包括掷骰子结果、棋子移动、撞子事件、跳跃事件。一局游戏大概需要五到十分钟取决于玩家的运气。有几个数字我觉得值得记录一下52个格子、4个玩家、每人4架棋子、总共16架棋子、掷到6的概率是六分之一、连续三次6的概率是二百一十六分之一。这些数字在调试平衡性的时候很有参考价值。如果你也想动手写一个我的建议是先从控制台版本开始把规则逻辑跑通确认没有bug之后再考虑加界面。规则逻辑是最难的部分界面只是锦上添花。而且控制台版本调试起来方便得多不需要处理各种UI事件和线程问题。最后分享一个我在这个项目里学到的最重要的经验写游戏和写业务系统最大的区别在于游戏的状态变更非常频繁而且状态之间相互影响。所以一定要把状态管理做好该集中的集中该分离的分离。飞行棋虽然小但它把这个道理讲得很清楚。