extends SceneTree var failures: Array[String] = [] const FORM_NATIVE_FACING := { &"jin_zhan_1": Vector2.LEFT, &"jin_zhan_3": Vector2.RIGHT, # 小女孩素材原生朝右:配置成 LEFT 曾导致她背对玩家攻击(镜像反了)。 &"yuan_cheng_1": Vector2.RIGHT, &"yuan_cheng_2": Vector2.RIGHT, } ## 远程弹道离 actor 原点的高度:可见脚底约 -40px,再加出手高度,对齐手部/法球。 const RANGED_PROJECTILE_HEIGHT := 86.0 func _init() -> void: _run.call_deferred() func _run() -> void: await _check_attack_intervals() await _check_form_native_facing() await _check_animation_feet_are_ground_locked() await _check_witch_projectile_stays_horizontal() await _check_little_girl_projectile_stays_horizontal() await _check_ranged_form_visual_heights_match() await _check_projectile_visual_rotates_with_direction() _finish() func _check_attack_intervals() -> void: var manager := _ensure_time_phase_manager() var scene: PackedScene = load("res://scenes/enemies/minion.tscn") _expect(scene != null, "Minion scene should load") if scene == null: return var past_strong := _spawn_form(scene, &"jin_zhan_3", &"jin_zhan_1", &"past_strong") manager.call("reset_to_initial", &"past") await process_frame _expect_int(int(past_strong.call("current_attack_interval_beats")), 2, "Strong minion phase should attack once every two beats") manager.call("set_time_phase", &"future", &"debug") await process_frame _expect_int(int(past_strong.call("current_attack_interval_beats")), 4, "Weak minion phase should attack once every four beats") past_strong.queue_free() var future_strong := _spawn_form(scene, &"yuan_cheng_1", &"yuan_cheng_2", &"future_strong") manager.call("set_time_phase", &"past", &"debug") await process_frame _expect_int(int(future_strong.call("current_attack_interval_beats")), 4, "Future-strong minion should be weak in Past and attack once every four beats") manager.call("set_time_phase", &"future", &"debug") await process_frame _expect_int(int(future_strong.call("current_attack_interval_beats")), 2, "Future-strong minion should be strong in Future and attack once every two beats") future_strong.queue_free() func _check_form_native_facing() -> void: var scene: PackedScene = load("res://scenes/enemies/minion.tscn") if scene == null: return for form_id: StringName in FORM_NATIVE_FACING.keys(): var minion := _spawn_form(scene, form_id, form_id, &"past_strong") await process_frame var target := Node2D.new() target.name = "Target" root.add_child(target) minion.global_position = Vector2(100.0, 100.0) target.global_position = Vector2(40.0, 100.0) minion.call("look_at_target", target) minion.call("flip_sprites") _expect_visual_scale_for_heading(minion, form_id, Vector2.LEFT, "%s should face left when target is left" % form_id) target.global_position = Vector2(160.0, 100.0) minion.call("look_at_target", target) minion.call("flip_sprites") _expect_visual_scale_for_heading(minion, form_id, Vector2.RIGHT, "%s should face right when target is right" % form_id) target.queue_free() minion.queue_free() func _check_animation_feet_are_ground_locked() -> void: var scene: PackedScene = load("res://scenes/enemies/minion.tscn") if scene == null: return for form_id: StringName in FORM_NATIVE_FACING.keys(): var minion := _spawn_form(scene, form_id, form_id, &"past_strong") await process_frame minion.set_physics_process(false) var animation_player := minion.get_node("AnimationPlayer") as AnimationPlayer var sprite := minion.get_node("Visual/CharacterSprite") as Sprite2D var baseline := _animation_baseline(animation_player, sprite, StringName("%s_idle" % form_id)) _expect(baseline < INF, "%s should have a measurable idle baseline" % form_id) for animation_name: StringName in animation_player.get_animation_list(): if not str(animation_name).begins_with(str(form_id)): continue _expect_animation_feet(animation_player, sprite, animation_name, baseline) minion.queue_free() func _check_witch_projectile_stays_horizontal() -> void: var player_scene: PackedScene = load("res://scenes/characters/player.tscn") var minion_scene: PackedScene = load("res://scenes/enemies/minion.tscn") var action: Resource = load("res://resources/actions/enemies/minion_yuan_cheng_2_attack.tres") _expect(player_scene != null, "Player scene should load for witch projectile") _expect(minion_scene != null, "Minion scene should load for witch projectile") _expect(action != null, "Witch projectile action should load") if player_scene == null or minion_scene == null or action == null: return var container := Node2D.new() container.name = "ActorsContainer" root.add_child(container) var player := player_scene.instantiate() as Node2D player.name = "Player" container.add_child(player) var witch := _spawn_form_under(container, minion_scene, &"yuan_cheng_2", &"yuan_cheng_2", &"future_strong") await process_frame await process_frame player.global_position = Vector2(100.0, 560.0) witch.global_position = Vector2(360.0, 560.0) witch.call("look_at_target", player) var left_request: Dictionary = (witch.call("projectile_requests_for_action", action) as Array)[0] var left_direction: Vector2 = left_request.get("direction", Vector2.ZERO) _expect_vector(left_direction, Vector2.LEFT, "Witch projectile should stay horizontal when firing left") player.global_position = Vector2(620.0, 560.0) witch.call("look_at_target", player) var right_request: Dictionary = (witch.call("projectile_requests_for_action", action) as Array)[0] var right_direction: Vector2 = right_request.get("direction", Vector2.ZERO) _expect_vector(right_direction, Vector2.RIGHT, "Witch projectile should stay horizontal when firing right") container.queue_free() func _check_little_girl_projectile_stays_horizontal() -> void: var player_scene: PackedScene = load("res://scenes/characters/player.tscn") var minion_scene: PackedScene = load("res://scenes/enemies/minion.tscn") var action: Resource = load("res://resources/actions/enemies/minion_yuan_cheng_1_attack_2.tres") _expect(player_scene != null, "Player scene should load for little girl projectile aim") _expect(minion_scene != null, "Minion scene should load for little girl projectile aim") _expect(action != null, "Little girl projectile action should load") if player_scene == null or minion_scene == null or action == null: return var container := Node2D.new() container.name = "ActorsContainer" root.add_child(container) var player := player_scene.instantiate() as Node2D player.name = "Player" container.add_child(player) var girl := _spawn_form_under(container, minion_scene, &"yuan_cheng_1", &"yuan_cheng_1", &"past_strong") await process_frame await process_frame player.global_position = Vector2(100.0, 560.0) girl.global_position = Vector2(360.0, 560.0) girl.call("look_at_target", player) var left_request: Dictionary = (girl.call("projectile_requests_for_action", action) as Array)[0] var left_direction: Vector2 = left_request.get("direction", Vector2.ZERO) _expect_vector(left_direction, Vector2.LEFT, "Little girl projectile should stay horizontal when firing left") player.global_position = Vector2(620.0, 560.0) girl.call("look_at_target", player) var right_request: Dictionary = (girl.call("projectile_requests_for_action", action) as Array)[0] var right_direction: Vector2 = right_request.get("direction", Vector2.ZERO) _expect_vector(right_direction, Vector2.RIGHT, "Little girl projectile should stay horizontal when firing right") container.queue_free() func _check_ranged_form_visual_heights_match() -> void: var scene: PackedScene = load("res://scenes/enemies/minion.tscn") _expect(scene != null, "Minion scene should load for ranged height check") if scene == null: return var girl := _spawn_form(scene, &"yuan_cheng_1", &"yuan_cheng_1", &"past_strong") var witch := _spawn_form(scene, &"yuan_cheng_2", &"yuan_cheng_2", &"past_strong") await process_frame var girl_height := _visible_sprite_height(girl) var witch_height := _visible_sprite_height(witch) _expect(girl_height > 0.0, "Little girl visible height should be measurable") _expect(witch_height > 0.0, "Witch visible height should be measurable") _expect_float(witch_height, girl_height, "Witch visible height should match little girl height after normalization", 8.0) _expect_float((witch.call("projectile_spawn_position", null) as Vector2).y, (girl.call("projectile_spawn_position", null) as Vector2).y, "Witch projectile lane should match little girl lane", 1.0) _expect_float( (girl.call("projectile_spawn_position", null) as Vector2).y - girl.global_position.y, -RANGED_PROJECTILE_HEIGHT, "Ranged projectiles should launch from hand/orb height, not the ankles", 0.5 ) girl.queue_free() witch.queue_free() func _check_projectile_visual_rotates_with_direction() -> void: var projectile_scene: PackedScene = load("res://scenes/combat/player_projectile.tscn") _expect(projectile_scene != null, "Projectile scene should load") if projectile_scene == null: return var projectile := projectile_scene.instantiate() as Area2D var direction := Vector2(-0.8, -0.2).normalized() projectile.set("direction", direction) root.add_child(projectile) await process_frame var sprite := projectile.get_node_or_null("Sprite") as Sprite2D _expect(sprite != null, "Projectile should create a Sprite") if sprite != null: _expect_float(float(sprite.rotation), direction.angle(), "Projectile Sprite should rotate along its travel direction") _expect_equal(sprite.flip_h, false, "Projectile Sprite should use rotation rather than horizontal flip for aiming") projectile.queue_free() func _spawn_form(scene: PackedScene, past_form: StringName, future_form: StringName, strength_profile: StringName) -> Node2D: return _spawn_form_under(root, scene, past_form, future_form, strength_profile) func _spawn_form_under(parent: Node, scene: PackedScene, past_form: StringName, future_form: StringName, strength_profile: StringName) -> Node2D: var minion := scene.instantiate() as Node2D minion.set("past_form_id", past_form) minion.set("future_form_id", future_form) minion.set("strength_profile", strength_profile) parent.add_child(minion) return minion func _expect_visual_scale_for_heading(minion: Node, form_id: StringName, heading: Vector2, label: String) -> void: _expect_equal(minion.get("heading"), heading, label) var native_facing: Vector2 = FORM_NATIVE_FACING.get(form_id, Vector2.RIGHT) var expected_sign := 1.0 if heading == native_facing else -1.0 var scale_x := float(minion.get_node("Visual").scale.x) if signf(scale_x) != expected_sign: failures.append("%s: expected visual scale sign %.0f, got %.3f" % [label, expected_sign, scale_x]) func _animation_baseline(animation_player: AnimationPlayer, sprite: Sprite2D, animation_name: StringName) -> float: if not animation_player.has_animation(animation_name): return INF animation_player.play(animation_name) animation_player.seek(0.0, true) animation_player.advance(0.0) return _visible_sprite_bottom_global_y(sprite) func _expect_animation_feet(animation_player: AnimationPlayer, sprite: Sprite2D, animation_name: StringName, baseline: float) -> void: var animation := animation_player.get_animation(animation_name) if animation == null: return var frame_track := _frame_track_index(animation) if frame_track < 0: return for key_index: int in range(animation.track_get_key_count(frame_track)): var key_time := float(animation.track_get_key_time(frame_track, key_index)) animation_player.play(animation_name) animation_player.seek(key_time, true) animation_player.advance(0.0) var feet := _visible_sprite_bottom_global_y(sprite) if absf(feet - baseline) > 0.5: failures.append("%s frame %d should keep feet on %.2f, got %.2f" % [animation_name, key_index, baseline, feet]) func _frame_track_index(animation: Animation) -> int: for track_index: int in range(animation.get_track_count()): if str(animation.track_get_path(track_index)).ends_with(":frame"): return track_index return -1 func _visible_sprite_bottom_global_y(sprite: Sprite2D) -> float: if sprite == null or sprite.texture == null: return INF var image := sprite.texture.get_image() if image == null or image.is_empty(): return INF var hframes := maxi(1, sprite.hframes) var vframes := maxi(1, sprite.vframes) var frame_width := image.get_width() / hframes var frame_height := image.get_height() / vframes var frame_index := clampi(sprite.frame, 0, hframes * vframes - 1) var column := frame_index % hframes var row := int(frame_index / hframes) var bottom := -1 for y: int in range(frame_height): for x: int in range(frame_width): var pixel := image.get_pixel(column * frame_width + x, row * frame_height + y) if pixel.a > 0.01: bottom = y if bottom < 0: return INF return sprite.to_global(sprite.offset + Vector2(0.0, bottom)).y func _visible_sprite_height(minion: Node2D) -> float: var sprite := minion.get_node_or_null("Visual/CharacterSprite") as Sprite2D var visual := minion.get_node_or_null("Visual") as Node2D if sprite == null or visual == null or sprite.texture == null: return 0.0 var bounds := _visible_sprite_bounds_local(sprite) if bounds.size == Vector2.ZERO: return 0.0 return bounds.size.y * absf(visual.scale.y) func _visible_sprite_bounds_local(sprite: Sprite2D) -> Rect2: var image := sprite.texture.get_image() if image == null or image.is_empty(): return Rect2() var hframes := maxi(1, sprite.hframes) var vframes := maxi(1, sprite.vframes) var frame_width := image.get_width() / hframes var frame_height := image.get_height() / vframes var frame_index := clampi(sprite.frame, 0, hframes * vframes - 1) var column := frame_index % hframes var row := int(frame_index / hframes) var left := frame_width var right := -1 var top := frame_height var bottom := -1 for y: int in range(frame_height): for x: int in range(frame_width): var pixel := image.get_pixel(column * frame_width + x, row * frame_height + y) if pixel.a > 0.01: left = mini(left, x) right = maxi(right, x) top = mini(top, y) bottom = maxi(bottom, y) if right < left or bottom < top: return Rect2() return Rect2(Vector2(left, top), Vector2(right - left, bottom - top)) func _ensure_time_phase_manager() -> Node: var manager := root.get_node_or_null("TimePhaseManager") if manager == null: manager = load("res://autoload/time_phase_manager.gd").new() manager.name = "TimePhaseManager" root.add_child(manager) return manager 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_int(actual: int, expected: int, label: String) -> void: if actual != expected: failures.append("%s: expected %d, got %d" % [label, expected, actual]) func _expect_float(actual: float, expected: float, label: String, tolerance := 0.01) -> void: if absf(actual - expected) > tolerance: failures.append("%s: expected %.3f, got %.3f" % [label, expected, actual]) func _expect_vector(actual: Vector2, expected: Vector2, label: String) -> void: if actual.distance_to(expected) > 0.01: failures.append("%s: expected %s, got %s" % [label, expected, actual]) func _finish() -> void: if failures.is_empty(): print("PASS minion cadence and facing") quit(0) else: for failure: String in failures: push_error(failure) quit(1)