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)