spaceship/Ship.gd
Loïc Wisniewski 004808f067 Repair a broken working tree, and make every crafter press report
The crafter buttons doing nothing was not the crafter: Ship.gd was left
mid-edit and failed to parse (undeclared SHIELD_COLOR/SHIELD_RADIUS, and
a spawn() that only exists on the concrete VFX subclass). A script that
does not compile takes Main.tscn's whole node tree with it, so every
button in the game was dead. The project directory is the one being
played from, so an unfinished edit there is a broken build - finish or
revert before stopping.

With that fixed, the real gap underneath: an order the crafter cannot
pay for is ACCEPTED and queued (#16), which is indistinguishable from a
dead button. Ordering now reports either "building now" or "queued - N
more ore must be delivered here", and the transient message is rendered
LAST so it is the first line read rather than buried under the standing
fleet-capacity note.

test_crafter_feedback covers all three presses and asserts the message
survives the per-frame refresh, which is the specific way this looked
broken before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:50:09 +02:00

225 lines
8.3 KiB
GDScript

extends Area2D
signal destroyed
# Deliberately faster than Fleet.cruise_speed: the fleet as a whole travels
# at the cruise rate, and this headroom is what lets an individual ship
# close on its formation slot - so a pivot mid-flight resolves quickly
# instead of the ship having no speed budget left over for it.
@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 fire_range: float = 320.0
@export var fire_cooldown: float = 0.5
# How long to wait before looking for targets again after finding none.
@export var idle_rescan_interval: float = 0.25
@export var projectile_damage: int = 1
# Which kind of vessel this is (GameState.VESSEL_*). Drives both its
# researched statistics and whether it carries a shield (#5).
@export var vessel_type: String = "interceptor"
# --- shield (#5) ----------------------------------------------------------
const SHIELD_COLOR: Color = Color(0.45, 0.8, 1.0)
const SHIELD_RADIUS: float = 30.0
# A shield vessel survives a hit that would destroy any other ship, and is
# then defenceless until it cycles back up. Personal, not a bubble: it
# protects the vessel carrying it and nothing else.
var shield_charges: int = 0
var shield_recharge_left: float = 0.0
var _shield_ring: Node2D = null
# Neon colours, assigned by Fleet on spawn so a scene can tint its fleet.
# The lunar surface is a bright grey, where the default near-white core all
# but vanishes - PlanetSurface.tscn passes a dark core there. See NeonDraw.
var glow_color: Color = Color(0.25, 0.85, 1.0)
var core_color: Color = Color(1.0, 1.0, 1.0)
var velocity: Vector2 = Vector2.ZERO
# The fleet's own velocity, supplied by Fleet as CONTEXT rather than as a
# facing command - the ship works out where to point from it.
#
# It is needed because `velocity` below is not the ship's true motion while
# it is holding formation: the slot it chases sits inside arrive_radius, so
# the ship snaps onto it and reports zero velocity even though it is being
# carried along at cruise speed. Facing derived from `velocity` alone
# therefore froze at whatever heading the ship last happened to travel.
# Adding the two gives the ship's ACTUAL motion through the world, which is
# the honest thing to point along in every case: it equals the fleet's
# heading when station-keeping, the ship's own heading when manoeuvring
# alone, and the true blend of both while strafing into a new slot.
var fleet_velocity: Vector2 = Vector2.ZERO
var move_target: Vector2
var fire_timer: float = 0.0
var projectile_scene: PackedScene = preload("res://Projectile.tscn")
func _ready() -> void:
move_target = position
add_to_group("player_ships")
area_entered.connect(_on_area_entered)
if vessel_type == GameState.VESSEL_SHIELD:
_build_shield_ring()
shield_charges = GameState.shield_max_charges()
apply_vessel_stats()
queue_redraw()
func set_move_target(world_pos: Vector2) -> void:
move_target = world_pos
func apply_vessel_stats() -> void:
# Read from GameState rather than baked in at spawn, so researching an
# upgrade improves the ships already flying and not just the next one
# built. Fleet calls this on every vessel when something is bought.
max_speed = GameState.ship_max_speed(vessel_type)
acceleration = GameState.ship_acceleration(vessel_type)
fire_range = GameState.ship_fire_range(vessel_type)
fire_cooldown = GameState.ship_fire_cooldown(vessel_type)
projectile_damage = GameState.ship_damage(vessel_type)
if vessel_type == GameState.VESSEL_SHIELD:
# Topping up a live shield with researched capacity, without
# refilling one that is currently down and cycling.
var was_up: bool = shield_charges > 0
shield_charges = GameState.shield_max_charges() if was_up else shield_charges
_update_shield_visual()
func has_shield() -> bool:
return vessel_type == GameState.VESSEL_SHIELD and shield_charges > 0
func _update_shield_visual() -> void:
if _shield_ring == null:
return
# visible only - per #10 the ring is rasterised once and never redrawn to
# turn it on and off.
_shield_ring.visible = has_shield()
func _tick_shield(delta: float) -> void:
if vessel_type != GameState.VESSEL_SHIELD:
return
if shield_charges >= GameState.shield_max_charges():
return
shield_recharge_left -= delta
if shield_recharge_left <= 0.0:
shield_charges += 1
shield_recharge_left = GameState.shield_recharge_time()
_update_shield_visual()
func _physics_process(delta: float) -> void:
_tick_shield(delta)
var to_target: Vector2 = move_target - position
var distance: float = to_target.length()
if distance <= arrive_radius:
# Snap and stop dead - prevents residual float-level jitter that
# would otherwise keep nudging back and forth around the target.
position = move_target
velocity = Vector2.ZERO
else:
# Braking-distance formula: the fastest speed we can be going right
# now and still be able to decelerate to 0 exactly at the target,
# given our acceleration. Without this the ship approaches at full
# speed, overshoots (stopping distance = max_speed^2 / (2*accel)),
# and oscillates back and forth forever instead of settling.
var braking_speed: float = sqrt(2.0 * acceleration * distance)
var desired_speed: float = min(max_speed, braking_speed)
var desired_velocity: Vector2 = to_target.normalized() * desired_speed
velocity = velocity.move_toward(desired_velocity, acceleration * delta)
position += velocity * delta
# One owner, one rule: face the way this ship is actually travelling.
var motion: Vector2 = velocity + fleet_velocity
if motion.length() > 8.0:
var target_angle: float = motion.angle() + PI / 2.0
rotation = lerp_angle(rotation, target_angle, rotation_speed * delta)
_update_weapon(delta)
func _update_weapon(delta: float) -> void:
fire_timer -= delta
if fire_timer > 0.0:
return
var target: Node2D = _find_nearest_enemy()
if target == null:
# Back off before scanning again. Without this, fire_timer stays at
# or below zero and every ship queries the whole "enemies" group and
# loops it on EVERY physics frame whenever nothing is in range -
# which is most of the time.
fire_timer = idle_rescan_interval
return
fire_timer = fire_cooldown
var proj: Node2D = projectile_scene.instantiate()
proj.position = position
proj.damage = projectile_damage
get_tree().root.add_child(proj)
proj.launch(target.position - position)
func _find_nearest_enemy() -> Node2D:
var enemies: Array = get_tree().get_nodes_in_group("enemies")
var nearest: Node2D = null
var best_dist: float = fire_range
for e in enemies:
if not is_instance_valid(e):
continue
var d: float = position.distance_to(e.position)
if d <= best_dist:
best_dist = d
nearest = e
return nearest
func _on_area_entered(area: Area2D) -> void:
if not (area.is_in_group("enemies") or area.is_in_group("asteroids")):
return
if has_shield():
# Absorbed. The thing that hit it is still destroyed - a shield stops
# the vessel dying, it does not make the rock harmless - but the
# vessel survives and is defenceless until the shield cycles back.
shield_charges -= 1
shield_recharge_left = GameState.shield_recharge_time()
_update_shield_visual()
if area.is_in_group("asteroids") and is_instance_valid(area):
area.queue_free()
return
ShipExplosionVFX.spawn(get_tree().root, position)
destroyed.emit()
queue_free()
func _build_shield_ring() -> void:
# Its own child, drawn once, toggled with `visible` (#10). A shield that
# came and went by redrawing the ship would re-tessellate the hull every
# time it cycled.
_shield_ring = NeonPiece.create(self, Vector2.ZERO, SHIELD_COLOR)
var points: PackedVector2Array = PackedVector2Array()
for i in range(14):
var a: float = (float(i) / 14.0) * TAU
points.append(Vector2(cos(a), sin(a)) * SHIELD_RADIUS)
_shield_ring.add_polygon(points, 0.7)
_shield_ring.queue_redraw()
_shield_ring.visible = false
func _draw() -> void:
var nose: Vector2 = Vector2(0, -22)
var left: Vector2 = Vector2(-14, 16)
var right: Vector2 = Vector2(14, 16)
var points: PackedVector2Array = PackedVector2Array([nose, left, right])
# Constant glow, drawn once. No pulse means nothing changes frame to
# frame, so the ship never needs redrawing after _ready() - which also
# removes four antialiased polyline rebuilds per ship per frame.
NeonDraw.draw_glowing_polygon(self, points, glow_color, 1.0, core_color)