#84: move 215 root files into a folder hierarchy

Everything lived in one flat directory, so a diff gave no clue what area it
touched and finding the file you wanted meant knowing its exact name. Twelve
folders now, grouped by what a thing IS rather than what type it is - a
vessel's definition, its component scenes and its upgrade resources sit
together, because that is the set you read when you change one of them.

  buildings  core  controllers  enemies  logistics  planets
  resources  shaders  surface  ui  vessels  vfx  world

332 files moved and every res:// reference rewritten - 306 files touched,
including the autoload and main-scene paths in project.godot. Nothing renamed:
this is purely where files sit, so the diff is a pile of pure moves and no
behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at
the root, where Godot and the export preset expect them.

Verified three ways, because a suite alone would not have caught all of it:
the full 117 pass, the web export builds clean, and the game renders.

The export is why that matters. The FIRST export after the move failed with
"Attempt to open script 'res://GameState.gd'" - the old path - while
project.godot on disk plainly said res://core/GameState.gd. That is this
project's documented Android FUSE mtime trap: Godot served a cached copy of a
file whose content had changed. A `touch` fixed it. Nothing in the test suite
would have found that, because the suite runs from a copy on a native
filesystem - the one place the bug cannot happen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-21 19:44:14 +02:00
commit 2f161621c3
495 changed files with 571 additions and 567 deletions

View file

@ -0,0 +1,12 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://vessels/VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "car_capacity"
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

@ -0,0 +1,26 @@
[gd_resource type="Resource" script_class="VesselDefinition" load_steps=4 format=3]
[ext_resource type="Script" path="res://vessels/VesselDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://vessels/VesselCargoComponent.tscn" id="2"]
[ext_resource type="Resource" path="res://vessels/CarrierCapacityUpgrade.tres" id="3"]
[resource]
script = ExtResource("1")
definition_id = "carrier"
display_name = "Carrier"
description = "Unarmed hauler with a much larger hold than any combat vessel. Cannot fire."
build_cost = 24
build_time = 20.0
icon = 34
glow_color = Color(1, 0.78, 0.3, 1)
hull_points = PackedVector2Array(-32, -10, -18, -18, 18, -18, 32, -10, 32, 14, -32, 14)
fleet_eligible = true
capabilities = PackedStringArray("cargo")
component_scenes = Array[PackedScene]([ExtResource("2")])
base_stats = {"max_speed": 380.0, "acceleration": 1080.0, "delivery_speed": 180.0, "cargo_capacity": 120.0}
upgrades = Array[Resource]([ExtResource("3")])
unlock_id = "unlock_carrier"
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://vessels/VesselDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://vessels/VesselDistorderComponent.tscn" id="2"]
[resource]
script = ExtResource("1")
definition_id = "distorder"
display_name = "Energy Propulser"
description = "Unarmed propulsion hull that burns energy to drive the whole fleet faster. While one flies with you, a fader lets you push cruise speed past its default."
build_cost = 14
build_resource_costs = {"ore": 14, "energy": 8}
build_time = 16.0
icon = 37
glow_color = Color(0.85, 0.95, 0.15, 1)
hull_points = PackedVector2Array(0, -26, 9, -8, 13, 6, 18, 20, 0, 12, -18, 20, -13, 6, -9, -8)
fleet_eligible = true
capabilities = PackedStringArray("distorder")
component_scenes = Array[PackedScene]([ExtResource("2")])
base_stats = {"max_speed": 430.0, "acceleration": 1230.0, "delivery_speed": 180.0}
unlock_id = "unlock_distorder"
unlock_name = "Unlock Energy Propulser"
unlock_description = "Research a propulsion hull that burns energy to drive the whole fleet faster - while one flies with you, a fader lets you push cruise speed past its default."
unlock_cost = 0
unlock_resource_costs = {"energy": 50}
research_point_cost = 1
unlock_icon = -1

327
vessels/Fleet.gd Normal file
View file

