Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
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
|
|
|
|
|
|
2026-08-11 22:50:09 +02:00
|
|
|
# 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
|
|
|
|
|
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
# 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)
|
2026-08-11 22:50:09 +02:00
|
|
|
if vessel_type == GameState.VESSEL_SHIELD:
|
|
|
|
|
_build_shield_ring()
|
|
|
|
|
shield_charges = GameState.shield_max_charges()
|
|
|
|
|
apply_vessel_stats()
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
queue_redraw()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func set_move_target(world_pos: Vector2) -> void:
|
|
|
|
|
move_target = world_pos
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 22:50:09 +02:00
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
func _physics_process(delta: float) -> void:
|
2026-08-11 22:50:09 +02:00
|
|
|
_tick_shield(delta)
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
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:
|
2026-08-11 22:50:09 +02:00
|
|
|
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
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|