三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Godot 4.x 实现 JRPG 回合制战斗系统:架构、状态机与实战优化

Godot 4.x 实现 JRPG 回合制战斗系统:架构、状态机与实战优化

1. 项目概述与核心价值

最近在社区里看到不少朋友对用Godot引擎制作JRPG(日式角色扮演游戏)很感兴趣,尤其是战斗系统这块,感觉是个难点。我自己恰好用Godot 4.x完整实现过一个2D JRPG的战斗模块,过程踩了不少坑,也总结了一套比较清晰、可复用的思路。这篇内容就是把我当时的实现过程、核心逻辑以及那些“教科书里不会写”的细节整理出来。它不是一个按部就班的“手把手”,而是侧重于系统设计思路、关键技术的实现原理,以及如何避免常见的性能与逻辑陷阱。无论你是刚接触Godot想做个回合制Demo的新手,还是已经有一定基础、希望构建更健壮战斗系统的开发者,相信这些从实战中提炼的经验都能给你带来直接的帮助。

所谓JRPG战斗系统,核心通常是指令式回合制,玩家和敌人在一个回合内依次选择行动(攻击、技能、道具、防御等),然后根据速度等属性决定行动顺序,并播放相应的动画和特效。在Godot里实现它,难点不在于某个单一功能,而在于如何优雅地管理复杂的状态流转、事件通信和资源加载。下面,我就从最核心的设计思路开始拆解。

2. 战斗系统整体架构设计

2.1 为什么选择节点与信号驱动的架构

在Godot里做游戏,最大的优势就是其基于场景(Scene)和节点(Node)的树形结构,以及内置的信号(Signal)机制。对于JRPG战斗系统这种状态明确、事件驱动的模块,强行用单例全局管理器或者复杂的继承链,往往会把自己绕进去。我采用的是一种“中心调度+事件响应”的混合架构。

整个战斗场景是一个主场景(BattleScene),它不负责具体的战斗逻辑计算,而是作为一个容器和调度器。其核心子节点通常包括:

  • UI层:包含玩家指令菜单(BattleMenu)、角色状态显示(HUD)、战斗日志(BattleLog)等。
  • 实体层:包含所有参战单位的实例,如玩家队伍(Party)节点(下面挂载多个PlayerCharacter场景)和敌人队伍(Enemies)节点(下面挂载多个Enemy场景)。
  • 逻辑层:这是看不见但最关键的一层,通常由一个或多个BattleManager(作为BattleScene的子节点或通过自动加载的单例实现)来充当。它负责回合流程控制、行动队列排序、伤害计算公式解析与执行。

它们之间的通信,几乎完全依靠Godot的信号。例如,当玩家在BattleMenu中选择“攻击”指令时,菜单发出一个action_selected信号,携带行动者和目标ID;BattleManager连接这个信号,接收到后开始处理这次攻击的逻辑序列。这样做的好处是解耦,UI不需要知道伤害怎么算,管理器也不需要知道菜单怎么画,后期调试和扩展异常方便。

2.2 核心数据模型的设计要点

战斗离不开数据。你需要为角色和敌人设计一个基础的数据结构(Resource),我称之为BattleActorData。它继承自Resource,方便独立编辑和存储。

# BattleActorData.gd extends Resource class_name BattleActorData @export var actor_name: String = "" @export var max_hp: int = 100 @export var max_mp: int = 50 @export var attack: int = 10 @export var defense: int = 5 @export var speed: int = 8 # ... 其他属性如力量、魔力、运气等 @export var sprite_frames: SpriteFrames # 战斗动画 @export var skills: Array[SkillData] = [] # 可使用的技能数据

然后,战斗中的实体(PlayerCharacterEnemy)都会持有一个BattleActorData的实例,并维护当前战斗中的实时状态,如current_hpcurrent_mp,以及各种状态效果(StatusEffect)的数组。这里一个重要的技巧是:将基础属性和战斗状态分离。基础属性(BattleActorData)在战斗开始时加载,通常只读;战斗状态则随着战斗进程实时变化。这避免了直接修改资源文件,也便于实现战斗结束后的状态恢复。

3. 回合流程与状态机的实现

3.1 构建清晰的回合状态机

