Add space-time distorder vessel and fleet speed fader (#59)

GameState.fleet_speed_multiplier (1.0-3.0) is applied at the point of
use - Fleet's cruise anchor and Ship's own max_speed/acceleration -
rather than baked into stored stats, so a stat upgrade researched
mid-boost can't corrupt the base value and the fader takes effect
immediately with no re-apply step.

Ship.arrive_radius is scaled by the same multiplier: at higher speeds
each physics tick covers a proportionally larger step, and once that
step outgrows a fixed arrive_radius the ship can overshoot its
snap-to-target zone every tick while chasing a target that's now also
receding faster - visible as the fleet stretching apart under boost.
Scaling it preserves the same "snaps onto its slot" tightness at any
boost level that 1.0x already has.

SpeedFader.gd is a self-contained vertical drag control (TouchJoystick's
raw-touch idiom, not _gui_input) that writes straight to the autoload
rather than reporting a value for something else to apply. It also
resets the multiplier to 1.0 the instant fleet.has_capability("distorder")
goes false, so a fleet can never be left permanently boosted by a vessel
it no longer has.

VesselDistorderComponent carries no per-shot behaviour (energy drain
while boosted is #62) - its presence is what matters. Registered in
VesselDefinitions, wired into the crafter/hangar/ship-lab menus per
#57's note (5/5/6 -> 6/6/7 columns).
This commit is contained in:
Loïc Wisniewski 2026-08-16 17:59:04 +02:00
commit 116e0f97d3
14 changed files with 675 additions and 86 deletions

View file

@ -32,12 +32,14 @@ const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd")
get_node("../UI/LabMenu/VesselA"), get_node("../UI/LabMenu/VesselB"),
get_node("../UI/LabMenu/VesselC"), get_node("../UI/LabMenu/VesselD"),
get_node("../UI/LabMenu/VesselE"), get_node("../UI/LabMenu/VesselF"),
get_node("../UI/LabMenu/VesselG"),
]
@onready var lab_vessel_label: Label = get_node("../UI/LabMenu/VesselLabel")
@onready var lab_vessel_labels: Array = [
get_node("../UI/LabMenu/VesselALabel"), get_node("../UI/LabMenu/VesselBLabel"),
get_node("../UI/LabMenu/VesselCLabel"), get_node("../UI/LabMenu/VesselDLabel"),
get_node("../UI/LabMenu/VesselELabel"), get_node("../UI/LabMenu/VesselFLabel"),
get_node("../UI/LabMenu/VesselGLabel"),
]
@onready var lab_detail: Label = get_node("../UI/LabMenu/DetailLabel")
@onready var lab_status: Label = get_node("../UI/LabMenu/StatusLabel")
@ -67,6 +69,7 @@ const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd")
get_node("../UI/HangarMenu/AvailableGasBomber"),
get_node("../UI/HangarMenu/AvailableCarrier"),
get_node("../UI/HangarMenu/AvailableLaser"),
get_node("../UI/HangarMenu/AvailableDistorder"),
]
@onready var hangar_available_labels: Array = [
get_node("../UI/HangarMenu/AvailableInterceptorLabel"),
@ -74,6 +77,7 @@ const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd")
get_node("../UI/HangarMenu/AvailableGasBomberLabel"),
get_node("../UI/HangarMenu/AvailableCarrierLabel"),
get_node("../UI/HangarMenu/AvailableLaserLabel"),
get_node("../UI/HangarMenu/AvailableDistorderLabel"),
]
@onready var hangar_fleet_slots: Array = [
get_node("../UI/HangarMenu/FleetInterceptor"),
@ -81,6 +85,7 @@ const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd")
get_node("../UI/HangarMenu/FleetGasBomber"),
get_node("../UI/HangarMenu/FleetCarrier"),
get_node("../UI/HangarMenu/FleetLaser"),
get_node("../UI/HangarMenu/FleetDistorder"),
]
@onready var hangar_fleet_labels: Array = [
get_node("../UI/HangarMenu/FleetInterceptorLabel"),
@ -88,6 +93,7 @@ const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd")
get_node("../UI/HangarMenu/FleetGasBomberLabel"),
get_node("../UI/HangarMenu/FleetCarrierLabel"),
get_node("../UI/HangarMenu/FleetLaserLabel"),
get_node("../UI/HangarMenu/FleetDistorderLabel"),
]
var build_menu_entries: Array = []
@ -117,13 +123,15 @@ var lab_building_id: int = 0
@onready var craft_carrier_label: Label = get_node("../UI/CraftMenu/CarrierLabel")
@onready var craft_laser_button: IconButton = get_node("../UI/CraftMenu/LaserButton")
@onready var craft_laser_label: Label = get_node("../UI/CraftMenu/LaserLabel")
@onready var craft_distorder_button: IconButton = get_node("../UI/CraftMenu/DistorderButton")
@onready var craft_distorder_label: Label = get_node("../UI/CraftMenu/DistorderLabel")
@onready var craft_vessel_buttons: Array = [
craft_interceptor_button, craft_shield_button, craft_gas_bomber_button, craft_carrier_button,
craft_laser_button,
craft_laser_button, craft_distorder_button,
]
@onready var craft_vessel_labels: Array = [
craft_interceptor_label, craft_shield_label, craft_gas_bomber_label, craft_carrier_label,
craft_laser_label,
craft_laser_label, craft_distorder_label,
]
# --- the transfer panel (#24) ---------------------------------------------

View file

