Godot回合制轮次系统:状态机与信号驱动实战
发布时间:2026/10/2 2:53:31 锦皓数字建站

第一次在Godot里做回合制Demo我以为只要有几个按钮和变量就够了玩家点一下攻击敌人回一下手谁血量低谁先死这不就是轮次吗。结果第一个原型跑起来之后问题比想象中多得多——按钮可以连点、敌人AI偶尔跑两轮、UI上的“轮到谁了”和实际操作顺序对不上。后来我才明白轮次不是一个简单的计数器而是一套状态流转规则。Godot游戏练习01系列做到第9节我把“游戏轮次”这部分单独拎出来完整梳理一遍怎么设计状态机、怎么用信号驱动回合切换、怎么处理玩家输入和敌人AI之间的等待关系以及那些最容易被忽略的坑。1. 轮次本质是状态流转先把状态机定义清楚很多刚接触回合制游戏的人第一反应是搞一个round_number变量玩家行动完就round_number 1然后让敌人行动。这个思路本身没问题但它没有回答几个关键问题当前是谁的回合玩家还能不能点按钮敌人AI是不是正在行动结算动画放完了没有这些信息靠一个整数是表达不清楚的。1.1 轮次不是数字是状态游戏轮次的本质是“当前属于谁的决策阶段”。常见的状态有这么几个玩家回合玩家可以移动、攻击、使用道具。敌人回合玩家输入被锁定敌人AI依次行动。回合结算处理中毒、流血、回合奖励等效果。轮次过渡场景、UI、动画正在切换任何角色都不能操作。一个完整的轮次循环其实就是在这些状态之间反复跳转。比如“玩家回合”结束后跳到“敌人回合”敌人行动完再跳回“玩家回合”每跳一次round_number加一。把状态改成显式的枚举比靠“当前是谁在动”这种隐式判断要可靠得多因为你可以随时知道整个游戏处于哪个阶段。1.2 状态机的骨架实现与防重复切换在Godot里我推荐用一个专门的TurnManager节点管理轮次而不是把逻辑散落在各个角色脚本里。最基础的状态机大概长这样extends Node # 回合状态 enum TurnState { PLAYER_TURN, ENEMY_TURN, ROUND_RESOLVE, ROUND_END } var current_state: TurnState TurnState.PLAYER_TURN var round_number: int 1 func change_state(new_state: TurnState) - void: if current_state new_state: return # 防止重复切换 current_state new_state match new_state: TurnState.PLAYER_TURN: _start_player_turn() TurnState.ENEMY_TURN: _start_enemy_turn() TurnState.ROUND_RESOLVE: _resolve_round_effects() TurnState.ROUND_END: _finish_round()细节都在_start_player_turn()、_start_enemy_turn()这些方法里。比如_start_player_turn()需要做这几件事把本回合行动点数重置为初始值。通知UI更新回合提示。启用玩家操作相关的输入。重置回合内标记位比如“本回合是否已经移动过”。这里有一个我早期经常踩的坑change_state()里没有判断new_state current_state导致敌人行动结束时误触发了两次玩家回合恢复。加上这个判断看起来很简单但它能挡住大量“看起来随机发生”的轮次跳变。尤其是当你用按钮、信号、动画回调都可能触发切换的时候这一行守卫能救命。2. 用信号让“轮到谁”的播报扩散到全场景Godot最推荐的消息机制就是信号。轮次切换这种事天然适合用信号来做回合管理器自己不知道UI长什么样也不需要知道哪个敌人现在在行动它只需要在状态切换时发出通知其他节点自己决定怎么响应。2.1 信号怎么设计我在项目里通常定义这样一组信号signal player_turn_started(round_number: int) signal enemy_turn_started(round_number: int) signal round_resolving(round_number: int) signal round_finished(round_number: int)为什么不只定义一个turn_changed(state)信号因为信号接收方往往只关心某几个状态。比如UI上的回合面板只想知道玩家回合什么时候开始、回合结束什么时候发生战斗单位的动画系统只关心敌人回合是否开始。把信号拆细一点每个节点连自己需要的就行避免到处写if state TurnState.ENEMY_TURN这种分支。2.2 UI层监听信号玩法层只负责改状态这套设计的核心好处是解耦。回合管理器本质上是“裁判”不直接操作棋子棋子、UI、特效都是“运动员”听到信号才行动。举个例子回合提示栏只需要这样连一次func _ready() - void: turn_manager.player_turn_started.connect(_on_player_turn_started) turn_manager.enemy_turn_started.connect(_on_enemy_turn_started) turn_manager.round_finished.connect(_on_round_finished) func _on_player_turn_started(round_no: int) - void: turn_label.text 第 %d 回合玩家行动 % round_no end_turn_button.disabled false func _on_enemy_turn_started(round_no: int) - void: turn_label.text 第 %d 回合敌人行动 % round_no end_turn_button.disabled true战斗单位那边的逻辑也可以各自挂信号。比如某个敌人的脚本里连接enemy_turn_started信号一到就开始执行自己的行为某个技能特效连接round_resolving回合结算动画开始的时候播放粒子效果。整个系统各司其职排查问题的时候也只需要顺着信号链路找不用来回翻脚本。2.3 常见错误忘了断开连接信号用多了之后最常见的坑反而是“忘了断开连接”。如果游戏里存在动态创建和销毁的单位比如召唤物、临时NPC它们queue_free()之后信号连接还挂在TurnManager上等下一次轮次信号发出来Godot会尝试调用已经释放的对象轻则打印报错重则把整个回合流程卡住。我在项目里的做法是要么在单位销毁前手动断开连接要么统一用turn_manager.player_turn_started.connect(callable, CONNECT_REFERENCE_COUNTED)这种带引用计数的连接方式让Godot在对象释放时自动清理连接。早期为了省事没用这个参数后来每次动态生成敌人都会出现“那个敌人消失了但还在跑AI”的灵异事件加了这个参数之后世界清净多了。3. 一整个回合的推进流水线输入锁、动画等待和AI行动状态机搭好了真正难的是把“一个回合内部”的流程跑顺。玩家点了“结束回合”接下来要经过一系列步骤才能回到玩家回合这个过程中玩家不能操作画面不能卡死敌人行动要有节奏感。这就是轮次系统的执行流水线。3.1 输入锁玩家只能在自己的回合内操作回合制游戏最烦人的体验就是“我还没操作完敌人就动了”或者“我点了一下角色连续动了好几格”。解决办法是在全局控制脚本里维护一个input_locked标志这个标志由TurnManager控制var input_locked: bool false func set_input_locked(locked: bool) - void: input_locked locked # 同时更新当前选中单位的操作状态 _update_action_buttons()玩家回合开始时_start_player_turn()调用set_input_locked(false)敌人回合开始前调用set_input_locked(true)。所有玩家操作的入口不管是按钮还是键盘按键第一行就先检查func _on_attack_button_pressed() - void: if turn_manager.current_state ! TurnState.PLAYER_TURN: return if turn_manager.input_locked: return _do_attack()这样写的好处是逻辑很直白哪怕UI因为某种原因没有正确禁用按钮底层输入锁也会兜底防止玩家操作穿透到错误阶段。3.2 用 await 等待动画和特效收尾Godot 4里await是处理回合节奏的好工具。回合结算需要播放动画但玩家回合不能等动画播完才恢复输入所以不能用await去硬等代码。正确姿势是“等待信号”或者“等待计时器”。举个例子敌人回合里每个敌人行动后需要等 0.3 秒让玩家看清发生了什么func _start_enemy_turn() - void: set_input_locked(true) await get_tree().create_timer(0.3).timeout for enemy in active_enemies: if not is_instance_valid(enemy): continue await enemy.execute_ai_action() await get_tree().create_timer(0.4).timeout change_state(TurnState.PLAYER_TURN)这段代码在执行时把函数变成了协程每await一次就把控制权交回引擎等计时器走完或者信号发出来再继续下一行。这是Godot处理回合节奏最实用的模式。需要注意一点await之后代码可能被取消。《退出场景树》时协程会被中断如果此时已经改变了一些状态可能会导致下次进入场景时状态残缺。我的习惯是所有可能被中断的任务要么在_exit_tree()里做兜底清理要么尽量把真正的状态变更放在回合开始的函数里而不是协程中间。3.3 敌人AI的“延迟-行动-结算-交回控制权”敌人AI不一定都是“每轮必须动一下”。有时候某个敌人会眩晕有时候它需要先播放出场动画再行动。可以把这些情况都封装到execute_ai_action()里让敌人自己决定“我要不要动、怎么动”而TurnManager只管一个敌人行动完就等下一拍。我在项目里给敌人AI设计了一个简单接口# enemy.gd func execute_ai_action() - void: if is_stunned: await get_tree().create_timer(0.2).timeout return # 先播放准备动画 animation_player.play(prepare) await animation_player.animation_finished # 再选择目标并执行行为 var target _select_target() _attack_target(target) # 等待攻击特效收尾 await attack_effect.finished这样做的好处是AI逻辑和回合管理完全解耦。回合管理器只是“挨个调用每个敌人的行动方法然后等它做完”而敌人内部用什么动画、攻击谁、是否眩晕都是自己的事。后续新增敌人类型只需要实现同一个接口。4. 轮次UI不只能看数字回合提示栏与行动单位高亮回合制游戏里UI的反馈质量直接影响手感。玩家需要一眼看出“现在该我动了吗”“这是第几回合”“刚才敌人做了什么”。这部分的实现不复杂但很多人会忽略细节。4.1 回合提示栏的搭建思路提示栏不需要太花哨一个Label加一段切换动画就够了。关键是文字更新必须由信号驱动不能在_process()里每一帧靠轮询判断。轮询的问题是重复计算、逻辑分散而且状态一多很容易漏掉某条更新路径。我之前踩过的坑玩家回合开始时turn_label.text 你的回合敌人在同一帧也调用了一次turn_label.text 敌人回合因为信号顺序不确定最后UI显示的是“你的回合”但实际是敌人回合。后来我把所有UI更新都收拢到对应的_on_xxx_turn_started()回调里并且约定“同一帧内只允许回合管理器发出一个回合开始信号”问题就消失了。提示栏还可以加一条颜色变化玩家回合用蓝色调敌人回合用红色调结算阶段用灰色调。这样玩家哪怕不看文字扫一眼屏幕颜色就知道该不该操作。4.2 高亮当前行动单位的通用做法当多个角色在场上时UI要能让人看出“当前是哪个单位在行动”。我的做法是在每个行动单位上挂一个高亮组件# highlight.gd func set_active(active: bool) - void: if active: highlight_material.set_shader_parameter(intensity, 1.0) status_icon.modulate Color.WHITE else: highlight_material.set_shader_parameter(intensity, 0.0) status_icon.modulate Color.DIM_GRAYTurnManager切换到某个状态时会遍历当前行动单位列表调用set_active(true)高亮当前单位同时把上一个单位取消高亮。这里需要注意单位列表必须在回合开始前就确定好不能在行动过程中临时增删否则会出现“两个单位同时高亮”的错乱。4.3 回合按钮的启用禁用和防连点“结束回合”按钮的disabled属性必须跟状态机强绑定玩家回合启用敌人回合禁用结算阶段禁用。禁用按钮的同时最好也把按钮文字改成“敌人行动中…”或者“结算中…”让玩家知道为什么不能点。防连点是另一个大坑。很多玩家会连续点击“结束回合”如果change_state()里没有防重复判断一股脑切换两次就可能出现“玩家回合结束后立即又开启一个玩家回合”或者“敌人跳过一次行动”的Bug。我除了在状态机入口做守卫还在按钮回调里加了一层检查func _on_end_turn_button_pressed() - void: if turn_manager.current_state ! TurnState.PLAYER_TURN: return if turn_manager.input_locked: return end_turn_button.disabled true # 立即禁用防止同一帧内二次点击 turn_manager.change_state(TurnState.ENEMY_TURN)每次点击先把按钮禁用等敌人回合结束、信号回到玩家回合时再重新启用。这样就算玩家疯狂点击也只有一个回合切换生效。5. 实战里最容易翻车的几个轮次问题状态机、信号、UI都做了可实际跑起来还是会遇到各种“时好时坏”的问题。这里整理几个我在这套轮次系统里真实遇到过的Bug每一个都值得注意。5.1 回合还没结束就被再次触发典型症状玩家回合结束按钮按下去敌人还没行动完UI却显示“玩家的回合”了。多半是因为某个动画回调或AI协程提前调用了change_state(PLAYER_TURN)。排查方法是在change_state()入口打印状态切换日志把“谁请求的切换、当前状态、目标状态”都记下来。我用的日志格式是这样的print([TurnManager] change_state: %s - %s, caller%s % [current_state, new_state, get_stack().get_frame(1).source])这样一旦出现异常切换马上能看到是哪个脚本在什么时机干的好事。根本修复办法有两个一是所有外部切换都走统一接口二是在协程中重复检查current_state比如敌人AI的await结束后先确认自己还处于ENEMY_TURN状态再执行下一步。5.2 玩家行动后没释放输入锁导致“假死”典型症状玩家角色行动完轮次显示切到了敌人回合敌人也动完了但玩家什么都点不了画面好像卡死了。十有八九是输入锁在某条路径上没被释放。我最初在“攻击完成”回调里手动解锁结果角色被眩晕、攻击目标提前死亡、动画被打断这些边角情况都会导致解锁代码根本没执行到。后来改成彻底依赖回合信号_on_player_turn_started里无条件解锁_on_enemy_turn_started里无条件加锁。这样不管中间发生了多少异常只要回合正确切换了输入状态一定是对的。5.3 信号重复连接让一轮回调跑好几次如果你在代码里多次调用connect()又没用CONNECT_REFERENCE_COUNTED同一个信号连接多次之后发一次信号回调会执行好几遍。敌人在_ready()里连接了一次又在某个初始化函数里连接了一次等敌人回合开始它就连着行动两轮。修复不难要么确保连接只执行一次要么在连接前先is_connected()检查一遍if not turn_manager.enemy_turn_started.is_connected(_on_enemy_turn_started): turn_manager.enemy_turn_started.connect(_on_enemy_turn_started)我推荐所有动态节点都做这个检查虽然多写一行但能避免大量“幽灵连接”。5.4 单位被移除后信号还挂在身上这个前面提过动态生成的敌人在被击杀、移除后如果信号连接还挂在TurnManager上Godot会在下一次发信号时尝试调用一个不存在的对象。日志里会出现“Invalid call”或者“Previously freed instance”之类的报错。两个处理办法一是单位被移除时主动断开所有连接二是统一用CONNECT_REFERENCE_COUNTED连接方式。如果你写的是比较大型的项目我更推荐第二种因为它不需要去所有移除单位的地方手动补代码省心很多。5.5 轮次记录不一致导致回滚/重放混乱如果你之前用Godot做过网络同步或者录像回放功能会发现轮次记录对不上是个很头疼的问题。因为轮次里夹杂了大量动画计时器和随机数不同客户端执行顺序稍有不同回滚时状态就“脏”了。针对回合制游戏最稳妥的做法是把随机数生成统一收敛到回合管理器也就是所有“掷骰子”“随机选目标”都调用同一个随机源而不是每个战斗单位各写各的randi()。这样回滚时只要按同样的种子走一遍回合流程就能一致。我在练习项目里就吃过这个亏敌人暴击时有时在玩家回合结算前有时在结算后重放录像时画面完全对不上后来统一随机源才解决。5.6 排查轮次Bug的通用套路我排查轮次问题有一套固定流程分享出来供参考先看TurnManager的日志确认状态切换顺序是否正确。在看板模式下打开场景树检查是否有多余的节点或未释放的单位。暂时关闭所有动画和特效计时器看问题是否由时序错乱引起。让AI行动变成纯同步、无await的版本如果能稳定复现就跟协程有关。最后再怀疑UI层禁用所有UI信号连接看底层战斗逻辑是否自洽。很多“随机出现”的问题在把动画和协程全部关掉之后都会变成稳定复现定位会容易很多。6. 轮次设计还能怎么扩展行动点、后手顺序和动画队列基础轮次跑通后你会开始考虑更丰富的设计。这里提几个我目前在练习项目里用的扩展思路也是从简单回合制往策略游戏迈进的必经之路。6.1 行动点系统每个单位有体力限制单纯的“玩家回合→敌人回合”只适合最简Demo。稍微复杂一点你会想让玩家一个回合内能做多件事但数量有限。行动点系统就能解决这个问题。在_start_player_turn()里把action_points重置为max_action_points每次玩家移动或攻击都扣一点扣到零就强制结束回合。关键是行动点和输入锁要一起判断func try_spend_action_point(cost: int) - bool: if input_locked: return false if action_points cost: return false action_points - cost action_points_changed.emit(action_points, max_action_points) if action_points 0: end_player_turn() return true这样玩家回合内可以自由安排行动顺序比如“先走两格再攻击一次”资源管理就成了核心玩法的一部分。敌人也可以有行动点AI每次消耗一点规划自己的行动组合。6.2 多单位行动顺序先手值或队列如果你有多个玩家角色、多个敌人固定轮次就变成“按顺序排队行动”。这时候需要维护一个行动队列而不是PLAYER_TURN和ENEMY_TURN两个状态互切。简单的做法是给每个单位一个initiative先手值每回合开始时按先手值排序逐个调用execute_ai_action()或等待玩家输入。状态机可以简化成TURN_START生成行动队列。WAITING_ACTION当前单位行动玩家单位等待输入敌人单位自动AI。TURN_END清空队列轮次数加一。这个扩展比双状态复杂一些但本质上还是状态机只是把“玩家/敌人”换成了“单位队列”。6.3 动画队列避免所有单位同时行动当敌人有多个单位时如果每个敌人各自在_on_enemy_turn_started里播动画屏幕上会出现“所有敌人同时攻击”的混乱场面。解决办法是让敌人AI像流水线一样依次执行前面已经展示过for enemy in active_enemies配合await的写法。这里有个细节敌人列表里的单位可能在行动过程中被击杀、被冻结所以每次取出单位后要检查is_instance_valid()否则一个被击杀的幽灵单位会卡住整个流程让后面的敌人永远等不到行动。轮次系统做完之后你会发现它几乎串联了游戏里的所有核心模块UI、战斗、AI、动画、网络同步。哪怕只是一个简单回合制Demo把状态机、信号、输入锁这三个概念理顺了后面加任何新玩法都会顺畅很多。我也还在这个练习项目里继续迭代下一步准备把轮次回放和存档功能加上。如果你正在Godot里做回合制希望这份笔记能帮你少踩几个坑。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。