Compare commits

...
Author SHA1 Message Date
Loïc Wisniewski
d68f4d4f88 Refactor recent gameplay systems after code review 2026-08-16 21:38:07 +02:00
Loïc Wisniewski
3f3a22227d Add research lab planets and research points (#63) 2026-08-16 20:14:29 +02:00
Loïc Wisniewski
651f8b787e Space-time distorder drains energy while boosting (#62)
GameState._advance_distorder_drain() runs in GameState._process() -
not the distorder component's tick() - alongside extractor mining and
production, so it (and the fleet's travel) keeps working across the
space<->surface scene swap. Drain scales with how far above 1.0 the
multiplier is: (fleet_speed_multiplier - 1.0) / (MAX - 1.0) *
ENERGY_DRAIN_PER_SECOND (2.0 at full boost), accumulated fractionally
the same way extractor mining_progress is so small multipliers still
drain a nonzero amount instead of rounding to zero every frame.

Running the fleet hold's energy to exactly zero force-resets
fleet_speed_multiplier to 1.0 and raises a one-shot
boost_depleted_notice flag, since GameState can't reach into whichever
scene's SpeedFader may or may not currently exist. SpeedFader.gd
consumes the flag on its next _process(), snaps its knob down (it was
already reading the multiplier live, so this falls out for free) and
shows "Out of energy!" - also clearing active_touch_index so a
still-held drag can't immediately re-boost from the position the
finger never moved away from.

test_space_time_distorder.gd (#59) updated to stock fleet energy
first, for the same reason #60/#61 needed it: boosting is no longer
free, and that test's own multi-second boosted phases would otherwise
get cut short mid-measurement by running out.
2026-08-16 19:21:54 +02:00
Loïc Wisniewski
facbd83dd2 Laser shooters consume energy from the fleet hold when firing (#61)
Mirrors #60's gas bomber ammunition exactly: ENERGY_PER_SHOT (8) drawn
from GameState.fleet_inventory before consuming the firing ship's
cooldown, the button's enabled rule requires affordability too, and an
unaffordable-but-otherwise-ready press still explains itself through a
new LaserStatus label rather than silently doing nothing.

Added two small generic GameState helpers this needed:
deposit_fleet_resource()/deposit_bank_resource(), counterparts to
#60's withdraw_fleet_resource() for resources (energy) with no
dedicated fleet_cargo/fleet_gas_cargo-style property. Along the way,
confirmed deposit_resource_to_building() on a "bank" building deposits
into that building's own per-record inventory, NOT bank_inventory
itself - only building_resource()/building_inventory() special-case
the "bank" capability to redirect there.

test_laser_vessel.gd (#58) updated to stock fleet energy first, since
shots are no longer free - same fix #60 required for the gas bomber's
own pre-existing tests. Also fixed test_laser_vessel.gd's own instance
of the bare-ClassName.CONST script-corruption trap (documented in
#60's commit): var skill: LaserController and a bare
LaserController.BEAM_HALF_WIDTH reference, mirrored into a local const
instead.
2026-08-16 19:11:31 +02:00
Loïc Wisniewski
17d0cf7e66 Gas bombers consume gas from the fleet hold when firing (#60)
Shots cost GAS_PER_SHOT (5) from GameState.fleet_inventory, not the
bank - the fleet has to be carrying its own ammunition. The button's
enabled rule now also requires affordability, and a press on a ready-
but-unaffordable bomber still consumes the touch to explain itself via
a new status label (GasBombStatus, IconButton's own "greyed but still
pressable" convention) rather than either silently doing nothing or
consuming the bomber's cooldown for a shot that never fires.

Also documents a real, reproducible GDScript trap hit while writing
tests/test_gas_bomber_ammo.gd: a bare `ClassName.SOME_CONST` reference
anywhere in a -s entry test script can corrupt method resolution for
the WHOLE script, making an unrelated, earlier `some_node._process()`
call fail with "Nonexistent function '_process' in base 'Nil'" despite
the node being valid and the method existing (confirmed via
has_method()). Root cause unconfirmed; fixed by mirroring the constant
's value into a local const instead of referencing the class directly,
in all three gas-bomber tests.

tests/test_gas_bomber.gd and test_gas_bomber_cooldowns.gd updated to
stock fleet gas first, since shots are no longer free.
2026-08-16 18:54:44 +02:00
Loïc Wisniewski
116e0f97d3 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).
2026-08-16 17:59:04 +02:00
111 changed files with 2269 additions and 632 deletions

View file

@ -0,0 +1,191 @@
class_name AimedFleetAbilityController
extends Node2D
# Shared press-drag-release input and camera behavior for manually aimed fleet
# abilities. Subclasses provide the component, ammunition, target calculation,
# shot effect, and effect visuals.
const AIM_DEADZONE: float = 22.0
@export var minimum_zoom: float = 0.4
@onready var fleet: Node2D = get_node("../Fleet")
@onready var camera: Camera2D = get_node("../Camera2D")
var button: IconButton
var status: Label
var status_message_until: int = 0
var aiming: bool = false
var aim_touch: int = -1
var aim_start_screen: Vector2 = Vector2.ZERO
var aim_screen: Vector2 = Vector2.ZERO
var aim_target: Vector2 = Vector2.ZERO
var saved_zoom: float = 1.0
var restoring_zoom: bool = false
func _ready() -> void:
button = get_node(_button_path())
status = get_node(_status_path())
button.visible = false
status.text = ""
queue_redraw()
func _process(delta: float) -> void:
var had_visuals: bool = aiming or _has_effect_visuals()
button.visible = fleet.has_capability(_component_id())
button.enabled = _next_ready_ship() != null and _can_afford_shot()
if not button.visible and aiming:
_cancel_aim()
if status.text != "" and Time.get_ticks_msec() >= status_message_until:
status.text = ""
if restoring_zoom and not aiming:
var zoom: float = move_toward(camera.zoom.x, saved_zoom,
maxf(saved_zoom, 0.1) * delta * 5.0)
camera.zoom = Vector2(zoom, zoom)
if is_equal_approx(zoom, saved_zoom):
restoring_zoom = false
_advance_effects(delta)
if aiming or _has_effect_visuals() or had_visuals:
queue_redraw()
func can_begin_aim() -> bool:
# An unaffordable shot still claims the press so the status label can
# explain what is missing instead of silently passing the touch through.
return button.visible and not button._blocked_by_modal() \
and not aiming and _next_ready_ship() != null
func handle_touch(event: InputEventScreenTouch) -> bool:
if event.pressed:
if not can_begin_aim() \
or not Rect2(button.global_position, button.size).has_point(event.position):
return false
if not _can_afford_shot():
_say(_unaffordable_message())
return true
aiming = true
aim_touch = event.index
aim_start_screen = event.position
aim_screen = event.position
saved_zoom = camera.zoom.x
restoring_zoom = false
button.pressed_down = true
button.queue_redraw()
_update_aim()
return true
if event.index != aim_touch:
return false
if aim_start_screen.distance_to(aim_screen) >= AIM_DEADZONE:
_fire()
else:
_cancel_aim()
return true
func handle_drag(event: InputEventScreenDrag) -> bool:
if not aiming or event.index != aim_touch:
return false
aim_screen = event.position
_update_aim()
return true
func _update_aim() -> void:
var origin: Vector2 = _aim_origin()
aim_target = _calculate_aim_target(aim_screen - aim_start_screen, origin)
_update_aim_zoom(origin, aim_target)
queue_redraw()
func _update_aim_zoom(origin: Vector2, target: Vector2) -> void:
var displacement: Vector2 = target - origin
var half_screen: Vector2 = get_viewport_rect().size * 0.5 - Vector2(54.0, 54.0)
var padding: float = _aim_padding()
var fit_x: float = saved_zoom if absf(displacement.x) < 1.0 \
else half_screen.x / (absf(displacement.x) + padding)
var fit_y: float = saved_zoom if absf(displacement.y) < 1.0 \
else half_screen.y / (absf(displacement.y) + padding)
var desired: float = clampf(minf(saved_zoom, minf(fit_x, fit_y)),
minimum_zoom, saved_zoom)
camera.zoom = Vector2(desired, desired)
func _finish_aim() -> void:
aiming = false
aim_touch = -1
button.pressed_down = false
button.queue_redraw()
restoring_zoom = true
func _cancel_aim() -> void:
_finish_aim()
queue_redraw()
func _fire() -> void:
var ship: Node2D = _next_ready_ship()
if ship == null:
_cancel_aim()
return
if not _can_afford_shot():
_say(_unaffordable_message())
_cancel_aim()
return
var component: Node = ship.get_component(_component_id())
if component == null or not _consume_shot(component):
_cancel_aim()
return
GameState.withdraw_fleet_resource(_ammo_resource(), _ammo_cost())
_perform_shot(ship, component)
_finish_aim()
button.enabled = _next_ready_ship() != null and _can_afford_shot()
func _next_ready_ship() -> Node2D:
for ship in fleet.ships:
if not is_instance_valid(ship):
continue
var component: Node = ship.get_component(_component_id())
if component != null and component.is_ready():
return ship
return null
func _can_afford_shot() -> bool:
return GameState.fleet_resource(_ammo_resource()) >= _ammo_cost()
func _unaffordable_message() -> String:
var resource_type: String = _ammo_resource()
return "Need %d %s in the fleet hold to fire (have %d)" % [
_ammo_cost(), GameState.resource_name(resource_type).to_lower(),
GameState.fleet_resource(resource_type),
]
func _say(message: String) -> void:
status.text = message
status_message_until = Time.get_ticks_msec() + 3000
func _aim_origin() -> Vector2:
return fleet.get_centroid()
# Subclass hooks.
func _button_path() -> NodePath: return NodePath()
func _status_path() -> NodePath: return NodePath()
func _component_id() -> String: return ""
func _ammo_resource() -> String: return ""
func _ammo_cost() -> int: return 0
func _aim_padding() -> float: return 0.0
func _has_effect_visuals() -> bool: return false
func _advance_effects(_delta: float) -> void: pass
func _consume_shot(_component: Node) -> bool: return false
func _perform_shot(_ship: Node2D, _component: Node) -> void: pass
func _calculate_aim_target(_drag: Vector2, origin: Vector2) -> Vector2: return origin

View file

@ -0,0 +1 @@
uid://dbahjmvwru7bp

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) ---------------------------------------------
@ -672,6 +680,13 @@ func _confirm_build() -> void:
if not GameState.is_planet_conquered(planet.planet_id):
build_status.text = "%s is not captured yet" % planet.planet_name
return
if String(planet.resource_type) == "":
# #63: a research_lab planet's whole point is that it yields
# nothing to mine - an extractor there would sit on an empty
# resource id with no ResourceDefinition, breaking its own
# glyph/HUD rendering the moment it produced anything.
build_status.text = "%s has nothing to extract" % planet.planet_name
return
if not GameState.spend(definition.cost):
build_status.text = "Not enough rocks (need %d, have %d)" % [definition.cost, GameState.rocks]
return
@ -715,14 +730,18 @@ func _refresh_skill_menu(id: String = "") -> void:
# leaving the panel blank, which reads as broken.
skill_detail.text = "Select a node to see what it unlocks."
skill_cost.amount = 0
skill_status.text = "Fleet capacity %d" % GameState.max_fleet_size()
skill_status.text = "Fleet capacity %d · Research points %d" \
% [GameState.max_fleet_size(), GameState.research_points]
skill_unlock_button.visible = false
return
var node: Dictionary = GameState.skill_node(id)
if node.is_empty():
return
var points_needed: int = GameState.skill_research_point_cost(id)
skill_detail.text = "%s\n%s" % [node.name, node.desc]
if points_needed > 0:
skill_detail.text += "\nResearch points: %d/%d" % [GameState.research_points, points_needed]
skill_cost.amount = int(node.cost)
if GameState.is_unlocked(id):
@ -734,6 +753,12 @@ func _refresh_skill_menu(id: String = "") -> void:
elif not GameState.can_afford(int(node.cost)):
skill_status.text = "Not enough rocks - you have %d" % GameState.rocks
skill_unlock_button.visible = false
elif GameState.research_points < points_needed:
# #63: a distinct reason from the ore one above - conflating them
# would leave the player trying to fix the wrong shortage.
skill_status.text = "Not enough research points - you have %d, need %d" \
% [GameState.research_points, points_needed]
skill_unlock_button.visible = false
else:
skill_status.text = ""
skill_unlock_button.visible = true
@ -894,7 +919,8 @@ func _refresh_lab() -> void:
lab_vessel_labels[i].text = definition.display_name
lab_vessel_buttons[i].highlighted = this_vessel == lab_vessel
lab_vessel_buttons[i].queue_redraw()
lab_vessel_label.text = "%s research" % GameState.vessel_name(lab_vessel)
lab_vessel_label.text = "%s research · %d RP" % [
GameState.vessel_name(lab_vessel), GameState.research_points]
var options: Array = _lab_options()
for i in range(lab_upgrade_buttons.size()):
@ -906,6 +932,9 @@ func _refresh_lab() -> void:
var option: Dictionary = options[i]
var price_text: String = _format_costs(option.costs if bool(option.unlock) \
else GameState.research_costs(option.id))
var point_cost: int = GameState.research_point_cost(String(option.id))
if point_cost > 0:
price_text += " · %d RP" % point_cost
if not bool(option.unlock) and GameState.upgrade_maxed(option.id):
price_text = "MAX"
var level_note: String = "" if bool(option.unlock) else " %d/%d" % [
@ -924,11 +953,22 @@ func _refresh_lab() -> void:
lab_choice = clampi(lab_choice, 0, options.size() - 1)
var choice: Dictionary = options[lab_choice]
var choice_points: int = GameState.research_point_cost(String(choice.id))
lab_detail.text = "%s\n%s" % [choice.name, choice.desc]
if choice_points > 0:
lab_detail.text += "\nResearch points: %d/%d" % [GameState.research_points, choice_points]
var queued: Dictionary = GameState.lab_research_order(lab_building_id)
lab_buy_button.enabled = queued.is_empty() and (choice.unlock or not GameState.upgrade_maxed(choice.id))
if not queued.is_empty():
lab_status.text = "%s %d%%" % ["Researching" if bool(queued.started) else "Waiting for resources", int(100.0 * float(queued.elapsed) / maxf(GameState.lab_research_time(), 0.001))]
var queued_points: int = GameState.research_point_cost(String(queued.id))
if not bool(queued.started) and GameState.research_points < queued_points:
# #63: distinct from "Waiting for resources" - flying more ore/gas
# to this lab cannot fix a research-point shortfall, only
# conquering a research lab elsewhere can.
lab_status.text = "Need %d research point%s (have %d)" \
% [queued_points, "" if queued_points == 1 else "s", GameState.research_points]
else:
lab_status.text = "%s %d%%" % ["Researching" if bool(queued.started) else "Waiting for resources", int(100.0 * float(queued.elapsed) / maxf(GameState.lab_research_time(), 0.001))]
lab_buy_button.queue_redraw()
@ -1003,7 +1043,7 @@ func _transfer_place_name() -> String:
var b: Dictionary = GameState.find_building(transfer_building_id)
if b.is_empty():
return "this building"
if b.type == GameState.BUILDING_MOTHERSHIP:
if GameState.building_has_capability(b, "bank"):
# Worth naming as the bank rather than as a structure: this is the
# one building where unloading makes the ore spendable everywhere.
return "the bank"
@ -1016,7 +1056,7 @@ func _refresh_transfer() -> void:
return
var building: Dictionary = GameState.find_building(transfer_building_id)
var heading: String = "This building"
if not building.is_empty() and building.type == GameState.BUILDING_MOTHERSHIP:
if GameState.building_has_capability(building, "bank"):
heading = "Mothership bank"
transfer_list.set_destination(transfer_building_id, heading)

View file

@ -9,6 +9,7 @@ extends Resource
@export var menu_icon: int
@export var visual_scene: PackedScene
@export var capabilities: PackedStringArray = []
@export var logistics: Resource
@export var map_color: Color = Color.WHITE
@export var map_sides: int = 4
@export var map_radius: float = 7.0

View file

@ -0,0 +1,13 @@
class_name BuildingLogisticsProfile
extends Resource
# Optional logistics and conversion behavior composed into a building
# definition. GameState consumes this data generically, so adding another
# converter does not require another resource-type branch in transport code.
@export var input_buffers: Dictionary = {}
@export var collected_outputs: PackedStringArray = []
@export var conversion_input: String = ""
@export var conversion_output: String = ""
@export var conversion_rate: float = 0.0
@export var conversion_yield: int = 1

View file

@ -0,0 +1 @@
uid://cydp18abylrgd

View file

@ -7,5 +7,6 @@ display_name = "Reinforced Hold"
description = "+30 cargo capacity per level."
base_cost = 40
max_level = 3
research_point_cost = 1
icon = 21
additive_modifiers = {"cargo_capacity": 30.0}

View file

@ -22,4 +22,5 @@ unlock_name = "Unlock Carrier"
unlock_description = "Research a dedicated hauler hull that trades weapons for a much larger cargo hold."
unlock_cost = 0
unlock_resource_costs = {"ore": 60}
research_point_cost = 1
unlock_icon = -1

View file

@ -0,0 +1,25 @@
[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}
research_point_cost = 1
unlock_icon = -1

1
EnemyBigLaserBeam.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://coopf1u1lnb8b

View file

@ -0,0 +1 @@
uid://6oj7ia3em4x0

View file

@ -0,0 +1 @@
uid://eqkp0coeoh4d

View file

@ -0,0 +1 @@
uid://b3b64pvw4y53x

View file

@ -80,7 +80,7 @@ func spawn_ships(count: int, center: Vector2 = Vector2.ZERO,
ship.destroyed.connect(_on_ship_destroyed.bind(ship))
fleet_target = center
GameState.sync_fleet_cargo_capacity(vessel_types())
_sync_composition_state()
func vessel_types() -> PackedStringArray:
@ -91,6 +91,15 @@ func vessel_types() -> PackedStringArray:
return result
func _sync_composition_state() -> void:
GameState.sync_fleet_cargo_capacity(vessel_types())
# Composition changes are authoritative; scene teardown is not. Keeping
# this out of SpeedFader prevents an outgoing scene's empty fleet from
# resetting a boost while the same vessels are being restored on a surface.
if not has_capability("distorder"):
GameState.set_fleet_speed_multiplier(1.0)
func has_vessel_type(vessel_type: String) -> bool:
for ship in ships:
if is_instance_valid(ship) and String(ship.vessel_type) == vessel_type:
@ -139,7 +148,7 @@ func add_ship(vessel_type: String = GameState.VESSEL_INTERCEPTOR) -> bool:
if ship.has_signal("destroyed"):
ship.destroyed.connect(_on_ship_destroyed.bind(ship))
ship.set_move_target(fleet_target + offset)
GameState.sync_fleet_cargo_capacity(vessel_types())
_sync_composition_state()
return true
@ -157,7 +166,7 @@ func store_ship(index: int) -> String:
formation_offsets.remove_at(index)
ship.queue_free()
apply_formation()
GameState.sync_fleet_cargo_capacity(vessel_types())
_sync_composition_state()
return vessel_type
@ -200,18 +209,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:
@ -276,7 +292,7 @@ func _on_ship_destroyed(ship: Node) -> void:
if idx != -1:
ships.remove_at(idx)
formation_offsets.remove_at(idx)
GameState.sync_fleet_cargo_capacity(vessel_types())
_sync_composition_state()
func get_centroid() -> Vector2:

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

@ -4,6 +4,7 @@ const INVENTORY = preload("res://Inventory.gd")
const RESOURCE_DEFINITIONS = preload("res://ResourceDefinitions.gd")
const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd")
const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd")
const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd")
# Autoload singleton - the one thing that survives change_scene_to_file()
@ -22,6 +23,35 @@ 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
# #62: energy cost of sustaining a boost, scaled by how far above 1.0 the
# multiplier is - a small boost costs proportionally less, full boost costs
# ENERGY_DRAIN_PER_SECOND. Sitting at exactly 1.0 costs nothing.
const ENERGY_DRAIN_PER_SECOND: float = 2.0
var _distorder_drain_progress: float = 0.0
# One-shot notice, consumed and cleared by SpeedFader.gd: true the instant
# a sustained boost is force-reset for running the fleet hold's energy
# to zero, so the control (which may not even be part of the current
# scene tree at that exact frame) can show why on its next _process().
var boost_depleted_notice: bool = false
func set_fleet_speed_multiplier(value: float) -> void:
fleet_speed_multiplier = clampf(value, 1.0, MAX_FLEET_SPEED_MULTIPLIER)
# #63: currency gated behind conquering a research_lab planet - required by
# both progression paths (skill tree nodes, vessel/upgrade research) on top
# of their existing ore/resource costs.
signal research_points_changed(total: int)
var research_points: int = 0
# 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
@ -93,6 +123,26 @@ func fleet_resource(resource_type: String) -> int:
return fleet_inventory.amount(resource_type)
# #60: consumed outright (ammunition), not moved to another inventory like
# transfer_resource_to_building/_fleet - returns the amount actually taken.
func withdraw_fleet_resource(resource_type: String, amount: int) -> int:
return fleet_inventory.withdraw(resource_type, amount)
# #61: generic counterparts, for resources (energy) with no dedicated
# fleet_cargo/fleet_gas_cargo-style property of their own. Deliberately NOT
# the same as deposit_resource_to_building() on a "bank" building - that
# deposits into the building's OWN per-record inventory, not bank_inventory
# itself (only building_resource()/building_inventory() special-case "bank"
# capability to redirect there).
func deposit_fleet_resource(resource_type: String, amount: int) -> int:
return fleet_inventory.deposit(resource_type, amount)
func deposit_bank_resource(resource_type: String, amount: int) -> int:
return bank_inventory.deposit(resource_type, amount)
# #56: total across every resource type in the hold, and the combined cap
# that limits it - a HUD readout wants both, not any one resource's amount.
func fleet_cargo_used() -> int:
@ -230,6 +280,13 @@ var buildings: Array = []
# tree always reads root-at-the-bottom and cannot drift out of step with the
# dependencies when nodes are added. `requires` is a single prerequisite id
# ("" for a root).
# #63: research_points (default 0 where omitted) is a SEPARATE currency from
# cost (ore), spent only from GameState.research_points - earned by
# conquering a research_lab planet, never bought. Balance, decided
# deliberately: shipyards and logistics stay at 0 so a fresh game can still
# reach a ship crafter and an extractor with zero research points (see the
# closing comment on #63 for the reasoning); every other node - none of them
# required for that opening - costs at least 1.
const SKILL_TREE: Array = [
{
"id": "shipyards", "name": "Shipyards", "cost": 15, "requires": "",
@ -243,42 +300,42 @@ const SKILL_TREE: Array = [
},
{
"id": "coordination", "name": "Coordination", "cost": 20, "requires": "shipyards",
"x": 0.50, "unlocks": BUILDING_COMMAND_CENTER,
"x": 0.50, "unlocks": BUILDING_COMMAND_CENTER, "research_points": 1,
"desc": "Build command centers, where the fleet formation is arranged.",
},
{
"id": "freight", "name": "Freight Handling", "cost": 40, "requires": "logistics",
"x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB,
"x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB, "research_points": 1,
"desc": "Build transport hubs. Each hub includes its own automatic transporters.",
},
{
"id": "vessel_science", "name": "Vessel Science", "cost": 35, "requires": "shipyards",
"x": 0.82, "unlocks": BUILDING_SHIP_LAB,
"x": 0.82, "unlocks": BUILDING_SHIP_LAB, "research_points": 1,
"desc": "Build ship labs, where each vessel type is upgraded and new ones unlocked.",
},
{
"id": "freight_yards", "name": "Freight Yards", "cost": 80, "requires": "freight",
"x": 0.18, "hub_bonus": 1,
"x": 0.18, "hub_bonus": 1, "research_points": 2,
"desc": "Permanent: +1 transporter stationed at every hub, including ones already built.",
},
{
"id": "power_systems", "name": "Power Systems", "cost": 45, "requires": "freight",
"x": 0.50, "unlocks": BUILDING_GAS_BURNER,
"x": 0.50, "unlocks": BUILDING_GAS_BURNER, "research_points": 2,
"desc": "Build gas burners. Each converts gas into energy at its own site - hub transporters keep it stocked and collect what it makes.",
},
{
"id": "wing", "name": "Wing Doctrine", "cost": 30, "requires": "coordination",
"x": 0.82, "fleet_bonus": 1,
"x": 0.82, "fleet_bonus": 1, "research_points": 1,
"desc": "Permanent: +1 vessel in the fleet.",
},
{
"id": "squadron", "name": "Squadron Doctrine", "cost": 70, "requires": "wing",
"x": 0.50, "fleet_bonus": 1,
"x": 0.50, "fleet_bonus": 1, "research_points": 2,
"desc": "Permanent: +1 vessel in the fleet.",
},
{
"id": "armada", "name": "Armada Doctrine", "cost": 150, "requires": "squadron",
"x": 0.50, "fleet_bonus": 1,
"x": 0.50, "fleet_bonus": 1, "research_points": 3,
"desc": "Permanent: +1 vessel in the fleet.",
},
]
@ -328,16 +385,27 @@ func prerequisite_met(id: String) -> bool:
func can_unlock(id: String) -> bool:
return not is_unlocked(id) and prerequisite_met(id) and can_afford(int(skill_node(id).cost))
var node: Dictionary = skill_node(id)
return not is_unlocked(id) and prerequisite_met(id) and can_afford(int(node.cost)) \
and research_points >= skill_research_point_cost(id)
func skill_research_point_cost(id: String) -> int:
return int(skill_node(id).get("research_points", 0))
func unlock(id: String) -> bool:
if not can_unlock(id):
return false
if not spend(int(skill_node(id).cost)):
var node: Dictionary = skill_node(id)
if not spend(int(node.cost)):
return false
var points_needed: int = skill_research_point_cost(id)
if points_needed > 0:
research_points -= points_needed
research_points_changed.emit(research_points)
unlocked.append(id)
if int(skill_node(id).get("hub_bonus", 0)) > 0:
if int(node.get("hub_bonus", 0)) > 0:
_sync_hub_transporters()
return true
@ -381,6 +449,10 @@ func reset_new_game() -> void:
planet_name = ""
planet_id = ""
planet_definition_id = "rocky"
fleet_speed_multiplier = 1.0
_distorder_drain_progress = 0.0
boost_depleted_notice = false
research_points = 0
explored_chunks.clear()
bank_inventory = INVENTORY.new({RESOURCE_ORE: 500})
fleet_inventory = INVENTORY.new()
@ -454,6 +526,13 @@ func building_has_capability(building: Dictionary, capability: String) -> bool:
return definition != null and capability in definition.capabilities
func building_logistics_profile(building: Dictionary) -> Resource:
if building.is_empty():
return null
var definition: Resource = BUILDING_DEFINITIONS.get_definition(String(building.type))
return null if definition == null else definition.logistics
# --- vessel types and their upgrades (#5) ---------------------------------
# Combat vessels can join the player fleet. Transporter metadata lives in the
@ -465,6 +544,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.
@ -556,10 +636,14 @@ func unlock_vessel(vessel: String) -> bool:
if definition == null or definition.unlock_id == "" or vessel_is_unlocked(vessel) or not has_ship_lab():
return false
var costs: Dictionary = research_costs(definition.unlock_id)
if not can_afford_resources(costs):
var points_needed: int = int(definition.research_point_cost)
if not can_afford_resources(costs) or research_points < points_needed:
return false
for resource_type in costs:
bank_inventory.withdraw(String(resource_type), int(costs[resource_type]))
if points_needed > 0:
research_points -= points_needed
research_points_changed.emit(research_points)
vessel_levels[definition.unlock_id] = 1
vessel_upgraded.emit(definition.unlock_id)
return true
@ -606,7 +690,8 @@ func upgrade_cost(id: String) -> int:
func can_buy_upgrade(id: String) -> bool:
return has_ship_lab() and not upgrade_maxed(id) and can_afford(upgrade_cost(id))
return has_ship_lab() and not upgrade_maxed(id) and can_afford(upgrade_cost(id)) \
and research_points >= research_point_cost(id)
func buy_upgrade(id: String) -> bool:
@ -614,6 +699,10 @@ func buy_upgrade(id: String) -> bool:
return false
if not spend(upgrade_cost(id)):
return false
var points_needed: int = research_point_cost(id)
if points_needed > 0:
research_points -= points_needed
research_points_changed.emit(research_points)
vessel_levels[id] = upgrade_level(id) + 1
vessel_upgraded.emit(id)
return true
@ -876,6 +965,14 @@ func research_cost(id: String) -> int:
return int(research_costs(id).get(RESOURCE_ORE, 0))
func research_point_cost(id: String) -> int:
var unlock_definition: Resource = vessel_for_unlock(id)
if unlock_definition != null:
return int(unlock_definition.research_point_cost)
var d: Dictionary = upgrade_def(id)
return int(d.resource.research_point_cost) if not d.is_empty() else 0
func research_costs(id: String) -> Dictionary:
var unlock_definition: Resource = vessel_for_unlock(id)
if unlock_definition != null:
@ -904,10 +1001,20 @@ func _advance_lab_research(delta: float) -> void:
if inventory.amount(String(resource_type)) < int(costs[resource_type]):
affordable = false
break
# #63: research points are a fleet-wide currency, not delivered
# to the lab like ore/gas - checked and spent here too so a
# shortfall stalls the order the same way an undelivered
# resource does, rather than letting it start regardless.
var points_needed: int = research_point_cost(id)
if points_needed > research_points:
affordable = false
if not affordable:
continue
for resource_type in costs:
inventory.withdraw(String(resource_type), int(costs[resource_type]))
if points_needed > 0:
research_points -= points_needed
research_points_changed.emit(research_points)
order.started = true
order.elapsed = float(order.elapsed) + delta
if float(order.elapsed) < lab_research_time():
@ -950,19 +1057,20 @@ func has_building_of_type(type: String) -> bool:
func remove_buildings_of_type(type: String) -> int:
var kept: Array = []
var removed_ids: Array = []
var removed_hub_ids: Array = []
var removed: int = 0
for b in buildings:
if b.type == type:
removed += 1
removed_ids.append(int(b.id))
if building_has_capability(b, "transport_hub"):
removed_hub_ids.append(int(b.id))
else:
kept.append(b)
buildings = kept
if type == BUILDING_TRANSPORT_HUB and not removed_ids.is_empty():
if not removed_hub_ids.is_empty():
var kept_transporters: Array = []
for transporter in transporters:
if int(transporter.get("hub_id", 0)) not in removed_ids:
if int(transporter.get("hub_id", 0)) not in removed_hub_ids:
kept_transporters.append(transporter)
transporters = kept_transporters
return removed
@ -1092,44 +1200,76 @@ func _extractor_inventory(extractor: Dictionary) -> RefCounted:
return inventory
# --- gas burners (#54) -----------------------------------------------------
#
# Converts gas into energy at the building's own site, simulated here
# alongside extractor mining and production so it keeps running whether or
# not the player is nearby. Deliberately withdraws only from the building's
# OWN inventory, never the bank - a burner with no local gas simply stalls,
# exactly like a lab or crafter waiting on materials, rather than conjuring
# energy for free.
# --- building logistics and resource conversion ---------------------------
const GAS_BURN_RATE: float = 1.0 # gas/second
const ENERGY_PER_GAS: int = 2
# Local gas reserve a burner tries to keep topped up. Transporters treat
# falling short of this the same way they treat a queued production/research
# order (see _dispatch_transporters()'s gas_burner_gas_deficit() use).
const GAS_BURNER_BUFFER: int = 40
func gas_burner_gas_deficit(id: int) -> int:
var b: Dictionary = find_building(id)
if not building_has_capability(b, "gas_burner"):
func building_logistics_deficit(id: int, resource_type: String) -> int:
var building: Dictionary = find_building(id)
var profile: Resource = building_logistics_profile(building)
if profile == null:
return 0
return maxi(0, GAS_BURNER_BUFFER - building_resource(id, RESOURCE_GAS))
var target: int = int(profile.input_buffers.get(resource_type, 0))
return maxi(0, target - building_resource(id, resource_type))
func _advance_gas_burners(delta: float) -> void:
for b in buildings:
if not building_has_capability(b, "gas_burner"):
func building_requested_resource(id: int, resource_type: String) -> int:
return maxi(outstanding_resource_cost(id, resource_type),
building_logistics_deficit(id, resource_type))
func _collectable_building_resource(building: Dictionary, minimum_amount: int) -> String:
var profile: Resource = building_logistics_profile(building)
if profile == null:
return ""
for resource_type in profile.collected_outputs:
if building_resource(int(building.id), resource_type) >= minimum_amount:
return resource_type
return ""
func _advance_resource_converters(delta: float) -> void:
for building in buildings:
var profile: Resource = building_logistics_profile(building)
if profile == null or profile.conversion_input == "" \
or profile.conversion_output == "" or profile.conversion_rate <= 0.0 \
or profile.conversion_yield <= 0:
continue
var inventory: RefCounted = _inventory_for_record(b)
if inventory.amount(RESOURCE_GAS) <= 0:
continue # stalled - no local gas to burn
var progress: float = float(b.get("gas_burn_progress", 0.0)) + delta * GAS_BURN_RATE
var whole: int = mini(int(floor(progress)), inventory.amount(RESOURCE_GAS))
var inventory: RefCounted = _inventory_for_record(building)
if inventory.amount(profile.conversion_input) <= 0:
continue
var progress: float = float(building.get("conversion_progress", 0.0)) \
+ delta * profile.conversion_rate
var whole: int = mini(int(floor(progress)), inventory.amount(profile.conversion_input))
if whole > 0:
inventory.withdraw(RESOURCE_GAS, whole)
inventory.deposit(RESOURCE_ENERGY, whole * ENERGY_PER_GAS)
inventory.withdraw(profile.conversion_input, whole)
inventory.deposit(profile.conversion_output, whole * profile.conversion_yield)
progress -= whole
b.gas_burn_progress = progress
building.conversion_progress = progress
func _advance_distorder_drain(delta: float) -> void:
if fleet_speed_multiplier <= 1.0:
# Nothing owed at 1.0x - and no stale fractional progress should
# carry over into a LATER, unrelated boost.
_distorder_drain_progress = 0.0
return
var rate: float = (fleet_speed_multiplier - 1.0) / (MAX_FLEET_SPEED_MULTIPLIER - 1.0) \
* ENERGY_DRAIN_PER_SECOND
_distorder_drain_progress += rate * delta
var whole: int = int(floor(_distorder_drain_progress))
if whole <= 0:
return
_distorder_drain_progress -= whole
var available: int = fleet_inventory.amount(RESOURCE_ENERGY)
if available >= whole:
fleet_inventory.withdraw(RESOURCE_ENERGY, whole)
return
# Ran out mid-drain: take whatever is left (down to exactly zero) and
# force the boost back to its default, rather than let the fleet keep
# travelling fast on credit.
fleet_inventory.withdraw(RESOURCE_ENERGY, available)
fleet_speed_multiplier = 1.0
_distorder_drain_progress = 0.0
boost_depleted_notice = true
# Planet conquest: a planet must have every one of its capture points held
@ -1142,7 +1282,7 @@ var planet_capture: Dictionary = {}
signal planet_conquered(planet_id: String)
func register_capture_points(planet_key: String, total: int) -> void:
func register_capture_points(planet_key: String, total: int, definition_id: String = "") -> void:
# Idempotent: landing again on a planet must not wipe points already
# captured on a previous visit.
if planet_capture.has(planet_key):
@ -1153,8 +1293,13 @@ func register_capture_points(planet_key: String, total: int) -> void:
# A planet demanding zero points is vacuously already held (#55: a
# black hole's "void" profile has no authored PlanetSetup at all, so
# this is the only registration it ever gets) - every planet WITH real
# points still starts unconquered, exactly as before.
planet_capture[planet_key] = {"captured": captured, "conquered": total <= 0}
# points still starts unconquered, exactly as before. The planet definition
# supplies an optional conquest reward, avoiding planet-id special cases in
# the capture system.
var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id)
var point_reward: int = 0 if definition == null else int(definition.conquest_research_points)
planet_capture[planet_key] = {"captured": captured, "conquered": total <= 0,
"research_point_reward": point_reward, "research_awarded": false}
func capture_state(planet_key: String) -> Dictionary:
@ -1181,6 +1326,16 @@ func mark_point_captured(planet_key: String, index: int) -> void:
if captured.all(func(c): return bool(c)):
state.conquered = true
planet_conquered.emit(planet_key)
# #63: definition-authored research points, exactly once per planet.
# state.conquered flipping above already makes a second call to this
# whole function a no-op via the early return above it, but
# research_awarded is its own explicit guard on the award itself
# regardless of how conquest was reached.
var reward: int = int(state.get("research_point_reward", 0))
if reward > 0 and not bool(state.get("research_awarded", false)):
state.research_awarded = true
research_points += reward
research_points_changed.emit(research_points)
func is_planet_conquered(planet_key: String) -> bool:
@ -1206,7 +1361,8 @@ func _process(delta: float) -> void:
_advance_production(delta)
_advance_lab_research(delta)
_advance_vessel_deliveries(delta)
_advance_gas_burners(delta)
_advance_resource_converters(delta)
_advance_distorder_drain(delta)
for e in extractors:
e.mining_progress = float(e.get("mining_progress", 0.0)) + delta * EXTRACTOR_RATE
var whole: int = int(floor(float(e.mining_progress)))
@ -1364,13 +1520,7 @@ func _dispatch_transporters() -> void:
for b in buildings:
var needed_resource := ""
for resource_type in resource_types():
# A gas burner's ongoing gas deficit is folded in alongside
# queued production/research costs (#54) - both are just "this
# building wants more of resource_type", so the one dispatch
# path already below serves both without any new transporter
# state.
var need: int = maxi(outstanding_resource_cost(int(b.id), resource_type),
gas_burner_gas_deficit(int(b.id)) if resource_type == RESOURCE_GAS else 0)
var need: int = building_requested_resource(int(b.id), resource_type)
if need > 0 and bank_inventory.amount(resource_type) > 0:
needed_resource = resource_type
break
@ -1423,14 +1573,11 @@ func _dispatch_transporters() -> void:
_begin_leg(chosen_extractor, transporter_position(chosen_extractor), extractor.position,
TRANSPORTER_TO_EXTRACTOR)
# Collecting a gas burner's energy output, last: queued deliveries and
# extractor collection both outrank it, same "supply what's needed
# before what's merely ready" ordering _dispatch_transporters() already
# holds for extractors over queued orders (f5c60f8).
# Collect building outputs last: queued deliveries and extractor collection
# both outrank resources that are merely ready for pickup.
for b in buildings:
if not building_has_capability(b, "gas_burner"):
continue
if building_resource(int(b.id), RESOURCE_ENERGY) < load or _transporter_inbound_for_pickup(int(b.id)):
var pickup_resource: String = _collectable_building_resource(b, load)
if pickup_resource == "" or _transporter_inbound_for_pickup(int(b.id)):
continue
var chosen_pickup: Dictionary = {}
var best_pickup_distance: float = INF
@ -1444,7 +1591,7 @@ func _dispatch_transporters() -> void:
if chosen_pickup.is_empty():
return
chosen_pickup.target_id = int(b.id)
chosen_pickup.resource_type = RESOURCE_ENERGY
chosen_pickup.resource_type = pickup_resource
chosen_pickup.extractor_index = -1
_begin_leg(chosen_pickup, transporter_position(chosen_pickup), b.position,
TRANSPORTER_TO_BUILDING_PICKUP)
@ -1474,8 +1621,7 @@ func _arrive(t: Dictionary) -> void:
TRANSPORTER_TO_MOTHERSHIP:
var target: Dictionary = find_building(int(t.target_id))
var resource_type: String = String(t.get("resource_type", RESOURCE_ORE))
var want: int = maxi(outstanding_resource_cost(int(t.target_id), resource_type),
gas_burner_gas_deficit(int(t.target_id)) if resource_type == RESOURCE_GAS else 0)
var want: int = building_requested_resource(int(t.target_id), resource_type)
var take: int = mini(mini(want, bank_inventory.amount(resource_type)), transporter_load_limit())
if take <= 0 or target.is_empty():
# Nothing to carry, or the building is gone. Go home empty.

View file

@ -1,61 +1,64 @@
class_name GasBombController
extends Node2D
extends "res://AimedFleetAbilityController.gd"
const BOMB_COLOR := Color(0.96, 0.22, 0.72)
const TARGET_COLOR := Color(0.78, 0.32, 1.0)
const MAX_RANGE := 950.0
const AOE_RADIUS := 180.0
const DRAG_RADIUS := 260.0
const AIM_DEADZONE := 22.0
const PROJECTILE_SPEED := 720.0
const DAMAGE := 3
const COOLDOWN := 5.0
const GAS_PER_SHOT: int = 5
@export var minimum_zoom: float = 0.4
@onready var fleet: Node2D = get_node("../Fleet")
@onready var camera: Camera2D = get_node("../Camera2D")
@onready var button: IconButton = get_node("../UI/GasBombButton")
var aiming := false
var aim_touch := -1
var aim_start_screen := Vector2.ZERO
var aim_screen := Vector2.ZERO
var aim_target := Vector2.ZERO
var saved_zoom := 1.0
var restoring_zoom := false
var projectiles: Array = []
var explosions: Array = []
var last_hit_count := 0
var last_hit_count: int = 0
var projectile_active: bool:
get: return not projectiles.is_empty()
var explosion_time: float:
get:
var longest := 0.0
var longest: float = 0.0
for explosion in explosions:
longest = maxf(longest, float(explosion.time))
return longest
func _ready() -> void:
button.visible = false
queue_redraw()
func _button_path() -> NodePath: return "../UI/GasBombButton"
func _status_path() -> NodePath: return "../UI/GasBombStatus"
func _component_id() -> String: return "gas_bomber"
func _ammo_resource() -> String: return GameState.RESOURCE_GAS
func _ammo_cost() -> int: return GAS_PER_SHOT
func _aim_padding() -> float: return AOE_RADIUS
func _has_effect_visuals() -> bool: return not projectiles.is_empty() or not explosions.is_empty()
func _process(delta: float) -> void:
var had_visuals := aiming or not projectiles.is_empty() or not explosions.is_empty()
button.visible = fleet.has_capability("gas_bomber")
button.enabled = _next_ready_bomber() != null
if not button.visible and aiming:
_cancel_aim()
func _calculate_aim_target(drag: Vector2, origin: Vector2) -> Vector2:
if drag.length() < 0.001:
return origin
var distance: float = minf(drag.length() / DRAG_RADIUS, 1.0) * MAX_RANGE
return origin + drag.normalized() * distance
if restoring_zoom and not aiming:
var z := move_toward(camera.zoom.x, saved_zoom, maxf(saved_zoom, 0.1) * delta * 5.0)
camera.zoom = Vector2(z, z)
if is_equal_approx(z, saved_zoom):
restoring_zoom = false
func _consume_shot(component: Node) -> bool:
return component.consume_shot(COOLDOWN)
func _perform_shot(ship: Node2D, _component: Node) -> void:
projectiles.append({
"position": ship.global_position,
"target": aim_target,
"trail": PackedVector2Array([ship.global_position]),
})
func _next_ready_bomber() -> Node2D:
return _next_ready_ship()
func _advance_effects(delta: float) -> void:
for i in range(projectiles.size() - 1, -1, -1):
var projectile: Dictionary = projectiles[i]
var distance: float = (projectile.position as Vector2).distance_to(projectile.target)
@ -64,8 +67,8 @@ func _process(delta: float) -> void:
_explode(projectile.target)
projectiles.remove_at(i)
else:
projectile.position = (projectile.position as Vector2).move_toward(projectile.target,
PROJECTILE_SPEED * delta)
projectile.position = (projectile.position as Vector2).move_toward(
projectile.target, PROJECTILE_SPEED * delta)
var trail: PackedVector2Array = projectile.trail
trail.append(projectile.position)
while trail.size() > 12:
@ -76,107 +79,6 @@ func _process(delta: float) -> void:
explosion.time = maxf(float(explosion.time) - delta, 0.0)
if float(explosion.time) <= 0.0:
explosions.remove_at(i)
if aiming or not projectiles.is_empty() or not explosions.is_empty() or had_visuals:
queue_redraw()
func can_begin_aim() -> bool:
return button.visible and button.enabled and not button._blocked_by_modal() \
and not aiming
func handle_touch(event: InputEventScreenTouch) -> bool:
if event.pressed:
if not can_begin_aim() or not Rect2(button.global_position, button.size).has_point(event.position):
return false
aiming = true
aim_touch = event.index
aim_start_screen = event.position
aim_screen = event.position
saved_zoom = camera.zoom.x
restoring_zoom = false
button.pressed_down = true
button.queue_redraw()
_update_aim()
return true
if event.index != aim_touch:
return false
var should_fire := aim_start_screen.distance_to(aim_screen) >= AIM_DEADZONE
if should_fire:
_fire()
else:
_cancel_aim()
return true
func handle_drag(event: InputEventScreenDrag) -> bool:
if not aiming or event.index != aim_touch:
return false
aim_screen = event.position
_update_aim()
return true
func _update_aim() -> void:
var drag := aim_screen - aim_start_screen
var origin: Vector2 = fleet.get_centroid()
if drag.length() < 0.001:
aim_target = origin
else:
var distance := minf(drag.length() / DRAG_RADIUS, 1.0) * MAX_RANGE
aim_target = origin + drag.normalized() * distance
_update_aim_zoom(origin, aim_target)
queue_redraw()
func _update_aim_zoom(origin: Vector2, target: Vector2) -> void:
var displacement := target - origin
var half_screen := get_viewport_rect().size * 0.5 - Vector2(54.0, 54.0)
var fit_x := saved_zoom if absf(displacement.x) < 1.0 else half_screen.x / (absf(displacement.x) + AOE_RADIUS)
var fit_y := saved_zoom if absf(displacement.y) < 1.0 else half_screen.y / (absf(displacement.y) + AOE_RADIUS)
var desired := clampf(minf(saved_zoom, minf(fit_x, fit_y)), minimum_zoom, saved_zoom)
camera.zoom = Vector2(desired, desired)
func _finish_aim() -> void:
aiming = false
aim_touch = -1
button.pressed_down = false
button.queue_redraw()
restoring_zoom = true
func _cancel_aim() -> void:
_finish_aim()
queue_redraw()
func _fire() -> void:
var bomber: Node2D = _next_ready_bomber()
if bomber == null:
_cancel_aim()
return
var component: Node = bomber.get_component("gas_bomber")
if not component.consume_shot(COOLDOWN):
_cancel_aim()
return
projectiles.append({
"position": bomber.global_position,
"target": aim_target,
"trail": PackedVector2Array([bomber.global_position]),
})
_finish_aim()
button.enabled = _next_ready_bomber() != null
func _next_ready_bomber() -> Node2D:
for ship in fleet.ships:
if not is_instance_valid(ship):
continue
var component: Node = ship.get_component("gas_bomber")
if component != null and component.is_ready():
return ship
return null
func _explode(at: Vector2) -> void:
@ -191,28 +93,29 @@ func _explode(at: Vector2) -> void:
func _draw() -> void:
if aiming:
var origin: Vector2 = fleet.get_centroid()
NeonDraw.draw_glowing_line(self, origin, aim_target, TARGET_COLOR, 0.7, 0.8)
NeonDraw.draw_glowing_line(self, _aim_origin(), aim_target, TARGET_COLOR, 0.7, 0.8)
_draw_ring(aim_target, AOE_RADIUS, TARGET_COLOR, 0.8)
_draw_ring(aim_target, 18.0, BOMB_COLOR, 1.0)
for projectile in projectiles:
var trail: PackedVector2Array = projectile.trail
if trail.size() >= 2:
for i in range(1, trail.size()):
var alpha := float(i) / trail.size()
draw_line(trail[i - 1], trail[i],
Color(BOMB_COLOR, alpha * 0.65), 4.0 + alpha * 4.0, true)
var alpha: float = float(i) / trail.size()
draw_line(trail[i - 1], trail[i], Color(BOMB_COLOR, alpha * 0.65),
4.0 + alpha * 4.0, true)
draw_circle(projectile.position, 12.0, Color(BOMB_COLOR, 0.35))
NeonDraw.draw_glowing_circle(self, projectile.position, 7.0, BOMB_COLOR, 1.0)
for explosion in explosions:
var progress := 1.0 - float(explosion.time) / 0.8
var radius := lerpf(24.0, AOE_RADIUS, minf(progress * 1.5, 1.0))
var progress: float = 1.0 - float(explosion.time) / 0.8
var radius: float = lerpf(24.0, AOE_RADIUS, minf(progress * 1.5, 1.0))
draw_circle(explosion.position, radius, Color(BOMB_COLOR, (1.0 - progress) * 0.16))
_draw_ring(explosion.position, radius, BOMB_COLOR, 1.0 - progress)
for i in range(7):
var angle := TAU * float(i) / 7.0 + progress
var cloud_at: Vector2 = explosion.position + Vector2(cos(angle), sin(angle)) * radius * 0.55
draw_circle(cloud_at, 18.0 + 14.0 * progress, Color(BOMB_COLOR, (1.0 - progress) * 0.22))
var angle: float = TAU * float(i) / 7.0 + progress
var cloud_at: Vector2 = explosion.position \
+ Vector2(cos(angle), sin(angle)) * radius * 0.55
draw_circle(cloud_at, 18.0 + 14.0 * progress,
Color(BOMB_COLOR, (1.0 - progress) * 0.22))
func _draw_ring(center: Vector2, radius: float, color: Color, alpha: float) -> void:

View file

@ -23,4 +23,5 @@ unlock_name = "Unlock Gas Bomber"
unlock_description = "Research volatile-gas containment and unlock the Gas Bomber for ship crafters."
unlock_cost = 0
unlock_resource_costs = {"gas": 30}
research_point_cost = 1
unlock_icon = 31

View file

@ -1,6 +1,19 @@
[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3]
[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=4 format=3]
[ext_resource type="Script" path="res://BuildingDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://GasBurnerBuildingVisual.tscn" id="2"]
[ext_resource type="Script" path="res://BuildingLogisticsProfile.gd" id="3"]
[sub_resource type="Resource" id="Resource_logistics"]
script = ExtResource("3")
input_buffers = {
"gas": 40
}
collected_outputs = PackedStringArray("energy")
conversion_input = "gas"
conversion_output = "energy"
conversion_rate = 1.0
conversion_yield = 2
[resource]
script = ExtResource("1")
definition_id = "gas_burner"
@ -11,6 +24,7 @@ action_icon = 33
menu_icon = 33
visual_scene = ExtResource("2")
capabilities = PackedStringArray("gas_burner")
logistics = SubResource("Resource_logistics")
map_color = Color(0.95, 0.35, 0.15, 1)
map_sides = 5
map_radius = 7.0

View file

@ -0,0 +1 @@
uid://c5a74rb5s4q21

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.

View file

@ -7,5 +7,6 @@ display_name = "Heavy Slugs"
description = "+1 damage per shot."
base_cost = 30
max_level = 3
research_point_cost = 1
icon = 15
additive_modifiers = {"damage": 1.0}

View file

@ -7,5 +7,6 @@ display_name = "Targeting Array"
description = "+60 firing range per level."
base_cost = 20
max_level = 3
research_point_cost = 1
icon = 17
additive_modifiers = {"fire_range": 60.0}

View file

@ -7,5 +7,6 @@ display_name = "Autoloader"
description = "Fires 15% faster per level."
base_cost = 25
max_level = 3
research_point_cost = 1
icon = 16
multiplicative_modifiers = {"fire_cooldown": 0.85}

View file

@ -7,5 +7,6 @@ display_name = "Thrusters"
description = "+40 top speed, +60 acceleration per level."
base_cost = 25
max_level = 3
research_point_cost = 1
icon = 18
additive_modifiers = {"max_speed": 40.0, "acceleration": 60.0}

View file

@ -1,164 +1,60 @@
class_name LaserController
extends Node2D
# Same press-drag-release aiming plumbing as GasBombController, adapted for
# an instant beam rather than a lobbed, timed projectile: there is no travel
# time or explosion delay - the beam strikes the instant the shot fires, and
# only a brief flash lingers for feedback. Damage/range/cooldown are read
# straight off the firing ship's own VesselLaserComponent (already resolved
# from vessel_stat/upgrades) rather than hardcoded here, unlike the bomber's
# AOE_RADIUS/DAMAGE/COOLDOWN.
#
# Reused as-is by both Main.tscn and PlanetSurface.tscn (same relative
# ../Fleet, ../Camera2D, ../UI/LaserButton paths as GasBombController), so
# it works identically in space and on a landed surface with no branching.
extends "res://AimedFleetAbilityController.gd"
const BEAM_COLOR := Color(0.3, 0.9, 1.0)
const TARGET_COLOR := Color(0.6, 0.95, 1.0)
const BEAM_HALF_WIDTH := 22.0
const DRAG_DEADZONE := 22.0
const FLASH_DURATION := 0.22
const ENERGY_PER_SHOT: int = 8
@export var minimum_zoom: float = 0.4
@onready var fleet: Node2D = get_node("../Fleet")
@onready var camera: Camera2D = get_node("../Camera2D")
@onready var button: IconButton = get_node("../UI/LaserButton")
var aiming := false
var aim_touch := -1
var aim_start_screen := Vector2.ZERO
var aim_screen := Vector2.ZERO
var aim_target := Vector2.ZERO
var saved_zoom := 1.0
var restoring_zoom := false
var beams: Array = []
var last_hit_count := 0
var last_hit_count: int = 0
func _ready() -> void:
button.visible = false
queue_redraw()
func _button_path() -> NodePath: return "../UI/LaserButton"
func _status_path() -> NodePath: return "../UI/LaserStatus"
func _component_id() -> String: return "laser"
func _ammo_resource() -> String: return GameState.RESOURCE_ENERGY
func _ammo_cost() -> int: return ENERGY_PER_SHOT
func _aim_padding() -> float: return BEAM_HALF_WIDTH
func _has_effect_visuals() -> bool: return not beams.is_empty()
func _process(delta: float) -> void:
var had_visuals := aiming or not beams.is_empty()
button.visible = fleet.has_capability("laser")
button.enabled = _next_ready_laser_ship() != null
if not button.visible and aiming:
_cancel_aim()
func _aim_max_range() -> float:
var ship: Node2D = _next_ready_ship()
if ship == null:
return 950.0
var component: Node = ship.get_component(_component_id())
return component.beam_range if component != null else 950.0
if restoring_zoom and not aiming:
var z := move_toward(camera.zoom.x, saved_zoom, maxf(saved_zoom, 0.1) * delta * 5.0)
camera.zoom = Vector2(z, z)
if is_equal_approx(z, saved_zoom):
restoring_zoom = false
func _calculate_aim_target(drag: Vector2, origin: Vector2) -> Vector2:
# A beam always reaches full range; dragging chooses its direction only.
var direction: Vector2 = drag.normalized() if drag.length() > 0.001 else Vector2.UP
return origin + direction * _aim_max_range()
func _consume_shot(component: Node) -> bool:
return component.consume_shot()
func _perform_shot(ship: Node2D, component: Node) -> void:
var direction: Vector2 = aim_target - _aim_origin()
direction = direction.normalized() if direction.length() > 0.001 else Vector2.UP
var beam_to: Vector2 = ship.global_position + direction * component.beam_range
_strike(ship.global_position, beam_to, direction, int(component.damage))
func _next_ready_laser_ship() -> Node2D:
return _next_ready_ship()
func _advance_effects(delta: float) -> void:
for i in range(beams.size() - 1, -1, -1):
var beam: Dictionary = beams[i]
beam.time = maxf(float(beam.time) - delta, 0.0)
if float(beam.time) <= 0.0:
beams.remove_at(i)
if aiming or not beams.is_empty() or had_visuals:
queue_redraw()
func can_begin_aim() -> bool:
return button.visible and button.enabled and not button._blocked_by_modal() \
and not aiming
func handle_touch(event: InputEventScreenTouch) -> bool:
if event.pressed:
if not can_begin_aim() or not Rect2(button.global_position, button.size).has_point(event.position):
return false
aiming = true
aim_touch = event.index
aim_start_screen = event.position
aim_screen = event.position
saved_zoom = camera.zoom.x
restoring_zoom = false
button.pressed_down = true
button.queue_redraw()
_update_aim()
return true
if event.index != aim_touch:
return false
var should_fire := aim_start_screen.distance_to(aim_screen) >= DRAG_DEADZONE
if should_fire:
_fire()
else:
_cancel_aim()
return true
func handle_drag(event: InputEventScreenDrag) -> bool:
if not aiming or event.index != aim_touch:
return false
aim_screen = event.position
_update_aim()
return true
func _aim_origin() -> Vector2:
return fleet.get_centroid()
func _aim_max_range() -> float:
var ship: Node2D = _next_ready_laser_ship()
if ship == null:
return 950.0
var component: Node = ship.get_component("laser")
return component.beam_range if component != null else 950.0
func _update_aim() -> void:
# A beam always reaches its full range - only the DIRECTION is aimed,
# unlike the bomb's drag-controlled distance.
var drag := aim_screen - aim_start_screen
var origin: Vector2 = _aim_origin()
var direction: Vector2 = drag.normalized() if drag.length() > 0.001 else Vector2.UP
aim_target = origin + direction * _aim_max_range()
_update_aim_zoom(origin, aim_target)
queue_redraw()
func _update_aim_zoom(origin: Vector2, target: Vector2) -> void:
var displacement := target - origin
var half_screen := get_viewport_rect().size * 0.5 - Vector2(54.0, 54.0)
var fit_x := saved_zoom if absf(displacement.x) < 1.0 else half_screen.x / (absf(displacement.x) + BEAM_HALF_WIDTH)
var fit_y := saved_zoom if absf(displacement.y) < 1.0 else half_screen.y / (absf(displacement.y) + BEAM_HALF_WIDTH)
var desired := clampf(minf(saved_zoom, minf(fit_x, fit_y)), minimum_zoom, saved_zoom)
camera.zoom = Vector2(desired, desired)
func _finish_aim() -> void:
aiming = false
aim_touch = -1
button.pressed_down = false
button.queue_redraw()
restoring_zoom = true
func _cancel_aim() -> void:
_finish_aim()
queue_redraw()
func _fire() -> void:
var shooter: Node2D = _next_ready_laser_ship()
if shooter == null:
_cancel_aim()
return
var component: Node = shooter.get_component("laser")
if not component.consume_shot():
_cancel_aim()
return
var direction: Vector2 = (aim_target - _aim_origin())
direction = direction.normalized() if direction.length() > 0.001 else Vector2.UP
var beam_to: Vector2 = shooter.global_position + direction * component.beam_range
_strike(shooter.global_position, beam_to, direction, int(component.damage))
_finish_aim()
button.enabled = _next_ready_laser_ship() != null
func _strike(from: Vector2, to: Vector2, direction: Vector2, damage: int) -> void:
@ -178,20 +74,12 @@ func _strike(from: Vector2, to: Vector2, direction: Vector2, damage: int) -> voi
last_hit_count += 1
func _next_ready_laser_ship() -> Node2D:
for ship in fleet.ships:
if not is_instance_valid(ship):
continue
var component: Node = ship.get_component("laser")
if component != null and component.is_ready():
return ship
return null
func _draw() -> void:
if aiming:
NeonDraw.draw_glowing_line(self, _aim_origin(), aim_target, TARGET_COLOR, 0.7, 0.6)
NeonDraw.draw_glowing_line(self, _aim_origin(), aim_target,
TARGET_COLOR, 0.7, 0.6)
for beam in beams:
var progress: float = 1.0 - float(beam.time) / FLASH_DURATION
var alpha: float = 1.0 - progress
NeonDraw.draw_glowing_line(self, beam.from, beam.to, BEAM_COLOR, alpha, 1.2 - progress * 0.6)
NeonDraw.draw_glowing_line(self, beam.from, beam.to,
BEAM_COLOR, alpha, 1.2 - progress * 0.6)

1
LaserController.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://cdqg3isy1lbuf

View file

@ -7,5 +7,6 @@ display_name = "Capacitor Bank"
description = "Recharges 0.6s sooner per level."
base_cost = 30
max_level = 3
research_point_cost = 1
icon = 20
additive_modifiers = {"beam_cooldown": -0.6}

View file

@ -7,5 +7,6 @@ display_name = "Focusing Lens"
description = "+1 damage per shot."
base_cost = 30
max_level = 3
research_point_cost = 1
icon = 15
additive_modifiers = {"damage": 1.0}

View file

@ -7,5 +7,6 @@ display_name = "Beam Collimator"
description = "+150 beam range per level."
base_cost = 25
max_level = 3
research_point_cost = 1
icon = 17
additive_modifiers = {"beam_range": 150.0}

View file

@ -26,4 +26,5 @@ unlock_name = "Unlock Laser Ship"
unlock_description = "Research a directed-energy hull for the ship crafter - manually aimed, no ammo, cools down between shots."
unlock_cost = 0
unlock_resource_costs = {"energy": 40}
research_point_cost = 1
unlock_icon = -1

312
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
@ -264,6 +287,38 @@ script = ExtResource("10")
icon = 32
glow_color = Color(0.96, 0.22, 0.72, 1)
[node name="GasBombStatus" type="Label" parent="UI"]
mouse_filter = 2
anchor_left = 1.0
anchor_right = 1.0
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = -344
offset_right = -24
offset_top = -480
offset_bottom = -430
theme_override_font_sizes/font_size = 18
theme_override_colors/font_color = Color(0.96, 0.22, 0.72, 1)
autowrap_mode = 2
horizontal_alignment = 1
text = ""
[node name="LaserStatus" type="Label" parent="UI"]
mouse_filter = 2
anchor_left = 1.0
anchor_right = 1.0
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = -344
offset_right = -24
offset_top = -540
offset_bottom = -490
theme_override_font_sizes/font_size = 18
theme_override_colors/font_color = Color(0.3, 0.9, 1, 1)
autowrap_mode = 2
horizontal_alignment = 1
text = ""
[node name="PivotCWButton" type="Control" parent="UI"]
mouse_filter = 2
anchor_left = 1.0
@ -839,7 +894,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 +905,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 +914,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 +926,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 +936,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 +948,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 +958,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 +970,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 +980,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 +992,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 +1034,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 +1045,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 +1054,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 +1066,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 +1076,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 +1088,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 +1098,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 +1110,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 +1120,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 +1132,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 +1206,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 +1216,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 +1227,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 +1238,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 +1249,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 +1260,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 +1296,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 +1307,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 +1319,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 +1331,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 +1343,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 +1355,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 +1620,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 +1633,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 +1645,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 +1659,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 +1673,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 +1687,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 +1701,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 +1715,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 +1729,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 +1743,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 +1752,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
@ -1936,9 +2086,9 @@ anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -64.0
offset_top = -456.0
offset_top = -400.0
offset_right = 76.0
offset_bottom = -392.0
offset_bottom = -336.0
script = ExtResource("19")
digit_height = 52.0
@ -1948,9 +2098,9 @@ anchor_right = 1.0
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 32.0
offset_top = -386.0
offset_top = -326.0
offset_right = -32.0
offset_bottom = -318.0
offset_bottom = -250.0
theme_override_font_sizes/font_size = 32
autowrap_mode = 2
horizontal_alignment = 1
@ -1962,9 +2112,9 @@ anchor_top = 1.0
anchor_right = 0.5
anchor_bottom = 1.0
offset_left = -74.0
offset_top = -304.0
offset_top = -236.0
offset_right = 74.0
offset_bottom = -156.0
offset_bottom = -88.0
script = ExtResource("10")
icon = 5
glow_color = Color(0.45, 0.9, 0.55, 1)

View file

@ -8,3 +8,7 @@ extends Resource
@export var surface_profile_id: String = "rocky"
@export var resource_id: String = "ore"
@export var map_color: Color = Color(0.7, 0.65, 0.55)
# Optional reward granted once when every authored capture point is claimed.
# Keeping it on the definition lets conquest remain generic as new planet
# rewards are introduced.
@export var conquest_research_points: int = 0

View file

@ -4,11 +4,13 @@ extends RefCounted
const ROCKY_ID: String = "rocky"
const GAS_GIANT_ID: String = "gas_giant"
const BLACK_HOLE_ID: String = "black_hole"
const RESEARCH_LAB_ID: String = "research_lab"
const DEFAULT_DEFINITION_ID: String = ROCKY_ID
const DEFINITIONS: Dictionary = {
ROCKY_ID: preload("res://RockyPlanetDefinition.tres"),
GAS_GIANT_ID: preload("res://GasGiantDefinition.tres"),
BLACK_HOLE_ID: preload("res://BlackHolePlanetDefinition.tres"),
RESEARCH_LAB_ID: preload("res://ResearchLabPlanetDefinition.tres"),
}

View file

@ -19,7 +19,8 @@ func apply_setup(new_setup: Resource) -> void:
if setup == null:
return
GameState.register_capture_points(GameState.planet_id, setup.capture_points.size())
GameState.register_capture_points(GameState.planet_id, setup.capture_points.size(),
GameState.planet_definition_id)
for i in range(setup.capture_points.size()):
_add_capture_point(i, setup.capture_points[i])
for spawn in setup.enemy_spawns:

View file

@ -0,0 +1,42 @@
[gd_resource type="Resource" script_class="PlanetSetup" format=3]
[ext_resource type="Script" path="res://PlanetSetupSpawn.gd" id="1"]
[ext_resource type="Script" path="res://PlanetSetup.gd" id="2"]
[sub_resource type="Resource" id="Resource_ta1"]
script = ExtResource("1")
enemy_id = "turret_spawner"
position = Vector2(-620.0, -180.0)
[sub_resource type="Resource" id="Resource_ta2"]
script = ExtResource("1")
enemy_id = "turret_spawner"
position = Vector2(640.0, -160.0)
[sub_resource type="Resource" id="Resource_ta3"]
script = ExtResource("1")
enemy_id = "artillery"
position = Vector2(0.0, -720.0)
[sub_resource type="Resource" id="Resource_ta4"]
script = ExtResource("1")
enemy_id = "sniper"
position = Vector2(-380.0, 420.0)
[sub_resource type="Resource" id="Resource_ta5"]
script = ExtResource("1")
enemy_id = "sniper"
position = Vector2(400.0, 440.0)
[sub_resource type="Resource" id="Resource_ta6"]
script = ExtResource("1")
enemy_id = "chaser"
position = Vector2(120.0, 60.0)
[resource]
script = ExtResource("2")
setup_id = "research_lab_1"
display_name = "Research Lab Garrison"
surface_profile_id = "research_lab"
capture_points = PackedVector2Array(-450.0, -60.0, 460.0, -40.0, 0.0, 480.0)
enemy_spawns = Array[Resource]([SubResource("Resource_ta1"), SubResource("Resource_ta2"), SubResource("Resource_ta3"), SubResource("Resource_ta4"), SubResource("Resource_ta5"), SubResource("Resource_ta6")])

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
@ -204,6 +229,38 @@ icon = 32
glow_color = Color(0.82, 0.08, 0.48, 1)
core_color = Color(0.02, 0.09, 0.15, 1)
[node name="GasBombStatus" type="Label" parent="UI"]
mouse_filter = 2
anchor_left = 1.0
anchor_right = 1.0
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = -344
offset_right = -24
offset_top = -480
offset_bottom = -430
theme_override_font_sizes/font_size = 18
theme_override_colors/font_color = Color(0.82, 0.08, 0.48, 1)
autowrap_mode = 2
horizontal_alignment = 1
text = ""
[node name="LaserStatus" type="Label" parent="UI"]
mouse_filter = 2
anchor_left = 1.0
anchor_right = 1.0
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = -344
offset_right = -24
offset_top = -540
offset_bottom = -490
theme_override_font_sizes/font_size = 18
theme_override_colors/font_color = Color(0.2, 0.6, 0.68, 1)
autowrap_mode = 2
horizontal_alignment = 1
text = ""
[node name="PivotCWButton" type="Control" parent="UI"]
mouse_filter = 2
anchor_left = 1.0

View file

@ -6,6 +6,7 @@ const PROFILES: Dictionary = {
"rocky": preload("res://RockySurfaceProfile.tres"),
"gas": preload("res://GasSurfaceProfile.tres"),
"void": preload("res://VoidSurfaceProfile.tres"),
"research_lab": preload("res://ResearchLabSurfaceProfile.tres"),
}

View file

@ -0,0 +1,15 @@
[gd_resource type="Resource" script_class="PlanetDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://PlanetDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://ResearchLabPlanetVisual.tscn" id="2"]
[resource]
script = ExtResource("1")
definition_id = "research_lab"
display_name = "Research Lab"
names = PackedStringArray("Blacksite Zero", "Cordoned Array", "Dead Signal Station", "The Vault", "Interdicted Post")
visual_scene = ExtResource("2")
surface_profile_id = "research_lab"
resource_id = ""
map_color = Color(0.7, 0.2, 0.15, 1)
conquest_research_points = 1

View file

@ -0,0 +1,55 @@
extends Node2D
# Same informal interface as GasGiantVisual/RockyPlanetVisual - configure(),
# extractor_anchor(), animates via its own _process(). Deliberately angular
# and armoured rather than a natural silhouette (#63: this is an ENEMY
# installation, not a celestial body), with a rotating dish and blinking
# warning lights to read as occupied/hostile at a glance.
var phase: float = 0.0
const RADIUS: float = 108.0
const HULL_COLOR: Color = Color(0.55, 0.1, 0.08)
const CORE_COLOR: Color = Color(0.9, 0.25, 0.18)
const WARNING_COLOR: Color = Color(1.0, 0.2, 0.15)
const DARK_CORE: Color = Color(0.14, 0.02, 0.02)
func configure(_shape_seed: int) -> void:
queue_redraw()
func extractor_anchor() -> Vector2:
return Vector2(0.0, -(RADIUS + 40.0))
func _process(delta: float) -> void:
phase += delta
queue_redraw()
func _draw() -> void:
var hull: PackedVector2Array = PackedVector2Array()
for i in range(6):
var angle: float = TAU * float(i) / 6.0
hull.append(Vector2(cos(angle), sin(angle)) * RADIUS)
NeonDraw.draw_glowing_polygon(self, hull, HULL_COLOR, 0.85, DARK_CORE)
var inner: PackedVector2Array = PackedVector2Array()
for i in range(6):
var angle: float = TAU * float(i) / 6.0 + PI / 6.0
inner.append(Vector2(cos(angle), sin(angle)) * RADIUS * 0.55)
NeonDraw.draw_glowing_polygon(self, inner, CORE_COLOR, 0.9, DARK_CORE)
# A slow-rotating dish - reads as actively transmitting, not derelict.
var dish_angle: float = phase * 0.6
var dish_tip: Vector2 = Vector2(cos(dish_angle), sin(dish_angle)) * RADIUS * 1.35
NeonDraw.draw_glowing_line(self, Vector2.ZERO, dish_tip, CORE_COLOR, 0.8, 0.55, DARK_CORE)
NeonDraw.draw_glowing_circle(self, dish_tip, 11.0, CORE_COLOR, 1.0, DARK_CORE)
# Blinking hull-corner warning lights.
var blink: float = 0.4 + 0.6 * absf(sin(phase * 4.0))
for i in range(6):
var angle: float = TAU * float(i) / 6.0
var at: Vector2 = Vector2(cos(angle), sin(angle)) * RADIUS
NeonDraw.draw_glowing_circle(self, at, 5.0, WARNING_COLOR, blink, DARK_CORE)

View file

@ -0,0 +1 @@
uid://c6nasspc8ewwj

View file

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

View file

@ -0,0 +1,12 @@
[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=2 format=3]
[ext_resource type="Script" path="res://PlanetSurfaceProfile.gd" id="1"]
[resource]
script = ExtResource("1")
profile_id = "research_lab"
base_color = Color(0.13, 0.1, 0.1, 1)
crater_color = Color(0.05, 0.03, 0.03, 1)
highlight_color = Color(0.55, 0.15, 0.1, 1)
caption_color = Color(0.9, 0.6, 0.55, 1)
caption_outline_color = Color(0.05, 0.02, 0.02, 0.9)

View file

@ -7,5 +7,6 @@ display_name = "Layered Emitters"
description = "+1 hit absorbed before the shield drops."
base_cost = 60
max_level = 2
research_point_cost = 1
icon = 19
additive_modifiers = {"shield_charges": 1.0}

View file

@ -7,5 +7,6 @@ display_name = "Fast Cycling"
description = "Shield returns 1.2s sooner per level."
base_cost = 35
max_level = 3
research_point_cost = 1
icon = 20
additive_modifiers = {"shield_recharge": -1.2}

View file

@ -7,5 +7,6 @@ display_name = "Thrusters"
description = "+40 top speed, +60 acceleration per level."
base_cost = 25
max_level = 3
research_point_cost = 1
icon = 18
additive_modifiers = {"max_speed": 40.0, "acceleration": 60.0}

View file

@ -24,4 +24,5 @@ unlock_id = "unlock_shield"
unlock_name = "Unlock Shield Vessel"
unlock_description = "Lets the ship crafter build shield vessels. Its shield absorbs one lethal hit, then cycles back up."
unlock_cost = 45
research_point_cost = 1
unlock_icon = 23

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

125
SpeedFader.gd Normal file
View file

@ -0,0 +1,125 @@
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
var status_message_until: int = 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:
# Fleet owns persistent composition state. This outgoing UI node can
# briefly observe an empty fleet during a scene swap, so it must only
# clear its own interaction state here.
active_touch_index = -1
knob_t = 0.0
return
knob_t = (GameState.fleet_speed_multiplier - 1.0) \
/ maxf(GameState.MAX_FLEET_SPEED_MULTIPLIER - 1.0, 0.001)
# #62: GameState._process() (not this node's own _process()) is what
# drains energy and force-resets the multiplier, since it has to keep
# running across the space<->surface swap and while this control might
# not even be in the current scene tree. It leaves a one-shot flag for
# whichever SpeedFader picks it up next.
if GameState.boost_depleted_notice:
GameState.boost_depleted_notice = false
# Still holding the drag at the moment energy ran out would
# otherwise re-boost on the very next drag event, from a knob
# position the finger never actually moved away from.
active_touch_index = -1
label.text = "Out of energy!"
status_message_until = Time.get_ticks_msec() + 3000
elif Time.get_ticks_msec() >= status_message_until:
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)

1
SpeedFader.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://m16rgs2ed7w1

View file

@ -7,5 +7,6 @@ display_name = "Expanded Hold"
description = "Carries 8 more resources per supply run."
base_cost = 30
max_level = 3
research_point_cost = 1
icon = 21
additive_modifiers = {"cargo_capacity": 8.0}

View file

@ -7,5 +7,6 @@ display_name = "Freight Engines"
description = "+70 speed on supply runs."
base_cost = 30
max_level = 3
research_point_cost = 1
icon = 22
additive_modifiers = {"transport_speed": 70.0}

View file

@ -0,0 +1,84 @@
class_name VesselCooldownFillComponent
extends Node2D
# Shared per-vessel cooldown state and hull-fill feedback for abilities that
# grant one independently recharging shot per ship.
var cooldown_duration: float = 1.0
var cooldown_left: float = 0.0
func setup() -> void:
show_behind_parent = true
queue_redraw()
func is_ready() -> bool:
return cooldown_left <= 0.0
func _begin_cooldown(duration: float) -> bool:
if not is_ready():
return false
cooldown_duration = maxf(duration, 0.001)
cooldown_left = cooldown_duration
queue_redraw()
return true
func tick(delta: float) -> void:
if cooldown_left <= 0.0:
return
cooldown_left = maxf(cooldown_left - delta, 0.0)
queue_redraw()
func cooldown_progress() -> float:
return 1.0 - cooldown_left / maxf(cooldown_duration, 0.001)
func _filled_hull(points: PackedVector2Array, progress: float) -> PackedVector2Array:
if points.size() < 3 or progress <= 0.001:
return PackedVector2Array()
if progress >= 0.999:
return points.duplicate()
var top: float = INF
var bottom: float = -INF
for point in points:
top = minf(top, point.y)
bottom = maxf(bottom, point.y)
var fill_y: float = lerpf(bottom, top, clampf(progress, 0.0, 1.0))
var clipped := PackedVector2Array()
var previous: Vector2 = points[-1]
var previous_inside: bool = previous.y >= fill_y
for current in points:
var current_inside: bool = current.y >= fill_y
if current_inside != previous_inside:
var amount: float = (fill_y - previous.y) / (current.y - previous.y)
clipped.append(previous.lerp(current, amount))
if current_inside:
clipped.append(current)
previous = current
previous_inside = current_inside
return clipped
func _draw() -> void:
var ship: Node = get_parent()
if ship == null or ship.definition == null:
return
var hull: PackedVector2Array = ship.definition.hull_points
var ready_color: Color = _ready_color()
if is_ready():
draw_colored_polygon(hull, Color(ready_color, 0.42))
NeonDraw.draw_glowing_polygon(self, hull, ready_color, 0.75,
ship.core_color, 0.75)
return
draw_colored_polygon(hull, Color(_cooling_color(), 0.58))
var filled: PackedVector2Array = _filled_hull(hull, cooldown_progress())
if filled.size() >= 3:
draw_colored_polygon(filled, Color(ready_color, 0.68))
func _ready_color() -> Color: return Color.WHITE
func _cooling_color() -> Color: return Color(0.2, 0.2, 0.2)

View file

@ -0,0 +1 @@
uid://drsagaj2wr0ni

View file

@ -23,6 +23,10 @@ extends Resource
@export_multiline var unlock_description: String
@export var unlock_cost: int
@export var unlock_resource_costs: Dictionary = {}
# #63: spent from GameState.research_points, alongside unlock_resource_costs
# rather than instead of it. 0 (the default) means this unlock needs no
# research points at all.
@export var research_point_cost: int = 0
@export var unlock_icon: int = -1

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 @@
uid://c2una2fh0f06b

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

@ -1,84 +1,19 @@
class_name VesselGasBomberComponent
extends Node2D
extends "res://VesselCooldownFillComponent.gd"
var capability_id := "gas_bomber"
var cooldown_duration := 5.0
var cooldown_left := 0.0
const READY_COLOR := Color(1.0, 0.25, 0.78)
const COOLING_COLOR := Color(0.38, 0.16, 0.32)
func setup() -> void:
# Keep the ship's normal neon outline above the charge fill.
show_behind_parent = true
queue_redraw()
func is_ready() -> bool:
return cooldown_left <= 0.0
func _init() -> void:
cooldown_duration = 5.0
func consume_shot(duration: float) -> bool:
if not is_ready():
return false
cooldown_duration = maxf(duration, 0.001)
cooldown_left = cooldown_duration
queue_redraw()
return true
return _begin_cooldown(duration)
func tick(delta: float) -> void:
if cooldown_left <= 0.0:
return
cooldown_left = maxf(cooldown_left - delta, 0.0)
queue_redraw()
func cooldown_progress() -> float:
return 1.0 - cooldown_left / maxf(cooldown_duration, 0.001)
func _filled_hull(points: PackedVector2Array, progress: float) -> PackedVector2Array:
if points.size() < 3 or progress <= 0.001:
return PackedVector2Array()
if progress >= 0.999:
return points.duplicate()
var top := INF
var bottom := -INF
for point in points:
top = minf(top, point.y)
bottom = maxf(bottom, point.y)
var fill_y: float = lerpf(bottom, top, clampf(progress, 0.0, 1.0))
var clipped := PackedVector2Array()
var previous: Vector2 = points[-1]
var previous_inside := previous.y >= fill_y
for current in points:
var current_inside := current.y >= fill_y
if current_inside != previous_inside:
var amount: float = (fill_y - previous.y) / (current.y - previous.y)
clipped.append(previous.lerp(current, amount))
if current_inside:
clipped.append(current)
previous = current
previous_inside = current_inside
return clipped
func _draw() -> void:
var parent_ship: Node = get_parent()
if parent_ship == null or parent_ship.definition == null:
return
var core: Color = parent_ship.core_color if parent_ship != null else Color.WHITE
var hull: PackedVector2Array = parent_ship.definition.hull_points
if is_ready():
# A fully lit hull means this individual bomber owns an available shot.
draw_colored_polygon(hull, Color(READY_COLOR, 0.42))
NeonDraw.draw_glowing_polygon(self, hull, READY_COLOR, 0.75, core, 0.75)
return
# Rearming is shown inside the bomber instead of as a shield-like ring:
# the muted silhouette fills from its engines toward its nose.
draw_colored_polygon(hull, Color(COOLING_COLOR, 0.58))
var filled := _filled_hull(hull, cooldown_progress())
if filled.size() >= 3:
draw_colored_polygon(filled, Color(READY_COLOR, 0.68))
func _ready_color() -> Color: return READY_COLOR
func _cooling_color() -> Color: return COOLING_COLOR

View file

@ -1,29 +1,16 @@
class_name VesselLaserComponent
extends Node2D
# Same shape as VesselGasBomberComponent (per-ship cooldown, ready/cooling
# hull fill) - #58's own note: two laser vessels must give two shots, so
# this lives on the SHIP, not the fleet-wide LaserController.
#
# Unlike the gas bomber, damage/beam_range/cooldown are resolved from the
# vessel's own base_stats/upgrades (apply_stats(), the same pattern
# VesselWeaponComponent uses) rather than hardcoded in the controller, so
# ship-lab upgrades actually reach them.
extends "res://VesselCooldownFillComponent.gd"
var capability_id := "laser"
var damage: int = 2
var beam_range: float = 950.0
var cooldown_duration: float = 4.0
var cooldown_left: float = 0.0
const READY_COLOR := Color(0.3, 0.9, 1.0)
const COOLING_COLOR := Color(0.08, 0.28, 0.34)
func setup() -> void:
# Keep the ship's normal neon outline above the charge fill.
show_behind_parent = true
queue_redraw()
func _init() -> void:
cooldown_duration = 4.0
func apply_stats() -> void:
@ -35,66 +22,9 @@ func apply_stats() -> void:
queue_redraw()
func is_ready() -> bool:
return cooldown_left <= 0.0
func consume_shot() -> bool:
if not is_ready():
return false
cooldown_left = maxf(cooldown_duration, 0.001)
queue_redraw()
return true
return _begin_cooldown(cooldown_duration)
func tick(delta: float) -> void:
if cooldown_left <= 0.0:
return
cooldown_left = maxf(cooldown_left - delta, 0.0)
queue_redraw()
func cooldown_progress() -> float:
return 1.0 - cooldown_left / maxf(cooldown_duration, 0.001)
func _filled_hull(points: PackedVector2Array, progress: float) -> PackedVector2Array:
if points.size() < 3 or progress <= 0.001:
return PackedVector2Array()
if progress >= 0.999:
return points.duplicate()
var top := INF
var bottom := -INF
for point in points:
top = minf(top, point.y)
bottom = maxf(bottom, point.y)
var fill_y: float = lerpf(bottom, top, clampf(progress, 0.0, 1.0))
var clipped := PackedVector2Array()
var previous: Vector2 = points[-1]
var previous_inside := previous.y >= fill_y
for current in points:
var current_inside := current.y >= fill_y
if current_inside != previous_inside:
var amount: float = (fill_y - previous.y) / (current.y - previous.y)
clipped.append(previous.lerp(current, amount))
if current_inside:
clipped.append(current)
previous = current
previous_inside = current_inside
return clipped
func _draw() -> void:
var parent_ship: Node = get_parent()
if parent_ship == null or parent_ship.definition == null:
return
var core: Color = parent_ship.core_color if parent_ship != null else Color.WHITE
var hull: PackedVector2Array = parent_ship.definition.hull_points
if is_ready():
draw_colored_polygon(hull, Color(READY_COLOR, 0.42))
NeonDraw.draw_glowing_polygon(self, hull, READY_COLOR, 0.75, core, 0.75)
return
draw_colored_polygon(hull, Color(COOLING_COLOR, 0.58))
var filled := _filled_hull(hull, cooldown_progress())
if filled.size() >= 3:
draw_colored_polygon(filled, Color(READY_COLOR, 0.68))
func _ready_color() -> Color: return READY_COLOR
func _cooling_color() -> Color: return COOLING_COLOR

View file

@ -0,0 +1 @@
uid://bds5gccvd8dj8

View file

@ -9,3 +9,7 @@ extends Resource
@export var icon: int
@export var additive_modifiers: Dictionary = {}
@export var multiplicative_modifiers: Dictionary = {}
# #63: spent from GameState.research_points per level bought, alongside
# base_cost (ore) rather than instead of it. 0 (the default) means this
# level needs no research points at all.
@export var research_point_cost: int = 0

View file

@ -22,6 +22,10 @@ const GAS_GIANT_THRESHOLD: float = 0.55
# chance independent of biome - not a second smooth region to tune against
# the first.
const BLACK_HOLE_CHANCE: float = 0.05
# #63: findable without excessive exploration - research points gate mid/
# late skill-tree nodes and vessel research, so a player needs to run into
# one of these reasonably often, not just as a rare curiosity.
const RESEARCH_LAB_CHANCE: float = 0.08
# A predictable early gas source for play-testing. The origin chunk is
# deterministically normal space. Its explicit in-chunk position puts it
@ -95,6 +99,8 @@ static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary:
# roll doesn't shift which random values any LATER draw (planet_name,
# below) consumes for chunks that would have existed before it.
var black_hole_roll: float = rng.randf()
# Same principle, its own independent roll (#63).
var research_lab_roll: float = rng.randf()
# A stable per-chunk roll makes gas giants persistent landmarks rather
# than a different resource whenever the scene reloads.
var gas_giant: bool = guaranteed_nearby_giant or biome_at_chunk(chunk) == Biome.SPACE
@ -104,6 +110,8 @@ static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary:
# that chunk's whole purpose is to always be the same reachable source.
if not guaranteed_nearby_giant and black_hole_roll < BLACK_HOLE_CHANCE:
definition_id = PLANET_DEFINITIONS.BLACK_HOLE_ID
elif not guaranteed_nearby_giant and research_lab_roll < RESEARCH_LAB_CHANCE:
definition_id = PLANET_DEFINITIONS.RESEARCH_LAB_ID
var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id)
var names: PackedStringArray = definition.names
var planet_name: String = names[rng.randi() % names.size()]

1
gas_surface.gdshader.uid Normal file
View file

@ -0,0 +1 @@
uid://bhxcvtw6tcshl

View file

@ -0,0 +1 @@
uid://25xjfqi2qk7a

View file

@ -0,0 +1 @@
uid://f5a8wm4wr2i4

View file

@ -0,0 +1 @@
uid://b5ah2jh0i16du

View file

@ -0,0 +1 @@
uid://brt82kn8lb3u5

View file

@ -0,0 +1 @@
uid://bs6hguk0tkc6c

View file

@ -0,0 +1 @@
uid://fd1m4excjdbn

View file

@ -0,0 +1 @@
uid://c0q640rosb53o

View file

@ -0,0 +1 @@
uid://c8lgxg63d8s52

View file

@ -0,0 +1 @@
uid://cy6n62kcp7q1y

View file

@ -0,0 +1 @@
uid://c0yjf3qdv31eo

View file

@ -8,7 +8,13 @@ extends Node2D
# ... and again at 720x960 to check a shorter screen format.
func _ready() -> void:
var configured: bool = false
func _process(_delta: float) -> void:
if configured:
return
configured = true
GameState.buildings.clear()
GameState.extractors.clear()
GameState.transporters.clear()
@ -16,9 +22,12 @@ func _ready() -> void:
GameState.gas = 36
GameState.fleet_cargo = 24
GameState.fleet_gas_cargo = 12
GameState.unlocked = ["shipyards", "logistics", "freight"]
GameState.research_points = 0
GameState.unlocked = ["shipyards"]
var main: Node = load("res://Main.tscn").instantiate()
add_child(main)
main.get_node("EnemySpawner").set_process(false)
main.get_node("AsteroidField").set_process(false)
main.get_node("BuildingField").place(GameState.BUILDING_MOTHERSHIP, Vector2(400.0, 0.0))
var mothership_id: int = int(GameState.buildings[-1].id)
var bc: Node = main.get_node("BuildController")
@ -27,5 +36,5 @@ func _ready() -> void:
main.get_node("UI/SkillMenu").visible = true
# Select a node so the description, price and status all have text - an
# empty description cannot collide with anything.
bc.skill_tree.selected_id = "freight"
bc._refresh_skill_menu("freight")
bc.skill_tree.selected_id = "coordination"
bc._refresh_skill_menu("coordination")

View file

@ -0,0 +1 @@
uid://dfi84x2tcwqwy

View file

@ -0,0 +1 @@
uid://clf1bitxms0ti

View file

@ -0,0 +1 @@
uid://vlcbg3juhpy3

View file

@ -48,6 +48,7 @@ func _initialize() -> void:
game_state.reset_progression()
game_state.build_time_scale = 0.02
game_state.rocks = 500
game_state.research_points = 1
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")

View file

@ -0,0 +1 @@
uid://gj02xu88lxpm

View file

@ -0,0 +1,150 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_distorder_energy.gd
# Forgejo #62: sustaining a boost above the fleet's default speed drains
# energy from the fleet hold, continuously, proportional to how far above
# 1.0x the multiplier is.
#
# Most of this is pure GameState math (GameState._advance_distorder_drain,
# called directly the same way other tests call _advance_lab_research/
# _advance_production) - no Main.tscn/Fleet needed for it, which is itself
# proof the drain is scene-independent (it lives in the autoload's own
# _process(), unlike a component's tick() which only runs while its ship's
# scene is loaded) and so "continues correctly across a landing and
# return" without a scene-swap simulation to re-prove that.
#
# Covers:
# A. sustained boost drains energy at the configured rate; half boost
# drains proportionally less than full boost
# B. a small multiplier still drains a nonzero amount over time - no
# fractional rounding loss
# D. at exactly 1.0x, energy is never consumed
# C. hitting zero energy forces the multiplier back to 1.0, and the
# SpeedFader visibly follows it down with the reason shown
var game_state: Node
var main: Node
var fleet: Node
var fader: Node
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.reset_progression()
game_state.fleet_speed_multiplier = 1.0
game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 1000)
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
# --- D. nothing drains at exactly 1.0x ----------------------------
var before: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY)
for i in range(60):
game_state._advance_distorder_drain(1.0)
if game_state.fleet_resource(GameState.RESOURCE_ENERGY) != before:
return _fail_now("energy drained at 1.0x: %d -> %d" \
% [before, game_state.fleet_resource(GameState.RESOURCE_ENERGY)])
print("no drain at 1.0x, even over 60 simulated seconds")
# --- A. full boost drains at the configured rate ------------------
game_state.fleet_speed_multiplier = GameState.MAX_FLEET_SPEED_MULTIPLIER
var full_before: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY)
for i in range(10):
game_state._advance_distorder_drain(1.0)
var full_drained: int = full_before - game_state.fleet_resource(GameState.RESOURCE_ENERGY)
if full_drained != 20:
return _fail_now("full boost drained %d over 10s, expected 20 (ENERGY_DRAIN_PER_SECOND=2.0)" \
% full_drained)
print("full boost drained %d over 10s, matches ENERGY_DRAIN_PER_SECOND" % full_drained)
# --- A continued: half boost drains proportionally less -----------
game_state.fleet_speed_multiplier = 2.0
var half_before: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY)
for i in range(10):
game_state._advance_distorder_drain(1.0)
var half_drained: int = half_before - game_state.fleet_resource(GameState.RESOURCE_ENERGY)
if half_drained <= 0 or half_drained >= full_drained:
return _fail_now("half boost drained %d over 10s, expected between 0 and full's %d" \
% [half_drained, full_drained])
print("half boost drained %d over 10s - less than full's %d, still nonzero" \
% [half_drained, full_drained])
# --- B. a small multiplier still drains a nonzero amount ----------
game_state.fleet_speed_multiplier = 1.05
var small_before: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY)
for i in range(600):
game_state._advance_distorder_drain(0.1)
var small_drained: int = small_before - game_state.fleet_resource(GameState.RESOURCE_ENERGY)
if small_drained <= 0:
return _fail_now("a small (1.05x) sustained boost drained nothing over 60s - fractional rounding loss")
print("small boost (1.05x) still drained %d over 60s (no rounding loss)" % small_drained)
# --- C. running out resets the multiplier and raises the notice ---
game_state.withdraw_fleet_resource(GameState.RESOURCE_ENERGY,
game_state.fleet_resource(GameState.RESOURCE_ENERGY))
game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 5)
game_state.fleet_speed_multiplier = GameState.MAX_FLEET_SPEED_MULTIPLIER
game_state.boost_depleted_notice = false
for i in range(10):
game_state._advance_distorder_drain(1.0)
if game_state.fleet_resource(GameState.RESOURCE_ENERGY) != 0:
return _fail_now("fleet energy did not reach exactly 0, got %d" \
% game_state.fleet_resource(GameState.RESOURCE_ENERGY))
if not is_equal_approx(game_state.fleet_speed_multiplier, 1.0):
return _fail_now("multiplier was not forced back to 1.0 after running out: %f" \
% game_state.fleet_speed_multiplier)
if not game_state.boost_depleted_notice:
return _fail_now("no depleted notice was raised when energy ran out")
print("running out of energy dropped the multiplier to 1.0 and raised the notice")
stage = 1
elapsed = 0.0
return false
1:
# --- C continued: the SpeedFader visibly follows it down ----------
if elapsed < 0.2:
return false
main = load("res://Main.tscn").instantiate()
root.add_child(main)
fleet = main.get_node("Fleet")
fader = main.get_node("UI/SpeedFader")
fleet.spawn_ships(1, Vector2.ZERO, PackedStringArray([GameState.VESSEL_DISTORDER]))
fader._process(0.0)
if not fader.visible:
return _fail_now("fader hidden with a distorder ship in the fleet")
game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 4)
game_state.set_fleet_speed_multiplier(GameState.MAX_FLEET_SPEED_MULTIPLIER)
fader._process(0.0)
if fader.knob_t < 0.9:
return _fail_now("fader knob did not reflect the boosted multiplier before draining: %f" \
% fader.knob_t)
for i in range(10):
game_state._advance_distorder_drain(1.0)
if not is_equal_approx(game_state.fleet_speed_multiplier, 1.0):
return _fail_now("multiplier was not forced back to 1.0 after running out (with a live fleet)")
fader._process(0.0)
if fader.knob_t > 0.05:
return _fail_now("fader knob did not snap down after the boost was depleted: %f" % fader.knob_t)
if fader.label.text != "Out of energy!":
return _fail_now("fader did not show the depleted reason, text was '%s'" % fader.label.text)
if game_state.boost_depleted_notice:
return _fail_now("boost_depleted_notice was not consumed by the fader")
print("fader visibly snapped its knob down and explained the depletion")
print("TEST_DISTORDER_ENERGY: PASS")
quit(0)
return true
return false
func _fail_now(message: String) -> bool:
print("TEST_DISTORDER_ENERGY: FAIL (%s)" % message)
quit(1)
return true

