318 lines
13 KiB
GDScript
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
|