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

资讯详情

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

C#控制台飞行棋项目实战:从零实现游戏逻辑与面向对象设计

C#控制台飞行棋项目实战:从零实现游戏逻辑与面向对象设计 1. 项目概述与核心价值最近在整理硬盘里的老项目翻出来一个用C#写的控制台飞行棋游戏代码量不大但麻雀虽小五脏俱全。这个项目是我当年带新人入门时用的经典案例它完美地融合了C#的基础语法、面向对象思想、控制台交互和简单的游戏逻辑。对于刚学完C#语法、正愁没地方练手的新手来说自己动手实现一个飞行棋远比对着书本敲“Hello World”要有趣和有用得多。飞行棋本身规则简单直观玩家轮流掷骰子棋子沿固定路径前进遇到特定格子有特殊效果如暂停、跳跃、退回起点。用代码实现它你需要处理玩家轮流操作、棋盘状态维护、棋子移动逻辑、格子事件触发以及胜负判定。整个过程就像搭积木把学过的if-else、switch-case、循环、数组、类与对象、方法封装等知识点有机地串联起来。最终你会得到一个可以运行、可以和朋友对战的完整游戏这种成就感是单纯学习语法无法比拟的。更重要的是这个项目能帮你建立“程序思维”。你会开始思考棋盘怎么用数据结构表示玩家的状态位置、是否暂停如何存储如何让程序“等待”玩家按任意键掷骰子如何清屏并重新绘制棋盘以模拟动画效果解决这些问题的过程就是你从“知道语法”到“会用语法解决问题”的关键跨越。2. 游戏核心设计与数据结构建模在动手写代码之前我们必须先把游戏规则“翻译”成计算机能理解的数据结构和逻辑流程。盲目开写只会导致代码混乱后期难以调试和维护。2.1 棋盘与地图数据建模飞行棋的棋盘是一个闭合的环形路径。在控制台环境下我们最直观的表示方法就是用一维数组。数组的每个下标对应棋盘上的一个格子数组元素的值代表该格子的类型。// 使用枚举定义格子类型提高代码可读性 public enum GridType { Normal, // 普通格0 LuckyTurn, // 幸运轮盘1触发随机事件 LandMine, // 地雷2暂停一回合 Pause, // 暂停3暂停一回合 TimeTunnel // 时空隧道4跳跃前进 } // 地图数据初始化 // 数组长度就是棋盘总格数元素值是对应的GridType枚举值 // 这里用一个简化地图作为示例实际可以设计更复杂的路径 private GridType[] gameMap new GridType[100]; // 假设棋盘有100格 private void InitMap() { // 初始化所有格子为普通格 for (int i 0; i gameMap.Length; i) { gameMap[i] GridType.Normal; } // 设置特殊格子的位置 // 幸运轮盘第5 15 25格... int[] luckyTurnPos { 5, 15, 25, 35, 45, 55, 65, 75, 85, 95 }; foreach (int pos in luckyTurnPos) { if (pos gameMap.Length) gameMap[pos] GridType.LuckyTurn; } // 地雷第10 20 30格... int[] landMinePos { 10, 20, 30, 40, 50, 60, 70, 80, 90 }; foreach (int pos in landMinePos) { if (pos gameMap.Length) gameMap[pos] GridType.LandMine; } // 暂停第13 33 53格... int[] pausePos { 13, 33, 53, 73, 93 }; foreach (int pos in pausePos) { if (pos gameMap.Length) gameMap[pos] GridType.Pause; } // 时空隧道第7 27 47 67 87格... int[] tunnelPos { 7, 27, 47, 67, 87 }; foreach (int pos in tunnelPos) { if (pos gameMap.Length) gameMap[pos] GridType.TimeTunnel; } }注意地图数据的设计是游戏可玩性的核心。你可以通过调整特殊格子的类型、数量和分布来改变游戏的节奏和策略性。例如在终点前密集布置地雷和暂停格可以增加游戏的悬念和反转。2.2 玩家状态与游戏核心类设计接下来需要设计玩家类Player和游戏管理类GameManager。这是面向对象思想的体现数据和对数据的操作被封装在一起。public class Player { public int Id { get; set; } // 玩家ID如12 public string Name { get; set; } // 玩家名称如“玩家A”“电脑” public int Position { get; set; } // 当前在棋盘上的位置数组下标 public bool IsPaused { get; set; } // 是否处于暂停状态 public ConsoleColor Color { get; set; } // 控制台显示颜色用于区分玩家 public Player(int id, string name, ConsoleColor color) { Id id; Name name; Position 0; // 起始位置为0 IsPaused false; Color color; } // 判断玩家是否到达终点 public bool IsWinner(int mapLength) { // 通常当位置大于等于最后一格时算获胜。注意数组下标从0开始。 return Position mapLength - 1; } } public class GameManager { private Player[] players; private GridType[] gameMap; private int currentPlayerIndex; // 当前轮到哪个玩家数组下标 private Random random; // 随机数生成器用于掷骰子 public GameManager() { random new Random(); InitMap(); InitPlayers(); currentPlayerIndex 0; } private void InitPlayers() { players new Player[2]; // 假设双人对战 players[0] new Player(1, 玩家A, ConsoleColor.Red); players[1] new Player(2, 玩家B, ConsoleColor.Blue); // 可以扩展为更多玩家 } // ... 其他方法如游戏主循环、绘制棋盘等 }实操心得将Random对象作为GameManager的成员变量而不是在每次掷骰子时new Random()这很重要。因为Random默认使用系统时间作为种子如果在极短时间内连续创建多个Random实例它们可能会获得相同的时间种子从而导致生成的随机数序列相同或高度相似让骰子失去随机性。3. 控制台交互与游戏主循环实现控制台程序没有华丽的界面但通过字符的巧妙排列和颜色也能做出不错的视觉效果和交互体验。游戏的核心是一个while循环直到有玩家获胜才退出。3.1 棋盘绘制方法详解绘制棋盘的本质是将gameMap数组和players的位置信息转换成字符输出到控制台。我们需要一个方法来根据坐标返回应该显示的字符。private void DrawMap() { Console.Clear(); // 清屏实现动画效果 Console.WriteLine( 飞行棋 \n); // 第一行绘制地图格子标识 for (int i 0; i gameMap.Length; i) { // 先判断这个格子上有没有玩家 string playerSymbol GetPlayerSymbolOnPosition(i); if (!string.IsNullOrEmpty(playerSymbol)) { // 有玩家用玩家的符号和颜色显示 Console.ForegroundColor GetPlayerColorOnPosition(i); Console.Write(playerSymbol); Console.ResetColor(); } else { // 没有玩家根据格子类型显示 switch (gameMap[i]) { case GridType.Normal: Console.Write(□); break; case GridType.LuckyTurn: Console.ForegroundColor ConsoleColor.Yellow; Console.Write(★); Console.ResetColor(); break; case GridType.LandMine: Console.ForegroundColor ConsoleColor.Red; Console.Write(●); Console.ResetColor(); break; case GridType.Pause: Console.ForegroundColor ConsoleColor.Blue; Console.Write(▲); Console.ResetColor(); break; case GridType.TimeTunnel: Console.ForegroundColor ConsoleColor.Green; Console.Write(卐); Console.ResetColor(); break; default: Console.Write( ); break; } } // 每10个格子换一行让棋盘看起来整齐 if ((i 1) % 10 0) { Console.WriteLine(); } else { Console.Write( ); // 格子间加个空格 } } Console.WriteLine(\n); // 图例说明 Console.WriteLine(图例: ★-幸运轮盘 ●-地雷 ▲-暂停 卐-时空隧道); foreach (var player in players) { Console.ForegroundColor player.Color; Console.Write($ {player.Id} ); Console.ResetColor(); Console.WriteLine($- {player.Name}); } } // 辅助方法获取指定位置上的玩家显示符号 private string GetPlayerSymbolOnPosition(int pos) { ListPlayer playersHere new ListPlayer(); foreach (var player in players) { if (player.Position pos) { playersHere.Add(player); } } if (playersHere.Count 0) return ; if (playersHere.Count 1) return playersHere[0].Id.ToString(); // 单个玩家显示数字 // 多个玩家在同一格显示为“”表示重叠 return ; } // 辅助方法获取指定位置上玩家的颜色重叠时取第一个玩家的颜色 private ConsoleColor GetPlayerColorOnPosition(int pos) { foreach (var player in players) { if (player.Position pos) { return player.Color; } } return ConsoleColor.Gray; // 默认颜色 }3.2 游戏主循环与回合逻辑主循环控制着游戏的进程。每一轮我们处理当前玩家的回合然后切换到下一个玩家。public void StartGame() { Console.WriteLine(游戏开始); bool gameOver false; while (!gameOver) { Player currentPlayer players[currentPlayerIndex]; // 1. 绘制当前棋盘状态 DrawMap(); // 2. 显示当前玩家信息 Console.ForegroundColor currentPlayer.Color; Console.WriteLine($\n轮到 [{currentPlayer.Name}] 的回合。当前位置{currentPlayer.Position}); Console.ResetColor(); // 3. 检查玩家是否被暂停 if (currentPlayer.IsPaused) { Console.WriteLine($[{currentPlayer.Name}] 处于暂停状态本轮无法行动。); currentPlayer.IsPaused false; // 暂停状态解除 // 切换到下一个玩家 currentPlayerIndex (currentPlayerIndex 1) % players.Length; Console.WriteLine(按任意键继续...); Console.ReadKey(true); continue; } // 4. 玩家行动阶段 Console.WriteLine(按任意键掷骰子...); Console.ReadKey(true); // 等待玩家按键增加交互感 int dicePoints RollDice(); Console.WriteLine($掷出了 {dicePoints} 点); // 5. 移动玩家 MovePlayer(currentPlayer, dicePoints); // 6. 检查是否到达终点 if (currentPlayer.IsWinner(gameMap.Length)) { DrawMap(); Console.ForegroundColor currentPlayer.Color; Console.WriteLine($\n 恭喜 [{currentPlayer.Name}] 获胜); Console.ResetColor(); gameOver true; break; } // 7. 触发格子事件 TriggerGridEvent(currentPlayer); // 8. 切换到下一个玩家 currentPlayerIndex (currentPlayerIndex 1) % players.Length; // 回合间隔让玩家有时间看清结果 Console.WriteLine(按任意键进入下一回合...); Console.ReadKey(true); } Console.WriteLine(\n游戏结束); } private int RollDice() { // 生成1-6的随机数模拟六面骰子 return random.Next(1, 7); } private void MovePlayer(Player player, int steps) { int newPosition player.Position steps; // 处理绕圈逻辑如果超过地图长度则取余数实现环形地图 // 但通常飞行棋到达终点即获胜这里简单处理为不能超出 if (newPosition gameMap.Length) { newPosition gameMap.Length - 1; // 直接移动到终点 } player.Position newPosition; Console.WriteLine($[{player.Name}] 移动到了第 {player.Position} 格。); }注意事项在控制台游戏中频繁的Console.Clear()和Console.ReadKey()是关键。Clear用于刷新界面模拟动画ReadKey用于控制游戏节奏让玩家有足够时间阅读信息。但要注意在ReadKey时传入参数trueConsole.ReadKey(true)可以阻止按下的键显示在控制台上保持界面整洁。4. 核心游戏机制特殊格子事件触发飞行棋的趣味性很大程度上来自于踩中特殊格子时触发的各种事件。这部分逻辑需要清晰、独立并且易于扩展。4.1 事件触发与处理逻辑在玩家移动后需要根据其新位置所在的格子类型触发相应事件。private void TriggerGridEvent(Player player) { int pos player.Position; GridType currentGrid gameMap[pos]; Console.Write($第{pos}格是); switch (currentGrid) { case GridType.LuckyTurn: Console.ForegroundColor ConsoleColor.Yellow; Console.WriteLine( [幸运轮盘]); Console.ResetColor(); TriggerLuckyTurn(player); break; case GridType.LandMine: Console.ForegroundColor ConsoleColor.Red; Console.WriteLine( [地雷]); Console.ResetColor(); TriggerLandMine(player); break; case GridType.Pause: Console.ForegroundColor ConsoleColor.Blue; Console.WriteLine( [暂停]); Console.ResetColor(); TriggerPause(player); break; case GridType.TimeTunnel: Console.ForegroundColor ConsoleColor.Green; Console.WriteLine( [时空隧道]); Console.ResetColor(); TriggerTimeTunnel(player); break; default: // 普通格无事发生 Console.WriteLine(普通格。); break; } }4.2 各类事件的具体实现每个事件都应该有明确的效果和反馈。为了增加趣味性可以在幸运轮盘中设计多种随机效果。private void TriggerLuckyTurn(Player player) { // 幸运轮盘有多种随机效果 int luck random.Next(1, 7); // 再来一次1-6的随机 switch (luck) { case 1: Console.WriteLine(幸运降临前进3格); MovePlayer(player, 3); // 注意前进后可能再次触发格子事件这是一个递归或循环处理点 // 简单实现可以不再触发复杂实现可以递归调用TriggerGridEvent break; case 2: Console.WriteLine(厄运缠身后退2格); MovePlayer(player, -2); // 需要实现后退逻辑 break; case 3: Console.WriteLine(与对手交换位置); SwapPositionWithOtherPlayer(player); break; case 4: Console.WriteLine(获得一次额外投掷机会); // 这里需要改变游戏流程让当前玩家再行动一次 // 一个简单的实现是在当前回合末尾不切换玩家或者设置一个标志位 GiveExtraTurn(player); break; case 5: Console.WriteLine(放置一个地雷在当前位置); // 将当前格子永久变为地雷影响后续所有玩家 gameMap[player.Position] GridType.LandMine; break; case 6: Console.WriteLine(无事发生。); break; } } private void TriggerLandMine(Player player) { Console.WriteLine($轰[{player.Name}] 踩中地雷暂停一回合); player.IsPaused true; // 可选让玩家退后几步作为额外惩罚 // MovePlayer(player, -3); } private void TriggerPause(Player player) { Console.WriteLine($[{player.Name}] 被魔法禁锢暂停一回合); player.IsPaused true; } private void TriggerTimeTunnel(Player player) { int jumpDistance random.Next(3, 8); // 随机跳跃3-7格 Console.WriteLine($时空穿梭[{player.Name}] 前进 {jumpDistance} 格); MovePlayer(player, jumpDistance); } // --- 辅助方法实现 --- private void MovePlayer(Player player, int steps) { // 增强版的移动方法支持后退steps为负数 int newPosition player.Position steps; if (newPosition 0) { newPosition 0; // 不能退到起点之前 } else if (newPosition gameMap.Length) { newPosition gameMap.Length - 1; } player.Position newPosition; Console.WriteLine($[{player.Name}] 移动到了第 {player.Position} 格。); } private void SwapPositionWithOtherPlayer(Player currentPlayer) { // 找到另一个玩家 Player otherPlayer null; foreach (var p in players) { if (p ! currentPlayer) { otherPlayer p; break; } } if (otherPlayer ! null) { int tempPos currentPlayer.Position; currentPlayer.Position otherPlayer.Position; otherPlayer.Position tempPos; Console.WriteLine($[{currentPlayer.Name}] 与 [{otherPlayer.Name}] 交换了位置); } } private void GiveExtraTurn(Player player) { // 一种实现方式设置一个标志让主循环在当前回合结束后不切换玩家 // 这里为了简化我们采用一个取巧的方法让当前玩家索引回退一步 // 注意这只在双人游戏中简单有效多人游戏需要更严谨的状态管理 currentPlayerIndex (currentPlayerIndex - 1 players.Length) % players.Length; Console.WriteLine($[{player.Name}] 获得额外一回合); }踩坑记录在实现“幸运轮盘-额外投掷机会”或“踩中地雷后后退并可能再次触发事件”这类功能时要特别注意避免无限递归。例如如果玩家前进3格后又踩到幸运轮盘轮盘效果又是前进就可能循环不止。解决方法有两种一是规定特殊格子事件只触发一次即移动后触发因移动奖励带来的再次移动不触发新事件二是设置一个事件触发深度计数器超过一定深度比如3层就强制停止。5. 功能增强与代码优化实践一个基础版本完成后我们可以从可玩性、代码结构和用户体验方面进行增强。这是项目从“能用”到“好用”的关键。5.1 增加游戏配置与难度选择我们可以把地图数据、玩家数量、特殊事件概率等抽离出来做成可配置的甚至允许玩家在游戏开始前选择难度。public class GameSettings { public int MapSize { get; set; } 100; public int PlayerCount { get; set; } 2; public DictionaryGridType, int SpecialGridDensity { get; set; } // 各类特殊格子的密度百分比或数量 public bool EnableLuckyTurn { get; set; } true; // ... 其他设置 } // 在GameManager中引用设置 public GameManager(GameSettings settings) { this.settings settings; random new Random(); InitMap(settings); // 根据设置初始化地图 InitPlayers(settings.PlayerCount); }5.2 实现玩家重叠与碰撞事件当两个或多个玩家走到同一格时可以增加一些互动比如后来者将前者“踢回起点”或者触发决斗。private void CheckPlayerCollision(Player movedPlayer) { foreach (var otherPlayer in players) { if (otherPlayer ! movedPlayer otherPlayer.Position movedPlayer.Position) { Console.WriteLine($ 碰撞[{movedPlayer.Name}] 撞到了 [{otherPlayer.Name}]); // 简单规则被撞的玩家退回起点 Console.WriteLine($[{otherPlayer.Name}] 被撞回起点); otherPlayer.Position 0; // 复杂规则可以触发猜拳小游戏决定谁退后 // TriggerMiniGame(movedPlayer, otherPlayer); break; // 假设一次只处理一个碰撞 } } } // 在MovePlayer方法末尾调用此检查5.3 代码重构与职责分离随着功能增加GameManager类会变得臃肿。好的实践是将不同职责拆分到不同的类中。Map类专门负责地图数据的生成、管理和查询如获取某个位置的类型、获取两个位置间的距离等。RuleEngine类专门负责规则判定如移动是否合法、碰撞如何处理、事件如何触发等。这样游戏逻辑修改时只需改动RuleEngine。ConsoleRenderer类专门负责所有控制台绘制工作与游戏逻辑完全解耦。未来如果想改成图形界面只需替换这个类。// 示例独立的渲染器 public class ConsoleRenderer { public void Draw(GameState state) // GameState包含地图、玩家等信息 { Console.Clear(); DrawHeader(); DrawMap(state.Map, state.Players); DrawPlayerInfo(state.Players); DrawLegend(); } // ... 具体的绘制方法 }5.4 输入验证与异常处理对于需要玩家输入的地方如选择操作必须加入验证防止程序因非法输入而崩溃。private int GetPlayerChoice(string prompt, int min, int max) { int choice; while (true) { Console.Write(prompt); string input Console.ReadLine(); if (int.TryParse(input, out choice) choice min choice max) { return choice; } Console.WriteLine($输入无效请输入 {min} 到 {max} 之间的数字。); } }6. 常见问题排查与调试技巧开发过程中你肯定会遇到各种“诡异”的Bug。这里分享几个我踩过的坑和解决方法。6.1 棋盘绘制错乱或闪烁问题描述棋盘显示不正常格子对不齐或者刷新时屏幕闪烁严重。排查思路检查控制台字体确保控制台使用的是等宽字体如Consolas, Courier New。非等宽字体会导致空格和字符宽度不一致破坏对齐。检查绘制逻辑在DrawMap方法中仔细检查循环和换行逻辑。确保每行打印的格子数量是固定的例如10个并且每个格子占用的控制台宽度一致一个汉字或两个英文字符。闪烁问题频繁的Console.Clear()是导致闪烁的主要原因。对于简单的回合制游戏可以不用每回合都清屏而是只重绘发生变化的部分但这在控制台实现较复杂。一个折中方案是在ReadKey等待玩家操作前清屏这样玩家按下键后界面一次性更新视觉上会稳定很多。6.2 玩家移动或事件触发逻辑错误问题描述玩家移动步数不对踩中特殊格子没反应或者事件效果与预期不符。排查技巧善用断点和监视窗口在MovePlayer和TriggerGridEvent方法开始处设置断点。运行游戏当触发这些方法时查看传入的参数player对象、steps、pos是否正确。添加详细的日志输出在关键步骤后使用Console.WriteLine输出中间状态。例如在移动前打印“玩家A准备从X格移动Y步”移动后打印“玩家A移动到了Z格格子类型是T”。这能帮你清晰跟踪程序执行流。检查随机数如果骰子点数或幸运轮盘效果总是很“规律”怀疑Random对象被重复创建。确保整个游戏只使用一个Random实例。6.3 游戏状态管理混乱问题描述额外回合逻辑导致玩家连续行动多次或者暂停状态解除时机不对。解决方案状态标志要清晰像IsPaused、HasExtraTurn这样的标志位必须在逻辑中成对出现设置和清除。在GameManager中专门用一个方法CleanUpTurn()在每个玩家回合结束时重置所有临时状态标志除了需要持续到下一回合的如IsPaused。流程图辅助设计在实现复杂回合逻辑如幸运轮盘触发额外回合前先在纸上画出状态流程图。明确“额外回合”是一个立即插入的动作还是修改“下一回合玩家索引”的逻辑。采用上文中currentPlayerIndex回退的方法虽然取巧但在复杂规则下容易出错。更稳健的方法是引入一个“待行动玩家队列”的概念。6.4 程序在特定输入后崩溃问题描述游戏在等待玩家按键或输入时如果输入某些特殊键或直接关闭控制台程序崩溃。防御性编程包裹Console.ReadKey使用try-catch块捕获可能发生的异常如InvalidOperationException当控制台输入被重定向时。try { Console.ReadKey(true); } catch (InvalidOperationException) { // 处理异常例如退出游戏或使用默认值 Environment.Exit(0); }验证数组边界在通过计算得到数组下标如gameMap[newPosition]前务必检查newPosition是否在0到gameMap.Length - 1的范围内。MovePlayer方法中已经做了保护。这个C#飞行棋项目虽然不大但它像一块试金石能检验你对基础语法和编程思想的掌握程度。从数据建模到控制流从用户交互到异常处理每一个环节都值得细细打磨。当你成功运行起自己编写的游戏并和朋友玩上几局后回过头再看那些代码你会对“程序是如何运行”的有更深的理解。编程的乐趣就在于这种从无到有、让想法变成现实的过程。
返回列表