View file

@ -0,0 +1 @@
uid://bistn7dgiv7lw

View file

@ -0,0 +1 @@
uid://c18rxosfduxoa

View file

@ -21,6 +21,7 @@ func _initialize() -> void:
game_state.extractors.clear()
game_state.transporters.clear()
game_state.reset_progression()
game_state.research_points = 1
game_state.add_extractor("Test Planet", EXTRACTOR_AT)
extractor_index = 0

View file

@ -0,0 +1 @@
uid://b1fyqy5i8mqf0

View file

@ -3,7 +3,10 @@ extends SceneTree
var game_state: Node
var main: Node
var fleet: Node
var skill: GasBombController
var skill: Node
# Mirrors GasBombController.AOE_RADIUS's value rather than referencing the
# class constant directly - see test_gas_bomber_cooldowns.gd's identical fix.
const AOE_RADIUS_FOR_TEST: float = 180.0
var inside_enemies: Array = []
var outside_enemy: Node
var stage := 0
@ -24,6 +27,10 @@ func _initialize() -> void:
game_state.fleet_cargo = 0
game_state.fleet_gas_cargo = 0
game_state.build_time_scale = 0.01
game_state.research_points = 1
# #60: after research/crafting resets the fleet hold below, top it up
# with fleet-carried gas for the shot itself (ammunition, not the ore/gas
# spent researching or crafting the bomber).
# Research is gas-only: any amount of ore and 29 gas must still leave it
# waiting, then the thirtieth unit starts and completes the unlock.
@ -69,6 +76,7 @@ func _initialize() -> void:
_fail_now("crafting did not produce a typed Gas Bomber")
return
game_state.fleet_gas_cargo = 20
main = load("res://Main.tscn").instantiate()
root.add_child(main)
fleet = main.get_node("Fleet")
@ -117,7 +125,7 @@ func _process(delta: float) -> bool:
enemy.speed = 0.0
inside_enemies.append(enemy)
outside_enemy = load("res://Enemy.tscn").instantiate()
outside_enemy.position = skill.aim_target + Vector2(GasBombController.AOE_RADIUS + 80.0, 0)
outside_enemy.position = skill.aim_target + Vector2(AOE_RADIUS_FOR_TEST + 80.0, 0)
main.add_child(outside_enemy)
outside_enemy.speed = 0.0

