From 097e17f7aac917fcb22ce78c224eb188ad4664f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sun, 16 Aug 2026 12:10:31 +0200 Subject: [PATCH] Gas burner building and the energy resource (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new building that consumes gas to produce energy, a new resource stored and moved exactly like ore and gas. The resource: EnergyResourceGlyph (a cell/bolt mark) + energy's own warm yellow-green ResourceDefinition, registered in ResourceDefinitions.ORDER/DEFINITIONS and GameState.RESOURCE_ENERGY. Inventory/ResourceBar/ResourceTransferList needed no changes - #49 already made them resource-agnostic, confirmed structurally in the test rather than touched. The building: GasBurnerBuildingVisual (a hexagonal chamber with intake vents in gas's colour and output ports in energy's, so the silhouette itself reads "gas in, energy out"), GasBurnerBuildingDefinition (capabilities=["gas_burner"], its own build-menu icon appended to IconButton.Icon rather than inserted, per the ticket's own warning about renumbering stored scene values), and the matching UI/BuildMenu/GasBurner* nodes in Main.tscn. Gated behind a new "Power Systems" skill-tree node, mirroring how "freight" unlocks the transport hub. Conversion: _advance_gas_burners(), simulated in GameState alongside _advance_production/extractor mining so it keeps running while the player is elsewhere. Withdraws gas and deposits energy in the building's OWN inventory only - never the bank - so it stalls with no local gas exactly like a lab waiting on materials. Logistics: a gas burner's ongoing gas deficit is folded into the existing queued-building-supply dispatch path (same "needs resource X" loop production/research orders already use, extended with gas_burner_gas_deficit()), and a new TO_BUILDING_PICKUP/ TO_MOTHERSHIP_FROM_BUILDING transporter state pair collects its energy to the bank, mirroring the extractor pickup path per the ticket. The existing f5c60f8 priority (queued deliveries outrank collection) holds: the pickup dispatch loop runs last. Tests: new tests/test_gas_burner.gd (resource genericity, stall semantics, research gate, and full delivery+pickup logistics via a real hub), run_tests transporter/resource regressions, and screenshots of the build menu (all 7 buildings) and transfer menu (all 3 resources). Full suite: 81/81. Two real bugs found and fixed along the way, neither gas-burner- specific: - _transporter_inbound_to() matched ANY non-idle transporter with a matching target_id, regardless of purpose. Once pickup states shared target_id with delivery states (both address a building, not an extractor), a transporter delivering gas made the SAME building look "busy" to the unrelated energy-pickup dispatch, starving it even with a second, genuinely free transporter sitting idle at the hub. Split into _transporter_inbound_to() (delivery states only) and a new _transporter_inbound_for_pickup() (pickup states only). - The build menu's 3-row/6-slot layout (MENU_ROW_TOPS + row content sizing) had no room for a 7th building - despite the ticket's own note that no layout changes should be needed. A naive 4th row ran off the bottom of a 720x1280 viewport; fitting one meant both adding a 4th row top AND shrinking the shared button/label/cost sizing so each row's content no longer taller than the compressed row spacing. Caught only by actually screenshotting it - a real build menu, not headless, since --headless renders nothing. 🤖 Generated with Claude Code Co-Authored-By: Claude Sonnet 5 --- BuildController.gd | 19 ++- BuildingDefinitions.gd | 3 +- BuildingVisualBase.gd | 38 +++++ EnergyResourceDefinition.tres | 11 ++ EnergyResourceGlyph.gd | 26 ++++ EnergyResourceGlyph.tscn | 13 ++ GameState.gd | 130 ++++++++++++++++- GasBurnerBuildingDefinition.tres | 20 +++ GasBurnerBuildingVisual.gd | 9 ++ GasBurnerBuildingVisual.tscn | 11 ++ IconButton.gd | 26 ++++ Main.tscn | 42 ++++++ ResourceDefinitions.gd | 4 +- tests/_gas_burner_build_menu_preview.gd | 21 +++ tests/_gas_burner_build_menu_preview.tscn | 6 + tests/_gas_burner_transfer_preview.gd | 31 ++++ tests/_gas_burner_transfer_preview.tscn | 6 + tests/test_gas_burner.gd | 163 ++++++++++++++++++++++ 18 files changed, 567 insertions(+), 12 deletions(-) create mode 100644 EnergyResourceDefinition.tres create mode 100644 EnergyResourceGlyph.gd create mode 100644 EnergyResourceGlyph.tscn create mode 100644 GasBurnerBuildingDefinition.tres create mode 100644 GasBurnerBuildingVisual.gd create mode 100644 GasBurnerBuildingVisual.tscn create mode 100644 tests/_gas_burner_build_menu_preview.gd create mode 100644 tests/_gas_burner_build_menu_preview.tscn create mode 100644 tests/_gas_burner_transfer_preview.gd create mode 100644 tests/_gas_burner_transfer_preview.tscn create mode 100644 tests/test_gas_burner.gd diff --git a/BuildController.gd b/BuildController.gd index 02fb933..44ca2c9 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -490,14 +490,21 @@ func _planet_under_fleet() -> Node2D: # Grid geometry, shared with the layout below. Rows of two, and the menu is # rebuilt from whatever is currently researched (#21) rather than being a # fixed arrangement with holes punched in it. -const MENU_ROW_TOPS: Array = [-300.0, -10.0, 280.0] +const MENU_ROW_TOPS: Array = [-300.0, -77.0, 146.0, 369.0] const MENU_COL_X: float = 140.0 const MENU_BUTTON_HALF: float = 64.0 -const MENU_BUTTON_H: float = 128.0 +const MENU_BUTTON_H: float = 100.0 const MENU_LABEL_HALF: float = 150.0 -const MENU_LABEL_H: float = 66.0 +const MENU_LABEL_H: float = 52.0 const MENU_COST_HALF: float = 60.0 -const MENU_COST_H: float = 52.0 +const MENU_COST_H: float = 44.0 +# Gaps from each row's own top to where the label/cost start, sized to the +# shrunk MENU_*_H values above (#54: fitting a 4th row meant tightening +# MENU_ROW_TOPS' spacing to 223px, less than the original 256px a row's +# button+label+cost needed - shrinking those closed the gap without +# resorting to a scrollable menu). +const MENU_LABEL_OFFSET: float = 106.0 +const MENU_COST_OFFSET: float = 162.0 func _place_entry(node: Control, cx: float, top: float, half: float, height: float) -> void: @@ -530,8 +537,8 @@ func _layout_build_menu() -> void: var cx: float = 0.0 if alone else (-MENU_COL_X if i % 2 == 0 else MENU_COL_X) var top: float = MENU_ROW_TOPS[mini(row, MENU_ROW_TOPS.size() - 1)] _place_entry(shown[i][1], cx, top, MENU_BUTTON_HALF, MENU_BUTTON_H) - _place_entry(shown[i][2], cx, top + 134.0, MENU_LABEL_HALF, MENU_LABEL_H) - _place_entry(shown[i][3], cx, top + 204.0, MENU_COST_HALF, MENU_COST_H) + _place_entry(shown[i][2], cx, top + MENU_LABEL_OFFSET, MENU_LABEL_HALF, MENU_LABEL_H) + _place_entry(shown[i][3], cx, top + MENU_COST_OFFSET, MENU_COST_HALF, MENU_COST_H) func _refresh_build_status() -> void: diff --git a/BuildingDefinitions.gd b/BuildingDefinitions.gd index f05bf8e..fc1c4e0 100644 --- a/BuildingDefinitions.gd +++ b/BuildingDefinitions.gd @@ -1,7 +1,7 @@ class_name BuildingDefinitions extends RefCounted -const ORDER: PackedStringArray = ["mothership", "ship_crafter", "command_center", "extractor", "transport_hub", "ship_lab"] +const ORDER: PackedStringArray = ["mothership", "ship_crafter", "command_center", "extractor", "transport_hub", "ship_lab", "gas_burner"] const DEFINITIONS: Dictionary = { "mothership": preload("res://MothershipBuildingDefinition.tres"), "ship_crafter": preload("res://CrafterBuildingDefinition.tres"), @@ -9,6 +9,7 @@ const DEFINITIONS: Dictionary = { "extractor": preload("res://ExtractorBuildingDefinition.tres"), "transport_hub": preload("res://TransportHubBuildingDefinition.tres"), "ship_lab": preload("res://ShipLabBuildingDefinition.tres"), + "gas_burner": preload("res://GasBurnerBuildingDefinition.tres"), } static func get_definition(definition_id: String) -> Resource: diff --git a/BuildingVisualBase.gd b/BuildingVisualBase.gd index cd339b3..f1bdfe3 100644 --- a/BuildingVisualBase.gd +++ b/BuildingVisualBase.gd @@ -24,6 +24,8 @@ 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) +const GAS_BURNER_COLOR: Color = Color(0.95, 0.35, 0.15) +const GAS_BURNER_ENERGY_COLOR: Color = Color(0.85, 0.95, 0.15) # 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 @@ -278,6 +280,42 @@ func _animate_transport_hub() -> void: _hub_traffic.rotation = phase_time * HUB_SPIN_SPEED +# --- gas burner (#54) ------------------------------------------------------ +# A squat furnace rather than more scaffolding: a hexagonal combustion +# chamber with a hot inner core, fed by intake vents drawn in gas's own +# colour and exhausting through ports glowing in energy's colour - so the +# structure itself reads as "gas in, energy out" without needing a label. +# Drawn once and never redrawn, same as the command centre. + +const BURNER_CHAMBER_RADIUS: float = 50.0 +const BURNER_CORE_RADIUS: float = 34.0 +const BURNER_VENT_COUNT: int = 4 +const BURNER_VENT_LENGTH: float = 26.0 +const BURNER_PORT_RADIUS: float = 6.0 + + +func _draw_gas_burner_static(target: CanvasItem) -> void: + var c: Color = GAS_BURNER_COLOR + + NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, BURNER_CHAMBER_RADIUS, 6, PI / 6.0), c, 1.0) + NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, BURNER_CORE_RADIUS, 6, PI / 6.0), c, 0.5, + NeonDraw.DEFAULT_CORE, 0.7) + NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, BURNER_CORE_RADIUS * 0.5, 6, PI / 6.0), + Color(1.0, 0.75, 0.35), 0.85) + + for i in range(BURNER_VENT_COUNT): + var a: float = (float(i) / BURNER_VENT_COUNT) * TAU + PI / 4.0 + var dir: Vector2 = Vector2(cos(a), sin(a)) + var tip: Vector2 = dir * (BURNER_CHAMBER_RADIUS + BURNER_VENT_LENGTH) + # Intake vent in gas's own colour, so it is unmistakably the feed + # rather than another structural brace. + NeonDraw.draw_glowing_line(target, dir * BURNER_CHAMBER_RADIUS, tip, + Color(0.85, 0.52, 1.0), 0.7, 0.34) + # Output port glowing in energy's colour, at the same vent tip - + # the building's own silhouette shows both ends of the conversion. + NeonDraw.draw_glowing_polygon(target, _ring(tip, BURNER_PORT_RADIUS, 5), GAS_BURNER_ENERGY_COLOR, 0.9) + + func _diamond(centre: Vector2, r: float) -> PackedVector2Array: return PackedVector2Array([ centre + Vector2(0, -r), centre + Vector2(r, 0), diff --git a/EnergyResourceDefinition.tres b/EnergyResourceDefinition.tres new file mode 100644 index 0000000..b56cbd9 --- /dev/null +++ b/EnergyResourceDefinition.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://EnergyResourceGlyph.tscn" id="2"] + +[resource] +script = ExtResource("1") +resource_id = "energy" +display_name = "Energy" +color = Color(0.85, 0.95, 0.15, 1) +glyph_scene = ExtResource("2") diff --git a/EnergyResourceGlyph.gd b/EnergyResourceGlyph.gd new file mode 100644 index 0000000..667f142 --- /dev/null +++ b/EnergyResourceGlyph.gd @@ -0,0 +1,26 @@ +extends Control + +@export var colour: Color = Color(0.85, 0.95, 0.15) + + +func _draw() -> void: + var centre: Vector2 = size * 0.5 + var radius: float = minf(size.x, size.y) * 0.34 + # A cell outline (battery/reactor cell, same hexagon family as the ore + # glyph's own outline) with a bolt struck through it, so energy reads as + # "stored power" rather than being confused with ore's plain hexagon. + var cell := PackedVector2Array() + for i in range(6): + var angle: float = TAU * float(i) / 6.0 - PI * 0.5 + cell.append(centre + Vector2(cos(angle), sin(angle)) * radius) + NeonDraw.draw_glowing_polygon(self, cell, colour, 1.0, colour.darkened(0.78), maxf(0.5, radius * 0.035)) + + var bolt := PackedVector2Array([ + centre + Vector2(radius * 0.18, -radius * 0.75), + centre + Vector2(-radius * 0.32, radius * 0.05), + centre + Vector2(radius * 0.05, radius * 0.05), + centre + Vector2(-radius * 0.18, radius * 0.75), + centre + Vector2(radius * 0.32, -radius * 0.05), + centre + Vector2(radius * 0.0, -radius * 0.05), + ]) + NeonDraw.draw_glowing_polygon(self, bolt, colour.lightened(0.35), 1.0, colour.darkened(0.6), maxf(0.4, radius * 0.03)) diff --git a/EnergyResourceGlyph.tscn b/EnergyResourceGlyph.tscn new file mode 100644 index 0000000..ada9d15 --- /dev/null +++ b/EnergyResourceGlyph.tscn @@ -0,0 +1,13 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://EnergyResourceGlyph.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/GameState.gd b/GameState.gd index 0516b14..a1e385c 100644 --- a/GameState.gd +++ b/GameState.gd @@ -32,6 +32,7 @@ var explored_chunks: Dictionary = {} const RESOURCE_ORE: String = RESOURCE_DEFINITIONS.ORE_ID const RESOURCE_GAS: String = RESOURCE_DEFINITIONS.GAS_ID +const RESOURCE_ENERGY: String = RESOURCE_DEFINITIONS.ENERGY_ID const RESOURCE_TYPES: PackedStringArray = RESOURCE_DEFINITIONS.ORDER # All resource-bearing locations use the same container. The old public @@ -190,11 +191,15 @@ const BUILDING_EXTRACTOR: String = "extractor" const BUILDING_TRANSPORT_HUB: String = "transport_hub" # Where vessel types are researched and new ones unlocked (#5). const BUILDING_SHIP_LAB: String = "ship_lab" +# Converts gas into energy, both stored/moved through the building's own +# inventory exactly like every other building (#54). +const BUILDING_GAS_BURNER: String = "gas_burner" const COMMAND_CENTER_COST: int = 15 const EXTRACTOR_COST: int = 25 const TRANSPORT_HUB_COST: int = 30 const SHIP_LAB_COST: int = 35 +const GAS_BURNER_COST: int = 30 var buildings: Array = [] @@ -242,6 +247,11 @@ const SKILL_TREE: Array = [ "x": 0.18, "hub_bonus": 1, "desc": "Permanent: +1 transporter stationed at every hub, including ones already built.", }, + { + "id": "power_systems", "name": "Power Systems", "cost": 45, "requires": "freight", + "x": 0.50, "unlocks": BUILDING_GAS_BURNER, + "desc": "Build gas burners. Each converts gas into energy at its own site - hub transporters keep it stocked and collect what it makes.", + }, { "id": "wing", "name": "Wing Doctrine", "cost": 30, "requires": "coordination", "x": 0.82, "fleet_bonus": 1, @@ -1047,6 +1057,46 @@ func _extractor_inventory(extractor: Dictionary) -> RefCounted: return inventory +# --- gas burners (#54) ----------------------------------------------------- +# +# Converts gas into energy at the building's own site, simulated here +# alongside extractor mining and production so it keeps running whether or +# not the player is nearby. Deliberately withdraws only from the building's +# OWN inventory, never the bank - a burner with no local gas simply stalls, +# exactly like a lab or crafter waiting on materials, rather than conjuring +# energy for free. + +const GAS_BURN_RATE: float = 1.0 # gas/second +const ENERGY_PER_GAS: int = 2 +# Local gas reserve a burner tries to keep topped up. Transporters treat +# falling short of this the same way they treat a queued production/research +# order (see _dispatch_transporters()'s gas_burner_gas_deficit() use). +const GAS_BURNER_BUFFER: int = 40 + + +func gas_burner_gas_deficit(id: int) -> int: + var b: Dictionary = find_building(id) + if not building_has_capability(b, "gas_burner"): + return 0 + return maxi(0, GAS_BURNER_BUFFER - building_resource(id, RESOURCE_GAS)) + + +func _advance_gas_burners(delta: float) -> void: + for b in buildings: + if not building_has_capability(b, "gas_burner"): + continue + var inventory: RefCounted = _inventory_for_record(b) + if inventory.amount(RESOURCE_GAS) <= 0: + continue # stalled - no local gas to burn + var progress: float = float(b.get("gas_burn_progress", 0.0)) + delta * GAS_BURN_RATE + var whole: int = mini(int(floor(progress)), inventory.amount(RESOURCE_GAS)) + if whole > 0: + inventory.withdraw(RESOURCE_GAS, whole) + inventory.deposit(RESOURCE_ENERGY, whole * ENERGY_PER_GAS) + progress -= whole + b.gas_burn_progress = progress + + # Planet conquest: a planet must have every one of its capture points held # before an extractor can be built there (see BuildController's placement # gates). Keyed by the same stable planet_id as `extractors`, entirely @@ -1117,6 +1167,7 @@ func _process(delta: float) -> void: _advance_production(delta) _advance_lab_research(delta) _advance_vessel_deliveries(delta) + _advance_gas_burners(delta) for e in extractors: e.mining_progress = float(e.get("mining_progress", 0.0)) + delta * EXTRACTOR_RATE var whole: int = int(floor(float(e.mining_progress))) @@ -1166,6 +1217,12 @@ const TRANSPORTER_TO_BUILDING: String = "to_building" const TRANSPORTER_RETURNING: String = "returning" const TRANSPORTER_TO_EXTRACTOR: String = "to_extractor" const TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR: String = "extractor_to_mothership" +# Collecting an output resource (energy) directly from a regular building's +# own inventory - a gas burner, not an extractor - and banking it. Reuses +# `target_id` exactly like the ordinary supply path does, rather than +# `extractor_index`: the source is a building, already addressable that way. +const TRANSPORTER_TO_BUILDING_PICKUP: String = "to_building_pickup" +const TRANSPORTER_TO_MOTHERSHIP_FROM_BUILDING: String = "building_to_mothership" var transporters: Array = [] @@ -1227,8 +1284,23 @@ func _begin_leg(t: Dictionary, from: Vector2, to: Vector2, state: String) -> voi func _transporter_inbound_to(building_id: int) -> bool: + # Delivery-purpose states only (#54): a building can have BOTH a + # transporter delivering it something (gas, for a burner) and a + # separate one collecting something from it (energy) at once, since + # they are two different transporters and two different legs. Matching + # on target_id alone, regardless of state, would make either purpose + # block the other from ever being dispatched to the same building. for t in transporters: - if t.state != TRANSPORTER_IDLE and int(t.target_id) == building_id: + if (t.state == TRANSPORTER_TO_MOTHERSHIP or t.state == TRANSPORTER_TO_BUILDING) \ + and int(t.target_id) == building_id: + return true + return false + + +func _transporter_inbound_for_pickup(building_id: int) -> bool: + for t in transporters: + if (t.state == TRANSPORTER_TO_BUILDING_PICKUP or t.state == TRANSPORTER_TO_MOTHERSHIP_FROM_BUILDING) \ + and int(t.target_id) == building_id: return true return false @@ -1253,8 +1325,14 @@ func _dispatch_transporters() -> void: for b in buildings: var needed_resource := "" for resource_type in resource_types(): - if outstanding_resource_cost(int(b.id), resource_type) > 0 \ - and bank_inventory.amount(resource_type) > 0: + # A gas burner's ongoing gas deficit is folded in alongside + # queued production/research costs (#54) - both are just "this + # building wants more of resource_type", so the one dispatch + # path already below serves both without any new transporter + # state. + var need: int = maxi(outstanding_resource_cost(int(b.id), resource_type), + gas_burner_gas_deficit(int(b.id)) if resource_type == RESOURCE_GAS else 0) + if need > 0 and bank_inventory.amount(resource_type) > 0: needed_resource = resource_type break if needed_resource == "": @@ -1306,6 +1384,32 @@ func _dispatch_transporters() -> void: _begin_leg(chosen_extractor, transporter_position(chosen_extractor), extractor.position, TRANSPORTER_TO_EXTRACTOR) + # Collecting a gas burner's energy output, last: queued deliveries and + # extractor collection both outrank it, same "supply what's needed + # before what's merely ready" ordering _dispatch_transporters() already + # holds for extractors over queued orders (f5c60f8). + for b in buildings: + if not building_has_capability(b, "gas_burner"): + continue + if building_resource(int(b.id), RESOURCE_ENERGY) < load or _transporter_inbound_for_pickup(int(b.id)): + continue + var chosen_pickup: Dictionary = {} + var best_pickup_distance: float = INF + for t in transporters: + if t.state != TRANSPORTER_IDLE: + continue + var d: float = transporter_position(t).distance_squared_to(b.position) + if d < best_pickup_distance: + best_pickup_distance = d + chosen_pickup = t + if chosen_pickup.is_empty(): + return + chosen_pickup.target_id = int(b.id) + chosen_pickup.resource_type = RESOURCE_ENERGY + chosen_pickup.extractor_index = -1 + _begin_leg(chosen_pickup, transporter_position(chosen_pickup), b.position, + TRANSPORTER_TO_BUILDING_PICKUP) + func _advance_transporters(delta: float) -> void: _sync_hub_transporters() @@ -1331,7 +1435,8 @@ func _arrive(t: Dictionary) -> void: TRANSPORTER_TO_MOTHERSHIP: var target: Dictionary = find_building(int(t.target_id)) var resource_type: String = String(t.get("resource_type", RESOURCE_ORE)) - var want: int = outstanding_resource_cost(int(t.target_id), resource_type) + var want: int = maxi(outstanding_resource_cost(int(t.target_id), resource_type), + gas_burner_gas_deficit(int(t.target_id)) if resource_type == RESOURCE_GAS else 0) var take: int = mini(mini(want, bank_inventory.amount(resource_type)), transporter_load_limit()) if take <= 0 or target.is_empty(): # Nothing to carry, or the building is gone. Go home empty. @@ -1374,6 +1479,23 @@ func _arrive(t: Dictionary) -> void: t.carrying = 0 t.extractor_index = -1 _begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING) + TRANSPORTER_TO_BUILDING_PICKUP: + var source: Dictionary = find_building(int(t.target_id)) + var resource_type: String = String(t.get("resource_type", RESOURCE_ENERGY)) + var take: int = 0 if source.is_empty() \ + else mini(building_resource(int(t.target_id), resource_type), transporter_load_limit()) + if take <= 0 or source.is_empty(): + t.target_id = 0 + _begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING) + return + _inventory_for_record(source).withdraw(resource_type, take) + t.carrying = take + _begin_leg(t, here, get_mothership_position(), TRANSPORTER_TO_MOTHERSHIP_FROM_BUILDING) + TRANSPORTER_TO_MOTHERSHIP_FROM_BUILDING: + bank_inventory.deposit(String(t.get("resource_type", RESOURCE_ENERGY)), int(t.carrying)) + t.carrying = 0 + t.target_id = 0 + _begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING) TRANSPORTER_RETURNING: t.state = TRANSPORTER_IDLE t.progress = 1.0 diff --git a/GasBurnerBuildingDefinition.tres b/GasBurnerBuildingDefinition.tres new file mode 100644 index 0000000..09d2240 --- /dev/null +++ b/GasBurnerBuildingDefinition.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://GasBurnerBuildingVisual.tscn" id="2"] +[resource] +script = ExtResource("1") +definition_id = "gas_burner" +display_name = "Gas Burner" +description = "Burns gas into energy at its own site. Hub transporters keep it stocked with gas and collect the energy it makes." +cost = 30 +action_icon = 33 +menu_icon = 33 +visual_scene = ExtResource("2") +capabilities = PackedStringArray("gas_burner") +map_color = Color(0.95, 0.35, 0.15, 1) +map_sides = 5 +map_radius = 7.0 +map_phase = 0.0 +menu_button_name = "GasBurnerButton" +menu_label_name = "GasBurnerLabel" +menu_cost_name = "GasBurnerCost" diff --git a/GasBurnerBuildingVisual.gd b/GasBurnerBuildingVisual.gd new file mode 100644 index 0000000..472fadd --- /dev/null +++ b/GasBurnerBuildingVisual.gd @@ -0,0 +1,9 @@ +extends "res://BuildingVisualBase.gd" + +func _ready() -> void: + set_process(false) + queue_redraw() + +func _draw() -> void: + register_draw() + _draw_gas_burner_static(self) diff --git a/GasBurnerBuildingVisual.tscn b/GasBurnerBuildingVisual.tscn new file mode 100644 index 0000000..adffe2d --- /dev/null +++ b/GasBurnerBuildingVisual.tscn @@ -0,0 +1,11 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://GasBurnerBuildingVisual.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/IconButton.gd b/IconButton.gd index ca019ca..70b0a16 100644 --- a/IconButton.gd +++ b/IconButton.gd @@ -31,6 +31,10 @@ enum Icon { MINUS, PLUS, SHIELD_VESSEL, GAS_BOMBER, GAS_BOMB, + # Appended, not inserted alongside the other building icons above - + # inserting there would silently renumber every icon int already + # stored in existing scenes (#54). + GAS_BURNER, } # Size the glyphs were originally drawn against. Every glyph dimension is @@ -221,6 +225,8 @@ func _draw() -> void: _draw_gas_bomber(center, pulse, s) Icon.GAS_BOMB: _draw_gas_bomb(center, pulse, s) + Icon.GAS_BURNER: + _draw_gas_burner_icon(center, pulse, s) func _draw_close(center: Vector2, pulse: float, s: float) -> void: @@ -266,6 +272,26 @@ func _draw_gas_bomb(center: Vector2, pulse: float, s: float) -> void: _active_color(), pulse * 0.85, core_color) +func _draw_gas_burner_icon(center: Vector2, pulse: float, s: float) -> void: + # A hexagonal chamber with a hot core and vents, miniaturised from the + # building's own silhouette (BuildingVisualBase._draw_gas_burner_static) + # so the menu icon reads as the same structure being placed. + var outer: PackedVector2Array = PackedVector2Array() + var core: PackedVector2Array = PackedVector2Array() + for i in range(6): + var a: float = (float(i) / 6.0) * TAU + PI / 6.0 + outer.append(center + Vector2(cos(a), sin(a)) * 11.0 * s) + core.append(center + Vector2(cos(a), sin(a)) * 5.5 * s) + NeonDraw.draw_glowing_polygon(self, outer, _active_color(), pulse, core_color) + NeonDraw.draw_glowing_polygon(self, core, _active_color(), pulse * 0.85, core_color) + + 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(self, center + dir * 11.0 * s, center + dir * 15.5 * s, + _active_color(), pulse, 0.35 * s, core_color) + + func _draw_arrow(center: Vector2, pulse: float, direction: float, s: float) -> void: var r: float = 9.0 * s var tip: Vector2 = center + Vector2(0, r * direction) diff --git a/Main.tscn b/Main.tscn index cfa2e8f..8cbed22 100644 --- a/Main.tscn +++ b/Main.tscn @@ -576,6 +576,48 @@ script = ExtResource("19") amount = 35 digit_height = 40.0 +[node name="GasBurnerButton" type="Control" parent="UI/BuildMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -64 +offset_right = 64 +offset_top = 570 +offset_bottom = 698 +script = ExtResource("10") +icon = 33 +glow_color = Color(0.95, 0.35, 0.15, 1) + +[node name="GasBurnerLabel" type="Label" parent="UI/BuildMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -150 +offset_right = 150 +offset_top = 704 +offset_bottom = 770 +theme_override_font_sizes/font_size = 32 +autowrap_mode = 2 +text = "Gas Burner" +horizontal_alignment = 1 + +[node name="GasBurnerCost" type="Control" parent="UI/BuildMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = -60 +offset_right = 60 +offset_top = 774 +offset_bottom = 826 +script = ExtResource("19") +amount = 30 +digit_height = 40.0 + [node name="CloseButton" type="Control" parent="UI/BuildMenu"] anchor_left = 1.0 anchor_right = 1.0 diff --git a/ResourceDefinitions.gd b/ResourceDefinitions.gd index 7ddf4c0..3726aae 100644 --- a/ResourceDefinitions.gd +++ b/ResourceDefinitions.gd @@ -3,10 +3,12 @@ extends RefCounted const ORE_ID: String = "ore" const GAS_ID: String = "gas" -const ORDER: PackedStringArray = [ORE_ID, GAS_ID] +const ENERGY_ID: String = "energy" +const ORDER: PackedStringArray = [ORE_ID, GAS_ID, ENERGY_ID] const DEFINITIONS: Dictionary = { ORE_ID: preload("res://OreResourceDefinition.tres"), GAS_ID: preload("res://GasResourceDefinition.tres"), + ENERGY_ID: preload("res://EnergyResourceDefinition.tres"), } diff --git a/tests/_gas_burner_build_menu_preview.gd b/tests/_gas_burner_build_menu_preview.gd new file mode 100644 index 0000000..f5116d0 --- /dev/null +++ b/tests/_gas_burner_build_menu_preview.gd @@ -0,0 +1,21 @@ +extends Node2D + +# Preview, not a test - #54's build menu screenshot. Everything researched, +# including power_systems, so all 7 buildings show at once: the 7th (gas +# burner) is the first to land beyond the menu's original 3-row/6-slot +# layout, worth checking for fit even though the ticket calls +# BuildController._layout_build_menu() already generic enough to need no +# changes. +# +# bash tests/screenshot.sh res://tests/_gas_burner_build_menu_preview.tscn /tmp/menu.png 20 720x1280 + + +func _ready() -> void: + GameState.buildings.clear() + GameState.extractors.clear() + GameState.rocks = 500 + GameState.unlocked = ["shipyards", "logistics", "coordination", "freight", "vessel_science", "power_systems"] + var main: Node = load("res://Main.tscn").instantiate() + add_child(main) + main.get_node("UI/BuildMenu").visible = true + main.get_node("BuildController")._refresh_build_status() diff --git a/tests/_gas_burner_build_menu_preview.tscn b/tests/_gas_burner_build_menu_preview.tscn new file mode 100644 index 0000000..0876eae --- /dev/null +++ b/tests/_gas_burner_build_menu_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_gas_burner_build_menu_preview.gd" id="1"] + +[node name="GasBurnerBuildMenuPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/_gas_burner_transfer_preview.gd b/tests/_gas_burner_transfer_preview.gd new file mode 100644 index 0000000..b4645e7 --- /dev/null +++ b/tests/_gas_burner_transfer_preview.gd @@ -0,0 +1,31 @@ +extends Node2D + +# Preview, not a test - #54's "transfer menu with three resources laid out +# cleanly" screenshot. A gas burner holding all three resources at once +# (ore incidentally, gas as its fuel, energy as its output) exercises the +# transfer list at its widest. +# +# bash tests/screenshot.sh res://tests/_gas_burner_transfer_preview.tscn /tmp/m.png 20 720x1280 + + +func _ready() -> void: + GameState.buildings.clear() + GameState.extractors.clear() + GameState.transporters.clear() + GameState.rocks = 180 + GameState.gas = 90 + GameState.fleet_cargo = 24 + GameState.fleet_gas_cargo = 12 + GameState.unlocked = ["shipyards", "logistics", "freight", "power_systems"] + var main: Node = load("res://Main.tscn").instantiate() + add_child(main) + main.get_node("BuildingField").place(GameState.BUILDING_GAS_BURNER, Vector2(400.0, 0.0)) + var burner_id: int = int(GameState.buildings[-1].id) + GameState.deposit_resource_to_building(burner_id, GameState.RESOURCE_ORE, 14) + GameState.deposit_resource_to_building(burner_id, GameState.RESOURCE_GAS, 22) + GameState.deposit_resource_to_building(burner_id, GameState.RESOURCE_ENERGY, 36) + var bc: Node = main.get_node("BuildController") + bc.transfer_building_id = burner_id + main.get_node("UI/TransferMenu").visible = true + bc._refresh_transfer() + bc.transfer_status.text = "Moved 25 ore into this building" diff --git a/tests/_gas_burner_transfer_preview.tscn b/tests/_gas_burner_transfer_preview.tscn new file mode 100644 index 0000000..2c680d0 --- /dev/null +++ b/tests/_gas_burner_transfer_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_gas_burner_transfer_preview.gd" id="1"] + +[node name="GasBurnerTransferPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/test_gas_burner.gd b/tests/test_gas_burner.gd new file mode 100644 index 0000000..4dfffd0 --- /dev/null +++ b/tests/test_gas_burner.gd @@ -0,0 +1,163 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_gas_burner.gd +# Forgejo #54: gas burner building + the energy resource, stored/moved +# exactly like ore and gas. +# +# Modeled on tests/test_extractor_income.gd's shape: pure GameState state, +# no scene loaded, poking buildings/inventories directly and waiting out +# real production/transporter timing with generous margins rather than +# fixed frame counts (SceneTree physics can run ahead of _process(), trap 5 +# in the repo notes). +# +# Covers: +# A. energy is fully generic - in resource_types(), has its own +# ResourceDefinition/glyph/colour - with no per-resource code anywhere +# (ResourceBar/ResourceTransferList already iterate resource_types()) +# B. a burner with local gas converts it to energy, into its OWN +# inventory, at the configured rate; with no gas it stalls and never +# touches the bank +# C. the building is gated behind its own research node +# D. a hub transporter both delivers gas to a burner and collects its +# energy, following the existing supply/pickup dispatch paths + +var game_state: Node +var stage: int = 0 +var elapsed: float = 0.0 +var mothership_id: int = 0 +var hub_id: int = 0 +var burner_id: int = 0 +var load_limit: int = 0 +var energy_before_stall: int = 0 + +const MOTHERSHIP_AT := Vector2.ZERO +const HUB_AT := Vector2(150.0, 0.0) +const BURNER_AT := Vector2(400.0, 0.0) + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.rocks = 0 + game_state.gas = 0 + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.reset_progression() + + mothership_id = game_state.add_building(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT) + burner_id = game_state.add_building(GameState.BUILDING_GAS_BURNER, BURNER_AT) + game_state.deposit_resource_to_building(burner_id, GameState.RESOURCE_GAS, 20) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + # --- A. energy is fully generic ------------------------------- + if not game_state.resource_types().has(GameState.RESOURCE_ENERGY): + return _fail("energy is not in resource_types() - the HUD/transfer list would never show it") + var definition: Resource = game_state.resource_definition(GameState.RESOURCE_ENERGY) + if definition == null or definition.glyph_scene == null: + return _fail("energy has no ResourceDefinition or glyph_scene") + var energy_color: Color = game_state.resource_color(GameState.RESOURCE_ENERGY) + if energy_color == game_state.resource_color(GameState.RESOURCE_ORE) \ + or energy_color == game_state.resource_color(GameState.RESOURCE_GAS): + return _fail("energy shares a colour with ore or gas") + print("energy is generic: in resource_types(), own definition, own colour %s" % energy_color) + + # --- B. produces energy from local gas at the configured rate - + if elapsed < 2.5: + return false + var gas_left: int = game_state.building_resource(burner_id, GameState.RESOURCE_GAS) + var energy_made: int = game_state.building_resource(burner_id, GameState.RESOURCE_ENERGY) + var gas_burned: int = 20 - gas_left + if gas_burned < 2: + return _fail("burner only burned %d gas in 2.5s at rate %.1f/s" % [gas_burned, GameState.GAS_BURN_RATE]) + if energy_made != gas_burned * GameState.ENERGY_PER_GAS: + return _fail("burner made %d energy for %d gas burned, expected %dx" % [ + energy_made, gas_burned, GameState.ENERGY_PER_GAS + ]) + if game_state.rocks != 0 or game_state.gas != 0: + return _fail("conversion touched the bank (rocks=%d gas=%d) - it must only use local stock" % [ + game_state.rocks, game_state.gas + ]) + print("burner converted %d gas -> %d energy locally, bank untouched" % [gas_burned, energy_made]) + + # --- stall with no gas ----------------------------------------- + var burner_inventory: RefCounted = game_state.building_inventory(burner_id) + burner_inventory.withdraw(GameState.RESOURCE_GAS, burner_inventory.amount(GameState.RESOURCE_GAS)) + energy_before_stall = game_state.building_resource(burner_id, GameState.RESOURCE_ENERGY) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 1.5: + return false + var energy_now: int = game_state.building_resource(burner_id, GameState.RESOURCE_ENERGY) + if energy_now != energy_before_stall: + return _fail("burner made energy with no local gas (energy %d -> %d) - it must stall" % [ + energy_before_stall, energy_now + ]) + print("stalled with no gas: energy held at %d" % energy_now) + + # --- C. gated behind research ----------------------------------- + if game_state.is_building_unlocked(GameState.BUILDING_GAS_BURNER): + return _fail("gas_burner is buildable with no research at all") + var gate: String = game_state.research_for_building(GameState.BUILDING_GAS_BURNER) + if gate == "": + return _fail("no research node gates gas_burner") + game_state.unlocked = ["shipyards", "logistics", "freight", "power_systems"] + if not game_state.is_building_unlocked(GameState.BUILDING_GAS_BURNER): + return _fail("still locked after researching '%s'" % gate) + print("locked behind research '%s', unlockable" % gate) + + # --- D. logistics: reset for a fresh delivery/pickup test ----- + game_state.buildings.clear() + game_state.transporters.clear() + game_state.gas = 200 + mothership_id = game_state.add_building(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT) + hub_id = game_state.add_building(GameState.BUILDING_TRANSPORT_HUB, HUB_AT) + burner_id = game_state.add_building(GameState.BUILDING_GAS_BURNER, BURNER_AT) + if game_state.transporters.size() != game_state.hub_capacity(): + return _fail("hub did not provision its transporters") + load_limit = game_state.transporter_load_limit() + elapsed = 0.0 + stage = 2 + return false + 2: + # --- a transporter delivers gas to the burner ------------------ + if game_state.building_resource(burner_id, GameState.RESOURCE_GAS) > 0: + print("transporter delivered gas to the burner: %d" % [ + game_state.building_resource(burner_id, GameState.RESOURCE_GAS) + ]) + # Force the burner's local energy up to a full load so pickup + # dispatch fires immediately, rather than waiting out the + # genuinely slow conversion rate for a second, separate check. + game_state.building_inventory(burner_id).set_amount(GameState.RESOURCE_ENERGY, load_limit) + elapsed = 0.0 + stage = 3 + return false + if elapsed > 10.0: + return _fail("no gas was ever delivered to the burner") + return false + 3: + # --- a transporter collects the burner's energy to the bank --- + if game_state.banked_resource(GameState.RESOURCE_ENERGY) >= load_limit: + print("transporter collected %d energy to the bank" % game_state.banked_resource(GameState.RESOURCE_ENERGY)) + print("TEST_GAS_BURNER: PASS") + quit(0) + return true + if elapsed > 10.0: + return _fail("burner's energy was never collected (bank=%d burner=%d)" % [ + game_state.banked_resource(GameState.RESOURCE_ENERGY), + game_state.building_resource(burner_id, GameState.RESOURCE_ENERGY), + ]) + return false + return false + + +func _fail(message: String) -> bool: + print("TEST_GAS_BURNER: FAIL (%s)" % message) + quit(1) + return true