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)