Gas burner building and the energy resource (#54)

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 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-16 12:10:31 +02:00
commit 097e17f7aa
18 changed files with 567 additions and 12 deletions

View file

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

View file

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

View file

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

View file

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

26
EnergyResourceGlyph.gd Normal file
View file

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

13
EnergyResourceGlyph.tscn Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"),
}

View file

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

View file

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

View file

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

View file

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

163
tests/test_gas_burner.gd Normal file
View file

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