197 lines
8.1 KiB
GDScript
197 lines
8.1 KiB
GDScript
extends SceneTree
|
||
|
||
## 第四轮修复契约(2026-07-05,07-06 修订):
|
||
## 1. 小怪击退:残速不再被 AI 每帧接管清零,产生实际水平滑行;
|
||
## 垂直击退后假高度轴正常积分并落地复位 Grounded(修复"被剑雨/下砸
|
||
## 打过一次后 ground_state 永久 Airborne、小怪从此不能出招"的隐藏 bug)。
|
||
## 2. 趔趄门禁:击退滑行期间 Boss/小怪不得起手新动作(EnemyActionDriver
|
||
## 钩子),否则动作突进立刻接管 velocity 冲回玩家,把击退整个吃掉
|
||
## (真实 stage 实测净位移为负);Boss 受击链强制撤退豁免(清残速标志)。
|
||
## 3. 关卡背景单层视差:背景水平速度为相机 0.8 倍(0.92 肉眼难辨已加强),
|
||
## 锚点 2047,无相机保持原位;脚下地砖轻微滑动是单张烙死原画的已知
|
||
## 取舍,无伤视差待美术拆层。
|
||
## (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
|
||
|
||
# 无相机:背景保持原位(headless/菜单兜底)。
|
||
_expect_float(art_layer.position.x, 896.5, "无相机时背景保持基准位置")
|
||
|
||
# 有相机:偏移 = (相机中心 - 1734.5) × 0.8——"远景天幕"语义,背景 80%
|
||
# 跟随相机,仅 0.2 倍速随世界滚动,保证远景夕阳时刻在画面内(策划定案)。
|
||
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 + (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 + (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)
|