From 2f68515f331bdec173a28b43ae67f0c29bf4ebda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Fri, 14 Aug 2026 16:05:32 +0200 Subject: [PATCH 1/5] Compose planet surfaces from profiles --- GameState.gd | 2 ++ GasSurfaceFeatures.tscn | 7 +++++++ GasSurfaceProfile.tres | 14 +++++++++++++ GasVentField.gd | 3 ++- Planet.gd | 2 ++ PlanetAtmosphereController.gd | 1 + PlanetField.gd | 2 ++ PlanetSurface.tscn | 9 +-------- PlanetSurfaceController.gd | 38 ++++++++++++++++++----------------- PlanetSurfaceProfile.gd | 13 ++++++++++++ PlanetSurfaceProfile.gd.uid | 1 + PlanetSurfaceProfiles.gd | 12 +++++++++++ PlanetSurfaceProfiles.gd.uid | 1 + RockField.gd | 9 ++------- RockySurfaceFeatures.tscn | 7 +++++++ RockySurfaceProfile.tres | 14 +++++++++++++ WorldGen.gd | 3 ++- tests/_gas_surface_preview.gd | 1 + tests/test_gas_system.gd | 23 +++++++++++++++++---- 19 files changed, 123 insertions(+), 39 deletions(-) create mode 100644 GasSurfaceFeatures.tscn create mode 100644 GasSurfaceProfile.tres create mode 100644 PlanetSurfaceProfile.gd create mode 100644 PlanetSurfaceProfile.gd.uid create mode 100644 PlanetSurfaceProfiles.gd create mode 100644 PlanetSurfaceProfiles.gd.uid create mode 100644 RockySurfaceFeatures.tscn create mode 100644 RockySurfaceProfile.tres diff --git a/GameState.gd b/GameState.gd index f855f2a..6b19551 100644 --- a/GameState.gd +++ b/GameState.gd @@ -1,5 +1,6 @@ extends Node + # Autoload singleton - the one thing that survives change_scene_to_file() # scene swaps between space (Main.tscn) and a planet's surface # (PlanetSurface.tscn). Everything else in either scene gets torn down @@ -13,6 +14,7 @@ var return_camera_zoom: float = 1.0 var planet_name: String = "" var planet_type: String = "" +var surface_profile_id: String = "rocky" # Explored-chunk history for the world map's fog-of-war reveal. Lives # here (not on ExplorationTracker, which gets destroyed and recreated on diff --git a/GasSurfaceFeatures.tscn b/GasSurfaceFeatures.tscn new file mode 100644 index 0000000..576b66d --- /dev/null +++ b/GasSurfaceFeatures.tscn @@ -0,0 +1,7 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://GasVentField.gd" id="1"] + +[node name="ResourceProvider" type="Node2D"] +script = ExtResource("1") +fleet_path = NodePath("../../Fleet") diff --git a/GasSurfaceProfile.tres b/GasSurfaceProfile.tres new file mode 100644 index 0000000..0267c48 --- /dev/null +++ b/GasSurfaceProfile.tres @@ -0,0 +1,14 @@ +[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=3 format=3] + +[ext_resource type="Script" path="res://PlanetSurfaceProfile.gd" id="1"] +[ext_resource type="PackedScene" path="res://GasSurfaceFeatures.tscn" id="2"] + +[resource] +script = ExtResource("1") +profile_id = "gas" +features_scene = ExtResource("2") +base_color = Color(0.13, 0.025, 0.22, 1) +crater_color = Color(0.055, 0.008, 0.11, 1) +highlight_color = Color(0.52, 0.17, 0.7, 1) +caption_color = Color(0.78, 0.88, 0.94, 1) +caption_outline_color = Color(0.02, 0.01, 0.05, 0.9) diff --git a/GasVentField.gd b/GasVentField.gd index 9cdd338..831ca4c 100644 --- a/GasVentField.gd +++ b/GasVentField.gd @@ -1,5 +1,6 @@ extends Node2D -@onready var fleet: Node2D = get_node("../Fleet") +@export var fleet_path: NodePath = NodePath("../Fleet") +@onready var fleet: Node2D = get_node(fleet_path) var vents: Array[Vector2] = [] var progress: float = 0.0 var emission_phase: float = 0.0 diff --git a/Planet.gd b/Planet.gd index c4eddbd..71b1e9f 100644 --- a/Planet.gd +++ b/Planet.gd @@ -1,5 +1,6 @@ extends Node2D + # A landable world-object - unlike regular Asteroid.gd, planets are # stationary ("Big Stable Asteroid" - stable means it doesn't drift or # destroy ships on touch). Whether the fleet counts as "landed" is decided @@ -13,6 +14,7 @@ extends Node2D @export var planet_name: String = "" @export var planet_type: String = "rocky" @export var resource_type: String = "ore" +@export var surface_profile_id: String = "rocky" @export var landing_radius: float = 140.0 # Set by whoever spawns this planet (PlanetField, seeded from the chunk's # own coordinates) so the outline shape is stable across respawns of the diff --git a/PlanetAtmosphereController.gd b/PlanetAtmosphereController.gd index cc497d4..b13e69f 100644 --- a/PlanetAtmosphereController.gd +++ b/PlanetAtmosphereController.gd @@ -114,6 +114,7 @@ func _on_enter_pressed() -> void: GameState.return_camera_zoom = camera.zoom.x GameState.planet_name = current_planet.planet_name GameState.planet_type = current_planet.planet_type + GameState.surface_profile_id = current_planet.surface_profile_id var tween: Tween = create_tween() tween.tween_property(fade_rect, "color:a", 1.0, fade_duration) diff --git a/PlanetField.gd b/PlanetField.gd index 4dbbdd8..53b6ddf 100644 --- a/PlanetField.gd +++ b/PlanetField.gd @@ -1,5 +1,6 @@ extends Node2D + # Spawns "Big Stable Asteroid" planets in ASTEROID_FIELD biome chunks near # the fleet where WorldGen says one belongs, and despawns them once the # fleet has moved far enough away - mirrors AsteroidField's chunk-lifecycle @@ -85,6 +86,7 @@ func _spawn_chunk_planet(chunk: Vector2i) -> void: planet.shape_seed = data.shape_seed planet.planet_type = String(data.get("planet_type", "rocky")) planet.resource_type = String(data.get("resource_type", "ore")) + planet.surface_profile_id = String(data.get("surface_profile_id", "rocky")) # Deferred: update_chunks() can run synchronously from inside a # parent's own _ready() chain (forced once right after the fleet is # positioned, to avoid a pop-in on scene load) - Godot refuses a diff --git a/PlanetSurface.tscn b/PlanetSurface.tscn index 507ec2b..81cea2d 100644 --- a/PlanetSurface.tscn +++ b/PlanetSurface.tscn @@ -7,9 +7,7 @@ [ext_resource type="Script" path="res://PlanetSurfaceController.gd" id="5"] [ext_resource type="Script" path="res://IconButton.gd" id="6"] [ext_resource type="Script" path="res://TouchJoystick.gd" id="7"] -[ext_resource type="Script" path="res://RockField.gd" id="8"] [ext_resource type="Script" path="res://ResourceCounter.gd" id="9"] -[ext_resource type="Script" path="res://GasVentField.gd" id="10"] [ext_resource type="Script" path="res://GasCounter.gd" id="11"] [ext_resource type="Script" path="res://CargoCounter.gd" id="12"] [ext_resource type="Script" path="res://GasCargoCounter.gd" id="13"] @@ -49,12 +47,7 @@ ship_core_color = Color(0.02, 0.09, 0.15, 1) position = Vector2(0, 0) zoom = Vector2(1, 1) -[node name="RockField" type="Node2D" parent="."] -script = ExtResource("8") - -[node name="GasVentField" type="Node2D" parent="."] -visible = false -script = ExtResource("10") +[node name="SurfaceFeatures" type="Node2D" parent="."] [node name="SurfaceController" type="Node2D" parent="."] script = ExtResource("5") diff --git a/PlanetSurfaceController.gd b/PlanetSurfaceController.gd index 502d8d4..9f632b4 100644 --- a/PlanetSurfaceController.gd +++ b/PlanetSurfaceController.gd @@ -1,5 +1,7 @@ extends Node2D +const SURFACE_PROFILES = preload("res://PlanetSurfaceProfiles.gd") + # Bootstraps the surviving fleet on this scene's own startup (from # GameState, since this is a fresh scene tree - nothing here carries # over automatically) and handles the always-available exit prompt that @@ -13,29 +15,14 @@ extends Node2D @onready var planet_name_label: Label = get_node("../UI/PlanetNameLabel") @onready var exit_button: IconButton = get_node("../UI/ExitButton") @onready var fade_rect: ColorRect = get_node("../FadeOverlay/FadeRect") +@onready var background: ColorRect = get_node("../BackgroundLayer/LunarBackground") +@onready var feature_host: Node2D = get_node("../SurfaceFeatures") var transitioning: bool = false func _ready() -> void: - if GameState.planet_type == "gas_giant": - # A gas giant is not lunar terrain: its surface view uses a deep, - # coloured atmosphere instead of the rocky field. The background is - # shader-driven, so changing ColorRect.color does not tint it; update - # the actual palette uniforms. - get_node("../RockField").visible = false - get_node("../GasVentField").visible = true - var background: ColorRect = get_node("../BackgroundLayer/LunarBackground") - var atmosphere: ShaderMaterial = background.material - atmosphere.set_shader_parameter("base_color", Color(0.13, 0.025, 0.22, 1.0)) - atmosphere.set_shader_parameter("crater_color", Color(0.055, 0.008, 0.11, 1.0)) - atmosphere.set_shader_parameter("highlight_color", Color(0.52, 0.17, 0.7, 1.0)) - # The lunar surface uses dark captions on a bright ground. Flip only - # those captions for the night-dark gas atmosphere. - for caption_path in ["../UI/BankCaption", "../UI/FleetCaption"]: - var caption: Label = get_node(caption_path) - caption.add_theme_color_override("font_color", Color(0.78, 0.88, 0.94)) - caption.add_theme_color_override("font_outline_color", Color(0.02, 0.01, 0.05, 0.9)) + _compose_surface(SURFACE_PROFILES.get_profile(GameState.surface_profile_id)) fleet.spawn_ships(GameState.surviving_ship_count, Vector2.ZERO) camera.position = Vector2.ZERO camera.zoom = Vector2(GameState.return_camera_zoom, GameState.return_camera_zoom) @@ -47,6 +34,21 @@ func _ready() -> void: tween.tween_property(fade_rect, "color:a", 0.0, fade_duration) +func _compose_surface(profile: Resource) -> void: + var atmosphere: ShaderMaterial = background.material + atmosphere.set_shader_parameter("base_color", profile.base_color) + atmosphere.set_shader_parameter("crater_color", profile.crater_color) + atmosphere.set_shader_parameter("highlight_color", profile.highlight_color) + for caption_path in ["../UI/BankCaption", "../UI/FleetCaption"]: + var caption: Label = get_node(caption_path) + caption.add_theme_color_override("font_color", profile.caption_color) + caption.add_theme_color_override("font_outline_color", profile.caption_outline_color) + if profile.features_scene == null: + return + var features: Node = profile.features_scene.instantiate() + feature_host.add_child(features) + + func _on_exit_pressed() -> void: if transitioning: return diff --git a/PlanetSurfaceProfile.gd b/PlanetSurfaceProfile.gd new file mode 100644 index 0000000..d2ea9be --- /dev/null +++ b/PlanetSurfaceProfile.gd @@ -0,0 +1,13 @@ +class_name PlanetSurfaceProfile +extends Resource + +# Declarative recipe for a landed planet surface. The host scene only knows +# how to apply one of these; the feature scene owns the actual resource nodes. + +@export var profile_id: String = "rocky" +@export var features_scene: PackedScene +@export var base_color: Color = Color(0.75, 0.75, 0.76, 1.0) +@export var crater_color: Color = Color(0.44, 0.44, 0.45, 1.0) +@export var highlight_color: Color = Color(0.89, 0.89, 0.9, 1.0) +@export var caption_color: Color = Color(0.08, 0.16, 0.2, 1.0) +@export var caption_outline_color: Color = Color(0.9, 0.95, 0.96, 0.8) diff --git a/PlanetSurfaceProfile.gd.uid b/PlanetSurfaceProfile.gd.uid new file mode 100644 index 0000000..884fe16 --- /dev/null +++ b/PlanetSurfaceProfile.gd.uid @@ -0,0 +1 @@ +uid://b31vef6ge1tjh diff --git a/PlanetSurfaceProfiles.gd b/PlanetSurfaceProfiles.gd new file mode 100644 index 0000000..97da418 --- /dev/null +++ b/PlanetSurfaceProfiles.gd @@ -0,0 +1,12 @@ +class_name PlanetSurfaceProfiles +extends RefCounted + +const DEFAULT_PROFILE_ID: String = "rocky" +const PROFILES: Dictionary = { + "rocky": preload("res://RockySurfaceProfile.tres"), + "gas": preload("res://GasSurfaceProfile.tres"), +} + + +static func get_profile(profile_id: String) -> Resource: + return PROFILES.get(profile_id, PROFILES[DEFAULT_PROFILE_ID]) diff --git a/PlanetSurfaceProfiles.gd.uid b/PlanetSurfaceProfiles.gd.uid new file mode 100644 index 0000000..c0f3f93 --- /dev/null +++ b/PlanetSurfaceProfiles.gd.uid @@ -0,0 +1 @@ +uid://bufr6mnwbobxb diff --git a/RockField.gd b/RockField.gd index a82389c..dd839ed 100644 --- a/RockField.gd +++ b/RockField.gd @@ -17,7 +17,8 @@ extends Node2D @export var despawn_radius: float = 2600.0 @export var check_interval: float = 0.5 -@onready var fleet: Node2D = get_node("../Fleet") +@export var fleet_path: NodePath = NodePath("../Fleet") +@onready var fleet: Node2D = get_node(fleet_path) var rock_scene: PackedScene = preload("res://Rock.tscn") var rocks: Array = [] @@ -26,12 +27,6 @@ var rng: RandomNumberGenerator = RandomNumberGenerator.new() func _ready() -> void: - # Gas giants have their own gas vents and must never receive ore pickups. - # This check happens before the initial population pass, unlike merely - # hiding the field later from PlanetSurfaceController. - if GameState.planet_type == "gas_giant": - set_process(false) - return rng.randomize() # Populate immediately rather than waiting out the first interval, so # the surface isn't visibly empty for half a second on arrival. diff --git a/RockySurfaceFeatures.tscn b/RockySurfaceFeatures.tscn new file mode 100644 index 0000000..f31b5c8 --- /dev/null +++ b/RockySurfaceFeatures.tscn @@ -0,0 +1,7 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://RockField.gd" id="1"] + +[node name="ResourceProvider" type="Node2D"] +script = ExtResource("1") +fleet_path = NodePath("../../Fleet") diff --git a/RockySurfaceProfile.tres b/RockySurfaceProfile.tres new file mode 100644 index 0000000..ab220e0 --- /dev/null +++ b/RockySurfaceProfile.tres @@ -0,0 +1,14 @@ +[gd_resource type="Resource" script_class="PlanetSurfaceProfile" load_steps=3 format=3] + +[ext_resource type="Script" path="res://PlanetSurfaceProfile.gd" id="1"] +[ext_resource type="PackedScene" path="res://RockySurfaceFeatures.tscn" id="2"] + +[resource] +script = ExtResource("1") +profile_id = "rocky" +features_scene = ExtResource("2") +base_color = Color(0.75, 0.75, 0.76, 1) +crater_color = Color(0.44, 0.44, 0.45, 1) +highlight_color = Color(0.89, 0.89, 0.9, 1) +caption_color = Color(0.08, 0.16, 0.2, 1) +caption_outline_color = Color(0.9, 0.95, 0.96, 0.8) diff --git a/WorldGen.gd b/WorldGen.gd index e36a51f..feea7f6 100644 --- a/WorldGen.gd +++ b/WorldGen.gd @@ -96,7 +96,8 @@ static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary: if gas_giant: planet_name = GAS_GIANT_NAMES[rng.randi() % GAS_GIANT_NAMES.size()] return {"local_pos": local_pos, "name": planet_name, "shape_seed": shape_seed, - "planet_type": "gas_giant" if gas_giant else "rocky", "resource_type": "gas" if gas_giant else "ore"} + "planet_type": "gas_giant" if gas_giant else "rocky", "resource_type": "gas" if gas_giant else "ore", + "surface_profile_id": "gas" if gas_giant else "rocky"} static func biome_color(biome: int) -> Color: diff --git a/tests/_gas_surface_preview.gd b/tests/_gas_surface_preview.gd index ba68e1c..41cde0a 100644 --- a/tests/_gas_surface_preview.gd +++ b/tests/_gas_surface_preview.gd @@ -4,6 +4,7 @@ extends Node2D func _ready() -> void: GameState.planet_name = "Violet Tempest" GameState.planet_type = "gas_giant" + GameState.surface_profile_id = "gas" GameState.surviving_ship_count = 3 GameState.rocks = 128 GameState.gas = 46 diff --git a/tests/test_gas_system.gd b/tests/test_gas_system.gd index 898e43d..f0797d1 100644 --- a/tests/test_gas_system.gd +++ b/tests/test_gas_system.gd @@ -37,19 +37,34 @@ func _process(delta: float) -> bool: # emission advances continuously so the source visibly looks active. game_state.planet_name = "Gas Test" game_state.planet_type = "gas_giant" + game_state.surface_profile_id = "gas" var surface: Node = load("res://PlanetSurface.tscn").instantiate() root.add_child(surface) - var rock_field: Node = surface.get_node("RockField") - var gas_field: Node = surface.get_node("GasVentField") - if not rock_field.rocks.is_empty() or rock_field.is_processing(): + var gas_field: Node = surface.get_node("SurfaceFeatures/ResourceProvider") + if gas_field.get_script() != load("res://GasVentField.gd"): surface.free() - return _fail("gas surface spawned or continued processing ore rocks") + return _fail("gas surface did not compose the gas feature scene") + if surface.get_node_or_null("RockField") != null: + surface.free() + return _fail("gas surface tree contains an ore producer") gas_field._process(0.5) if gas_field.emission_phase <= 0.0: surface.free() return _fail("gas-source emission animation did not advance") surface.free() + game_state.surface_profile_id = "rocky" + var rocky_surface: Node = load("res://PlanetSurface.tscn").instantiate() + root.add_child(rocky_surface) + var rocky_features: Node = rocky_surface.get_node("SurfaceFeatures/ResourceProvider") + if rocky_features.get_script() != load("res://RockField.gd"): + rocky_surface.free() + return _fail("rocky surface did not compose the ore feature scene") + if rocky_surface.get_node_or_null("GasVentField") != null: + rocky_surface.free() + return _fail("rocky surface tree contains a gas producer") + rocky_surface.free() + var found_giant: bool = false for x in range(-12, 13): for y in range(-12, 13): From 02643c76c437eb8ae4a1ccbf8e02ff8060e4ec6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Fri, 14 Aug 2026 16:13:24 +0200 Subject: [PATCH 2/5] Drive planets from definitions --- GameState.gd | 3 +- GasGiantDefinition.tres | 14 ++++++ GasGiantVisual.gd | 31 ++++++++++++++ GasGiantVisual.gd.uid | 1 + GasGiantVisual.tscn | 6 +++ Planet.gd | 80 +++++++++++++---------------------- Planet.tscn | 2 + PlanetAtmosphereController.gd | 3 +- PlanetDefinition.gd | 10 +++++ PlanetDefinition.gd.uid | 1 + PlanetDefinitions.gd | 14 ++++++ PlanetDefinitions.gd.uid | 1 + PlanetField.gd | 4 +- PlanetSurfaceController.gd | 4 +- RockyPlanetDefinition.tres | 14 ++++++ RockyPlanetVisual.gd | 26 ++++++++++++ RockyPlanetVisual.gd.uid | 1 + RockyPlanetVisual.tscn | 6 +++ WorldGen.gd | 19 +++------ WorldMapContent.gd | 5 ++- tests/_gas_surface_preview.gd | 3 +- tests/test_gas_system.gd | 17 ++++---- 22 files changed, 183 insertions(+), 82 deletions(-) create mode 100644 GasGiantDefinition.tres create mode 100644 GasGiantVisual.gd create mode 100644 GasGiantVisual.gd.uid create mode 100644 GasGiantVisual.tscn create mode 100644 PlanetDefinition.gd create mode 100644 PlanetDefinition.gd.uid create mode 100644 PlanetDefinitions.gd create mode 100644 PlanetDefinitions.gd.uid create mode 100644 RockyPlanetDefinition.tres create mode 100644 RockyPlanetVisual.gd create mode 100644 RockyPlanetVisual.gd.uid create mode 100644 RockyPlanetVisual.tscn diff --git a/GameState.gd b/GameState.gd index 6b19551..11a4b7a 100644 --- a/GameState.gd +++ b/GameState.gd @@ -13,8 +13,7 @@ var return_world_position: Vector2 = Vector2.ZERO var return_camera_zoom: float = 1.0 var planet_name: String = "" -var planet_type: String = "" -var surface_profile_id: String = "rocky" +var planet_definition_id: String = "rocky" # Explored-chunk history for the world map's fog-of-war reveal. Lives # here (not on ExplorationTracker, which gets destroyed and recreated on diff --git a/GasGiantDefinition.tres b/GasGiantDefinition.tres new file mode 100644 index 0000000..0a5109c --- /dev/null +++ b/GasGiantDefinition.tres @@ -0,0 +1,14 @@ +[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://GasGiantVisual.tscn" id="2"] + +[resource] +script = ExtResource("1") +definition_id = "gas_giant" +display_name = "Gas giant" +names = PackedStringArray("Aurelia", "Boreas", "Cerulean Deep", "Helios Major", "Vesper") +visual_scene = ExtResource("2") +surface_profile_id = "gas" +resource_id = "gas" +map_color = Color(0.65, 0.42, 0.9, 1) diff --git a/GasGiantVisual.gd b/GasGiantVisual.gd new file mode 100644 index 0000000..e9172ca --- /dev/null +++ b/GasGiantVisual.gd @@ -0,0 +1,31 @@ +extends Node2D + +var gas_phase: float = 0.0 + + +func configure(_shape_seed: int) -> void: + queue_redraw() + + +func extractor_anchor() -> Vector2: + return Vector2(0.0, -118.0) + + +func _process(delta: float) -> void: + gas_phase += delta + queue_redraw() + + +func _draw() -> void: + var rim := PackedVector2Array() + for i in range(32): + var angle := TAU * float(i) / 32.0 + rim.append(Vector2(cos(angle) * 118.0, sin(angle) * 118.0)) + NeonDraw.draw_glowing_polygon(self, rim, Color(0.4, 0.78, 1.0), 0.9) + for row in [-62.0, -28.0, 8.0, 43.0, 73.0]: + var band := PackedVector2Array() + var half_width: float = sqrt(maxf(0.0, 118.0 * 118.0 - row * row)) + for i in range(15): + var x := lerpf(-half_width, half_width, float(i) / 14.0) + band.append(Vector2(x, row + sin(x * 0.045 + gas_phase * 1.6 + row) * 7.0)) + NeonDraw.draw_glowing_path(self, band, Color(0.85, 0.52, 1.0), 0.75, 0.65) diff --git a/GasGiantVisual.gd.uid b/GasGiantVisual.gd.uid new file mode 100644 index 0000000..af718cf --- /dev/null +++ b/GasGiantVisual.gd.uid @@ -0,0 +1 @@ +uid://bxfw4ja2hw4pf diff --git a/GasGiantVisual.tscn b/GasGiantVisual.tscn new file mode 100644 index 0000000..513b87e --- /dev/null +++ b/GasGiantVisual.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://GasGiantVisual.gd" id="1"] + +[node name="Visual" type="Node2D"] +script = ExtResource("1") diff --git a/Planet.gd b/Planet.gd index 71b1e9f..4d17d84 100644 --- a/Planet.gd +++ b/Planet.gd @@ -1,5 +1,7 @@ extends Node2D +const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd") + # A landable world-object - unlike regular Asteroid.gd, planets are # stationary ("Big Stable Asteroid" - stable means it doesn't drift or @@ -12,16 +14,33 @@ extends Node2D # while the fleet as a whole was clearly sitting on the planet. @export var planet_name: String = "" -@export var planet_type: String = "rocky" -@export var resource_type: String = "ore" -@export var surface_profile_id: String = "rocky" +@export var definition_id: String = PLANET_DEFINITIONS.DEFAULT_DEFINITION_ID @export var landing_radius: float = 140.0 # Set by whoever spawns this planet (PlanetField, seeded from the chunk's # own coordinates) so the outline shape is stable across respawns of the # same chunk, instead of reading as a "different" planet every time. @export var shape_seed: int = 0 -var shape_points: PackedVector2Array = PackedVector2Array() +var definition: Resource +var visual: Node2D + +var resource_type: String: + get: return String(definition.resource_id) if definition != null else "ore" + +var surface_profile_id: String: + get: return String(definition.surface_profile_id) if definition != null else "rocky" + +var shape_points: PackedVector2Array: + get: + if visual != null and "shape_points" in visual: + return visual.shape_points + return PackedVector2Array() + +var gas_phase: float: + get: + if visual != null and "gas_phase" in visual: + return float(visual.gas_phase) + return 0.0 # An extractor is built down on this planet's SURFACE, in a different scene # the player is no longer looking at, so from space nothing distinguished a @@ -39,31 +58,20 @@ const DERRICK_HALF_WIDTH: float = 16.0 # only at _ready() left the planet looking untouched until it happened to # despawn and respawn. var shows_extractor: bool = false -var gas_phase: float = 0.0 func _ready() -> void: add_to_group("planets") z_index = -1 - - var rng: RandomNumberGenerator = RandomNumberGenerator.new() - rng.seed = shape_seed - var point_count: int = 10 - for i in range(point_count): - var angle: float = (float(i) / point_count) * TAU - var radius: float = rng.randf_range(70.0, 100.0) - shape_points.append(Vector2(cos(angle), sin(angle)) * radius) + definition = PLANET_DEFINITIONS.get_definition(definition_id) + visual = definition.visual_scene.instantiate() + visual.configure(shape_seed) + get_node("VisualHost").add_child(visual) shows_extractor = GameState.has_extractor_at(planet_name) GameState.extractor_added.connect(_on_extractor_added) queue_redraw() -func _process(delta: float) -> void: - if planet_type == "gas_giant": - gas_phase += delta - queue_redraw() - - func _on_extractor_added(built_on: String) -> void: # Only the planet it was actually built on, and only a redraw when the # answer really changed - every other planet in range gets this too. @@ -74,14 +82,9 @@ func _on_extractor_added(built_on: String) -> void: func extractor_derrick_anchor() -> Vector2: - # The rim vertex the derrick stands on: the shape's own topmost point, - # rather than a fixed radius, so it sits ON the outline whatever lumpy - # shape the seed drew instead of floating off it or sinking inside it. - var base: Vector2 = Vector2(0.0, -100.0) - for p in shape_points: - if p.y < base.y: - base = p - return base + if visual != null and visual.has_method("extractor_anchor"): + return visual.extractor_anchor() + return Vector2(0.0, -100.0) func extractor_derrick_tip() -> Vector2: @@ -113,28 +116,5 @@ func extractor_derrick_points() -> PackedVector2Array: func _draw() -> void: - if planet_type == "gas_giant": - _draw_gas_giant() - if shows_extractor: - NeonDraw.draw_glowing_polygon(self, extractor_derrick_points(), EXTRACTOR_MARKER_COLOR, 1.0) - return - NeonDraw.draw_glowing_polygon(self, shape_points, Color(0.6, 0.55, 0.5), 1.0) if shows_extractor: NeonDraw.draw_glowing_polygon(self, extractor_derrick_points(), EXTRACTOR_MARKER_COLOR, 1.0) - - -func _draw_gas_giant() -> void: - # A round, animated giant with drifting latitudinal bands. It is visibly - # unlike the rocky outline and keeps moving even while the fleet is idle. - var rim := PackedVector2Array() - for i in range(32): - var a := TAU * float(i) / 32.0 - rim.append(Vector2(cos(a) * 118.0, sin(a) * 118.0)) - NeonDraw.draw_glowing_polygon(self, rim, Color(0.4, 0.78, 1.0), 0.9) - for row in [-62.0, -28.0, 8.0, 43.0, 73.0]: - var band := PackedVector2Array() - var half_width: float = sqrt(maxf(0.0, 118.0 * 118.0 - row * row)) - for i in range(15): - var x := lerpf(-half_width, half_width, float(i) / 14.0) - band.append(Vector2(x, row + sin(x * 0.045 + gas_phase * 1.6 + row) * 7.0)) - NeonDraw.draw_glowing_path(self, band, Color(0.85, 0.52, 1.0), 0.75, 0.65) diff --git a/Planet.tscn b/Planet.tscn index 844672b..0e7fe1f 100644 --- a/Planet.tscn +++ b/Planet.tscn @@ -4,3 +4,5 @@ [node name="Planet" type="Node2D"] script = ExtResource("1") + +[node name="VisualHost" type="Node2D" parent="."] diff --git a/PlanetAtmosphereController.gd b/PlanetAtmosphereController.gd index b13e69f..3a66ea4 100644 --- a/PlanetAtmosphereController.gd +++ b/PlanetAtmosphereController.gd @@ -113,8 +113,7 @@ func _on_enter_pressed() -> void: GameState.return_world_position = current_planet.position GameState.return_camera_zoom = camera.zoom.x GameState.planet_name = current_planet.planet_name - GameState.planet_type = current_planet.planet_type - GameState.surface_profile_id = current_planet.surface_profile_id + GameState.planet_definition_id = current_planet.definition_id var tween: Tween = create_tween() tween.tween_property(fade_rect, "color:a", 1.0, fade_duration) diff --git a/PlanetDefinition.gd b/PlanetDefinition.gd new file mode 100644 index 0000000..e184241 --- /dev/null +++ b/PlanetDefinition.gd @@ -0,0 +1,10 @@ +class_name PlanetDefinition +extends Resource + +@export var definition_id: String = "rocky" +@export var display_name: String = "Rocky planet" +@export var names: PackedStringArray = [] +@export var visual_scene: PackedScene +@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) diff --git a/PlanetDefinition.gd.uid b/PlanetDefinition.gd.uid new file mode 100644 index 0000000..ee09e6d --- /dev/null +++ b/PlanetDefinition.gd.uid @@ -0,0 +1 @@ +uid://dt6tsdmmlnfaf diff --git a/PlanetDefinitions.gd b/PlanetDefinitions.gd new file mode 100644 index 0000000..fa9dd2d --- /dev/null +++ b/PlanetDefinitions.gd @@ -0,0 +1,14 @@ +class_name PlanetDefinitions +extends RefCounted + +const ROCKY_ID: String = "rocky" +const GAS_GIANT_ID: String = "gas_giant" +const DEFAULT_DEFINITION_ID: String = ROCKY_ID +const DEFINITIONS: Dictionary = { + ROCKY_ID: preload("res://RockyPlanetDefinition.tres"), + GAS_GIANT_ID: preload("res://GasGiantDefinition.tres"), +} + + +static func get_definition(definition_id: String) -> Resource: + return DEFINITIONS.get(definition_id, DEFINITIONS[DEFAULT_DEFINITION_ID]) diff --git a/PlanetDefinitions.gd.uid b/PlanetDefinitions.gd.uid new file mode 100644 index 0000000..188741c --- /dev/null +++ b/PlanetDefinitions.gd.uid @@ -0,0 +1 @@ +uid://ipsld2ex2t8e diff --git a/PlanetField.gd b/PlanetField.gd index 53b6ddf..6a8c3c6 100644 --- a/PlanetField.gd +++ b/PlanetField.gd @@ -84,9 +84,7 @@ func _spawn_chunk_planet(chunk: Vector2i) -> void: planet.position = origin + data.local_pos planet.planet_name = data.name planet.shape_seed = data.shape_seed - planet.planet_type = String(data.get("planet_type", "rocky")) - planet.resource_type = String(data.get("resource_type", "ore")) - planet.surface_profile_id = String(data.get("surface_profile_id", "rocky")) + planet.definition_id = String(data.get("definition_id", "rocky")) # Deferred: update_chunks() can run synchronously from inside a # parent's own _ready() chain (forced once right after the fleet is # positioned, to avoid a pop-in on scene load) - Godot refuses a diff --git a/PlanetSurfaceController.gd b/PlanetSurfaceController.gd index 9f632b4..7c683d4 100644 --- a/PlanetSurfaceController.gd +++ b/PlanetSurfaceController.gd @@ -1,6 +1,7 @@ extends Node2D const SURFACE_PROFILES = preload("res://PlanetSurfaceProfiles.gd") +const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd") # Bootstraps the surviving fleet on this scene's own startup (from # GameState, since this is a fresh scene tree - nothing here carries @@ -22,7 +23,8 @@ var transitioning: bool = false func _ready() -> void: - _compose_surface(SURFACE_PROFILES.get_profile(GameState.surface_profile_id)) + var planet_definition: Resource = PLANET_DEFINITIONS.get_definition(GameState.planet_definition_id) + _compose_surface(SURFACE_PROFILES.get_profile(planet_definition.surface_profile_id)) fleet.spawn_ships(GameState.surviving_ship_count, Vector2.ZERO) camera.position = Vector2.ZERO camera.zoom = Vector2(GameState.return_camera_zoom, GameState.return_camera_zoom) diff --git a/RockyPlanetDefinition.tres b/RockyPlanetDefinition.tres new file mode 100644 index 0000000..b0a3e9f --- /dev/null +++ b/RockyPlanetDefinition.tres @@ -0,0 +1,14 @@ +[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://RockyPlanetVisual.tscn" id="2"] + +[resource] +script = ExtResource("1") +definition_id = "rocky" +display_name = "Rocky planet" +names = PackedStringArray("Kepler's Rest", "Ceres Minor", "Vesta's Shadow", "Ironhold", "Drift Anchor", "Stonefall", "Grey Watch", "Silent Quarry", "Baros Rock", "Umbra Point") +visual_scene = ExtResource("2") +surface_profile_id = "rocky" +resource_id = "ore" +map_color = Color(0.7, 0.65, 0.55, 1) diff --git a/RockyPlanetVisual.gd b/RockyPlanetVisual.gd new file mode 100644 index 0000000..dc2a889 --- /dev/null +++ b/RockyPlanetVisual.gd @@ -0,0 +1,26 @@ +extends Node2D + +var shape_points: PackedVector2Array = PackedVector2Array() + + +func configure(shape_seed: int) -> void: + shape_points.clear() + var rng := RandomNumberGenerator.new() + rng.seed = shape_seed + for i in range(10): + var angle: float = (float(i) / 10.0) * TAU + var radius: float = rng.randf_range(70.0, 100.0) + shape_points.append(Vector2(cos(angle), sin(angle)) * radius) + queue_redraw() + + +func extractor_anchor() -> Vector2: + var anchor := Vector2(0.0, -100.0) + for point in shape_points: + if point.y < anchor.y: + anchor = point + return anchor + + +func _draw() -> void: + NeonDraw.draw_glowing_polygon(self, shape_points, Color(0.6, 0.55, 0.5), 1.0) diff --git a/RockyPlanetVisual.gd.uid b/RockyPlanetVisual.gd.uid new file mode 100644 index 0000000..b6ecadb --- /dev/null +++ b/RockyPlanetVisual.gd.uid @@ -0,0 +1 @@ +uid://b82xh8nosedlm diff --git a/RockyPlanetVisual.tscn b/RockyPlanetVisual.tscn new file mode 100644 index 0000000..ee6a302 --- /dev/null +++ b/RockyPlanetVisual.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://RockyPlanetVisual.gd" id="1"] + +[node name="Visual" type="Node2D"] +script = ExtResource("1") diff --git a/WorldGen.gd b/WorldGen.gd index feea7f6..5e73ee5 100644 --- a/WorldGen.gd +++ b/WorldGen.gd @@ -1,6 +1,8 @@ class_name WorldGen extends RefCounted +const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd") + # Deterministic procedural world generation: the world is divided into # square chunks, and each chunk's biome is derived from a seeded # low-frequency noise field sampled at the chunk's own coordinates - so @@ -22,13 +24,6 @@ const GAS_GIANT_THRESHOLD: float = 0.55 const NEARBY_GAS_GIANT_CHUNK: Vector2i = Vector2i.ZERO const NEARBY_GAS_GIANT_LOCAL_POS: Vector2 = Vector2(900.0, 600.0) -const PLANET_NAMES: Array = [ - "Kepler's Rest", "Ceres Minor", "Vesta's Shadow", "Ironhold", - "Drift Anchor", "Stonefall", "Grey Watch", "Silent Quarry", - "Baros Rock", "Umbra Point", -] -const GAS_GIANT_NAMES: Array = ["Aurelia", "Boreas", "Cerulean Deep", "Helios Major", "Vesper"] - static var _noise: FastNoiseLite static var _planet_noise: FastNoiseLite @@ -88,16 +83,16 @@ static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary: var guaranteed_nearby_giant: bool = chunk == NEARBY_GAS_GIANT_CHUNK var local_pos: Vector2 = NEARBY_GAS_GIANT_LOCAL_POS if guaranteed_nearby_giant \ else Vector2(rng.randf() * CHUNK_SIZE, rng.randf() * CHUNK_SIZE) - var planet_name: String = PLANET_NAMES[rng.randi() % PLANET_NAMES.size()] var shape_seed: int = rng.randi() # 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 - if gas_giant: - planet_name = GAS_GIANT_NAMES[rng.randi() % GAS_GIANT_NAMES.size()] + var definition_id: String = PLANET_DEFINITIONS.GAS_GIANT_ID if gas_giant else PLANET_DEFINITIONS.ROCKY_ID + var definition: Resource = PLANET_DEFINITIONS.get_definition(definition_id) + var names: PackedStringArray = definition.names + var planet_name: String = names[rng.randi() % names.size()] return {"local_pos": local_pos, "name": planet_name, "shape_seed": shape_seed, - "planet_type": "gas_giant" if gas_giant else "rocky", "resource_type": "gas" if gas_giant else "ore", - "surface_profile_id": "gas" if gas_giant else "rocky"} + "definition_id": definition_id} static func biome_color(biome: int) -> Color: diff --git a/WorldMapContent.gd b/WorldMapContent.gd index e8ba8df..33212a0 100644 --- a/WorldMapContent.gd +++ b/WorldMapContent.gd @@ -1,5 +1,7 @@ extends Node2D +const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd") + const WORLD_TO_LOCAL: float = 0.04 # Mirrors Building.gd's own MOTHERSHIP_COLOR/CRAFTER_COLOR/COMMAND_COLOR so a @@ -105,7 +107,8 @@ func _draw() -> void: # spawn_radius_chunks of the fleet). var data: Dictionary = WorldGen.planet_data_at_chunk(chunk) var planet_local: Vector2 = origin_local + data.local_pos * WORLD_TO_LOCAL - draw_circle(planet_local, 5.0, Color(0.7, 0.65, 0.55)) + var definition: Resource = PLANET_DEFINITIONS.get_definition(String(data.definition_id)) + draw_circle(planet_local, 5.0, definition.map_color) draw_arc(planet_local, 5.0, 0.0, TAU, 16, Color(1.0, 1.0, 1.0, 0.9), 1.0, true) if chunk_has_extractor_badge(chunk): diff --git a/tests/_gas_surface_preview.gd b/tests/_gas_surface_preview.gd index 41cde0a..5dafbe5 100644 --- a/tests/_gas_surface_preview.gd +++ b/tests/_gas_surface_preview.gd @@ -3,8 +3,7 @@ extends Node2D func _ready() -> void: GameState.planet_name = "Violet Tempest" - GameState.planet_type = "gas_giant" - GameState.surface_profile_id = "gas" + GameState.planet_definition_id = "gas_giant" GameState.surviving_ship_count = 3 GameState.rocks = 128 GameState.gas = 46 diff --git a/tests/test_gas_system.gd b/tests/test_gas_system.gd index f0797d1..94edc1d 100644 --- a/tests/test_gas_system.gd +++ b/tests/test_gas_system.gd @@ -27,8 +27,7 @@ func _process(delta: float) -> bool: return _fail("nearby gas giant chunk does not contain a planet") if WorldGen.biome_at_chunk(nearby_chunk) != WorldGen.Biome.SPACE: return _fail("nearby gas giant is not in the normal space biome") - if String(nearby_data.get("planet_type", "")) != "gas_giant" \ - or String(nearby_data.get("resource_type", "")) != "gas": + if String(nearby_data.get("definition_id", "")) != "gas_giant": return _fail("nearby guaranteed planet is not a gas source") if nearby_position.length() > WorldGen.CHUNK_SIZE * 2.0: return _fail("nearby gas giant spawned too far away: %s" % nearby_position) @@ -36,8 +35,7 @@ func _process(delta: float) -> bool: # Gas surfaces have vents only: no deferred ore pickups, and the vent # emission advances continuously so the source visibly looks active. game_state.planet_name = "Gas Test" - game_state.planet_type = "gas_giant" - game_state.surface_profile_id = "gas" + game_state.planet_definition_id = "gas_giant" var surface: Node = load("res://PlanetSurface.tscn").instantiate() root.add_child(surface) var gas_field: Node = surface.get_node("SurfaceFeatures/ResourceProvider") @@ -53,7 +51,7 @@ func _process(delta: float) -> bool: return _fail("gas-source emission animation did not advance") surface.free() - game_state.surface_profile_id = "rocky" + game_state.planet_definition_id = "rocky" var rocky_surface: Node = load("res://PlanetSurface.tscn").instantiate() root.add_child(rocky_surface) var rocky_features: Node = rocky_surface.get_node("SurfaceFeatures/ResourceProvider") @@ -69,16 +67,17 @@ func _process(delta: float) -> bool: for x in range(-12, 13): for y in range(-12, 13): var data: Dictionary = WorldGen.planet_data_at_chunk(Vector2i(x, y)) - if String(data.get("planet_type", "")) == "gas_giant": - found_giant = String(data.get("resource_type", "")) == "gas" + if String(data.get("definition_id", "")) == "gas_giant": + found_giant = true break if found_giant: break if not found_giant: return _fail("world generation produced no deterministic gas giant") var giant: Node2D = load("res://Planet.tscn").instantiate() - giant.planet_type = "gas_giant" - giant._process(0.5) + giant.definition_id = "gas_giant" + root.add_child(giant) + giant.visual._process(0.5) if giant.gas_phase <= 0.0: return _fail("gas giant animation did not advance") giant.free() From 1721edd1236dd6d2777eda70f3c66826cbf175bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Fri, 14 Aug 2026 20:32:58 +0200 Subject: [PATCH 3/5] Store resources in generic inventories --- GameState.gd | 236 +++++++++++++------------- GasResourceDefinition.tres | 11 ++ GasResourceGlyph.gd | 19 +++ GasResourceGlyph.gd.uid | 1 + GasResourceGlyph.tscn | 13 ++ Inventory.gd | 79 +++++++++ Inventory.gd.uid | 1 + OreResourceDefinition.tres | 11 ++ OreResourceGlyph.gd | 14 ++ OreResourceGlyph.gd.uid | 1 + OreResourceGlyph.tscn | 13 ++ ResourceBar.gd | 8 +- ResourceDefinition.gd | 7 + ResourceDefinition.gd.uid | 1 + ResourceDefinitions.gd | 18 ++ ResourceDefinitions.gd.uid | 1 + ResourceGlyph.gd | 48 ++---- ResourceTransferList.gd | 6 +- tests/test_generic_inventory.gd | 48 ++++++ tests/test_generic_inventory.gd.uid | 1 + tests/test_manual_supply_route.gd | 3 +- tests/test_resource_transfer.gd | 2 +- tests/test_resources_and_buildings.gd | 2 +- tests/test_transporter_supply.gd | 3 +- 24 files changed, 380 insertions(+), 167 deletions(-) create mode 100644 GasResourceDefinition.tres create mode 100644 GasResourceGlyph.gd create mode 100644 GasResourceGlyph.gd.uid create mode 100644 GasResourceGlyph.tscn create mode 100644 Inventory.gd create mode 100644 Inventory.gd.uid create mode 100644 OreResourceDefinition.tres create mode 100644 OreResourceGlyph.gd create mode 100644 OreResourceGlyph.gd.uid create mode 100644 OreResourceGlyph.tscn create mode 100644 ResourceDefinition.gd create mode 100644 ResourceDefinition.gd.uid create mode 100644 ResourceDefinitions.gd create mode 100644 ResourceDefinitions.gd.uid create mode 100644 tests/test_generic_inventory.gd create mode 100644 tests/test_generic_inventory.gd.uid diff --git a/GameState.gd b/GameState.gd index 11a4b7a..500ec5d 100644 --- a/GameState.gd +++ b/GameState.gd @@ -1,5 +1,8 @@ extends Node +const INVENTORY = preload("res://Inventory.gd") +const RESOURCE_DEFINITIONS = preload("res://ResourceDefinitions.gd") + # Autoload singleton - the one thing that survives change_scene_to_file() # scene swaps between space (Main.tscn) and a planet's surface @@ -23,126 +26,101 @@ var explored_chunks: Dictionary = {} # --- Resources ------------------------------------------------------------- -# Ore that has been BANKED at the mothership. This is the only pool anything -# can be bought with. -# Starts stocked for play-testing so buildings can be tried without mining -# first - drop this back to 0 for a real playthrough. -var rocks: int = 500 +const RESOURCE_ORE: String = RESOURCE_DEFINITIONS.ORE_ID +const RESOURCE_GAS: String = RESOURCE_DEFINITIONS.GAS_ID +const RESOURCE_TYPES: PackedStringArray = RESOURCE_DEFINITIONS.ORDER -# Gas is a second, banked resource. It is deliberately separate from ore: -# construction still spends ore, while future gas technologies can consume -# gas without making the two materials silently interchangeable. -var gas: int = 0 +# All resource-bearing locations use the same container. The old public +# names below are computed compatibility accessors, not separate storage. +var bank_inventory: RefCounted = INVENTORY.new({RESOURCE_ORE: 500}) +var fleet_inventory: RefCounted = INVENTORY.new() -# Ore the fleet is physically carrying and has not delivered yet (#14). Mining -# fills this, not `rocks`: ore is cargo until the fleet brings it home, so a -# long mining run is a real trip with something to lose rather than instant -# income from anywhere on the map. -# -# Lives here, like everything else that has to survive Main.tscn being torn -# down and rebuilt on every trip to a planet's surface - and cargo mined ON a -# surface would otherwise be dropped the moment the player took off. -var fleet_cargo: int = 0 -var fleet_gas_cargo: int = 0 +var rocks: int: + get: return bank_inventory.amount(RESOURCE_ORE) + set(value): bank_inventory.set_amount(RESOURCE_ORE, value) -# Resource-facing UI and logistics iterate this list instead of growing a -# second set of controls and controller branches every time a material is -# introduced. Keep presentation data here with the state it describes so a -# resource has one name and colour everywhere it appears. -const RESOURCE_ORE: String = "ore" -const RESOURCE_GAS: String = "gas" -const RESOURCE_TYPES: PackedStringArray = [RESOURCE_ORE, RESOURCE_GAS] -const RESOURCE_NAMES: Dictionary = { - RESOURCE_ORE: "Ore", - RESOURCE_GAS: "Gas", -} -const RESOURCE_COLORS: Dictionary = { - RESOURCE_ORE: Color(0.0, 0.72, 0.82), - RESOURCE_GAS: Color(0.85, 0.52, 1.0), -} +var gas: int: + get: return bank_inventory.amount(RESOURCE_GAS) + set(value): bank_inventory.set_amount(RESOURCE_GAS, value) + +var fleet_cargo: int: + get: return fleet_inventory.amount(RESOURCE_ORE) + set(value): fleet_inventory.set_amount(RESOURCE_ORE, value) + +var fleet_gas_cargo: int: + get: return fleet_inventory.amount(RESOURCE_GAS) + set(value): fleet_inventory.set_amount(RESOURCE_GAS, value) func collect_cargo(amount: int = 1) -> void: - fleet_cargo += amount + fleet_inventory.deposit(RESOURCE_ORE, amount) func collect_gas_cargo(amount: int = 1) -> void: - fleet_gas_cargo += amount + fleet_inventory.deposit(RESOURCE_GAS, amount) + + +func resource_types() -> PackedStringArray: + return RESOURCE_DEFINITIONS.ids() + + +func resource_definition(resource_type: String) -> Resource: + return RESOURCE_DEFINITIONS.get_definition(resource_type) func resource_name(resource_type: String) -> String: - return String(RESOURCE_NAMES.get(resource_type, resource_type.capitalize())) + var definition: Resource = resource_definition(resource_type) + return String(definition.display_name) if definition != null else resource_type.capitalize() func resource_color(resource_type: String) -> Color: - return RESOURCE_COLORS.get(resource_type, Color.WHITE) + var definition: Resource = resource_definition(resource_type) + return definition.color if definition != null else Color.WHITE func banked_resource(resource_type: String) -> int: - match resource_type: - RESOURCE_ORE: return rocks - RESOURCE_GAS: return gas - _: return 0 + return bank_inventory.amount(resource_type) func fleet_resource(resource_type: String) -> int: - match resource_type: - RESOURCE_ORE: return fleet_cargo - RESOURCE_GAS: return fleet_gas_cargo - _: return 0 + return fleet_inventory.amount(resource_type) + + +func _inventory_for_record(record: Dictionary) -> RefCounted: + var inventory: RefCounted = record.get("inventory") as RefCounted + if inventory == null: + inventory = INVENTORY.new() + record.inventory = inventory + # Compatibility migration for state made before #33. Erase each legacy + # field once absorbed so it cannot drift out of step with the inventory. + if record.has("stock"): + inventory.set_amount(RESOURCE_ORE, int(record.stock)) + record.erase("stock") + if record.has("gas_stock"): + inventory.set_amount(RESOURCE_GAS, int(record.gas_stock)) + record.erase("gas_stock") + return inventory + + +func building_inventory(id: int) -> RefCounted: + var building: Dictionary = find_building(id) + if building.is_empty(): + return null + if building.type == BUILDING_MOTHERSHIP: + return bank_inventory + return _inventory_for_record(building) func building_resource(id: int, resource_type: String) -> int: - var building: Dictionary = find_building(id) - if building.is_empty(): - return 0 - if building.type == BUILDING_MOTHERSHIP: - return banked_resource(resource_type) - match resource_type: - RESOURCE_ORE: return int(building.get("stock", 0)) - RESOURCE_GAS: return int(building.get("gas_stock", 0)) - _: return 0 + var inventory: RefCounted = building_inventory(id) + return 0 if inventory == null else inventory.amount(resource_type) func transfer_resource_to_building(id: int, resource_type: String, amount: int) -> int: - var building: Dictionary = find_building(id) - if building.is_empty(): - return 0 - var moved: int = mini(maxi(amount, 0), fleet_resource(resource_type)) - if moved <= 0: - return 0 - match resource_type: - RESOURCE_ORE: fleet_cargo -= moved - RESOURCE_GAS: fleet_gas_cargo -= moved - _: return 0 - if building.type == BUILDING_MOTHERSHIP: - match resource_type: - RESOURCE_ORE: rocks += moved - RESOURCE_GAS: gas += moved - else: - match resource_type: - RESOURCE_ORE: building.stock = int(building.get("stock", 0)) + moved - RESOURCE_GAS: building.gas_stock = int(building.get("gas_stock", 0)) + moved - return moved + return fleet_inventory.transfer_to(building_inventory(id), resource_type, amount) func transfer_resource_to_fleet(id: int, resource_type: String, amount: int) -> int: - var building: Dictionary = find_building(id) - if building.is_empty(): - return 0 - var moved: int = mini(maxi(amount, 0), building_resource(id, resource_type)) - if moved <= 0: - return 0 - if building.type == BUILDING_MOTHERSHIP: - match resource_type: - RESOURCE_ORE: rocks -= moved - RESOURCE_GAS: gas -= moved - else: - match resource_type: - RESOURCE_ORE: building.stock = int(building.get("stock", 0)) - moved - RESOURCE_GAS: building.gas_stock = int(building.get("gas_stock", 0)) - moved - match resource_type: - RESOURCE_ORE: fleet_cargo += moved - RESOURCE_GAS: fleet_gas_cargo += moved - return moved + var source: RefCounted = building_inventory(id) + return 0 if source == null else source.transfer_to(fleet_inventory, resource_type, amount) # The batch size the transfer menu offers, next to "All". A batch rather @@ -401,8 +379,8 @@ func add_building(type: String, at_position: Vector2) -> int: var id: int = next_building_id next_building_id += 1 buildings.append({ - "id": id, "type": type, "position": at_position, "stock": 0, - "gas_stock": 0, "orders": [], + "id": id, "type": type, "position": at_position, + "inventory": INVENTORY.new(), "orders": [], }) return id @@ -655,8 +633,7 @@ func can_produce(type: String) -> bool: func building_stock(id: int) -> int: - var b: Dictionary = find_building(id) - return 0 if b.is_empty() else int(b.get("stock", 0)) + return building_resource(id, RESOURCE_ORE) func pending_orders(id: int) -> int: @@ -703,7 +680,7 @@ func cancel_order(id: int, index: int) -> bool: return false var order: Dictionary = orders[index] if bool(order.started): - b.stock = int(b.get("stock", 0)) + product_cost(String(order.product)) + _inventory_for_record(b).deposit(RESOURCE_ORE, product_cost(String(order.product))) orders.remove_at(index) return true @@ -718,7 +695,7 @@ func outstanding_cost(id: int) -> int: # A started build has already paid, so it is not still owed. if not bool(o.started): need += product_cost(String(o.product)) - return maxi(need - int(b.get("stock", 0)), 0) + return maxi(need - _inventory_for_record(b).amount(RESOURCE_ORE), 0) func deposit_to_building(id: int, amount: int) -> int: @@ -727,8 +704,7 @@ func deposit_to_building(id: int, amount: int) -> int: var b: Dictionary = find_building(id) if b.is_empty(): return 0 - b.stock = int(b.get("stock", 0)) + amount - return amount + return _inventory_for_record(b).deposit(RESOURCE_ORE, amount) func queue_order(id: int, product: String = PRODUCT_SHIP) -> bool: @@ -763,9 +739,10 @@ func _advance_production(delta: float) -> void: var order: Dictionary = orders[0] var product: String = String(order.product) var cost: int = product_cost(product) + var inventory: RefCounted = _inventory_for_record(b) if not bool(order.started): - if int(b.get("stock", 0)) < cost: + if inventory.amount(RESOURCE_ORE) < cost: continue if product == PRODUCT_TRANSPORTER and transporters.size() >= transport_capacity(): # A hub was demolished after this was ordered, so the berth it @@ -773,7 +750,7 @@ func _advance_production(delta: float) -> void: continue if product == PRODUCT_TRANSPORTER and nearest_hub_to(b.position).is_empty(): continue - b.stock = int(b.stock) - cost + inventory.withdraw(RESOURCE_ORE, cost) order.started = true order.elapsed = float(order.elapsed) + delta @@ -826,10 +803,11 @@ func _advance_lab_research(delta: float) -> void: var order: Dictionary = orders[0] var id: String = String(order.id) var cost: int = UNLOCK_SHIELD_COST if id == UNLOCK_SHIELD_VESSEL else upgrade_cost(id) + var inventory: RefCounted = _inventory_for_record(lab) if not bool(order.started): - if int(lab.get("stock", 0)) < cost: + if inventory.amount(RESOURCE_ORE) < cost: continue - lab.stock = int(lab.stock) - cost + inventory.withdraw(RESOURCE_ORE, cost) order.started = true order.elapsed = float(order.elapsed) + delta if float(order.elapsed) < lab_research_time(): @@ -918,7 +896,7 @@ func rotate_formation(radians: float) -> void: # Extractors mine into their OWN local stock. They do not own a magical # hauler: ore becomes spendable only when a hub-stationed transporter picks # up a full load and physically delivers it to the mothership. Entries are -# {"position", "planet_name", "resource_type", "stock", "mining_progress"}. +# {"position", "planet_name", "resource_type", "inventory", "mining_progress"}. const EXTRACTOR_RATE: float = 2.0 # ore per second var extractors: Array = [] @@ -939,7 +917,7 @@ func add_extractor(planet_name: String, at_position: Vector2, resource_type: Str "position": at_position, "planet_name": planet_name, "resource_type": resource_type, - "stock": 0, + "inventory": INVENTORY.new(), "mining_progress": 0.0, }) # An extractor is built from SPACE, with the fleet parked on the planet - @@ -952,7 +930,21 @@ func add_extractor(planet_name: String, at_position: Vector2, resource_type: Str func extractor_stock(index: int) -> int: if index < 0 or index >= extractors.size(): return 0 - return int(extractors[index].get("stock", 0)) + var extractor: Dictionary = extractors[index] + return _extractor_inventory(extractor).amount(String(extractor.get("resource_type", RESOURCE_ORE))) + + +func _extractor_inventory(extractor: Dictionary) -> RefCounted: + var inventory: RefCounted = extractor.get("inventory") as RefCounted + if inventory == null: + inventory = INVENTORY.new() + extractor.inventory = inventory + # Old extractor state had one stock field whose material was described by + # resource_type. Absorb it into that key on first access. + if extractor.has("stock"): + inventory.set_amount(String(extractor.get("resource_type", RESOURCE_ORE)), int(extractor.stock)) + extractor.erase("stock") + return inventory func _process(delta: float) -> void: @@ -965,7 +957,7 @@ func _process(delta: float) -> void: e.mining_progress = float(e.get("mining_progress", 0.0)) + delta * EXTRACTOR_RATE var whole: int = int(floor(float(e.mining_progress))) if whole > 0: - e.stock = int(e.get("stock", 0)) + whole + _extractor_inventory(e).deposit(String(e.get("resource_type", RESOURCE_ORE)), whole) e.mining_progress = float(e.mining_progress) - whole _advance_transporters(delta) @@ -1127,7 +1119,8 @@ func _dispatch_transporters() -> void: var load: int = transporter_load_limit() for i in range(extractors.size()): var extractor: Dictionary = extractors[i] - if int(extractor.get("stock", 0)) < load or _transporter_inbound_to_extractor(i): + var resource_id: String = String(extractor.get("resource_type", RESOURCE_ORE)) + if _extractor_inventory(extractor).amount(resource_id) < load or _transporter_inbound_to_extractor(i): continue var chosen_extractor: Dictionary = {} var best_extractor_distance: float = INF @@ -1141,7 +1134,7 @@ func _dispatch_transporters() -> void: if chosen_extractor.is_empty(): return chosen_extractor.extractor_index = i - chosen_extractor.resource_type = String(extractor.get("resource_type", "ore")) + chosen_extractor.resource_type = resource_id chosen_extractor.target_id = 0 _begin_leg(chosen_extractor, transporter_position(chosen_extractor), extractor.position, TRANSPORTER_TO_EXTRACTOR) @@ -1192,13 +1185,13 @@ func _arrive(t: Dictionary) -> void: TRANSPORTER_TO_MOTHERSHIP: var target: Dictionary = find_building(int(t.target_id)) var want: int = outstanding_cost(int(t.target_id)) - var take: int = mini(mini(want, rocks), transporter_load_limit()) + var take: int = mini(mini(want, bank_inventory.amount(RESOURCE_ORE)), transporter_load_limit()) if take <= 0 or target.is_empty(): # Nothing to carry, or the building is gone. Go home empty. t.target_id = 0 _begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING) return - rocks -= take + bank_inventory.withdraw(RESOURCE_ORE, take) t.carrying = take _begin_leg(t, here, target.position, TRANSPORTER_TO_BUILDING) TRANSPORTER_TO_BUILDING: @@ -1206,7 +1199,7 @@ func _arrive(t: Dictionary) -> void: if deposit_to_building(int(t.target_id), int(t.carrying)) <= 0: # Building demolished in transit - put the ore back rather # than quietly destroying it. - rocks += int(t.carrying) + bank_inventory.deposit(RESOURCE_ORE, int(t.carrying)) t.carrying = 0 t.target_id = 0 _begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING) @@ -1217,20 +1210,19 @@ func _arrive(t: Dictionary) -> void: return var extractor: Dictionary = extractors[extractor_index] var load: int = transporter_load_limit() - if int(extractor.get("stock", 0)) < load: + var resource_id: String = String(extractor.get("resource_type", RESOURCE_ORE)) + var extractor_inventory: RefCounted = _extractor_inventory(extractor) + if extractor_inventory.amount(resource_id) < load: t.extractor_index = -1 _begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING) return - extractor.stock = int(extractor.stock) - load + extractor_inventory.withdraw(resource_id, load) t.carrying = load - t.resource_type = String(extractor.get("resource_type", "ore")) + t.resource_type = resource_id _begin_leg(t, here, get_mothership_position(), TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR) TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR: # Bank only on arrival; cargo retains the extractor's resource type. - if String(t.get("resource_type", "ore")) == "gas": - gas += int(t.carrying) - else: - rocks += int(t.carrying) + bank_inventory.deposit(String(t.get("resource_type", RESOURCE_ORE)), int(t.carrying)) t.carrying = 0 t.extractor_index = -1 _begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING) @@ -1240,11 +1232,11 @@ func _arrive(t: Dictionary) -> void: func can_afford(cost: int) -> bool: - return rocks >= cost + return bank_inventory.amount(RESOURCE_ORE) >= cost func spend(cost: int) -> bool: if not can_afford(cost): return false - rocks -= cost + bank_inventory.withdraw(RESOURCE_ORE, cost) return true diff --git a/GasResourceDefinition.tres b/GasResourceDefinition.tres new file mode 100644 index 0000000..a36af07 --- /dev/null +++ b/GasResourceDefinition.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="ResourceDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://ResourceDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://GasResourceGlyph.tscn" id="2"] + +[resource] +script = ExtResource("1") +resource_id = "gas" +display_name = "Gas" +color = Color(0.85, 0.52, 1, 1) +glyph_scene = ExtResource("2") diff --git a/GasResourceGlyph.gd b/GasResourceGlyph.gd new file mode 100644 index 0000000..8bd9965 --- /dev/null +++ b/GasResourceGlyph.gd @@ -0,0 +1,19 @@ +extends Control + +@export var colour: Color = Color(0.85, 0.52, 1.0) + + +func _draw() -> void: + var centre: Vector2 = size * 0.5 + var radius: float = minf(size.x, size.y) * 0.34 + var bubbles: Array = [ + [centre + Vector2(-radius * 0.38, radius * 0.12), radius * 0.46], + [centre + Vector2(radius * 0.34, radius * 0.26), radius * 0.58], + [centre + Vector2(radius * 0.38, -radius * 0.48), radius * 0.32], + ] + for bubble in bubbles: + var ring := PackedVector2Array() + for i in range(12): + var angle: float = TAU * float(i) / 12.0 + ring.append(bubble[0] + Vector2(cos(angle), sin(angle)) * float(bubble[1])) + NeonDraw.draw_glowing_polygon(self, ring, colour, 1.0, colour.darkened(0.78), maxf(0.5, radius * 0.035)) diff --git a/GasResourceGlyph.gd.uid b/GasResourceGlyph.gd.uid new file mode 100644 index 0000000..1dfbb0c --- /dev/null +++ b/GasResourceGlyph.gd.uid @@ -0,0 +1 @@ +uid://d1ko6yhtjjavx diff --git a/GasResourceGlyph.tscn b/GasResourceGlyph.tscn new file mode 100644 index 0000000..ee02276 --- /dev/null +++ b/GasResourceGlyph.tscn @@ -0,0 +1,13 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://GasResourceGlyph.gd" id="1"] + +[node name="GlyphVisual" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +script = ExtResource("1") diff --git a/Inventory.gd b/Inventory.gd new file mode 100644 index 0000000..aab25fd --- /dev/null +++ b/Inventory.gd @@ -0,0 +1,79 @@ +class_name Inventory +extends RefCounted + +var total_capacity: int = -1 +var resource_capacities: Dictionary = {} +var _amounts: Dictionary = {} + + +func _init(initial: Dictionary = {}, capacity: int = -1) -> void: + total_capacity = capacity + for resource_id in initial: + set_amount(String(resource_id), int(initial[resource_id])) + + +func amount(resource_id: String) -> int: + return int(_amounts.get(resource_id, 0)) + + +func total() -> int: + var result: int = 0 + for value in _amounts.values(): + result += int(value) + return result + + +func capacity_for(resource_id: String) -> int: + return int(resource_capacities.get(resource_id, -1)) + + +func space_for(resource_id: String) -> int: + var space: int = 1 << 30 + if total_capacity >= 0: + space = maxi(total_capacity - total(), 0) + var resource_capacity: int = capacity_for(resource_id) + if resource_capacity >= 0: + space = mini(space, maxi(resource_capacity - amount(resource_id), 0)) + return space + + +func set_amount(resource_id: String, value: int) -> int: + _amounts.erase(resource_id) + var accepted: int = mini(maxi(value, 0), space_for(resource_id)) + if accepted > 0: + _amounts[resource_id] = accepted + return amount(resource_id) + + +func deposit(resource_id: String, requested: int) -> int: + var moved: int = mini(maxi(requested, 0), space_for(resource_id)) + if moved > 0: + _amounts[resource_id] = amount(resource_id) + moved + return moved + + +func withdraw(resource_id: String, requested: int) -> int: + var moved: int = mini(maxi(requested, 0), amount(resource_id)) + if moved <= 0: + return 0 + var remaining: int = amount(resource_id) - moved + if remaining > 0: + _amounts[resource_id] = remaining + else: + _amounts.erase(resource_id) + return moved + + +func transfer_to(destination: Inventory, resource_id: String, requested: int) -> int: + if destination == null: + return 0 + var moved: int = mini(mini(maxi(requested, 0), amount(resource_id)), destination.space_for(resource_id)) + if moved <= 0: + return 0 + withdraw(resource_id, moved) + destination.deposit(resource_id, moved) + return moved + + +func snapshot() -> Dictionary: + return _amounts.duplicate() diff --git a/Inventory.gd.uid b/Inventory.gd.uid new file mode 100644 index 0000000..59722a2 --- /dev/null +++ b/Inventory.gd.uid @@ -0,0 +1 @@ +uid://celx1yj6cp2rf diff --git a/OreResourceDefinition.tres b/OreResourceDefinition.tres new file mode 100644 index 0000000..6da27dc --- /dev/null +++ b/OreResourceDefinition.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="ResourceDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://ResourceDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://OreResourceGlyph.tscn" id="2"] + +[resource] +script = ExtResource("1") +resource_id = "ore" +display_name = "Ore" +color = Color(0, 0.72, 0.82, 1) +glyph_scene = ExtResource("2") diff --git a/OreResourceGlyph.gd b/OreResourceGlyph.gd new file mode 100644 index 0000000..e28f7a3 --- /dev/null +++ b/OreResourceGlyph.gd @@ -0,0 +1,14 @@ +extends Control + +@export var colour: Color = Color(0.0, 0.72, 0.82) + + +func _draw() -> void: + var centre: Vector2 = size * 0.5 + var radius: float = minf(size.x, size.y) * 0.34 + var points := PackedVector2Array() + for i in range(6): + var angle: float = TAU * float(i) / 6.0 - PI * 0.5 + points.append(centre + Vector2(cos(angle), sin(angle)) * radius) + NeonDraw.draw_glowing_polygon(self, points, colour, 1.0, colour.darkened(0.78), maxf(0.5, radius * 0.035)) + NeonDraw.draw_glowing_line(self, points[5], points[2], colour, 0.55, maxf(0.4, radius * 0.025), colour.darkened(0.78)) diff --git a/OreResourceGlyph.gd.uid b/OreResourceGlyph.gd.uid new file mode 100644 index 0000000..2ba6598 --- /dev/null +++ b/OreResourceGlyph.gd.uid @@ -0,0 +1 @@ +uid://cwgkhqo4ev5ao diff --git a/OreResourceGlyph.tscn b/OreResourceGlyph.tscn new file mode 100644 index 0000000..384a20e --- /dev/null +++ b/OreResourceGlyph.tscn @@ -0,0 +1,13 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://OreResourceGlyph.gd" id="1"] + +[node name="GlyphVisual" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +script = ExtResource("1") diff --git a/ResourceBar.gd b/ResourceBar.gd index 2bf11b4..6594fc1 100644 --- a/ResourceBar.gd +++ b/ResourceBar.gd @@ -3,7 +3,7 @@ extends Control # Compact resource table shared by crafting, research and the ship lab. # Rows are resources; columns are locations. Adding another material to -# GameState.RESOURCE_TYPES automatically gives it a row here. +# The resource-definition catalog automatically gives each material a row. var building_id: int = 0 @@ -24,7 +24,7 @@ func _ready() -> void: add_child(label) _header_labels.append(label) - for resource_type in GameState.RESOURCE_TYPES: + for resource_type in GameState.resource_types(): var glyph := ResourceGlyph.new() glyph.resource_type = resource_type add_child(glyph) @@ -78,7 +78,7 @@ func _layout() -> void: var row_height: float = (size.y - HEADER_HEIGHT - 4.0) / maxf(1.0, float(_rows.size())) var row_index: int = 0 - for resource_type in GameState.RESOURCE_TYPES: + for resource_type in GameState.resource_types(): var row: Dictionary = _rows[resource_type] var y: float = HEADER_HEIGHT + float(row_index) * row_height row.glyph.position = Vector2(2.0, y + 2.0) @@ -94,7 +94,7 @@ func _layout() -> void: func _process(_delta: float) -> void: - for resource_type in GameState.RESOURCE_TYPES: + for resource_type in GameState.resource_types(): var row: Dictionary = _rows.get(resource_type, {}) if row.is_empty(): continue diff --git a/ResourceDefinition.gd b/ResourceDefinition.gd new file mode 100644 index 0000000..acb5acc --- /dev/null +++ b/ResourceDefinition.gd @@ -0,0 +1,7 @@ +class_name ResourceDefinition +extends Resource + +@export var resource_id: String = "ore" +@export var display_name: String = "Ore" +@export var color: Color = Color.WHITE +@export var glyph_scene: PackedScene diff --git a/ResourceDefinition.gd.uid b/ResourceDefinition.gd.uid new file mode 100644 index 0000000..440e5e4 --- /dev/null +++ b/ResourceDefinition.gd.uid @@ -0,0 +1 @@ +uid://kvy02iu7pbyu diff --git a/ResourceDefinitions.gd b/ResourceDefinitions.gd new file mode 100644 index 0000000..7ddf4c0 --- /dev/null +++ b/ResourceDefinitions.gd @@ -0,0 +1,18 @@ +class_name ResourceDefinitions +extends RefCounted + +const ORE_ID: String = "ore" +const GAS_ID: String = "gas" +const ORDER: PackedStringArray = [ORE_ID, GAS_ID] +const DEFINITIONS: Dictionary = { + ORE_ID: preload("res://OreResourceDefinition.tres"), + GAS_ID: preload("res://GasResourceDefinition.tres"), +} + + +static func get_definition(resource_id: String) -> Resource: + return DEFINITIONS.get(resource_id) + + +static func ids() -> PackedStringArray: + return ORDER diff --git a/ResourceDefinitions.gd.uid b/ResourceDefinitions.gd.uid new file mode 100644 index 0000000..9ef7d79 --- /dev/null +++ b/ResourceDefinitions.gd.uid @@ -0,0 +1 @@ +uid://ci7yyyxyd3o67 diff --git a/ResourceGlyph.gd b/ResourceGlyph.gd index aa80578..14a90d0 100644 --- a/ResourceGlyph.gd +++ b/ResourceGlyph.gd @@ -1,6 +1,8 @@ class_name ResourceGlyph extends Control +const RESOURCE_DEFINITIONS = preload("res://ResourceDefinitions.gd") + # A small, non-interactive resource mark shared by tables and transfer rows. # Resource buttons used to borrow IconButton, which made decorative icons # look tappable and encouraged each screen to invent its own version. @@ -12,43 +14,21 @@ func set_resource_type(value: String) -> void: if resource_type == value: return resource_type = value - queue_redraw() + if is_node_ready(): + _rebuild_visual() func _ready() -> void: mouse_filter = Control.MOUSE_FILTER_IGNORE - queue_redraw() + _rebuild_visual() -func _draw() -> void: - var colour: Color = GameState.resource_color(resource_type) - var centre: Vector2 = size * 0.5 - var radius: float = minf(size.x, size.y) * 0.34 - match resource_type: - GameState.RESOURCE_GAS: - _draw_gas(centre, radius, colour) - _: - _draw_ore(centre, radius, colour) - - -func _draw_ore(centre: Vector2, radius: float, colour: Color) -> void: - var points := PackedVector2Array() - for i in range(6): - var angle: float = TAU * float(i) / 6.0 - PI * 0.5 - points.append(centre + Vector2(cos(angle), sin(angle)) * radius) - NeonDraw.draw_glowing_polygon(self, points, colour, 1.0, colour.darkened(0.78), maxf(0.5, radius * 0.035)) - NeonDraw.draw_glowing_line(self, points[5], points[2], colour, 0.55, maxf(0.4, radius * 0.025), colour.darkened(0.78)) - - -func _draw_gas(centre: Vector2, radius: float, colour: Color) -> void: - var bubbles: Array = [ - [centre + Vector2(-radius * 0.38, radius * 0.12), radius * 0.46], - [centre + Vector2(radius * 0.34, radius * 0.26), radius * 0.58], - [centre + Vector2(radius * 0.38, -radius * 0.48), radius * 0.32], - ] - for bubble in bubbles: - var ring := PackedVector2Array() - for i in range(12): - var angle: float = TAU * float(i) / 12.0 - ring.append(bubble[0] + Vector2(cos(angle), sin(angle)) * float(bubble[1])) - NeonDraw.draw_glowing_polygon(self, ring, colour, 1.0, colour.darkened(0.78), maxf(0.5, radius * 0.035)) +func _rebuild_visual() -> void: + for child in get_children(): + child.queue_free() + var definition: Resource = RESOURCE_DEFINITIONS.get_definition(resource_type) + if definition == null or definition.glyph_scene == null: + return + var visual: Control = definition.glyph_scene.instantiate() + visual.colour = definition.color + add_child(visual) diff --git a/ResourceTransferList.gd b/ResourceTransferList.gd index 1883119..859f0f1 100644 --- a/ResourceTransferList.gd +++ b/ResourceTransferList.gd @@ -27,7 +27,7 @@ func _ready() -> void: _destination_heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER add_child(_destination_heading) - for resource_type in GameState.RESOURCE_TYPES: + for resource_type in GameState.resource_types(): var colour: Color = GameState.resource_color(resource_type) var glyph := ResourceGlyph.new() glyph.resource_type = resource_type @@ -140,7 +140,7 @@ func set_destination(id: int, heading: String) -> void: func refresh() -> void: - for resource_type in GameState.RESOURCE_TYPES: + for resource_type in GameState.resource_types(): var row: Dictionary = _rows.get(resource_type, {}) if row.is_empty(): continue @@ -169,7 +169,7 @@ func _layout() -> void: var row_height: float = (size.y - HEADER_HEIGHT) / maxf(1.0, float(_rows.size())) var row_index: int = 0 - for resource_type in GameState.RESOURCE_TYPES: + for resource_type in GameState.resource_types(): var row: Dictionary = _rows[resource_type] var y: float = HEADER_HEIGHT + float(row_index) * row_height var glyph_size: float = minf(66.0, row_height * 0.23) diff --git a/tests/test_generic_inventory.gd b/tests/test_generic_inventory.gd new file mode 100644 index 0000000..9e9418c --- /dev/null +++ b/tests/test_generic_inventory.gd @@ -0,0 +1,48 @@ +extends SceneTree + +const INVENTORY = preload("res://Inventory.gd") +const TEST_RESOURCE: String = "crystal" + + +func _initialize() -> void: + var game_state: Node = root.get_node("GameState") + game_state.buildings.clear() + game_state.bank_inventory.set_amount(TEST_RESOURCE, 40) + game_state.fleet_inventory.set_amount(TEST_RESOURCE, 0) + + var mothership_id: int = game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO) + var crafter_id: int = game_state.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(100, 0)) + if game_state.transfer_resource_to_fleet(mothership_id, TEST_RESOURCE, 15) != 15: + return _fail("third resource could not move from bank to fleet") + if game_state.transfer_resource_to_building(crafter_id, TEST_RESOURCE, 9) != 9: + return _fail("third resource could not move from fleet to building") + if game_state.transfer_resource_to_fleet(crafter_id, TEST_RESOURCE, 4) != 4: + return _fail("third resource could not move back out of building") + if game_state.banked_resource(TEST_RESOURCE) != 25 \ + or game_state.fleet_resource(TEST_RESOURCE) != 10 \ + or game_state.building_resource(crafter_id, TEST_RESOURCE) != 5: + return _fail("third-resource amounts were not conserved") + + var limited: RefCounted = INVENTORY.new({}, 10) + limited.resource_capacities[TEST_RESOURCE] = 6 + if limited.deposit(TEST_RESOURCE, 99) != 6 or limited.deposit("water", 99) != 4: + return _fail("total or per-resource capacity was not enforced") + + var legacy_id: int = game_state.next_building_id + game_state.next_building_id += 1 + game_state.buildings.append({"id": legacy_id, "type": GameState.BUILDING_SHIP_CRAFTER, + "position": Vector2.ZERO, "stock": 7, "gas_stock": 3, "orders": []}) + if game_state.building_resource(legacy_id, GameState.RESOURCE_ORE) != 7 \ + or game_state.building_resource(legacy_id, GameState.RESOURCE_GAS) != 3: + return _fail("legacy building stock was not migrated") + var migrated: Dictionary = game_state.find_building(legacy_id) + if migrated.has("stock") or migrated.has("gas_stock") or not migrated.has("inventory"): + return _fail("legacy fields remained after migration") + + print("TEST_GENERIC_INVENTORY: PASS") + quit(0) + + +func _fail(message: String) -> void: + print("TEST_GENERIC_INVENTORY: FAIL (%s)" % message) + quit(1) diff --git a/tests/test_generic_inventory.gd.uid b/tests/test_generic_inventory.gd.uid new file mode 100644 index 0000000..d8972c6 --- /dev/null +++ b/tests/test_generic_inventory.gd.uid @@ -0,0 +1 @@ +uid://53pexhmp3ns0 diff --git a/tests/test_manual_supply_route.gd b/tests/test_manual_supply_route.gd index f8479d1..bfb1f86 100644 --- a/tests/test_manual_supply_route.gd +++ b/tests/test_manual_supply_route.gd @@ -42,7 +42,8 @@ const CRAFTER_AT: Vector2 = Vector2(3000.0, 0.0) func _total() -> int: var sum: int = game_state.rocks + game_state.fleet_cargo for b in game_state.buildings: - sum += int(b.get("stock", 0)) + if b.type != GameState.BUILDING_MOTHERSHIP: + sum += game_state.building_resource(int(b.id), GameState.RESOURCE_ORE) return sum diff --git a/tests/test_resource_transfer.gd b/tests/test_resource_transfer.gd index 7b6a00c..f5035bc 100644 --- a/tests/test_resource_transfer.gd +++ b/tests/test_resource_transfer.gd @@ -39,7 +39,7 @@ func _total() -> int: var sum: int = game_state.rocks + game_state.fleet_cargo for b in game_state.buildings: if b.type != GameState.BUILDING_MOTHERSHIP: - sum += int(b.get("stock", 0)) + sum += game_state.building_resource(int(b.id), GameState.RESOURCE_ORE) return sum diff --git a/tests/test_resources_and_buildings.gd b/tests/test_resources_and_buildings.gd index 6069a1b..17c6780 100644 --- a/tests/test_resources_and_buildings.gd +++ b/tests/test_resources_and_buildings.gd @@ -38,7 +38,7 @@ func _process(delta: float) -> bool: match stage: 0: # --- gather a rock ------------------------------------------- - scene.get_node("RockField").set_process(false) + scene.get_node("SurfaceFeatures/ResourceProvider").set_process(false) rock = load("res://Rock.tscn").instantiate() rock.build_shape(1) # Placed OUTSIDE the pickup shape but inside attract_radius, so diff --git a/tests/test_transporter_supply.gd b/tests/test_transporter_supply.gd index 8130e16..ca9dcbb 100644 --- a/tests/test_transporter_supply.gd +++ b/tests/test_transporter_supply.gd @@ -43,7 +43,8 @@ const CRAFTER_AT: Vector2 = Vector2(900.0, -300.0) func _total() -> int: var sum: int = game_state.rocks + game_state.fleet_cargo for b in game_state.buildings: - sum += int(b.get("stock", 0)) + if b.type != GameState.BUILDING_MOTHERSHIP: + sum += game_state.building_resource(int(b.id), GameState.RESOURCE_ORE) for t in game_state.transporters: sum += int(t.get("carrying", 0)) # Ore inside a build that has STARTED but not finished (#23). It has left From 0504b24d8d04cb497beb6ae3daad0e21c1cd019f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Fri, 14 Aug 2026 21:44:28 +0200 Subject: [PATCH 4/5] Compose buildings from definitions and capabilities --- BuildController.gd | 218 +++------ Building.gd | 669 +-------------------------- Building.tscn | 2 + BuildingActionProvider.gd | 3 + BuildingActionProvider.gd.uid | 1 + BuildingDefinition.gd | 20 + BuildingDefinition.gd.uid | 1 + BuildingDefinitions.gd | 21 + BuildingDefinitions.gd.uid | 1 + BuildingField.gd | 2 +- BuildingVisualBase.gd | 606 ++++++++++++++++++++++++ BuildingVisualBase.gd.uid | 1 + CommandBuildingDefinition.tres | 20 + CommandBuildingVisual.gd | 9 + CommandBuildingVisual.gd.uid | 1 + CommandBuildingVisual.tscn | 11 + CrafterBuildingDefinition.tres | 19 + CrafterBuildingVisual.gd | 14 + CrafterBuildingVisual.gd.uid | 1 + CrafterBuildingVisual.tscn | 11 + ExtractorBuildingDefinition.tres | 16 + GameState.gd | 39 +- MothershipBuildingDefinition.tres | 21 + MothershipBuildingVisual.gd | 12 + MothershipBuildingVisual.gd.uid | 1 + MothershipBuildingVisual.tscn | 11 + ShipLabBuildingDefinition.tres | 20 + ShipLabBuildingVisual.gd | 12 + ShipLabBuildingVisual.gd.uid | 1 + ShipLabBuildingVisual.tscn | 11 + TransportHubBuildingDefinition.tres | 20 + TransportHubBuildingVisual.gd | 12 + TransportHubBuildingVisual.gd.uid | 1 + TransportHubBuildingVisual.tscn | 11 + WorldMapContent.gd | 46 +- tests/_build_popup_preview.gd | 2 +- tests/_crafter_preview.gd | 6 +- tests/test_animation_continuity.gd | 2 +- tests/test_build_info_popup.gd | 10 +- tests/test_building_composition.gd | 35 ++ tests/test_building_redraw_cost.gd | 8 +- tests/test_crafter_animation.gd | 11 +- tests/test_extractor_building.gd | 6 +- tests/test_mothership_replacement.gd | 8 +- tests/test_skill_tree.gd | 4 +- tests/test_transport_hub_building.gd | 12 +- 46 files changed, 1091 insertions(+), 878 deletions(-) create mode 100644 BuildingActionProvider.gd create mode 100644 BuildingActionProvider.gd.uid create mode 100644 BuildingDefinition.gd create mode 100644 BuildingDefinition.gd.uid create mode 100644 BuildingDefinitions.gd create mode 100644 BuildingDefinitions.gd.uid create mode 100644 BuildingVisualBase.gd create mode 100644 BuildingVisualBase.gd.uid create mode 100644 CommandBuildingDefinition.tres create mode 100644 CommandBuildingVisual.gd create mode 100644 CommandBuildingVisual.gd.uid create mode 100644 CommandBuildingVisual.tscn create mode 100644 CrafterBuildingDefinition.tres create mode 100644 CrafterBuildingVisual.gd create mode 100644 CrafterBuildingVisual.gd.uid create mode 100644 CrafterBuildingVisual.tscn create mode 100644 ExtractorBuildingDefinition.tres create mode 100644 MothershipBuildingDefinition.tres create mode 100644 MothershipBuildingVisual.gd create mode 100644 MothershipBuildingVisual.gd.uid create mode 100644 MothershipBuildingVisual.tscn create mode 100644 ShipLabBuildingDefinition.tres create mode 100644 ShipLabBuildingVisual.gd create mode 100644 ShipLabBuildingVisual.gd.uid create mode 100644 ShipLabBuildingVisual.tscn create mode 100644 TransportHubBuildingDefinition.tres create mode 100644 TransportHubBuildingVisual.gd create mode 100644 TransportHubBuildingVisual.gd.uid create mode 100644 TransportHubBuildingVisual.tscn create mode 100644 tests/test_building_composition.gd diff --git a/BuildController.gd b/BuildController.gd index 114575f..b598703 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -1,5 +1,7 @@ extends Node2D +const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd") + # Space-side building UI. Owns: # - the build menu (place a mothership, ship crafter or command centre) # - the shared ACTION button, which adapts to whatever the fleet is @@ -22,14 +24,8 @@ extends Node2D @onready var building_action_button: IconButton = get_node("../UI/BuildingActionButton") @onready var build_menu: Control = get_node("../UI/BuildMenu") -@onready var build_mothership_button: IconButton = get_node("../UI/BuildMenu/MothershipButton") -@onready var build_crafter_button: IconButton = get_node("../UI/BuildMenu/CrafterButton") -@onready var build_command_button: IconButton = get_node("../UI/BuildMenu/CommandButton") @onready var build_close_button: IconButton = get_node("../UI/BuildMenu/CloseButton") @onready var build_status: Label = get_node("../UI/BuildMenu/StatusLabel") -@onready var build_extractor_button: IconButton = get_node("../UI/BuildMenu/ExtractorButton") -@onready var build_hub_button: IconButton = get_node("../UI/BuildMenu/TransportHubButton") -@onready var build_lab_button: IconButton = get_node("../UI/BuildMenu/ShipLabButton") @onready var lab_menu: Control = get_node("../UI/LabMenu") @onready var lab_vessel_buttons: Array = [ get_node("../UI/LabMenu/VesselA"), get_node("../UI/LabMenu/VesselB"), @@ -78,20 +74,7 @@ extends Node2D get_node("../UI/HangarMenu/FleetShieldLabel"), get_node("../UI/HangarMenu/FleetTransporterLabel"), ] -@onready var build_menu_entries: Array = [ - [GameState.BUILDING_MOTHERSHIP, build_mothership_button, - get_node("../UI/BuildMenu/MothershipLabel"), get_node("../UI/BuildMenu/MothershipCost")], - [GameState.BUILDING_SHIP_CRAFTER, build_crafter_button, - get_node("../UI/BuildMenu/CrafterLabel"), get_node("../UI/BuildMenu/CrafterCost")], - [GameState.BUILDING_COMMAND_CENTER, build_command_button, - get_node("../UI/BuildMenu/CommandLabel"), get_node("../UI/BuildMenu/CommandCost")], - [GameState.BUILDING_EXTRACTOR, build_extractor_button, - get_node("../UI/BuildMenu/ExtractorLabel"), get_node("../UI/BuildMenu/ExtractorCost")], - [GameState.BUILDING_TRANSPORT_HUB, build_hub_button, - get_node("../UI/BuildMenu/TransportHubLabel"), get_node("../UI/BuildMenu/TransportHubCost")], - [GameState.BUILDING_SHIP_LAB, build_lab_button, - get_node("../UI/BuildMenu/ShipLabLabel"), get_node("../UI/BuildMenu/ShipLabCost")], -] +var build_menu_entries: Array = [] # Which vessel the lab screen is showing, and which visible upgrade card is # selected. Each vessel gets its own small set of direct cards; the detail and @@ -148,41 +131,6 @@ var lab_building_id: int = 0 @onready var formation_close_button: IconButton = get_node("../UI/FormationMenu/CloseButton") @onready var formation_reset_button: IconButton = get_node("../UI/FormationMenu/ResetButton") -# What each option is and does, shown before anything is spent. Picking a -# structure is a real commitment - it costs ore, and a mothership placement -# can destroy the one you already have - so no option builds straight from -# the menu; each one explains itself first and waits for a confirmation. -# -# The bodies are kept short on purpose: at the UI's font size the popup holds -# roughly eight lines, and a placement condition or a price warning is -# appended below them and needs some of those lines. -const BUILDING_INFO: Dictionary = { - GameState.BUILDING_MOTHERSHIP: { - "title": "Mothership", - "body": "Fleet home port. A wiped-out fleet rebuilds here, and extractor haulers deliver ore here.", - }, - GameState.BUILDING_SHIP_CRAFTER: { - "title": "Ship Crafter", - "body": "Fly the fleet onto it, then use the action button to build extra vessels.", - }, - GameState.BUILDING_COMMAND_CENTER: { - "title": "Command Center", - "body": "Fly the fleet onto it to drag your fleet into a new formation. Rebuilt fleets keep it.", - }, - GameState.BUILDING_SHIP_LAB: { - "title": "Ship Lab", - "body": "Fly the fleet onto it to upgrade each vessel type, and to unlock the shield vessel.", - }, - GameState.BUILDING_TRANSPORT_HUB: { - "title": "Transport Hub", - "body": "Stations transport vessels. They fetch ore from your mothership and deliver it to whichever building is waiting on it.", - }, - GameState.BUILDING_EXTRACTOR: { - "title": "Extractor", - "body": "Mines a planet and runs a hauler to your mothership. It keeps earning while you are away.", - }, -} - # Which structure the popup is currently offering to build, or "" when it is # only explaining something that cannot be built right now. var pending_build_type: String = "" @@ -210,12 +158,7 @@ var context_actions_expanded: bool = false func _ready() -> void: build_button.pressed.connect(_on_build_pressed) build_close_button.pressed.connect(_close_build_menu) - build_mothership_button.pressed.connect(_on_place_mothership) - build_crafter_button.pressed.connect(_on_place_crafter) - build_command_button.pressed.connect(_on_place_command_center) - build_extractor_button.pressed.connect(_on_place_extractor) - build_hub_button.pressed.connect(_on_place_transport_hub) - build_lab_button.pressed.connect(_on_place_ship_lab) + _configure_build_menu_entries() craft_shield_button.pressed.connect(_on_craft_shield) lab_close_button.pressed.connect(func(): lab_menu.visible = false) lab_buy_button.pressed.connect(_on_lab_buy) @@ -267,6 +210,21 @@ func _ready() -> void: _layout_context_buttons() +func _configure_build_menu_entries() -> void: + build_menu_entries.clear() + for definition in BUILDING_DEFINITIONS.all(): + if definition.menu_button_name == "": + continue + var button: IconButton = get_node("../UI/BuildMenu/%s" % definition.menu_button_name) + var label: Label = get_node("../UI/BuildMenu/%s" % definition.menu_label_name) + var cost_number: NeonNumber = get_node("../UI/BuildMenu/%s" % definition.menu_cost_name) + button.icon = definition.menu_icon + button.pressed.connect(_show_building_info.bind(String(definition.definition_id))) + label.text = definition.display_name + cost_number.amount = definition.cost + build_menu_entries.append([definition.definition_id, button, label, cost_number, definition]) + + # True while a building prompt or panel owns the shared action slot, so the # enter-atmosphere prompt knows to stay out of the way. func is_showing_building_prompt() -> bool: @@ -318,9 +276,7 @@ func _process(delta: float) -> void: if build_menu.visible: # Keep the greyed state live while the menu is open, so flying onto # a planet lights the option up without reopening it. - build_extractor_button.enabled = _planet_under_fleet() != null \ - and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR) - build_extractor_button.queue_redraw() + _refresh_build_option_states() if craft_menu.visible: # Live, so a delivery arriving while the menu is open visibly works @@ -359,10 +315,7 @@ func _process(delta: float) -> void: current_building = pending_building context_actions_expanded = false - # The hangar is where finished vessels are delivered (#23), so it belongs - # at the mothership and nowhere else. - hangar_button.visible = current_building != null \ - and current_building.building_type == GameState.BUILDING_MOTHERSHIP + hangar_button.visible = false # Every building can hold ore, so the transfer button rides alongside the # action button rather than being one of the things the action button # turns into - the two are independent choices at the same structure. @@ -371,7 +324,7 @@ func _process(delta: float) -> void: action_button.visible = current_building != null if current_building != null: var actions: Array = _context_action_buttons() - building_action_button.icon = _action_icon_for(current_building.building_type) + building_action_button.icon = int(current_building.definition.action_icon) building_action_button.queue_redraw() action_button.icon = building_action_button.icon if actions.size() == 1 else IconButton.Icon.ACTION_MENU action_button.queue_redraw() @@ -403,13 +356,20 @@ func _layout_context_buttons() -> void: func _context_action_buttons() -> Array: if current_building == null: return [] - var actions: Array = [transfer_button] - if current_building.building_type in [GameState.BUILDING_MOTHERSHIP, GameState.BUILDING_SHIP_CRAFTER, - GameState.BUILDING_SHIP_LAB, GameState.BUILDING_COMMAND_CENTER]: - actions.push_front(building_action_button) - if current_building.building_type == GameState.BUILDING_MOTHERSHIP: - actions.append(hangar_button) - return actions + var buttons: Array = [] + for action in current_building.available_actions(): + var button: IconButton = _button_for_building_action(action) + if button != null and button not in buttons: + buttons.append(button) + return buttons + + +func _button_for_building_action(action: String) -> IconButton: + if action == "transfer": + return transfer_button + if action == "hangar": + return hangar_button + return building_action_button func _on_context_action_pressed() -> void: @@ -433,20 +393,6 @@ func _collapse_context_actions() -> void: _layout_context_buttons() -func _action_icon_for(type: String) -> int: - match type: - GameState.BUILDING_SHIP_CRAFTER: - return IconButton.Icon.SHIP - GameState.BUILDING_TRANSPORT_HUB: - return IconButton.Icon.TRANSPORT_HUB - GameState.BUILDING_SHIP_LAB: - return IconButton.Icon.SHIP_LAB - GameState.BUILDING_MOTHERSHIP: - return IconButton.Icon.MOTHERSHIP - _: - return IconButton.Icon.COMMAND_CENTER - - func _check_fleet_wipe() -> void: if not fleet.ships.is_empty(): return @@ -465,11 +411,12 @@ func _on_action_pressed() -> void: for bar in resource_bars: bar.building_id = current_building.building_id - if current_building.building_type == GameState.BUILDING_MOTHERSHIP: + var action: String = current_building.primary_action() + if action == "research": skill_menu.visible = true skill_tree.reset_scroll() _refresh_skill_menu() - elif current_building.building_type == GameState.BUILDING_SHIP_CRAFTER: + elif action == "craft": # Remembered explicitly: _process() clears current_building while any # menu is open, so by the next frame the panel would no longer know # which crafter it belongs to. Losing this line makes every button in @@ -478,11 +425,11 @@ func _on_action_pressed() -> void: craft_building_id = current_building.building_id craft_menu.visible = true _refresh_craft_status() - elif current_building.building_type == GameState.BUILDING_SHIP_LAB: + elif action == "lab": lab_building_id = current_building.building_id lab_menu.visible = true _refresh_lab() - elif current_building.building_type == GameState.BUILDING_COMMAND_CENTER: + elif action == "formation": formation_menu.visible = true formation_editor.queue_redraw() @@ -551,8 +498,7 @@ func _refresh_build_status() -> void: # Researched but not usable right here is still shown, greyed: an # extractor needs a planet under the fleet, which is a "not now" rather # than a "not yet". - build_extractor_button.enabled = _planet_under_fleet() != null \ - and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR) + _refresh_build_option_states() # Prices are shown under each option now, so this only needs to say # whether the fleet is somewhere an extractor could go. @@ -562,16 +508,14 @@ func _refresh_build_status() -> void: build_status.text = "" -func _on_place_mothership() -> void: - _show_building_info(GameState.BUILDING_MOTHERSHIP) - - -func _on_place_crafter() -> void: - _show_building_info(GameState.BUILDING_SHIP_CRAFTER) - - -func _on_place_command_center() -> void: - _show_building_info(GameState.BUILDING_COMMAND_CENTER) +func _refresh_build_option_states() -> void: + for entry in build_menu_entries: + var definition: Resource = entry[4] + var enabled: bool = GameState.is_building_unlocked(definition.definition_id) + if definition.placement_mode == "planet": + enabled = enabled and _planet_under_fleet() != null + entry[1].enabled = enabled + entry[1].queue_redraw() func _close_build_menu() -> void: @@ -582,40 +526,17 @@ func _close_build_menu() -> void: build_menu.visible = false -func _on_place_ship_lab() -> void: - _show_building_info(GameState.BUILDING_SHIP_LAB) - - -func _on_place_transport_hub() -> void: - _show_building_info(GameState.BUILDING_TRANSPORT_HUB) - - -func _on_place_extractor() -> void: - # The option stays pressable while greyed, so this is also how a - # not-over-a-planet extractor explains itself. - _show_building_info(GameState.BUILDING_EXTRACTOR) - - func cost_of(type: String) -> int: - match type: - GameState.BUILDING_TRANSPORT_HUB: - return GameState.TRANSPORT_HUB_COST - GameState.BUILDING_SHIP_LAB: - return GameState.SHIP_LAB_COST - GameState.BUILDING_MOTHERSHIP: - return GameState.MOTHERSHIP_COST - GameState.BUILDING_SHIP_CRAFTER: - return GameState.SHIP_CRAFTER_COST - GameState.BUILDING_COMMAND_CENTER: - return GameState.COMMAND_CENTER_COST - _: - return GameState.EXTRACTOR_COST + var definition: Resource = BUILDING_DEFINITIONS.get_definition(type) + return int(definition.cost) if definition != null else 0 func _show_building_info(type: String) -> void: - var info: Dictionary = BUILDING_INFO[type] - var cost: int = cost_of(type) - var lines: Array = [info.body] + var definition: Resource = BUILDING_DEFINITIONS.get_definition(type) + if definition == null: + return + var cost: int = int(definition.cost) + var lines: Array = [definition.description] var can_build: bool = true # Locked structures still list themselves, greyed, so the player can see @@ -626,7 +547,7 @@ func _show_building_info(type: String) -> void: # Anything that would stop this particular placement is spelled out in # the same popup, rather than silently failing after the press. - if can_build and type == GameState.BUILDING_EXTRACTOR: + if can_build and definition.placement_mode == "planet": var planet: Node2D = _planet_under_fleet() if planet == null: lines.append("No planet in reach - fly over one.") @@ -635,15 +556,15 @@ func _show_building_info(type: String) -> void: lines.append("%s already has an extractor." % planet.planet_name) can_build = false else: - lines.append("Will mine %s from %s." % ["gas" if planet.resource_type == "gas" else "ore", planet.planet_name]) - elif can_build and type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership(): + lines.append("Will mine %s from %s." % [GameState.resource_name(planet.resource_type), planet.planet_name]) + elif can_build and definition.unique and GameState.has_building_of_type(type): lines.append("WARNING: only one may exist - this destroys your current one.") if can_build and not GameState.can_afford(cost): lines.append("Not enough rocks - needs %d, you have %d." % [cost, GameState.rocks]) can_build = false - info_title.text = info.title + info_title.text = definition.display_name info_message.text = "\n\n".join(lines) info_cost.amount = cost info_build_button.visible = can_build @@ -668,28 +589,31 @@ func _confirm_build() -> void: # planet an extractor was going to mine. if not GameState.is_building_unlocked(type): return + var definition: Resource = BUILDING_DEFINITIONS.get_definition(type) + if definition == null: + return - if type == GameState.BUILDING_EXTRACTOR: + if definition.placement_mode == "planet": var planet: Node2D = _planet_under_fleet() if planet == null or GameState.has_extractor_at(planet.planet_name): build_status.text = "No planet in reach any more" return - if not GameState.spend(GameState.EXTRACTOR_COST): - build_status.text = "Not enough rocks (need %d, have %d)" % [GameState.EXTRACTOR_COST, GameState.rocks] + if not GameState.spend(definition.cost): + build_status.text = "Not enough rocks (need %d, have %d)" % [definition.cost, GameState.rocks] return GameState.add_extractor(planet.planet_name, planet.position, planet.resource_type) build_menu.visible = false return - if type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership(): - if not GameState.can_afford(GameState.MOTHERSHIP_COST): + if definition.unique and GameState.has_building_of_type(type): + if not GameState.can_afford(definition.cost): build_status.text = "Not enough rocks (need %d, have %d)" % [ - GameState.MOTHERSHIP_COST, GameState.rocks + definition.cost, GameState.rocks ] return # Only now is the old one destroyed - never before the new one is # known to be affordable, or the player would be left with none. - GameState.remove_buildings_of_type(GameState.BUILDING_MOTHERSHIP) + GameState.remove_buildings_of_type(type) building_field.rebuild_all() _try_place(type, cost_of(type)) diff --git a/Building.gd b/Building.gd index d4387d7..b9dea7e 100644 --- a/Building.gd +++ b/Building.gd @@ -1,665 +1,42 @@ extends Node2D -# A player-placed structure in space. Three kinds for now: -# mothership - where a wiped-out fleet is rebuilt -# ship_crafter - fly the fleet onto it to buy additional ships -# command_center - reconfigure the fleet formation -# -# Buildings are stationary and harmless; whether the fleet is "on" one is -# decided by BuildingField comparing the fleet CENTROID against -# interact_radius, the same stable single-number test Planet uses (see -# Planet.gd for why per-ship overlap was a poor fit). -# -# Nothing here rests on ground or vents into an atmosphere - these are -# orbital structures, so they are built out of trusses, radiator fins, -# reactor cores and open cradles rather than walls and roofs. -# -# The crafter and the mothership animate, so they run _process. The command -# centre is drawn once and never redrawn - per-frame redraws of art that -# never changes were a real framerate cost earlier on. +const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd") -@export var building_type: String = "mothership" -# Which GameState.buildings record this node is showing. Set by -# BuildingField when it spawns, so a node can be traced back to the stock -# and production queue that survive the scene swap (#16). +@export var definition_id: String = "mothership" @export var building_id: int = 0 @export var interact_radius: float = 150.0 -const MOTHERSHIP_COLOR: Color = Color(0.45, 0.9, 0.55) -const CRAFTER_COLOR: Color = Color(1.0, 0.72, 0.25) -const COMMAND_COLOR: Color = Color(0.55, 0.6, 1.0) -const TRANSPORT_HUB_COLOR: Color = Color(0.95, 0.55, 0.85) -const SHIP_LAB_COLOR: Color = Color(0.55, 0.95, 0.9) +var definition: Resource +var visual: Node2D -# Ship lab: a test frame with a vessel held in it and instruments sweeping -# over it. Reads as a workshop rather than a factory - the crafter already -# owns "assembly line", so this is measurement, not production. -const LAB_FRAME_W: float = 78.0 -const LAB_FRAME_H: float = 58.0 -const LAB_MOUNT_RADIUS: float = 15.0 -const LAB_SCANNER_ORBIT: float = 96.0 -const LAB_SCAN_SPEED: float = 0.9 - -# Transport hub: an octagonal dock ring with berths around it and a freight -# core in the middle. Deliberately radial-but-flat, so it does not read as a -# small mothership - the mothership is the only thing with swept arms. -const HUB_RING_RADIUS: float = 74.0 -const HUB_DECK_RADIUS: float = 54.0 -const HUB_CORE_RADIUS: float = 22.0 -const HUB_BERTH_COUNT: int = 8 -const HUB_BERTH_LENGTH: float = 20.0 -const HUB_DOCKED_COUNT: int = 3 -const HUB_DOCKED_ORBIT: float = 96.0 -const HUB_SPIN_SPEED: float = 0.22 -# The vessel on the assembly line is drawn in the fleet's own colour, so it -# reads as "one of yours being built" rather than as shipyard machinery. -const HULL_COLOR: Color = Color(0.25, 0.85, 1.0) - -# One assembly cycle: parts are welded on, the finished hull flashes, then -# it clears the cradle and the line starts over. -const CYCLE: float = 3.6 -const ASSEMBLE_END: float = 0.66 # fraction of CYCLE spent welding -const FLASH_END: float = 0.76 # ... then the completion flash - # ... then launch, to 1.0 - -const CORE_CENTRE: Vector2 = Vector2(-58.0, 12.0) -const BAY_CENTRE: Vector2 = Vector2(46.0, 12.0) -const RIB_RADIUS: float = 36.0 -const RAIL_Y: float = -32.0 -const FLASH_RING_RADIUS: float = 20.0 - -# The hull in four pieces, attached in this order. Each carries the point -# the crane welds it on at, which is where the sparks fly from. -const SHIP_PARTS: Array = [ - {"at": 0.04, "weld": Vector2(0, -12), "points": [Vector2(0, -26), Vector2(-8, 4), Vector2(8, 4)]}, - {"at": 0.28, "weld": Vector2(-13, 7), "points": [Vector2(-8, -4), Vector2(-22, 14), Vector2(-8, 10)]}, - {"at": 0.48, "weld": Vector2(13, 7), "points": [Vector2(8, -4), Vector2(22, 14), Vector2(8, 10)]}, - {"at": 0.70, "weld": Vector2(0, 10), "points": [Vector2(-7, 4), Vector2(7, 4), Vector2(5, 14), Vector2(-5, 14)]}, -] - -# The assembly clock, wrapped at CYCLE because the whole build sequence is -# expressed as a fraction of it. -var anim_time: float = 0.0 - -# Free-running seconds for everything that is merely cyclic - reactor pulses, -# orbiting satellites, blinking beacons. These must NOT share anim_time: -# CYCLE is 3.6s, which is not a whole number of periods for any of them, so -# wrapping made every one of them leap at the wrap. The satellites were the -# obvious casualty, snapping back 66 degrees every 3.6 seconds. -# -# Deliberately never wrapped. GDScript floats are 64-bit, so even after hours -# of play the argument to sin() keeps far more precision than a frame needs, -# and any wrap point would reintroduce exactly the discontinuity above. -var phase_time: float = 0.0 - -var _hull_parts: Array[PackedVector2Array] = [] - -# Every moving element is its own NeonPiece, rasterised once when the -# building is created and thereafter only moved, scaled, faded or hidden. -# Nothing here ever calls queue_redraw() again: the building's own _draw() -# lays down the fixed structure a single time, and the animation is pure -# node-property work from then on. -var _core_cell: NeonPiece = null -var _satellites: Node2D = null -var _beacons: Array = [] -var _clamps: NeonPiece = null -var _crane: Node2D = null -var _hoist: NeonPiece = null -var _sparks: Node2D = null -var _hull_holder: Node2D = null -var _hull_pieces: Array = [] -# Rotating holder for the transports stationed at a transport hub (#18). -var _hub_traffic: Node2D = null -# Sweeping instrument booms at a ship lab (#5). -var _lab_scanners: Node2D = null -var _flash_ring: NeonPiece = null -var _wash: NeonPiece = null +var building_type: String: + get: return definition_id + set(value): definition_id = value func _ready() -> void: add_to_group("buildings") z_index = -1 - # The crafter runs an assembly line and the mothership holds a live - # reactor with satellites in orbit, so both animate. The command centre is - # entirely static and needs no per-frame work at all. - var animated: bool = building_type != GameState.BUILDING_COMMAND_CENTER - set_process(animated) - if building_type == GameState.BUILDING_SHIP_CRAFTER: - for part in SHIP_PARTS: - _hull_parts.append(PackedVector2Array(part.points)) - _build_crafter_pieces() - elif building_type == GameState.BUILDING_MOTHERSHIP: - _build_mothership_pieces() - elif building_type == GameState.BUILDING_TRANSPORT_HUB: - _build_hub_pieces() - elif building_type == GameState.BUILDING_SHIP_LAB: - _build_lab_pieces() - queue_redraw() + definition = BUILDING_DEFINITIONS.get_definition(definition_id) + if definition == null or definition.visual_scene == null: + return + visual = definition.visual_scene.instantiate() + get_node("VisualHost").add_child(visual) -func _process(delta: float) -> void: - anim_time = fmod(anim_time + delta, CYCLE) - phase_time += delta - apply_animation() +func available_actions() -> PackedStringArray: + if visual == null: + return PackedStringArray() + var provider: Node = visual.get_node_or_null("ActionProvider") + return provider.actions if provider != null else PackedStringArray() -# Pushes the current clock onto the movable pieces. Normally driven from -# _process; public so a preview or a test can pin the clock and see exactly -# that frame without having to run the animation up to it in real time. -func apply_animation() -> void: - match building_type: - GameState.BUILDING_MOTHERSHIP: - _animate_mothership() - GameState.BUILDING_SHIP_CRAFTER: - _animate_crafter() - GameState.BUILDING_TRANSPORT_HUB: - _animate_transport_hub() - GameState.BUILDING_SHIP_LAB: - _animate_ship_lab() - - -# Where the satellites are, and how open the reactor core is. Both are read -# by _draw and by the test that checks they stay continuous across the -# assembly clock's wrap. -func orbit_angle() -> float: - return phase_time * ORBIT_SPEED - - -func core_pulse() -> float: - return 0.5 + 0.5 * sin(phase_time * PULSE_SPEED) +func primary_action() -> String: + for action in available_actions(): + if action != "transfer" and action != "hangar": + return action + return "" func get_display_name() -> String: - match building_type: - GameState.BUILDING_MOTHERSHIP: - return "Mothership" - GameState.BUILDING_SHIP_CRAFTER: - return "Ship Crafter" - _: - return "Command Center" - - -# How many times this building's geometry has actually been rasterised. The -# whole point of the NeonPiece decomposition is that this stays at 1 for the -# building's lifetime however long it animates, so it is worth being able to -# assert rather than assume. -var draw_count: int = 0 - - -func _draw() -> void: - draw_count += 1 - # Runs exactly once per building: this is the fixed structure. Everything - # that moves is a NeonPiece child, animated through node properties, so - # nothing here ever asks for a redraw again. - match building_type: - GameState.BUILDING_COMMAND_CENTER: - _draw_command_center() - GameState.BUILDING_MOTHERSHIP: - _draw_mothership_static(self) - GameState.BUILDING_TRANSPORT_HUB: - _draw_transport_hub_static(self) - GameState.BUILDING_SHIP_LAB: - _draw_ship_lab_static(self) - _: - _draw_crafter_static(self) - - -# --- shared helpers ------------------------------------------------------- - -func _ring(centre: Vector2, radius: float, sides: int, phase: float = 0.0) -> PackedVector2Array: - var pts: PackedVector2Array = PackedVector2Array() - for i in range(sides): - var a: float = (float(i) / float(sides)) * TAU + phase - pts.append(centre + Vector2(cos(a), sin(a)) * radius) - return pts - - -func _arc(centre: Vector2, radius: float, from_a: float, to_a: float, steps: int) -> PackedVector2Array: - var pts: PackedVector2Array = PackedVector2Array() - for i in range(steps + 1): - var a: float = lerpf(from_a, to_a, float(i) / float(steps)) - pts.append(centre + Vector2(cos(a), sin(a)) * radius) - return pts - - - - -# --- command centre: a tactical plotting hub ------------------------------ -# The mothership is radial-and-swept, the crafter is an asymmetric assembly -# line, so the command centre reads best as the odd one out: rigid, hexagonal, -# a wheel rather than a claw. Six spokes tie a hex rim to a central diamond -# readout, and every spoke ends in a formation port - half of them holding a -# token, half left open - so the structure itself shows fleet slots waiting -# to be filled, which is exactly what the drag-and-drop editor inside does. - -const CMD_HEX_RADIUS: float = 88.0 -const CMD_DECK_RADIUS: float = 66.0 -const CMD_HUB_RADIUS: float = 30.0 -const CMD_SCREEN_RADIUS: float = 14.0 -const CMD_PORT_COUNT: int = 6 -const CMD_PORT_LENGTH: float = 16.0 -const CMD_TOKEN_RADIUS: float = 9.0 -const CMD_STUD_RADIUS: float = 7.0 - - -# --- ship lab ------------------------------------------------------------- - -func _draw_ship_lab_static(target: CanvasItem) -> void: - var c: Color = SHIP_LAB_COLOR - - # Outer test frame, open at the corners so it reads as a rig rather than - # a solid hull. - for sx in [-1.0, 1.0]: - for sy in [-1.0, 1.0]: - var corner: Vector2 = Vector2(sx * LAB_FRAME_W, sy * LAB_FRAME_H) - NeonDraw.draw_glowing_line(target, corner, - corner - Vector2(sx * LAB_FRAME_W * 0.45, 0.0), c, 0.9, 0.42) - NeonDraw.draw_glowing_line(target, corner, - corner - Vector2(0.0, sy * LAB_FRAME_H * 0.5), c, 0.9, 0.42) - - # The vessel under test, held nose-up in the middle - deliberately the - # fleet's own silhouette, so it is obvious what the lab is for. - NeonDraw.draw_glowing_polygon(target, PackedVector2Array([ - Vector2(0, -26), Vector2(13, 12), Vector2(0, 5), Vector2(-13, 12), - ]), HULL_COLOR, 0.95) - - # Mount ring and its bracing. - NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, LAB_MOUNT_RADIUS, 6, PI / 6.0), c, 0.5, - NeonDraw.DEFAULT_CORE, 0.7) - for i in range(4): - var a: float = (float(i) / 4.0) * TAU + PI / 4.0 - var dir: Vector2 = Vector2(cos(a), sin(a)) - NeonDraw.draw_glowing_line(target, dir * LAB_MOUNT_RADIUS, dir * 40.0, c, 0.45, 0.3) - - -func _build_lab_pieces() -> void: - # Two instrument booms sweeping round the vessel. One holder, rasterised - # once, rotated per frame (#10). - _lab_scanners = Node2D.new() - add_child(_lab_scanners) - for i in range(2): - var a: float = float(i) * PI - var at: Vector2 = Vector2(cos(a), sin(a)) * LAB_SCANNER_ORBIT - var piece: NeonPiece = NeonPiece.create(_lab_scanners, at, SHIP_LAB_COLOR) - piece.add_polygon(PackedVector2Array([ - Vector2(-4, -12), Vector2(4, -12), Vector2(4, 12), Vector2(-4, 12), - ])) - piece.add_line(Vector2(0, 0), Vector2(-cos(a), -sin(a)) * 26.0, 0.3, 0.5) - piece.rotation = a - piece.queue_redraw() - - -func _animate_ship_lab() -> void: - _lab_scanners.rotation = phase_time * LAB_SCAN_SPEED - - -# --- transport hub -------------------------------------------------------- - -func _draw_transport_hub_static(target: CanvasItem) -> void: - var c: Color = TRANSPORT_HUB_COLOR - - # Dock ring and the deck nested inside it. - NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_RING_RADIUS, HUB_BERTH_COUNT, PI / 8.0), c, 1.0) - NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_DECK_RADIUS, HUB_BERTH_COUNT, PI / 8.0), c, 0.45, NeonDraw.DEFAULT_CORE, 0.7) - - # Freight core: a squared-off hold rather than a reactor, so it reads as - # storage and not as another power plant. - NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_CORE_RADIUS, 4, PI / 4.0), c, 0.95) - - for i in range(HUB_BERTH_COUNT): - var a: float = (float(i) / HUB_BERTH_COUNT) * TAU + PI / 8.0 - var dir: Vector2 = Vector2(cos(a), sin(a)) - NeonDraw.draw_glowing_line(target, dir * HUB_CORE_RADIUS, dir * HUB_DECK_RADIUS, c, 0.5, 0.32) - # Berth arm standing off the ring, where a transporter parks. - NeonDraw.draw_glowing_line(target, dir * HUB_RING_RADIUS, - dir * (HUB_RING_RADIUS + HUB_BERTH_LENGTH), c, 0.75, 0.38) - - -func _build_hub_pieces() -> void: - # Stationed transports, as NeonPiece children of one holder that turns. - # Per #10 the geometry is rasterised once and only the holder's rotation - # changes, so the traffic costs no redraws at all. - _hub_traffic = Node2D.new() - add_child(_hub_traffic) - for i in range(HUB_DOCKED_COUNT): - var a: float = (float(i) / HUB_DOCKED_COUNT) * TAU - var at: Vector2 = Vector2(cos(a), sin(a)) * HUB_DOCKED_ORBIT - var piece: NeonPiece = NeonPiece.create(_hub_traffic, at, TRANSPORT_HUB_COLOR) - # A stubby freighter: blunt, boxy, nothing like the fleet's darts. - piece.add_polygon(PackedVector2Array([ - Vector2(-9, -5), Vector2(9, -5), Vector2(12, 0), Vector2(9, 5), Vector2(-9, 5), - ])) - piece.rotation = a + PI / 2.0 - piece.queue_redraw() - - -func _animate_transport_hub() -> void: - _hub_traffic.rotation = phase_time * HUB_SPIN_SPEED - - -func _diamond(centre: Vector2, r: float) -> PackedVector2Array: - return PackedVector2Array([ - centre + Vector2(0, -r), centre + Vector2(r, 0), - centre + Vector2(0, r), centre + Vector2(-r, 0), - ]) - - -func _draw_command_center() -> void: - var c: Color = COMMAND_COLOR - var readout: Color = Color(0.82, 0.86, 1.0) - - # The rim: a hex table holding the whole layout together, with a fainter - # deck ring nested just inside it - the same "plating has layers" read the - # mothership gets from its nested reactor rings. - NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, CMD_HEX_RADIUS, CMD_PORT_COUNT, PI / 6.0), c, 1.0) - NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, CMD_DECK_RADIUS, CMD_PORT_COUNT, PI / 6.0), c, 0.5, NeonDraw.DEFAULT_CORE, 0.7) - - for i in range(CMD_PORT_COUNT): - var a: float = (float(i) / CMD_PORT_COUNT) * TAU + PI / 6.0 - var dir: Vector2 = Vector2(cos(a), sin(a)) - # Spoke bracing the hub to the rim. - NeonDraw.draw_glowing_line(self, dir * CMD_HUB_RADIUS, dir * CMD_HEX_RADIUS, c, 0.65, 0.36) - # Socket where the mast plugs into the rim. - NeonDraw.draw_glowing_polygon(self, _ring(dir * CMD_HEX_RADIUS, CMD_STUD_RADIUS, 6, a), c, 0.85) - # Port mast standing proud of the rim; every other one is manned. - var port_tip: Vector2 = dir * (CMD_HEX_RADIUS + CMD_PORT_LENGTH) - NeonDraw.draw_glowing_line(self, dir * CMD_HEX_RADIUS, port_tip, c, 0.8, 0.4) - if i % 2 == 0: - NeonDraw.draw_glowing_polygon(self, _diamond(port_tip, CMD_TOKEN_RADIUS), c, 1.0) - - # Command core: a diamond hub with a lit readout set inside it. - NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_HUB_RADIUS), c, 1.0) - NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_SCREEN_RADIUS), readout, 0.9) - - -# --- mothership: a radial flagship ---------------------------------------- -# Not a big fighter. A mothership is the thing everything else orbits, so it -# is built radially around a reactor core rather than pointed in a direction: -# six swept talons, crystal panels set between them, and satellites circling -# the whole assembly. A forward-facing hull, however large, just reads as -# another ship. - -const ARM_COUNT: int = 6 -const ARM_INNER: float = 42.0 -const ARM_OUTER: float = 106.0 -const ARM_SWEEP: float = 0.72 -const SATELLITE_COUNT: int = 4 -const SATELLITE_ORBIT: float = 126.0 -const ORBIT_SPEED: float = 0.32 -const PULSE_SPEED: float = 2.4 - - -func _talon(base_angle: float) -> PackedVector2Array: - # One swept claw: the outer edge runs from the core to the tip, and the - # inner edge comes back a little behind it, closing to a point so the arm - # tapers instead of ending in a blunt stub. - var steps: int = 7 - var pts: PackedVector2Array = PackedVector2Array() - for i in range(steps + 1): - var f: float = float(i) / float(steps) - var a: float = base_angle + ARM_SWEEP * pow(f, 1.3) - pts.append(Vector2(cos(a), sin(a)) * lerpf(ARM_INNER, ARM_OUTER, f)) - for i in range(steps, -1, -1): - var f: float = float(i) / float(steps) - var trail: float = 0.34 * (1.0 - f) + 0.04 - var a: float = base_angle + ARM_SWEEP * pow(f, 1.3) - trail - pts.append(Vector2(cos(a), sin(a)) * lerpf(ARM_INNER, ARM_OUTER, f)) - return pts - - -func _draw_mothership_static(canvas: CanvasItem) -> void: - var c: Color = MOTHERSHIP_COLOR - for i in range(ARM_COUNT): - var base: float = (float(i) / float(ARM_COUNT)) * TAU - NeonDraw.draw_glowing_polygon(canvas, _talon(base), c, 0.95) - # Crystal panel set into the gap behind each arm. - var pa: float = base - 0.30 - NeonDraw.draw_glowing_polygon(canvas, PackedVector2Array([ - Vector2(cos(pa - 0.11), sin(pa - 0.11)) * 54.0, - Vector2(cos(pa + 0.11), sin(pa + 0.11)) * 54.0, - Vector2(cos(pa + 0.15), sin(pa + 0.15)) * 86.0, - Vector2(cos(pa - 0.15), sin(pa - 0.15)) * 86.0, - ]), c, 0.7) - - # Reactor housing: the nested rings around the cell that breathes. - NeonDraw.draw_glowing_polygon(canvas, _ring(Vector2.ZERO, 38.0, 6, PI / 6.0), c, 1.0) - NeonDraw.draw_glowing_polygon(canvas, _ring(Vector2.ZERO, 24.0, 6, 0.0), c, 0.85) - - -const CORE_CELL_RADIUS: float = 12.0 - - -func _build_mothership_pieces() -> void: - var core_color: Color = Color(0.75, 1.0, 0.85) - - # The breathing cell: drawn at a reference size and then scaled, rather - # than rebuilt at a new radius every frame. - _core_cell = NeonPiece.create(self, Vector2.ZERO, core_color) - _core_cell.add_polygon(_ring(Vector2.ZERO, CORE_CELL_RADIUS, 6, PI / 6.0)) - - # Satellites sit at fixed offsets inside a holder; the orbit is the - # holder's rotation, so four hexagon pairs become one transform update. - _satellites = Node2D.new() - add_child(_satellites) - for i in range(SATELLITE_COUNT): - var a: float = (float(i) / float(SATELLITE_COUNT)) * TAU - var at: Vector2 = Vector2(cos(a), sin(a)) * SATELLITE_ORBIT - var shell: NeonPiece = NeonPiece.create(_satellites, at, MOTHERSHIP_COLOR) - shell.add_polygon(_ring(Vector2.ZERO, 10.0, 6, PI / 6.0), 0.85) - var cell: NeonPiece = NeonPiece.create(_satellites, at, core_color) - cell.add_polygon(_ring(Vector2.ZERO, 4.5, 6, PI / 6.0), 0.8) - - -func _animate_mothership() -> void: - var breath: float = core_pulse() - var radius: float = lerpf(9.0, 15.0, breath) - _core_cell.scale = Vector2.ONE * (radius / CORE_CELL_RADIUS) - _core_cell.modulate.a = lerpf(0.55, 1.0, breath) - _satellites.rotation = orbit_angle() - - -# --- ship crafter: an orbital yard with a hull in the cradle -------------- - -func _draw_crafter_static(canvas: CanvasItem) -> void: - var c: Color = CRAFTER_COLOR - - # Fabricator core housing. This replaces the chimney - nothing vents - # smoke in vacuum. - NeonDraw.draw_glowing_polygon(canvas, _ring(CORE_CENTRE, 26.0, 6, PI / 6.0), c, 1.0) - - # Radiator fins above and below the core, slatted like real ones. - for dir_y in [-1.0, 1.0]: - var fin: PackedVector2Array = PackedVector2Array([ - CORE_CENTRE + Vector2(-16, 30 * dir_y), CORE_CENTRE + Vector2(18, 34 * dir_y), - CORE_CENTRE + Vector2(24, 54 * dir_y), CORE_CENTRE + Vector2(-10, 50 * dir_y), - ]) - NeonDraw.draw_glowing_polygon(canvas, fin, c, 0.75) - for f in [0.35, 0.68]: - NeonDraw.draw_glowing_line(canvas, - fin[0].lerp(fin[3], f), fin[1].lerp(fin[2], f), c, 0.5, 0.28) - - # Truss spine linking the core to the cradle: two rails with X-bracing, - # the standard "this was assembled in orbit" read. - var spine_top: float = 4.0 - var spine_bottom: float = 22.0 - NeonDraw.draw_glowing_line(canvas, Vector2(-32, spine_top), Vector2(14, spine_top), c, 0.9, 0.4) - NeonDraw.draw_glowing_line(canvas, Vector2(-32, spine_bottom), Vector2(14, spine_bottom), c, 0.9, 0.4) - for i in range(3): - var x0: float = -32.0 + float(i) * 15.0 - var x1: float = x0 + 15.0 - NeonDraw.draw_glowing_line(canvas, Vector2(x0, spine_top), Vector2(x1, spine_bottom), c, 0.55, 0.28) - NeonDraw.draw_glowing_line(canvas, Vector2(x0, spine_bottom), Vector2(x1, spine_top), c, 0.55, 0.28) - - # Construction cradle: two open ribs holding the hull, with the crane - # rail overhead. Open at the top so the finished hull can clear it. - NeonDraw.draw_glowing_path(canvas, _arc(BAY_CENTRE, RIB_RADIUS, 2.10, 4.18, 7), c, 1.0, 0.7) - NeonDraw.draw_glowing_path(canvas, _arc(BAY_CENTRE, RIB_RADIUS, -1.04, 1.04, 7), c, 1.0, 0.7) - NeonDraw.draw_glowing_line(canvas, Vector2(10, RAIL_Y), Vector2(82, RAIL_Y), c, 0.9, 0.55) - for side in [-1.0, 1.0]: - NeonDraw.draw_glowing_line(canvas, - BAY_CENTRE + Vector2(RIB_RADIUS * side, -6.0), Vector2(BAY_CENTRE.x + 44.0 * side, RAIL_Y), c, 0.7, 0.3) - - - -func _build_crafter_pieces() -> void: - var c: Color = CRAFTER_COLOR - - # The core's inner cell: fades with the reactor rather than being redrawn - # at a new brightness each frame. - _core_cell = NeonPiece.create(self, CORE_CENTRE, Color(1.0, 0.9, 0.6)) - _core_cell.add_polygon(_ring(Vector2.ZERO, 12.0, 6, PI / 6.0)) - - # Docking beacons, blinking out of step with each other. - for i in range(2): - var lamp: Vector2 = BAY_CENTRE + Vector2(-RIB_RADIUS + 2.0 + float(i) * (RIB_RADIUS * 2.0 - 4.0), 30.0) - var beacon: NeonPiece = NeonPiece.create(self, lamp, Color(1.0, 0.95, 0.75)) - beacon.add_polygon(_ring(Vector2.ZERO, 3.0, 4)) - _beacons.append(beacon) - - # Clamp arms reaching in from each rib. They let go as the hull launches, - # which is a fade rather than a redraw. - _clamps = NeonPiece.create(self, Vector2.ZERO, c) - for side in [-1.0, 1.0]: - var outer: Vector2 = BAY_CENTRE + Vector2(RIB_RADIUS * side, 6.0) - var inner: Vector2 = BAY_CENTRE + Vector2(24.0 * side, 6.0) - _clamps.add_line(outer, inner, 0.35) - _clamps.add_line(inner + Vector2(0, -5), inner + Vector2(0, 5), 0.3) - - # Crane: the head rides the rail, so only its x moves. The hoist is drawn - # one unit long and stretched with scale.y to reach whatever seam is - # being welded - stretching a line needs no redraw, redrawing it would. - _crane = Node2D.new() - add_child(_crane) - var head: NeonPiece = NeonPiece.create(_crane, Vector2.ZERO, c) - head.add_polygon(PackedVector2Array([ - Vector2(-10, -7), Vector2(10, -7), Vector2(6, 6), Vector2(-6, 6), - ])) - head.add_line(Vector2(0, -7), Vector2(0, RAIL_Y - (RAIL_Y + 13.0)), 0.35, 0.8) - _hoist = NeonPiece.create(_crane, Vector2(0, 6), c, 0.5) - _hoist.add_line(Vector2.ZERO, Vector2(0, 1), 0.3) - - # Weld sparks: four spokes on a holder that spins and stretches. The - # jitter is genuinely procedural, but rotating and scaling a holder gives - # the same flicker without re-tessellating the spokes every frame. - _sparks = Node2D.new() - add_child(_sparks) - for i in range(4): - var a: float = float(i) * TAU / 4.0 - var spark: NeonPiece = NeonPiece.create(_sparks, Vector2.ZERO, Color(1.0, 0.95, 0.7)) - spark.add_line(Vector2.ZERO, Vector2(cos(a), sin(a)) * 11.0, 0.3) - - # The hull under construction. Each part is its own piece so attaching one - # is a `visible` flip, and the launch is the holder's position and alpha. - _hull_holder = Node2D.new() - add_child(_hull_holder) - for i in range(_hull_parts.size()): - var part: NeonPiece = NeonPiece.create(_hull_holder, Vector2.ZERO, HULL_COLOR) - part.add_polygon(_hull_parts[i]) - part.visible = false - _hull_pieces.append(part) - - # Engine wash under the hull, and the completion ring. The ring is drawn - # at a reference radius and scaled outwards. - _wash = NeonPiece.create(_hull_holder, Vector2.ZERO, Color(1.0, 0.85, 0.45), 0.0) - for dx in [-4.0, 4.0]: - _wash.add_line(Vector2(dx, 14), Vector2(dx * 1.6, 30.0), 0.45) - - _flash_ring = NeonPiece.create(self, BAY_CENTRE, HULL_COLOR, 0.0) - _flash_ring.add_polygon(_ring(Vector2.ZERO, FLASH_RING_RADIUS, 6, PI / 6.0)) - - -func _animate_crafter() -> void: - var t: float = anim_time / CYCLE - - _core_cell.modulate.a = 0.45 + 0.55 * (0.5 + 0.5 * sin(phase_time * 4.0)) - for i in range(_beacons.size()): - _beacons[i].modulate.a = 0.15 + 0.45 * (0.5 + 0.5 * sin(phase_time * 6.0 + float(i) * PI)) - - if t < ASSEMBLE_END: - _animate_assembly(t / ASSEMBLE_END) - elif t < FLASH_END: - _animate_completion_flash((t - ASSEMBLE_END) / (FLASH_END - ASSEMBLE_END)) - else: - _animate_launch((t - FLASH_END) / (1.0 - FLASH_END)) - - -# How many pieces are on the hull at this point through the assembly phase. -# Parts attach in order, so this is just how many thresholds have passed. -func attached_part_count(p: float) -> int: - var n: int = 0 - for part in SHIP_PARTS: - if p >= float(part.at): - n += 1 - return n - - -func _place_hull(centre: Vector2, alpha: float) -> void: - # The parts are defined around their own origin, so the holder goes - # straight to the point they should be centred on. - _hull_holder.position = centre - _hull_holder.modulate.a = alpha - - -func _animate_assembly(p: float) -> void: - _clamps.visible = true - _clamps.modulate.a = 0.8 - _flash_ring.modulate.a = 0.0 - _wash.modulate.a = 0.0 - _place_hull(BAY_CENTRE, 1.0) - - var next_part: int = attached_part_count(p) - for i in range(_hull_pieces.size()): - var attached: bool = i < next_part - _hull_pieces[i].visible = attached - if attached: - # Freshly attached parts flare, then settle to full brightness. - var age: float = p - float(SHIP_PARTS[i].at) - _hull_pieces[i].modulate.a = 1.0 if age > 0.08 else lerpf(0.3, 1.0, age / 0.08) - - # The crane rides the rail over whichever seam is being worked, and - # retracts once there is nothing left to attach. - var weld: Vector2 = BAY_CENTRE + (SHIP_PARTS[mini(next_part, SHIP_PARTS.size() - 1)].weld as Vector2) - if next_part >= _hull_pieces.size(): - weld.y = RAIL_Y + 16.0 - _crane.position = Vector2(weld.x, RAIL_Y + 13.0) - _hoist.scale.y = maxf(weld.y - (RAIL_Y + 19.0), 1.0) - - var welding: bool = next_part < _hull_pieces.size() - _sparks.visible = welding - if welding: - var flicker: float = 0.45 + 0.55 * absf(sin(phase_time * 34.0)) - _sparks.position = weld - _sparks.rotation = phase_time * 7.0 - _sparks.scale = Vector2.ONE * (0.55 + 0.45 * flicker) - _sparks.modulate.a = flicker - - -func _animate_completion_flash(p: float) -> void: - # Whole hull, clamps springing open, and a ring pushing outwards to mark - # it as finished. - _clamps.modulate.a = 1.0 - p - _sparks.visible = false - _place_hull(BAY_CENTRE, 1.0) - for piece in _hull_pieces: - piece.visible = true - piece.modulate.a = 1.0 - # Retracted, so it does not hang over the finished hull. - _crane.position = Vector2(BAY_CENTRE.x, RAIL_Y + 13.0) - _hoist.scale.y = 1.0 - _flash_ring.modulate.a = 1.0 - p - _flash_ring.scale = Vector2.ONE * (lerpf(18.0, 56.0, p) / FLASH_RING_RADIUS) - - -func _animate_launch(p: float) -> void: - # Clears the cradle and fades out; the next cycle starts from bare ribs. - _clamps.modulate.a = 0.0 - _sparks.visible = false - _flash_ring.modulate.a = 0.0 - var eased: float = p * p - var fade: float = clampf(1.0 - p * 1.15, 0.0, 1.0) - _place_hull(BAY_CENTRE + Vector2(0.0, -124.0 * eased), fade) - for piece in _hull_pieces: - piece.visible = true - piece.modulate.a = 1.0 - # Engine wash, strongest just after it breaks free of the clamps. - _wash.modulate.a = 0.4 + 0.6 * sin(minf(p * 3.2, PI)) + return String(definition.display_name) if definition != null else definition_id.capitalize() diff --git a/Building.tscn b/Building.tscn index 174c302..3929101 100644 --- a/Building.tscn +++ b/Building.tscn @@ -4,3 +4,5 @@ [node name="Building" type="Node2D"] script = ExtResource("1") + +[node name="VisualHost" type="Node2D" parent="."] diff --git a/BuildingActionProvider.gd b/BuildingActionProvider.gd new file mode 100644 index 0000000..1a9dba8 --- /dev/null +++ b/BuildingActionProvider.gd @@ -0,0 +1,3 @@ +extends Node + +@export var actions: PackedStringArray = [] diff --git a/BuildingActionProvider.gd.uid b/BuildingActionProvider.gd.uid new file mode 100644 index 0000000..e5e07f8 --- /dev/null +++ b/BuildingActionProvider.gd.uid @@ -0,0 +1 @@ +uid://c4dah6xluvenl diff --git a/BuildingDefinition.gd b/BuildingDefinition.gd new file mode 100644 index 0000000..fdc990e --- /dev/null +++ b/BuildingDefinition.gd @@ -0,0 +1,20 @@ +class_name BuildingDefinition +extends Resource + +@export var definition_id: String +@export var display_name: String +@export_multiline var description: String +@export var cost: int +@export var action_icon: int +@export var menu_icon: int +@export var visual_scene: PackedScene +@export var capabilities: PackedStringArray = [] +@export var map_color: Color = Color.WHITE +@export var map_sides: int = 4 +@export var map_radius: float = 7.0 +@export var map_phase: float = 0.0 +@export var menu_button_name: String +@export var menu_label_name: String +@export var menu_cost_name: String +@export_enum("space", "planet") var placement_mode: String = "space" +@export var unique: bool = false diff --git a/BuildingDefinition.gd.uid b/BuildingDefinition.gd.uid new file mode 100644 index 0000000..7473bd0 --- /dev/null +++ b/BuildingDefinition.gd.uid @@ -0,0 +1 @@ +uid://d10s0frtxiw62 diff --git a/BuildingDefinitions.gd b/BuildingDefinitions.gd new file mode 100644 index 0000000..f05bf8e --- /dev/null +++ b/BuildingDefinitions.gd @@ -0,0 +1,21 @@ +class_name BuildingDefinitions +extends RefCounted + +const ORDER: PackedStringArray = ["mothership", "ship_crafter", "command_center", "extractor", "transport_hub", "ship_lab"] +const DEFINITIONS: Dictionary = { + "mothership": preload("res://MothershipBuildingDefinition.tres"), + "ship_crafter": preload("res://CrafterBuildingDefinition.tres"), + "command_center": preload("res://CommandBuildingDefinition.tres"), + "extractor": preload("res://ExtractorBuildingDefinition.tres"), + "transport_hub": preload("res://TransportHubBuildingDefinition.tres"), + "ship_lab": preload("res://ShipLabBuildingDefinition.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 diff --git a/BuildingDefinitions.gd.uid b/BuildingDefinitions.gd.uid new file mode 100644 index 0000000..0dead32 --- /dev/null +++ b/BuildingDefinitions.gd.uid @@ -0,0 +1 @@ +uid://cpm0u3gu0exke diff --git a/BuildingField.gd b/BuildingField.gd index 496c26a..09b37bd 100644 --- a/BuildingField.gd +++ b/BuildingField.gd @@ -50,7 +50,7 @@ func get_building_fleet_is_on() -> Node2D: func _spawn(record: Dictionary) -> void: var b: Node2D = building_scene.instantiate() b.position = record.position - b.building_type = record.type + b.definition_id = record.type b.building_id = int(record.get("id", 0)) # Deferred for the same reason as the other field spawners: this can run # from inside a parent's _ready() chain, where a direct add_child() is diff --git a/BuildingVisualBase.gd b/BuildingVisualBase.gd new file mode 100644 index 0000000..cd339b3 --- /dev/null +++ b/BuildingVisualBase.gd @@ -0,0 +1,606 @@ +class_name BuildingVisualBase +extends Node2D + +# A player-placed structure in space. Three kinds for now: +# mothership - where a wiped-out fleet is rebuilt +# ship_crafter - fly the fleet onto it to buy additional ships +# command_center - reconfigure the fleet formation +# +# Buildings are stationary and harmless; whether the fleet is "on" one is +# decided by BuildingField comparing the fleet CENTROID against +# interact_radius, the same stable single-number test Planet uses (see +# Planet.gd for why per-ship overlap was a poor fit). +# +# Nothing here rests on ground or vents into an atmosphere - these are +# orbital structures, so they are built out of trusses, radiator fins, +# reactor cores and open cradles rather than walls and roofs. +# +# The crafter and the mothership animate, so they run _process. The command +# centre is drawn once and never redrawn - per-frame redraws of art that +# never changes were a real framerate cost earlier on. + +const MOTHERSHIP_COLOR: Color = Color(0.45, 0.9, 0.55) +const CRAFTER_COLOR: Color = Color(1.0, 0.72, 0.25) +const COMMAND_COLOR: Color = Color(0.55, 0.6, 1.0) +const TRANSPORT_HUB_COLOR: Color = Color(0.95, 0.55, 0.85) +const SHIP_LAB_COLOR: Color = Color(0.55, 0.95, 0.9) + +# Ship lab: a test frame with a vessel held in it and instruments sweeping +# over it. Reads as a workshop rather than a factory - the crafter already +# owns "assembly line", so this is measurement, not production. +const LAB_FRAME_W: float = 78.0 +const LAB_FRAME_H: float = 58.0 +const LAB_MOUNT_RADIUS: float = 15.0 +const LAB_SCANNER_ORBIT: float = 96.0 +const LAB_SCAN_SPEED: float = 0.9 + +# Transport hub: an octagonal dock ring with berths around it and a freight +# core in the middle. Deliberately radial-but-flat, so it does not read as a +# small mothership - the mothership is the only thing with swept arms. +const HUB_RING_RADIUS: float = 74.0 +const HUB_DECK_RADIUS: float = 54.0 +const HUB_CORE_RADIUS: float = 22.0 +const HUB_BERTH_COUNT: int = 8 +const HUB_BERTH_LENGTH: float = 20.0 +const HUB_DOCKED_COUNT: int = 3 +const HUB_DOCKED_ORBIT: float = 96.0 +const HUB_SPIN_SPEED: float = 0.22 +# The vessel on the assembly line is drawn in the fleet's own colour, so it +# reads as "one of yours being built" rather than as shipyard machinery. +const HULL_COLOR: Color = Color(0.25, 0.85, 1.0) + +# One assembly cycle: parts are welded on, the finished hull flashes, then +# it clears the cradle and the line starts over. +const CYCLE: float = 3.6 +const ASSEMBLE_END: float = 0.66 # fraction of CYCLE spent welding +const FLASH_END: float = 0.76 # ... then the completion flash + # ... then launch, to 1.0 + +const CORE_CENTRE: Vector2 = Vector2(-58.0, 12.0) +const BAY_CENTRE: Vector2 = Vector2(46.0, 12.0) +const RIB_RADIUS: float = 36.0 +const RAIL_Y: float = -32.0 +const FLASH_RING_RADIUS: float = 20.0 + +# The hull in four pieces, attached in this order. Each carries the point +# the crane welds it on at, which is where the sparks fly from. +const SHIP_PARTS: Array = [ + {"at": 0.04, "weld": Vector2(0, -12), "points": [Vector2(0, -26), Vector2(-8, 4), Vector2(8, 4)]}, + {"at": 0.28, "weld": Vector2(-13, 7), "points": [Vector2(-8, -4), Vector2(-22, 14), Vector2(-8, 10)]}, + {"at": 0.48, "weld": Vector2(13, 7), "points": [Vector2(8, -4), Vector2(22, 14), Vector2(8, 10)]}, + {"at": 0.70, "weld": Vector2(0, 10), "points": [Vector2(-7, 4), Vector2(7, 4), Vector2(5, 14), Vector2(-5, 14)]}, +] + +# The assembly clock, wrapped at CYCLE because the whole build sequence is +# expressed as a fraction of it. +var anim_time: float = 0.0 + +# Free-running seconds for everything that is merely cyclic - reactor pulses, +# orbiting satellites, blinking beacons. These must NOT share anim_time: +# CYCLE is 3.6s, which is not a whole number of periods for any of them, so +# wrapping made every one of them leap at the wrap. The satellites were the +# obvious casualty, snapping back 66 degrees every 3.6 seconds. +# +# Deliberately never wrapped. GDScript floats are 64-bit, so even after hours +# of play the argument to sin() keeps far more precision than a frame needs, +# and any wrap point would reintroduce exactly the discontinuity above. +var phase_time: float = 0.0 + +var _hull_parts: Array[PackedVector2Array] = [] + +# Every moving element is its own NeonPiece, rasterised once when the +# building is created and thereafter only moved, scaled, faded or hidden. +# Nothing here ever calls queue_redraw() again: the building's own _draw() +# lays down the fixed structure a single time, and the animation is pure +# node-property work from then on. +var _core_cell: NeonPiece = null +var _satellites: Node2D = null +var _beacons: Array = [] +var _clamps: NeonPiece = null +var _crane: Node2D = null +var _hoist: NeonPiece = null +var _sparks: Node2D = null +var _hull_holder: Node2D = null +var _hull_pieces: Array = [] +# Rotating holder for the transports stationed at a transport hub (#18). +var _hub_traffic: Node2D = null +# Sweeping instrument booms at a ship lab (#5). +var _lab_scanners: Node2D = null +var _flash_ring: NeonPiece = null +var _wash: NeonPiece = null + + +func _process(delta: float) -> void: + anim_time = fmod(anim_time + delta, CYCLE) + phase_time += delta + apply_animation() + + +# Pushes the current clock onto the movable pieces. Normally driven from +# _process; public so a preview or a test can pin the clock and see exactly +# that frame without having to run the animation up to it in real time. +func apply_animation() -> void: + pass + + +# Where the satellites are, and how open the reactor core is. Both are read +# by _draw and by the test that checks they stay continuous across the +# assembly clock's wrap. +func orbit_angle() -> float: + return phase_time * ORBIT_SPEED + + +func core_pulse() -> float: + return 0.5 + 0.5 * sin(phase_time * PULSE_SPEED) + + +# How many times this building's geometry has actually been rasterised. The +# whole point of the NeonPiece decomposition is that this stays at 1 for the +# building's lifetime however long it animates, so it is worth being able to +# assert rather than assume. +var draw_count: int = 0 + + +func register_draw() -> void: + draw_count += 1 + + +# --- shared helpers ------------------------------------------------------- + +func _ring(centre: Vector2, radius: float, sides: int, phase: float = 0.0) -> PackedVector2Array: + var pts: PackedVector2Array = PackedVector2Array() + for i in range(sides): + var a: float = (float(i) / float(sides)) * TAU + phase + pts.append(centre + Vector2(cos(a), sin(a)) * radius) + return pts + + +func _arc(centre: Vector2, radius: float, from_a: float, to_a: float, steps: int) -> PackedVector2Array: + var pts: PackedVector2Array = PackedVector2Array() + for i in range(steps + 1): + var a: float = lerpf(from_a, to_a, float(i) / float(steps)) + pts.append(centre + Vector2(cos(a), sin(a)) * radius) + return pts + + + + +# --- command centre: a tactical plotting hub ------------------------------ +# The mothership is radial-and-swept, the crafter is an asymmetric assembly +# line, so the command centre reads best as the odd one out: rigid, hexagonal, +# a wheel rather than a claw. Six spokes tie a hex rim to a central diamond +# readout, and every spoke ends in a formation port - half of them holding a +# token, half left open - so the structure itself shows fleet slots waiting +# to be filled, which is exactly what the drag-and-drop editor inside does. + +const CMD_HEX_RADIUS: float = 88.0 +const CMD_DECK_RADIUS: float = 66.0 +const CMD_HUB_RADIUS: float = 30.0 +const CMD_SCREEN_RADIUS: float = 14.0 +const CMD_PORT_COUNT: int = 6 +const CMD_PORT_LENGTH: float = 16.0 +const CMD_TOKEN_RADIUS: float = 9.0 +const CMD_STUD_RADIUS: float = 7.0 + + +# --- ship lab ------------------------------------------------------------- + +func _draw_ship_lab_static(target: CanvasItem) -> void: + var c: Color = SHIP_LAB_COLOR + + # Outer test frame, open at the corners so it reads as a rig rather than + # a solid hull. + for sx in [-1.0, 1.0]: + for sy in [-1.0, 1.0]: + var corner: Vector2 = Vector2(sx * LAB_FRAME_W, sy * LAB_FRAME_H) + NeonDraw.draw_glowing_line(target, corner, + corner - Vector2(sx * LAB_FRAME_W * 0.45, 0.0), c, 0.9, 0.42) + NeonDraw.draw_glowing_line(target, corner, + corner - Vector2(0.0, sy * LAB_FRAME_H * 0.5), c, 0.9, 0.42) + + # The vessel under test, held nose-up in the middle - deliberately the + # fleet's own silhouette, so it is obvious what the lab is for. + NeonDraw.draw_glowing_polygon(target, PackedVector2Array([ + Vector2(0, -26), Vector2(13, 12), Vector2(0, 5), Vector2(-13, 12), + ]), HULL_COLOR, 0.95) + + # Mount ring and its bracing. + NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, LAB_MOUNT_RADIUS, 6, PI / 6.0), c, 0.5, + NeonDraw.DEFAULT_CORE, 0.7) + for i in range(4): + var a: float = (float(i) / 4.0) * TAU + PI / 4.0 + var dir: Vector2 = Vector2(cos(a), sin(a)) + NeonDraw.draw_glowing_line(target, dir * LAB_MOUNT_RADIUS, dir * 40.0, c, 0.45, 0.3) + + +func _build_lab_pieces() -> void: + # Two instrument booms sweeping round the vessel. One holder, rasterised + # once, rotated per frame (#10). + _lab_scanners = Node2D.new() + add_child(_lab_scanners) + for i in range(2): + var a: float = float(i) * PI + var at: Vector2 = Vector2(cos(a), sin(a)) * LAB_SCANNER_ORBIT + var piece: NeonPiece = NeonPiece.create(_lab_scanners, at, SHIP_LAB_COLOR) + piece.add_polygon(PackedVector2Array([ + Vector2(-4, -12), Vector2(4, -12), Vector2(4, 12), Vector2(-4, 12), + ])) + piece.add_line(Vector2(0, 0), Vector2(-cos(a), -sin(a)) * 26.0, 0.3, 0.5) + piece.rotation = a + piece.queue_redraw() + + +func _animate_ship_lab() -> void: + _lab_scanners.rotation = phase_time * LAB_SCAN_SPEED + + +# --- transport hub -------------------------------------------------------- + +func _draw_transport_hub_static(target: CanvasItem) -> void: + var c: Color = TRANSPORT_HUB_COLOR + + # Dock ring and the deck nested inside it. + NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_RING_RADIUS, HUB_BERTH_COUNT, PI / 8.0), c, 1.0) + NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_DECK_RADIUS, HUB_BERTH_COUNT, PI / 8.0), c, 0.45, NeonDraw.DEFAULT_CORE, 0.7) + + # Freight core: a squared-off hold rather than a reactor, so it reads as + # storage and not as another power plant. + NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_CORE_RADIUS, 4, PI / 4.0), c, 0.95) + + for i in range(HUB_BERTH_COUNT): + var a: float = (float(i) / HUB_BERTH_COUNT) * TAU + PI / 8.0 + var dir: Vector2 = Vector2(cos(a), sin(a)) + NeonDraw.draw_glowing_line(target, dir * HUB_CORE_RADIUS, dir * HUB_DECK_RADIUS, c, 0.5, 0.32) + # Berth arm standing off the ring, where a transporter parks. + NeonDraw.draw_glowing_line(target, dir * HUB_RING_RADIUS, + dir * (HUB_RING_RADIUS + HUB_BERTH_LENGTH), c, 0.75, 0.38) + + +func _build_hub_pieces() -> void: + # Stationed transports, as NeonPiece children of one holder that turns. + # Per #10 the geometry is rasterised once and only the holder's rotation + # changes, so the traffic costs no redraws at all. + _hub_traffic = Node2D.new() + add_child(_hub_traffic) + for i in range(HUB_DOCKED_COUNT): + var a: float = (float(i) / HUB_DOCKED_COUNT) * TAU + var at: Vector2 = Vector2(cos(a), sin(a)) * HUB_DOCKED_ORBIT + var piece: NeonPiece = NeonPiece.create(_hub_traffic, at, TRANSPORT_HUB_COLOR) + # A stubby freighter: blunt, boxy, nothing like the fleet's darts. + piece.add_polygon(PackedVector2Array([ + Vector2(-9, -5), Vector2(9, -5), Vector2(12, 0), Vector2(9, 5), Vector2(-9, 5), + ])) + piece.rotation = a + PI / 2.0 + piece.queue_redraw() + + +func _animate_transport_hub() -> void: + _hub_traffic.rotation = phase_time * HUB_SPIN_SPEED + + +func _diamond(centre: Vector2, r: float) -> PackedVector2Array: + return PackedVector2Array([ + centre + Vector2(0, -r), centre + Vector2(r, 0), + centre + Vector2(0, r), centre + Vector2(-r, 0), + ]) + + +func _draw_command_center() -> void: + var c: Color = COMMAND_COLOR + var readout: Color = Color(0.82, 0.86, 1.0) + + # The rim: a hex table holding the whole layout together, with a fainter + # deck ring nested just inside it - the same "plating has layers" read the + # mothership gets from its nested reactor rings. + NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, CMD_HEX_RADIUS, CMD_PORT_COUNT, PI / 6.0), c, 1.0) + NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, CMD_DECK_RADIUS, CMD_PORT_COUNT, PI / 6.0), c, 0.5, NeonDraw.DEFAULT_CORE, 0.7) + + for i in range(CMD_PORT_COUNT): + var a: float = (float(i) / CMD_PORT_COUNT) * TAU + PI / 6.0 + var dir: Vector2 = Vector2(cos(a), sin(a)) + # Spoke bracing the hub to the rim. + NeonDraw.draw_glowing_line(self, dir * CMD_HUB_RADIUS, dir * CMD_HEX_RADIUS, c, 0.65, 0.36) + # Socket where the mast plugs into the rim. + NeonDraw.draw_glowing_polygon(self, _ring(dir * CMD_HEX_RADIUS, CMD_STUD_RADIUS, 6, a), c, 0.85) + # Port mast standing proud of the rim; every other one is manned. + var port_tip: Vector2 = dir * (CMD_HEX_RADIUS + CMD_PORT_LENGTH) + NeonDraw.draw_glowing_line(self, dir * CMD_HEX_RADIUS, port_tip, c, 0.8, 0.4) + if i % 2 == 0: + NeonDraw.draw_glowing_polygon(self, _diamond(port_tip, CMD_TOKEN_RADIUS), c, 1.0) + + # Command core: a diamond hub with a lit readout set inside it. + NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_HUB_RADIUS), c, 1.0) + NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_SCREEN_RADIUS), readout, 0.9) + + +# --- mothership: a radial flagship ---------------------------------------- +# Not a big fighter. A mothership is the thing everything else orbits, so it +# is built radially around a reactor core rather than pointed in a direction: +# six swept talons, crystal panels set between them, and satellites circling +# the whole assembly. A forward-facing hull, however large, just reads as +# another ship. + +const ARM_COUNT: int = 6 +const ARM_INNER: float = 42.0 +const ARM_OUTER: float = 106.0 +const ARM_SWEEP: float = 0.72 +const SATELLITE_COUNT: int = 4 +const SATELLITE_ORBIT: float = 126.0 +const ORBIT_SPEED: float = 0.32 +const PULSE_SPEED: float = 2.4 + + +func _talon(base_angle: float) -> PackedVector2Array: + # One swept claw: the outer edge runs from the core to the tip, and the + # inner edge comes back a little behind it, closing to a point so the arm + # tapers instead of ending in a blunt stub. + var steps: int = 7 + var pts: PackedVector2Array = PackedVector2Array() + for i in range(steps + 1): + var f: float = float(i) / float(steps) + var a: float = base_angle + ARM_SWEEP * pow(f, 1.3) + pts.append(Vector2(cos(a), sin(a)) * lerpf(ARM_INNER, ARM_OUTER, f)) + for i in range(steps, -1, -1): + var f: float = float(i) / float(steps) + var trail: float = 0.34 * (1.0 - f) + 0.04 + var a: float = base_angle + ARM_SWEEP * pow(f, 1.3) - trail + pts.append(Vector2(cos(a), sin(a)) * lerpf(ARM_INNER, ARM_OUTER, f)) + return pts + + +func _draw_mothership_static(canvas: CanvasItem) -> void: + var c: Color = MOTHERSHIP_COLOR + for i in range(ARM_COUNT): + var base: float = (float(i) / float(ARM_COUNT)) * TAU + NeonDraw.draw_glowing_polygon(canvas, _talon(base), c, 0.95) + # Crystal panel set into the gap behind each arm. + var pa: float = base - 0.30 + NeonDraw.draw_glowing_polygon(canvas, PackedVector2Array([ + Vector2(cos(pa - 0.11), sin(pa - 0.11)) * 54.0, + Vector2(cos(pa + 0.11), sin(pa + 0.11)) * 54.0, + Vector2(cos(pa + 0.15), sin(pa + 0.15)) * 86.0, + Vector2(cos(pa - 0.15), sin(pa - 0.15)) * 86.0, + ]), c, 0.7) + + # Reactor housing: the nested rings around the cell that breathes. + NeonDraw.draw_glowing_polygon(canvas, _ring(Vector2.ZERO, 38.0, 6, PI / 6.0), c, 1.0) + NeonDraw.draw_glowing_polygon(canvas, _ring(Vector2.ZERO, 24.0, 6, 0.0), c, 0.85) + + +const CORE_CELL_RADIUS: float = 12.0 + + +func _build_mothership_pieces() -> void: + var core_color: Color = Color(0.75, 1.0, 0.85) + + # The breathing cell: drawn at a reference size and then scaled, rather + # than rebuilt at a new radius every frame. + _core_cell = NeonPiece.create(self, Vector2.ZERO, core_color) + _core_cell.add_polygon(_ring(Vector2.ZERO, CORE_CELL_RADIUS, 6, PI / 6.0)) + + # Satellites sit at fixed offsets inside a holder; the orbit is the + # holder's rotation, so four hexagon pairs become one transform update. + _satellites = Node2D.new() + add_child(_satellites) + for i in range(SATELLITE_COUNT): + var a: float = (float(i) / float(SATELLITE_COUNT)) * TAU + var at: Vector2 = Vector2(cos(a), sin(a)) * SATELLITE_ORBIT + var shell: NeonPiece = NeonPiece.create(_satellites, at, MOTHERSHIP_COLOR) + shell.add_polygon(_ring(Vector2.ZERO, 10.0, 6, PI / 6.0), 0.85) + var cell: NeonPiece = NeonPiece.create(_satellites, at, core_color) + cell.add_polygon(_ring(Vector2.ZERO, 4.5, 6, PI / 6.0), 0.8) + + +func _animate_mothership() -> void: + var breath: float = core_pulse() + var radius: float = lerpf(9.0, 15.0, breath) + _core_cell.scale = Vector2.ONE * (radius / CORE_CELL_RADIUS) + _core_cell.modulate.a = lerpf(0.55, 1.0, breath) + _satellites.rotation = orbit_angle() + + +# --- ship crafter: an orbital yard with a hull in the cradle -------------- + +func _draw_crafter_static(canvas: CanvasItem) -> void: + var c: Color = CRAFTER_COLOR + + # Fabricator core housing. This replaces the chimney - nothing vents + # smoke in vacuum. + NeonDraw.draw_glowing_polygon(canvas, _ring(CORE_CENTRE, 26.0, 6, PI / 6.0), c, 1.0) + + # Radiator fins above and below the core, slatted like real ones. + for dir_y in [-1.0, 1.0]: + var fin: PackedVector2Array = PackedVector2Array([ + CORE_CENTRE + Vector2(-16, 30 * dir_y), CORE_CENTRE + Vector2(18, 34 * dir_y), + CORE_CENTRE + Vector2(24, 54 * dir_y), CORE_CENTRE + Vector2(-10, 50 * dir_y), + ]) + NeonDraw.draw_glowing_polygon(canvas, fin, c, 0.75) + for f in [0.35, 0.68]: + NeonDraw.draw_glowing_line(canvas, + fin[0].lerp(fin[3], f), fin[1].lerp(fin[2], f), c, 0.5, 0.28) + + # Truss spine linking the core to the cradle: two rails with X-bracing, + # the standard "this was assembled in orbit" read. + var spine_top: float = 4.0 + var spine_bottom: float = 22.0 + NeonDraw.draw_glowing_line(canvas, Vector2(-32, spine_top), Vector2(14, spine_top), c, 0.9, 0.4) + NeonDraw.draw_glowing_line(canvas, Vector2(-32, spine_bottom), Vector2(14, spine_bottom), c, 0.9, 0.4) + for i in range(3): + var x0: float = -32.0 + float(i) * 15.0 + var x1: float = x0 + 15.0 + NeonDraw.draw_glowing_line(canvas, Vector2(x0, spine_top), Vector2(x1, spine_bottom), c, 0.55, 0.28) + NeonDraw.draw_glowing_line(canvas, Vector2(x0, spine_bottom), Vector2(x1, spine_top), c, 0.55, 0.28) + + # Construction cradle: two open ribs holding the hull, with the crane + # rail overhead. Open at the top so the finished hull can clear it. + NeonDraw.draw_glowing_path(canvas, _arc(BAY_CENTRE, RIB_RADIUS, 2.10, 4.18, 7), c, 1.0, 0.7) + NeonDraw.draw_glowing_path(canvas, _arc(BAY_CENTRE, RIB_RADIUS, -1.04, 1.04, 7), c, 1.0, 0.7) + NeonDraw.draw_glowing_line(canvas, Vector2(10, RAIL_Y), Vector2(82, RAIL_Y), c, 0.9, 0.55) + for side in [-1.0, 1.0]: + NeonDraw.draw_glowing_line(canvas, + BAY_CENTRE + Vector2(RIB_RADIUS * side, -6.0), Vector2(BAY_CENTRE.x + 44.0 * side, RAIL_Y), c, 0.7, 0.3) + + + +func _build_crafter_pieces() -> void: + var c: Color = CRAFTER_COLOR + + # The core's inner cell: fades with the reactor rather than being redrawn + # at a new brightness each frame. + _core_cell = NeonPiece.create(self, CORE_CENTRE, Color(1.0, 0.9, 0.6)) + _core_cell.add_polygon(_ring(Vector2.ZERO, 12.0, 6, PI / 6.0)) + + # Docking beacons, blinking out of step with each other. + for i in range(2): + var lamp: Vector2 = BAY_CENTRE + Vector2(-RIB_RADIUS + 2.0 + float(i) * (RIB_RADIUS * 2.0 - 4.0), 30.0) + var beacon: NeonPiece = NeonPiece.create(self, lamp, Color(1.0, 0.95, 0.75)) + beacon.add_polygon(_ring(Vector2.ZERO, 3.0, 4)) + _beacons.append(beacon) + + # Clamp arms reaching in from each rib. They let go as the hull launches, + # which is a fade rather than a redraw. + _clamps = NeonPiece.create(self, Vector2.ZERO, c) + for side in [-1.0, 1.0]: + var outer: Vector2 = BAY_CENTRE + Vector2(RIB_RADIUS * side, 6.0) + var inner: Vector2 = BAY_CENTRE + Vector2(24.0 * side, 6.0) + _clamps.add_line(outer, inner, 0.35) + _clamps.add_line(inner + Vector2(0, -5), inner + Vector2(0, 5), 0.3) + + # Crane: the head rides the rail, so only its x moves. The hoist is drawn + # one unit long and stretched with scale.y to reach whatever seam is + # being welded - stretching a line needs no redraw, redrawing it would. + _crane = Node2D.new() + add_child(_crane) + var head: NeonPiece = NeonPiece.create(_crane, Vector2.ZERO, c) + head.add_polygon(PackedVector2Array([ + Vector2(-10, -7), Vector2(10, -7), Vector2(6, 6), Vector2(-6, 6), + ])) + head.add_line(Vector2(0, -7), Vector2(0, RAIL_Y - (RAIL_Y + 13.0)), 0.35, 0.8) + _hoist = NeonPiece.create(_crane, Vector2(0, 6), c, 0.5) + _hoist.add_line(Vector2.ZERO, Vector2(0, 1), 0.3) + + # Weld sparks: four spokes on a holder that spins and stretches. The + # jitter is genuinely procedural, but rotating and scaling a holder gives + # the same flicker without re-tessellating the spokes every frame. + _sparks = Node2D.new() + add_child(_sparks) + for i in range(4): + var a: float = float(i) * TAU / 4.0 + var spark: NeonPiece = NeonPiece.create(_sparks, Vector2.ZERO, Color(1.0, 0.95, 0.7)) + spark.add_line(Vector2.ZERO, Vector2(cos(a), sin(a)) * 11.0, 0.3) + + # The hull under construction. Each part is its own piece so attaching one + # is a `visible` flip, and the launch is the holder's position and alpha. + _hull_holder = Node2D.new() + add_child(_hull_holder) + for i in range(_hull_parts.size()): + var part: NeonPiece = NeonPiece.create(_hull_holder, Vector2.ZERO, HULL_COLOR) + part.add_polygon(_hull_parts[i]) + part.visible = false + _hull_pieces.append(part) + + # Engine wash under the hull, and the completion ring. The ring is drawn + # at a reference radius and scaled outwards. + _wash = NeonPiece.create(_hull_holder, Vector2.ZERO, Color(1.0, 0.85, 0.45), 0.0) + for dx in [-4.0, 4.0]: + _wash.add_line(Vector2(dx, 14), Vector2(dx * 1.6, 30.0), 0.45) + + _flash_ring = NeonPiece.create(self, BAY_CENTRE, HULL_COLOR, 0.0) + _flash_ring.add_polygon(_ring(Vector2.ZERO, FLASH_RING_RADIUS, 6, PI / 6.0)) + + +func _animate_crafter() -> void: + var t: float = anim_time / CYCLE + + _core_cell.modulate.a = 0.45 + 0.55 * (0.5 + 0.5 * sin(phase_time * 4.0)) + for i in range(_beacons.size()): + _beacons[i].modulate.a = 0.15 + 0.45 * (0.5 + 0.5 * sin(phase_time * 6.0 + float(i) * PI)) + + if t < ASSEMBLE_END: + _animate_assembly(t / ASSEMBLE_END) + elif t < FLASH_END: + _animate_completion_flash((t - ASSEMBLE_END) / (FLASH_END - ASSEMBLE_END)) + else: + _animate_launch((t - FLASH_END) / (1.0 - FLASH_END)) + + +# How many pieces are on the hull at this point through the assembly phase. +# Parts attach in order, so this is just how many thresholds have passed. +func attached_part_count(p: float) -> int: + var n: int = 0 + for part in SHIP_PARTS: + if p >= float(part.at): + n += 1 + return n + + +func _place_hull(centre: Vector2, alpha: float) -> void: + # The parts are defined around their own origin, so the holder goes + # straight to the point they should be centred on. + _hull_holder.position = centre + _hull_holder.modulate.a = alpha + + +func _animate_assembly(p: float) -> void: + _clamps.visible = true + _clamps.modulate.a = 0.8 + _flash_ring.modulate.a = 0.0 + _wash.modulate.a = 0.0 + _place_hull(BAY_CENTRE, 1.0) + + var next_part: int = attached_part_count(p) + for i in range(_hull_pieces.size()): + var attached: bool = i < next_part + _hull_pieces[i].visible = attached + if attached: + # Freshly attached parts flare, then settle to full brightness. + var age: float = p - float(SHIP_PARTS[i].at) + _hull_pieces[i].modulate.a = 1.0 if age > 0.08 else lerpf(0.3, 1.0, age / 0.08) + + # The crane rides the rail over whichever seam is being worked, and + # retracts once there is nothing left to attach. + var weld: Vector2 = BAY_CENTRE + (SHIP_PARTS[mini(next_part, SHIP_PARTS.size() - 1)].weld as Vector2) + if next_part >= _hull_pieces.size(): + weld.y = RAIL_Y + 16.0 + _crane.position = Vector2(weld.x, RAIL_Y + 13.0) + _hoist.scale.y = maxf(weld.y - (RAIL_Y + 19.0), 1.0) + + var welding: bool = next_part < _hull_pieces.size() + _sparks.visible = welding + if welding: + var flicker: float = 0.45 + 0.55 * absf(sin(phase_time * 34.0)) + _sparks.position = weld + _sparks.rotation = phase_time * 7.0 + _sparks.scale = Vector2.ONE * (0.55 + 0.45 * flicker) + _sparks.modulate.a = flicker + + +func _animate_completion_flash(p: float) -> void: + # Whole hull, clamps springing open, and a ring pushing outwards to mark + # it as finished. + _clamps.modulate.a = 1.0 - p + _sparks.visible = false + _place_hull(BAY_CENTRE, 1.0) + for piece in _hull_pieces: + piece.visible = true + piece.modulate.a = 1.0 + # Retracted, so it does not hang over the finished hull. + _crane.position = Vector2(BAY_CENTRE.x, RAIL_Y + 13.0) + _hoist.scale.y = 1.0 + _flash_ring.modulate.a = 1.0 - p + _flash_ring.scale = Vector2.ONE * (lerpf(18.0, 56.0, p) / FLASH_RING_RADIUS) + + +func _animate_launch(p: float) -> void: + # Clears the cradle and fades out; the next cycle starts from bare ribs. + _clamps.modulate.a = 0.0 + _sparks.visible = false + _flash_ring.modulate.a = 0.0 + var eased: float = p * p + var fade: float = clampf(1.0 - p * 1.15, 0.0, 1.0) + _place_hull(BAY_CENTRE + Vector2(0.0, -124.0 * eased), fade) + for piece in _hull_pieces: + piece.visible = true + piece.modulate.a = 1.0 + # Engine wash, strongest just after it breaks free of the clamps. + _wash.modulate.a = 0.4 + 0.6 * sin(minf(p * 3.2, PI)) diff --git a/BuildingVisualBase.gd.uid b/BuildingVisualBase.gd.uid new file mode 100644 index 0000000..8827724 --- /dev/null +++ b/BuildingVisualBase.gd.uid @@ -0,0 +1 @@ +uid://p7vuv1xsksc diff --git a/CommandBuildingDefinition.tres b/CommandBuildingDefinition.tres new file mode 100644 index 0000000..727a6f4 --- /dev/null +++ b/CommandBuildingDefinition.tres @@ -0,0 +1,20 @@ +[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3] +[ext_resource type="Script" path="res://BuildingDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://CommandBuildingVisual.tscn" id="2"] +[resource] +script = ExtResource("1") +definition_id = "command_center" +display_name = "Command Center" +description = "Fly the fleet onto it to drag your fleet into a new formation. Rebuilt fleets keep it." +cost = 15 +action_icon = 10 +menu_icon = 10 +visual_scene = ExtResource("2") +capabilities = PackedStringArray("formation") +map_color = Color(0.55, 0.6, 1, 1) +map_sides = 4 +map_radius = 6.0 +map_phase = 0.785398 +menu_button_name = "CommandButton" +menu_label_name = "CommandLabel" +menu_cost_name = "CommandCost" diff --git a/CommandBuildingVisual.gd b/CommandBuildingVisual.gd new file mode 100644 index 0000000..713b407 --- /dev/null +++ b/CommandBuildingVisual.gd @@ -0,0 +1,9 @@ +extends "res://BuildingVisualBase.gd" + +func _ready() -> void: + set_process(false) + queue_redraw() + +func _draw() -> void: + register_draw() + _draw_command_center() diff --git a/CommandBuildingVisual.gd.uid b/CommandBuildingVisual.gd.uid new file mode 100644 index 0000000..9a270e2 --- /dev/null +++ b/CommandBuildingVisual.gd.uid @@ -0,0 +1 @@ +uid://bpb5pq8g56ywn diff --git a/CommandBuildingVisual.tscn b/CommandBuildingVisual.tscn new file mode 100644 index 0000000..048b3f3 --- /dev/null +++ b/CommandBuildingVisual.tscn @@ -0,0 +1,11 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://CommandBuildingVisual.gd" id="1"] +[ext_resource type="Script" path="res://BuildingActionProvider.gd" id="2"] + +[node name="Visual" type="Node2D"] +script = ExtResource("1") + +[node name="ActionProvider" type="Node" parent="."] +script = ExtResource("2") +actions = PackedStringArray("formation", "transfer") diff --git a/CrafterBuildingDefinition.tres b/CrafterBuildingDefinition.tres new file mode 100644 index 0000000..2a3f91f --- /dev/null +++ b/CrafterBuildingDefinition.tres @@ -0,0 +1,19 @@ +[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3] +[ext_resource type="Script" path="res://BuildingDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://CrafterBuildingVisual.tscn" id="2"] +[resource] +script = ExtResource("1") +definition_id = "ship_crafter" +display_name = "Ship Crafter" +description = "Fly the fleet onto it, then use the action button to build extra vessels." +cost = 12 +action_icon = 2 +menu_icon = 9 +visual_scene = ExtResource("2") +capabilities = PackedStringArray("production") +map_color = Color(1, 0.72, 0.25, 1) +map_sides = 4 +map_radius = 7.0 +menu_button_name = "CrafterButton" +menu_label_name = "CrafterLabel" +menu_cost_name = "CrafterCost" diff --git a/CrafterBuildingVisual.gd b/CrafterBuildingVisual.gd new file mode 100644 index 0000000..ddd41a8 --- /dev/null +++ b/CrafterBuildingVisual.gd @@ -0,0 +1,14 @@ +extends "res://BuildingVisualBase.gd" + +func _ready() -> void: + for part in SHIP_PARTS: + _hull_parts.append(PackedVector2Array(part.points)) + _build_crafter_pieces() + queue_redraw() + +func apply_animation() -> void: + _animate_crafter() + +func _draw() -> void: + register_draw() + _draw_crafter_static(self) diff --git a/CrafterBuildingVisual.gd.uid b/CrafterBuildingVisual.gd.uid new file mode 100644 index 0000000..05576b0 --- /dev/null +++ b/CrafterBuildingVisual.gd.uid @@ -0,0 +1 @@ +uid://dbwebd1vf7biy diff --git a/CrafterBuildingVisual.tscn b/CrafterBuildingVisual.tscn new file mode 100644 index 0000000..c3bb670 --- /dev/null +++ b/CrafterBuildingVisual.tscn @@ -0,0 +1,11 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://CrafterBuildingVisual.gd" id="1"] +[ext_resource type="Script" path="res://BuildingActionProvider.gd" id="2"] + +[node name="Visual" type="Node2D"] +script = ExtResource("1") + +[node name="ActionProvider" type="Node" parent="."] +script = ExtResource("2") +actions = PackedStringArray("craft", "transfer") diff --git a/ExtractorBuildingDefinition.tres b/ExtractorBuildingDefinition.tres new file mode 100644 index 0000000..0584758 --- /dev/null +++ b/ExtractorBuildingDefinition.tres @@ -0,0 +1,16 @@ +[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=2 format=3] +[ext_resource type="Script" path="res://BuildingDefinition.gd" id="1"] +[resource] +script = ExtResource("1") +definition_id = "extractor" +display_name = "Extractor" +description = "Mines resources into local storage for transport vessels to collect. It keeps extracting while you are away." +cost = 25 +action_icon = 11 +menu_icon = 11 +capabilities = PackedStringArray("extraction") +map_color = Color(1, 0.85, 0.3, 1) +menu_button_name = "ExtractorButton" +menu_label_name = "ExtractorLabel" +menu_cost_name = "ExtractorCost" +placement_mode = "planet" diff --git a/GameState.gd b/GameState.gd index 500ec5d..56f773f 100644 --- a/GameState.gd +++ b/GameState.gd @@ -2,6 +2,7 @@ extends Node const INVENTORY = preload("res://Inventory.gd") const RESOURCE_DEFINITIONS = preload("res://ResourceDefinitions.gd") +const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd") # Autoload singleton - the one thing that survives change_scene_to_file() @@ -104,7 +105,7 @@ func building_inventory(id: int) -> RefCounted: var building: Dictionary = find_building(id) if building.is_empty(): return null - if building.type == BUILDING_MOTHERSHIP: + if building_has_capability(building, "bank"): return bank_inventory return _inventory_for_record(building) @@ -313,11 +314,6 @@ func unlock(id: String) -> bool: func is_building_unlocked(type: String) -> bool: - # The mothership is always buildable: it is where research happens, so - # locking it would leave a player with no mothership unable to ever get - # one. - if type == BUILDING_MOTHERSHIP: - return true for node in SKILL_TREE: if node.get("unlocks", "") == type: return is_unlocked(node.id) @@ -392,6 +388,13 @@ func find_building(id: int) -> Dictionary: return {} +func building_has_capability(building: Dictionary, capability: String) -> bool: + if building.is_empty(): + return false + var definition: Resource = BUILDING_DEFINITIONS.get_definition(String(building.type)) + return definition != null and capability in definition.capabilities + + # --- vessel types and their upgrades (#5) --------------------------------- # The three things that can be flying around. The first and last double as @@ -494,7 +497,7 @@ signal vessel_upgraded(upgrade_id: String) func has_ship_lab() -> bool: for b in buildings: - if b.type == BUILDING_SHIP_LAB: + if building_has_capability(b, "vessel_research"): return true return false @@ -627,9 +630,8 @@ func product_cost(product: String) -> int: func can_produce(type: String) -> bool: - # Only the ship crafter builds anything. The hub STATIONS transporters, - # it does not make them (#12). - return type == BUILDING_SHIP_CRAFTER + var definition: Resource = BUILDING_DEFINITIONS.get_definition(type) + return definition != null and "production" in definition.capabilities func building_stock(id: int) -> int: @@ -777,7 +779,7 @@ func _advance_production(delta: float) -> void: func queue_lab_research(lab_id: int, upgrade_id: String) -> bool: var lab: Dictionary = find_building(lab_id) - if lab.is_empty() or lab.type != BUILDING_SHIP_LAB or upgrade_id == "": + if not building_has_capability(lab, "vessel_research") or upgrade_id == "": return false var orders: Array = lab.get("research_orders", []) if not orders.is_empty() or (upgrade_id != UNLOCK_SHIELD_VESSEL and upgrade_maxed(upgrade_id)): @@ -795,7 +797,7 @@ func lab_research_order(lab_id: int) -> Dictionary: func _advance_lab_research(delta: float) -> void: for lab in buildings: - if lab.type != BUILDING_SHIP_LAB: + if not building_has_capability(lab, "vessel_research"): continue var orders: Array = lab.get("research_orders", []) if orders.is_empty(): @@ -841,6 +843,13 @@ func has_mothership() -> bool: return get_mothership_position() != null +func has_building_of_type(type: String) -> bool: + for building in buildings: + if building.type == type: + return true + return false + + func remove_buildings_of_type(type: String) -> int: var kept: Array = [] var removed: int = 0 @@ -857,7 +866,7 @@ func get_mothership_position() -> Variant: # Where a wiped-out fleet gets rebuilt. Returns null when no mothership # has been built yet, so the caller can fall back to the world origin. for b in buildings: - if b.type == BUILDING_MOTHERSHIP: + if building_has_capability(b, "bank"): return b.position return null @@ -1017,7 +1026,7 @@ func hub_capacity() -> int: func hub_count() -> int: var n: int = 0 for b in buildings: - if b.type == BUILDING_TRANSPORT_HUB: + if building_has_capability(b, "transport_hub"): n += 1 return n @@ -1056,7 +1065,7 @@ func nearest_hub_to(at: Vector2) -> Dictionary: var best: Dictionary = {} var best_d: float = INF for b in buildings: - if b.type != BUILDING_TRANSPORT_HUB: + if not building_has_capability(b, "transport_hub"): continue var d: float = at.distance_squared_to(b.position) if d < best_d: diff --git a/MothershipBuildingDefinition.tres b/MothershipBuildingDefinition.tres new file mode 100644 index 0000000..a032777 --- /dev/null +++ b/MothershipBuildingDefinition.tres @@ -0,0 +1,21 @@ +[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3] +[ext_resource type="Script" path="res://BuildingDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://MothershipBuildingVisual.tscn" id="2"] +[resource] +script = ExtResource("1") +definition_id = "mothership" +display_name = "Mothership" +description = "Fleet home port. A wiped-out fleet rebuilds here, and extractor transports deliver resources here." +cost = 20 +action_icon = 8 +menu_icon = 8 +visual_scene = ExtResource("2") +capabilities = PackedStringArray("bank", "research", "hangar") +map_color = Color(0.45, 0.9, 0.55, 1) +map_sides = 6 +map_radius = 8.0 +map_phase = 0.523599 +menu_button_name = "MothershipButton" +menu_label_name = "MothershipLabel" +menu_cost_name = "MothershipCost" +unique = true diff --git a/MothershipBuildingVisual.gd b/MothershipBuildingVisual.gd new file mode 100644 index 0000000..ba2f617 --- /dev/null +++ b/MothershipBuildingVisual.gd @@ -0,0 +1,12 @@ +extends "res://BuildingVisualBase.gd" + +func _ready() -> void: + _build_mothership_pieces() + queue_redraw() + +func apply_animation() -> void: + _animate_mothership() + +func _draw() -> void: + register_draw() + _draw_mothership_static(self) diff --git a/MothershipBuildingVisual.gd.uid b/MothershipBuildingVisual.gd.uid new file mode 100644 index 0000000..7ac3669 --- /dev/null +++ b/MothershipBuildingVisual.gd.uid @@ -0,0 +1 @@ +uid://m43bwdwhqgy4 diff --git a/MothershipBuildingVisual.tscn b/MothershipBuildingVisual.tscn new file mode 100644 index 0000000..0cc3615 --- /dev/null +++ b/MothershipBuildingVisual.tscn @@ -0,0 +1,11 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://MothershipBuildingVisual.gd" id="1"] +[ext_resource type="Script" path="res://BuildingActionProvider.gd" id="2"] + +[node name="Visual" type="Node2D"] +script = ExtResource("1") + +[node name="ActionProvider" type="Node" parent="."] +script = ExtResource("2") +actions = PackedStringArray("research", "transfer", "hangar") diff --git a/ShipLabBuildingDefinition.tres b/ShipLabBuildingDefinition.tres new file mode 100644 index 0000000..0823f52 --- /dev/null +++ b/ShipLabBuildingDefinition.tres @@ -0,0 +1,20 @@ +[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3] +[ext_resource type="Script" path="res://BuildingDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://ShipLabBuildingVisual.tscn" id="2"] +[resource] +script = ExtResource("1") +definition_id = "ship_lab" +display_name = "Ship Lab" +description = "Fly the fleet onto it to upgrade each vessel type and unlock new vessel designs." +cost = 35 +action_icon = 13 +menu_icon = 13 +visual_scene = ExtResource("2") +capabilities = PackedStringArray("vessel_research") +map_color = Color(0.55, 0.95, 0.9, 1) +map_sides = 3 +map_radius = 7.5 +map_phase = -1.570796 +menu_button_name = "ShipLabButton" +menu_label_name = "ShipLabLabel" +menu_cost_name = "ShipLabCost" diff --git a/ShipLabBuildingVisual.gd b/ShipLabBuildingVisual.gd new file mode 100644 index 0000000..9655162 --- /dev/null +++ b/ShipLabBuildingVisual.gd @@ -0,0 +1,12 @@ +extends "res://BuildingVisualBase.gd" + +func _ready() -> void: + _build_lab_pieces() + queue_redraw() + +func apply_animation() -> void: + _animate_ship_lab() + +func _draw() -> void: + register_draw() + _draw_ship_lab_static(self) diff --git a/ShipLabBuildingVisual.gd.uid b/ShipLabBuildingVisual.gd.uid new file mode 100644 index 0000000..fbb5203 --- /dev/null +++ b/ShipLabBuildingVisual.gd.uid @@ -0,0 +1 @@ +uid://b0p7p0fs0th0b diff --git a/ShipLabBuildingVisual.tscn b/ShipLabBuildingVisual.tscn new file mode 100644 index 0000000..e71fdf2 --- /dev/null +++ b/ShipLabBuildingVisual.tscn @@ -0,0 +1,11 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://ShipLabBuildingVisual.gd" id="1"] +[ext_resource type="Script" path="res://BuildingActionProvider.gd" id="2"] + +[node name="Visual" type="Node2D"] +script = ExtResource("1") + +[node name="ActionProvider" type="Node" parent="."] +script = ExtResource("2") +actions = PackedStringArray("lab", "transfer") diff --git a/TransportHubBuildingDefinition.tres b/TransportHubBuildingDefinition.tres new file mode 100644 index 0000000..59ad15d --- /dev/null +++ b/TransportHubBuildingDefinition.tres @@ -0,0 +1,20 @@ +[gd_resource type="Resource" script_class="BuildingDefinition" load_steps=3 format=3] +[ext_resource type="Script" path="res://BuildingDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://TransportHubBuildingVisual.tscn" id="2"] +[resource] +script = ExtResource("1") +definition_id = "transport_hub" +display_name = "Transport Hub" +description = "Stations transport vessels. They collect extracted resources and supply buildings that are waiting on materials." +cost = 30 +action_icon = 12 +menu_icon = 12 +visual_scene = ExtResource("2") +capabilities = PackedStringArray("transport_hub") +map_color = Color(0.95, 0.55, 0.85, 1) +map_sides = 8 +map_radius = 7.5 +map_phase = 0.392699 +menu_button_name = "TransportHubButton" +menu_label_name = "TransportHubLabel" +menu_cost_name = "TransportHubCost" diff --git a/TransportHubBuildingVisual.gd b/TransportHubBuildingVisual.gd new file mode 100644 index 0000000..a8a0d44 --- /dev/null +++ b/TransportHubBuildingVisual.gd @@ -0,0 +1,12 @@ +extends "res://BuildingVisualBase.gd" + +func _ready() -> void: + _build_hub_pieces() + queue_redraw() + +func apply_animation() -> void: + _animate_transport_hub() + +func _draw() -> void: + register_draw() + _draw_transport_hub_static(self) diff --git a/TransportHubBuildingVisual.gd.uid b/TransportHubBuildingVisual.gd.uid new file mode 100644 index 0000000..b1ca4cb --- /dev/null +++ b/TransportHubBuildingVisual.gd.uid @@ -0,0 +1 @@ +uid://6qyl5qnhw0ho diff --git a/TransportHubBuildingVisual.tscn b/TransportHubBuildingVisual.tscn new file mode 100644 index 0000000..1426761 --- /dev/null +++ b/TransportHubBuildingVisual.tscn @@ -0,0 +1,11 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://TransportHubBuildingVisual.gd" id="1"] +[ext_resource type="Script" path="res://BuildingActionProvider.gd" id="2"] + +[node name="Visual" type="Node2D"] +script = ExtResource("1") + +[node name="ActionProvider" type="Node" parent="."] +script = ExtResource("2") +actions = PackedStringArray("transfer") diff --git a/WorldMapContent.gd b/WorldMapContent.gd index 33212a0..5d4667b 100644 --- a/WorldMapContent.gd +++ b/WorldMapContent.gd @@ -1,20 +1,10 @@ extends Node2D const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd") +const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd") const WORLD_TO_LOCAL: float = 0.04 -# Mirrors Building.gd's own MOTHERSHIP_COLOR/CRAFTER_COLOR/COMMAND_COLOR so a -# marker on the map reads as "the same building" at a glance. Duplicated -# rather than cross-referenced - the same colour is already independently -# duplicated between Ship.gd, Fleet.gd and Building.gd's HULL_COLOR, so this -# follows the codebase's existing convention rather than inventing a new one. -const MOTHERSHIP_MARKER_COLOR: Color = Color(0.45, 0.9, 0.55) -const CRAFTER_MARKER_COLOR: Color = Color(1.0, 0.72, 0.25) -const COMMAND_MARKER_COLOR: Color = Color(0.55, 0.6, 1.0) -const TRANSPORT_HUB_MARKER_COLOR: Color = Color(0.95, 0.55, 0.85) -const SHIP_LAB_MARKER_COLOR: Color = Color(0.55, 0.95, 0.9) - # A structure built on a planet's surface (see GameState.extractors) is not # itself a map marker - it is a small badge on the existing planet marker, # in a colour distinct from both the planets and the space buildings so it @@ -40,28 +30,20 @@ func _process(_delta: float) -> void: func building_markers() -> Array: var markers: Array = [] for b in GameState.buildings: + var definition: Resource = BUILDING_DEFINITIONS.get_definition(String(b.type)) + if definition == null: + continue markers.append({ "position": b.position * WORLD_TO_LOCAL, "type": b.type, - "color": _building_marker_color(b.type), + "color": definition.map_color, + "sides": definition.map_sides, + "radius": definition.map_radius, + "phase": definition.map_phase, }) return markers -func _building_marker_color(type: String) -> Color: - match type: - GameState.BUILDING_MOTHERSHIP: - return MOTHERSHIP_MARKER_COLOR - GameState.BUILDING_SHIP_CRAFTER: - return CRAFTER_MARKER_COLOR - GameState.BUILDING_TRANSPORT_HUB: - return TRANSPORT_HUB_MARKER_COLOR - GameState.BUILDING_SHIP_LAB: - return SHIP_LAB_MARKER_COLOR - _: - return COMMAND_MARKER_COLOR - - # Whether the planet in this (explored) chunk has an extractor on it, per # GameState.has_extractor_at(). A separate predicate for the same # GPU-less-testability reason as building_markers(). @@ -119,17 +101,7 @@ func _draw() -> void: # Buildings are placed freely in space rather than tied to a chunk, so # they get their own pass rather than living inside the chunk loop above. for marker in building_markers(): - match marker.type: - GameState.BUILDING_MOTHERSHIP: - _draw_marker_polygon(_regular_polygon(marker.position, 8.0, 6, PI / 6.0), marker.color) - GameState.BUILDING_SHIP_CRAFTER: - _draw_marker_polygon(_regular_polygon(marker.position, 7.0, 4, 0.0), marker.color) - GameState.BUILDING_TRANSPORT_HUB: - _draw_marker_polygon(_regular_polygon(marker.position, 7.5, 8, PI / 8.0), marker.color) - GameState.BUILDING_SHIP_LAB: - _draw_marker_polygon(_regular_polygon(marker.position, 7.5, 3, -PI / 2.0), marker.color) - _: - _draw_marker_polygon(_regular_polygon(marker.position, 6.0, 4, PI / 4.0), marker.color) + _draw_marker_polygon(_regular_polygon(marker.position, marker.radius, marker.sides, marker.phase), marker.color) var fleet_local: Vector2 = fleet.get_centroid() * WORLD_TO_LOCAL var marker_points: PackedVector2Array = PackedVector2Array([ diff --git a/tests/_build_popup_preview.gd b/tests/_build_popup_preview.gd index 3bb938e..d3c6b1d 100644 --- a/tests/_build_popup_preview.gd +++ b/tests/_build_popup_preview.gd @@ -28,4 +28,4 @@ func _ready() -> void: # Flip SHOW_POPUP to check the menu underneath # instead of the popup that covers it. if SHOW_POPUP: - controller._on_place_mothership() + controller._show_building_info(GameState.BUILDING_MOTHERSHIP) diff --git a/tests/_crafter_preview.gd b/tests/_crafter_preview.gd index dc89fe4..52888aa 100644 --- a/tests/_crafter_preview.gd +++ b/tests/_crafter_preview.gd @@ -31,9 +31,9 @@ func _ready() -> void: b.position = Vector2(-240.0 + float(i % 3) * 240.0, -105.0 + float(i / 3) * 215.0) add_child(b) if cell.has("phase"): - b.set_process(false) - b.anim_time = float(cell.phase) * b.CYCLE - b.apply_animation() + b.visual.set_process(false) + b.visual.anim_time = float(cell.phase) * b.visual.CYCLE + b.visual.apply_animation() var cam: Camera2D = Camera2D.new() cam.zoom = Vector2(1.65, 1.65) diff --git a/tests/test_animation_continuity.gd b/tests/test_animation_continuity.gd index d373a25..3919aa6 100644 --- a/tests/test_animation_continuity.gd +++ b/tests/test_animation_continuity.gd @@ -53,7 +53,7 @@ func _process(delta: float) -> bool: var ship: Node2D = null for b in field.spawned: if is_instance_valid(b) and b.building_type == GameState.BUILDING_MOTHERSHIP: - ship = b + ship = b.visual if ship == null: print("TEST_ANIMATION_CONTINUITY: FAIL (mothership never spawned)") quit(1) diff --git a/tests/test_build_info_popup.gd b/tests/test_build_info_popup.gd index ef1d708..c67d45c 100644 --- a/tests/test_build_info_popup.gd +++ b/tests/test_build_info_popup.gd @@ -28,9 +28,9 @@ var elapsed: float = 0.0 # Extractor is excluded: it needs a planet underneath, which # test_extractor_building covers in full. const OPTIONS: Array = [ - {"press": "_on_place_mothership", "type": "mothership", "title": "Mothership"}, - {"press": "_on_place_crafter", "type": "ship_crafter", "title": "Ship Crafter"}, - {"press": "_on_place_command_center", "type": "command_center", "title": "Command Center"}, + {"type": "mothership", "title": "Mothership"}, + {"type": "ship_crafter", "title": "Ship Crafter"}, + {"type": "command_center", "title": "Command Center"}, ] @@ -84,7 +84,7 @@ func _process(delta: float) -> bool: var built_before: int = _count(type) controller._on_build_pressed() - controller.call(option.press) + controller._show_building_info(type) var informed: bool = popup.visible \ and title.text == option.title \ @@ -111,7 +111,7 @@ func _process(delta: float) -> bool: # --- reopening and confirming builds it ------------------ controller._on_build_pressed() - controller.call(option.press) + controller._show_building_info(type) controller._confirm_build() var built: bool = _count(type) == built_before + 1 var charged: bool = game_state.rocks == rocks_before - expected_cost diff --git a/tests/test_building_composition.gd b/tests/test_building_composition.gd new file mode 100644 index 0000000..7cadf5d --- /dev/null +++ b/tests/test_building_composition.gd @@ -0,0 +1,35 @@ +extends SceneTree + +const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd") + + +func _initialize() -> void: + var scene: PackedScene = load("res://Building.tscn") + for definition in BUILDING_DEFINITIONS.all(): + if definition.placement_mode != "space": + continue + var building: Node2D = scene.instantiate() + building.definition_id = definition.definition_id + building._ready() + if building.definition == null \ + or building.definition.definition_id != definition.definition_id \ + or building.visual == null: + _fail("%s did not compose its definition and visual (resolved=%s visual=%s)" % [ + definition.definition_id, + "null" if building.definition == null else building.definition.definition_id, + building.visual, + ]) + return + var provider: Node = building.visual.get_node_or_null("ActionProvider") + if provider == null or not provider.actions.has("transfer"): + _fail("%s has no composed action provider" % definition.definition_id) + return + building.free() + + print("TEST_BUILDING_COMPOSITION: PASS") + quit(0) + + +func _fail(message: String) -> void: + print("TEST_BUILDING_COMPOSITION: FAIL (%s)" % message) + quit(1) diff --git a/tests/test_building_redraw_cost.gd b/tests/test_building_redraw_cost.gd index c083754..1c81451 100644 --- a/tests/test_building_redraw_cost.gd +++ b/tests/test_building_redraw_cost.gd @@ -61,11 +61,11 @@ func _process(delta: float) -> bool: continue match b.building_type: GameState.BUILDING_SHIP_CRAFTER: - crafter = b + crafter = b.visual GameState.BUILDING_MOTHERSHIP: - mothership = b + mothership = b.visual GameState.BUILDING_COMMAND_CENTER: - command = b + command = b.visual if crafter == null or mothership == null or command == null: print("TEST_BUILDING_REDRAW_COST: FAIL (buildings never spawned)") quit(1) @@ -88,7 +88,7 @@ func _process(delta: float) -> bool: for b in [crafter, mothership, command]: if b.draw_count != 1: print("TEST_BUILDING_REDRAW_COST: FAIL (%s redrew its geometry %d times)" % [ - b.building_type, b.draw_count + b.name, b.draw_count ]) quit(1) return true diff --git a/tests/test_crafter_animation.gd b/tests/test_crafter_animation.gd index 7ddab14..9b5d57e 100644 --- a/tests/test_crafter_animation.gd +++ b/tests/test_crafter_animation.gd @@ -57,16 +57,19 @@ func _process(delta: float) -> bool: # Spawning is deferred, so give the tree a frame to attach them. if elapsed < 0.2: return false - crafter = _find(GameState.BUILDING_SHIP_CRAFTER) - if crafter == null: + var crafter_building: Node2D = _find(GameState.BUILDING_SHIP_CRAFTER) + if crafter_building == null: print("TEST_CRAFTER_ANIMATION: FAIL (crafter never spawned)") quit(1) return true + crafter = crafter_building.visual var animated: bool = crafter.is_processing() - var mothership: Node2D = _find(GameState.BUILDING_MOTHERSHIP) + var mothership_building: Node2D = _find(GameState.BUILDING_MOTHERSHIP) + var mothership: Node2D = mothership_building.visual if mothership_building != null else null var mothership_animates: bool = mothership != null and mothership.is_processing() - var command: Node2D = _find(GameState.BUILDING_COMMAND_CENTER) + var command_building: Node2D = _find(GameState.BUILDING_COMMAND_CENTER) + var command: Node2D = command_building.visual if command_building != null else null var command_static: bool = command != null and not command.is_processing() print("crafter animates=%s mothership animates=%s command centre static=%s" % [ animated, mothership_animates, command_static diff --git a/tests/test_extractor_building.gd b/tests/test_extractor_building.gd index 13b2567..0a4b769 100644 --- a/tests/test_extractor_building.gd +++ b/tests/test_extractor_building.gd @@ -69,7 +69,7 @@ func _process(delta: float) -> bool: print("TEST_EXTRACTOR_BUILDING: FAIL (enabled with no planet in reach)") quit(1) return true - controller._on_place_extractor() + controller._show_building_info(GameState.BUILDING_EXTRACTOR) var explained: bool = popup.visible and "planet" in popup.get_node("Panel/MessageLabel").text.to_lower() var offered: bool = popup.get_node("Panel/BuildButton").visible print("no planet: greyed=%s popup_shown=%s confirm_offered=%s" % [ @@ -118,7 +118,7 @@ func _process(delta: float) -> bool: return true var rocks_before: int = game_state.rocks - controller._on_place_extractor() + controller._show_building_info(GameState.BUILDING_EXTRACTOR) var planet_field2: Node = main_scene.get_node("PlanetField") var planet2: Node2D = planet_field2.populated_chunks[target_chunk][0] @@ -148,7 +148,7 @@ func _process(delta: float) -> bool: # A second one on the same planet must be refused, not charged. var rocks_now: int = game_state.rocks controller._on_build_pressed() - controller._on_place_extractor() + controller._show_building_info(GameState.BUILDING_EXTRACTOR) var re_offered: bool = popup.get_node("Panel/BuildButton").visible controller._confirm_build() # must do nothing even if pressed var still_one: bool = game_state.extractors.size() == 1 and not re_offered diff --git a/tests/test_mothership_replacement.gd b/tests/test_mothership_replacement.gd index 6ebe817..576f039 100644 --- a/tests/test_mothership_replacement.gd +++ b/tests/test_mothership_replacement.gd @@ -67,7 +67,7 @@ func _process(delta: float) -> bool: return false # --- first one: informed, then built ------------------------- controller._on_build_pressed() - controller._on_place_mothership() + controller._show_building_info(GameState.BUILDING_MOTHERSHIP) var first_text: String = popup.get_node("Panel/MessageLabel").text.to_lower() var first_warned: bool = "destroy" in first_text print("first: popup=%s warned=%s built_before_confirm=%d" % [ @@ -86,7 +86,7 @@ func _process(delta: float) -> bool: # --- second: warns, builds nothing --------------------------- _place_fleet(second_pos) controller._on_build_pressed() - controller._on_place_mothership() + controller._show_building_info(GameState.BUILDING_MOTHERSHIP) var warned: bool = popup.visible and "destroy" in popup.get_node("Panel/MessageLabel").text.to_lower() print("second press: warned=%s motherships=%d" % [warned, _count_motherships()]) if not warned or _count_motherships() != 1: @@ -107,7 +107,7 @@ func _process(delta: float) -> bool: # --- confirming replaces ------------------------------------- var rocks_before: int = game_state.rocks - controller._on_place_mothership() + controller._show_building_info(GameState.BUILDING_MOTHERSHIP) controller._confirm_build() var only_one: bool = _count_motherships() == 1 var moved: bool = game_state.get_mothership_position().distance_to(second_pos) < 60.0 @@ -124,7 +124,7 @@ func _process(delta: float) -> bool: game_state.rocks = GameState.MOTHERSHIP_COST - 1 _place_fleet(Vector2(2500.0, -800.0)) controller._on_build_pressed() - controller._on_place_mothership() + controller._show_building_info(GameState.BUILDING_MOTHERSHIP) # With nothing affordable there is no confirm button to press; # pressing it anyway must still do nothing. var offered: bool = popup.get_node("Panel/BuildButton").visible diff --git a/tests/test_skill_tree.gd b/tests/test_skill_tree.gd index 7b2a887..5c9d21c 100644 --- a/tests/test_skill_tree.gd +++ b/tests/test_skill_tree.gd @@ -72,9 +72,9 @@ func _process(delta: float) -> bool: # Since #21 an unresearched structure is not listed at all, rather # than listed and greyed. It is therefore unreachable from the # menu - but the popup must still refuse if something calls - # _on_place_crafter() by another route. + # the generic definition entry point by another route. var hidden: bool = not scene.get_node("UI/BuildMenu/CrafterButton").visible - controller._on_place_crafter() + controller._show_building_info(GameState.BUILDING_SHIP_CRAFTER) var offered: bool = popup.get_node("Panel/BuildButton").visible print("locked crafter: hidden_from_menu=%s confirm_offered=%s" % [hidden, offered]) if not hidden or offered: diff --git a/tests/test_transport_hub_building.gd b/tests/test_transport_hub_building.gd index b2cc4eb..dcb03a6 100644 --- a/tests/test_transport_hub_building.gd +++ b/tests/test_transport_hub_building.gd @@ -1,5 +1,7 @@ extends SceneTree +const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd") + # Headless gameplay test: run with # godot --headless -s res://tests/test_transport_hub_building.gd # Forgejo #18 (part 5 of #12): the Transport Hub is a placeable building of @@ -67,7 +69,7 @@ func _process(delta: float) -> bool: var icons: Dictionary = {} for type in [GameState.BUILDING_MOTHERSHIP, GameState.BUILDING_SHIP_CRAFTER, GameState.BUILDING_COMMAND_CENTER, GameState.BUILDING_TRANSPORT_HUB]: - var icon: int = controller._action_icon_for(type) + var icon: int = BUILDING_DEFINITIONS.get_definition(type).action_icon if icons.has(icon): print("TEST_TRANSPORT_HUB_BUILDING: FAIL (%s and %s share an icon)" % [type, icons[icon]]) quit(1) @@ -99,7 +101,7 @@ func _process(delta: float) -> bool: return false for b in field.spawned: if is_instance_valid(b) and b.building_type == GameState.BUILDING_TRANSPORT_HUB: - hub = b + hub = b.visual if hub == null: print("TEST_TRANSPORT_HUB_BUILDING: FAIL (never spawned)") quit(1) @@ -120,9 +122,9 @@ func _process(delta: float) -> bool: for m in markers: if m.type == GameState.BUILDING_TRANSPORT_HUB: found = true - if m.color == map_content.CRAFTER_MARKER_COLOR \ - or m.color == map_content.MOTHERSHIP_MARKER_COLOR \ - or m.color == map_content.COMMAND_MARKER_COLOR: + if m.color == BUILDING_DEFINITIONS.get_definition(GameState.BUILDING_SHIP_CRAFTER).map_color \ + or m.color == BUILDING_DEFINITIONS.get_definition(GameState.BUILDING_MOTHERSHIP).map_color \ + or m.color == BUILDING_DEFINITIONS.get_definition(GameState.BUILDING_COMMAND_CENTER).map_color: print("TEST_TRANSPORT_HUB_BUILDING: FAIL (map marker reuses another building's colour)") quit(1) return true From 14081ad2308bc8de0550e85ac005a160dc31a187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Fri, 14 Aug 2026 22:12:57 +0200 Subject: [PATCH 5/5] Compose vessels from definitions and capabilities --- BuildController.gd | 185 ++++++++++--------- GameState.gd | 228 +++++++++++------------- InterceptorDamageUpgrade.tres | 11 ++ InterceptorRangeUpgrade.tres | 11 ++ InterceptorRateUpgrade.tres | 11 ++ InterceptorSpeedUpgrade.tres | 11 ++ InterceptorVesselDefinition.tres | 20 +++ ShieldHitsUpgrade.tres | 11 ++ ShieldRechargeUpgrade.tres | 11 ++ ShieldSpeedUpgrade.tres | 11 ++ ShieldVesselDefinition.tres | 25 +++ Ship.gd | 234 ++++++++----------------- TransportCapacityUpgrade.tres | 11 ++ TransportSpeedUpgrade.tres | 11 ++ TransporterVesselDefinition.tres | 19 ++ VesselCargoComponent.gd | 9 + VesselCargoComponent.gd.uid | 1 + VesselCargoComponent.tscn | 4 + VesselDefinition.gd | 26 +++ VesselDefinition.gd.uid | 1 + VesselDefinitions.gd | 24 +++ VesselDefinitions.gd.uid | 1 + VesselDelivery.gd | 10 +- VesselDeliveryField.gd | 2 +- VesselShieldComponent.gd | 69 ++++++++ VesselShieldComponent.gd.uid | 1 + VesselShieldComponent.tscn | 4 + VesselTransportComponent.gd | 9 + VesselTransportComponent.gd.uid | 1 + VesselTransportComponent.tscn | 4 + VesselUpgradeDefinition.gd | 11 ++ VesselUpgradeDefinition.gd.uid | 1 + VesselWeaponComponent.gd | 47 +++++ VesselWeaponComponent.gd.uid | 1 + VesselWeaponComponent.tscn | 4 + tests/test_building_composition.gd.uid | 1 + tests/test_hangar_management.gd | 1 + tests/test_vessel_composition.gd | 50 ++++++ tests/test_vessel_composition.gd.uid | 1 + 39 files changed, 706 insertions(+), 387 deletions(-) create mode 100644 InterceptorDamageUpgrade.tres create mode 100644 InterceptorRangeUpgrade.tres create mode 100644 InterceptorRateUpgrade.tres create mode 100644 InterceptorSpeedUpgrade.tres create mode 100644 InterceptorVesselDefinition.tres create mode 100644 ShieldHitsUpgrade.tres create mode 100644 ShieldRechargeUpgrade.tres create mode 100644 ShieldSpeedUpgrade.tres create mode 100644 ShieldVesselDefinition.tres create mode 100644 TransportCapacityUpgrade.tres create mode 100644 TransportSpeedUpgrade.tres create mode 100644 TransporterVesselDefinition.tres create mode 100644 VesselCargoComponent.gd create mode 100644 VesselCargoComponent.gd.uid create mode 100644 VesselCargoComponent.tscn create mode 100644 VesselDefinition.gd create mode 100644 VesselDefinition.gd.uid create mode 100644 VesselDefinitions.gd create mode 100644 VesselDefinitions.gd.uid create mode 100644 VesselShieldComponent.gd create mode 100644 VesselShieldComponent.gd.uid create mode 100644 VesselShieldComponent.tscn create mode 100644 VesselTransportComponent.gd create mode 100644 VesselTransportComponent.gd.uid create mode 100644 VesselTransportComponent.tscn create mode 100644 VesselUpgradeDefinition.gd create mode 100644 VesselUpgradeDefinition.gd.uid create mode 100644 VesselWeaponComponent.gd create mode 100644 VesselWeaponComponent.gd.uid create mode 100644 VesselWeaponComponent.tscn create mode 100644 tests/test_building_composition.gd.uid create mode 100644 tests/test_vessel_composition.gd create mode 100644 tests/test_vessel_composition.gd.uid diff --git a/BuildController.gd b/BuildController.gd index b598703..1d33801 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -1,6 +1,7 @@ extends Node2D const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd") +const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd") # Space-side building UI. Owns: # - the build menu (place a mothership, ship crafter or command centre) @@ -32,6 +33,10 @@ const BUILDING_DEFINITIONS = preload("res://BuildingDefinitions.gd") get_node("../UI/LabMenu/VesselC"), ] @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"), +] @onready var lab_detail: Label = get_node("../UI/LabMenu/DetailLabel") @onready var lab_status: Label = get_node("../UI/LabMenu/StatusLabel") @onready var lab_buy_button: IconButton = get_node("../UI/LabMenu/BuyButton") @@ -98,6 +103,12 @@ var lab_building_id: int = 0 @onready var craft_interceptor_label: Label = get_node("../UI/CraftMenu/InterceptorLabel") @onready var craft_transporter_label: Label = get_node("../UI/CraftMenu/TransporterLabel") @onready var craft_shield_label: Label = get_node("../UI/CraftMenu/ShieldLabel") +@onready var craft_vessel_buttons: Array = [ + craft_interceptor_button, craft_shield_button, craft_transporter_button, +] +@onready var craft_vessel_labels: Array = [ + craft_interceptor_label, craft_shield_label, craft_transporter_label, +] # --- the transfer panel (#24) --------------------------------------------- # Ore used to be moved from inside whichever panel happened to be open: "load @@ -159,7 +170,6 @@ func _ready() -> void: build_button.pressed.connect(_on_build_pressed) build_close_button.pressed.connect(_close_build_menu) _configure_build_menu_entries() - craft_shield_button.pressed.connect(_on_craft_shield) lab_close_button.pressed.connect(func(): lab_menu.visible = false) lab_buy_button.pressed.connect(_on_lab_buy) for i in range(lab_vessel_buttons.size()): @@ -172,8 +182,9 @@ func _ready() -> void: action_button.pressed.connect(_on_context_action_pressed) building_action_button.pressed.connect(_on_action_pressed) - craft_interceptor_button.pressed.connect(_on_craft_interceptor) - craft_transporter_button.pressed.connect(_on_craft_transporter) + for i in range(mini(craft_vessel_buttons.size(), VESSEL_DEFINITIONS.all().size())): + var definition: Resource = VESSEL_DEFINITIONS.all()[i] + craft_vessel_buttons[i].pressed.connect(_on_craft_vessel.bind(definition.definition_id)) craft_close_button.pressed.connect(func(): craft_menu.visible = false) transfer_button.pressed.connect(_on_transfer_pressed) @@ -692,26 +703,19 @@ func _refresh_craft_status() -> void: # Prices live with the buttons they describe. The top of the screen is # intentionally reserved for the shared resource bar and a short action # message; repeating fleet, queue and transport counts here buried the UI. - craft_interceptor_label.text = "Interceptor\n%d ore" % GameState.SHIP_COST - craft_transporter_label.text = "Transporter\n%d ore" % GameState.TRANSPORTER_COST - craft_shield_label.text = "Shield Vessel\n%d ore" % GameState.SHIELD_COST craft_status.text = "" - # Nowhere to station one means the order would be refused, so say so - # rather than letting the button look available and do nothing. - craft_transporter_button.enabled = GameState.has_transport_hub() \ - and GameState.can_station_transporter() - craft_transporter_button.queue_redraw() - - craft_shield_button.enabled = GameState.shield_vessel_unlocked() - craft_shield_button.queue_redraw() - - # Fleet capacity no longer gates ORDERING (#23). A finished vessel waits - # at the mothership until the player crews it, so a full fleet is a - # reason not to collect one yet - not a reason the crafter cannot build. - # This is also what stopped the crafter being inert on a fresh save, - # where the fleet starts at exactly its cap. - craft_interceptor_button.enabled = true - craft_interceptor_button.queue_redraw() + var definitions: Array = VESSEL_DEFINITIONS.all() + for i in range(mini(definitions.size(), craft_vessel_buttons.size())): + var definition: Resource = definitions[i] + var button: IconButton = craft_vessel_buttons[i] + craft_vessel_labels[i].text = "%s\n%d ore" % [definition.display_name, definition.build_cost] + button.icon = definition.icon + button.glow_color = definition.glow_color + button.enabled = GameState.vessel_is_unlocked(definition.definition_id) + if definition.has_capability("transport"): + button.enabled = button.enabled and GameState.has_transport_hub() \ + and GameState.can_station_transporter() + button.queue_redraw() _refresh_queue() if craft_message != "": if Time.get_ticks_msec() < craft_message_until: @@ -721,40 +725,40 @@ func _refresh_craft_status() -> void: func _on_craft_interceptor() -> void: + _on_craft_vessel(GameState.VESSEL_INTERCEPTOR) + + +func _on_craft_vessel(vessel: String) -> void: if craft_building_id == 0: _say("No crafter selected - reopen this panel from the building") return - # No affordability check: an order the crafter cannot pay for yet is - # QUEUED, and completes by itself once ore is delivered here (#16). But - # queueing silently looks identical to a dead button, so it says which - # happened and what the crafter is still short of. - GameState.queue_order(craft_building_id) - _report_order(GameState.PRODUCT_SHIP) + var definition: Resource = VESSEL_DEFINITIONS.get_definition(vessel) + if definition == null: + return + if not GameState.queue_order(craft_building_id, vessel): + if not GameState.vessel_is_unlocked(vessel): + _say("%s must be unlocked at a ship lab first" % definition.display_name) + elif definition.has_capability("transport") and not GameState.has_transport_hub(): + _say("Build a Transport Hub first - a transporter needs somewhere to be stationed") + elif definition.has_capability("transport"): + _say("All %d berths are taken - build another hub, or research Freight Yards" % + GameState.transport_capacity()) + return + _report_order(vessel) func _report_order(product: String) -> void: var short: int = GameState.outstanding_cost(craft_building_id) if short > 0: _say("%s queued - %d more ore must be delivered here" % [ - GameState.VESSEL_NAMES.get(product, product), short + GameState.vessel_name(product), short ]) else: - _say("%s ordered - building now" % GameState.VESSEL_NAMES.get(product, product)) + _say("%s ordered - building now" % GameState.vessel_name(product)) func _on_craft_transporter() -> void: - if craft_building_id == 0: - _say("No crafter selected - reopen this panel from the building") - return - if not GameState.queue_order(craft_building_id, GameState.PRODUCT_TRANSPORTER): - # Two different refusals, and the player needs to know WHICH. - if not GameState.has_transport_hub(): - _say("Build a Transport Hub first - a transporter needs somewhere to be stationed") - else: - _say("All %d berths are taken - build another hub, or research Freight Yards" % - GameState.transport_capacity()) - return - _refresh_craft_status() + _on_craft_vessel(GameState.VESSEL_TRANSPORTER) # --- ship lab (#5) -------------------------------------------------------- @@ -764,10 +768,13 @@ func _lab_options() -> Array: # entry, because unlocking the type and upgrading it belong in the same # place - and its upgrades are pointless until it exists. var options: Array = [] - if lab_vessel == GameState.VESSEL_SHIELD and not GameState.shield_vessel_unlocked(): - options.append({"unlock": true, "name": "Unlock Shield Vessel", - "desc": "Lets the ship crafter build shield vessels. Its shield absorbs one lethal hit, then cycles back up.", - "cost": GameState.UNLOCK_SHIELD_COST}) + var definition: Resource = VESSEL_DEFINITIONS.get_definition(lab_vessel) + if definition == null: + return options + if not GameState.vessel_is_unlocked(lab_vessel): + options.append({"unlock": true, "id": definition.unlock_id, + "name": definition.unlock_name, "desc": definition.unlock_description, + "cost": definition.unlock_cost, "icon": IconButton.Icon.UPGRADE_UNLOCK_SHIELD}) return options for u in GameState.upgrades_for(lab_vessel): options.append({"unlock": false, "id": u.id, "name": u.name, "desc": u.desc}) @@ -775,8 +782,10 @@ func _lab_options() -> Array: func _on_lab_vessel(index: int) -> void: - lab_vessel = [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD, - GameState.VESSEL_TRANSPORTER][index] + var definitions: Array = VESSEL_DEFINITIONS.all() + if index < 0 or index >= definitions.size(): + return + lab_vessel = definitions[index].definition_id lab_choice = 0 _refresh_lab() @@ -794,7 +803,7 @@ func _on_lab_buy() -> void: if options.is_empty(): return var choice: Dictionary = options[lab_choice] - var id: String = GameState.UNLOCK_SHIELD_VESSEL if choice.unlock else String(choice.id) + var id: String = String(choice.id) if not GameState.queue_lab_research(lab_building_id, id): lab_status.text = "Research is already queued or complete" return @@ -803,14 +812,17 @@ func _on_lab_buy() -> void: func _refresh_lab() -> void: - for i in range(lab_vessel_buttons.size()): - var this_vessel: String = [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD, - GameState.VESSEL_TRANSPORTER][i] + var definitions: Array = VESSEL_DEFINITIONS.all() + for i in range(mini(lab_vessel_buttons.size(), definitions.size())): + var definition: Resource = definitions[i] + var this_vessel: String = definition.definition_id lab_vessel_buttons[i].enabled = true - lab_vessel_buttons[i].glow_color = _vessel_colour(this_vessel) + lab_vessel_buttons[i].icon = definition.icon + lab_vessel_buttons[i].glow_color = definition.glow_color + 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_NAMES.get(lab_vessel, lab_vessel) + lab_vessel_label.text = "%s research" % GameState.vessel_name(lab_vessel) var options: Array = _lab_options() for i in range(lab_upgrade_buttons.size()): @@ -820,7 +832,7 @@ func _refresh_lab() -> void: if not visible: continue var option: Dictionary = options[i] - var price_text: String = "%d ore" % (GameState.UNLOCK_SHIELD_COST if bool(option.unlock) else GameState.upgrade_cost(option.id)) + var price_text: String = "%d ore" % (int(option.cost) if bool(option.unlock) else GameState.upgrade_cost(option.id)) 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" % [ @@ -848,29 +860,11 @@ func _refresh_lab() -> void: func _upgrade_icon(option: Dictionary) -> IconButton.Icon: - if bool(option.unlock): - return IconButton.Icon.UPGRADE_UNLOCK_SHIELD - match String(option.id): - "int_damage": return IconButton.Icon.UPGRADE_DAMAGE - "int_rate": return IconButton.Icon.UPGRADE_RATE - "int_range": return IconButton.Icon.UPGRADE_RANGE - "int_speed", "shd_speed": return IconButton.Icon.UPGRADE_THRUSTERS - "shd_hits": return IconButton.Icon.UPGRADE_SHIELD_HITS - "shd_recharge": return IconButton.Icon.UPGRADE_SHIELD_RECHARGE - "trn_capacity": return IconButton.Icon.UPGRADE_CARGO - "trn_speed": return IconButton.Icon.UPGRADE_ENGINES - _: return IconButton.Icon.SHIP_LAB + return int(option.get("icon", IconButton.Icon.SHIP_LAB)) func _on_craft_shield() -> void: - if craft_building_id == 0: - _say("No crafter selected - reopen this panel from the building") - return - if not GameState.shield_vessel_unlocked(): - _say("Shield vessels must be unlocked at a ship lab first") - return - GameState.queue_order(craft_building_id, GameState.VESSEL_SHIELD) - _report_order(GameState.VESSEL_SHIELD) + _on_craft_vessel(GameState.VESSEL_SHIELD) # --- moving ore, in both directions, at any building (#24) ---------------- @@ -957,8 +951,8 @@ func _refresh_queue() -> void: if not slot.visible: continue var order: Dictionary = orders[i] - slot.icon = IconButton.Icon.TRANSPORT_HUB if order.product == GameState.PRODUCT_TRANSPORTER \ - else IconButton.Icon.SHIP + var definition: Resource = VESSEL_DEFINITIONS.get_definition(String(order.product)) + slot.icon = definition.icon slot.glow_color = _vessel_colour(String(order.product)) # Only the one under construction reads as active; the rest are # waiting, and greyed says so without needing a caption each. @@ -971,29 +965,24 @@ func _refresh_queue() -> void: var head: Dictionary = orders[0] if bool(head.started): queue_detail.text = "Building %s %d%%\nTap to cancel" % [ - GameState.VESSEL_NAMES.get(head.product, head.product), + GameState.vessel_name(String(head.product)), int(round(float(head.progress) * 100.0)), ] else: queue_detail.text = "%s waiting for ore\nTap to cancel" % \ - GameState.VESSEL_NAMES.get(head.product, head.product) + GameState.vessel_name(String(head.product)) func _vessel_colour(product: String) -> Color: - match product: - GameState.VESSEL_SHIELD: - return Color(0.45, 0.8, 1.0) - GameState.PRODUCT_TRANSPORTER: - return Color(0.95, 0.55, 0.85) - _: - return Color(0.25, 0.85, 1.0) + var definition: Resource = VESSEL_DEFINITIONS.get_definition(product) + return definition.glow_color if definition != null else Color(0.25, 0.85, 1.0) func _on_queue_slot(index: int) -> void: var orders: Array = GameState.order_list(craft_building_id) if index >= orders.size(): return - var vessel: String = GameState.VESSEL_NAMES.get(orders[index].product, orders[index].product) + var vessel: String = GameState.vessel_name(String(orders[index].product)) var refunded: bool = bool(orders[index].started) if not GameState.cancel_order(craft_building_id, index): return @@ -1018,12 +1007,12 @@ func _on_hangar_crew_type(index: int) -> void: return var stored_index: int = GameState.stored_vessels.find(type) if stored_index < 0: - hangar_status.text = "No %s available" % GameState.VESSEL_NAMES.get(type, type) + hangar_status.text = "No %s available" % GameState.vessel_name(type) return if not fleet.add_ship(GameState.take_stored_vessel(stored_index)): hangar_status.text = "Could not crew that vessel" return - hangar_status.text = "%s joined the fleet" % GameState.VESSEL_NAMES.get(type, type) + hangar_status.text = "%s joined the fleet" % GameState.vessel_name(type) _refresh_hangar() @@ -1035,32 +1024,40 @@ func _on_hangar_store_type(index: int) -> void: ship_index = i break if ship_index < 0: - hangar_status.text = "No %s in the fleet" % GameState.VESSEL_NAMES.get(type, type) + hangar_status.text = "No %s in the fleet" % GameState.vessel_name(type) return var stored: String = fleet.store_ship(ship_index) if stored == "": hangar_status.text = "Keep one ship deployed" return GameState.stored_vessels.append(stored) - hangar_status.text = "%s stored in the hangar" % GameState.VESSEL_NAMES.get(stored, stored) + hangar_status.text = "%s stored in the hangar" % GameState.vessel_name(stored) _refresh_hangar() func _hangar_types() -> Array: - return [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD, GameState.VESSEL_TRANSPORTER] + var types: Array = [] + for definition in VESSEL_DEFINITIONS.all(): + types.append(definition.definition_id) + return types func _refresh_hangar() -> void: var types: Array = _hangar_types() for i in range(types.size()): var type: String = types[i] + var definition: Resource = VESSEL_DEFINITIONS.get_definition(type) var available: int = GameState.stored_vessels.count(type) var deployed: int = 0 for ship in fleet.ships: if is_instance_valid(ship) and String(ship.vessel_type) == type: deployed += 1 - hangar_available_labels[i].text = "%s\n%d available" % [GameState.VESSEL_NAMES.get(type, type), available] - hangar_fleet_labels[i].text = "%s\n%d in fleet" % [GameState.VESSEL_NAMES.get(type, type), deployed] + hangar_available_labels[i].text = "%s\n%d available" % [definition.display_name, available] + hangar_fleet_labels[i].text = "%s\n%d in fleet" % [definition.display_name, deployed] + hangar_available_slots[i].icon = definition.icon + hangar_fleet_slots[i].icon = definition.icon + hangar_available_slots[i].glow_color = definition.glow_color + hangar_fleet_slots[i].glow_color = definition.glow_color hangar_available_slots[i].enabled = available > 0 and fleet.ships.size() < GameState.max_fleet_size() hangar_fleet_slots[i].enabled = deployed > 0 and fleet.ships.size() > 1 hangar_available_slots[i].queue_redraw() diff --git a/GameState.gd b/GameState.gd index 56f773f..37d110a 100644 --- a/GameState.gd +++ b/GameState.gd @@ -3,6 +3,7 @@ extends Node 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") # Autoload singleton - the one thing that survives change_scene_to_file() @@ -404,21 +405,10 @@ const VESSEL_INTERCEPTOR: String = "interceptor" const VESSEL_SHIELD: String = "shield" const VESSEL_TRANSPORTER: String = "transporter" -const VESSEL_NAMES: Dictionary = { - VESSEL_INTERCEPTOR: "Interceptor", - VESSEL_SHIELD: "Shield Vessel", - VESSEL_TRANSPORTER: "Transporter", -} - +# Compatibility constants for callers and old tests. Runtime metadata is +# resolved from VesselDefinition below. const SHIELD_COST: int = 14 - -# How long one vessel takes to build, once its ore is actually in the -# building. A queue you can watch and cancel needs time to exist in. -const BUILD_TIMES: Dictionary = { - VESSEL_INTERCEPTOR: 10.0, - VESSEL_SHIELD: 16.0, - VESSEL_TRANSPORTER: 8.0, -} +const TRANSPORTER_COST: int = 10 const LAB_RESEARCH_TIME: float = 8.0 @@ -429,57 +419,14 @@ var build_time_scale: float = 1.0 func build_time(product: String) -> float: - return float(BUILD_TIMES.get(product, 10.0)) * build_time_scale + var definition: Resource = VESSEL_DEFINITIONS.get_definition(product) + return (definition.build_time if definition != null else 10.0) * build_time_scale func lab_research_time() -> float: return LAB_RESEARCH_TIME * build_time_scale -# Researched at the ship lab, per vessel type. Separate from SKILL_TREE on -# purpose: that gates what the player may build at all and is a strategic -# spine, while these are repeatable tuning on a vessel you already have. -# `cost` is the FIRST level; each level after costs cost * (level + 1), so -# the third point in something is not the same price as the first. -const VESSEL_UPGRADES: Array = [ - { - "id": "int_damage", "vessel": VESSEL_INTERCEPTOR, "name": "Heavy Slugs", - "cost": 30, "max_level": 3, "desc": "+1 damage per shot.", - }, - { - "id": "int_rate", "vessel": VESSEL_INTERCEPTOR, "name": "Autoloader", - "cost": 25, "max_level": 3, "desc": "Fires 15% faster per level.", - }, - { - "id": "int_range", "vessel": VESSEL_INTERCEPTOR, "name": "Targeting Array", - "cost": 20, "max_level": 3, "desc": "+60 firing range per level.", - }, - { - "id": "int_speed", "vessel": VESSEL_INTERCEPTOR, "name": "Thrusters", - "cost": 25, "max_level": 3, "desc": "+40 top speed, +60 acceleration per level.", - }, - { - "id": "shd_hits", "vessel": VESSEL_SHIELD, "name": "Layered Emitters", - "cost": 60, "max_level": 2, "desc": "+1 hit absorbed before the shield drops.", - }, - { - "id": "shd_recharge", "vessel": VESSEL_SHIELD, "name": "Fast Cycling", - "cost": 35, "max_level": 3, "desc": "Shield returns 1.2s sooner per level.", - }, - { - "id": "shd_speed", "vessel": VESSEL_SHIELD, "name": "Thrusters", - "cost": 25, "max_level": 3, "desc": "+40 top speed, +60 acceleration per level.", - }, - { - "id": "trn_capacity", "vessel": VESSEL_TRANSPORTER, "name": "Expanded Hold", - "cost": 30, "max_level": 3, "desc": "Carries 8 more ore per supply run.", - }, - { - "id": "trn_speed", "vessel": VESSEL_TRANSPORTER, "name": "Freight Engines", - "cost": 30, "max_level": 3, "desc": "+70 speed on supply runs.", - }, -] - -# Base figures every vessel starts from, before anything is researched. +# Compatibility constants. VesselDefinition owns the live base figures. const SHIP_BASE_DAMAGE: int = 1 const SHIP_BASE_COOLDOWN: float = 0.5 const SHIP_BASE_RANGE: float = 320.0 @@ -503,9 +450,7 @@ func has_ship_lab() -> bool: func shield_vessel_unlocked() -> bool: - # Unlocked AT the lab, by paying for it there - a vessel type rather than - # a stat, so it is a one-off rather than a level. - return int(vessel_levels.get(UNLOCK_SHIELD_VESSEL, 0)) > 0 + return vessel_is_unlocked(VESSEL_SHIELD) const UNLOCK_SHIELD_VESSEL: String = "unlock_shield" @@ -513,27 +458,69 @@ const UNLOCK_SHIELD_COST: int = 45 func unlock_shield_vessel() -> bool: - if shield_vessel_unlocked() or not has_ship_lab(): + return unlock_vessel(VESSEL_SHIELD) + + +func vessel_definition(vessel: String) -> Resource: + return VESSEL_DEFINITIONS.get_definition(vessel) + + +func vessel_name(vessel: String) -> String: + var definition: Resource = vessel_definition(vessel) + return definition.display_name if definition != null else vessel.capitalize() + + +func vessel_has_capability(vessel: String, capability: String) -> bool: + var definition: Resource = vessel_definition(vessel) + return definition != null and definition.has_capability(capability) + + +func vessel_is_unlocked(vessel: String) -> bool: + var definition: Resource = vessel_definition(vessel) + if definition == null: return false - if not spend(UNLOCK_SHIELD_COST): + return definition.unlock_id == "" or int(vessel_levels.get(definition.unlock_id, 0)) > 0 + + +func vessel_for_unlock(unlock_id: String) -> Resource: + for definition in VESSEL_DEFINITIONS.all(): + if definition.unlock_id == unlock_id: + return definition + return null + + +func unlock_vessel(vessel: String) -> bool: + var definition: Resource = vessel_definition(vessel) + if definition == null or definition.unlock_id == "" or vessel_is_unlocked(vessel) or not has_ship_lab(): return false - vessel_levels[UNLOCK_SHIELD_VESSEL] = 1 - vessel_upgraded.emit(UNLOCK_SHIELD_VESSEL) + if not spend(definition.unlock_cost): + return false + vessel_levels[definition.unlock_id] = 1 + vessel_upgraded.emit(definition.unlock_id) return true func upgrade_def(id: String) -> Dictionary: - for u in VESSEL_UPGRADES: - if u.id == id: - return u + for definition in VESSEL_DEFINITIONS.all(): + for upgrade in definition.upgrades: + if upgrade.upgrade_id == id: + return {"id": upgrade.upgrade_id, "vessel": definition.definition_id, + "name": upgrade.display_name, "desc": upgrade.description, + "cost": upgrade.base_cost, "max_level": upgrade.max_level, + "icon": upgrade.icon, "resource": upgrade} return {} func upgrades_for(vessel: String) -> Array: var out: Array = [] - for u in VESSEL_UPGRADES: - if u.vessel == vessel: - out.append(u) + var definition: Resource = vessel_definition(vessel) + if definition == null: + return out + for upgrade in definition.upgrades: + out.append({"id": upgrade.upgrade_id, "vessel": vessel, + "name": upgrade.display_name, "desc": upgrade.description, + "cost": upgrade.base_cost, "max_level": upgrade.max_level, + "icon": upgrade.icon, "resource": upgrade}) return out @@ -567,66 +554,51 @@ func buy_upgrade(id: String) -> bool: return true -# --- what those upgrades actually come to --------------------------------- -# Read live rather than baked into a vessel when it is built, so researching -# something improves the ships already flying instead of only the next ones. - -func ship_damage(vessel: String) -> int: - return SHIP_BASE_DAMAGE + (upgrade_level("int_damage") if vessel == VESSEL_INTERCEPTOR else 0) +func vessel_stat(vessel: String, stat: String) -> float: + var definition: Resource = vessel_definition(vessel) + if definition == null: + return 0.0 + var value: float = float(definition.base_stats.get(stat, 0.0)) + for upgrade in definition.upgrades: + var level: int = upgrade_level(upgrade.upgrade_id) + value += float(upgrade.additive_modifiers.get(stat, 0.0)) * level + if upgrade.multiplicative_modifiers.has(stat): + value *= pow(float(upgrade.multiplicative_modifiers[stat]), level) + if definition.minimum_stats.has(stat): + value = maxf(value, float(definition.minimum_stats[stat])) + return value -func ship_fire_cooldown(vessel: String) -> float: - if vessel != VESSEL_INTERCEPTOR: - return SHIP_BASE_COOLDOWN - return SHIP_BASE_COOLDOWN * pow(0.85, float(upgrade_level("int_rate"))) - - -func ship_fire_range(vessel: String) -> float: - return SHIP_BASE_RANGE + (60.0 * upgrade_level("int_range") if vessel == VESSEL_INTERCEPTOR else 0.0) - - -func _speed_levels(vessel: String) -> int: - if vessel == VESSEL_SHIELD: - return upgrade_level("shd_speed") - return upgrade_level("int_speed") - - -func ship_max_speed(vessel: String) -> float: - return SHIP_BASE_SPEED + 40.0 * _speed_levels(vessel) - - -func ship_acceleration(vessel: String) -> float: - return SHIP_BASE_ACCEL + 60.0 * _speed_levels(vessel) - +func ship_damage(vessel: String) -> int: return int(vessel_stat(vessel, "damage")) +func ship_fire_cooldown(vessel: String) -> float: return vessel_stat(vessel, "fire_cooldown") +func ship_fire_range(vessel: String) -> float: return vessel_stat(vessel, "fire_range") +func ship_max_speed(vessel: String) -> float: return vessel_stat(vessel, "max_speed") +func ship_acceleration(vessel: String) -> float: return vessel_stat(vessel, "acceleration") func shield_max_charges() -> int: - return SHIELD_BASE_CHARGES + upgrade_level("shd_hits") - + var definition: Resource = VESSEL_DEFINITIONS.with_capability("shield") + return int(vessel_stat(definition.definition_id, "shield_charges")) func shield_recharge_time() -> float: - return maxf(2.0, SHIELD_BASE_RECHARGE - 1.2 * upgrade_level("shd_recharge")) - + var definition: Resource = VESSEL_DEFINITIONS.with_capability("shield") + return vessel_stat(definition.definition_id, "shield_recharge") func transporter_load_limit() -> int: - return TRANSPORTER_BASE_LOAD + 8 * upgrade_level("trn_capacity") - + var definition: Resource = VESSEL_DEFINITIONS.with_capability("transport") + return int(vessel_stat(definition.definition_id, "cargo_capacity")) func transporter_speed() -> float: - return TRANSPORTER_BASE_SPEED + 70.0 * upgrade_level("trn_speed") + var definition: Resource = VESSEL_DEFINITIONS.with_capability("transport") + return vessel_stat(definition.definition_id, "transport_speed") const PRODUCT_SHIP: String = "interceptor" const PRODUCT_TRANSPORTER: String = "transporter" -const TRANSPORTER_COST: int = 10 - func product_cost(product: String) -> int: - if product == PRODUCT_TRANSPORTER: - return TRANSPORTER_COST - if product == VESSEL_SHIELD: - return SHIELD_COST - return SHIP_COST + var definition: Resource = vessel_definition(product) + return definition.build_cost if definition != null else 0 func can_produce(type: String) -> bool: @@ -713,13 +685,14 @@ func queue_order(id: int, product: String = PRODUCT_SHIP) -> bool: var b: Dictionary = find_building(id) if b.is_empty() or not can_produce(b.type): return false - if product == VESSEL_SHIELD and not shield_vessel_unlocked(): + var definition: Resource = vessel_definition(product) + if definition == null or not vessel_is_unlocked(product): return false - if product == PRODUCT_TRANSPORTER and not has_transport_hub(): + if definition.has_capability("transport") and not has_transport_hub(): # Nowhere to station it. Refused rather than queued, so the player is # told now instead of watching an order sit forever. return false - if product == PRODUCT_TRANSPORTER and not can_station_transporter(): + if definition.has_capability("transport") and not can_station_transporter(): # Every berth is taken or spoken for (#20). return false (b.orders as Array).append({"product": product, "elapsed": 0.0, "started": false}) @@ -742,15 +715,16 @@ func _advance_production(delta: float) -> void: var product: String = String(order.product) var cost: int = product_cost(product) var inventory: RefCounted = _inventory_for_record(b) + var definition: Resource = vessel_definition(product) if not bool(order.started): if inventory.amount(RESOURCE_ORE) < cost: continue - if product == PRODUCT_TRANSPORTER and transporters.size() >= transport_capacity(): + if definition.has_capability("transport") and transporters.size() >= transport_capacity(): # A hub was demolished after this was ordered, so the berth it # was going to use is gone. Hold rather than overfill. continue - if product == PRODUCT_TRANSPORTER and nearest_hub_to(b.position).is_empty(): + if definition.has_capability("transport") and nearest_hub_to(b.position).is_empty(): continue inventory.withdraw(RESOURCE_ORE, cost) order.started = true @@ -760,7 +734,7 @@ func _advance_production(delta: float) -> void: continue orders.pop_front() - if product == PRODUCT_TRANSPORTER: + if definition.has_capability("transport"): # Transporters are unchanged: they take themselves to the nearest # hub and wait for work (#19). _station_transporter(b.position) @@ -782,7 +756,8 @@ func queue_lab_research(lab_id: int, upgrade_id: String) -> bool: if not building_has_capability(lab, "vessel_research") or upgrade_id == "": return false var orders: Array = lab.get("research_orders", []) - if not orders.is_empty() or (upgrade_id != UNLOCK_SHIELD_VESSEL and upgrade_maxed(upgrade_id)): + var unlock_definition: Resource = vessel_for_unlock(upgrade_id) + if not orders.is_empty() or (unlock_definition == null and upgrade_maxed(upgrade_id)): return false orders.append({"id": upgrade_id, "elapsed": 0.0, "started": false}) lab.research_orders = orders @@ -804,7 +779,8 @@ func _advance_lab_research(delta: float) -> void: continue var order: Dictionary = orders[0] var id: String = String(order.id) - var cost: int = UNLOCK_SHIELD_COST if id == UNLOCK_SHIELD_VESSEL else upgrade_cost(id) + var unlock_definition: Resource = vessel_for_unlock(id) + var cost: int = unlock_definition.unlock_cost if unlock_definition != null else upgrade_cost(id) var inventory: RefCounted = _inventory_for_record(lab) if not bool(order.started): if inventory.amount(RESOURCE_ORE) < cost: @@ -814,7 +790,7 @@ func _advance_lab_research(delta: float) -> void: order.elapsed = float(order.elapsed) + delta if float(order.elapsed) < lab_research_time(): continue - if id == UNLOCK_SHIELD_VESSEL: + if unlock_definition != null: vessel_levels[id] = 1 else: vessel_levels[id] = upgrade_level(id) + 1 @@ -1041,7 +1017,7 @@ func pending_transporter_orders() -> int: var n: int = 0 for b in buildings: for o in b.get("orders", []): - if String(o.product) == PRODUCT_TRANSPORTER: + if vessel_has_capability(String(o.product), "transport"): n += 1 return n diff --git a/InterceptorDamageUpgrade.tres b/InterceptorDamageUpgrade.tres new file mode 100644 index 0000000..835851a --- /dev/null +++ b/InterceptorDamageUpgrade.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3] +[ext_resource type="Script" path="res://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 +icon = 15 +additive_modifiers = {"damage": 1.0} diff --git a/InterceptorRangeUpgrade.tres b/InterceptorRangeUpgrade.tres new file mode 100644 index 0000000..ce01efe --- /dev/null +++ b/InterceptorRangeUpgrade.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3] +[ext_resource type="Script" path="res://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 +icon = 17 +additive_modifiers = {"fire_range": 60.0} diff --git a/InterceptorRateUpgrade.tres b/InterceptorRateUpgrade.tres new file mode 100644 index 0000000..4add45e --- /dev/null +++ b/InterceptorRateUpgrade.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3] +[ext_resource type="Script" path="res://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 +icon = 16 +multiplicative_modifiers = {"fire_cooldown": 0.85} diff --git a/InterceptorSpeedUpgrade.tres b/InterceptorSpeedUpgrade.tres new file mode 100644 index 0000000..5549971 --- /dev/null +++ b/InterceptorSpeedUpgrade.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3] +[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"] +[resource] +script = ExtResource("1") +upgrade_id = "int_speed" +display_name = "Thrusters" +description = "+40 top speed, +60 acceleration per level." +base_cost = 25 +max_level = 3 +icon = 18 +additive_modifiers = {"max_speed": 40.0, "acceleration": 60.0} diff --git a/InterceptorVesselDefinition.tres b/InterceptorVesselDefinition.tres new file mode 100644 index 0000000..a18f6c4 --- /dev/null +++ b/InterceptorVesselDefinition.tres @@ -0,0 +1,20 @@ +[gd_resource type="Resource" script_class="VesselDefinition" load_steps=7 format=3] +[ext_resource type="Script" path="res://VesselDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://VesselWeaponComponent.tscn" id="2"] +[ext_resource type="Resource" path="res://InterceptorDamageUpgrade.tres" id="3"] +[ext_resource type="Resource" path="res://InterceptorRateUpgrade.tres" id="4"] +[ext_resource type="Resource" path="res://InterceptorRangeUpgrade.tres" id="5"] +[ext_resource type="Resource" path="res://InterceptorSpeedUpgrade.tres" id="6"] +[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) +capabilities = PackedStringArray("weapon") +component_scenes = Array[PackedScene]([ExtResource("2")]) +base_stats = {"damage": 1.0, "fire_cooldown": 0.5, "fire_range": 320.0, "max_speed": 460.0, "acceleration": 450.0} +upgrades = Array[Resource]([ExtResource("3"), ExtResource("4"), ExtResource("5"), ExtResource("6")]) diff --git a/ShieldHitsUpgrade.tres b/ShieldHitsUpgrade.tres new file mode 100644 index 0000000..2530597 --- /dev/null +++ b/ShieldHitsUpgrade.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3] +[ext_resource type="Script" path="res://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 +icon = 19 +additive_modifiers = {"shield_charges": 1.0} diff --git a/ShieldRechargeUpgrade.tres b/ShieldRechargeUpgrade.tres new file mode 100644 index 0000000..d73cf09 --- /dev/null +++ b/ShieldRechargeUpgrade.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3] +[ext_resource type="Script" path="res://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 +icon = 20 +additive_modifiers = {"shield_recharge": -1.2} diff --git a/ShieldSpeedUpgrade.tres b/ShieldSpeedUpgrade.tres new file mode 100644 index 0000000..fe5a4b6 --- /dev/null +++ b/ShieldSpeedUpgrade.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3] +[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"] +[resource] +script = ExtResource("1") +upgrade_id = "shd_speed" +display_name = "Thrusters" +description = "+40 top speed, +60 acceleration per level." +base_cost = 25 +max_level = 3 +icon = 18 +additive_modifiers = {"max_speed": 40.0, "acceleration": 60.0} diff --git a/ShieldVesselDefinition.tres b/ShieldVesselDefinition.tres new file mode 100644 index 0000000..9df3bf8 --- /dev/null +++ b/ShieldVesselDefinition.tres @@ -0,0 +1,25 @@ +[gd_resource type="Resource" script_class="VesselDefinition" load_steps=7 format=3] +[ext_resource type="Script" path="res://VesselDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://VesselWeaponComponent.tscn" id="2"] +[ext_resource type="PackedScene" path="res://VesselShieldComponent.tscn" id="3"] +[ext_resource type="Resource" path="res://ShieldHitsUpgrade.tres" id="4"] +[ext_resource type="Resource" path="res://ShieldRechargeUpgrade.tres" id="5"] +[ext_resource type="Resource" path="res://ShieldSpeedUpgrade.tres" id="6"] +[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 = 2 +glow_color = Color(0.45, 0.8, 1, 1) +capabilities = PackedStringArray("weapon", "shield") +component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3")]) +base_stats = {"damage": 1.0, "fire_cooldown": 0.5, "fire_range": 320.0, "max_speed": 460.0, "acceleration": 450.0, "shield_charges": 1.0, "shield_recharge": 6.0} +minimum_stats = {"shield_recharge": 2.0} +upgrades = Array[Resource]([ExtResource("4"), ExtResource("5"), ExtResource("6")]) +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 diff --git a/Ship.gd b/Ship.gd index 624c312..1c2727c 100644 --- a/Ship.gd +++ b/Ship.gd @@ -2,224 +2,134 @@ extends Area2D signal destroyed -# Deliberately faster than Fleet.cruise_speed: the fleet as a whole travels -# at the cruise rate, and this headroom is what lets an individual ship -# close on its formation slot - so a pivot mid-flight resolves quickly -# instead of the ship having no speed budget left over for it. +const VESSEL_DEFINITIONS = preload("res://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 fire_range: float = 320.0 -@export var fire_cooldown: float = 0.5 -# How long to wait before looking for targets again after finding none. -@export var idle_rescan_interval: float = 0.25 -@export var projectile_damage: int = 1 - -# Which kind of vessel this is (GameState.VESSEL_*). Drives both its -# researched statistics and whether it carries a shield (#5). @export var vessel_type: String = "interceptor" -# --- shield (#5) ---------------------------------------------------------- -const SHIELD_COLOR: Color = Color(0.45, 0.8, 1.0) -const SHIELD_RADIUS: float = 30.0 - -# A shield vessel survives a hit that would destroy any other ship, and is -# then defenceless until it cycles back up. Personal, not a bubble: it -# protects the vessel carrying it and nothing else. -var shield_charges: int = 0 -var shield_recharge_left: float = 0.0 -var _shield_ring: Node2D = null - -# Neon colours, assigned by Fleet on spawn so a scene can tint its fleet. -# The lunar surface is a bright grey, where the default near-white core all -# but vanishes - PlanetSurface.tscn passes a dark core there. See NeonDraw. +var definition: Resource var glow_color: Color = Color(0.25, 0.85, 1.0) -var core_color: Color = Color(1.0, 1.0, 1.0) - +var core_color: Color = Color.WHITE var velocity: Vector2 = Vector2.ZERO -# The fleet's own velocity, supplied by Fleet as CONTEXT rather than as a -# facing command - the ship works out where to point from it. -# -# It is needed because `velocity` below is not the ship's true motion while -# it is holding formation: the slot it chases sits inside arrive_radius, so -# the ship snaps onto it and reports zero velocity even though it is being -# carried along at cruise speed. Facing derived from `velocity` alone -# therefore froze at whatever heading the ship last happened to travel. -# Adding the two gives the ship's ACTUAL motion through the world, which is -# the honest thing to point along in every case: it equals the fleet's -# heading when station-keeping, the ship's own heading when manoeuvring -# alone, and the true blend of both while strafing into a new slot. var fleet_velocity: Vector2 = Vector2.ZERO var move_target: Vector2 -var fire_timer: float = 0.0 -var projectile_scene: PackedScene = preload("res://Projectile.tscn") + +# 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) - if vessel_type == GameState.VESSEL_SHIELD: - _build_shield_ring() - shield_charges = GameState.shield_max_charges() + 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: - # Read from GameState rather than baked in at spawn, so researching an - # upgrade improves the ships already flying and not just the next one - # built. Fleet calls this on every vessel when something is bought. - max_speed = GameState.ship_max_speed(vessel_type) - acceleration = GameState.ship_acceleration(vessel_type) - fire_range = GameState.ship_fire_range(vessel_type) - fire_cooldown = GameState.ship_fire_cooldown(vessel_type) - projectile_damage = GameState.ship_damage(vessel_type) - if vessel_type == GameState.VESSEL_SHIELD: - # Topping up a live shield with researched capacity, without - # refilling one that is currently down and cycling. - var was_up: bool = shield_charges > 0 - shield_charges = GameState.shield_max_charges() if was_up else shield_charges - _update_shield_visual() + 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: - return vessel_type == GameState.VESSEL_SHIELD and shield_charges > 0 - - -func _update_shield_visual() -> void: - if _shield_ring == null: - return - # visible only - per #10 the ring is rasterised once and never redrawn to - # turn it on and off. - _shield_ring.visible = has_shield() - - -func _tick_shield(delta: float) -> void: - if vessel_type != GameState.VESSEL_SHIELD: - return - if shield_charges >= GameState.shield_max_charges(): - return - shield_recharge_left -= delta - if shield_recharge_left <= 0.0: - shield_charges += 1 - shield_recharge_left = GameState.shield_recharge_time() - _update_shield_visual() + var component: Node = get_component("shield") + return component != null and component.is_active() func _physics_process(delta: float) -> void: - _tick_shield(delta) + for child in get_children(): + if child.has_method("tick"): + child.tick(delta) + var to_target: Vector2 = move_target - position var distance: float = to_target.length() - if distance <= arrive_radius: - # Snap and stop dead - prevents residual float-level jitter that - # would otherwise keep nudging back and forth around the target. position = move_target velocity = Vector2.ZERO else: - # Braking-distance formula: the fastest speed we can be going right - # now and still be able to decelerate to 0 exactly at the target, - # given our acceleration. Without this the ship approaches at full - # speed, overshoots (stopping distance = max_speed^2 / (2*accel)), - # and oscillates back and forth forever instead of settling. var braking_speed: float = sqrt(2.0 * acceleration * distance) var desired_speed: float = min(max_speed, braking_speed) var desired_velocity: Vector2 = to_target.normalized() * desired_speed velocity = velocity.move_toward(desired_velocity, acceleration * delta) position += velocity * delta - # One owner, one rule: face the way this ship is actually travelling. var motion: Vector2 = velocity + fleet_velocity if motion.length() > 8.0: var target_angle: float = motion.angle() + PI / 2.0 rotation = lerp_angle(rotation, target_angle, rotation_speed * delta) - _update_weapon(delta) - - -func _update_weapon(delta: float) -> void: - fire_timer -= delta - if fire_timer > 0.0: - return - - var target: Node2D = _find_nearest_enemy() - if target == null: - # Back off before scanning again. Without this, fire_timer stays at - # or below zero and every ship queries the whole "enemies" group and - # loops it on EVERY physics frame whenever nothing is in range - - # which is most of the time. - fire_timer = idle_rescan_interval - return - - fire_timer = fire_cooldown - var proj: Node2D = projectile_scene.instantiate() - proj.position = position - proj.damage = projectile_damage - get_tree().root.add_child(proj) - proj.launch(target.position - position) - - -func _find_nearest_enemy() -> Node2D: - var enemies: Array = get_tree().get_nodes_in_group("enemies") - var nearest: Node2D = null - var best_dist: float = fire_range - - for e in enemies: - if not is_instance_valid(e): - continue - var d: float = position.distance_to(e.position) - if d <= best_dist: - best_dist = d - nearest = e - - return nearest - func _on_area_entered(area: Area2D) -> void: if not (area.is_in_group("enemies") or area.is_in_group("asteroids")): return - if has_shield(): - # Absorbed. The thing that hit it is still destroyed - a shield stops - # the vessel dying, it does not make the rock harmless - but the - # vessel survives and is defenceless until the shield cycles back. - shield_charges -= 1 - shield_recharge_left = GameState.shield_recharge_time() - _update_shield_visual() - if area.is_in_group("asteroids") and is_instance_valid(area): - area.queue_free() - return + for child in get_children(): + if child.has_method("absorb_hit") and child.absorb_hit(): + if area.is_in_group("asteroids") and is_instance_valid(area): + area.queue_free() + return ShipExplosionVFX.spawn(get_tree().root, position) destroyed.emit() queue_free() -func _build_shield_ring() -> void: - # Its own child, drawn once, toggled with `visible` (#10). A shield that - # came and went by redrawing the ship would re-tessellate the hull every - # time it cycled. - _shield_ring = NeonPiece.create(self, Vector2.ZERO, SHIELD_COLOR) - var points: PackedVector2Array = PackedVector2Array() - for i in range(14): - var a: float = (float(i) / 14.0) * TAU - points.append(Vector2(cos(a), sin(a)) * SHIELD_RADIUS) - _shield_ring.add_polygon(points, 0.7) - _shield_ring.queue_redraw() - _shield_ring.visible = false - - func _draw() -> void: - var nose: Vector2 = Vector2(0, -22) - var left: Vector2 = Vector2(-14, 16) - var right: Vector2 = Vector2(14, 16) - var points: PackedVector2Array = PackedVector2Array([nose, left, right]) - # Constant glow, drawn once. No pulse means nothing changes frame to - # frame, so the ship never needs redrawing after _ready() - which also - # removes four antialiased polyline rebuilds per ship per frame. + 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) diff --git a/TransportCapacityUpgrade.tres b/TransportCapacityUpgrade.tres new file mode 100644 index 0000000..2c2b920 --- /dev/null +++ b/TransportCapacityUpgrade.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3] +[ext_resource type="Script" path="res://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 +icon = 21 +additive_modifiers = {"cargo_capacity": 8.0} diff --git a/TransportSpeedUpgrade.tres b/TransportSpeedUpgrade.tres new file mode 100644 index 0000000..43c3c96 --- /dev/null +++ b/TransportSpeedUpgrade.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3] +[ext_resource type="Script" path="res://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 +icon = 22 +additive_modifiers = {"transport_speed": 70.0} diff --git a/TransporterVesselDefinition.tres b/TransporterVesselDefinition.tres new file mode 100644 index 0000000..c654a48 --- /dev/null +++ b/TransporterVesselDefinition.tres @@ -0,0 +1,19 @@ +[gd_resource type="Resource" script_class="VesselDefinition" load_steps=6 format=3] +[ext_resource type="Script" path="res://VesselDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://VesselCargoComponent.tscn" id="2"] +[ext_resource type="PackedScene" path="res://VesselTransportComponent.tscn" id="3"] +[ext_resource type="Resource" path="res://TransportCapacityUpgrade.tres" id="4"] +[ext_resource type="Resource" path="res://TransportSpeedUpgrade.tres" id="5"] +[resource] +script = ExtResource("1") +definition_id = "transporter" +display_name = "Transporter" +description = "Unarmed cargo vessel stationed at a transport hub to move resources." +build_cost = 10 +build_time = 8.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": 450.0, "cargo_capacity": 20.0, "transport_speed": 340.0} +upgrades = Array[Resource]([ExtResource("4"), ExtResource("5")]) diff --git a/VesselCargoComponent.gd b/VesselCargoComponent.gd new file mode 100644 index 0000000..4373500 --- /dev/null +++ b/VesselCargoComponent.gd @@ -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")) diff --git a/VesselCargoComponent.gd.uid b/VesselCargoComponent.gd.uid new file mode 100644 index 0000000..85443bc --- /dev/null +++ b/VesselCargoComponent.gd.uid @@ -0,0 +1 @@ +uid://bo16x4wq6hg5k diff --git a/VesselCargoComponent.tscn b/VesselCargoComponent.tscn new file mode 100644 index 0000000..34eafa6 --- /dev/null +++ b/VesselCargoComponent.tscn @@ -0,0 +1,4 @@ +[gd_scene load_steps=2 format=3] +[ext_resource type="Script" path="res://VesselCargoComponent.gd" id="1"] +[node name="CargoComponent" type="Node"] +script = ExtResource("1") diff --git a/VesselDefinition.gd b/VesselDefinition.gd new file mode 100644 index 0000000..0f6637f --- /dev/null +++ b/VesselDefinition.gd @@ -0,0 +1,26 @@ +class_name VesselDefinition +extends Resource + +@export var definition_id: String +@export var display_name: String +@export_multiline var description: String +@export var build_cost: int +@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 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 + + +func has_capability(capability: String) -> bool: + return capability in capabilities diff --git a/VesselDefinition.gd.uid b/VesselDefinition.gd.uid new file mode 100644 index 0000000..371e084 --- /dev/null +++ b/VesselDefinition.gd.uid @@ -0,0 +1 @@ +uid://c6txy3v61blm8 diff --git a/VesselDefinitions.gd b/VesselDefinitions.gd new file mode 100644 index 0000000..2b33845 --- /dev/null +++ b/VesselDefinitions.gd @@ -0,0 +1,24 @@ +class_name VesselDefinitions +extends RefCounted + +const ORDER: PackedStringArray = ["interceptor", "shield", "transporter"] +const DEFINITIONS: Dictionary = { + "interceptor": preload("res://InterceptorVesselDefinition.tres"), + "shield": preload("res://ShieldVesselDefinition.tres"), + "transporter": preload("res://TransporterVesselDefinition.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 with_capability(capability: String) -> Resource: + for definition in all(): + if definition.has_capability(capability): + return definition + return null diff --git a/VesselDefinitions.gd.uid b/VesselDefinitions.gd.uid new file mode 100644 index 0000000..d907359 --- /dev/null +++ b/VesselDefinitions.gd.uid @@ -0,0 +1 @@ +uid://cgwj1q4nf3ar7 diff --git a/VesselDelivery.gd b/VesselDelivery.gd index 4c07a6d..6392b62 100644 --- a/VesselDelivery.gd +++ b/VesselDelivery.gd @@ -1,8 +1,10 @@ extends Node2D +const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd") + # Visual-only craft-to-mothership flight. GameState owns position/progress; # this node just renders a finished hull headed toward the hangar. -var vessel_type: String = GameState.VESSEL_INTERCEPTOR +var vessel_type: String = "interceptor" func _ready() -> void: @@ -11,6 +13,6 @@ func _ready() -> void: func _draw() -> void: - var color: Color = Color(0.45, 0.8, 1.0) if vessel_type == GameState.VESSEL_SHIELD else Color(0.95, 0.55, 0.85) if vessel_type == GameState.VESSEL_TRANSPORTER else Color(0.25, 0.85, 1.0) - var hull := PackedVector2Array([Vector2(0, -20), Vector2(13, 15), Vector2(0, 9), Vector2(-13, 15)]) - NeonDraw.draw_glowing_polygon(self, hull, color, 1.0) + var definition: Resource = VESSEL_DEFINITIONS.get_definition(vessel_type) + if definition != null: + NeonDraw.draw_glowing_polygon(self, definition.hull_points, definition.glow_color, 1.0) diff --git a/VesselDeliveryField.gd b/VesselDeliveryField.gd index 606b1cf..9b47319 100644 --- a/VesselDeliveryField.gd +++ b/VesselDeliveryField.gd @@ -17,9 +17,9 @@ func _process(_delta: float) -> void: var node: Node2D = active.get(i) if node == null or not is_instance_valid(node): node = delivery_script.new() + node.vessel_type = String(delivery.type) get_parent().add_child(node) active[i] = node - node.vessel_type = String(delivery.type) node.position = pos var direction: Vector2 = (delivery.to as Vector2) - (delivery.from as Vector2) if direction.length() > 0.1: diff --git a/VesselShieldComponent.gd b/VesselShieldComponent.gd new file mode 100644 index 0000000..15a8edf --- /dev/null +++ b/VesselShieldComponent.gd @@ -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() diff --git a/VesselShieldComponent.gd.uid b/VesselShieldComponent.gd.uid new file mode 100644 index 0000000..4ab2382 --- /dev/null +++ b/VesselShieldComponent.gd.uid @@ -0,0 +1 @@ +uid://ghacatobxl11 diff --git a/VesselShieldComponent.tscn b/VesselShieldComponent.tscn new file mode 100644 index 0000000..50ba89e --- /dev/null +++ b/VesselShieldComponent.tscn @@ -0,0 +1,4 @@ +[gd_scene load_steps=2 format=3] +[ext_resource type="Script" path="res://VesselShieldComponent.gd" id="1"] +[node name="ShieldComponent" type="Node"] +script = ExtResource("1") diff --git a/VesselTransportComponent.gd b/VesselTransportComponent.gd new file mode 100644 index 0000000..7aec619 --- /dev/null +++ b/VesselTransportComponent.gd @@ -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") diff --git a/VesselTransportComponent.gd.uid b/VesselTransportComponent.gd.uid new file mode 100644 index 0000000..a2c81fd --- /dev/null +++ b/VesselTransportComponent.gd.uid @@ -0,0 +1 @@ +uid://tsapg8ld1vl8 diff --git a/VesselTransportComponent.tscn b/VesselTransportComponent.tscn new file mode 100644 index 0000000..a16d4e9 --- /dev/null +++ b/VesselTransportComponent.tscn @@ -0,0 +1,4 @@ +[gd_scene load_steps=2 format=3] +[ext_resource type="Script" path="res://VesselTransportComponent.gd" id="1"] +[node name="TransportComponent" type="Node"] +script = ExtResource("1") diff --git a/VesselUpgradeDefinition.gd b/VesselUpgradeDefinition.gd new file mode 100644 index 0000000..083e092 --- /dev/null +++ b/VesselUpgradeDefinition.gd @@ -0,0 +1,11 @@ +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 = {} diff --git a/VesselUpgradeDefinition.gd.uid b/VesselUpgradeDefinition.gd.uid new file mode 100644 index 0000000..c615214 --- /dev/null +++ b/VesselUpgradeDefinition.gd.uid @@ -0,0 +1 @@ +uid://dw41jux1cn3sw diff --git a/VesselWeaponComponent.gd b/VesselWeaponComponent.gd new file mode 100644 index 0000000..1bc6855 --- /dev/null +++ b/VesselWeaponComponent.gd @@ -0,0 +1,47 @@ +class_name VesselWeaponComponent +extends Node + +var capability_id: String = "weapon" +var fire_range: float +var fire_cooldown: float +var projectile_damage: int +var fire_timer: float = 0.0 +var projectile_scene: PackedScene = preload("res://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")) + + +func tick(delta: float) -> void: + fire_timer -= delta + if fire_timer > 0.0: + return + var ship: Node2D = get_parent() + var target: Node2D = _find_nearest_enemy(ship) + if target == null: + fire_timer = 0.25 + return + var projectile: Node2D = projectile_scene.instantiate() + projectile.position = ship.position + projectile.damage = projectile_damage + ship.get_tree().root.add_child(projectile) + projectile.launch(target.position - ship.position) + fire_timer = fire_cooldown + + +func _find_nearest_enemy(ship: Node2D) -> Node2D: + var nearest: Node2D = null + var best_distance: float = fire_range + for enemy in ship.get_tree().get_nodes_in_group("enemies"): + if not is_instance_valid(enemy): + continue + var distance: float = ship.position.distance_to(enemy.position) + if distance <= best_distance: + best_distance = distance + nearest = enemy + return nearest diff --git a/VesselWeaponComponent.gd.uid b/VesselWeaponComponent.gd.uid new file mode 100644 index 0000000..9fd5306 --- /dev/null +++ b/VesselWeaponComponent.gd.uid @@ -0,0 +1 @@ +uid://cwo0nkmr4e1sl diff --git a/VesselWeaponComponent.tscn b/VesselWeaponComponent.tscn new file mode 100644 index 0000000..b3bd611 --- /dev/null +++ b/VesselWeaponComponent.tscn @@ -0,0 +1,4 @@ +[gd_scene load_steps=2 format=3] +[ext_resource type="Script" path="res://VesselWeaponComponent.gd" id="1"] +[node name="WeaponComponent" type="Node"] +script = ExtResource("1") diff --git a/tests/test_building_composition.gd.uid b/tests/test_building_composition.gd.uid new file mode 100644 index 0000000..f74b39a --- /dev/null +++ b/tests/test_building_composition.gd.uid @@ -0,0 +1 @@ +uid://bljnijxm3n20e diff --git a/tests/test_hangar_management.gd b/tests/test_hangar_management.gd index eb6d399..4de3cac 100644 --- a/tests/test_hangar_management.gd +++ b/tests/test_hangar_management.gd @@ -68,6 +68,7 @@ func _process(delta: float) -> bool: # own non-overlapping berths to the right only after expansion. var mothership: Node2D = load("res://Building.tscn").instantiate() mothership.set("building_type", GameState.BUILDING_MOTHERSHIP) + mothership._ready() controller.current_building = mothership controller.context_actions_expanded = true controller._layout_context_buttons() diff --git a/tests/test_vessel_composition.gd b/tests/test_vessel_composition.gd new file mode 100644 index 0000000..f25f447 --- /dev/null +++ b/tests/test_vessel_composition.gd @@ -0,0 +1,50 @@ +extends SceneTree + +const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd") + +var ships: Array = [] +var elapsed: float = 0.0 +var game_state: Node + + +func _initialize() -> void: + game_state = root.get_node("GameState") + var scene: PackedScene = load("res://Ship.tscn") + for definition in VESSEL_DEFINITIONS.all(): + var ship: Area2D = scene.instantiate() + ship.vessel_type = definition.definition_id + root.add_child(ship) + ships.append(ship) + + +func _process(delta: float) -> bool: + elapsed += delta + if elapsed < 0.1: + return false + var definitions: Array = VESSEL_DEFINITIONS.all() + if ships.size() != definitions.size(): + return _fail("catalog and composed fleet differ in size") + for i in range(definitions.size()): + var definition: Resource = definitions[i] + var ship: Node = ships[i] + if ship.definition == null or ship.definition.definition_id != definition.definition_id: + return _fail("%s did not resolve its definition" % definition.definition_id) + for capability in definition.capabilities: + if not ship.has_capability(capability): + return _fail("%s did not compose %s" % [definition.definition_id, capability]) + if game_state.product_cost(definition.definition_id) != definition.build_cost \ + or game_state.vessel_name(definition.definition_id) != definition.display_name: + return _fail("%s metadata did not come from its definition" % definition.definition_id) + if ships[0].has_capability("shield") or not ships[1].has_capability("shield"): + return _fail("shield behavior is not controlled by component presence") + if ships[2].has_capability("weapon") or not ships[2].has_capability("transport"): + return _fail("transporter capabilities were composed incorrectly") + print("TEST_VESSEL_COMPOSITION: PASS") + quit(0) + return true + + +func _fail(message: String) -> bool: + print("TEST_VESSEL_COMPOSITION: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_vessel_composition.gd.uid b/tests/test_vessel_composition.gd.uid new file mode 100644 index 0000000..d490e27 --- /dev/null +++ b/tests/test_vessel_composition.gd.uid @@ -0,0 +1 @@ +uid://cq73poxld1e35