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
|
|
|
|
|
|
2026-08-14 22:12:57 +02:00
|
|
|
const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd")
|
|
|
|
|
|
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
|
|
|
@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
|
2026-08-11 22:50:09 +02:00
|
|
|
@export var vessel_type: String = "interceptor"
|
|
|
|
|
|
2026-08-14 22:12:57 +02:00
|
|
|
var definition: Resource
|
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 glow_color: Color = Color(0.25, 0.85, 1.0)
|
2026-08-14 22:12:57 +02:00
|
|
|
var core_color: Color = Color.WHITE
|
Bound individual vessel speed; stop Fleet.stop() accumulating it (#72)
Rotating the fleet and spamming a direction let vessels fall out of sync
and fly at wildly high speeds. The pivot cooldown added earlier throttled
how often the exploit could be triggered; it never touched the cause.
Fleet.stop() did `ship.velocity = ship.velocity + ship.fleet_velocity`,
compensating for the fact that Ship.velocity read zero during cruise. The
next steer refilled fleet_velocity, so every press-release cycle added a
whole cruise velocity to the ship's own, decaying only at `acceleration` in
between. Tapping faster than that bled it off stacked them without limit,
and ships desynced because each bled off at a rate set by its own distance
to its slot. Measured before this change: spamming a direction while
pivoting flew the fleet 32737 units in 3s against 784 for simply holding
the stick, with a vessel reaching 48x its own top speed.
The compensation existed because `velocity` was not the ship's real motion:
Ship snapped to its slot every tick and zeroed it, so the truth lived in a
second field and anything wanting it had to add the two together. Fixed at
that root rather than by clamping the symptom:
- fleet_velocity is now fed FORWARD into the steering instead of carried
alongside it. The ship aims to travel at its slot's velocity plus a
correction closing the gap, so holding formation costs no error and
`velocity` is always true world motion.
- The snap only applies when the slot is standing still, which is the one
case where converging asymptotically would leave a residue. While the
slot moves the ship steers continuously - a snap there was the teleport
that made `velocity` lie in the first place.
- A hard bound on the INTEGRATED result every tick, not merely on the
request, so no sequence of commands can leave a vessel over its own top
speed.
- Fleet.stop() injects nothing; it reports that the slot has halted.
tests/test_fleet_speed_bounded.gd covers it, and fails against the previous
code with the numbers above: no vessel exceeds its own top speed however
hard the direction is spammed, spamming cannot cover more ground than
holding the stick, both hold while the formation is pivoting, and the fleet
stays together.
test_space_time_distorder.gd now clears the asteroid and enemy groups at
setup, the same clean-slate idiom test_ship_lab.gd uses. Silencing the
spawners was never enough - what Main.tscn had already placed kept moving,
and losing the distorder vessel to a rock resets the multiplier correctly,
which the test then reported three stages later as a failed scene swap. It
also checks it still crews a distorder vessel before relying on that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:34 +02:00
|
|
|
# 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.
|
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 velocity: Vector2 = Vector2.ZERO
|
Bound individual vessel speed; stop Fleet.stop() accumulating it (#72)
Rotating the fleet and spamming a direction let vessels fall out of sync
and fly at wildly high speeds. The pivot cooldown added earlier throttled
how often the exploit could be triggered; it never touched the cause.
Fleet.stop() did `ship.velocity = ship.velocity + ship.fleet_velocity`,
compensating for the fact that Ship.velocity read zero during cruise. The
next steer refilled fleet_velocity, so every press-release cycle added a
whole cruise velocity to the ship's own, decaying only at `acceleration` in
between. Tapping faster than that bled it off stacked them without limit,
and ships desynced because each bled off at a rate set by its own distance
to its slot. Measured before this change: spamming a direction while
pivoting flew the fleet 32737 units in 3s against 784 for simply holding
the stick, with a vessel reaching 48x its own top speed.
The compensation existed because `velocity` was not the ship's real motion:
Ship snapped to its slot every tick and zeroed it, so the truth lived in a
second field and anything wanting it had to add the two together. Fixed at
that root rather than by clamping the symptom:
- fleet_velocity is now fed FORWARD into the steering instead of carried
alongside it. The ship aims to travel at its slot's velocity plus a
correction closing the gap, so holding formation costs no error and
`velocity` is always true world motion.
- The snap only applies when the slot is standing still, which is the one
case where converging asymptotically would leave a residue. While the
slot moves the ship steers continuously - a snap there was the teleport
that made `velocity` lie in the first place.
- A hard bound on the INTEGRATED result every tick, not merely on the
request, so no sequence of commands can leave a vessel over its own top
speed.
- Fleet.stop() injects nothing; it reports that the slot has halted.
tests/test_fleet_speed_bounded.gd covers it, and fails against the previous
code with the numbers above: no vessel exceeds its own top speed however
hard the direction is spammed, spamming cannot cover more ground than
holding the stick, both hold while the formation is pivoting, and the fleet
stays together.
test_space_time_distorder.gd now clears the asteroid and enemy groups at
setup, the same clean-slate idiom test_ship_lab.gd uses. Silencing the
spawners was never enough - what Main.tscn had already placed kept moving,
and losing the distorder vessel to a rock resets the multiplier correctly,
which the test then reported three stages later as a failed scene swap. It
also checks it still crews a distorder vessel before relying on that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:34 +02:00
|
|
|
# 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.
|
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 fleet_velocity: Vector2 = Vector2.ZERO
|
|
|
|
|
var move_target: Vector2
|
2026-08-14 22:12:57 +02:00
|
|
|
|
|
|
|
|
# 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
|
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 _ready() -> void:
|
|
|
|
|
move_target = position
|
|
|
|
|
add_to_group("player_ships")
|
|
|
|
|
area_entered.connect(_on_area_entered)
|
2026-08-14 22:12:57 +02:00
|
|
|
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()
|
2026-08-11 22:50:09 +02:00
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
2026-08-14 22:12:57 +02:00
|
|
|
func get_component(capability: String) -> Node:
|
|
|
|
|
for child in get_children():
|
|
|
|
|
if child.get("capability_id") == capability:
|
|
|
|
|
return child
|
|
|
|
|
return 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
|
|
|
|
|
|
|
|
|
2026-08-14 22:12:57 +02:00
|
|
|
func has_capability(capability: String) -> bool:
|
|
|
|
|
return get_component(capability) != null
|
2026-08-11 22:50:09 +02:00
|
|
|
|
|
|
|
|
|
2026-08-14 22:12:57 +02:00
|
|
|
func set_move_target(world_pos: Vector2) -> void:
|
|
|
|
|
move_target = world_pos
|
2026-08-11 22:50:09 +02:00
|
|
|
|
|
|
|
|
|
2026-08-14 22:12:57 +02:00
|
|
|
func apply_vessel_stats() -> void:
|
|
|
|
|
if definition == null:
|
|
|
|
|
definition = VESSEL_DEFINITIONS.get_definition(vessel_type)
|
|
|
|
|
if definition == null:
|
2026-08-11 22:50:09 +02:00
|
|
|
return
|
2026-08-14 22:12:57 +02:00
|
|
|
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()
|
2026-08-11 22:50:09 +02:00
|
|
|
|
|
|
|
|
|
2026-08-14 22:12:57 +02:00
|
|
|
func has_shield() -> bool:
|
|
|
|
|
var component: Node = get_component("shield")
|
|
|
|
|
return component != null and component.is_active()
|
2026-08-11 22:50:09 +02:00
|
|
|
|
|
|
|
|
|
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-14 22:12:57 +02:00
|
|
|
for child in get_children():
|
|
|
|
|
if child.has_method("tick"):
|
|
|
|
|
child.tick(delta)
|
|
|
|
|
|
2026-08-16 17:59:04 +02:00
|
|
|
# #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
|
Bound individual vessel speed; stop Fleet.stop() accumulating it (#72)
Rotating the fleet and spamming a direction let vessels fall out of sync
and fly at wildly high speeds. The pivot cooldown added earlier throttled
how often the exploit could be triggered; it never touched the cause.
Fleet.stop() did `ship.velocity = ship.velocity + ship.fleet_velocity`,
compensating for the fact that Ship.velocity read zero during cruise. The
next steer refilled fleet_velocity, so every press-release cycle added a
whole cruise velocity to the ship's own, decaying only at `acceleration` in
between. Tapping faster than that bled it off stacked them without limit,
and ships desynced because each bled off at a rate set by its own distance
to its slot. Measured before this change: spamming a direction while
pivoting flew the fleet 32737 units in 3s against 784 for simply holding
the stick, with a vessel reaching 48x its own top speed.
The compensation existed because `velocity` was not the ship's real motion:
Ship snapped to its slot every tick and zeroed it, so the truth lived in a
second field and anything wanting it had to add the two together. Fixed at
that root rather than by clamping the symptom:
- fleet_velocity is now fed FORWARD into the steering instead of carried
alongside it. The ship aims to travel at its slot's velocity plus a
correction closing the gap, so holding formation costs no error and
`velocity` is always true world motion.
- The snap only applies when the slot is standing still, which is the one
case where converging asymptotically would leave a residue. While the
slot moves the ship steers continuously - a snap there was the teleport
that made `velocity` lie in the first place.
- A hard bound on the INTEGRATED result every tick, not merely on the
request, so no sequence of commands can leave a vessel over its own top
speed.
- Fleet.stop() injects nothing; it reports that the slot has halted.
tests/test_fleet_speed_bounded.gd covers it, and fails against the previous
code with the numbers above: no vessel exceeds its own top speed however
hard the direction is spammed, spamming cannot cover more ground than
holding the stick, both hold while the formation is pivoting, and the fleet
stays together.
test_space_time_distorder.gd now clears the asteroid and enemy groups at
setup, the same clean-slate idiom test_ship_lab.gd uses. Silencing the
spawners was never enough - what Main.tscn had already placed kept moving,
and losing the distorder vessel to a rock resets the multiplier correctly,
which the test then reported three stages later as a failed scene swap. It
also checks it still crews a distorder vessel before relying on that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:34 +02:00
|
|
|
var effective_acceleration: float = acceleration * boost
|
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()
|
Bound individual vessel speed; stop Fleet.stop() accumulating it (#72)
Rotating the fleet and spamming a direction let vessels fall out of sync
and fly at wildly high speeds. The pivot cooldown added earlier throttled
how often the exploit could be triggered; it never touched the cause.
Fleet.stop() did `ship.velocity = ship.velocity + ship.fleet_velocity`,
compensating for the fact that Ship.velocity read zero during cruise. The
next steer refilled fleet_velocity, so every press-release cycle added a
whole cruise velocity to the ship's own, decaying only at `acceleration` in
between. Tapping faster than that bled it off stacked them without limit,
and ships desynced because each bled off at a rate set by its own distance
to its slot. Measured before this change: spamming a direction while
pivoting flew the fleet 32737 units in 3s against 784 for simply holding
the stick, with a vessel reaching 48x its own top speed.
The compensation existed because `velocity` was not the ship's real motion:
Ship snapped to its slot every tick and zeroed it, so the truth lived in a
second field and anything wanting it had to add the two together. Fixed at
that root rather than by clamping the symptom:
- fleet_velocity is now fed FORWARD into the steering instead of carried
alongside it. The ship aims to travel at its slot's velocity plus a
correction closing the gap, so holding formation costs no error and
`velocity` is always true world motion.
- The snap only applies when the slot is standing still, which is the one
case where converging asymptotically would leave a residue. While the
slot moves the ship steers continuously - a snap there was the teleport
that made `velocity` lie in the first place.
- A hard bound on the INTEGRATED result every tick, not merely on the
request, so no sequence of commands can leave a vessel over its own top
speed.
- Fleet.stop() injects nothing; it reports that the slot has halted.
tests/test_fleet_speed_bounded.gd covers it, and fails against the previous
code with the numbers above: no vessel exceeds its own top speed however
hard the direction is spammed, spamming cannot cover more ground than
holding the stick, both hold while the formation is pivoting, and the fleet
stays together.
test_space_time_distorder.gd now clears the asteroid and enemy groups at
setup, the same clean-slate idiom test_ship_lab.gd uses. Silencing the
spawners was never enough - what Main.tscn had already placed kept moving,
and losing the distorder vessel to a rock resets the multiplier correctly,
which the test then reported three stages later as a failed scene swap. It
also checks it still crews a distorder vessel before relying on that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:34 +02:00
|
|
|
|
|
|
|
|
# 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:
|
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
|
|
|
position = move_target
|
|
|
|
|
velocity = Vector2.ZERO
|
|
|
|
|
else:
|
Bound individual vessel speed; stop Fleet.stop() accumulating it (#72)
Rotating the fleet and spamming a direction let vessels fall out of sync
and fly at wildly high speeds. The pivot cooldown added earlier throttled
how often the exploit could be triggered; it never touched the cause.
Fleet.stop() did `ship.velocity = ship.velocity + ship.fleet_velocity`,
compensating for the fact that Ship.velocity read zero during cruise. The
next steer refilled fleet_velocity, so every press-release cycle added a
whole cruise velocity to the ship's own, decaying only at `acceleration` in
between. Tapping faster than that bled it off stacked them without limit,
and ships desynced because each bled off at a rate set by its own distance
to its slot. Measured before this change: spamming a direction while
pivoting flew the fleet 32737 units in 3s against 784 for simply holding
the stick, with a vessel reaching 48x its own top speed.
The compensation existed because `velocity` was not the ship's real motion:
Ship snapped to its slot every tick and zeroed it, so the truth lived in a
second field and anything wanting it had to add the two together. Fixed at
that root rather than by clamping the symptom:
- fleet_velocity is now fed FORWARD into the steering instead of carried
alongside it. The ship aims to travel at its slot's velocity plus a
correction closing the gap, so holding formation costs no error and
`velocity` is always true world motion.
- The snap only applies when the slot is standing still, which is the one
case where converging asymptotically would leave a residue. While the
slot moves the ship steers continuously - a snap there was the teleport
that made `velocity` lie in the first place.
- A hard bound on the INTEGRATED result every tick, not merely on the
request, so no sequence of commands can leave a vessel over its own top
speed.
- Fleet.stop() injects nothing; it reports that the slot has halted.
tests/test_fleet_speed_bounded.gd covers it, and fails against the previous
code with the numbers above: no vessel exceeds its own top speed however
hard the direction is spammed, spamming cannot cover more ground than
holding the stick, both hold while the formation is pivoting, and the fleet
stays together.
test_space_time_distorder.gd now clears the asteroid and enemy groups at
setup, the same clean-slate idiom test_ship_lab.gd uses. Silencing the
spawners was never enough - what Main.tscn had already placed kept moving,
and losing the distorder vessel to a rock resets the multiplier correctly,
which the test then reported three stages later as a failed scene swap. It
also checks it still crews a distorder vessel before relying on that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:34 +02:00
|
|
|
# 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)
|
2026-08-16 17:59:04 +02:00
|
|
|
velocity = velocity.move_toward(desired_velocity, effective_acceleration * delta)
|
Bound individual vessel speed; stop Fleet.stop() accumulating it (#72)
Rotating the fleet and spamming a direction let vessels fall out of sync
and fly at wildly high speeds. The pivot cooldown added earlier throttled
how often the exploit could be triggered; it never touched the cause.
Fleet.stop() did `ship.velocity = ship.velocity + ship.fleet_velocity`,
compensating for the fact that Ship.velocity read zero during cruise. The
next steer refilled fleet_velocity, so every press-release cycle added a
whole cruise velocity to the ship's own, decaying only at `acceleration` in
between. Tapping faster than that bled it off stacked them without limit,
and ships desynced because each bled off at a rate set by its own distance
to its slot. Measured before this change: spamming a direction while
pivoting flew the fleet 32737 units in 3s against 784 for simply holding
the stick, with a vessel reaching 48x its own top speed.
The compensation existed because `velocity` was not the ship's real motion:
Ship snapped to its slot every tick and zeroed it, so the truth lived in a
second field and anything wanting it had to add the two together. Fixed at
that root rather than by clamping the symptom:
- fleet_velocity is now fed FORWARD into the steering instead of carried
alongside it. The ship aims to travel at its slot's velocity plus a
correction closing the gap, so holding formation costs no error and
`velocity` is always true world motion.
- The snap only applies when the slot is standing still, which is the one
case where converging asymptotically would leave a residue. While the
slot moves the ship steers continuously - a snap there was the teleport
that made `velocity` lie in the first place.
- A hard bound on the INTEGRATED result every tick, not merely on the
request, so no sequence of commands can leave a vessel over its own top
speed.
- Fleet.stop() injects nothing; it reports that the slot has halted.
tests/test_fleet_speed_bounded.gd covers it, and fails against the previous
code with the numbers above: no vessel exceeds its own top speed however
hard the direction is spammed, spamming cannot cover more ground than
holding the stick, both hold while the formation is pivoting, and the fleet
stays together.
test_space_time_distorder.gd now clears the asteroid and enemy groups at
setup, the same clean-slate idiom test_ship_lab.gd uses. Silencing the
spawners was never enough - what Main.tscn had already placed kept moving,
and losing the distorder vessel to a rock resets the multiplier correctly,
which the test then reported three stages later as a failed scene swap. It
also checks it still crews a distorder vessel before relying on that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:34 +02:00
|
|
|
velocity = velocity.limit_length(max_speed * boost)
|
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
|
|
|
position += velocity * delta
|
|
|
|
|
|
Bound individual vessel speed; stop Fleet.stop() accumulating it (#72)
Rotating the fleet and spamming a direction let vessels fall out of sync
and fly at wildly high speeds. The pivot cooldown added earlier throttled
how often the exploit could be triggered; it never touched the cause.
Fleet.stop() did `ship.velocity = ship.velocity + ship.fleet_velocity`,
compensating for the fact that Ship.velocity read zero during cruise. The
next steer refilled fleet_velocity, so every press-release cycle added a
whole cruise velocity to the ship's own, decaying only at `acceleration` in
between. Tapping faster than that bled it off stacked them without limit,
and ships desynced because each bled off at a rate set by its own distance
to its slot. Measured before this change: spamming a direction while
pivoting flew the fleet 32737 units in 3s against 784 for simply holding
the stick, with a vessel reaching 48x its own top speed.
The compensation existed because `velocity` was not the ship's real motion:
Ship snapped to its slot every tick and zeroed it, so the truth lived in a
second field and anything wanting it had to add the two together. Fixed at
that root rather than by clamping the symptom:
- fleet_velocity is now fed FORWARD into the steering instead of carried
alongside it. The ship aims to travel at its slot's velocity plus a
correction closing the gap, so holding formation costs no error and
`velocity` is always true world motion.
- The snap only applies when the slot is standing still, which is the one
case where converging asymptotically would leave a residue. While the
slot moves the ship steers continuously - a snap there was the teleport
that made `velocity` lie in the first place.
- A hard bound on the INTEGRATED result every tick, not merely on the
request, so no sequence of commands can leave a vessel over its own top
speed.
- Fleet.stop() injects nothing; it reports that the slot has halted.
tests/test_fleet_speed_bounded.gd covers it, and fails against the previous
code with the numbers above: no vessel exceeds its own top speed however
hard the direction is spammed, spamming cannot cover more ground than
holding the stick, both hold while the formation is pivoting, and the fleet
stays together.
test_space_time_distorder.gd now clears the asteroid and enemy groups at
setup, the same clean-slate idiom test_ship_lab.gd uses. Silencing the
spawners was never enough - what Main.tscn had already placed kept moving,
and losing the distorder vessel to a rock resets the multiplier correctly,
which the test then reported three stages later as a failed scene swap. It
also checks it still crews a distorder vessel before relying on that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:20:34 +02:00
|
|
|
if velocity.length() > 8.0:
|
|
|
|
|
var target_angle: float = velocity.angle() + PI / 2.0
|
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
|
|
|
rotation = lerp_angle(rotation, target_angle, rotation_speed * delta)
|
|
|
|
|
|
|
|
|
|
|
2026-08-15 14:43:29 +02:00
|
|
|
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.
|
2026-08-14 22:12:57 +02:00
|
|
|
for child in get_children():
|
|
|
|
|
if child.has_method("absorb_hit") and child.absorb_hit():
|
2026-08-15 14:43:29 +02:00
|
|
|
return true
|
2026-08-11 22:50:09 +02:00
|
|
|
ShipExplosionVFX.spawn(get_tree().root, position)
|
|
|
|
|
destroyed.emit()
|
|
|
|
|
queue_free()
|
2026-08-15 14:43:29 +02:00
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _on_area_entered(area: Area2D) -> void:
|
|
|
|
|
if not (area.is_in_group("enemies") or area.is_in_group("asteroids")):
|
|
|
|
|
return
|
|
|
|
|
var absorbed: bool = take_damage(1)
|
|
|
|
|
if absorbed and area.is_in_group("asteroids") and is_instance_valid(area):
|
|
|
|
|
area.queue_free()
|
2026-08-11 22:50:09 +02:00
|
|
|
|
|
|
|
|
|
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:
|
2026-08-14 22:12:57 +02:00
|
|
|
var points: PackedVector2Array = definition.hull_points if definition != null else PackedVector2Array([
|
|
|
|
|
Vector2(0, -22), Vector2(-14, 16), Vector2(14, 16),
|
|
|
|
|
])
|
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
|
|
|
NeonDraw.draw_glowing_polygon(self, points, glow_color, 1.0, core_color)
|