回合制战斗的本质是一个状态机。我通常定义以下几个核心状态:

  1. START: 战斗初始化,加载队伍数据,播放开场动画。
  2. PLAYER_TURN: 等待玩家输入指令。此时UI菜单激活。
  3. ENEMY_TURN: 自动执行敌人的AI逻辑,选择行动。
  4. EXECUTING_ACTION: 执行一个具体的行动(攻击、技能等),包括播放动画、计算伤害、更新UI。
  5. JUDGING: 判断战斗是否结束(一方全灭)。
  6. END: 战斗结束,处理奖励结算和场景切换。

BattleManager中,我会用一个枚举变量state来跟踪当前状态,并在_process或通过信号触发状态转移。

# BattleManager.gd 部分代码 enum BattleState {START, PLAYER_TURN, ENEMY_TURN, EXECUTING_ACTION, JUDGING, END} var current_state: BattleState = BattleState.START func _process(delta): match current_state: BattleState.PLAYER_TURN: # 等待玩家输入,这里通常不做事,由UI信号驱动 pass BattleState.ENEMY_TURN: if not is_processing_enemy_turn: start_enemy_turn() BattleState.EXECUTING_ACTION: # 可能正在播放动画,检查动画是否播放完毕 if current_action != null and current_action.is_finished(): proceed_to_next_action_or_turn() # ... 其他状态

3.2 行动队列与速度属性排序

JRPG中,敌我双方的行动顺序并不是简单的“玩家一轮,敌人一轮”,而是根据所有参战单位的“速度”(Speed)属性或其他公式进行排序。这需要在每一轮(Round)开始时计算。

我实现了一个ActionQueue系统。在START状态或每一轮行动全部执行完毕后,BattleManager会收集所有存活单位的引用,并根据它们的实时速度值(可能受状态影响)进行排序,生成一个action_queue: Array数组。这个队列决定了本回合内所有单位行动的先后顺序。

func build_action_queue(): action_queue.clear() var all_actors: Array = get_all_living_actors() # 获取所有存活的玩家和敌人 # 可以在这里加入随机因子,让速度相近的单位顺序有变化 all_actors.sort_custom(func(a, b): return a.get_current_speed() > b.get_current_speed()) action_queue = all_actors.duplicate() current_actor_index = 0

然后,状态机进入PLAYER_TURNENEMY_TURN,其实质是让队列中当前指向的单位(action_queue[current_actor_index])开始选择行动。如果是玩家单位,就激活UI菜单;如果是敌人,则调用其AI决策函数。

注意:排序的时机很重要。不要在每次单个行动结束后都重新排序,那样会改变尚未行动单位的顺序,不符合多数JRPG的设定。通常是在一轮所有单位都行动过一次后,再重新排序生成下一轮的队列。

4. 玩家指令系统与UI交互

4.1 分层级指令菜单的实现

经典的JRPG战斗菜单通常是层级式的:根菜单(攻击、技能、道具、防御、逃跑) -> 子菜单(如选择技能后,弹出技能列表;选择道具后,弹出道具列表) -> 目标选择(选择敌人或友方)。

我使用多个PanelControl节点来构建这些菜单,并通过可见性(visible)来控制层级切换。每个菜单选项都是一个Button,连接到对应的函数。

关键点在于目标选择。当玩家选择了一个需要目标的行动(如攻击、单体治疗术)后,需要进入目标选择模式。我的做法是:

  1. 高亮所有可选的目标(例如,攻击时高亮所有敌人)。
  2. 为每个可目标单位(通常是Sprite2D或其下的Area2D)连接gui_input事件。
  3. 当玩家点击一个高亮单位时,触发目标确认,将(行动者, 行动类型, 目标)这个三元组封装成一个BattleAction对象,发送给BattleManager
# BattleMenu.gd 中处理目标选择 func select_target(target_actor): var action = BattleAction.new() action.caster = current_actor action.type = Global.ACTION_TYPE.ATTACK # 或 SKILL action.skill_data = selected_skill # 如果是技能 action.targets = [target_actor] emit_signal(“action_confirmed”, action) hide_target_cursor() # 隐藏所有高亮

4.2 使用Godot的Input Map处理复杂输入

战斗菜单的导航(上下左右、确认、取消)最好使用Godot的Input Map。在项目设置中定义好ui_up,ui_down,ui_accept,ui_cancel等动作,并绑定到键盘和手柄按键。这样在菜单代码里,只需要在_input_process函数中检测这些动作,就可以实现跨设备的统一控制。

func _process(delta): if not visible: return if Input.is_action_just_pressed(“ui_down”): move_selection(1) # 向下移动选择 if Input.is_action_just_pressed(“ui_accept”): confirm_selection()