View file

@ -0,0 +1,142 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_gas_bomber_ammo.gd
# Forgejo #60: gas bomb shots draw ammunition from the FLEET HOLD
# (GameState.fleet_inventory), not the bank - the fleet has to be carrying
# its own gas.
#
# Covers:
# A. gas sitting in the bank does not enable firing - only the fleet
# hold counts
# B. with insufficient fleet gas the button is disabled, pressing it
# explains why, and no projectile is created / no cooldown consumed
# C. firing drains exactly GAS_PER_SHOT from the fleet hold and nothing
# from the bank
var game_state: Node
var main: Node
var fleet: Node
var skill: Node
var bomber_component: Node
var stage: int = 0
var elapsed: float = 0.0
# Mirrors GasBombController.GAS_PER_SHOT's value rather than referencing
# the class constant directly - a bare ClassName.CONST reference elsewhere
# in a test
# script has been observed to corrupt method resolution for the WHOLE
# script (an unrelated skill._process() call earlier in the same file then
# fails with "Nonexistent function '_process' in base 'Nil'"), root cause
# unconfirmed. See tests/test_gas_bomber_cooldowns.gd's identical fix.
const GAS_PER_SHOT: int = 5
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_gas_bomber"] = 1
game_state.rocks = 0
game_state.gas = 0
game_state.fleet_gas_cargo = 0
main = load("res://Main.tscn").instantiate()
root.add_child(main)
fleet = main.get_node("Fleet")
skill = main.get_node("GasBombController")
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)
# An interceptor rides along purely for its "cargo" capability
# (#56): a fleet made only of a gas bomber has zero cargo
# capacity, which would silently clamp every fleet_gas_cargo
# assignment below to 0.
fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray([
GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_GAS_BOMBER]))
bomber_component = fleet.ships[1].get_component("gas_bomber")
skill._process(0.0)
# --- A. bank gas does not count -----------------------------------
game_state.gas = 500
game_state.fleet_gas_cargo = 0
skill._process(0.0)
if skill.button.enabled:
return _fail_now("500 banked gas (0 in the fleet hold) left the control enabled")
print("500 banked gas, 0 fleet gas: control correctly disabled")
# --- B. insufficient fleet gas --------------------------------------
game_state.fleet_gas_cargo = GAS_PER_SHOT - 1
skill._process(0.0)
if skill.button.enabled:
return _fail_now("fleet gas one short of GAS_PER_SHOT left the control enabled")
var press := InputEventScreenTouch.new()
press.index = 3
press.position = skill.button.global_position + skill.button.size * 0.5
press.pressed = true
main._handle_touch(press)
if skill.aiming:
return _fail_now("aiming began despite insufficient fleet gas")
if skill.status.text == "":
return _fail_now("an unaffordable press did not explain itself")
print("insufficient fleet gas: disabled, press explains itself, no aiming began")
if not bomber_component.is_ready():
return _fail_now("a refused press consumed the bomber's cooldown")
if game_state.fleet_resource(GameState.RESOURCE_GAS) != GAS_PER_SHOT - 1:
return _fail_now("a refused press spent fleet gas anyway")
print("no cooldown consumed, no gas spent on a refused press")
# --- C. exact ammo cost on a real shot ------------------------------
game_state.fleet_gas_cargo = GAS_PER_SHOT + 7
skill._process(0.0)
if not skill.button.enabled:
return _fail_now("enough fleet gas still left the control disabled")
var press2 := InputEventScreenTouch.new()
press2.index = 4
press2.position = skill.button.global_position + skill.button.size * 0.5
press2.pressed = true
main._handle_touch(press2)
if not skill.aiming:
return _fail_now("an affordable press did not begin aiming")
var drag := InputEventScreenDrag.new()
drag.index = 4
drag.position = press2.position + Vector2(-300.0, 0.0)
main._handle_drag(drag)
var release := InputEventScreenTouch.new()
release.index = 4
release.position = drag.position
release.pressed = false
main._handle_touch(release)
if skill.projectiles.size() != 1:
return _fail_now("a paid-for shot did not launch a projectile")
if bomber_component.is_ready():
return _fail_now("firing did not consume the bomber's cooldown")
if game_state.fleet_resource(GameState.RESOURCE_GAS) != 7:
return _fail_now("fleet gas after firing is %d, expected 7 (paid exactly %d)" \
% [game_state.fleet_resource(GameState.RESOURCE_GAS), GAS_PER_SHOT])
if game_state.gas != 500:
return _fail_now("firing touched the bank (%d), expected it untouched at 500" % game_state.gas)
print("firing drained exactly %d from the fleet hold, bank untouched" % GAS_PER_SHOT)
print("TEST_GAS_BOMBER_AMMO: PASS")
quit(0)
return true
return false
func _fail_now(message: String) -> bool:
print("TEST_GAS_BOMBER_AMMO: FAIL (%s)" % message)
quit(1)
return true