@ -0,0 +1,327 @@
extends Node2D
# Formation slots come from GameState.formation (editable at a command
# centre) rather than being fixed here, so a layout the player arranged
# survives scene swaps and fleet rebuilds. Ships are always
# spawned programmatically (spawn_ships), using however many of these
# offsets are needed. The optional vessel-type list preserves the exact
# mixed fleet through scene swaps; a fresh start still defaults each slot
# to an interceptor.
# The fleet's cruise speed. This is the speed of the FLEET as a whole: the
# anchor below advances at exactly this rate, and individual ships are
# allowed to fly faster than it (Ship.max_speed) so they can close on their
# formation slot - which is what makes a pivot while under way look sharp
# instead of sluggish.
@export var cruise_speed: float = 260.0
# Per-scene neon palette for the fleet. PlanetSurface.tscn overrides these
# with darker values, since the lunar surface is bright grey and the default
# near-white core would wash out against it. See NeonDraw.
@export var ship_glow_color: Color = Color(0.25, 0.85, 1.0)
@export var ship_core_color: Color = Color(1.0, 1.0, 1.0)
var ship_scene: PackedScene = preload("res://vessels/Ship.tscn")
var ships: Array = []
var formation_offsets: Array = []
var fleet_target: Vector2 = Vector2.ZERO
# Formation centre while under joystick steering, and the heading it is
# travelling along. Zero heading means the fleet is not being steered.
var anchor: Vector2 = Vector2.ZERO
var steer_direction: Vector2 = Vector2.ZERO
func _ready() -> void:
# Lets anything resolve "the fleet" without a hardcoded ../Fleet sibling
# path - enemies parented under different hosts (e.g. a planet surface's
# SurfaceFeatures) need this to find their target.
add_to_group("player_fleet")
# Researching a vessel upgrade improves the ships already flying, not
# just the next one built (#5).
GameState.vessel_upgraded.connect(_on_vessel_upgraded)
func _on_vessel_upgraded(_id: String) -> void:
for ship in ships:
if is_instance_valid(ship):
ship.apply_vessel_stats()
func spawn_ships(count: int, center: Vector2 = Vector2.ZERO,
vessel_types: PackedStringArray = PackedStringArray()) -> void:
for ship in ships:
if is_instance_valid(ship):
ship.queue_free()
ships.clear()
formation_offsets.clear()
var spawn_types: PackedStringArray = PackedStringArray()
if vessel_types.is_empty():
for i in range(count):
spawn_types.append(GameState.VESSEL_INTERCEPTOR)
else:
for vessel_type in vessel_types:
if GameState.vessel_is_fleet_eligible(vessel_type):
spawn_types.append(vessel_type)
if spawn_types.is_empty():
spawn_types.append(GameState.VESSEL_INTERCEPTOR)
var n: int = clamp(spawn_types.size(), 1, GameState.max_fleet_size())
for i in range(n):
var offset: Vector2 = GameState.formation[i]
var ship: Area2D = ship_scene.instantiate()
ship.vessel_type = spawn_types[i]
ship.position = center + offset
ship.glow_color = ship_glow_color
ship.core_color = ship_core_color
add_child(ship)
ships.append(ship)
formation_offsets.append(offset)
if ship.has_signal("destroyed"):
ship.destroyed.connect(_on_ship_destroyed.bind(ship))
fleet_target = center
_sync_composition_state()
func vessel_types() -> PackedStringArray:
var result: PackedStringArray = PackedStringArray()
for ship in ships:
if is_instance_valid(ship):
result.append(String(ship.vessel_type))
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.
# Each distorder vessel past the first widens the speed ceiling, so the
# COUNT matters, not merely whether one is present.
GameState.set_distorder_vessel_count(count_ships_with_capability("distorder"))
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:
return true
return false
func first_vessel_of_type(vessel_type: String) -> Node2D:
for ship in ships:
if is_instance_valid(ship) and String(ship.vessel_type) == vessel_type:
return ship
return null
func has_capability(capability: String) -> bool:
return first_ship_with_capability(capability) != null
func count_ships_with_capability(capability: String) -> int:
var total: int = 0
for ship in ships:
if is_instance_valid(ship) and ship.has_capability(capability):
total += 1
return total
func first_ship_with_capability(capability: String) -> Node2D:
for ship in ships:
if is_instance_valid(ship) and ship.has_capability(capability):
return ship
return null
func add_ship(vessel_type: String = GameState.VESSEL_INTERCEPTOR) -> bool:
# Adds one vessel in the next free formation slot (bought from a ship
# crafter). Returns false when the wedge is already full.
if not GameState.vessel_is_fleet_eligible(vessel_type) \
or ships.size() >= GameState.max_fleet_size():
return false
var offset: Vector2 = GameState.formation[ships.size()]
var ship: Area2D = ship_scene.instantiate()
# Before add_child, so Ship._ready() builds the right vessel - the shield
# ring is created there and only for a shield vessel.
ship.vessel_type = vessel_type
# From the formation origin, not the centroid: with a lopsided layout the
# centroid is already displaced by the average offset, so adding the new
# ship's own offset to it would drop it well outside the wedge.
ship.position = get_formation_origin() + offset
ship.glow_color = ship_glow_color
ship.core_color = ship_core_color
add_child(ship)
ships.append(ship)
formation_offsets.append(offset)
if ship.has_signal("destroyed"):
ship.destroyed.connect(_on_ship_destroyed.bind(ship))
ship.set_move_target(fleet_target + offset)
_sync_composition_state()
return true
func store_ship(index: int) -> String:
# A fleet always keeps one ship deployed. Removing the last one would trip
# the wiped-out-fleet safety net on the next frame and immediately spawn a
# replacement, which is the opposite of storing it at the mothership.
if ships.size() <= 1 or index < 0 or index >= ships.size():
return ""
var ship: Node = ships[index]
if not is_instance_valid(ship):
return ""
var vessel_type: String = String(ship.vessel_type)
ships.remove_at(index)
formation_offsets.remove_at(index)
ship.queue_free()
apply_formation()
_sync_composition_state()
return vessel_type
func apply_formation() -> void:
# Re-seats the EXISTING ships into the current GameState.formation.
# Without this, editing the layout at a command centre (or pivoting it)
# only affected ships built later, which read as the edit doing nothing.
for i in range(ships.size()):
if i >= GameState.max_fleet_size():
break
formation_offsets[i] = GameState.formation[i]
_assign_slot_targets()
func move_to(world_pos: Vector2) -> void:
steer_direction = Vector2.ZERO
fleet_target = world_pos
anchor = world_pos
_assign_slot_targets()
func move_in_direction(direction: Vector2) -> void:
# Joystick steering. Sets the heading; _process advances the anchor.
#
# This used to park a target 2000 units ahead and let the ships chase
# it. That made formation changes barely register while flying: with
# the aim point that far away, a 60-unit formation offset moved a
# ship's heading by under two degrees, so pivoting mid-flight looked
# like it did almost nothing. Anchoring the formation and having each
# ship hold its own slot relative to it fixes that.
if direction == Vector2.ZERO:
stop()
return
if steer_direction == Vector2.ZERO:
anchor = get_formation_origin()
steer_direction = direction.normalized()
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. #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 * effective_cruise_speed
func stop() -> void:
steer_direction = Vector2.ZERO
# Aim each ship at the point where its own braking curve brings it to
# rest, so releasing the stick coasts to a halt instead of snapping to
# a dead stop (which is what targeting the current position would do,
# since that lands inside arrive_radius immediately).
fleet_target = get_formation_origin()
for ship in ships:
# A ship's `velocity` is already its true motion, including the
# fleet's travel, because Ship feeds the slot velocity forward into
# its own steering rather than carrying it in a separate field. So
# stopping is only a matter of telling it the slot has halted and
# aiming it at the point its own braking curve reaches.
#
# This used to do `ship.velocity += ship.fleet_velocity` here, to
# compensate for `velocity` reading zero while the ship was in fact
# cruising (Ship snapped to its slot every tick and zeroed it). That
# compensation was an unbounded ACCUMULATOR: every release added a
# whole cruise velocity, the next steer refilled fleet_velocity, and
# tapping a direction repeatedly stacked them up faster than
# `acceleration` could bleed them off - so a vessel could be flung
# along at many times its own top speed, and ships fell out of sync
# because each bled off at a rate set by its own distance to its
# slot. Ship now bounds its total speed every tick as well, so
# neither half of that can come back.
var motion: Vector2 = ship.velocity
# The fleet is not cruising any more, so the slot is standing still.
ship.fleet_velocity = Vector2.ZERO
var speed: float = motion.length()
if speed <= 0.001:
ship.set_move_target(ship.position)
continue
var braking_distance: float = (speed * speed) / (2.0 * ship.acceleration)
ship.set_move_target(ship.position + motion.normalized() * braking_distance)
func get_move_targets() -> Array:
var result: Array = []
for ship in ships:
result.append(ship.move_target)
return result
func restore(previous_fleet_target: Vector2, previous_move_targets: Array) -> void:
fleet_target = previous_fleet_target
for i in range(ships.size()):
ships[i].move_target = previous_move_targets[i]
func _on_ship_destroyed(ship: Node) -> void:
var idx: int = ships.find(ship)
if idx != -1:
ships.remove_at(idx)
formation_offsets.remove_at(idx)
_sync_composition_state()
func get_centroid() -> Vector2:
if ships.is_empty():
return fleet_target
var sum: Vector2 = Vector2.ZERO
for ship in ships:
sum += ship.position
return sum / ships.size()
func get_formation_origin() -> Vector2:
# Where the anchor must be for the ships to be standing in their slots
# right now. This is NOT the centroid: ships sit at anchor + offset, so
# the centroid is anchor + the AVERAGE offset, and the two only coincide
# when the layout happens to be balanced around its origin.
#
# The default wedge is balanced, which is why using the centroid as the
# anchor looked fine - until a layout dragged off to one corner at a
# command centre made the average offset large. Seeding the anchor from
# the centroid then displaced every slot by that average the instant
# steering began, so the fleet lurched towards the formation's own
# lopsided side before the anchor caught up and it flew the right way.
if ships.is_empty():
return fleet_target
var sum: Vector2 = Vector2.ZERO
for offset in formation_offsets:
sum += offset
return get_centroid() - sum / formation_offsets.size()

