ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C#控制台回合制战斗游戏开发:面向对象编程与游戏循环实践

2026/8/11 9:09:28 拓冰建站 浏览量
C#控制台回合制战斗游戏开发:面向对象编程与游戏循环实践

1. 项目概述与核心设计思路

几年前,当我第一次决定用C#写点东西时,我跳过了那些“Hello World”和计算器,直接瞄准了一个小目标:做一个能在控制台里跑起来的回合制战斗游戏。这听起来有点复古,毕竟现在都是3A大作和手游的天下。但恰恰是这种纯粹性,让它成为了学习C#面向对象编程、逻辑控制以及项目结构设计的绝佳练手项目。你不需要考虑复杂的图形界面、网络同步或者物理引擎,只需要专注于最核心的游戏逻辑:两个角色,你一拳我一脚,直到一方倒下。这个过程,能把C#里类、对象、继承、多态这些抽象概念,变成屏幕上实实在在的“攻击”、“防御”、“生命值减少”的反馈,学习效果拔群。

这个项目的核心,就是模拟一个经典的JRPG(日式角色扮演游戏)战斗场景。玩家操控一个英雄角色,面对一个或多个敌人,每个回合双方轮流执行动作,如攻击、使用技能或道具,直到一方生命值归零。在控制台环境下,所有交互都通过文字输入输出完成,这要求我们的代码逻辑必须极其清晰,状态管理必须非常严谨。通过构建这个小游戏,你不仅能学会如何用C#的Console.WriteLineConsole.ReadLine来构建交互,更能深入理解游戏循环(Game Loop)、状态机(State Machine)以及如何设计可扩展的角色和技能系统。哪怕你未来想做更复杂的Unity游戏或后端服务,这里打下的基础都会让你受益匪浅。

2. 核心类设计与面向对象实践

2.1 角色基类:一切战斗单位的蓝图

游戏里的英雄和怪物,虽然立场不同,但有很多共性:都有名字、生命值(HP)、攻击力、防御力,都能执行攻击等动作。用面向对象的思想,我们首先要抽象出一个Character基类。

public abstract class Character { public string Name { get; protected set; } public int MaxHP { get; protected set; } public int CurrentHP { get; protected set; } public int Attack { get; protected set; } public int Defense { get; protected set; } public bool IsAlive => CurrentHP > 0; protected Character(string name, int maxHP, int attack, int defense) { Name = name; MaxHP = maxHP; CurrentHP = maxHP; // 初始时满血 Attack = attack; Defense = defense; } // 受到伤害的通用方法 public virtual void TakeDamage(int damage) { // 简单的伤害计算公式:最终伤害 = 攻击方攻击力 - 受击方防御力(至少为1) int actualDamage = Math.Max(1, damage - Defense); CurrentHP -= actualDamage; Console.WriteLine($"{Name} 受到了 {actualDamage} 点伤害!"); if (!IsAlive) { Console.WriteLine($"{Name} 被击败了!"); } } // 治疗通用方法 public virtual void Heal(int amount) { int healAmount = Math.Min(amount, MaxHP - CurrentHP); CurrentHP += healAmount; Console.WriteLine($"{Name} 恢复了 {healAmount} 点生命值。"); } // 执行一个回合的行动,这是一个抽象方法,由子类具体实现 public abstract void PerformAction(Character target); }

这里有几个关键点:

