Godot AnimationTree 条件不该拥有攻击状态:一次性请求与动作代次的边界

角色刚开始第二次攻击,第一次攻击的动画完成信号却在同一帧迟到。如果回调直接把 is_attacking 改成 false,第二次攻击会被旧动画结束;如果 AnimationTreerequest_attack 条件没有及时清除,同一个请求还可能连续触发两次状态切换。画面树看似只是表现配置,实际上已经反向拥有了玩法状态。

这类错误不能只靠“信号连接顺序”修补。攻击身份、当前玩法状态和动画切换请求必须有明确所有者:玩法状态机产生带 action_id 的表现快照,动画桥消费一次性条件,完成回调携带原动作身份返回,由玩法状态机决定它是否仍有资格改变状态。

玩法状态单向驱动动画图,迟到完成被挡在所有权边界外的技术主视觉

布尔条件缺少“哪一次攻击”

AnimationTree 条件适合表达一次过渡请求,却无法单独回答请求属于哪次玩法动作。连续两次攻击都把 request_attack 设为 true 时,布尔值没有变化;第一次攻击的完成回调也只知道某个动画结束,不知道当前动作已经推进到第二代。

最小 Godot 探针把边界拆成两个对象。CombatStateOwner 持有 action_idstateAnimationTreeBridge 只持有最近呈现的动作代次、一次性条件和实际消费次数。真实场景中,桥会把 conditions.request_attack 写入 AnimationTree 参数;探针保留决定所有权的核心语义,不假装验证具体动画资源、混合时长或骨骼输出。

核心实现位于 $PROBE_ROOT/combat_animation.gd

class_name CombatAnimation
extends RefCounted

enum FinishResult { ACCEPTED, STALE_ACTION, WRONG_CLIP }

class CombatStateOwner:
	var action_id := 0
	var state := "idle"

	func begin_attack() -> Dictionary:
		action_id += 1
		state = "attacking"
		return {
			"action_id": action_id,
			"state": state,
			"request_attack": true
		}

	func finish_attack(
		completed_action_id: int,
		clip: String
	) -> FinishResult:
		if completed_action_id != action_id:
			return FinishResult.STALE_ACTION
		if clip != "attack":
			return FinishResult.WRONG_CLIP
		state = "idle"
		return FinishResult.ACCEPTED

class AnimationTreeBridge:
	var active_action_id := 0
	var conditions := {"request_attack": false}
	var transition_count := 0

	func present(snapshot: Dictionary) -> void:
		if snapshot.action_id <= active_action_id:
			return
		active_action_id = snapshot.action_id
		conditions.request_attack = snapshot.request_attack

	func consume_requests() -> void:
		if conditions.request_attack:
			transition_count += 1
		conditions.request_attack = false

begin_attack 先推进动作代次,再产生不可回写的表现快照。桥拒绝旧代次快照,因此异步资源加载或延迟消息不能让动作 1 重新覆盖动作 2。条件被消费后立即清零;它代表“请求一次过渡”,不是“角色持续处于攻击状态”。

表现更新点也必须唯一。如果输入处理、物理帧和动画回调都能直接写同一个条件,清零时机仍会依赖节点执行顺序。更稳妥的做法是让玩法层只更新快照,桥在一次明确的表现阶段比较 action_id,写入本帧条件,然后完成消费。这样暂停、慢动作或一帧内多次玩法推进不会把条件变成隐式队列;确实需要保留多个动作时,应传递带身份的请求列表,而不是让一个布尔值承担排队语义。

动画完成是提案,不是状态提交

固定序列先开始动作 1,桥消费两次。第一次把 transition_count 增加到 1,第二次因为条件已经清零而无变化。随后玩法层开始动作 2,动作 1 的完成回调迟到。finish_attack(1, "attack") 返回 STALE_ACTION,当前状态仍为 attacking

动作 2 的表现快照驱动 AnimationTree 桥,动作 1 的迟到完成保持当前攻击状态的关系图

这里还校验了 Clip 身份。即使回调携带当前 action_id=2,若完成的是 idle,也返回 WRONG_CLIP。只比较代次仍可能让同一动作图中的其他 Clip 错误结束攻击。只有动作 2 与 attack 同时匹配,玩法所有者才把状态改回 idle

这种设计没有禁止动画回调影响玩法,而是把回调降为带身份的提交提案。命中帧、移动窗口或连段窗口仍可通过同一合同提交,但事件必须携带玩法动作身份,最终裁决不能藏在 AnimationTree 的当前节点名或布尔条件里。

取消同样遵守这一边界。玩法层若在动作 2 期间进入受击状态,应先推进动作代次并发布新的表现快照;之后到达的攻击完成自然失效。桥可以根据新快照执行淡出或跳转,但“是否允许取消”仍由玩法迁移规则决定。否则策划调整一条动画过渡,就可能同时改变无敌帧、技能冷却或连段资格,表现资源也无法独立热更新。

Godot 无界面运行证明旧完成没有穿透边界

探针使用 Godot 工程、场景入口和 GDScript 实际运行。命令如下:

godot --headless --path $PROBE_ROOT --editor --quit
godot --headless --path $PROBE_ROOT

工程导入退出码为 0,墙钟 10010 毫秒;运行退出码为 0,12 项断言通过、失败 0、跳过 0,墙钟 930 毫秒。决定性输出保留了请求消费和状态变化:

first: action=1 state=attacking request_consumed=true transitions=1
stale-finish: completed=1 active=2 result=STALE_ACTION state=attacking
wrong-clip: completed=2 clip=idle result=WRONG_CLIP state=attacking
current-finish: completed=2 clip=attack result=ACCEPTED state=idle transitions=2
tests: pass=12 fail=0 skip=0 duration_ms=2

这些结果证明的是状态所有权与动作代次,不是动画播放质量或性能。探针没有加载真实 AnimationTree 资源,也没有验证 BlendSpace、过渡淡入淡出、取消窗口和网络回滚。接入实际场景时,AnimationTreeBridge.consume_requests 应在统一表现更新点写入条件并清除本地请求;动画信号需要附带开始播放时捕获的 action_id,不能在完成时读取“当前动作 ID”冒充原始身份。

开篇的第二次攻击不应被第一次动画结束。让玩法状态机拥有动作代次,让动画桥只消费一次性表现请求,再让完成信号带原身份回来,旧完成就会成为可诊断的 STALE_ACTION,而不是一次偶发的状态跳变。若后续加入取消、连段或回滚,需要扩展的是玩法动作合同与允许的迁移集合;AnimationTree 仍只负责把已经裁决的意图变成姿态。