1
vessels/Fleet.gd.uid Normal file
View file

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

View file

@ -0,0 +1,27 @@
[gd_resource type="Resource" script_class="VesselDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://vessels/VesselDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://vessels/VesselGasBomberComponent.tscn" id="2"]
[resource]
script = ExtResource("1")
definition_id = "gas_bomber"
display_name = "Gas Bomber"
description = "Area-control vessel that launches volatile gas bombs at a manually aimed target."
build_cost = 10
build_resource_costs = {"gas": 8, "ore": 10}
build_time = 18.0
icon = 31
glow_color = Color(0.96, 0.32, 0.78, 1)
hull_points = PackedVector2Array(0, -24, -24, 14, -10, 10, 0, 22, 10, 10, 24, 14)
fleet_eligible = true
capabilities = PackedStringArray("gas_bomber")
component_scenes = Array[PackedScene]([ExtResource("2")])
base_stats = {"max_speed": 420.0, "acceleration": 1200.0, "delivery_speed": 180.0}
unlock_id = "unlock_gas_bomber"
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

@ -0,0 +1,12 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://vessels/VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "int_damage"
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

@ -0,0 +1,12 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://vessels/VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "int_range"
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

@ -0,0 +1,12 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://vessels/VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "int_rate"
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

@ -0,0 +1,21 @@
[gd_resource type="Resource" script_class="VesselDefinition" load_steps=7 format=3]
[ext_resource type="Script" path="res://vessels/VesselDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://vessels/VesselWeaponComponent.tscn" id="2"]
[ext_resource type="Resource" path="res://vessels/InterceptorDamageUpgrade.tres" id="3"]
[ext_resource type="Resource" path="res://vessels/InterceptorRateUpgrade.tres" id="4"]
[ext_resource type="Resource" path="res://vessels/InterceptorRangeUpgrade.tres" id="5"]
[ext_resource type="PackedScene" path="res://vessels/VesselCargoComponent.tscn" id="7"]
[resource]
script = ExtResource("1")
definition_id = "interceptor"
display_name = "Interceptor"
description = "Fast combat vessel with a rapid-firing cannon."
build_cost = 6
build_time = 10.0
icon = 2
glow_color = Color(0.25, 0.85, 1, 1)
fleet_eligible = true
capabilities = PackedStringArray("weapon", "cargo")
component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("7")])
base_stats = {"damage": 1.0, "fire_cooldown": 0.5, "fire_range": 320.0, "max_speed": 460.0, "acceleration": 1350.0, "delivery_speed": 180.0, "cargo_capacity": 20.0}
upgrades = Array[Resource]([ExtResource("3"), ExtResource("4"), ExtResource("5")])

