Initial project sync
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user