View file

@ -0,0 +1 @@
uid://cwcw77qvykk35

View file

@ -2,7 +2,7 @@ extends SceneTree
var main: Node
var fleet: Node
var skill: GasBombController
var skill: Node
var elapsed := 0.0
@ -12,6 +12,8 @@ func _initialize() -> void:
state.extractors.clear()
state.transporters.clear()
state.vessel_levels["unlock_gas_bomber"] = 1
# #60: shots draw from the fleet hold now - stock enough for both fires.
state.fleet_gas_cargo = 20
main = load("res://Main.tscn").instantiate()
root.add_child(main)
fleet = main.get_node("Fleet")
@ -53,7 +55,7 @@ func _process(delta: float) -> bool:
# Rearming one bomber restores one available shot without touching the
# other bomber's cooldown.
first.tick(GasBombController.COOLDOWN)
first.tick(5.0) # matches GasBombController.COOLDOWN
skill._process(0.0)
if not first.is_ready() or second.is_ready() or not skill.button.enabled:
return _fail("one completed cooldown did not restore exactly one shot")

View file

@ -1 +1 @@
uid://cwo6ake5taias
uid://tguuracp1ewt

View file

@ -30,6 +30,8 @@ var hub_id: int = 0
var burner_id: int = 0
var load_limit: int = 0
var energy_before_stall: int = 0
var conversion_rate: float = 0.0
var conversion_yield: int = 0
const MOTHERSHIP_AT := Vector2.ZERO
const HUB_AT := Vector2(150.0, 0.0)
@ -48,6 +50,9 @@ func _initialize() -> void:
mothership_id = game_state.add_building(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)
burner_id = game_state.add_building(GameState.BUILDING_GAS_BURNER, BURNER_AT)
game_state.deposit_resource_to_building(burner_id, GameState.RESOURCE_GAS, 20)
var logistics: Resource = game_state.building_logistics_profile(game_state.find_building(burner_id))
conversion_rate = logistics.conversion_rate
conversion_yield = logistics.conversion_yield
func _process(delta: float) -> bool:
@ -73,10 +78,10 @@ func _process(delta: float) -> bool:
var energy_made: int = game_state.building_resource(burner_id, GameState.RESOURCE_ENERGY)
var gas_burned: int = 20 - gas_left
if gas_burned < 2:
return _fail("burner only burned %d gas in 2.5s at rate %.1f/s" % [gas_burned, GameState.GAS_BURN_RATE])
if energy_made != gas_burned * GameState.ENERGY_PER_GAS:
return _fail("burner only burned %d gas in 2.5s at rate %.1f/s" % [gas_burned, conversion_rate])
if energy_made != gas_burned * conversion_yield:
return _fail("burner made %d energy for %d gas burned, expected %dx" % [
energy_made, gas_burned, GameState.ENERGY_PER_GAS
energy_made, gas_burned, conversion_yield
])
if game_state.rocks != 0 or game_state.gas != 0:
return _fail("conversion touched the bank (rocks=%d gas=%d) - it must only use local stock" % [

View file

@ -0,0 +1 @@
uid://dlx2eu6lw3ps6

View file

@ -12,6 +12,7 @@ func _initialize() -> void:
state.transporters.clear()
state.reset_progression()
state.rocks = 900
state.research_points = state.skill_research_point_cost("freight_yards")
state.unlocked = ["shipyards", "logistics", "freight"]
var hub_a: int = state.add_building(state.BUILDING_TRANSPORT_HUB, Vector2(-500, 0))

View file

@ -10,6 +10,7 @@ func _initialize() -> void:
game.vessel_levels.clear()
game.build_time_scale = 0.02
game.rocks = 500
game.research_points = 1
lab_id = game.add_building(GameState.BUILDING_SHIP_LAB, Vector2.ZERO)
if not game.queue_lab_research(lab_id, "int_damage"):
_fail("could not queue research")

View file

@ -12,6 +12,7 @@ func _initialize() -> void:
state.transporters.clear()
state.reset_progression()
state.rocks = 100
state.research_points = 1
state.build_time_scale = 0.02
state.add_building(state.BUILDING_MOTHERSHIP, Vector2.ZERO)
state.add_building(state.BUILDING_TRANSPORT_HUB, Vector2(50, 0))

141
tests/test_laser_ammo.gd Normal file
View file

@ -0,0 +1,141 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_laser_ammo.gd
# Forgejo #61: laser shots draw ammunition from the FLEET HOLD
# (GameState.fleet_inventory), the same way gas bomb shots do (#60) - the
# fleet has to be carrying its own energy.
#
# Covers:
# A. banked energy does not enable firing - only the fleet hold counts
# B. with insufficient fleet energy the button is disabled, pressing it
# explains why, and no beam is fired / no cooldown consumed
# C. firing drains exactly ENERGY_PER_SHOT from the fleet hold and
# nothing from the bank
var game_state: Node
var main: Node
var fleet: Node
var skill: Node
var laser_component: Node
var stage: int = 0
var elapsed: float = 0.0
# Mirrors LaserController.ENERGY_PER_SHOT's value rather than referencing
# the class constant directly - a bare ClassName.CONST reference elsewhere
# in a test script has been observed to corrupt method resolution for the
# WHOLE script (an unrelated skill._process() call earlier in the same
# file then fails with "Nonexistent function '_process' in base 'Nil'"),
# root cause unconfirmed. See tests/test_gas_bomber_cooldowns.gd's
# identical fix (#60).
const ENERGY_PER_SHOT: int = 8
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_laser"] = 1
game_state.rocks = 0
game_state.gas = 0
game_state.fleet_gas_cargo = 0
main = load("res://Main.tscn").instantiate()
root.add_child(main)
fleet = main.get_node("Fleet")
skill = main.get_node("LaserController")
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)
# An interceptor rides along purely for its "cargo" capability
# (#56): a fleet made only of a laser ship has zero cargo
# capacity, which would silently clamp every fleet energy
# deposit below to 0.
fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray([
GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_LASER]))
laser_component = fleet.ships[1].get_component("laser")
skill._process(0.0)
# --- A. bank energy does not count ----------------------------
game_state.deposit_bank_resource(GameState.RESOURCE_ENERGY, 500)
skill._process(0.0)
if skill.button.enabled:
return _fail_now("500 banked energy (0 in the fleet hold) left the control enabled")
print("500 banked energy, 0 fleet energy: control correctly disabled")
# --- B. insufficient fleet energy -------------------------------
game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, ENERGY_PER_SHOT - 1)
skill._process(0.0)
if skill.button.enabled:
return _fail_now("fleet energy one short of ENERGY_PER_SHOT left the control enabled")
var press := InputEventScreenTouch.new()
press.index = 3
press.position = skill.button.global_position + skill.button.size * 0.5
press.pressed = true
main._handle_touch(press)
if skill.aiming:
return _fail_now("aiming began despite insufficient fleet energy")
if skill.status.text == "":
return _fail_now("an unaffordable press did not explain itself")
print("insufficient fleet energy: disabled, press explains itself, no aiming began")
if not laser_component.is_ready():
return _fail_now("a refused press consumed the laser ship's cooldown")
if game_state.fleet_resource(GameState.RESOURCE_ENERGY) != ENERGY_PER_SHOT - 1:
return _fail_now("a refused press spent fleet energy anyway")
print("no cooldown consumed, no energy spent on a refused press")
# --- C. exact ammo cost on a real shot -----------------------------
game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 8)
skill._process(0.0)
if not skill.button.enabled:
return _fail_now("enough fleet energy still left the control disabled")
var fleet_energy_before: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY)
var press2 := InputEventScreenTouch.new()
press2.index = 4
press2.position = skill.button.global_position + skill.button.size * 0.5
press2.pressed = true
main._handle_touch(press2)
if not skill.aiming:
return _fail_now("an affordable press did not begin aiming")
var drag := InputEventScreenDrag.new()
drag.index = 4
drag.position = press2.position + Vector2(-300.0, 0.0)
main._handle_drag(drag)
var release := InputEventScreenTouch.new()
release.index = 4
release.position = drag.position
release.pressed = false
main._handle_touch(release)
if laser_component.is_ready():
return _fail_now("firing did not consume the laser ship's cooldown")
var fleet_energy_after: int = game_state.fleet_resource(GameState.RESOURCE_ENERGY)
if fleet_energy_before - fleet_energy_after != ENERGY_PER_SHOT:
return _fail_now("firing spent %d fleet energy, expected exactly %d" \
% [fleet_energy_before - fleet_energy_after, ENERGY_PER_SHOT])
if game_state.banked_resource(GameState.RESOURCE_ENERGY) != 500:
return _fail_now("firing touched the bank (%d), expected it untouched at 500" \
% game_state.banked_resource(GameState.RESOURCE_ENERGY))
print("firing drained exactly %d from the fleet hold, bank untouched" % ENERGY_PER_SHOT)
print("TEST_LASER_AMMO: PASS")
quit(0)
return true
return false
func _fail_now(message: String) -> bool:
print("TEST_LASER_AMMO: FAIL (%s)" % message)
quit(1)
return true