View file

@ -0,0 +1,12 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://vessels/VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "lsr_cooldown"
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

@ -0,0 +1,12 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://vessels/VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "lsr_damage"
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

@ -0,0 +1,12 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://vessels/VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "lsr_range"
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

@ -0,0 +1,30 @@
[gd_resource type="Resource" script_class="VesselDefinition" load_steps=6 format=3]
[ext_resource type="Script" path="res://vessels/VesselDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://vessels/VesselLaserComponent.tscn" id="2"]
[ext_resource type="Resource" path="res://vessels/LaserDamageUpgrade.tres" id="3"]
[ext_resource type="Resource" path="res://vessels/LaserRangeUpgrade.tres" id="4"]
[ext_resource type="Resource" path="res://vessels/LaserCooldownUpgrade.tres" id="5"]
[resource]
script = ExtResource("1")
definition_id = "laser"
display_name = "Laser Ship"
description = "Manually aimed beam vessel. No ammo, but the emitter needs to cool down between shots."
build_cost = 12
build_resource_costs = {"ore": 12, "energy": 6}
build_time = 14.0
icon = 35
glow_color = Color(0.3, 0.9, 1, 1)
hull_points = PackedVector2Array(0, -26, -9, 14, -4, 10, 4, 10, 9, 14)
fleet_eligible = true
capabilities = PackedStringArray("laser")
component_scenes = Array[PackedScene]([ExtResource("2")])
base_stats = {"max_speed": 440.0, "acceleration": 1260.0, "delivery_speed": 180.0, "damage": 2.0, "beam_range": 1450.0, "beam_cooldown": 4.0}
minimum_stats = {"beam_cooldown": 1.5}
upgrades = Array[Resource]([ExtResource("3"), ExtResource("4"), ExtResource("5")])
unlock_id = "unlock_laser"
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

48
vessels/Projectile.gd Normal file
View file

@ -0,0 +1,48 @@
extends Area2D
@export var speed: float = 500.0
@export var lifetime: float = 1.5
@export var damage: int = 1
@export var bolt_length: float = 20.0
var velocity: Vector2 = Vector2.ZERO
var age: float = 0.0
func _ready() -> void:
add_to_group("player_projectiles")
area_entered.connect(_on_area_entered)
# Draw once. The earlier invisible-bolt bug came from _draw() deriving
# the bolt's look from `age` - drawing only at age==0 froze it at
# whatever the age==0 case rendered as. _draw() below is now a fixed
# shape with no age dependency, so one draw is safe and matches the
# Ship.gd/Asteroid.gd pattern: position/rotation are transform
# properties Godot applies at render time without re-tessellating.
queue_redraw()
func launch(direction: Vector2) -> void:
velocity = direction.normalized() * speed
rotation = direction.angle() + PI / 2.0
func _physics_process(delta: float) -> void:
position += velocity * delta
age += delta
if age >= lifetime:
queue_free()
func _on_area_entered(area: Area2D) -> void:
# Asteroids answer take_damage() exactly as enemies do, so both are hit by
# the same branch rather than by a second special case.
if area.is_in_group("enemies") or area.is_in_group("asteroids"):
area.take_damage(damage)
queue_free()
func _draw() -> void:
# Fixed-length, constant-brightness bolt. Drawn once and left alone.
var tip: Vector2 = Vector2(0, -bolt_length * 0.6)
var tail: Vector2 = Vector2(0, bolt_length * 0.4)
NeonDraw.draw_glowing_line(self, tail, tip, Color(1.0, 0.85, 0.3), 1.0, 0.5)