@ -0,0 +1,24 @@
[gd_resource type="Resource" script_class="VesselDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://VesselDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://VesselDistorderComponent.tscn" id="2"]
[resource]
script = ExtResource("1")
definition_id = "distorder"
display_name = "Distorder"
description = "Unarmed space-time distortion hull. While one flies with the fleet, a fader lets you push cruise speed beyond its default."
build_cost = 14
build_resource_costs = {"ore": 14, "energy": 8}
build_time = 16.0
icon = 37
glow_color = Color(0.7, 0.3, 1, 1)
hull_points = PackedVector2Array(0, -24, -14, -2, -6, 18, 10, 10, 12, -10)
fleet_eligible = true
capabilities = PackedStringArray("distorder")
component_scenes = Array[PackedScene]([ExtResource("2")])
base_stats = {"max_speed": 430.0, "acceleration": 410.0, "delivery_speed": 180.0}
unlock_id = "unlock_distorder"
unlock_name = "Unlock Distorder"
unlock_description = "Research a space-time distortion hull - while one flies with the fleet, a fader lets you push cruise speed beyond its default."
unlock_cost = 0
unlock_resource_costs = {"energy": 50}
unlock_icon = -1

View file

@ -200,18 +200,25 @@ func _process(delta: float) -> void:
if steer_direction == Vector2.ZERO:
return
# The anchor - the fleet's formation centre - travels at cruise_speed.
# Ships may exceed it individually to reach their slots.
anchor += steer_direction * cruise_speed * delta
# Ships may exceed it individually to reach their slots. #59: multiplied
# at the point of use, not baked into cruise_speed itself, so the base
# value stays stable regardless of the current boost.
anchor += steer_direction * _effective_cruise_speed() * delta
fleet_target = anchor
_assign_slot_targets()
func _effective_cruise_speed() -> float:
return cruise_speed * GameState.fleet_speed_multiplier
func _assign_slot_targets() -> void:
var effective_cruise_speed: float = _effective_cruise_speed()
for i in range(ships.size()):
ships[i].set_move_target(fleet_target + formation_offsets[i])
# Context, not a command: the ship adds this to its own velocity to
# work out how it is really moving. See Ship.fleet_velocity.
ships[i].fleet_velocity = steer_direction * cruise_speed
ships[i].fleet_velocity = steer_direction * effective_cruise_speed
func stop() -> void:

View file

@ -28,6 +28,7 @@ extends Node2D
# indirectly - e.g. Main.tscn loaded from inside a _screenshot_tool.gd
# preview - for a class_name this new. Node + duck-typed calls sidesteps it.
@onready var laser_controller: Node = get_node_or_null("LaserController")
@onready var speed_fader: Node = get_node_or_null("UI/SpeedFader")
var touches: Dictionary = {}
var pinch_active: bool = false
@ -263,6 +264,8 @@ func _handle_touch(event: InputEventScreenTouch) -> void:
return
if laser_controller != null and laser_controller.handle_touch(event):
return
if speed_fader != null and speed_fader.handle_touch(event):
return
if _handle_pivot_touch(event):
return
if joystick != null and joystick.visible and joystick.handle_touch(event):
@ -287,6 +290,8 @@ func _handle_drag(event: InputEventScreenDrag) -> void:
return
if laser_controller != null and laser_controller.handle_drag(event):
return
if speed_fader != null and speed_fader.handle_drag(event):
return
if joystick != null and joystick.visible and joystick.handle_drag(event):
return
if not touches.has(event.index):

View file

@ -22,6 +22,17 @@ var planet_name: String = ""
var planet_id: String = ""
var planet_definition_id: String = "rocky"
# #59: 1.0 is today's behaviour. Applied at the point of use (Fleet's cruise
# anchor, Ship's own max_speed/acceleration) rather than baked into stored
# stats, so a speed upgrade researched while boosted can't corrupt the base
# value - see Fleet.gd/Ship.gd for the multiply sites.
const MAX_FLEET_SPEED_MULTIPLIER: float = 3.0
var fleet_speed_multiplier: float = 1.0
func set_fleet_speed_multiplier(value: float) -> void:
fleet_speed_multiplier = clampf(value, 1.0, MAX_FLEET_SPEED_MULTIPLIER)
# Explored-chunk history for the world map's fog-of-war reveal. Lives
# here (not on ExplorationTracker, which gets destroyed and recreated on
# every scene swap) so exploration progress isn't lost every time the
@ -381,6 +392,7 @@ func reset_new_game() -> void:
planet_name = ""
planet_id = ""
planet_definition_id = "rocky"
fleet_speed_multiplier = 1.0
explored_chunks.clear()
bank_inventory = INVENTORY.new({RESOURCE_ORE: 500})
fleet_inventory = INVENTORY.new()
@ -465,6 +477,7 @@ const VESSEL_GAS_BOMBER: String = "gas_bomber"
const VESSEL_TRANSPORTER: String = "transporter"
const VESSEL_CARRIER: String = "carrier"
const VESSEL_LASER: String = "laser"
const VESSEL_DISTORDER: String = "distorder"
# Compatibility constants for callers and old tests. Runtime metadata is
# resolved from VesselDefinition below.

View file

