spaceship/vessels/Ship.gd
Loïc Wisniewski 1f5fecd371 Buy fleet berths with Fleet Offices
#219: fleet capacity is a built, supplied thing rather than a researched
one. The eight fleet-size doctrines are gone; capacity is one berth free
plus one per Fleet Office standing. An office charges every 25s for the
vessel in its berth, priced off that vessel's own build cost so an expensive
hull is expensive to keep, and drawn from its own store the way a gas burner
draws gas. An office that cannot pay goes dark, its vessel is scuttled, and
the player is told through the same queue quest lines use - which reaches
them on a planet surface too.

An empty berth is idle rather than starved: an office is only failing while
there is something in it to fail, and holding it disabled would leave a
berth that could never be crewed and so could never be paid for again.

Ships have no hit points and a shield absorbs a hit wholesale, so the lost
vessel is scuttled rather than damaged - otherwise a shielded hull would
survive its own eviction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-05 17:09:27 +02:00

226 lines
9.1 KiB
GDScript

extends Area2D
signal destroyed
const VESSEL_DEFINITIONS = preload("res://vessels/VesselDefinitions.gd")
const COMBAT_TARGET = preload("res://core/CombatTarget.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
scuttle()
return false
# Lost for a reason no shield can answer - #219's Fleet Office running dry,
# rather than something hitting it. Same ending as a lethal hit, deliberately:
# what the player sees is a vessel they had and no longer have, and giving
# that its own quieter exit would read as the ship simply vanishing.
func scuttle() -> void:
# #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())
# #146: the bang goes with the wreck being thrown clear, not with the
# explosion at the end of its drift - the hit is what the player needs
# to hear, and hearing it late reads as a different event.
Sfx.play(get_tree(), Sfx.SHIP_DEATH)
destroyed.emit()
queue_free()
# 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(COMBAT_TARGET.GROUP):
return
# A target is told about the impact BEFORE this ship resolves its own
# damage, because resolving it can free this node. Whether the impact does
# anything to the target is part of that target's composition (#82).
if area.has_method("collide_with_player_vessel"):
area.collide_with_player_vessel()
take_damage(1)
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)