191 lines
6.7 KiB
GDScript
191 lines
6.7 KiB
GDScript
class_name PlayerProjectile
|
|
extends Area2D
|
|
|
|
const EFFECT_TEXTURE := preload("res://assets/art/effects/effect_sheet.png")
|
|
# effect_sheet.png is a 6x2 grid of 32x32 cells; row one holds four flight frames
|
|
# (cells four and five are empty), row two holds the impact burst.
|
|
const SHEET_HFRAMES := 6
|
|
const SHEET_VFRAMES := 2
|
|
const FLIGHT_FRAME_COUNT := 4
|
|
const FLIGHT_FPS := 16.0
|
|
const VANISH_FRAME := 4
|
|
const HIT_LEAD_DISTANCE := 18.0
|
|
const DEFAULT_MAX_RANGE := 460.0
|
|
|
|
# The player blade wave uses the author's PROJECTILE sheet (5 frames, 33fps loop).
|
|
const WAVE_TEXTURE_PATH := "res://assets/art/characters/player/11_blade_wave/projectile.png"
|
|
# collision_radius keeps the hit circle inside the visible art: the effect_sheet
|
|
# flight frames show a ~32x18px bolt at scale 2 (radius 9 = half its height),
|
|
# the wave sheet's smallest frame is ~66x65px at scale 0.55 (radius 32 fits).
|
|
const VISUAL_PROFILES := {
|
|
&"bullet": {"hframes": SHEET_HFRAMES, "vframes": SHEET_VFRAMES, "first": 0, "last": FLIGHT_FRAME_COUNT - 1, "fps": FLIGHT_FPS, "scale": 2.0, "vanish_frame": VANISH_FRAME, "collision_radius": 9.0},
|
|
&"wave": {"path": WAVE_TEXTURE_PATH, "hframes": 5, "vframes": 1, "first": 0, "last": 4, "fps": 33.0, "scale": 0.55, "vanish_frame": -1, "collision_radius": 32.0},
|
|
}
|
|
|
|
@export var damage := 100
|
|
@export var hit_type: StringName = &"projectile"
|
|
@export var base_knockback := Vector2.ZERO
|
|
|
|
## Snapshotted when fired: weak-state enemy projectiles deal damage but do not
|
|
## interrupt, while Boss/player projectiles keep full authority.
|
|
var attacker_interrupts := true
|
|
var source_actor: Node = null
|
|
var action_context: Resource
|
|
var judgement_context: Dictionary = {"label": "perfect"}
|
|
var direction := Vector2.RIGHT
|
|
var speed := 520.0
|
|
var max_range := DEFAULT_MAX_RANGE
|
|
var visual_profile: StringName = &"bullet"
|
|
var _travelled := 0.0
|
|
var _age := 0.0
|
|
var _sprite: Sprite2D
|
|
var _hit_confirmed := false
|
|
|
|
|
|
func _init() -> void:
|
|
_create_sprite()
|
|
|
|
|
|
func _ready() -> void:
|
|
if _sprite == null:
|
|
_create_sprite()
|
|
_apply_visual_profile()
|
|
if not area_entered.is_connected(_on_area_entered):
|
|
area_entered.connect(_on_area_entered)
|
|
|
|
|
|
func _apply_visual_profile() -> void:
|
|
if _sprite == null:
|
|
return
|
|
var profile := _visual()
|
|
var texture_path := str(profile.get("path", ""))
|
|
if not texture_path.is_empty() and ResourceLoader.exists(texture_path):
|
|
_sprite.texture = load(texture_path)
|
|
else:
|
|
_sprite.texture = EFFECT_TEXTURE
|
|
_sprite.hframes = maxi(1, int(profile.get("hframes", 1)))
|
|
_sprite.vframes = maxi(1, int(profile.get("vframes", 1)))
|
|
var profile_scale := float(profile.get("scale", 1.0))
|
|
_sprite.scale = Vector2(profile_scale, profile_scale)
|
|
_sprite.frame = clampi(int(profile.get("first", 0)), 0, _sprite.hframes * _sprite.vframes - 1)
|
|
_apply_collision_profile(profile)
|
|
|
|
|
|
func _apply_collision_profile(profile: Dictionary) -> void:
|
|
var shape_node := get_node_or_null("CollisionShape2D") as CollisionShape2D
|
|
if shape_node == null or not (shape_node.shape is CircleShape2D):
|
|
return
|
|
# The scene's CircleShape2D is a shared subresource: duplicate before sizing
|
|
# per profile so bullet and wave instances never overwrite each other.
|
|
var circle := (shape_node.shape as CircleShape2D).duplicate() as CircleShape2D
|
|
circle.radius = maxf(1.0, float(profile.get("collision_radius", circle.radius)))
|
|
shape_node.shape = circle
|
|
|
|
|
|
func _visual() -> Dictionary:
|
|
return VISUAL_PROFILES.get(visual_profile, VISUAL_PROFILES[&"bullet"])
|
|
|
|
|
|
func _create_sprite() -> void:
|
|
if _sprite != null and is_instance_valid(_sprite):
|
|
return
|
|
_sprite = Sprite2D.new()
|
|
_sprite.name = "Sprite"
|
|
_sprite.texture = EFFECT_TEXTURE
|
|
_sprite.hframes = SHEET_HFRAMES
|
|
_sprite.vframes = SHEET_VFRAMES
|
|
_sprite.centered = true
|
|
_sprite.scale = Vector2(2.0, 2.0)
|
|
add_child(_sprite)
|
|
|
|
|
|
func _process(delta: float) -> void:
|
|
_age += delta
|
|
if not _hit_confirmed:
|
|
var profile := _visual()
|
|
var first := int(profile.get("first", 0))
|
|
var span := maxi(1, int(profile.get("last", first)) - first + 1)
|
|
_sprite.frame = first + int(_age * float(profile.get("fps", FLIGHT_FPS))) % span
|
|
_sprite.rotation = direction.angle()
|
|
_sprite.flip_h = false
|
|
|
|
|
|
func _physics_process(delta: float) -> void:
|
|
if _hit_confirmed:
|
|
return
|
|
var movement := direction.normalized() * speed * delta
|
|
var receiver := _swept_receiver(movement)
|
|
if receiver != null:
|
|
_on_area_entered(receiver)
|
|
return
|
|
position += movement
|
|
_travelled += movement.length()
|
|
if _travelled >= max_travel_distance():
|
|
queue_free()
|
|
|
|
|
|
func _on_area_entered(receiver: Area2D) -> void:
|
|
if _hit_confirmed:
|
|
return
|
|
if receiver.is_in_group("enemy_projectiles") and is_in_group("player_projectiles"):
|
|
receiver.queue_free()
|
|
return
|
|
if not receiver.is_in_group("damage_receivers"):
|
|
return
|
|
_hit_confirmed = true
|
|
_vanish_after_hit()
|
|
var combat := _combat_manager_or_null()
|
|
if combat != null and combat.has_method("resolve_hit"):
|
|
combat.call("resolve_hit", self, receiver)
|
|
queue_free()
|
|
|
|
|
|
func _combat_manager_or_null() -> Node:
|
|
if not is_inside_tree():
|
|
return null
|
|
return get_tree().root.get_node_or_null("CombatManager")
|
|
|
|
|
|
func max_travel_distance() -> float:
|
|
return max_range if max_range > 0.0 else DEFAULT_MAX_RANGE
|
|
|
|
|
|
func _vanish_after_hit() -> void:
|
|
set_deferred("monitoring", false)
|
|
set_deferred("monitorable", false)
|
|
visible = false
|
|
var vanish_frame := int(_visual().get("vanish_frame", -1))
|
|
if _sprite != null and vanish_frame >= 0:
|
|
_sprite.frame = vanish_frame
|
|
|
|
|
|
func _swept_receiver(movement: Vector2) -> Area2D:
|
|
if movement == Vector2.ZERO or not is_inside_tree():
|
|
return null
|
|
var travel_direction := movement.normalized()
|
|
var shape_node := get_node_or_null("CollisionShape2D") as CollisionShape2D
|
|
if shape_node != null and shape_node.shape != null:
|
|
var shape_query := PhysicsShapeQueryParameters2D.new()
|
|
shape_query.shape = shape_node.shape
|
|
shape_query.transform = shape_node.global_transform.translated(travel_direction * HIT_LEAD_DISTANCE + movement)
|
|
shape_query.collision_mask = collision_mask
|
|
shape_query.collide_with_areas = true
|
|
shape_query.collide_with_bodies = false
|
|
shape_query.exclude = [get_rid()]
|
|
for hit: Dictionary in get_world_2d().direct_space_state.intersect_shape(shape_query, 8):
|
|
var shape_collider = hit.get("collider", null)
|
|
if shape_collider is Area2D and shape_collider.is_in_group("damage_receivers"):
|
|
return shape_collider
|
|
var query := PhysicsRayQueryParameters2D.create(
|
|
global_position + travel_direction * HIT_LEAD_DISTANCE,
|
|
global_position + travel_direction * HIT_LEAD_DISTANCE + movement,
|
|
collision_mask
|
|
)
|
|
query.collide_with_areas = true
|
|
query.collide_with_bodies = false
|
|
var hit := get_world_2d().direct_space_state.intersect_ray(query)
|
|
var collider = hit.get("collider", null)
|
|
if collider is Area2D and collider.is_in_group("damage_receivers"):
|
|
return collider
|
|
return null
|