@ -41,6 +41,8 @@ enum Icon {
# #58: LASER_VESSEL is the ship glyph, LASER_SHOT the action button -
# same two-icon split as GAS_BOMBER/GAS_BOMB above.
LASER_VESSEL, LASER_SHOT,
# #59: a warped ring reads as a space-time distortion rather than a hull.
DISTORDER_VESSEL,
}
# Size the glyphs were originally drawn against. Every glyph dimension is
@ -239,6 +241,8 @@ func _draw() -> void:
_draw_laser_vessel(center, pulse, s)
Icon.LASER_SHOT:
_draw_laser_shot(center, pulse, s)
Icon.DISTORDER_VESSEL:
_draw_distorder_vessel(center, pulse, s)
func _draw_close(center: Vector2, pulse: float, s: float) -> void:
@ -277,6 +281,18 @@ func _draw_carrier(center: Vector2, pulse: float, s: float) -> void:
NeonDraw.draw_glowing_polygon(self, hull, _active_color(), pulse, core_color)
func _draw_distorder_vessel(center: Vector2, pulse: float, s: float) -> void:
# A ring whose radius wobbles with angle instead of a plain circle -
# reads as warped space rather than a hull silhouette.
var points: PackedVector2Array = PackedVector2Array()
for i in range(17):
var a: float = TAU * float(i) / 16.0
var r: float = (10.0 + 3.0 * sin(a * 3.0)) * s
points.append(center + Vector2(cos(a), sin(a)) * r)
NeonDraw.draw_glowing_path(self, points, _active_color(), pulse, 0.8, core_color)
NeonDraw.draw_glowing_circle(self, center, 2.5 * s, _active_color(), pulse, core_color)
func _draw_laser_vessel(center: Vector2, pulse: float, s: float) -> void:
# A slim dart rather than the interceptor's plain triangle - the notch at
# the nose reads as a fixed forward-facing emitter, not a hull point.

268
Main.tscn
View file

