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