extends SceneTree ## 第四轮修复契约(2026-07-05,07-06 修订): ## 1. 小怪击退:残速不再被 AI 每帧接管清零,产生实际水平滑行; ## 垂直击退后假高度轴正常积分并落地复位 Grounded(修复"被剑雨/下砸 ## 打过一次后 ground_state 永久 Airborne、小怪从此不能出招"的隐藏 bug)。 ## 2. 趔趄门禁:击退滑行期间 Boss/小怪不得起手新动作(EnemyActionDriver ## 钩子),否则动作突进立刻接管 velocity 冲回玩家,把击退整个吃掉 ## (真实 stage 实测净位移为负);Boss 受击链强制撤退豁免(清残速标志)。 ## 3. 关卡背景分层契约:当前整图里的地面/步道先锁在 1:1 世界层,避免脚下 ## 地砖相对角色滑动;美术拆出的天空/夕阳放入 SkyParallaxLayer 后继续按 ## 0.2 倍世界滚动(相机 0.8 跟随)。 ## (Boss 击退 ×9 的加码钉在 test_round3_fixes。) var failures: Array[String] = [] func _init() -> void: _run.call_deferred() func _run() -> void: root.size = Vector2i(1152, 648) var settings := root.get_node_or_null("GameSettings") if settings != null: settings.call("set_difficulty", &"easy") await _check_minion_horizontal_knockback() await _check_minion_vertical_knockback_recovers() await _check_stagger_gates_enemy_actions() await _check_boss_to_player_knockback() await _check_background_parallax() _finish() func _make_minion() -> Node2D: var scene: PackedScene = load("res://scenes/enemies/minion.tscn") var minion := scene.instantiate() as Node2D root.add_child(minion) # 与 test_minion_phase_system 同款:关 AI,排除追击/巡逻对位移的污染。 var behavior := minion.get_node_or_null("MinionBehavior") if behavior != null: behavior.set("enabled", false) minion.set("approach_direction", 0.0) return minion func _check_minion_horizontal_knockback() -> void: var minion := _make_minion() await process_frame minion.global_position = Vector2(500.0, 300.0) var motor := minion.get_node("MovementMotor") var start_x := minion.global_position.x motor.call("apply_knockback", Vector2(120.0, 0.0)) await physics_frame await physics_frame await physics_frame _expect((minion as CharacterBody2D).velocity.x > 0.0, "小怪击退残速不再被 AI 每帧清零") for _index: int in range(40): await physics_frame var displacement := minion.global_position.x - start_x _expect(displacement > 5.0, "小怪击退产生实际水平滑行(got %.2f px)" % displacement) _expect(not bool(motor.call("has_knockback_stray")), "残速衰减完后 stray 标志自清(AI 可恢复接管)") minion.free() func _check_minion_vertical_knockback_recovers() -> void: var minion := _make_minion() await process_frame minion.global_position = Vector2(500.0, 300.0) var motor := minion.get_node("MovementMotor") var state_machine := minion.get_node("StateMachine") motor.call("apply_knockback", Vector2(0.0, 300.0)) await physics_frame _expect_equal(state_machine.call("get_ground_state"), &"Airborne", "垂直击退令小怪进入 Airborne") var landed := false for _index: int in range(180): await physics_frame if state_machine.call("get_ground_state") == &"Grounded" \ and is_zero_approx(float(minion.get("height"))) \ and is_zero_approx(float(minion.get("height_speed"))): landed = true break _expect(landed, "垂直击退后小怪应自然落地复位 Grounded(旧版永久卡 Airborne 导致无法出招)") minion.free() func _check_stagger_gates_enemy_actions() -> void: # Boss:击退滑行期间不得起手新动作;强制撤退清残速后恢复。 var boss_scene: PackedScene = load("res://scenes/enemies/boss.tscn") var boss := boss_scene.instantiate() as Node2D root.add_child(boss) await process_frame boss.set("combat_enabled", true) boss.set("stationary", false) # 用不解析的动作 ID 旁路 BossBehaviorTree 的相位限型分支(melee/ranged # 按时间相位限制与本契约无关),只隔离趔趄门禁本身。 var boss_motor := boss.get_node("MovementMotor") _expect(bool(boss.call("can_start_enemy_action", &"__stagger_probe__")), "无击退时 Boss 可正常起手") boss_motor.call("apply_knockback", Vector2(120.0, 0.0)) _expect(not bool(boss.call("can_start_enemy_action", &"__stagger_probe__")), "击退滑行(趔趄)期间 Boss 不得起手新动作——否则突进会吃掉击退位移") boss_motor.call("clear_knockback_stray") _expect(bool(boss.call("can_start_enemy_action", &"__stagger_probe__")), "清残速标志后恢复起手(受击链强制撤退走此豁免)") boss.free() # 小怪:同款门禁。 var minion := _make_minion() await process_frame var minion_motor := minion.get_node("MovementMotor") _expect(bool(minion.call("can_start_enemy_action", &"__stagger_probe__")), "无击退时小怪可正常起手") minion_motor.call("apply_knockback", Vector2(120.0, 0.0)) _expect(not bool(minion.call("can_start_enemy_action", &"__stagger_probe__")), "击退滑行期间小怪不得起手新动作") minion.free() func _check_boss_to_player_knockback() -> void: # 2026-07-06 策划(连续三轮):"Boss 对玩家没有任何击退效果"。两个根因: # ①Boss 近战沿用默认基准 120,普通连段倍率 0.4/0.5 → 位移仅 2~3px,且相机 # 1:1 跟随玩家把位移完全吃进视觉;②敌方弹丸基准击退 (0,0),未来相位 # Boss 只用远程 → 恒为零。修复=Boss 近战基准 ×3 初速 + 敌弹补基准。 var boss_scene: PackedScene = load("res://scenes/enemies/boss.tscn") var boss := boss_scene.instantiate() root.add_child(boss) await process_frame var emitter := boss.get_node("DamageEmitter") _expect((emitter.get("base_knockback") as Vector2).is_equal_approx(Vector2(360.0, 304.056)), "Boss 近战基准击退 ×3 初速(普通连段 2px→20~32px、重击 190~343px)") boss.free() var container: Node2D = (load("res://scenes/stage/actors_container.gd") as GDScript).new() root.add_child(container) container.call("_on_projectile_requested", null, Vector2.ZERO, Vector2.LEFT, {"team": &"enemy"}) await process_frame var bullet := container.get_child(0) _expect((bullet.get("base_knockback") as Vector2).is_equal_approx(Vector2(360.0, 0.0)), "敌方弹丸补上基准击退(未来相位不再恒为零,弹丸不击飞)") _expect_float(float(bullet.get("speed")), 416.0, "敌弹速度保持 416 不受击退注入影响") container.free() func _check_background_parallax() -> void: var scene: PackedScene = load("res://scenes/ground/ground1.tscn") var ground := scene.instantiate() root.add_child(ground) await process_frame await process_frame var art_layer := ground.get_node("ArtLayer") as Node2D var sky_layer := ground.get_node("SkyParallaxLayer") as Node2D # 无相机:世界层与可选远景层都保持原位(headless/菜单兜底)。 _expect_float(art_layer.position.x, 896.5, "无相机时地面世界层保持基准位置") _expect_float(sky_layer.position.x, 896.5, "无相机时远景视差层保持基准位置") # 有相机:地面/步道保持 1:1 世界坐标;远景容器按 # (相机中心 - 1734.5) × 0.8 视差移动。 var camera := Camera2D.new() root.add_child(camera) camera.make_current() camera.global_position = Vector2(2547.0, 395.0) await process_frame await process_frame _expect_float(art_layer.position.x, 896.5, "相机右移时地面世界层不做视差") _expect_float(sky_layer.position.x, 896.5 + (2547.0 - 1734.5) * 0.8, "相机右移时远景层 80% 跟随") camera.global_position = Vector2(1547.0, 395.0) await process_frame await process_frame _expect_float(art_layer.position.x, 896.5, "相机左移时地面世界层仍不做视差") _expect_float(sky_layer.position.x, 896.5 + (1547.0 - 1734.5) * 0.8, "相机左移时远景层对称跟随") camera.free() ground.free() func _expect(condition: bool, label: String) -> void: if not condition: failures.append(label) func _expect_equal(actual: Variant, expected: Variant, label: String) -> void: if actual != expected: failures.append("%s: expected %s, got %s" % [label, expected, actual]) func _expect_float(actual: float, expected: float, label: String) -> void: if not is_equal_approx(actual, expected): failures.append("%s: expected %.4f, got %.4f" % [label, expected, actual]) func _finish() -> void: paused = false if failures.is_empty(): print("PASS round4 fixes") quit(0) else: for failure: String in failures: push_error(failure) quit(1)