@ -1,4 +1,4 @@
[gd_scene load_steps=28 format=3]
[gd_scene load_steps=29 format=3]
[ext_resource type="Script" path="res://Starfield.gd" id="1"]
[ext_resource type="Script" path="res://Fleet.gd" id="2"]
@ -26,6 +26,7 @@
[ext_resource type="Script" path="res://ResourceTransferList.gd" id="27"]
[ext_resource type="Script" path="res://GasBombController.gd" id="29"]
[ext_resource type="Script" path="res://LaserController.gd" id="30"]
[ext_resource type="Script" path="res://SpeedFader.gd" id="31"]
[sub_resource type="ShaderMaterial" id="1"]
shader = ExtResource("5")
@ -221,6 +222,28 @@ offset_top = -424
offset_bottom = -24
script = ExtResource("13")
[node name="SpeedFader" type="Control" parent="UI"]
visible = false
mouse_filter = 2
anchor_left = 1.0
anchor_right = 1.0
offset_left = -94
offset_right = -24
offset_top = 740
offset_bottom = 920
script = ExtResource("31")
[node name="Label" type="Label" parent="UI/SpeedFader"]
mouse_filter = 2
offset_left = -20
offset_right = 90
offset_top = -32
offset_bottom = -4
theme_override_font_sizes/font_size = 20
theme_override_colors/font_color = Color(0.7, 0.3, 1, 1)
horizontal_alignment = 1
text = "x1.0"
[node name="LaserButton" type="Control" parent="UI"]
visible = false
mouse_filter = 2
@ -839,7 +862,7 @@ horizontal_alignment = 1
anchor_left = 0.5
anchor_right = 0.5
offset_left = -290
offset_right = -198
offset_right = -215
offset_top = 255
offset_bottom = 375
script = ExtResource("10")
@ -850,7 +873,7 @@ mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -330
offset_right = -158
offset_right = -175
offset_top = 380
offset_bottom = 460
theme_override_font_sizes/font_size = 22
@ -859,8 +882,8 @@ horizontal_alignment = 1
[node name="AvailableShield" type="Control" parent="UI/HangarMenu"]
anchor_left = 0.5
anchor_right = 0.5
offset_left = -168
offset_right = -76
offset_left = -189
offset_right = -114
offset_top = 255
offset_bottom = 375
script = ExtResource("10")
@ -871,8 +894,8 @@ glow_color = Color(0.45, 0.8, 1, 1)
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -208
offset_right = -36
offset_left = -229
offset_right = -74
offset_top = 380
offset_bottom = 460
theme_override_font_sizes/font_size = 22
@ -881,8 +904,8 @@ horizontal_alignment = 1
[node name="AvailableGasBomber" type="Control" parent="UI/HangarMenu"]
anchor_left = 0.5
anchor_right = 0.5
offset_left = -46
offset_right = 46
offset_left = -88
offset_right = -13
offset_top = 255
offset_bottom = 375
script = ExtResource("10")
@ -893,8 +916,8 @@ glow_color = Color(0.96, 0.32, 0.78, 1)
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -86
offset_right = 86
offset_left = -128
offset_right = 27
offset_top = 380
offset_bottom = 460
theme_override_font_sizes/font_size = 22
@ -903,8 +926,8 @@ horizontal_alignment = 1
[node name="AvailableCarrier" type="Control" parent="UI/HangarMenu"]
anchor_left = 0.5
anchor_right = 0.5
offset_left = 76
offset_right = 168
offset_left = 13
offset_right = 88
offset_top = 255
offset_bottom = 375
script = ExtResource("10")
@ -915,8 +938,8 @@ glow_color = Color(1, 0.78, 0.3, 1)
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = 36
offset_right = 208
offset_left = -27
offset_right = 128
offset_top = 380
offset_bottom = 460
theme_override_font_sizes/font_size = 22
@ -925,8 +948,8 @@ horizontal_alignment = 1
[node name="AvailableLaser" type="Control" parent="UI/HangarMenu"]
anchor_left = 0.5
anchor_right = 0.5
offset_left = 198
offset_right = 290
offset_left = 114
offset_right = 189
offset_top = 255
offset_bottom = 375
script = ExtResource("10")
@ -937,7 +960,29 @@ glow_color = Color(0.3, 0.9, 1, 1)
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = 158
offset_left = 74
offset_right = 229
offset_top = 380
offset_bottom = 460
theme_override_font_sizes/font_size = 22
horizontal_alignment = 1
[node name="AvailableDistorder" type="Control" parent="UI/HangarMenu"]
anchor_left = 0.5
anchor_right = 0.5
offset_left = 215
offset_right = 290
offset_top = 255
offset_bottom = 375
script = ExtResource("10")
icon = 37
glow_color = Color(0.7, 0.3, 1, 1)
[node name="AvailableDistorderLabel" type="Label" parent="UI/HangarMenu"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = 175
offset_right = 330
offset_top = 380
offset_bottom = 460
@ -957,7 +1002,7 @@ horizontal_alignment = 1
anchor_left = 0.5
anchor_right = 0.5
offset_left = -290
offset_right = -198
offset_right = -215
offset_top = 565
offset_bottom = 685
script = ExtResource("10")
@ -968,7 +1013,7 @@ mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -330
offset_right = -158
offset_right = -175
offset_top = 690
offset_bottom = 770
theme_override_font_sizes/font_size = 22
@ -977,8 +1022,8 @@ horizontal_alignment = 1
[node name="FleetShield" type="Control" parent="UI/HangarMenu"]
anchor_left = 0.5
anchor_right = 0.5
offset_left = -168
offset_right = -76
offset_left = -189
offset_right = -114
offset_top = 565
offset_bottom = 685
script = ExtResource("10")
@ -989,8 +1034,8 @@ glow_color = Color(0.45, 0.8, 1, 1)
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -208
offset_right = -36
offset_left = -229
offset_right = -74
offset_top = 690
offset_bottom = 770
theme_override_font_sizes/font_size = 22
@ -999,8 +1044,8 @@ horizontal_alignment = 1
[node name="FleetGasBomber" type="Control" parent="UI/HangarMenu"]
anchor_left = 0.5
anchor_right = 0.5
offset_left = -46
offset_right = 46
offset_left = -88
offset_right = -13
offset_top = 565
offset_bottom = 685
script = ExtResource("10")
@ -1011,8 +1056,8 @@ glow_color = Color(0.96, 0.32, 0.78, 1)
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -86
offset_right = 86
offset_left = -128
offset_right = 27
offset_top = 690
offset_bottom = 770
theme_override_font_sizes/font_size = 22
@ -1021,8 +1066,8 @@ horizontal_alignment = 1
[node name="FleetCarrier" type="Control" parent="UI/HangarMenu"]
anchor_left = 0.5
anchor_right = 0.5
offset_left = 76
offset_right = 168
offset_left = 13
offset_right = 88
offset_top = 565
offset_bottom = 685
script = ExtResource("10")
@ -1033,8 +1078,8 @@ glow_color = Color(1, 0.78, 0.3, 1)
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = 36
offset_right = 208
offset_left = -27
offset_right = 128
offset_top = 690
offset_bottom = 770
theme_override_font_sizes/font_size = 22
@ -1043,8 +1088,8 @@ horizontal_alignment = 1
[node name="FleetLaser" type="Control" parent="UI/HangarMenu"]
anchor_left = 0.5
anchor_right = 0.5
offset_left = 198
offset_right = 290
offset_left = 114
offset_right = 189
offset_top = 565
offset_bottom = 685
script = ExtResource("10")
@ -1055,7 +1100,29 @@ glow_color = Color(0.3, 0.9, 1, 1)
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = 158
offset_left = 74
offset_right = 229
offset_top = 690
offset_bottom = 770
theme_override_font_sizes/font_size = 22
horizontal_alignment = 1
[node name="FleetDistorder" type="Control" parent="UI/HangarMenu"]
anchor_left = 0.5
anchor_right = 0.5
offset_left = 215
offset_right = 290
offset_top = 565
offset_bottom = 685
script = ExtResource("10")
icon = 37
glow_color = Color(0.7, 0.3, 1, 1)
[node name="FleetDistorderLabel" type="Label" parent="UI/HangarMenu"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = 175
offset_right = 330
offset_top = 690
offset_bottom = 770
@ -1107,7 +1174,7 @@ script = ExtResource("24")
anchor_left = 0.5
anchor_right = 0.5
offset_left = -300
offset_right = -230
offset_right = -240
offset_top = 274
offset_bottom = 394
script = ExtResource("10")
@ -1117,8 +1184,8 @@ glow_color = Color(0.25, 0.85, 1, 1)
[node name="VesselB" type="Control" parent="UI/LabMenu"]
anchor_left = 0.5
anchor_right = 0.5
offset_left = -194
offset_right = -124
offset_left = -210
offset_right = -150
offset_top = 274
offset_bottom = 394
script = ExtResource("10")
@ -1128,8 +1195,8 @@ glow_color = Color(0.45, 0.8, 1, 1)
[node name="VesselC" type="Control" parent="UI/LabMenu"]
anchor_left = 0.5
anchor_right = 0.5
offset_left = -88
offset_right = -18
offset_left = -120
offset_right = -60
offset_top = 274
offset_bottom = 394
script = ExtResource("10")
@ -1139,8 +1206,8 @@ glow_color = Color(0.96, 0.32, 0.78, 1)
[node name="VesselD" type="Control" parent="UI/LabMenu"]
anchor_left = 0.5
anchor_right = 0.5
offset_left = 18
offset_right = 88
offset_left = -30
offset_right = 30
offset_top = 274
offset_bottom = 394
script = ExtResource("10")
@ -1150,8 +1217,8 @@ glow_color = Color(0.95, 0.55, 0.85, 1)
[node name="VesselE" type="Control" parent="UI/LabMenu"]
anchor_left = 0.5
anchor_right = 0.5
offset_left = 124
offset_right = 194
offset_left = 60
offset_right = 120
offset_top = 274
offset_bottom = 394
script = ExtResource("10")
@ -1161,14 +1228,25 @@ glow_color = Color(1, 0.78, 0.3, 1)
[node name="VesselF" type="Control" parent="UI/LabMenu"]
anchor_left = 0.5
anchor_right = 0.5
offset_left = 230
offset_right = 300
offset_left = 150
offset_right = 210
offset_top = 274
offset_bottom = 394
script = ExtResource("10")
icon = 35
glow_color = Color(0.3, 0.9, 1, 1)
[node name="VesselG" type="Control" parent="UI/LabMenu"]
anchor_left = 0.5
anchor_right = 0.5
offset_left = 240
offset_right = 300
offset_top = 274
offset_bottom = 394
script = ExtResource("10")
icon = 37
glow_color = Color(0.7, 0.3, 1, 1)
[node name="VesselLabel" type="Label" parent="UI/LabMenu"]
visible = false
mouse_filter = 2
@ -1186,7 +1264,7 @@ mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -330
offset_right = -200
offset_right = -210
offset_top = 400
offset_bottom = 460
theme_override_font_sizes/font_size = 22
@ -1197,8 +1275,8 @@ text = "Interceptor"
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -224
offset_right = -94
offset_left = -240
offset_right = -120
offset_top = 400
offset_bottom = 460
theme_override_font_sizes/font_size = 22
@ -1209,8 +1287,8 @@ text = "Shield"
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -118
offset_right = 12
offset_left = -150
offset_right = -30
offset_top = 400
offset_bottom = 460
theme_override_font_sizes/font_size = 22
@ -1221,8 +1299,8 @@ text = "Gas Bomber"
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = 12
offset_right = 118
offset_left = -60
offset_right = 60
offset_top = 400
offset_bottom = 460
theme_override_font_sizes/font_size = 22
@ -1233,8 +1311,8 @@ text = "Transporter"
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = 94
offset_right = 224
offset_left = 30
offset_right = 150
offset_top = 400
offset_bottom = 460
theme_override_font_sizes/font_size = 22
@ -1245,14 +1323,26 @@ text = "Carrier"
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = 200
offset_right = 330
offset_left = 120
offset_right = 240
offset_top = 400
offset_bottom = 460
theme_override_font_sizes/font_size = 22
horizontal_alignment = 1
text = "Laser Ship"
[node name="VesselGLabel" type="Label" parent="UI/LabMenu"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = 210
offset_right = 330
offset_top = 400
offset_bottom = 460
theme_override_font_sizes/font_size = 22
horizontal_alignment = 1
text = "Distorder"
[node name="VesselUpgradeSeparator" type="ColorRect" parent="UI/LabMenu"]
mouse_filter = 2
anchor_right = 1.0
@ -1498,7 +1588,7 @@ anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -310
offset_right = -232
offset_right = -245
offset_top = -100
offset_bottom = 48
script = ExtResource("10")
@ -1511,7 +1601,7 @@ anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -330
offset_right = -212
offset_right = -225
offset_top = 56
offset_bottom = 136
theme_override_font_sizes/font_size = 24
@ -1523,8 +1613,8 @@ anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -202
offset_right = -124
offset_left = -221
offset_right = -156
offset_top = -100
offset_bottom = 48
script = ExtResource("10")
@ -1537,8 +1627,8 @@ anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -222
offset_right = -104
offset_left = -241
offset_right = -136
offset_top = 56
offset_bottom = 136
theme_override_font_sizes/font_size = 24
@ -1551,8 +1641,8 @@ anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -94
offset_right = -16
offset_left = -132
offset_right = -67
offset_top = -100
offset_bottom = 28
script = ExtResource("10")
@ -1565,8 +1655,8 @@ anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -114
offset_right = 4
offset_left = -152
offset_right = -47
offset_top = 56
offset_bottom = 150
theme_override_font_sizes/font_size = 24
@ -1579,8 +1669,8 @@ anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = 14
offset_right = 92
offset_left = -43
offset_right = 22
offset_top = -100
offset_bottom = 48
script = ExtResource("10")
@ -1593,8 +1683,8 @@ anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = -6
offset_right = 112
offset_left = -63
offset_right = 42
offset_top = 56
offset_bottom = 136
theme_override_font_sizes/font_size = 24
@ -1607,8 +1697,8 @@ anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = 122
offset_right = 200
offset_left = 46
offset_right = 111
offset_top = -100
offset_bottom = 48
script = ExtResource("10")
@ -1621,8 +1711,8 @@ anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = 102
offset_right = 220
offset_left = 26
offset_right = 131
offset_top = 56
offset_bottom = 136
theme_override_font_sizes/font_size = 24
@ -1630,6 +1720,34 @@ autowrap_mode = 2
text = "Laser Ship"
horizontal_alignment = 1
[node name="DistorderButton" type="Control" parent="UI/CraftMenu"]
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = 135
offset_right = 200
offset_top = -100
offset_bottom = 48
script = ExtResource("10")
icon = 37
glow_color = Color(0.7, 0.3, 1, 1)
[node name="DistorderLabel" type="Label" parent="UI/CraftMenu"]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = 115
offset_right = 220
offset_top = 56
offset_bottom = 136
theme_override_font_sizes/font_size = 24
autowrap_mode = 2
text = "Distorder"
horizontal_alignment = 1
[node name="QueueLabel" type="Label" parent="UI/CraftMenu"]
mouse_filter = 2
anchor_left = 1.0

View file

@ -1,4 +1,4 @@
[gd_scene load_steps=15 format=3]
[gd_scene load_steps=16 format=3]
[ext_resource type="Script" path="res://GameController.gd" id="1"]
[ext_resource type="Script" path="res://LunarBackground.gd" id="2"]
@ -10,6 +10,7 @@
[ext_resource type="Script" path="res://ResourceReadout.gd" id="9"]
[ext_resource type="Script" path="res://GasBombController.gd" id="14"]
[ext_resource type="Script" path="res://LaserController.gd" id="16"]
[ext_resource type="Script" path="res://SpeedFader.gd" id="17"]
[ext_resource type="Script" path="res://CaptureLabel.gd" id="15"]
[sub_resource type="ShaderMaterial" id="1"]
@ -157,6 +158,30 @@ script = ExtResource("7")
glow_color = Color(0.06, 0.32, 0.5, 1)
core_color = Color(0.02, 0.09, 0.15, 1)
[node name="SpeedFader" type="Control" parent="UI"]
visible = false
mouse_filter = 2
anchor_left = 1.0
anchor_right = 1.0
offset_left = -94
offset_right = -24
offset_top = 740
offset_bottom = 920
script = ExtResource("17")
glow_color = Color(0.5, 0.18, 0.6, 1)
core_color = Color(0.02, 0.09, 0.15, 1)
[node name="Label" type="Label" parent="UI/SpeedFader"]
mouse_filter = 2
offset_left = -20
offset_right = 90
offset_top = -32
offset_bottom = -4
theme_override_font_sizes/font_size = 20
theme_override_colors/font_color = Color(0.7, 0.3, 1, 1)
horizontal_alignment = 1
text = "x1.0"
[node name="LaserButton" type="Control" parent="UI"]
visible = false
mouse_filter = 2

21
Ship.gd
View file

@ -97,16 +97,29 @@ func _physics_process(delta: float) -> void:
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 to_target: Vector2 = move_target - position
var distance: float = to_target.length()
if distance <= arrive_radius:
if distance <= arrive_radius * boost:
position = move_target
velocity = Vector2.ZERO
else:
var braking_speed: float = sqrt(2.0 * acceleration * distance)
var desired_speed: float = min(max_speed, braking_speed)
var effective_acceleration: float = acceleration * boost
var braking_speed: float = sqrt(2.0 * effective_acceleration * distance)
var desired_speed: float = min(max_speed * boost, braking_speed)
var desired_velocity: Vector2 = to_target.normalized() * desired_speed
velocity = velocity.move_toward(desired_velocity, acceleration * delta)
velocity = velocity.move_toward(desired_velocity, effective_acceleration * delta)
position += velocity * delta
var motion: Vector2 = velocity + fleet_velocity

112
SpeedFader.gd Normal file
View file

@ -0,0 +1,112 @@
class_name SpeedFader
extends Control
# Vertical drag control for GameState.fleet_speed_multiplier. Same raw-touch
# idiom as TouchJoystick (handle_touch/handle_drag offered first by
# GameController, not _gui_input) and the same NeonDraw look, but a fader
# rather than a stick: the knob's Y position along a fixed vertical track IS
# the value, no spring-back to centre.
#
# Self-contained: writes straight to GameState.fleet_speed_multiplier rather
# than reporting a value for something else to apply, since the multiplier
# already lives in the one place (the autoload) that needs to survive scene
# swaps.
@export var glow_color: Color = Color(0.7, 0.3, 1.0)
@export var core_color: Color = Color(1.0, 1.0, 1.0)
@export var track_margin: float = 28.0
@onready var fleet: Node2D = get_node("../../Fleet")
@onready var label: Label = get_node("Label")
var active_touch_index: int = -1
# 0 = bottom of the track (1.0x), 1 = top (GameState.MAX_FLEET_SPEED_MULTIPLIER).
var knob_t: float = 0.0
func _ready() -> void:
visible = false
queue_redraw()
func _process(_delta: float) -> void:
var has_distorder: bool = fleet.has_capability("distorder")
if has_distorder != visible:
visible = has_distorder
if not has_distorder:
# A fleet must never be left permanently boosted by a vessel it no
# longer has - reset the instant the capability disappears, not just
# hide the control on top of a stale multiplier.
active_touch_index = -1
if GameState.fleet_speed_multiplier != 1.0:
GameState.fleet_speed_multiplier = 1.0
knob_t = 0.0
return
knob_t = (GameState.fleet_speed_multiplier - 1.0) \
/ maxf(GameState.MAX_FLEET_SPEED_MULTIPLIER - 1.0, 0.001)
label.text = "x%.1f" % GameState.fleet_speed_multiplier
queue_redraw()
func is_active() -> bool:
return active_touch_index != -1
func _blocked_by_modal() -> bool:
# Same check as IconButton._blocked_by_modal() - panels stay transparent
# to raw input (the skill tree/formation editor need _unhandled_input),
# so every raw-touch control checks for itself instead.
for menu in get_tree().get_nodes_in_group("modal_menus"):
if menu.is_visible_in_tree() and not menu.is_ancestor_of(self):
return true
return false
func handle_touch(event: InputEventScreenTouch) -> bool:
var local: Vector2 = event.position - global_position
if event.pressed:
if not visible or active_touch_index != -1 or _blocked_by_modal():
return false
if not Rect2(Vector2.ZERO, size).has_point(local):
return false
active_touch_index = event.index
_update_from_local(local)
return true
if event.index != active_touch_index:
return false
active_touch_index = -1
queue_redraw()
return true
func handle_drag(event: InputEventScreenDrag) -> bool:
if event.index != active_touch_index:
return false
_update_from_local(event.position - global_position)
return true
func _update_from_local(local: Vector2) -> void:
var usable: float = maxf(size.y - track_margin * 2.0, 1.0)
# Up is 1.0 (top of the track), matching how a real fader/throttle reads.
var t: float = 1.0 - clampf((local.y - track_margin) / usable, 0.0, 1.0)
knob_t = t
GameState.set_fleet_speed_multiplier(1.0 + t * (GameState.MAX_FLEET_SPEED_MULTIPLIER - 1.0))
label.text = "x%.1f" % GameState.fleet_speed_multiplier
queue_redraw()
func _draw() -> void:
var top: Vector2 = Vector2(size.x * 0.5, track_margin)
var bottom: Vector2 = Vector2(size.x * 0.5, size.y - track_margin)
NeonDraw.draw_glowing_line(self, bottom, top, glow_color, 0.55, 0.5, core_color)
# Tick marks at the extremes read as "min" and "max" at a glance.
for t in [0.0, 1.0]:
var y: float = lerpf(bottom.y, top.y, t)
NeonDraw.draw_glowing_line(self, Vector2(size.x * 0.5 - 12.0, y),
Vector2(size.x * 0.5 + 12.0, y), glow_color, 0.4, 0.35, core_color)
var knob_pos: Vector2 = bottom.lerp(top, clampf(knob_t, 0.0, 1.0))
NeonDraw.draw_glowing_circle(self, knob_pos, 16.0, glow_color,
1.0 if is_active() else 0.8, core_color)

View file

@ -1,7 +1,7 @@
class_name VesselDefinitions
extends RefCounted
const ORDER: PackedStringArray = ["interceptor", "shield", "gas_bomber", "transporter", "carrier", "laser"]
const ORDER: PackedStringArray = ["interceptor", "shield", "gas_bomber", "transporter", "carrier", "laser", "distorder"]
const DEFINITIONS: Dictionary = {
"interceptor": preload("res://InterceptorVesselDefinition.tres"),
"shield": preload("res://ShieldVesselDefinition.tres"),
@ -9,6 +9,7 @@ const DEFINITIONS: Dictionary = {
"transporter": preload("res://TransporterVesselDefinition.tres"),
"carrier": preload("res://CarrierVesselDefinition.tres"),
"laser": preload("res://LaserVesselDefinition.tres"),
"distorder": preload("res://DistorderVesselDefinition.tres"),
}
static func get_definition(definition_id: String) -> Resource:

View file

@ -0,0 +1,9 @@
class_name VesselDistorderComponent
extends Node
# Pure presence marker (#59) - no per-shot behaviour, no tick(). Its only
# job is to make Ship.has_capability("distorder") true, which is what
# SpeedFader.gd/Fleet.gd key off to reveal and apply the speed boost.
# Energy drain while boosted is a later ticket (#62).
var capability_id := "distorder"

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselDistorderComponent.gd" id="1"]
[node name="DistorderComponent" type="Node"]
script = ExtResource("1")

View file

@ -0,0 +1,232 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_space_time_distorder.gd
# Forgejo #59: a researchable vessel that lets the whole fleet travel faster
# than its default cruise speed, via a fader driving
# game_state.fleet_speed_multiplier.
#
# Covers:
# A. the fader is absent with no distorder ship, appears once one joins
# B. dragging it up measurably increases distance travelled per second;
# at 1.0 the fleet moves exactly as it does today
# C. formation holds together under boost - ships stay near their slot
# D. losing the vessel resets the multiplier and hides the fader
# E. the multiplier survives the space -> surface scene swap (it lives
# in the autoload)
var game_state: Node
var main: Node
var fleet: Node
var fader: Node
var stage: int = 0
var elapsed: float = 0.0
var baseline_distance: float = 0.0
var boosted_distance: float = 0.0
var start_centroid: Vector2 = Vector2.ZERO
var unboosted_lag: float = 0.0
const MEASURE_TIME: float = 1.0
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.transporters.clear()
game_state.stored_vessels.clear()
game_state.reset_progression()
game_state.vessel_levels["unlock_distorder"] = 1
main = load("res://Main.tscn").instantiate()
root.add_child(main)
fleet = main.get_node("Fleet")
fader = main.get_node("UI/SpeedFader")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if elapsed < 0.2:
return false
main.get_node("EnemySpawner").set_process(false)
main.get_node("AsteroidField").set_process(false)
# --- A. absent, then appears -------------------------------------
if fader.visible:
return _fail_now("fader appeared without a distorder ship")
fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray([
GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_DISTORDER]))
fader._process(0.0)
if not fader.visible:
return _fail_now("a distorder ship in the fleet did not reveal the fader")
print("fader hidden with no distorder ship, visible once one joins")
# --- B. baseline @ 1.0x -------------------------------------------
if not is_equal_approx(game_state.fleet_speed_multiplier, 1.0):
return _fail_now("multiplier is not 1.0 by default: %f" % game_state.fleet_speed_multiplier)
start_centroid = fleet.get_centroid()
fleet.move_in_direction(Vector2(1.0, 0.0))
elapsed = 0.0
stage = 1
return false
1:
if elapsed < MEASURE_TIME:
return false
baseline_distance = fleet.get_centroid().distance_to(start_centroid)
# Reference for the formation check below: pure pursuit (a ship
# always aiming straight at its CURRENT, still-moving target
# rather than leading it) has its own inherent non-zero steady-
# state following distance even at 1.0x - it scales with the
# speeds involved, so "holds together under boost" has to mean
# staying proportional to this, not shrinking to some fixed
# absolute number regardless of how fast the fleet is going.
for i in range(fleet.ships.size()):
var ship: Node2D = fleet.ships[i]
if not is_instance_valid(ship):
continue
var expected: Vector2 = fleet.fleet_target + fleet.formation_offsets[i]
unboosted_lag = maxf(unboosted_lag, ship.position.distance_to(expected))
fleet.stop()
print("baseline (1.0x): travelled %.1f over %.1fs, formation lag %.1f" \
% [baseline_distance, MEASURE_TIME, unboosted_lag])
elapsed = 0.0
stage = 2
return false
2:
# Let the braking manoeuvre from stop() fully settle before
# measuring the boosted leg, so leftover momentum from the
# baseline pass can't pad the comparison either way.
if elapsed < 0.5:
return false
var press := InputEventScreenTouch.new()
press.index = 5
press.position = fader.global_position + Vector2(fader.size.x * 0.5, fader.size.y - 4.0)
press.pressed = true
main._handle_touch(press)
if not fader.is_active():
return _fail_now("pressing the fader did not begin dragging")
var drag := InputEventScreenDrag.new()
drag.index = 5
# All the way to the top of the track = MAX_FLEET_SPEED_MULTIPLIER.
drag.position = fader.global_position + Vector2(fader.size.x * 0.5, 0.0)
main._handle_drag(drag)
if not is_equal_approx(game_state.fleet_speed_multiplier, GameState.MAX_FLEET_SPEED_MULTIPLIER):
return _fail_now("dragging to the top did not reach the max multiplier: %f" \
% game_state.fleet_speed_multiplier)
print("fader dragged to %.1fx" % game_state.fleet_speed_multiplier)
start_centroid = fleet.get_centroid()
fleet.move_in_direction(Vector2(1.0, 0.0))
elapsed = 0.0
stage = 3
return false
3:
if elapsed < MEASURE_TIME:
return false
boosted_distance = fleet.get_centroid().distance_to(start_centroid)
print("boosted (%.1fx): travelled %.1f over %.1fs" \
% [game_state.fleet_speed_multiplier, boosted_distance, MEASURE_TIME])
if boosted_distance <= baseline_distance * 1.3:
return _fail_now("boosted travel (%.1f) was not measurably more than baseline (%.1f)" \
% [boosted_distance, baseline_distance])
elapsed = 0.0
stage = 4
return false
4:
# --- C. formation holds together under boost ----------------------
# Both the anchor's advance and the ships' own catch-up speed are
# scaled by the SAME multiplier, so the initial accelerate-from-
# rest transient covers a proportionally larger absolute distance
# under a 3x boost - a moment right after the boost begins isn't
# "trailing", it just hasn't finished the same (time-invariant)
# catch-up burst yet. Keep steering a while longer so that burst
# is over before judging whether formation holds.
if elapsed < 1.5:
return false
# Allow the lag to scale with the boost (it inherently does, even
# with both the anchor and ship speeds multiplied by the exact
# same factor - see the note by unboosted_lag above) but not
# beyond that, with slack for measurement noise.
var allowed_lag: float = maxf(unboosted_lag, 20.0) * game_state.fleet_speed_multiplier * 1.5
for i in range(fleet.ships.size()):
var ship: Node2D = fleet.ships[i]
if not is_instance_valid(ship):
continue
var expected: Vector2 = fleet.fleet_target + fleet.formation_offsets[i]
var strayed: float = ship.position.distance_to(expected)
if strayed > allowed_lag:
return _fail_now("a ship strayed %.1f from its formation slot under sustained boost (unboosted lag was %.1f, allowed %.1f)" \
% [strayed, unboosted_lag, allowed_lag])
print("formation held together under sustained boost (lag stayed proportional to the boost)")
fleet.stop()
var release := InputEventScreenTouch.new()
release.index = 5
release.position = fader.global_position + Vector2(fader.size.x * 0.5, 0.0)
release.pressed = false
main._handle_touch(release)
# --- E. survives the space -> surface scene swap -------------------
# Done here, with the distorder ship still crewed and the fleet
# still boosted: tearing this scene down while nothing else
# changes the fleet's own composition proves the multiplier
# lives in the autoload rather than being reset as a side effect
# of the old scene's own SpeedFader still ticking once more
# during teardown (it would, correctly, self-reset the instant
# it next saw a fleet with no distorder ship - not a real
# scenario here since this fleet keeps its distorder ship the
# whole way across the swap, via GameState.surviving_vessel_types
# exactly like every other vessel type).
game_state.surviving_ship_count = fleet.ships.size()
game_state.surviving_vessel_types = fleet.vessel_types()
game_state.planet_id = "test_space_time_distorder"
game_state.planet_name = "Distorder Test"
main.queue_free()
elapsed = 0.0
stage = 5
return false
5:
if elapsed < 0.3:
return false
main = load("res://PlanetSurface.tscn").instantiate()
root.add_child(main)
current_scene = main
fleet = main.get_node("Fleet")
fader = main.get_node("UI/SpeedFader")
elapsed = 0.0
stage = 6
return false
6:
if elapsed < 0.3:
return false
if not is_equal_approx(game_state.fleet_speed_multiplier, 3.0):
return _fail_now("multiplier did not survive the space -> surface swap: %f" \
% game_state.fleet_speed_multiplier)
fader._process(0.0)
if not fader.visible:
return _fail_now("the surface scene's fader did not show the surviving distorder ship")
print("multiplier and fader both survived the space -> surface swap")
# --- D. losing the vessel resets the multiplier -------------------
fleet.spawn_ships(1, fleet.get_centroid(), PackedStringArray([GameState.VESSEL_INTERCEPTOR]))
fader._process(0.0)
if fader.visible:
return _fail_now("fader stayed visible after losing the last distorder ship")
if not is_equal_approx(game_state.fleet_speed_multiplier, 1.0):
return _fail_now("multiplier was not reset after losing the distorder ship: %f" \
% game_state.fleet_speed_multiplier)
print("losing the distorder ship reset the multiplier and hid the fader")
print("TEST_SPACE_TIME_DISTORDER: PASS")
quit(0)
return true
return false
func _fail_now(message: String) -> bool:
print("TEST_SPACE_TIME_DISTORDER: FAIL (%s)" % message)
quit(1)
return true