extends SceneTree const DriverScript := preload("res://scenes/components/frame_collision_driver.gd") const EmitterScript := preload("res://scenes/components/damage_emitter.gd") const ReceiverScript := preload("res://scenes/components/damage_receiver.gd") const ActionControllerScript := preload("res://scenes/components/action_controller.gd") const ActionDataScript := preload("res://resources/action_data.gd") var failures: Array[String] = [] func _init() -> void: _run.call_deferred() func _run() -> void: var actor := _build_actor() root.add_child(actor) await process_frame var driver: Node = actor.get_node("FrameCollisionDriver") var receiver := actor.get_node("DamageReceiver") as Area2D var receiver_shape := receiver.get_node("CollisionShape2D") as CollisionShape2D var emitter := actor.get_node("DamageEmitter") as Area2D var emitter_shape := emitter.get_node("CollisionShape2D") as CollisionShape2D var sprite := actor.get_node("Visual/CharacterSprite") as Sprite2D var controller: Node = actor.get_node("ActionController") # --- hurtbox follows the displayed frame's opaque bounds --- sprite.frame = 0 driver.call("refresh_now") var body_size := (receiver_shape.shape as RectangleShape2D).size _expect_float(body_size.x, 24.0, "Frame 0 hurtbox width should match the small pose") _expect_float(body_size.y, 40.0, "Frame 0 hurtbox height should match the pose") _expect_vector(receiver_shape.position, Vector2(0.0, -20.0), "Frame 0 hurtbox should centre on the pose") sprite.frame = 2 driver.call("refresh_now") var extended_size := (receiver_shape.shape as RectangleShape2D).size _expect_float(extended_size.x, 44.0, "Frame 2 hurtbox should widen with the extended pose") _expect_bool(extended_size.x > body_size.x, true, "Damage-receive matrix must change per frame") # --- hitbox only monitors on reaching ACTIVE frames --- var action: Resource = ActionDataScript.new() action.set("hit_type", &"melee") action.set("range", 40.0) emitter.call("configure_hit", action, {"label": "perfect"}) controller.set("phase", ActionControllerScript.Phase.ACTIVE) sprite.frame = 0 driver.call("refresh_now") _expect_bool(emitter.monitoring, false, "Windup frame (no forward reach) must not send damage") sprite.frame = 2 driver.call("refresh_now") _expect_bool(emitter.monitoring, true, "Weapon-extended frame must send damage") _expect_bool(emitter_shape.position.x < 0.0, true, "Facing left, the hit shape extends left") _expect_bool((emitter_shape.shape as RectangleShape2D).size.x >= 30.0, true, "Hit reach should cover max(pose reach, action range)") # recovery: leaving ACTIVE closes the window on the same frame controller.set("phase", ActionControllerScript.Phase.RECOVERY) driver.call("refresh_now") _expect_bool(emitter.monitoring, false, "Leaving ACTIVE must close the hit window") controller.set("phase", ActionControllerScript.Phase.ACTIVE) # --- mirroring follows heading/Visual flip --- actor.set("heading", Vector2.RIGHT) (actor.get_node("Visual") as Node2D).scale.x = -2.0 driver.call("refresh_now") _expect_bool(emitter.monitoring, true, "Mirrored reach frame still sends damage") _expect_bool(emitter_shape.position.x > 0.0, true, "Facing right, the hit shape extends right") # --- body matrix is re-asserted every tick --- actor.collision_layer = 5 actor.collision_mask = 99 driver.call("refresh_now") _expect_int(actor.collision_layer, 32, "Body collision layer must be re-applied per frame") _expect_int(actor.collision_mask, 65, "Body collision mask must be re-applied per frame") # --- one hit per swing even with per-frame windows --- var target := Area2D.new() target.add_to_group("damage_receivers") root.add_child(target) emitter.call("_on_area_entered", target) emitter.call("_on_area_entered", target) _expect_bool(emitter.call("has_already_hit", target), true, "Emitter should remember receivers hit this swing") emitter.call("clear_hit") _expect_bool(emitter.call("has_already_hit", target), false, "clear_hit should reset the per-swing memory") target.free() actor.free() _finish() func _build_actor() -> CharacterBody2D: var actor_script := GDScript.new() actor_script.source_code = "extends CharacterBody2D\nvar heading := Vector2.LEFT\n" actor_script.reload() var actor := CharacterBody2D.new() actor.set_script(actor_script) actor.collision_layer = 32 actor.collision_mask = 65 var visual := Node2D.new() visual.name = "Visual" visual.scale = Vector2(2, 2) actor.add_child(visual) var sprite := Sprite2D.new() sprite.name = "CharacterSprite" sprite.centered = false sprite.offset = Vector2(-16, -32) sprite.hframes = 4 sprite.vframes = 1 sprite.texture = _build_sheet() visual.add_child(sprite) var emitter := Area2D.new() emitter.name = "DamageEmitter" emitter.set_script(EmitterScript) emitter.collision_layer = 8 emitter.collision_mask = 4 emitter.monitoring = false var emitter_shape := CollisionShape2D.new() emitter_shape.name = "CollisionShape2D" emitter_shape.shape = RectangleShape2D.new() emitter.add_child(emitter_shape) actor.add_child(emitter) var receiver := Area2D.new() receiver.name = "DamageReceiver" receiver.set_script(ReceiverScript) receiver.collision_layer = 2 receiver.collision_mask = 16 var receiver_shape := CollisionShape2D.new() receiver_shape.name = "CollisionShape2D" receiver_shape.shape = RectangleShape2D.new() receiver.add_child(receiver_shape) actor.add_child(receiver) var controller_script := GDScript.new() controller_script.source_code = "extends Node\nvar phase := 0\n" controller_script.reload() var controller := Node.new() controller.name = "ActionController" controller.set_script(controller_script) actor.add_child(controller) var driver := Node.new() driver.name = "FrameCollisionDriver" driver.set_script(DriverScript) driver.set("hurt_margin", Vector2.ZERO) actor.add_child(driver) return actor func _build_sheet() -> ImageTexture: # 4 frames of 32x32. Frame 0: compact body (x 10..21). Frame 2: weapon # extended to the native-left edge (x 0..21). Frames 1/3 stay empty. var image := Image.create(128, 32, false, Image.FORMAT_RGBA8) image.fill(Color(0, 0, 0, 0)) for y: int in range(12, 32): for x: int in range(10, 22): image.set_pixel(x, y, Color.WHITE) for x: int in range(64, 86): image.set_pixel(x, y, Color.WHITE) return ImageTexture.create_from_image(image) func _expect_bool(actual: bool, expected: bool, 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) -> void: if absf(actual - expected) > 0.5: failures.append("%s: expected %.2f, got %.2f" % [label, expected, actual]) func _expect_vector(actual: Vector2, expected: Vector2, label: String) -> void: if actual.distance_to(expected) > 0.75: failures.append("%s: expected %s, got %s" % [label, expected, actual]) func _finish() -> void: if failures.is_empty(): print("PASS frame collision driver") quit(0) else: for failure: String in failures: push_error(failure) quit(1)