Add gas giant resource system

This commit is contained in:
Loïc Wisniewski 2026-08-13 21:07:35 +02:00
commit fc68c4e8cf
9 changed files with 180 additions and 10 deletions

View file

@ -643,7 +643,7 @@ func _show_building_info(type: String) -> void:
lines.append("%s already has an extractor." % planet.planet_name) lines.append("%s already has an extractor." % planet.planet_name)
can_build = false can_build = false
else: 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(): elif can_build and type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership():
lines.append("WARNING: only one may exist - this destroys your current one.") 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): if not GameState.spend(GameState.EXTRACTOR_COST):
build_status.text = "Not enough rocks (need %d, have %d)" % [GameState.EXTRACTOR_COST, GameState.rocks] build_status.text = "Not enough rocks (need %d, have %d)" % [GameState.EXTRACTOR_COST, GameState.rocks]
return return
GameState.add_extractor(planet.planet_name, planet.position) GameState.add_extractor(planet.planet_name, planet.position, planet.resource_type)
build_menu.visible = false build_menu.visible = false
return return

View file

@ -28,6 +28,11 @@ var explored_chunks: Dictionary = {}
# first - drop this back to 0 for a real playthrough. # first - drop this back to 0 for a real playthrough.
var rocks: int = 500 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 # 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 # 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 # 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 # Extractors mine into their OWN local stock. They do not own a magical
# hauler: ore becomes spendable only when a hub-stationed transporter picks # hauler: ore becomes spendable only when a hub-stationed transporter picks
# up a full load and physically delivers it to the mothership. Entries are # 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 const EXTRACTOR_RATE: float = 2.0 # ore per second
var extractors: Array = [] var extractors: Array = []
@ -788,10 +793,11 @@ func has_extractor_at(planet_name: String) -> bool:
signal extractor_added(planet_name: String) 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({ extractors.append({
"position": at_position, "position": at_position,
"planet_name": planet_name, "planet_name": planet_name,
"resource_type": resource_type,
"stock": 0, "stock": 0,
"mining_progress": 0.0, "mining_progress": 0.0,
}) })
@ -917,7 +923,7 @@ func _station_transporter(built_at: Vector2) -> void:
return return
transporters.append({ transporters.append({
"hub_id": int(hub.id), "state": TRANSPORTER_RETURNING, "target_id": 0, "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(): if chosen_extractor.is_empty():
return return
chosen_extractor.extractor_index = i chosen_extractor.extractor_index = i
chosen_extractor.resource_type = String(extractor.get("resource_type", "ore"))
chosen_extractor.target_id = 0 chosen_extractor.target_id = 0
_begin_leg(chosen_extractor, transporter_position(chosen_extractor), extractor.position, _begin_leg(chosen_extractor, transporter_position(chosen_extractor), extractor.position,
TRANSPORTER_TO_EXTRACTOR) TRANSPORTER_TO_EXTRACTOR)
@ -1058,9 +1065,13 @@ func _arrive(t: Dictionary) -> void:
return return
extractor.stock = int(extractor.stock) - load extractor.stock = int(extractor.stock) - load
t.carrying = 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) _begin_leg(t, here, get_mothership_position(), TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR)
TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR: TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR:
# Bank only on arrival; while in flight the ore remains in 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) rocks += int(t.carrying)
t.carrying = 0 t.carrying = 0
t.extractor_index = -1 t.extractor_index = -1

16
GasCounter.gd Normal file
View file

@ -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)

View file

@ -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://Starfield.gd" id="1"]
[ext_resource type="Script" path="res://Fleet.gd" id="2"] [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://CargoCounter.gd" id="22"]
[ext_resource type="Script" path="res://TransporterField.gd" id="23"] [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://ResourceBar.gd" id="24"]
[ext_resource type="Script" path="res://GasCounter.gd" id="25"]
[sub_resource type="ShaderMaterial" id="1"] [sub_resource type="ShaderMaterial" id="1"]
shader = ExtResource("5") shader = ExtResource("5")
@ -91,6 +92,34 @@ offset_bottom = 92
script = ExtResource("16") script = ExtResource("16")
digit_height = 60.0 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"] [node name="CargoCounter" type="Control" parent="UI"]
mouse_filter = 2 mouse_filter = 2
offset_left = 28 offset_left = 28

View file

@ -11,7 +11,8 @@ extends Node2D
# while the fleet as a whole was clearly sitting on the planet. # while the fleet as a whole was clearly sitting on the planet.
@export var planet_name: String = "" @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 @export var landing_radius: float = 140.0
# Set by whoever spawns this planet (PlanetField, seeded from the chunk's # Set by whoever spawns this planet (PlanetField, seeded from the chunk's
# own coordinates) so the outline shape is stable across respawns of the # 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 # only at _ready() left the planet looking untouched until it happened to
# despawn and respawn. # despawn and respawn.
var shows_extractor: bool = false var shows_extractor: bool = false
var gas_phase: float = 0.0
func _ready() -> void: func _ready() -> void:
@ -54,6 +56,12 @@ func _ready() -> void:
queue_redraw() 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: func _on_extractor_added(built_on: String) -> void:
# Only the planet it was actually built on, and only a redraw when the # 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. # answer really changed - every other planet in range gets this too.
@ -103,6 +111,28 @@ func extractor_derrick_points() -> PackedVector2Array:
func _draw() -> void: 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) NeonDraw.draw_glowing_polygon(self, shape_points, Color(0.6, 0.55, 0.5), 1.0)
if shows_extractor: if shows_extractor:
NeonDraw.draw_glowing_polygon(self, extractor_derrick_points(), EXTRACTOR_MARKER_COLOR, 1.0) 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)

View file

@ -83,6 +83,8 @@ func _spawn_chunk_planet(chunk: Vector2i) -> void:
planet.position = origin + data.local_pos planet.position = origin + data.local_pos
planet.planet_name = data.name planet.planet_name = data.name
planet.shape_seed = data.shape_seed 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 # Deferred: update_chunks() can run synchronously from inside a
# parent's own _ready() chain (forced once right after the fleet is # parent's own _ready() chain (forced once right after the fleet is
# positioned, to avoid a pop-in on scene load) - Godot refuses a # positioned, to avoid a pop-in on scene load) - Godot refuses a

View file

@ -41,6 +41,7 @@ const RULE_COLOR: Color = Color(0.25, 0.55, 0.7)
var _numbers: Array[NeonNumber] = [] var _numbers: Array[NeonNumber] = []
var _captions: Array[Label] = [] var _captions: Array[Label] = []
var _gas_caption: Label
func _ready() -> void: func _ready() -> void:
@ -69,6 +70,11 @@ func _ready() -> void:
_layout() _layout()
resized.connect(_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: func _layout() -> void:
@ -80,9 +86,14 @@ func _layout() -> void:
# The hexagon and digits are drawn from the node's own origin, so the # The hexagon and digits are drawn from the node's own origin, so the
# number is positioned rather than sized. # number is positioned rather than sized.
_numbers[i].position = Vector2(x, CAPTION_HEIGHT + CAPTION_GAP) _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: func _process(_delta: float) -> void:
_gas_caption.text = "GAS %d" % GameState.gas
_numbers[0].set_amount(GameState.rocks) _numbers[0].set_amount(GameState.rocks)
_numbers[1].set_amount(GameState.fleet_cargo) _numbers[1].set_amount(GameState.fleet_cargo)

View file

@ -19,6 +19,7 @@ const PLANET_NAMES: Array = [
"Drift Anchor", "Stonefall", "Grey Watch", "Silent Quarry", "Drift Anchor", "Stonefall", "Grey Watch", "Silent Quarry",
"Baros Rock", "Umbra Point", "Baros Rock", "Umbra Point",
] ]
const GAS_GIANT_NAMES: Array = ["Aurelia", "Boreas", "Cerulean Deep", "Helios Major", "Vesper"]
static var _noise: FastNoiseLite static var _noise: FastNoiseLite
static var _planet_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 local_pos: Vector2 = Vector2(rng.randf() * CHUNK_SIZE, rng.randf() * CHUNK_SIZE)
var planet_name: String = PLANET_NAMES[rng.randi() % PLANET_NAMES.size()] var planet_name: String = PLANET_NAMES[rng.randi() % PLANET_NAMES.size()]
var shape_seed: int = rng.randi() 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: static func biome_color(biome: int) -> Color:

64
tests/test_gas_system.gd Normal file
View file

@ -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