Files
rthythm_archor_game/scenes/components/frame_collision_driver.gd
T
2026-07-06 22:59:47 -07:00

318 lines
13 KiB
GDScript

class_name FrameCollisionDriver
extends Node
## Drives the three collision matrices from the CURRENT animation frame, every
## physics tick, for both the player and the boss:
## body - CharacterBody2D collision_layer/mask (composed with the
## MotionExecutor dash-through ghost bits so nothing goes stale),
## hurtbox - DamageReceiver shape follows the opaque pixel bounds of the
## frame being displayed (pose-accurate damage-receive matrix),
## hitbox - DamageEmitter only monitors on ACTIVE-phase frames whose art
## reaches forward past HIT_REACH_GATE of the sheet's own maximum
## (best-effort per sheet; action range stays the damage floor).
## If no frame of a swing ever passes the gate, the back half of
## the ACTIVE phase falls back to the plain range window so a
## beat-anchored swing can never whiff purely from art timing.
## Facing is derived per tick from heading x visual mirroring, so native-left
## sheets (player) and native-right sheets (boss) both measure true forward
## reach. Frame bounds are computed once per sheet in a single byte-level pass
## and cached statically; per-tick work is table lookups plus rect transforms.
const ActionControllerScript := preload("res://scenes/components/action_controller.gd")
const MIN_HURT_SIZE := Vector2(12.0, 16.0)
const HIT_REACH_GATE := 0.72
const HIT_MIN_FORWARD_PX := 18.0
const HIT_HEIGHT_FACTOR := 0.8
const HIT_START_OFFSET_PX := 6.0
@export var sprite_path: NodePath = ^"../Visual/CharacterSprite"
@export var damage_emitter_path: NodePath = ^"../DamageEmitter"
@export var damage_receiver_path: NodePath = ^"../DamageReceiver"
@export var action_controller_path: NodePath = ^"../ActionController"
@export var motion_executor_path: NodePath = ^"../MotionExecutor"
@export var body_collision_enabled := true
@export var damage_receiver_enabled := true
@export var damage_emitter_enabled := true
@export var hurtbox_enabled := true
@export var hitbox_enabled := true
## Actor-space pixels trimmed from the opaque bounds (total per axis) so
## clothing fringes and glow pixels do not count as body.
@export var hurt_margin := Vector2(12.0, 8.0)
var actor: CharacterBody2D
var sprite: Sprite2D
var emitter: Area2D
var receiver: Area2D
var controller: Node
var motion_executor: Node
var _base_body_layer := 0
var _base_body_mask := 0
var _base_emitter_layer := 0
var _base_emitter_mask := 0
var _base_receiver_layer := 0
var _base_receiver_mask := 0
var _swing_action: Resource
var _swing_had_hit_window := false
static var _sheet_bounds_cache: Dictionary = {}
func _ready() -> void:
# Run after ActionController/ActionExecutor state changes within the tick,
# so this component owns the final matrix values for the physics step.
process_physics_priority = 20
actor = get_parent() as CharacterBody2D
sprite = get_node_or_null(sprite_path) as Sprite2D
emitter = get_node_or_null(damage_emitter_path) as Area2D
receiver = get_node_or_null(damage_receiver_path) as Area2D
controller = get_node_or_null(action_controller_path)
motion_executor = get_node_or_null(motion_executor_path)
if actor != null:
_base_body_layer = actor.collision_layer
_base_body_mask = actor.collision_mask
if emitter != null:
_base_emitter_layer = emitter.collision_layer
_base_emitter_mask = emitter.collision_mask
_make_shape_unique(emitter)
if receiver != null:
_base_receiver_layer = receiver.collision_layer
_base_receiver_mask = receiver.collision_mask
_make_shape_unique(receiver)
func _physics_process(_delta: float) -> void:
refresh_now()
func refresh_now() -> void:
_apply_body_matrix()
var frame_rect := _current_frame_actor_rect()
if hurtbox_enabled:
_apply_hurtbox(frame_rect)
if hitbox_enabled:
_apply_hitbox(frame_rect)
func _apply_body_matrix() -> void:
if actor == null:
return
var layer := _base_body_layer
var mask := _base_body_mask
if not body_collision_enabled:
layer &= ~MotionExecutor.BODY_GHOST_BITS
mask &= ~MotionExecutor.BODY_GHOST_BITS
if motion_executor != null and motion_executor.has_method("is_ghosting") and motion_executor.is_ghosting():
layer &= ~MotionExecutor.BODY_GHOST_BITS
mask &= ~MotionExecutor.BODY_GHOST_BITS
actor.collision_layer = layer
actor.collision_mask = mask
func _apply_hurtbox(frame_rect: Rect2) -> void:
if receiver == null:
return
receiver.collision_layer = _base_receiver_layer
receiver.collision_mask = _base_receiver_mask
if not damage_receiver_enabled:
receiver.collision_layer = 0
receiver.collision_mask = 0
receiver.monitoring = false
receiver.monitorable = false
return
if _actor_is_dead():
receiver.monitoring = false
receiver.monitorable = false
return
receiver.monitoring = true
receiver.monitorable = true
if frame_rect.size == Vector2.ZERO:
return
var shape_node := receiver.get_node_or_null("CollisionShape2D") as CollisionShape2D
if shape_node == null or not (shape_node.shape is RectangleShape2D):
return
receiver.position = Vector2.ZERO
var size := frame_rect.size - hurt_margin
size = Vector2(maxf(MIN_HURT_SIZE.x, size.x), maxf(MIN_HURT_SIZE.y, size.y))
(shape_node.shape as RectangleShape2D).size = size
shape_node.position = frame_rect.get_center()
func _apply_hitbox(frame_rect: Rect2) -> void:
if emitter == null:
return
emitter.collision_layer = _base_emitter_layer
emitter.collision_mask = _base_emitter_mask
if not damage_emitter_enabled or _actor_is_dead():
emitter.collision_layer = 0 if not damage_emitter_enabled else _base_emitter_layer
emitter.collision_mask = 0 if not damage_emitter_enabled else _base_emitter_mask
emitter.monitoring = false
return
if controller == null or actor == null or sprite == null or sprite.texture == null:
# Without a phase source the emitter keeps its own configure/clear
# window behaviour (projectiles, tests, detached components).
return
emitter.position = Vector2.ZERO
var action: Resource = emitter.get("action_context") as Resource
var in_active: bool = int(controller.get("phase")) == ActionControllerScript.Phase.ACTIVE
var is_melee := action != null and StringName(str(action.get("hit_type"))) == &"melee"
if not in_active or not is_melee or frame_rect.size == Vector2.ZERO:
emitter.monitoring = false
_swing_action = null
_swing_had_hit_window = false
return
if action != _swing_action:
_swing_action = action
_swing_had_hit_window = false
var heading_x := 1.0
var heading: Variant = actor.get("heading")
if heading is Vector2 and absf((heading as Vector2).x) > 0.0:
heading_x = signf((heading as Vector2).x)
var hframes := maxi(1, sprite.hframes)
var vframes := maxi(1, sprite.vframes)
var frame := clampi(sprite.frame, 0, hframes * vframes - 1)
var anchor_px := _actor_anchor_in_sheet_pixels()
var bounds := _frame_opaque_bounds(sprite.texture, hframes, vframes, frame)
if bounds.size == Vector2.ZERO:
emitter.monitoring = false
return
# Which native side of the sheet is "forward" depends on both the heading
# and the Visual mirror: sprite-native +x maps to actor sign(basis_x).
var basis_x := (actor.global_transform.affine_inverse() * sprite.global_transform).x.x
var forward_is_native_left := (basis_x * heading_x) < 0.0
var native_forward := (anchor_px.x - bounds.position.x) if forward_is_native_left else (bounds.end.x - anchor_px.x)
var sheet_max := _sheet_max_native_extent(sprite.texture, hframes, vframes, anchor_px.x, forward_is_native_left)
var pixel_scale := frame_rect.size.x / maxf(1.0, bounds.size.x)
var forward_px := native_forward * pixel_scale
var reaches := sheet_max > 0.0 and native_forward >= HIT_REACH_GATE * sheet_max and forward_px >= HIT_MIN_FORWARD_PX
if not reaches:
if _swing_had_hit_window or not _in_late_active_fallback():
emitter.monitoring = false
return
# Fallback: no frame of this swing has reached yet and ACTIVE is half
# over — guarantee the plain range window (old behaviour floor) so a
# beat-anchored swing cannot whiff purely from art timing.
forward_px = 0.0
else:
_swing_had_hit_window = true
var reach := maxf(forward_px, float(action.get("range")))
if reach <= HIT_START_OFFSET_PX:
emitter.monitoring = false
return
var height := clampf(frame_rect.size.y * HIT_HEIGHT_FACTOR, 24.0, 140.0)
var shape_node := emitter.get_node_or_null("CollisionShape2D") as CollisionShape2D
if shape_node != null and shape_node.shape is RectangleShape2D:
var width := maxf(8.0, reach - HIT_START_OFFSET_PX)
(shape_node.shape as RectangleShape2D).size = Vector2(width, height)
shape_node.position = Vector2(heading_x * (HIT_START_OFFSET_PX + width * 0.5), frame_rect.get_center().y)
emitter.monitoring = true
func _actor_is_dead() -> bool:
if actor == null:
return false
var state_machine := actor.get_node_or_null("StateMachine")
if state_machine != null and state_machine.has_method("build_context"):
return StringName(str(state_machine.call("build_context").get("life_state", &"Alive"))) == &"Dead"
var health := actor.get_node_or_null("HealthComponent")
return health != null and int(health.get("current")) <= 0
func _in_late_active_fallback() -> bool:
var duration_value: Variant = controller.get("phase_duration")
var elapsed_value: Variant = controller.get("phase_elapsed")
if not (duration_value is float) or not (elapsed_value is float):
return false
var duration := duration_value as float
return duration > 0.0 and (elapsed_value as float) >= duration * 0.5
func _current_frame_actor_rect() -> Rect2:
if sprite == null or sprite.texture == null or actor == null or not sprite.is_inside_tree():
return Rect2()
var hframes := maxi(1, sprite.hframes)
var vframes := maxi(1, sprite.vframes)
var frame := clampi(sprite.frame, 0, hframes * vframes - 1)
var bounds := _frame_opaque_bounds(sprite.texture, hframes, vframes, frame)
if bounds.size == Vector2.ZERO:
return Rect2()
var local_origin := _sprite_pixel_origin() + bounds.position
var to_actor := actor.global_transform.affine_inverse() * sprite.global_transform
var corner_a := to_actor * local_origin
var corner_b := to_actor * (local_origin + bounds.size)
return Rect2(
Vector2(minf(corner_a.x, corner_b.x), minf(corner_a.y, corner_b.y)),
(corner_b - corner_a).abs()
)
func _sprite_pixel_origin() -> Vector2:
# Sprite-local coordinate of the sheet frame's top-left drawn pixel.
if sprite.centered:
return sprite.offset - _frame_size() * 0.5
return sprite.offset
func _actor_anchor_in_sheet_pixels() -> Vector2:
# The actor origin expressed in drawn-pixel coordinates of the frame.
var to_sprite := sprite.global_transform.affine_inverse() * actor.global_transform
return (to_sprite * Vector2.ZERO) - _sprite_pixel_origin()
func _frame_size() -> Vector2:
return Vector2(
float(sprite.texture.get_width()) / float(maxi(1, sprite.hframes)),
float(sprite.texture.get_height()) / float(maxi(1, sprite.vframes))
)
func _make_shape_unique(area: Area2D) -> void:
var shape_node := area.get_node_or_null("CollisionShape2D") as CollisionShape2D
if shape_node != null and shape_node.shape != null:
shape_node.shape = shape_node.shape.duplicate()
static func _frame_opaque_bounds(texture: Texture2D, hframes: int, vframes: int, frame: int) -> Rect2:
var all_bounds := _sheet_frame_bounds(texture, hframes, vframes)
if frame < 0 or frame >= all_bounds.size():
return Rect2()
return all_bounds[frame]
static func _sheet_max_native_extent(texture: Texture2D, hframes: int, vframes: int, anchor_x: float, left_side: bool) -> float:
var best := 0.0
for bounds: Rect2 in _sheet_frame_bounds(texture, hframes, vframes):
if bounds.size == Vector2.ZERO:
continue
var extent := (anchor_x - bounds.position.x) if left_side else (bounds.end.x - anchor_x)
best = maxf(best, extent)
return best
static func _sheet_frame_bounds(texture: Texture2D, hframes: int, vframes: int) -> Array:
# All frames of a sheet are measured once and cached. The per-frame crop +
# used-rect run on the C++ side, so even large sheets cost well under a
# millisecond and there is no first-swing hitch.
var key := "%d#%d#%d" % [texture.get_rid().get_id(), hframes, vframes]
if _sheet_bounds_cache.has(key):
return _sheet_bounds_cache[key]
var result: Array = []
var image := texture.get_image()
if image != null and not image.is_empty():
if image.is_compressed():
image.decompress()
var frame_width := maxi(1, image.get_width() / hframes)
var frame_height := maxi(1, image.get_height() / vframes)
for index: int in range(hframes * vframes):
var column := index % hframes
var row := int(float(index) / float(hframes))
var region := image.get_region(Rect2i(column * frame_width, row * frame_height, frame_width, frame_height))
var used := region.get_used_rect()
if used.size.x <= 0 or used.size.y <= 0:
result.append(Rect2())
else:
result.append(Rect2(used))
_sheet_bounds_cache[key] = result
return result