spaceship/Ship.gd
Loïc Wisniewski baac11ae8b Implement #80, #81, #82 and #83
#80 - desktop combat. The mouse aims, the left button fires, and the number
keys pick the weapon: 1 the fleet's guns, 2 and up the abilities. The twin
sticks are built for thumbs; on a desktop they mean dragging a virtual stick
with a pointer that could simply BE the crosshair.

The pointer path hands its heading to FireStickController rather than driving
weapons itself, so exactly one thing in the scene aims a gun, and a held stick
wins - a finger on it is a deliberate act, a mouse resting somewhere is not.
Abilities gained fire_at(), which aims and fires in one call instead of
pretending to be a press-drag-release touch.

It is gated on the absence of a touchscreen, because Godot emulates a mouse
button from every touch and without that a tap on a phone would fire the guns.
The gate has an explicit override, and not for tidiness: headless reports a
touchscreen, so with the automatic answer alone the aiming could never be
exercised by a test and the only covered behaviour would be the guard.

#81 - the bottom-left control hint is gone, from both scenes and from
GameController.

#82 - a chaser is destroyed by the impact when it reaches a vessel. It used to
pass through the wreck untouched and line up another pass, which made a
ramming enemy strictly better than any other. Data-driven per enemy
(dies_on_contact), because a sniper that happens to drift into a ship has not
spent itself doing it - and routed through take_damage, so it dies exactly as
it dies to gunfire. The enemy is told about the impact BEFORE the ship
resolves its own damage, since resolving it can free the ship.

#83 - the research tree's buy button is gone; tapping a node twice buys it.
Deliberately "the same node twice" rather than a timed double-tap: no window
to miss on a slow phone, no accidental purchase while browsing, and the first
tap always does something visible, so the second is never a blind commitment.
The status line now says "Tap again to research" - with the button gone it is
the only thing distinguishing an affordable node from an unaffordable one.

test_enemy_collision asserted the exact behaviour #82 reversed, and now checks
the ram costs both. Suite 117/117.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:49:47 +02:00

197 lines
7.8 KiB
GDScript

extends Area2D
signal destroyed
const VESSEL_DEFINITIONS = preload("res://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
ShipExplosionVFX.spawn(get_tree().root, position)
destroyed.emit()
queue_free()
return false
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)