  1. 抽象类与抽象方法Character被声明为abstract,意味着它不能被直接实例化,只能作为模板。PerformAction是抽象方法,强制所有子类(如HeroMonster)必须实现自己独特的行动逻辑。这是多态性的基础。
  2. 属性与字段:使用属性(get; protected set;)而不是公共字段,可以更好地控制访问权限。IsAlive是一个只读的计算属性,逻辑清晰。
  3. 虚方法TakeDamageHeal被声明为virtual,意味着子类可以重写(override)它们来实现特殊效果,比如某些怪物受到特定类型攻击伤害加倍,或者英雄有伤害减免天赋。

注意:在简单的控制台游戏中,这样的计算模型足够用了。但在更复杂的项目中,伤害计算可能会涉及暴击率、伤害浮动、元素抗性等,那时可以考虑引入一个独立的DamageCalculator类来处理,遵循单一职责原则。

2.2 英雄类:玩家的化身

英雄类继承自Character,并需要增加一些玩家特有的属性,比如经验值(EXP)、等级(Level),以及可能拥有的技能列表。

public class Hero : Character { public int Exp { get; private set; } public int Level { get; private set; } public List<Skill> Skills { get; private set; } public Hero(string name) : base(name, maxHP: 100, attack: 15, defense: 5) { Level = 1; Exp = 0; Skills = new List<Skill> { new Skill("重击", 消耗MP: 10, 描述: "造成攻击力150%的伤害"), new Skill("治疗术", 消耗MP: 15, 描述: "恢复自身50点生命值") }; // 可以初始化MP属性 MaxMP = 50; CurrentMP = MaxMP; } public int MaxMP { get; private set; } public int CurrentMP { get; private set; } // 重写行动方法,提供玩家选择 public override void PerformAction(Character target) { // 在实际游戏中,这里会显示一个菜单让玩家选择 // 例如:1. 普通攻击 2. 技能1 3. 技能2 4. 使用道具 // 这里为了示例,我们简化成固定逻辑 Console.WriteLine($"{Name} 对 {target.Name} 发动了普通攻击!"); target.TakeDamage(Attack); } // 使用技能 public void UseSkill(Skill skill, Character target) { if (CurrentMP < skill.MpCost) { Console.WriteLine("魔力不足!"); return; } CurrentMP -= skill.MpCost; Console.WriteLine($"{Name} 使用了技能【{skill.Name}】!"); // 根据技能类型执行不同效果,这里需要更复杂的技能系统 if (skill.Name == "重击") { target.TakeDamage((int)(Attack * 1.5)); } else if (skill.Name == "治疗术") { Heal(50); } } // 增加经验并检查升级 public void GainExp(int amount) { Exp += amount; Console.WriteLine($"{Name} 获得了 {amount} 点经验值。"); CheckLevelUp(); } private void CheckLevelUp() { // 简单的升级公式:每级所需经验 = 当前等级 * 100 int expToNextLevel = Level * 100; if (Exp >= expToNextLevel) { Level++; MaxHP += 20; CurrentHP = MaxHP; // 升级回满血 Attack += 5; Defense += 2; MaxMP += 10; CurrentMP = MaxMP; Console.WriteLine($"恭喜!{Name} 升级到 {Level} 级!"); Console.WriteLine($"生命值提升至{MaxHP},攻击力提升至{Attack},防御力提升至{Defense}。"); } } }

实操心得:在Hero类的设计中,我刻意将技能Skills作为一个List<Skill>。虽然在这个最小版本里,我们只是用if-else判断技能效果,但这为未来扩展留下了空间。你可以很容易地创建一个Skill类,包含技能名称、伤害系数、目标类型(单体、群体)、消耗MP、特殊效果(如中毒、眩晕)等属性,然后通过一个SkillManager来统一管理和执行技能逻辑。这种设计比把所有技能逻辑都硬编码在Hero类里要清晰和可维护得多。

2.3 怪物类:多样化的敌人

怪物类同样继承自Character。为了增加游戏趣味性,我们可以设计不同种类的怪物,它们可能有不同的AI行为模式。

public class Monster : Character { // 怪物类型,可用于决定AI行为 public enum MonsterType { Aggressive, Defensive, Healer } public MonsterType Type { get; private set; } public Monster(string name, int maxHP, int attack, int defense, MonsterType type) : base(name, maxHP, attack, defense) { Type = type; } // 怪物的行动逻辑(AI) public override void PerformAction(Character target) { // 根据怪物类型选择不同的行为策略 switch (Type) { case MonsterType.Aggressive: // 激进型:总是攻击 Console.WriteLine($"{Name} 狂暴地攻击 {target.Name}!"); target.TakeDamage(Attack); break; case MonsterType.Defensive: // 防御型:有一定概率防御(减少下次受到的伤害) if (new Random().Next(0, 100) < 30) // 30%概率防御 { Console.WriteLine($"{Name} 采取了防御姿态!"); Defense += 3; // 临时增加防御 // 注意:需要在下一回合开始时重置,这里需要额外的状态管理 } else { Console.WriteLine($"{Name} 攻击了 {target.Name}!"); target.TakeDamage(Attack); } break; case MonsterType.Healer: // 治疗型:生命值低时可能治疗自己 if (CurrentHP < MaxHP * 0.3 && new Random().Next(0,100) < 50) { Console.WriteLine($"{Name} 正在自我治疗!"); Heal(20); } else { Console.WriteLine($"{Name} 攻击了 {target.Name}!"); target.TakeDamage(Attack); } break; default: // 默认攻击 Console.WriteLine($"{Name} 攻击了 {target.Name}!"); target.TakeDamage(Attack); break; } } }

注意事项:上面的AI逻辑非常简陋,尤其是Defensive类型临时增加防御后没有重置,这会导致防御力无限叠加,破坏游戏平衡。一个更好的做法是引入一个StatusEffect系统,记录临时增益/减益效果的持续回合数。在每个回合开始时,遍历并更新这些状态。这虽然增加了复杂度,但能让游戏机制更加丰富和健壮。

3. 游戏循环与状态管理实现

3.1 主游戏循环:驱动一切的核心引擎

游戏循环是任何游戏的心脏,它不断检查游戏状态、处理输入、更新逻辑、渲染输出。在控制台回合制游戏中,循环相对简单。

public class Game { private Hero player; private List<Monster> monsters; private bool isGameOver; public Game() { InitializeGame(); } private void InitializeGame() { Console.WriteLine("========== 回合制战斗小游戏 =========="); Console.Write("请输入英雄的名字:"); string heroName = Console.ReadLine(); player = new Hero(string.IsNullOrEmpty(heroName) ? "无名勇士" : heroName); // 初始化怪物 monsters = new List<Monster> { new Monster("哥布林", maxHP: 50, attack: 10, defense: 2, Monster.MonsterType.Aggressive), new Monster("史莱姆", maxHP: 70, attack: 8, defense: 1, Monster.MonsterType.Defensive) }; isGameOver = false; Console.WriteLine($"勇敢的 {player.Name},你的冒险开始了!"); } public void Run() { while (!isGameOver) { Console.WriteLine("\n-----------------------"); DisplayGameState(); // 玩家回合 PlayerTurn(); // 检查战斗是否结束(玩家死亡或所有怪物死亡) if (CheckBattleOver()) { break; } // 怪物回合 MonsterTurn(); // 再次检查战斗是否结束 CheckBattleOver(); } GameEnd(); } private void DisplayGameState() { Console.WriteLine($"【{player.Name}】 HP: {player.CurrentHP}/{player.MaxHP} MP: {player.CurrentMP}/{player.MaxMP} Lv.{player.Level}"); Console.WriteLine("敌人:"); for (int i = 0; i < monsters.Count; i++) { var m = monsters[i]; Console.WriteLine($" {i + 1}. {m.Name} HP: {m.CurrentHP}/{m.MaxHP} {(m.IsAlive ? "" : "(已击败)")}"); } } // ... PlayerTurn, MonsterTurn 等方法后续实现 }

核心要点:这个Run方法就是一个典型的游戏循环。它持续运行,直到isGameOver变为true。每一轮循环代表一个完整的“回合”,包含玩家行动和怪物行动。DisplayGameState方法在每回合开始前刷新界面,让玩家知晓当前状况,这是控制台游戏的“渲染”环节。

3.2 玩家回合:交互与选择

玩家回合是整个游戏交互的核心。我们需要提供一个清晰的菜单,让玩家选择要执行的动作。

private void PlayerTurn() { Console.WriteLine("\n>>> 你的回合 <<<"); bool actionTaken = false; while (!actionTaken) { Console.WriteLine("请选择行动:"); Console.WriteLine("1. 攻击"); Console.WriteLine("2. 使用技能"); Console.WriteLine("3. 使用道具"); Console.WriteLine("4. 查看状态"); string input = Console.ReadLine(); switch (input) { case "1": // 攻击:选择目标 Character target = SelectTarget(); if (target != null) { player.PerformAction(target); actionTaken = true; } break; case "2": // 使用技能 UseSkillMenu(); actionTaken = true; // 假设选择技能后总会执行动作 break; case "3": // 使用道具(示例,未实现) Console.WriteLine("道具功能暂未开放。"); break; case "4": DisplayGameState(); break; default: Console.WriteLine("无效输入,请重新选择。"); break; } } } private Character SelectTarget() { // 列出所有存活的怪物 var aliveMonsters = monsters.Where(m => m.IsAlive).ToList(); if (aliveMonsters.Count == 0) { Console.WriteLine("没有可攻击的目标!"); return null; } Console.WriteLine("选择攻击目标:"); for (int i = 0; i < aliveMonsters.Count; i++) { Console.WriteLine($"{i + 1}. {aliveMonsters[i].Name} (HP: {aliveMonsters[i].CurrentHP})"); } if (int.TryParse(Console.ReadLine(), out int choice) && choice >= 1 && choice <= aliveMonsters.Count) { return aliveMonsters[choice - 1]; } else { Console.WriteLine("选择无效。"); return null; } } private void UseSkillMenu() { Console.WriteLine("选择要使用的技能:"); for (int i = 0; i < player.Skills.Count; i++) { var skill = player.Skills[i]; Console.WriteLine($"{i + 1}. {skill.Name} (消耗MP: {skill.MpCost}) - {skill.Description}"); } if (int.TryParse(Console.ReadLine(), out int choice) && choice >= 1 && choice <= player.Skills.Count) { var selectedSkill = player.Skills[choice - 1]; Character target = null; // 根据技能是攻击还是治疗选择目标 if (selectedSkill.Name == "治疗术") { target = player; // 治疗自己 } else { target = SelectTarget(); } if (target != null) { player.UseSkill(selectedSkill, target); } } else { Console.WriteLine("技能选择无效。"); } }

避坑技巧:在PlayerTurnwhile循环中,我设置了一个actionTaken标志位。只有当玩家成功执行了一个有效动作(如攻击、施法)后,才会退出循环,结束玩家回合。这防止了玩家因输入错误或查看状态而意外跳过回合。同时,对用户输入进行了严格的校验(TryParse和范围检查),这是保证程序健壮性的基本要求,能有效避免因非法输入导致的程序崩溃。

3.3 怪物回合与战斗结算

怪物回合的逻辑相对直接,就是遍历所有存活的怪物,让它们执行各自的PerformAction

private void MonsterTurn() { Console.WriteLine("\n>>> 敌人回合 <<<"); foreach (var monster in monsters) { if (monster.IsAlive) { // 怪物选择目标,这里简单设定为总是攻击玩家 monster.PerformAction(player); // 检查玩家是否被击败 if (!player.IsAlive) { return; // 玩家死亡,立即结束怪物回合 } } } } private bool CheckBattleOver() { // 玩家死亡 if (!player.IsAlive) { isGameOver = true; return true; } // 所有怪物死亡 if (monsters.All(m => !m.IsAlive)) { isGameOver = true; Console.WriteLine("\n********** 战斗胜利! **********"); // 奖励经验值 int totalExp = monsters.Sum(m => m is Monster mc ? 30 : 0); // 简单计算 player.GainExp(totalExp); // 可以在这里触发下一场战斗或游戏结束 return true; } return false; } private void GameEnd() { if (player.IsAlive) { Console.WriteLine($"恭喜 {player.Name} 成功击败了所有敌人!"); Console.WriteLine($"最终等级:{player.Level}, 经验值:{player.Exp}"); } else { Console.WriteLine($"很遗憾,{player.Name} 倒下了...游戏结束。"); } Console.WriteLine("按任意键退出..."); Console.ReadKey(); }

设计思考CheckBattleOver方法在玩家回合后和怪物回合后都会被调用,确保游戏状态能及时更新。胜利后给予经验奖励并触发升级检查,形成了最简单的成长闭环。在实际项目中,你可能会将战斗结算(经验计算、掉落物品)抽象成一个独立的BattleRewardSystem,使得逻辑更清晰,也便于后续添加更复杂的掉落规则。

4. 功能扩展与系统深化

一个基础的回合制框架搭建完成后,你可以像搭积木一样,为其添加更多系统,让游戏变得更有趣。

4.1 技能系统的重构与扩展

之前的技能实现是硬编码在Hero类里的,这不利于扩展。我们来重构一个独立的技能系统。

public class Skill { public string Name { get; } public int MpCost { get; } public string Description { get; } // 新增:技能效果委托,允许动态定义技能行为 public Action<Character, Character> Effect { get; } public Skill(string name, int mpCost, string description, Action<Character, Character> effect) { Name = name; MpCost = mpCost; Description = description; Effect = effect; } } // 在Hero类初始化技能时,可以这样定义: Skills = new List<Skill> { new Skill("火球术", 15, "发射火球,造成攻击力200%的魔法伤害", (caster, target) => { int damage = (int)(caster.Attack * 2.0); Console.WriteLine($"{caster.Name} 吟唱咒语,炽热的火球轰向 {target.Name}!"); target.TakeDamage(damage); }), new Skill("群体治疗", 30, "为我方全体恢复80点生命值", (caster, targets) => { // 注意:这里需要能作用于多个目标,Effect签名可能需要调整 Console.WriteLine($"{caster.Name} 释放出温暖的治疗之光!"); // 假设targets是Character列表,这里需要遍历治疗 }) };

进阶思路:使用委托(Action)或更复杂的技能效果类(ISkillEffect接口)来定义技能行为,可以将技能数据(名称、消耗)和行为逻辑解耦。技能数据甚至可以存储在JSON或XML配置文件中,实现“数据驱动”的设计。这样,策划人员(或者未来的你)不需要修改C#代码,只需修改配置文件,就能调整技能效果、添加新技能。

4.2 道具与背包系统

引入道具可以增加游戏的策略深度。我们可以设计一个Item基类和Potion(药水)、Bomb(炸弹)等子类。

public abstract class Item { public string Name { get; } public string Description { get; } public abstract void Use(Character user, Character target); } public class HealthPotion : Item { public int HealAmount { get; } public HealthPotion() : base("生命药水", "恢复50点生命值") { HealAmount = 50; } public override void Use(Character user, Character target) { target.Heal(HealAmount); Console.WriteLine($"{user.Name} 使用了{Name},为{target.Name}恢复了{HealAmount}点生命。"); } } // 在Hero类中添加背包 public class Hero : Character { // ... 其他属性 public Dictionary<Item, int> Inventory { get; private set; } // 物品和数量 public Hero(string name) : base(name, 100, 15, 5) { Inventory = new Dictionary<Item, int>(); Inventory.Add(new HealthPotion(), 3); // 初始拥有3瓶药水 } public void UseItem(int itemIndex, Character target) { // ... 实现使用道具的逻辑 } }

4.3 状态效果与战斗事件

为了让战斗更有策略性,可以引入状态效果(Buff/Debuff),如“中毒”(每回合扣血)、“防御提升”(临时增加防御)、“眩晕”(跳过一回合)。

public class StatusEffect { public string Name { get; } public int Duration { get; private set; } // 持续回合数 public Action<Character> OnTurnStart { get; } // 每回合开始时的效果 public Action<Character> OnTurnEnd { get; } // 每回合结束时的效果 public Action<Character> OnApply { get; } // 施加时的效果 public Action<Character> OnRemove { get; } // 移除时的效果 public StatusEffect(string name, int duration, Action<Character> onTurnStart, Action<Character> onTurnEnd, Action<Character> onApply, Action<Character> onRemove) { // ... 初始化 } public void Tick() { Duration--; } // 回合结束,持续时间减少 }

然后在Character类中添加一个List<StatusEffect>来管理当前身上的所有状态。在游戏循环的每回合开始和结束时,遍历并执行这些状态的效果。这虽然增加了系统的复杂度,但能让战斗的深度和趣味性呈指数级增长。

5. 常见问题、调试技巧与优化建议

5.1 控制台输入输出乱码与格式对齐

问题:在中文环境下,控制台可能会出现乱码,或者输出内容对不齐,影响美观。解决

  1. 在程序入口Main方法开始处,设置控制台编码:
    Console.OutputEncoding = System.Text.Encoding.UTF8;
  2. 对于格式对齐,可以使用string.Format或插值字符串配合固定宽度。例如,让生命值显示总是占固定宽度:
    Console.WriteLine($"{player.Name,-10} HP: {player.CurrentHP,3}/{player.MaxHP,3}");
    这里的-10表示左对齐且占10个字符宽度,3表示右对齐占3个字符宽度。

5.2 随机数生成与游戏平衡

问题:在怪物AI或伤害浮动中使用了new Random(),如果在短时间内快速连续创建多个Random实例,由于系统时钟作为种子,它们可能会产生相同或相近的随机数序列。解决

  1. 在类级别声明一个静态的Random实例。
    private static Random rng = new Random();
  2. 在整个游戏中都使用这个实例来生成随机数:int roll = rng.Next(1, 101);
  3. 对于伤害浮动,可以设计一个基础伤害范围,例如:int damage = baseDamage + rng.Next(-2, 3); // 在baseDamage-2到baseDamage+2之间浮动

5.3 对象引用与状态同步

问题:在Game类的monsters列表中存储了怪物对象。当怪物在战斗中被击败(CurrentHP <= 0),其IsAlive属性会变为false。但在遍历列表时,如果直接从列表中移除元素会导致异常。解决

  1. 通常不在遍历过程中直接移除。我们的做法是在逻辑上判断if (monster.IsAlive),只对存活的怪物进行操作。
  2. 如果确实需要清理已死亡的怪物,可以在战斗结束后或每回合结束后,使用LINQ重新赋值:
    monsters = monsters.Where(m => m.IsAlive).ToList();

5.4 代码组织与可维护性

问题:随着功能增加,所有代码都堆在Program.cs或少数几个类里,会变得难以阅读和维护。解决

  1. 命名空间与文件夹结构:创建清晰的文件夹结构,如Characters/Skills/Items/Managers/,将相关的类放到对应的文件夹和命名空间中。
  2. 单一职责原则:每个类只负责一件事。Game类负责主循环和流程控制,BattleManager负责处理战斗逻辑,UIManager负责所有控制台输入输出,DataManager负责加载游戏数据(如怪物属性、技能描述)。
  3. 使用配置文件:将怪物数据、技能数据、平衡性参数(如升级所需经验公式)提取到JSON或XML配置文件中。这样调整游戏内容无需重新编译代码。

5.5 下一步的扩展方向

当你完成了这个基础版本后,可以尝试挑战以下扩展,这会让你的C#功力更上一层楼:

  • 多场战斗与地图系统:设计一个简单的世界地图,玩家可以移动、触发不同战斗。
  • 存档/读档功能:使用System.Text.JsonNewtonsoft.Json库将游戏状态(玩家属性、背包、进度)序列化成JSON字符串,然后保存到文件中。读档时再反序列化回来。
  • 更复杂的AI:为怪物实现基于状态(如血量百分比)的行为树(Behavior Tree)或简单的效用系统(Utility System),而不是简单的switch-case
  • 图形化界面:虽然本项目是控制台游戏,但你可以尝试用Windows Forms、WPF或跨平台的Avalonia UI,为你的游戏逻辑套上一个图形外壳,学习事件驱动编程。
  • 网络对战:这是一个巨大的挑战。可以尝试用System.Net.Sockets实现一个简单的客户端-服务器架构,让两个玩家可以联网进行回合制对战。这会涉及到网络编程、数据序列化、状态同步等核心知识。

开发这个小游戏的过程,就像在打磨一件手工艺品。从最粗糙的框架开始,不断遇到问题、解决问题、重构代码、添加新功能。每一次迭代,你不仅是在完善游戏,更是在夯实自己作为C#开发者的基础。当你看到自己写的代码能让两个虚拟角色在屏幕上打得有来有回时,那种成就感是无可替代的。这不仅仅是你的第一个C#项目,更是你通往更广阔编程世界的一块坚实跳板。