View file

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

12
vessels/Projectile.tscn Normal file
View file

@ -0,0 +1,12 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://vessels/Projectile.gd" id="1"]
[sub_resource type="CircleShape2D" id="1"]
radius = 4.0
[node name="Projectile" type="Area2D"]
script = ExtResource("1")
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
shape = SubResource("1")

View file

@ -0,0 +1,12 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://vessels/VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "shd_hits"
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

@ -0,0 +1,12 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://vessels/VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "shd_recharge"
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

@ -0,0 +1,27 @@
[gd_resource type="Resource" script_class="VesselDefinition" load_steps=5 format=3]
[ext_resource type="Script" path="res://vessels/VesselDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://vessels/VesselShieldComponent.tscn" id="3"]
[ext_resource type="Resource" path="res://vessels/ShieldHitsUpgrade.tres" id="4"]
[ext_resource type="Resource" path="res://vessels/ShieldRechargeUpgrade.tres" id="5"]
[resource]
script = ExtResource("1")
definition_id = "shield"
display_name = "Shield Vessel"
description = "Combat vessel whose rechargeable personal shield absorbs lethal impacts."
build_cost = 14
build_time = 16.0
icon = 30
glow_color = Color(0.45, 0.8, 1, 1)
fleet_eligible = true
capabilities = PackedStringArray("shield")
component_scenes = Array[PackedScene]([ExtResource("3")])
hull_points = PackedVector2Array(0, -24, 20.7846, -12, 20.7846, 12, 0, 24, -20.7846, 12, -20.7846, -12)
base_stats = {"damage": 1.0, "fire_cooldown": 0.5, "fire_range": 320.0, "max_speed": 460.0, "acceleration": 1350.0, "shield_charges": 1.0, "shield_recharge": 6.0, "delivery_speed": 180.0}
minimum_stats = {"shield_recharge": 2.0}
upgrades = Array[Resource]([ExtResource("4"), ExtResource("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

197
vessels/Ship.gd Normal file
View file

@ -0,0 +1,197 @@
extends Area2D
signal destroyed
const VESSEL_DEFINITIONS = preload("res://vessels/VesselDefinitions.gd")
@export var max_speed: float = 460.0
@export var acceleration: float = 450.0
@export var rotation_speed: float = 8.0
@export var arrive_radius: float = 6.0
@export var vessel_type: String = "interceptor"
var definition: Resource
var glow_color: Color = Color(0.25, 0.85, 1.0)
var core_color: Color = Color.WHITE
# The ship's TRUE world velocity - what `position` is actually integrated
# from, and the single source of truth for how fast this vessel is moving.
# Nothing else may contribute motion; see the note in _physics_process.
var velocity: Vector2 = Vector2.ZERO
# The velocity of this ship's formation SLOT (the fleet anchor's own travel).
# Fed forward into the steering below rather than added on top of it: matching
# the slot's speed is most of the job of holding formation, so supplying it
# directly leaves `velocity` free to be a small correction and lets the ship
# sit in its slot with no steady-state lag and no teleporting.
var fleet_velocity: Vector2 = Vector2.ZERO
var move_target: Vector2
# Compatibility accessors keep old saves/tests readable while shield state
# itself belongs exclusively to the optional ShieldComponent.
var shield_charges: int:
get:
var component: Node = get_component("shield")
return component.charges if component != null else 0
set(value):
var component: Node = get_component("shield")
if component != null:
component.set_charges(value)
var shield_recharge_left: float:
get:
var component: Node = get_component("shield")
return component.recharge_left if component != null else 0.0
set(value):
var component: Node = get_component("shield")
if component != null:
component.recharge_left = value
var projectile_damage: int:
get:
var component: Node = get_component("weapon")
return component.projectile_damage if component != null else 0
func _ready() -> void:
move_target = position
add_to_group("player_ships")
area_entered.connect(_on_area_entered)
definition = VESSEL_DEFINITIONS.get_definition(vessel_type)
if definition == null:
return
glow_color = definition.glow_color
for component_scene in definition.component_scenes:
var component: Node = component_scene.instantiate()
add_child(component)
if component.has_method("setup"):
component.setup()
apply_vessel_stats()
queue_redraw()
func get_component(capability: String) -> Node:
for child in get_children():
if child.get("capability_id") == capability:
return child
return null
func has_capability(capability: String) -> bool:
return get_component(capability) != null
func set_move_target(world_pos: Vector2) -> void:
move_target = world_pos
func apply_vessel_stats() -> void:
if definition == null:
definition = VESSEL_DEFINITIONS.get_definition(vessel_type)
if definition == null:
return
max_speed = GameState.vessel_stat(vessel_type, "max_speed")
acceleration = GameState.vessel_stat(vessel_type, "acceleration")
for child in get_children():
if child.has_method("apply_stats"):
child.apply_stats()
func has_shield() -> bool:
var component: Node = get_component("shield")
return component != null and component.is_active()
func _physics_process(delta: float) -> void:
for child in get_children():
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 effective_acceleration: float = acceleration * boost
var to_target: Vector2 = move_target - position
var distance: float = to_target.length()
# Snap only when the slot is STANDING STILL. A stationary target is the
# one case where converging asymptotically would leave a permanent
# sub-pixel residue, and settling exactly is what "the fleet has arrived"
# means everywhere else in the game. While the slot is moving the ship
# steers continuously instead - a snap there would be a teleport, which
# is what used to make `velocity` read zero for a ship that was in fact
# travelling at cruise speed, and that lie is what the whole
# stop()/fleet_velocity bookkeeping tangle existed to paper over.
if fleet_velocity == Vector2.ZERO and distance <= arrive_radius * boost:
position = move_target
velocity = Vector2.ZERO
else:
# Feed-forward plus correction. The ship aims to travel at its slot's
# velocity, plus whatever extra closes the gap to the slot - so
# holding formation costs no error, and `velocity` stays the ship's
# real motion rather than a residual that has to be added to
# something else to mean anything.
var correction: Vector2 = Vector2.ZERO
if distance > 0.001:
var braking_speed: float = sqrt(2.0 * effective_acceleration * distance)
correction = to_target.normalized() * min(max_speed * boost, braking_speed)
var desired_velocity: Vector2 = fleet_velocity + correction
# HARD BOUND on how fast any single vessel may travel, applied to the
# total and re-applied every tick. Feed-forward plus correction can
# ask for more than a vessel can actually fly, and without this the
# excess was free speed: a ship that had been handed a large velocity
# kept it, decaying only at `acceleration`. Clamping the REQUEST is
# not enough - the clamp has to sit on the integrated result, so no
# sequence of commands can leave a vessel travelling faster than its
# own top speed.
desired_velocity = desired_velocity.limit_length(max_speed * boost)
velocity = velocity.move_toward(desired_velocity, effective_acceleration * delta)
velocity = velocity.limit_length(max_speed * boost)
position += velocity * delta
if velocity.length() > 8.0:
var target_angle: float = velocity.angle() + PI / 2.0
rotation = lerp_angle(rotation, target_angle, rotation_speed * delta)
func take_damage(_amount: int) -> bool:
# Ships have no hit points - any unabsorbed hit is lethal. A shield
# charge absorbs a hit entirely regardless of its amount, matching
# VesselShieldComponent's existing all-or-nothing semantics; `_amount`
# is accepted (not `int`-clamped away) so callers like EnemyProjectile
# have a normal damage parameter to pass even though it isn't graded here.
for child in get_children():
if child.has_method("absorb_hit") and child.absorb_hit():
return true
ShipExplosionVFX.spawn(get_tree().root, position)
destroyed.emit()
queue_free()
return false
func _on_area_entered(area: Area2D) -> void:
if not (area.is_in_group("enemies") or area.is_in_group("asteroids")):
return
# The enemy is told about the impact BEFORE this ship resolves its own
# damage, because resolving it can free this node - and a ship that dies
# to a ramming chaser must still take the chaser with it. Whether the
# impact actually destroys the enemy is the enemy's decision (#82).
if area.is_in_group("enemies") and area.has_method("collide_with_player_vessel"):
area.collide_with_player_vessel()
var absorbed: bool = take_damage(1)
if absorbed and area.is_in_group("asteroids") and is_instance_valid(area):
area.queue_free()
func _draw() -> void:
var points: PackedVector2Array = definition.hull_points if definition != null else PackedVector2Array([
Vector2(0, -22), Vector2(-14, 16), Vector2(14, 16),
])
NeonDraw.draw_glowing_polygon(self, points, glow_color, 1.0, core_color)

1
vessels/Ship.gd.uid Normal file
View file

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

12
vessels/Ship.tscn Normal file
View file

@ -0,0 +1,12 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://vessels/Ship.gd" id="1"]
[sub_resource type="CircleShape2D" id="1"]
radius = 16.0
[node name="Ship" type="Area2D"]
script = ExtResource("1")
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
shape = SubResource("1")

View file

@ -0,0 +1,12 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://vessels/VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "trn_capacity"
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

@ -0,0 +1,12 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://vessels/VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "trn_speed"
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,19 @@
[gd_resource type="Resource" script_class="VesselDefinition" load_steps=6 format=3]
[ext_resource type="Script" path="res://vessels/VesselDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://vessels/VesselCargoComponent.tscn" id="2"]
[ext_resource type="PackedScene" path="res://vessels/VesselTransportComponent.tscn" id="3"]
[ext_resource type="Resource" path="res://vessels/TransportCapacityUpgrade.tres" id="4"]
[ext_resource type="Resource" path="res://vessels/TransportSpeedUpgrade.tres" id="5"]
[resource]
script = ExtResource("1")
definition_id = "transporter"
display_name = "Transporter"
description = "Hub-owned logistics vessel that moves resources automatically."
build_cost = 0
build_time = 0.0
icon = 12
glow_color = Color(0.95, 0.55, 0.85, 1)
capabilities = PackedStringArray("cargo", "transport")
component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3")])
base_stats = {"max_speed": 460.0, "acceleration": 1350.0, "cargo_capacity": 20.0, "transport_speed": 150.0, "delivery_speed": 180.0}
upgrades = Array[Resource]([ExtResource("4"), ExtResource("5")])

View file

@ -0,0 +1,9 @@
class_name VesselCargoComponent
extends Node
var capability_id: String = "cargo"
var capacity: int
func apply_stats() -> void:
var state: Node = get_tree().root.get_node("GameState")
capacity = int(state.vessel_stat(get_parent().vessel_type, "cargo_capacity"))

View file

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

View file

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

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

@ -0,0 +1,45 @@
class_name VesselDefinition
extends Resource
# The stable internal key: capability strings, unlock ids in vessel_levels,
# component scene names and every test refer to a vessel by this, NOT by
# display_name. The two are deliberately allowed to diverge - #77 renamed the
# "distorder" to the Energy Propulser purely in what the player sees, and
# nothing underneath had to be migrated for it.
#
# Note for editors of the .tres files themselves: Godot's resource format does
# NOT accept `#` comments inside a [resource] block. One added there parses as
# part of the preceding value and silently blanks the property after it, which
# presents as "<blank> did not resolve its definition" a long way from the
# edit. Comments about the data go here, in the script.
@export var definition_id: String
@export var display_name: String
@export_multiline var description: String
@export var build_cost: int
@export var build_resource_costs: Dictionary = {}
@export var build_time: float = 10.0
@export var icon: int = 2
@export var glow_color: Color = Color(0.25, 0.85, 1.0)
@export var hull_points: PackedVector2Array = PackedVector2Array([
Vector2(0, -22), Vector2(-14, 16), Vector2(14, 16),
])
@export var fleet_eligible: bool = false
@export var capabilities: PackedStringArray = []
@export var component_scenes: Array[PackedScene] = []
@export var base_stats: Dictionary = {}
@export var minimum_stats: Dictionary = {}
@export var upgrades: Array[Resource] = []
@export var unlock_id: String
@export var unlock_name: String
@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
func has_capability(capability: String) -> bool:
return capability in capabilities

View file

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

View file

@ -0,0 +1,36 @@
class_name VesselDefinitions
extends RefCounted
const ORDER: PackedStringArray = ["interceptor", "shield", "gas_bomber", "transporter", "carrier", "laser", "distorder"]
const DEFINITIONS: Dictionary = {
"interceptor": preload("res://vessels/InterceptorVesselDefinition.tres"),
"shield": preload("res://vessels/ShieldVesselDefinition.tres"),
"gas_bomber": preload("res://vessels/GasBomberVesselDefinition.tres"),
"transporter": preload("res://vessels/TransporterVesselDefinition.tres"),
"carrier": preload("res://vessels/CarrierVesselDefinition.tres"),
"laser": preload("res://vessels/LaserVesselDefinition.tres"),
"distorder": preload("res://vessels/DistorderVesselDefinition.tres"),
}
static func get_definition(definition_id: String) -> Resource:
return DEFINITIONS.get(definition_id)
static func all() -> Array:
var result: Array = []
for definition_id in ORDER:
result.append(DEFINITIONS[definition_id])
return result
static func fleet() -> Array:
var result: Array = []
for definition in all():
if definition.fleet_eligible:
result.append(definition)
return result
static func with_capability(capability: String) -> Resource:
for definition in all():
if definition.has_capability(capability):
return definition
return null

View file

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

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://vessels/VesselDistorderComponent.gd" id="1"]
[node name="DistorderComponent" type="Node"]
script = ExtResource("1")

View file

@ -0,0 +1,19 @@
class_name VesselGasBomberComponent
extends "res://vessels/VesselCooldownFillComponent.gd"
var capability_id := "gas_bomber"
const READY_COLOR := Color(1.0, 0.25, 0.78)
const COOLING_COLOR := Color(0.38, 0.16, 0.32)
func _init() -> void:
cooldown_duration = 5.0
func consume_shot(duration: float) -> bool:
return _begin_cooldown(duration)
func _ready_color() -> Color: return READY_COLOR
func _cooling_color() -> Color: return COOLING_COLOR

View file

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

View file

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

View file

@ -0,0 +1,30 @@
class_name VesselLaserComponent
extends "res://vessels/VesselCooldownFillComponent.gd"
var capability_id := "laser"
var damage: int = 2
var beam_range: float = 950.0
const READY_COLOR := Color(0.3, 0.9, 1.0)
const COOLING_COLOR := Color(0.08, 0.28, 0.34)
func _init() -> void:
cooldown_duration = 4.0
func apply_stats() -> void:
var ship: Node = get_parent()
var state: Node = ship.get_tree().root.get_node("GameState")
damage = int(state.vessel_stat(ship.vessel_type, "damage"))
beam_range = state.vessel_stat(ship.vessel_type, "beam_range")
cooldown_duration = state.vessel_stat(ship.vessel_type, "beam_cooldown")
queue_redraw()
func consume_shot() -> bool:
return _begin_cooldown(cooldown_duration)
func _ready_color() -> Color: return READY_COLOR
func _cooling_color() -> Color: return COOLING_COLOR

View file

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

View file

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

View file

@ -0,0 +1,69 @@
class_name VesselShieldComponent
extends Node
const SHIELD_COLOR: Color = Color(0.45, 0.8, 1.0)
const SHIELD_RADIUS: float = 30.0
var capability_id: String = "shield"
var charges: int = 0
var recharge_left: float = 0.0
var ring: Node2D
func setup() -> void:
var ship: Node2D = get_parent()
var state: Node = ship.get_tree().root.get_node("GameState")
ring = NeonPiece.create(ship, Vector2.ZERO, SHIELD_COLOR)
var points := PackedVector2Array()
for i in range(14):
var angle: float = float(i) / 14.0 * TAU
points.append(Vector2(cos(angle), sin(angle)) * SHIELD_RADIUS)
ring.add_polygon(points, 0.7)
ring.queue_redraw()
charges = int(state.vessel_stat(ship.vessel_type, "shield_charges"))
_update_visual()
func apply_stats() -> void:
var was_up: bool = charges > 0
if was_up:
charges = int(_state().vessel_stat(get_parent().vessel_type, "shield_charges"))
_update_visual()
func tick(delta: float) -> void:
var maximum: int = int(_state().vessel_stat(get_parent().vessel_type, "shield_charges"))
if charges >= maximum:
return
recharge_left -= delta
if recharge_left <= 0.0:
charges += 1
recharge_left = _state().vessel_stat(get_parent().vessel_type, "shield_recharge")
_update_visual()
func is_active() -> bool:
return charges > 0
func absorb_hit() -> bool:
if not is_active():
return false
charges -= 1
recharge_left = _state().vessel_stat(get_parent().vessel_type, "shield_recharge")
_update_visual()
return true
func set_charges(value: int) -> void:
charges = value
_update_visual()
func _state() -> Node:
return get_tree().root.get_node("GameState")
func _update_visual() -> void:
if ring != null:
ring.visible = is_active()

View file

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

View file

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

View file

@ -0,0 +1,9 @@
class_name VesselTransportComponent
extends Node
var capability_id: String = "transport"
var travel_speed: float
func apply_stats() -> void:
var state: Node = get_tree().root.get_node("GameState")
travel_speed = state.vessel_stat(get_parent().vessel_type, "transport_speed")

View file

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

View file

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

View file

@ -0,0 +1,15 @@
class_name VesselUpgradeDefinition
extends Resource
@export var upgrade_id: String
@export var display_name: String
@export_multiline var description: String
@export var base_cost: int
@export var max_level: int = 1
@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

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

View file

@ -0,0 +1,58 @@
class_name VesselWeaponComponent
extends Node
# The fleet's gun. It does NOT pick its own targets: the player aims it with
# the fire stick, and it shoots along whatever heading that stick reports for
# as long as it is held.
#
# It used to seek the nearest enemy in range and fire on its own, which meant
# combat happened whether or not the player did anything - there was nothing
# to aim, and nothing to decide. Firing is now an action like any other, and
# the right-hand stick is what performs it.
#
# `fire_direction` is set every frame by FireStickController from the stick's
# own reading, and cleared the moment it is released. Zero means "not firing",
# which is the resting state.
var capability_id: String = "weapon"
var fire_range: float
var fire_cooldown: float
var projectile_damage: int
var fire_timer: float = 0.0
var fire_direction: Vector2 = Vector2.ZERO
var projectile_scene: PackedScene = preload("res://vessels/Projectile.tscn")
func apply_stats() -> void:
var ship: Node = get_parent()
var state: Node = ship.get_tree().root.get_node("GameState")
fire_range = state.vessel_stat(ship.vessel_type, "fire_range")
fire_cooldown = state.vessel_stat(ship.vessel_type, "fire_cooldown")
projectile_damage = int(state.vessel_stat(ship.vessel_type, "damage"))
# Called by FireStickController. A zero vector stops the gun.
func set_fire_direction(direction: Vector2) -> void:
fire_direction = direction if direction.length() > 0.001 else Vector2.ZERO
func is_firing() -> bool:
return fire_direction != Vector2.ZERO
func tick(delta: float) -> void:
# The cooldown runs down whether or not the trigger is held, so tapping
# the stick in a new direction is not punished by a timer that only
# started when the stick moved.
fire_timer -= delta
if fire_direction == Vector2.ZERO or fire_timer > 0.0:
return
var ship: Node2D = get_parent()
var projectile: Node2D = projectile_scene.instantiate()
projectile.position = ship.position
projectile.damage = projectile_damage
ship.get_tree().root.add_child(projectile)
# Aimed by DIRECTION, not at a target: launch() takes a displacement, so
# the gun's own range decides how far the shot is sent.
projectile.launch(fire_direction.normalized() * fire_range)
fire_timer = fire_cooldown

View file

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

View file

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