#130 asked for the hull to break away from the fleet, drift a few metres, and go up further off with something more explosive than a light animation. The wreck is a separate node rather than the Ship staying alive to animate, and that is the load-bearing part. A dying Ship still in the tree is still an Area2D in the player_ships group with live weapon components - it would keep firing, keep being fired at, keep counting as a target, and keep colliding on the way out. So the ship's own lifecycle is untouched: it leaves the fleet, emits destroyed, and is freed on exactly the frame it always was. What is left behind is scenery, and the test pins that. The explosion at the end was also the quieter of the two the game has. Killing an enemy was made deliberately loud by #87 while losing one of your own kept the base class's twelve-particle default - the reward louder than the loss. It is now the bigger of the two. #135 is the same scene from the other end: the prologue's convoy flies its escorts on the corners of the box, which is the exposed part of it, and the debris belt can take all four. With no interceptor left, the only hulls the vortex could spare were holds, and the player flew out of the prologue in a carrier. Two guards now: one interceptor is put back into the convoy at the top of the vortex, where a vessel appearing among ten being thrown around a black hole is not something anyone will catch; and the handover checks the TYPES it hands over rather than only the count - one surviving carrier is also a fleet of one, and it is not the fleet a new game starts with.
216 lines
8.7 KiB
GDScript
216 lines
8.7 KiB
GDScript
extends Area2D
|
|
|
|
signal destroyed
|
|
|
|
const VESSEL_DEFINITIONS = preload("res://vessels/VesselDefinitions.gd")
|
|
|
|
@export var max_speed: float = 460.0
|
|
@export var acceleration: float = 450.0
|
|
@export var rotation_speed: float = 8.0
|
|
@export var arrive_radius: float = 6.0
|
|
@export var vessel_type: String = "interceptor"
|
|
|
|
var definition: Resource
|
|
var glow_color: Color = Color(0.25, 0.85, 1.0)
|
|
var core_color: Color = Color.WHITE
|
|
# The ship's TRUE world velocity - what `position` is actually integrated
|
|
# from, and the single source of truth for how fast this vessel is moving.
|
|
# Nothing else may contribute motion; see the note in _physics_process.
|
|
var velocity: Vector2 = Vector2.ZERO
|
|
# The velocity of this ship's formation SLOT (the fleet anchor's own travel).
|
|
# Fed forward into the steering below rather than added on top of it: matching
|
|
# the slot's speed is most of the job of holding formation, so supplying it
|
|
# directly leaves `velocity` free to be a small correction and lets the ship
|
|
# sit in its slot with no steady-state lag and no teleporting.
|
|
var fleet_velocity: Vector2 = Vector2.ZERO
|
|
var move_target: Vector2
|
|
|
|
# Compatibility accessors keep old saves/tests readable while shield state
|
|
# itself belongs exclusively to the optional ShieldComponent.
|
|
var shield_charges: int:
|
|
get:
|
|
var component: Node = get_component("shield")
|
|
return component.charges if component != null else 0
|
|
set(value):
|
|
var component: Node = get_component("shield")
|
|
if component != null:
|
|
component.set_charges(value)
|
|
|
|
var shield_recharge_left: float:
|
|
get:
|
|
var component: Node = get_component("shield")
|
|
return component.recharge_left if component != null else 0.0
|
|
set(value):
|
|
var component: Node = get_component("shield")
|
|
if component != null:
|
|
component.recharge_left = value
|
|
|
|
var projectile_damage: int:
|
|
get:
|
|
var component: Node = get_component("weapon")
|
|
return component.projectile_damage if component != null else 0
|
|
|
|
|
|
func _ready() -> void:
|
|
move_target = position
|
|
add_to_group("player_ships")
|
|
area_entered.connect(_on_area_entered)
|
|
definition = VESSEL_DEFINITIONS.get_definition(vessel_type)
|
|
if definition == null:
|
|
return
|
|
glow_color = definition.glow_color
|
|
for component_scene in definition.component_scenes:
|
|
var component: Node = component_scene.instantiate()
|
|
add_child(component)
|
|
if component.has_method("setup"):
|
|
component.setup()
|
|
apply_vessel_stats()
|
|
queue_redraw()
|
|
|
|
|
|
func get_component(capability: String) -> Node:
|
|
for child in get_children():
|
|
if child.get("capability_id") == capability:
|
|
return child
|
|
return null
|
|
|
|
|
|
func has_capability(capability: String) -> bool:
|
|
return get_component(capability) != null
|
|
|
|
|
|
func set_move_target(world_pos: Vector2) -> void:
|
|
move_target = world_pos
|
|
|
|
|
|
func apply_vessel_stats() -> void:
|
|
if definition == null:
|
|
definition = VESSEL_DEFINITIONS.get_definition(vessel_type)
|
|
if definition == null:
|
|
return
|
|
max_speed = GameState.vessel_stat(vessel_type, "max_speed")
|
|
acceleration = GameState.vessel_stat(vessel_type, "acceleration")
|
|
for child in get_children():
|
|
if child.has_method("apply_stats"):
|
|
child.apply_stats()
|
|
|
|
|
|
func has_shield() -> bool:
|
|
var component: Node = get_component("shield")
|
|
return component != null and component.is_active()
|
|
|
|
|
|
func _physics_process(delta: float) -> void:
|
|
for child in get_children():
|
|
if child.has_method("tick"):
|
|
child.tick(delta)
|
|
|
|
# #59: multiplied here, at the point of use, rather than baked into the
|
|
# cached max_speed/acceleration fields apply_vessel_stats() sets - those
|
|
# stay the true base values, so a stat upgrade researched mid-boost can't
|
|
# corrupt them, and the fader takes effect the instant it moves with no
|
|
# re-apply needed. arrive_radius is scaled too: at N physics ticks/sec a
|
|
# ship's per-tick step is `speed * delta`, and once that step outgrows
|
|
# the (otherwise fixed) arrive_radius it can overshoot the snap-to-target
|
|
# zone every tick while chasing a target that is ALSO now receding N
|
|
# times faster - a small, boost-proportional radius keeps the same
|
|
# "snaps onto its slot" formation tightness at any boost level that
|
|
# 1.0x already has.
|
|
var boost: float = GameState.fleet_speed_multiplier
|
|
var effective_acceleration: float = acceleration * boost
|
|
var to_target: Vector2 = move_target - position
|
|
var distance: float = to_target.length()
|
|
|
|
# Snap only when the slot is STANDING STILL. A stationary target is the
|
|
# one case where converging asymptotically would leave a permanent
|
|
# sub-pixel residue, and settling exactly is what "the fleet has arrived"
|
|
# means everywhere else in the game. While the slot is moving the ship
|
|
# steers continuously instead - a snap there would be a teleport, which
|
|
# is what used to make `velocity` read zero for a ship that was in fact
|
|
# travelling at cruise speed, and that lie is what the whole
|
|
# stop()/fleet_velocity bookkeeping tangle existed to paper over.
|
|
if fleet_velocity == Vector2.ZERO and distance <= arrive_radius * boost:
|
|
position = move_target
|
|
velocity = Vector2.ZERO
|
|
else:
|
|
# Feed-forward plus correction. The ship aims to travel at its slot's
|
|
# velocity, plus whatever extra closes the gap to the slot - so
|
|
# holding formation costs no error, and `velocity` stays the ship's
|
|
# real motion rather than a residual that has to be added to
|
|
# something else to mean anything.
|
|
var correction: Vector2 = Vector2.ZERO
|
|
if distance > 0.001:
|
|
var braking_speed: float = sqrt(2.0 * effective_acceleration * distance)
|
|
correction = to_target.normalized() * min(max_speed * boost, braking_speed)
|
|
var desired_velocity: Vector2 = fleet_velocity + correction
|
|
# HARD BOUND on how fast any single vessel may travel, applied to the
|
|
# total and re-applied every tick. Feed-forward plus correction can
|
|
# ask for more than a vessel can actually fly, and without this the
|
|
# excess was free speed: a ship that had been handed a large velocity
|
|
# kept it, decaying only at `acceleration`. Clamping the REQUEST is
|
|
# not enough - the clamp has to sit on the integrated result, so no
|
|
# sequence of commands can leave a vessel travelling faster than its
|
|
# own top speed.
|
|
desired_velocity = desired_velocity.limit_length(max_speed * boost)
|
|
velocity = velocity.move_toward(desired_velocity, effective_acceleration * delta)
|
|
velocity = velocity.limit_length(max_speed * boost)
|
|
position += velocity * delta
|
|
|
|
if velocity.length() > 8.0:
|
|
var target_angle: float = velocity.angle() + PI / 2.0
|
|
rotation = lerp_angle(rotation, target_angle, rotation_speed * delta)
|
|
|
|
|
|
func take_damage(_amount: int) -> bool:
|
|
# Ships have no hit points - any unabsorbed hit is lethal. A shield
|
|
# charge absorbs a hit entirely regardless of its amount, matching
|
|
# VesselShieldComponent's existing all-or-nothing semantics; `_amount`
|
|
# is accepted (not `int`-clamped away) so callers like EnemyProjectile
|
|
# have a normal damage parameter to pass even though it isn't graded here.
|
|
for child in get_children():
|
|
if child.has_method("absorb_hit") and child.absorb_hit():
|
|
return true
|
|
# #130: the hull comes off the formation and explodes further out, rather
|
|
# than going up on the spot. The wreck is its own node - this one is
|
|
# still freed on this frame, so nothing about who is in the fleet, who
|
|
# can be shot, or who is a target changes.
|
|
ShipWreckVFX.spawn(get_tree().root, self, _breakaway_direction())
|
|
destroyed.emit()
|
|
queue_free()
|
|
return false
|
|
|
|
|
|
# Which way is "out of the fleet". Away from the formation's own centre, so a
|
|
# wreck leaves the wedge instead of tumbling back through it; a ship flying
|
|
# alone - or one not under a Fleet at all - simply carries on the way it was
|
|
# already pointed.
|
|
func _breakaway_direction() -> Vector2:
|
|
var parent: Node = get_parent()
|
|
if parent != null and parent.has_method("get_centroid"):
|
|
var out: Vector2 = position - (parent.get_centroid() as Vector2)
|
|
if out.length() > 1.0:
|
|
return out.normalized()
|
|
if velocity.length() > 1.0:
|
|
return velocity.normalized()
|
|
return Vector2.UP.rotated(rotation)
|
|
|
|
|
|
func _on_area_entered(area: Area2D) -> void:
|
|
if not (area.is_in_group("enemies") or area.is_in_group("asteroids")):
|
|
return
|
|
# The enemy is told about the impact BEFORE this ship resolves its own
|
|
# damage, because resolving it can free this node - and a ship that dies
|
|
# to a ramming chaser must still take the chaser with it. Whether the
|
|
# impact actually destroys the enemy is the enemy's decision (#82).
|
|
if area.is_in_group("enemies") and area.has_method("collide_with_player_vessel"):
|
|
area.collide_with_player_vessel()
|
|
var absorbed: bool = take_damage(1)
|
|
if absorbed and area.is_in_group("asteroids") and is_instance_valid(area):
|
|
area.queue_free()
|
|
|
|
|
|
func _draw() -> void:
|
|
var points: PackedVector2Array = definition.hull_points if definition != null else PackedVector2Array([
|
|
Vector2(0, -22), Vector2(-14, 16), Vector2(14, 16),
|
|
])
|
|
NeonDraw.draw_glowing_polygon(self, points, glow_color, 1.0, core_color)
|