ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Godot 4战术RPG模块化架构实战:网格、回合、技能与AI系统设计

Godot 4战术RPG模块化架构实战:网格、回合、技能与AI系统设计 做战术RPG第一个要认清的事实是这个品类对架构的挑剔程度远高于大多数横版过关或动作游戏。我用Godot 4做了将近三个月的战术RPG原型前后重构了两次才把网格、回合、技能、AI这些核心系统理顺。回头看最终救我的不是某个奇技淫巧而是坚持用模块化架构划分边界让每个系统只做一件事再通过信号与数据资源完成协作。这篇就把当时落地的模块划分和核心系统实现细节完整展开顺便把Godot 4里特有的几个坑也一并交代清楚。1. 复杂度来源与最终落地的模块划分1.1 战术RPG到底复杂在哪很多人做战斗demo的时候觉得战术RPG很简单一张格子地图一个角色点一下走过去点一下攻击完事。但等到真正想塞进技能、AI、多个职业、状态效果、回合结算时所有东西就开始互相拉扯。我列过一张依赖清单单位移动要依赖网格寻路技能命中要依赖目标位置AI决策要依赖技能范围和伤害估算状态效果结算要依赖回合流程画面表现又要反过来等待逻辑层计算完毕。只要其中任意一环和另一环产生直接引用改动一处就牵出雷区。最典型的情况是你在技能脚本里为了取目标方便直接存了一个Unit节点的引用结果AI模块也想调用同样的目标就开始到处找get_node整个代码变成了蜘蛛网。所以战术RPG的复杂度本质上不是某个算法难而是系统与系统之间的交互路径太多。如果不提前划清边界后期每加一个新技能、新道具都是对旧架构的一次爆破测试。1.2 我最终沿用的四层边界经历过两次重构之后我最终把项目拆成了这样四层每一层都只能向下一层依赖不允许跨层或反向依赖纯逻辑层core/不继承任何Node只使用Resource或RefCounted负责网格、战斗计算、AI决策、状态效果等所有“可以脱离画面运行”的规则。服务层services/使用Autoload注册的全局节点负责回合管理、战斗流程控制、事件分发相当于逻辑层与表现层之间的管理员。表现层visual/所有可见的Node2D角色、TileMapLayer地图、动画、特效、相机只负责把逻辑层的结果画出来不自己推导任何战斗规则。数据层data/技能、职业、属性成长、敌人配置等全部用.tres资源或JSON承载代码里不写死任何数值。当时的工程目录大致是这个样子res:// ├── src/ │ ├── core/ │ │ ├── grid/ │ │ │ ├── grid_data.gd │ │ │ └── pathfinder.gd │ │ ├── unit/ │ │ │ ├── unit_runtime.gd │ │ │ └── unit_stats.gd │ │ ├── combat/ │ │ │ ├── damage_calculator.gd │ │ │ └── skill_effects.gd │ │ └── ai/ │ │ └── ai_controller.gd │ ├── services/ │ │ ├── event_bus.gd │ │ ├── turn_manager.gd │ │ └── battle_service.gd │ ├── ui/ │ │ ├── action_panel.gd │ │ └── unit_status_bar.gd │ └── visual/ │ ├── units/ │ ├── effects/ │ └── board/ └── data/ ├── skills/ ├── characters/ └── enemies/这套结构的核心思路是逻辑层不知道UI和画面的存在表现层也绝不改数据。所有信息交换都通过服务层发出的信号完成而不是直接调用对方节点的方法。1.3 单向数据流是怎么走的我用一个具体流程来说明玩家点击“移动”按钮选择任意一个可移动格子角色走过去然后攻击敌人。数据流是这样的UI按钮发出action_requested信号BattleService接收后调用Pathfinder计算路径得到一串Vector2i坐标列表再发给单位所在的UnitRuntime执行移动。UnitRuntime移动完成后发出unit_moved信号TurnManager收到后重新计算可行动状态UI监听该信号刷新按钮可用性。这里最关键的是ActionPanel里面绝对不直接调用UnitRuntime的方法反而通过EventBus去广播请求。Units不关心谁发起了移动只关心BattleService分发的移动指令。这套机制让我后来添加手柄支持、AI自动行动、回放功能时几乎没有改动单位脚本。2. 网格层的纯逻辑抽象把棋盘和画面彻底拆开2.1 GridData一个不依赖任何节点类的棋盘战术RPG里网格是底层地基。如果网格逻辑跟TileMap节点绑死你换地图、做动画预览、跑单元测试、做自动战斗模拟时都会寸步难行。所以我第一件事就是写了一个纯数据结构的GridData。# grid_data.gd class_name GridData extends RefCounted ## 使用字典保存地形key是格子坐标Vector2ivalue是地形枚举 var _map: Dictionary {} const TERRAIN_PLAIN : 0 const TERRAIN_FOREST : 1 const TERRAIN_WATER : 2 const TERRAIN_OBSTACLE : 3 func set_cell(pos: Vector2i, terrain: int) - void: _map[pos] terrain func get_cell(pos: Vector2i) - int: return _map.get(pos, TERRAIN_OBSTACLE) func is_walkable(pos: Vector2i) - bool: var terrain : get_cell(pos) return terrain ! TERRAIN_OBSTACLE func get_walk_cost(pos: Vector2i) - int: var terrain : get_cell(pos) match terrain: TERRAIN_PLAIN: return 1 TERRAIN_FOREST: return 2 TERRAIN_WATER: return 3 return 999注意这里完全没有出现TileMapLayer、Node2D之类的东西坐标统一用Vector2i表示。为什么这样做因为Godot的TileMap节点本质上是“把格子画出来”的渲染工具它带了大量坐标映射、资源序列化、场景树相关的能力而这些对逻辑计算来说都是噪音。GridData独立出来后我可以脱离场景直接跑寻路测试可以用脚本生成随机地图进行AI模拟甚至可以把它放在服务器端做联机验证这是模块化带来的直接红利。2.2 寻路选型AStarGrid2D与地形代价加权Godot 4内置了AStarGrid2D这比自己在Godot 3时代从零实现A*省了太多事。但有几个使用细节要注意AStarGrid2D并不是创建后就自动同步TileMap的格子必须手动把不可走的地形标记为solid。# pathfinder.gd extends RefCounted var _grid: AStarGrid2D func setup(grid_size: Vector2i, grid_data: GridData) - void: _grid AStarGrid2D.new() _grid.region Rect2i(Vector2i.ZERO, grid_size) _grid.cell_size Vector2.ONE _grid.diagonal_mode AStarGrid2D.DIAGONAL_MODE_NEVER _grid.update() for x in range(grid_size.x): for y in range(grid_size.y): var pos : Vector2i(x, y) if not grid_data.is_walkable(pos): _grid.set_point_solid(pos, true) else: var weight : float(grid_data.get_walk_cost(pos)) _grid.set_point_weight_scale(pos, weight) func find_path(from: Vector2i, to: Vector2i) - Array[Vector2i]: if _grid null: return [] _grid.dirty_all() var path : _grid.get_id_path(from, to) return path这里有个非常隐蔽的坑如果你修改了某个格子的solid状态或权重必须调用一次脏标记接口否则AStarGrid2D内部缓存不会更新。早期我写地图编辑器时一边改格子一边让角色寻路结果路径半天不刷新后来才发现是忘了dirty_all()。另外我把DIAGONAL_MODE_NEVER关掉了对角线移动因为战术RPG的格子移动通常走四方向开了对角会穿越不可走格子的锐角边界。如果你的游戏需要沼泽减速、森林加闪避这种加权地形直接修改weight_scale就好路径算法会自动偏向低权重格这时候格子代价就不仅仅是“走不走得通”而是“走哪条路代价最小”。2.3 移动范围与攻击范围的可达性计算寻路解决“从一个点到另一个点怎么走”但战术RPG还需要解决“在当前移动力下能走多远”。这不能用寻路逐点试效率太低。正确做法是用Dijkstra广度扩散按剩余移动力去冲刷整个棋盘。func get_reachable_cells(start: Vector2i, move_points: int, grid_data: GridData) - Dictionary: var result : {} # pos - cost var queue : [[start, 0]] result[start] 0 while not queue.is_empty(): var current: Array queue.pop_front() var cur_pos: Vector2i current[0] var cur_cost: int current[1] for dir in [Vector2i.RIGHT, Vector2i.LEFT, Vector2i.DOWN, Vector2i.UP]: var next_pos : cur_pos dir if result.has(next_pos): continue if not grid_data.is_walkable(next_pos): continue var next_cost : cur_cost grid_data.get_walk_cost(next_pos) if next_cost move_points: result[next_pos] next_cost queue.append([next_pos, next_cost]) return result这个函数返回的是一个字典Key是可达坐标Value是从起点到该格子的总消耗。UI拿到这个字典后遍历每个格子生成高亮这个结构还能顺便区分“消耗1点移动力的蓝格”和“消耗2点的绿格”非常直观。攻击范围同理但要额外判断目标是否在技能射程内以及是否有地形遮挡。我用的方法是先把射程内的候选格子收集出来再用射线格到格的射线检测法判断遮挡。虽然不完美但不涉及视线高度的场合完全够用。3. 回合流程与行动点系统状态机撑起整场对局3.1 TurnManager的定位与信号设计回合管理是整个战斗的“大管家”但它不能什么活都干。我的TurnManager只负责两件事决定“当前轮到谁”以及通知“该行动了”。它不负责寻路、不负责播放动画、不直接执行技能只维护一个单位列表和一个战斗阶段枚举。# turn_manager.gd extends Node enum BattlePhase { PLAYER_TURN, ENEMY_TURN, VICTORY, DEFEAT } signal battle_started signal turn_started(unit_id: int) signal turn_ended(unit_id: int) signal battle_phase_changed(phase: BattlePhase) var _unit_ids: Array[int] [] var _current_index: int 0 func start_battle(unit_ids: Array[int]) - void: _unit_ids unit_ids _current_index 0 _sort_by_speed() battle_started.emit() _begin_turn(_unit_ids[_current_index]) func end_current_turn() - void: var old_id : _unit_ids[_current_index] turn_ended.emit(old_id) _current_index (_current_index 1) % _unit_ids.size() _begin_turn(_unit_ids[_current_index]) func _begin_turn(unit_id: int) - void: turn_started.emit(unit_id)为什么unit_id用int而不用节点引用因为存档时你不可能把整个Node对象序列化进去。每个Unit在初始化时注册一个唯一id回合管理、存档、信号转发都拿id来做表现层需要具体节点时再通过id查表。这是“逻辑与表现分离”的又一层体现——回合管理器永远不会因为某个节点被释放而崩溃。行动顺序我用了最简单的“按速度值从高到低排序”。绝大多数战术RPG都这么做够用也容易被玩家理解。如果你要做“等待指令”之类的复杂行动顺序再考虑在排序链表上插入动态权重但MVP阶段不必过度设计。3.2 行动点约束下的单位状态机一个单位在自己回合内的行为必须被严格约束否则就会出现“走三步、打两次”这种超模操作。我给每个UnitRuntime内部放了一个简单的状态机Idle - Moving - Acting - Wait。Idle回合开始后的初始态可以移动、可以使用技能、可以待机。Moving角色正在走格子此时禁止其他指令移动结束后回到Idle。Acting角色正在播放攻击或施法动画结束后根据剩余行动点决定是否回到Idle或进入Wait。Wait放弃剩余行动直接结束回合。行动点AP我设为2点移动消耗1点普通攻击消耗1点释放技能消耗2点。这部分逻辑全部放在UnitRuntime里# unit_runtime.gd extends RefCounted var unit_id: int var ap: int 2 var max_ap: int 2 var has_moved : false var has_acted : false func can_move() - bool: return ap 0 and not has_moved and has_acted false func can_act() - bool: return ap 1 and not has_acted func do_move() - void: if not can_move(): return ap - 1 has_moved true func do_act(ap_cost: int) - void: if ap ap_cost: return ap - ap_cost has_acted true值得说的是这些状态本身也是纯逻辑层的一份子不挂到场景树上。单位的表现节点比如UnitScene带Sprite、动画播放器会接收UnitRuntime变化后自行播放走路动画和攻击动画。这样即使把这个单位放进自动战斗模拟器里一样可以跑完整套规则无非不显示画面而已。3.3 技能冷却与回合结算的挂点问题技能冷却计算放哪里是我重构后才想明白的问题。一开始我把冷却计时放在技能资源里结果所有共用同一技能资源的敌人全都串了冷却。后来我改成技能资源保持只读冷却计数器放在UnitRuntime内部以“该单位自身回合数”为周期递减。# 挂载在UnitRuntime中 var cooldowns: Dictionary {} # skill_id - remaining_turns func setup_cooldown(skill_data: SkillData) - void: if skill_data.cooldown 0: cooldowns[skill_data.id] skill_data.cooldown func on_own_turn_start() - void: for skill_id in cooldowns.keys(): cooldowns[skill_id] max(cooldowns[skill_id] - 1, 0)这个设计的关键在于每个单位维护自己的冷却字典而不是改SkillData。回合结算的入口我放在TurnManager的turn_started信号回调中由BattleService统一转发给所有单位执行on_own_turn_start()这样就算未来加入“技能冷却减少30%”的装备属性也只需要在UnitRuntime上挂一个冷却减免系数。4. 数据驱动技能与伤害拒绝在代码里写数值4.1 用CustomResource定义技能数据写死技能效果的痛做过的都懂每加一个新技能就要改一大段if else平衡性调整时必须动代码。我最终把所有技能做成了自定义Resource子类直接在编辑器里创建.tres资源数据和逻辑彻底分离。# skill_data.gd class_name SkillData extends Resource enum SkillType { DAMAGE, HEAL, BUFF, DEBUFF } enum TargetTeam { ENEMY, ALLY, SELF } export var id: String export var display_name: String export var skill_type: SkillType SkillType.DAMAGE export var target_team: TargetTeam TargetTeam.ENEMY export var ap_cost: int 2 export var range_cells: int 1 export var damage_multiplier: float 1.0 export var hit_bonus: float 0.0 export var cooldown: int 0 export var status_effects: Array[StatusEffectData] []创建技能时我直接在文件系统里新建一个“火焰斩.tres”把伤害倍率设成2.0命中加成设0.1冷却设3回合。要调平衡就改这个资源文件完全不用碰代码。而且同一份技能数据可以被多个职业引用复制一份改名就能得到数值变体比如“烈焰斩强化版”。这里的坑在于Resource在编辑器里默认是引用共享的如果你在某个单位配置里直接内嵌了SkillData子资源然后又把这个技能复制给另一个单位两个单位可能指向同一个资源实例。所以每个单位配置只要引用技能id不要内嵌整个技能资源运行时再通过SkillDatabase按id查询。4.2 伤害公式与命中率放在哪个模块伤害计算我单独抽了一个静态类DamageCalculator它只做数学运算不直接读写节点。这样战斗日志、AI预判伤害、测试用例都可以直接调用同一个计算函数不会出现“AI谋略时算的伤害和实际打出来不一致”的bug。# damage_calculator.gd class_name DamageCalculator static func compute_damage(attacker: UnitRuntime, defender: UnitRuntime, skill: SkillData, is_critical: bool false) - int: var base : float(attacker.attack_power) var defense : float(defender.defense) var mitigation : defense / (defense 100.0) var dmg : base * skill.damage_multiplier * (1.0 - mitigation) if is_critical: dmg * 1.5 return max(int(dmg), 1) static func compute_hit_chance(attacker: UnitRuntime, defender: UnitRuntime, skill: SkillData) - float: var chance : 0.9 (float(attacker.accuracy) - float(defender.evasion)) * 0.01 skill.hit_bonus return clampf(chance, 0.05, 0.95)我特意用了defense / (defense 100)这种乘除法减伤而不是attack - defense直接相减。原因很简单相减公式在攻击力远高于防御时会打出天文伤害而防御力高于攻击力时又容易出现0伤害玩家体验极差。比例减伤的好处是无论攻防差值多大伤害都在可控区间内波动调整参数也直观100点防御约等于50%减伤200点防御约等于67%减伤。命中率这里默认基础90%攻防差值每差1点调整1%再叠技能命中加成最后用clamp限制在5%到95%之间。这五个点的下限和上限是必要的否则就会出现“百发百中”和“必闪”两个极端词条Balance会非常难做。4.3 状态效果的模块化Buff和Debuff不写死逻辑毒、灼烧、冰缓、攻防增减这些状态效果如果一个个写if技能系统迟早爆炸。我的做法是定义StatusEffectData资源包含效果类型、数值、持续回合数、是否可叠加然后由一个统一的StatusEffectContainer挂在UnitRuntime上执行。# status_effect_data.gd class_name StatusEffectData extends Resource enum ModifierType { FLAT_ATK, FLAT_DEF, DAMAGE_OVER_TIME, STUN, MOVEMENT_REDUCE } export var effect_id: String export var modifier_type: ModifierType export var amount: int 0 export var duration: int 1 export var stackable: bool false结算时UnitRuntime的伤害计算会先扫描容器内所有攻击类修正累加后传给DamageCalculator回合开始时先结算所有的持续伤害。每个状态效果实例要记录剩余回合数回合结束后递减减到0就移除。整个过程都是查表累加新加状态效果只需要新建一个StatusEffectData资源再在应用效果的地方写很小的处理分支绝不会侵入其他技能代码。5. 敌人AI不是越强越好而是要像“有限状态机评估”的产物5.1 目标评估表让每一只怪选择攻击对象敌人AI最怕写成“只会攻击最近目标”因为玩家很快会用坦克顶在前面戏耍AI。我用的方案是给每个敌人一张目标评估表遍历所有可攻击目标按威胁值打分。func evaluate_target(candidate: UnitRuntime, self_unit: UnitRuntime, grid_data: GridData) - float: var score : 0.0 var distance : grid_data.manhattan_distance(self_unit.cell, candidate.cell) score 100.0 / float(distance 1) # 距离越近分数越高 score candidate.attack_power * 0.1 # 攻击力高优先打 score (float(candidate.max_hp - candidate.hp) / float(candidate.max_hp)) * 50.0 # 残血优先收 if candidate.team_id ! self_unit.team_id: score 200.0 return score这个评分公式可以根据敌人性格调整参数刺客型敌人把攻击力权重调高坦克型敌人把距离权重调高辅助型敌人甚至可以反过来优先走位而非攻击。把这些参数放到敌人配置资源里之后调整AI风格就变成纯数据工作。5.2 行动合成移动和攻击是一个组合拳敌人AI不能光选一个目标它还要决定“走到哪一格再打”。最简单可靠的做法是枚举遍历所有可移动抵达的格子对每个格子枚举所有可用技能再对每个射程内目标计算“预计总收益”取最高的一对组合。func build_action_plan(enemy: UnitRuntime, grid_data: GridData) - Dictionary: var best_plan : {} var best_score : -INF var reachable : Pathfinder.get_reachable_cells(enemy.cell, enemy.ap, grid_data) for cell in reachable: if enemy.has_acted: continue for skill in enemy.available_skills(): var targets : get_targets_in_range(cell, enemy, skill, grid_data) for target in targets: var score : estimate_action_score(enemy, target, skill, cell) if score best_score: best_score score best_plan { move_cell: cell, target: target, skill: skill } return best_plan枚举法看着笨但在地图30×30以内、格子数量可接受的前提下性能完全撑得住。更重要的是它不需要维护复杂的状态机优先级逻辑简单、不容易出bug。我为每个敌人限制“最多尝试3个候选技能”避免后期技能变多后每帧枚举爆炸。5.3 难度调节点不要让AI拥有上帝视角很多新人做AI时会让AI读取全图所有玩家单位的位置和血量结果玩家还没走进怪物的视野范围怪物就已经冲向你了。这极其破坏体验。我的方案是给每个敌人一个可见范围半径只有进入半径的玩家单位才会出现在目标评估表里。难度调整我放在AI参数表上普通怪物“视野半径6、评估深度1回合”精英怪“视野半径8、评估深度2回合”Boss可以“视野半径12、无视地形限制”。这样做的好处是HARD模式不是单纯堆敌人数值而是让AI在战术层面更聪明玩家会明显感到“对面会绕过坦克切后排了”。6. Godot 4实战里的关键坑位TileMapLayer、池化和存档引用6.1 TileMapLayer版本迁移的API变化如果你用的是Godot 4.3之后会发现旧教程里的TileMap节点被拆成了TileMapLayer很多方法签名也变了。最典型的是旧版用get_cellv(pos)、set_cellv(pos, source_id, atlas_coords)新版直接改成get_cell_source_id(pos)、set_cell(pos, source_id, atlas_coords)。从你编辑器里调用的TileSet资源也可能需要重新配置物理层。我建议直接用TileMapLayer而不是旧TileMap因为新API是从底层重做过的层级结构更干净。但要注意两点第一TileMapLayer内部默认自带一个TileMapLayer属性你要在场景里新增Node2D时别选错类型第二运行时动态改格子一定要在物理查询后调用update_cells()或notify_runtime_tile_data_update()否则导航数据不会实时刷新。如果你还在用Godot 4.0到4.2迁移过来时建议新建一个TileMapLayer重新铺图而不是直接改脚本硬兼容少碰不少因为TileSet配置迁移带来的坑。6.2 网格高亮节点手动池化比反复创建更稳战术RPG里最吃性能的不是单位而是棋盘高亮。移动范围20多格、攻击范围10多格每次玩家切换选中单位都要创建几十个半透明格子节点。如果直接用preload().instantiate()和queue_free()切几次单位后帧率就会肉眼可见地掉下去。我的方案是做一个HighlightPool节点启动时预创建40个ColorRect矩形节点并隐藏显示时按需拿出来平移位置和设置Visibility用完再还回去。单个矩形开销很小但池化把创建释放的GC开销全省掉了实测在高亮40格、每秒切换多次选中目标的情况下帧率保持稳定。# highlight_pool.gd extends Node2D export var pool_size : 40 onready var _highlight_scene : preload(res://src/visual/highlight_cell.tscn) var _pool: Array[ColorRect] [] func _ready() - void: for i in range(pool_size): var rect : _highlight_scene.instantiate() as ColorRect rect.visible false add_child(rect) _pool.append(rect) func show_highlights(cells: Array[Vector2i], color: Color) - void: var count : min(cells.size(), _pool.size()) for i in range(count): var rect : _pool[i] rect.position cells[i] * cell_size rect.color color rect.visible true for i in range(count, _pool.size()): _pool[i].visible false这里注意矩形节点的AnchorPoint要设成左上角这样position直接等于格子坐标乘以格子尺寸不用再偏移半个格子。如果你做3D版同理可换成GridMesh实例化或MultiMesh原理完全一致。6.3 存档序列化不要直接存Resource引用最后是一个我踩得最深、几乎让存档报废的坑直接把Resource对象写进存档字典。Godot的Resource在运行时是引用共享的两个敌人如果引用了同一个“火焰斩.tres”存档时保存的是Resource的引用路径读档时如果资源版本更新或换过路径存进去的路径就失效了。我最终的存档格式统一使用纯数据字典Resource只存id或路径读档时再通过SkillDatabase、CharacterDatabase反查func save_unit(unit: UnitRuntime) - Dictionary: return { unit_id: unit.unit_id, cell: [unit.cell.x, unit.cell.y], hp: unit.hp, ap: unit.ap, skill_ids: unit.skill_ids, cooldowns: unit.cooldowns, status_effects: unit.status_effects.map(func(se): return se.to_dict()) } func load_unit(data: Dictionary) - UnitRuntime: var unit : UnitRuntime.new() unit.unit_id data[unit_id] unit.cell Vector2i(data[cell][0], data[cell][1]) unit.hp data[hp] unit.ap data[ap] unit.skill_ids data[skill_ids] unit.cooldowns data[cooldowns] unit.status_effects data[status_effects].map(func(d): return StatusEffectData.from_dict(d)) return unit这套写法保证了存档是可读、可控、可迁移的。哪怕未来我把“火焰斩”的伤害从2.0改成2.5旧存档也能正常读因为存档里没有塞数值快照运行时依然通过当前SkillData解析。顺带一提Godot 4.2之后Resource有uid机制保存引用时用uid字符串比路径更抗文件移动算是一个额外的保险。做战术RPG的体感是最难的部分真的不是单个系统怎么实现而是怎么让每个系统互相配合时不打架。模块化架构不是给人看的“整洁仪式”它是你后期加内容时能不能睡得着觉的关键。上面这些方案我都是在实际项目里踩过、改过、确认能扛住一轮又一轮需求变更后才沉淀下来的照着搭一轮你会明显感受到加新技能、新敌人时下笔轻松很多。
返回列表