View file

@ -0,0 +1 @@
uid://d24p5etger56g

View file

@ -20,7 +20,12 @@ extends SceneTree
var game_state: Node
var main: Node
var fleet: Node
var skill: LaserController
var skill: Node
# Mirrors LaserController.BEAM_HALF_WIDTH's value rather than referencing
# the class constant directly - a bare ClassName.CONST reference elsewhere
# in a test script has been observed to corrupt method resolution for the
# WHOLE script. See tests/test_gas_bomber_cooldowns.gd's identical fix (#60).
const BEAM_HALF_WIDTH: float = 22.0
var on_line_enemies: Array = []
var off_line_enemy: Node
var beyond_range_enemy: Node
@ -42,6 +47,13 @@ func _initialize() -> void:
# this ticket is about the laser's own behaviour, not re-proving the
# ship-lab research flow (already covered generically elsewhere).
game_state.vessel_levels["unlock_laser"] = 1
# #61: shots draw energy from the fleet hold now - stock enough for two
# fires. Set BEFORE the first spawn_ships() call (inside Main.tscn's own
# _ready(), triggered by instantiate() below): the fleet hold has no
# capacity cap yet at this point (#56's cap is only computed once a
# fleet composition exists), so this deposit can't be clamped by the
# soon-to-exist all-laser fleet having zero "cargo" capability.
game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 20)
main = load("res://Main.tscn").instantiate()
root.add_child(main)
fleet = main.get_node("Fleet")
@ -106,7 +118,7 @@ func _process(delta: float) -> bool:
off_line_enemy = load("res://Enemy.tscn").instantiate()
off_line_enemy.position = first_shooter.global_position \
+ Vector2(200.0, LaserController.BEAM_HALF_WIDTH + 60.0)
+ Vector2(200.0, BEAM_HALF_WIDTH + 60.0)
main.add_child(off_line_enemy)
off_line_enemy.speed = 0.0

View file

@ -0,0 +1 @@
uid://dp0e71h2124c8

Some files were not shown because too many files have changed in this diff Show more