From fc68c4e8cf4be65e9261fc18513a0cd4a27cb499 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Thu, 13 Aug 2026 21:07:35 +0200 Subject: [PATCH] Add gas giant resource system --- BuildController.gd | 4 +-- GameState.gd | 21 +++++++++---- GasCounter.gd | 16 ++++++++++ Main.tscn | 31 ++++++++++++++++++- Planet.gd | 32 +++++++++++++++++++- PlanetField.gd | 2 ++ ResourceBar.gd | 11 +++++++ WorldGen.gd | 9 +++++- tests/test_gas_system.gd | 64 ++++++++++++++++++++++++++++++++++++++++ 9 files changed, 180 insertions(+), 10 deletions(-) create mode 100644 GasCounter.gd create mode 100644 tests/test_gas_system.gd diff --git a/BuildController.gd b/BuildController.gd index 2568c06..8b64164 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -643,7 +643,7 @@ 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." % planet.planet_name) + 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("WARNING: only one may exist - this destroys your current one.") @@ -685,7 +685,7 @@ func _confirm_build() -> void: if not GameState.spend(GameState.EXTRACTOR_COST): build_status.text = "Not enough rocks (need %d, have %d)" % [GameState.EXTRACTOR_COST, GameState.rocks] return - GameState.add_extractor(planet.planet_name, planet.position) + GameState.add_extractor(planet.planet_name, planet.position, planet.resource_type) build_menu.visible = false return diff --git a/GameState.gd b/GameState.gd index 0ab114f..1383eb3 100644 --- a/GameState.gd +++ b/GameState.gd @@ -28,6 +28,11 @@ var explored_chunks: Dictionary = {} # first - drop this back to 0 for a real playthrough. var rocks: int = 500 +# 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 + # 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 @@ -772,7 +777,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", "stock", "mining_progress"}. +# {"position", "planet_name", "resource_type", "stock", "mining_progress"}. const EXTRACTOR_RATE: float = 2.0 # ore per second var extractors: Array = [] @@ -788,10 +793,11 @@ func has_extractor_at(planet_name: String) -> bool: signal extractor_added(planet_name: String) -func add_extractor(planet_name: String, at_position: Vector2) -> void: +func add_extractor(planet_name: String, at_position: Vector2, resource_type: String = "ore") -> void: extractors.append({ "position": at_position, "planet_name": planet_name, + "resource_type": resource_type, "stock": 0, "mining_progress": 0.0, }) @@ -917,7 +923,7 @@ func _station_transporter(built_at: Vector2) -> void: return transporters.append({ "hub_id": int(hub.id), "state": TRANSPORTER_RETURNING, "target_id": 0, - "carrying": 0, "progress": 0.0, "from": built_at, "to": hub.position, + "carrying": 0, "resource_type": "ore", "progress": 0.0, "from": built_at, "to": hub.position, }) @@ -977,6 +983,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.target_id = 0 _begin_leg(chosen_extractor, transporter_position(chosen_extractor), extractor.position, TRANSPORTER_TO_EXTRACTOR) @@ -1058,10 +1065,14 @@ func _arrive(t: Dictionary) -> void: return extractor.stock = int(extractor.stock) - load t.carrying = load + t.resource_type = String(extractor.get("resource_type", "ore")) _begin_leg(t, here, get_mothership_position(), TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR) TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR: - # Bank only on arrival; while in flight the ore remains in carrying. - rocks += int(t.carrying) + # 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) t.carrying = 0 t.extractor_index = -1 _begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING) diff --git a/GasCounter.gd b/GasCounter.gd new file mode 100644 index 0000000..deb7e2b --- /dev/null +++ b/GasCounter.gd @@ -0,0 +1,16 @@ +extends NeonNumber + +# Banked gas companion to the ore counter. Keeping it as the same neon +# numeral language makes both resources equally legible without implying gas +# can pay for ore-only construction. +const GAS_GLOW: Color = Color(0.85, 0.52, 1.0) +const GAS_CORE: Color = Color(0.2, 0.04, 0.28) + + +func _ready() -> void: + glow_color = GAS_GLOW + core_color = GAS_CORE + + +func _process(_delta: float) -> void: + set_amount(GameState.gas) diff --git a/Main.tscn b/Main.tscn index dbd94fd..4f65ca6 100644 --- a/Main.tscn +++ b/Main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=21 format=3] +[gd_scene load_steps=22 format=3] [ext_resource type="Script" path="res://Starfield.gd" id="1"] [ext_resource type="Script" path="res://Fleet.gd" id="2"] @@ -23,6 +23,7 @@ [ext_resource type="Script" path="res://CargoCounter.gd" id="22"] [ext_resource type="Script" path="res://TransporterField.gd" id="23"] [ext_resource type="Script" path="res://ResourceBar.gd" id="24"] +[ext_resource type="Script" path="res://GasCounter.gd" id="25"] [sub_resource type="ShaderMaterial" id="1"] shader = ExtResource("5") @@ -91,6 +92,34 @@ offset_bottom = 92 script = ExtResource("16") digit_height = 60.0 +[node name="OreCaption" type="Label" parent="UI"] +mouse_filter = 2 +offset_left = 28 +offset_top = 22 +offset_right = 120 +offset_bottom = 48 +theme_override_font_sizes/font_size = 18 +text = "ORE" + +[node name="GasCounter" type="Control" parent="UI"] +mouse_filter = 2 +offset_left = 336 +offset_top = 24 +offset_right = 628 +offset_bottom = 92 +script = ExtResource("25") +digit_height = 60.0 + +[node name="GasCaption" type="Label" parent="UI"] +mouse_filter = 2 +offset_left = 336 +offset_top = 22 +offset_right = 428 +offset_bottom = 48 +theme_override_font_sizes/font_size = 18 +theme_override_colors/font_color = Color(0.85, 0.52, 1, 1) +text = "GAS" + [node name="CargoCounter" type="Control" parent="UI"] mouse_filter = 2 offset_left = 28 diff --git a/Planet.gd b/Planet.gd index f3dbc4a..c4eddbd 100644 --- a/Planet.gd +++ b/Planet.gd @@ -11,7 +11,8 @@ extends Node2D # while the fleet as a whole was clearly sitting on the planet. @export var planet_name: String = "" -@export var planet_type: String = "big_stable_asteroid" +@export var planet_type: String = "rocky" +@export var resource_type: String = "ore" @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 @@ -36,6 +37,7 @@ 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: @@ -54,6 +56,12 @@ func _ready() -> void: 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. @@ -103,6 +111,28 @@ 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/PlanetField.gd b/PlanetField.gd index af7a747..4dbbdd8 100644 --- a/PlanetField.gd +++ b/PlanetField.gd @@ -83,6 +83,8 @@ 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")) # 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/ResourceBar.gd b/ResourceBar.gd index 8668ea9..9880c5c 100644 --- a/ResourceBar.gd +++ b/ResourceBar.gd @@ -41,6 +41,7 @@ const RULE_COLOR: Color = Color(0.25, 0.55, 0.7) var _numbers: Array[NeonNumber] = [] var _captions: Array[Label] = [] +var _gas_caption: Label func _ready() -> void: @@ -69,6 +70,11 @@ func _ready() -> void: _layout() resized.connect(_layout) + _gas_caption = Label.new() + _gas_caption.mouse_filter = Control.MOUSE_FILTER_IGNORE + _gas_caption.add_theme_font_size_override("font_size", 20) + _gas_caption.add_theme_color_override("font_color", Color(0.85, 0.52, 1.0)) + add_child(_gas_caption) func _layout() -> void: @@ -80,9 +86,14 @@ func _layout() -> void: # The hexagon and digits are drawn from the node's own origin, so the # number is positioned rather than sized. _numbers[i].position = Vector2(x, CAPTION_HEIGHT + CAPTION_GAP) + if _gas_caption != null: + _gas_caption.position = Vector2(size.x - 110.0, 2.0) + _gas_caption.size = Vector2(106.0, CAPTION_HEIGHT) + _gas_caption.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT func _process(_delta: float) -> void: + _gas_caption.text = "GAS %d" % GameState.gas _numbers[0].set_amount(GameState.rocks) _numbers[1].set_amount(GameState.fleet_cargo) diff --git a/WorldGen.gd b/WorldGen.gd index 75b4521..ee0ce6f 100644 --- a/WorldGen.gd +++ b/WorldGen.gd @@ -19,6 +19,7 @@ const PLANET_NAMES: Array = [ "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 @@ -79,7 +80,13 @@ static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary: var local_pos: Vector2 = 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() - return {"local_pos": local_pos, "name": planet_name, "shape_seed": shape_seed} + # A stable per-chunk roll makes gas giants persistent landmarks rather + # than a different resource whenever the scene reloads. + var gas_giant: bool = rng.randf() < 0.28 + 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"} static func biome_color(biome: int) -> Color: diff --git a/tests/test_gas_system.gd b/tests/test_gas_system.gd new file mode 100644 index 0000000..d690b9e --- /dev/null +++ b/tests/test_gas_system.gd @@ -0,0 +1,64 @@ +extends SceneTree + +# Forgejo #27: gas giants are deterministic animated gas sources, and the +# existing transporter route carries their gas without converting it to ore. + +var game_state: Node +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.gas = 0 + game_state.rocks = 0 + + +func _process(delta: float) -> bool: + elapsed += delta + if stage == 0: + var found_giant: bool = false + 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" + 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) + if giant.gas_phase <= 0.0: + return _fail("gas giant animation did not advance") + giant.free() + + game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO) + game_state.add_building(GameState.BUILDING_TRANSPORT_HUB, Vector2(100, 0)) + game_state.add_extractor("Aurelia", Vector2(400, 0), "gas") + game_state.extractors[0].stock = game_state.transporter_load_limit() + game_state.transporters.append({"hub_id": 2, "state": GameState.TRANSPORTER_TO_EXTRACTOR, + "target_id": 0, "extractor_index": 0, "carrying": 0, "resource_type": "gas", + "progress": 1.0, "from": Vector2(100, 0), "to": Vector2(400, 0)}) + game_state._arrive(game_state.transporters[0]) + if game_state.transporters[0].state != GameState.TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR: + return _fail("transporter did not collect gas from the extractor") + game_state._arrive(game_state.transporters[0]) + if game_state.gas != game_state.transporter_load_limit() or game_state.rocks != 0: + return _fail("gas delivery banked gas=%d ore=%d" % [game_state.gas, game_state.rocks]) + print("gas giant animated; transporter banked %d gas without creating ore" % game_state.gas) + print("TEST_GAS_SYSTEM: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_GAS_SYSTEM: FAIL (%s)" % message) + quit(1) + return true