5. 敌人AI与自动行动逻辑

5.1 实现一个简单的权重决策系统

敌人的AI不需要非常复杂,一个基于权重的随机选择就能做出足够“智能”的感觉。为每个敌人定义一个AIPattern资源,里面包含一系列可能的行动(AIAction)及其权重。

# AIAction.gd extends Resource class_name AIAction @export var action_type: Global.ACTION_TYPE @export var skill_to_use: SkillData # 如果是技能 @export var weight: int = 10 # 基础权重 @export_range(0, 1) var hp_threshold: float = 1.0 # 例如,HP低于30%时此行动权重增加

在敌人的decide_action()函数中:

  1. 获取所有可能的AIAction
  2. 根据当前战斗状况(如自身HP比例、玩家状态)动态调整每个行动的权重。
  3. 根据最终权重,使用随机函数选择一个行动。
  4. 根据行动类型,自动选择目标(如攻击生命值最低的玩家,或治疗生命值比例最低的队友)。
func decide_action() -> BattleAction: var available_actions = ai_pattern.actions.duplicate() var weighted_list = [] for ai_action in available_actions: var final_weight = ai_action.weight # 动态调整权重逻辑 if ai_action.hp_threshold > 0 and (current_hp / max_hp) < ai_action.hp_threshold: final_weight *= 2 # HP低时,此类行动权重加倍 for i in range(final_weight): weighted_list.append(ai_action) if weighted_list.is_empty(): return create_default_attack_action() var chosen_ai_action = weighted_list.pick_random() # ... 创建并返回 BattleAction

5.2 目标选择策略

目标选择是AI的重要组成部分。可以预先定义几种策略:

  • TARGET_RANDOM: 随机选择。
  • TARGET_LOWEST_HP: 生命值最低。
  • TARGET_HIGHEST_THREAT: 威胁值最高(如果设计了仇恨系统)。
  • TARGET_SELF: 自身。
  • TARGET_ALLY: 随机友方。

在决定行动后,根据行动类型(攻击、治疗、增益)和策略字段,调用对应的目标选择函数,将选中的目标列表填入BattleAction

6. 伤害计算、技能与状态效果系统

6.1 可配置的伤害公式

把伤害计算公式写在代码里是硬编码,不利于设计和平衡。我推荐使用Expression类或解析自定义的公式字符串。例如,在SkillData资源中,有一个字符串字段damage_formula,值可以是“atk * 2 - target.def”

BattleManager中执行伤害计算时:

func calculate_damage(caster, target, skill: SkillData) -> int: var expression = Expression.new() # 注册公式中可用的变量名 var error = expression.parse(skill.damage_formula, [“atk”, “def”, “matk”, “mdef”]) if error != OK: push_error(“Damage formula parse error: ” + expression.get_error_text()) return 0 # 传入实际参数值 var result = expression.execute([caster.attack, target.defense, caster.magic_attack, target.magic_defense]) if expression.has_execute_failed(): push_error(“Damage formula execute failed”) return 0 # 加入随机浮动,例如±10% var variance = 0.2 # 浮动范围20% var random_factor = 1.0 + randf_range(-variance/2, variance/2) result = int(result * random_factor) # 确保最小伤害为1 return max(1, result)

6.2 状态效果(Buff/Debuff)的通用框架

状态效果(如中毒、攻击提升、沉默)是JRPG的灵魂。我设计了一个StatusEffect基类资源,和一套挂在战斗实体上的StatusEffectManager组件。

StatusEffect资源包含:

  • icon: 状态图标
  • duration: 持续回合数
  • max_stacks: 可叠加层数
  • 几个重要的虚函数:on_apply,on_turn_start,on_turn_end,on_remove,modify_stat

StatusEffectManager负责:

  • 维护当前实体身上的效果列表。
  • 在每个回合开始/结束时,触发效果的on_turn_start/end
  • 当查询角色属性时,遍历所有效果,应用modify_stat进行修正。
# StatusEffectManager.gd 中的属性获取示例 func get_modified_stat(base_stat: int, stat_name: String) -> int: var modified_value = float(base_stat) for effect in active_effects: modified_value = effect.modify_stat(stat_name, modified_value) return int(modified_value)

这样,攻击力提升50%的效果,其modify_stat函数就是if stat_name == “attack”: return value * 1.5。系统变得非常灵活和可扩展。

7. 动画、特效与视觉反馈的整合

7.1 基于AnimationPlayer的序列动画

Godot的AnimationPlayer是编排战斗动画的利器。不要试图用代码控制每一帧,而是为每个技能或行动创建一个动画资源(Animation)。

一个典型的“攻击”动画序列可能包括:

  1. 攻击者移动到目标前(可选)。
  2. 攻击者播放攻击动画(Sprite2Danimation属性改变为“attack”)。
  3. 播放武器挥动或特效粒子。
  4. 目标播放受击动画和闪烁(modulate属性快速变化)。
  5. 伤害数字弹出。
  6. 双方返回原位。

AnimationPlayer中,你可以精确控制这些事件的时间点。通过调用轨道上的函数方法(Call Method Track),可以在动画的特定时刻触发游戏逻辑,比如在武器碰到敌人的那一帧调用apply_damage()函数。

7.2 伤害数字与战斗信息显示

伤害数字我推荐使用一个专门的Label场景(DamageNumber.tscn),设置为Billboard模式(始终面向屏幕),并使用TweenAnimationPlayer实现上浮、放大缩小、渐隐的效果。

当需要显示伤害时,实例化这个场景,设置文本,将其作为子节点添加到战斗场景的UI层,并播放动画。动画结束后自动queue_free()

战斗日志(BattleLog)则是一个RichTextLabel,每当有重要事件(攻击、使用技能、获得状态)发生时,BattleManager就向其追加一条带颜色的格式化文本。为了不刷屏,可以设置一个最大行数,自动移除旧信息。

8. 性能优化与常见问题排查

8.1 资源管理与内存泄漏预防

战斗场景中会频繁实例化特效、伤害数字、UI元素。必须做好资源管理。

  • 对象池(Object Pooling):对于频繁创建销毁的简单对象(如伤害数字),使用对象池。预先创建一定数量的实例,禁用并存入数组;需要时从池中取用并激活;用完后禁用并放回池中,而不是queue_free()。这能极大减少垃圾回收(GC)的压力。
  • 纹理与音频的预加载:在战斗场景的_ready()函数中,使用ResourceLoader.load()预加载所有可能用到的技能特效纹理、音效文件,存入字典。使用时直接读取字典,避免战斗中途加载造成的卡顿。
  • 及时断开信号连接:动态创建的UI元素(如菜单项)如果连接了信号,在其被释放前,务必使用disconnect()断开连接,或者使用Callable绑定,Godot 4.x中弱引用处理得更好,但养成好习惯能避免难以排查的引用残留。

8.2 常见Bug与调试技巧

  1. 行动顺序错乱:检查build_action_queue函数是否在正确的时机被调用。确保速度属性在排序时没有被状态效果错误地修改。在BattleManager中打印每一轮的行动队列,是快速定位问题的好方法。
  2. 伤害计算为0或异常:首先检查公式字符串的解析是否正确,Expression.parse()是否有错误。其次,检查传入的castertarget对象引用是否正确,它们的属性值是否在预期范围内。可以在计算函数内部加入详细的print语句,输出每一步的中间值。
  3. 状态效果不生效或无法移除:检查StatusEffectManageron_turn_end是否被正确调用。检查效果持续回合数duration的递减逻辑。确保在效果移除时,从管理器的列表中删除,并正确调用了on_remove来清理属性修改。
  4. UI菜单状态不同步:这是信号未及时更新的典型问题。确保任何导致角色状态(HP、MP、状态效果)改变的事件,都发射了一个信号(如hp_changed),并且UI组件连接了这个信号来更新显示。避免在_process里不断轮询查询状态。
  5. 动画与逻辑不同步:确保在AnimationPlayer的动画帧中调用的函数,其对象引用在调用时仍然有效(没有被提前释放)。考虑使用is_instance_valid()进行检查。对于复杂的多段动画,使用await关键字配合信号(如animation_finished)来顺序执行逻辑,能让代码更清晰。

最后,我的个人体会是,实现一个JRPG战斗系统更像是在设计一套规则和状态流转的协议。Godot的信号与节点树为这套协议提供了绝佳的载体。初期多花时间在架构设计上,明确每个节点的职责和通信方式,后期添加新技能、新特效、新AI都会事半功倍。不要害怕重构,第一个版本通常都是混乱的,在实现基本功能后,回头审视代码,将通用的部分(如伤害计算、状态管理)抽象出来,你会得到一个越来越健壮和优雅的战斗框架。

← 返回列表