diff --git a/BuildController.gd b/BuildController.gd index e291e76..6be8193 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -38,6 +38,9 @@ extends Node2D @onready var craft_menu: Control = get_node("../UI/CraftMenu") @onready var craft_interceptor_button: IconButton = get_node("../UI/CraftMenu/InterceptorButton") +@onready var craft_transporter_button: IconButton = get_node("../UI/CraftMenu/TransporterButton") +@onready var craft_store_button: IconButton = get_node("../UI/CraftMenu/StoreOreButton") +@onready var craft_store_label: Label = get_node("../UI/CraftMenu/StoreOreLabel") @onready var craft_close_button: IconButton = get_node("../UI/CraftMenu/CloseButton") @onready var craft_status: Label = get_node("../UI/CraftMenu/StatusLabel") @onready var load_ore_button: IconButton = get_node("../UI/SkillMenu/LoadOreButton") @@ -113,6 +116,8 @@ func _ready() -> void: craft_interceptor_button.pressed.connect(_on_craft_interceptor) load_ore_button.pressed.connect(_on_load_ore) + craft_transporter_button.pressed.connect(_on_craft_transporter) + craft_store_button.pressed.connect(_on_store_ore) craft_close_button.pressed.connect(func(): craft_menu.visible = false) formation_close_button.pressed.connect(func(): formation_menu.visible = false) @@ -452,10 +457,34 @@ func _refresh_craft_status() -> void: var queued: int = GameState.pending_orders(craft_building_id) var stock: int = GameState.building_stock(craft_building_id) var queue_text: String = "" if queued == 0 else " Queued %d" % queued - craft_status.text = "Interceptor %d ore Here %d%s Fleet %d/%d" % [ - GameState.SHIP_COST, stock, queue_text, - fleet.ships.size(), GameState.max_fleet_size() + craft_status.text = "Interceptor %d Transporter %d Here %d%s" % [ + GameState.SHIP_COST, GameState.TRANSPORTER_COST, stock, queue_text ] + # Nowhere to station one means the order would be refused, so say so + # rather than letting the button look available and do nothing. + craft_transporter_button.enabled = GameState.has_transport_hub() + craft_transporter_button.queue_redraw() + + craft_store_label.text = "Store ore here\nHold %d" % GameState.fleet_cargo + craft_store_button.enabled = GameState.fleet_cargo > 0 + craft_store_button.queue_redraw() + + # A full fleet cannot take another vessel, so the order would be refused. + # Showing that on the button is the difference between "not available yet" + # and a button that looks live and silently does nothing when pressed. + var room: bool = fleet.ships.size() + GameState.ships_owed \ + + GameState.pending_orders_of(craft_building_id, GameState.PRODUCT_SHIP) \ + < GameState.max_fleet_size() + craft_interceptor_button.enabled = room + craft_interceptor_button.queue_redraw() + # One statement of the fleet count, either way round: the capacity line + # replaces the plain readout rather than being bolted in front of it. + if room: + craft_status.text += " Fleet %d/%d" % [fleet.ships.size(), GameState.max_fleet_size()] + else: + craft_status.text = "Fleet full %d/%d - research capacity at the mothership\n%s" % [ + fleet.ships.size(), GameState.max_fleet_size(), craft_status.text + ] func _on_craft_interceptor() -> void: @@ -463,7 +492,8 @@ func _on_craft_interceptor() -> void: return # Still refused when there is nowhere to put the vessel - queueing an # order that can never be delivered would just bank ships_owed forever. - if fleet.ships.size() + GameState.ships_owed + GameState.pending_orders(craft_building_id) \ + if fleet.ships.size() + GameState.ships_owed \ + + GameState.pending_orders_of(craft_building_id, GameState.PRODUCT_SHIP) \ >= GameState.max_fleet_size(): craft_status.text = "Fleet is full (%d slots)" % GameState.max_fleet_size() return @@ -474,7 +504,7 @@ func _on_craft_interceptor() -> void: func _refresh_load_ore() -> void: - load_ore_label.text = "Load ore\nBank %d Hold %d" % [GameState.rocks, GameState.fleet_cargo] + load_ore_label.text = "Load ore\nBank %d Hold %d" % [GameState.rocks, GameState.fleet_cargo] load_ore_button.enabled = GameState.rocks > 0 load_ore_button.queue_redraw() @@ -484,6 +514,24 @@ func _on_load_ore() -> void: _refresh_load_ore() +func _on_store_ore() -> void: + if craft_building_id == 0: + return + var moved: int = GameState.unload_into_building(craft_building_id) + _refresh_craft_status() + if moved > 0: + craft_status.text = "Stored %d ore here %s" % [moved, craft_status.text] + + +func _on_craft_transporter() -> void: + if craft_building_id == 0: + return + if not GameState.queue_order(craft_building_id, GameState.PRODUCT_TRANSPORTER): + craft_status.text = "Build a Transport Hub first - a transporter needs somewhere to be stationed" + return + _refresh_craft_status() + + func _on_formation_changed() -> void: fleet.apply_formation() diff --git a/GameState.gd b/GameState.gd index 8e6827e..98ffa8b 100644 --- a/GameState.gd +++ b/GameState.gd @@ -277,7 +277,7 @@ func add_building(type: String, at_position: Vector2) -> int: var id: int = next_building_id next_building_id += 1 buildings.append({ - "id": id, "type": type, "position": at_position, "stock": 0, "queued": 0, + "id": id, "type": type, "position": at_position, "stock": 0, "orders": [], }) return id @@ -289,12 +289,22 @@ func find_building(id: int) -> Dictionary: return {} -func production_cost(type: String) -> int: - # What one order out of this building costs it. 0 means it produces - # nothing and cannot be ordered from. - if type == BUILDING_SHIP_CRAFTER: - return SHIP_COST - return 0 +const PRODUCT_SHIP: String = "interceptor" +const PRODUCT_TRANSPORTER: String = "transporter" + +const TRANSPORTER_COST: int = 10 + + +func product_cost(product: String) -> int: + if product == PRODUCT_TRANSPORTER: + return TRANSPORTER_COST + return SHIP_COST + + +func can_produce(type: String) -> bool: + # Only the ship crafter builds anything. The hub STATIONS transporters, + # it does not make them (#12). + return type == BUILDING_SHIP_CRAFTER func building_stock(id: int) -> int: @@ -304,7 +314,29 @@ func building_stock(id: int) -> int: func pending_orders(id: int) -> int: var b: Dictionary = find_building(id) - return 0 if b.is_empty() else int(b.get("queued", 0)) + return 0 if b.is_empty() else (b.get("orders", []) as Array).size() + + +func pending_orders_of(id: int, product: String) -> int: + var b: Dictionary = find_building(id) + if b.is_empty(): + return 0 + var n: int = 0 + for o in b.get("orders", []): + if o == product: + n += 1 + return n + + +func outstanding_cost(id: int) -> int: + # What this building still needs delivered to clear everything queued. + var b: Dictionary = find_building(id) + if b.is_empty(): + return 0 + var need: int = 0 + for o in b.get("orders", []): + need += product_cost(o) + return maxi(need - int(b.get("stock", 0)), 0) func deposit_to_building(id: int, amount: int) -> int: @@ -317,28 +349,43 @@ func deposit_to_building(id: int, amount: int) -> int: return amount -func queue_order(id: int) -> bool: +func queue_order(id: int, product: String = PRODUCT_SHIP) -> bool: var b: Dictionary = find_building(id) - if b.is_empty() or production_cost(b.type) <= 0: + if b.is_empty() or not can_produce(b.type): return false - b.queued = int(b.get("queued", 0)) + 1 + if product == PRODUCT_TRANSPORTER and not has_transport_hub(): + # Nowhere to station it. Refused rather than queued, so the player is + # told now instead of watching an order sit forever. + return false + (b.orders as Array).append(product) return true func _advance_production() -> void: for b in buildings: - var cost: int = production_cost(b.type) - if cost <= 0: + if not can_produce(b.type): continue # `while`, not `if`: a delivery big enough to cover several queued # orders should clear them all rather than dribbling one out per # frame. - while int(b.get("queued", 0)) > 0 and int(b.get("stock", 0)) >= cost: + var orders: Array = b.get("orders", []) + while not orders.is_empty(): + var product: String = String(orders[0]) + var cost: int = product_cost(product) + if int(b.get("stock", 0)) < cost: + break + if product == PRODUCT_TRANSPORTER and nearest_hub_to(b.position).is_empty(): + # Every hub was demolished while this sat in the queue. Leave + # it queued and the stock untouched rather than producing a + # vessel with nowhere to live. + break b.stock = int(b.stock) - cost - b.queued = int(b.queued) - 1 - if b.type == BUILDING_SHIP_CRAFTER: + orders.pop_front() + if product == PRODUCT_TRANSPORTER: + _station_transporter(b.position) + else: ships_owed += 1 - order_completed.emit(int(b.id), String(b.type)) + order_completed.emit(int(b.id), product) func has_mothership() -> bool: @@ -470,6 +517,7 @@ func _process(delta: float) -> void: # keep completing - while the player is on a planet's surface or nowhere # near either. _advance_production() + _advance_transporters(delta) if extractors.is_empty(): return for e in extractors: @@ -486,6 +534,154 @@ func _process(delta: float) -> void: e.progress -= 1.0 +# --- transporters (#19) --------------------------------------------------- + +# Vessels stationed at a transport hub that fetch ore from the mothership and +# deliver it to whichever building is waiting on it. +# +# Simulated HERE rather than by any node, exactly like the extractor haulers +# above and for the same reason: a route the player is nowhere near still has +# to run. `progress` drives both the drawn position and the moment the ore +# actually moves, so what you see and what happens cannot drift apart. +# +# Entries are {"hub_id", "state", "target_id", "carrying", "progress", +# "from", "to"}. +const TRANSPORTER_SPEED: float = 340.0 + +const TRANSPORTER_IDLE: String = "idle" +const TRANSPORTER_TO_MOTHERSHIP: String = "to_mothership" +const TRANSPORTER_TO_BUILDING: String = "to_building" +const TRANSPORTER_RETURNING: String = "returning" + +var transporters: Array = [] + + +func has_transport_hub() -> bool: + return not nearest_hub_to(Vector2.ZERO).is_empty() + + +func nearest_hub_to(at: Vector2) -> Dictionary: + var best: Dictionary = {} + var best_d: float = INF + for b in buildings: + if b.type != BUILDING_TRANSPORT_HUB: + continue + var d: float = at.distance_squared_to(b.position) + if d < best_d: + best_d = d + best = b + return best + + +func _station_transporter(built_at: Vector2) -> void: + # Built at the crafter, then it flies itself to the nearest hub and waits + # there for orders (#12). + var hub: Dictionary = nearest_hub_to(built_at) + if hub.is_empty(): + 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, + }) + + +func transporter_position(t: Dictionary) -> Vector2: + return (t.from as Vector2).lerp(t.to as Vector2, clampf(t.progress, 0.0, 1.0)) + + +func _hub_position(hub_id: int) -> Variant: + var hub: Dictionary = find_building(hub_id) + return null if hub.is_empty() else hub.position + + +func _begin_leg(t: Dictionary, from: Vector2, to: Vector2, state: String) -> void: + t.from = from + t.to = to + t.state = state + t.progress = 0.0 + + +func _transporter_inbound_to(building_id: int) -> bool: + for t in transporters: + if t.state != TRANSPORTER_IDLE and int(t.target_id) == building_id: + return true + return false + + +func _dispatch_transporters() -> void: + var mothership: Variant = get_mothership_position() + if mothership == null: + return # nowhere to load from + for b in buildings: + if outstanding_cost(int(b.id)) <= 0: + continue + # One at a time per building: without this every idle transporter + # would be sent after the same shortfall and they would all arrive + # carrying ore nobody needs any more. + if _transporter_inbound_to(int(b.id)): + continue + var chosen: Dictionary = {} + var best_d: float = INF + for t in transporters: + if t.state != TRANSPORTER_IDLE: + continue + var here: Vector2 = transporter_position(t) + var d: float = here.distance_squared_to(b.position) + if d < best_d: + best_d = d + chosen = t + if chosen.is_empty(): + return # every transporter is busy + chosen.target_id = int(b.id) + _begin_leg(chosen, transporter_position(chosen), mothership, TRANSPORTER_TO_MOTHERSHIP) + + +func _advance_transporters(delta: float) -> void: + if transporters.is_empty(): + return + _dispatch_transporters() + for t in transporters: + if t.state == TRANSPORTER_IDLE: + continue + var distance: float = (t.from as Vector2).distance_to(t.to as Vector2) + var leg: float = maxf(distance / TRANSPORTER_SPEED, 0.0001) + t.progress += delta / leg + if t.progress < 1.0: + continue + t.progress = 1.0 + _arrive(t) + + +func _arrive(t: Dictionary) -> void: + var here: Vector2 = t.to + var hub_pos: Variant = _hub_position(int(t.hub_id)) + match t.state: + TRANSPORTER_TO_MOTHERSHIP: + var target: Dictionary = find_building(int(t.target_id)) + var want: int = outstanding_cost(int(t.target_id)) + var take: int = mini(want, rocks) + if take <= 0 or target.is_empty(): + # Nothing to carry, or the building is gone. Go home empty. + t.target_id = 0 + _begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING) + return + rocks -= take + t.carrying = take + _begin_leg(t, here, target.position, TRANSPORTER_TO_BUILDING) + TRANSPORTER_TO_BUILDING: + # The ore moves at exactly the moment the vessel arrives. + if deposit_to_building(int(t.target_id), int(t.carrying)) <= 0: + # Building demolished in transit - put the ore back rather + # than quietly destroying it. + rocks += int(t.carrying) + 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 + + func can_afford(cost: int) -> bool: return rocks >= cost diff --git a/Main.tscn b/Main.tscn index 38e9f7e..f9242fe 100644 --- a/Main.tscn +++ b/Main.tscn @@ -22,6 +22,7 @@ [ext_resource type="Script" path="res://NeonPanel.gd" id="20"] [ext_resource type="Script" path="res://SkillTreeView.gd" id="21"] [ext_resource type="Script" path="res://CargoCounter.gd" id="22"] +[ext_resource type="Script" path="res://TransporterField.gd" id="23"] [sub_resource type="ShaderMaterial" id="1"] shader = ExtResource("5") @@ -72,6 +73,9 @@ script = ExtResource("14") [node name="HaulerField" type="Node2D" parent="."] script = ExtResource("18") +[node name="TransporterField" type="Node2D" parent="."] +script = ExtResource("23") + [node name="BuildController" type="Node2D" parent="."] script = ExtResource("15") @@ -609,8 +613,8 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -74 -offset_right = 74 +offset_left = -234 +offset_right = -86 offset_top = -74 offset_bottom = 74 script = ExtResource("10") @@ -622,14 +626,70 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -260 -offset_right = 260 +offset_left = -300 +offset_right = -20 offset_top = 100 offset_bottom = 200 -theme_override_font_sizes/font_size = 60 +theme_override_font_sizes/font_size = 32 text = "Interceptor" horizontal_alignment = 1 +[node name="TransporterButton" type="Control" parent="UI/CraftMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = 86 +offset_right = 234 +offset_top = -74 +offset_bottom = 74 +script = ExtResource("10") +icon = 12 +glow_color = Color(0.95, 0.55, 0.85, 1) + +[node name="TransporterLabel" type="Label" parent="UI/CraftMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = 20 +offset_right = 300 +offset_top = 96 +offset_bottom = 176 +theme_override_font_sizes/font_size = 32 +autowrap_mode = 2 +text = "Transporter" +horizontal_alignment = 1 + +[node name="StoreOreButton" type="Control" parent="UI/CraftMenu"] +anchor_left = 0.5 +anchor_top = 1.0 +anchor_right = 0.5 +anchor_bottom = 1.0 +offset_left = -74 +offset_top = -304 +offset_right = 74 +offset_bottom = -156 +script = ExtResource("10") +icon = 3 +glow_color = Color(1, 0.78, 0.25, 1) + +[node name="StoreOreLabel" type="Label" parent="UI/CraftMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_top = 1.0 +anchor_right = 0.5 +anchor_bottom = 1.0 +offset_left = -260 +offset_top = -148 +offset_right = 260 +offset_bottom = -24 +theme_override_font_sizes/font_size = 32 +autowrap_mode = 2 +text = "Store ore here" +horizontal_alignment = 1 + [node name="CloseButton" type="Control" parent="UI/CraftMenu"] anchor_left = 1.0 anchor_right = 1.0 @@ -794,33 +854,41 @@ horizontal_alignment = 1 [node name="UnlockButton" type="Control" parent="UI/SkillMenu"] anchor_left = 0.5 +anchor_top = 1.0 anchor_right = 0.5 +anchor_bottom = 1.0 offset_left = -74.0 -offset_top = 1090.0 +offset_top = -304.0 offset_right = 74.0 -offset_bottom = 1238.0 +offset_bottom = -156.0 script = ExtResource("10") icon = 5 glow_color = Color(0.45, 0.9, 0.55, 1) [node name="LoadOreButton" type="Control" parent="UI/SkillMenu"] anchor_left = 0.5 +anchor_top = 1.0 anchor_right = 0.5 +anchor_bottom = 1.0 offset_left = -256.0 -offset_top = 1090.0 +offset_top = -304.0 offset_right = -108.0 -offset_bottom = 1238.0 +offset_bottom = -156.0 script = ExtResource("10") icon = 4 glow_color = Color(1, 0.78, 0.25, 1) [node name="LoadOreLabel" type="Label" parent="UI/SkillMenu"] +mouse_filter = 2 anchor_left = 0.5 +anchor_top = 1.0 anchor_right = 0.5 -offset_left = -300.0 -offset_top = 1246.0 -offset_right = -64.0 -offset_bottom = 1296.0 +anchor_bottom = 1.0 +offset_left = -330.0 +offset_top = -150.0 +offset_right = -30.0 +offset_bottom = -14.0 +theme_override_font_sizes/font_size = 28 horizontal_alignment = 1 autowrap_mode = 2 text = "Load ore" diff --git a/Transporter.gd b/Transporter.gd new file mode 100644 index 0000000..07b5103 --- /dev/null +++ b/Transporter.gd @@ -0,0 +1,38 @@ +extends Node2D + +# Purely visual: a transport vessel running a hub -> mothership -> building +# supply route. Carries no state of its own; GameState owns the simulation +# and TransporterField creates, positions and frees this to match. + +const HULL_COLOR: Color = Color(0.95, 0.55, 0.85) + +var laden: bool = false + + +func _ready() -> void: + add_to_group("transporters") + z_index = -1 + queue_redraw() + + +func set_laden(value: bool) -> void: + if laden == value: + return + laden = value + queue_redraw() + + +func _draw() -> void: + # Squat and boxy, in the transport hub's own magenta, so it reads as + # "belongs to the hub" and not as an extractor hauler (green) or a fleet + # vessel (sharp cyan triangle). + var hull: PackedVector2Array = PackedVector2Array([ + Vector2(0, -16), Vector2(10, -4), Vector2(10, 14), Vector2(-10, 14), Vector2(-10, -4), + ]) + NeonDraw.draw_glowing_polygon(self, hull, HULL_COLOR, 1.0) + if laden: + # A visible payload, so a loaded run is distinguishable from a + # deadhead back to the hub at a glance. + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + Vector2(-5, 0), Vector2(5, 0), Vector2(5, 9), Vector2(-5, 9), + ]), HULL_COLOR, 0.85) diff --git a/Transporter.gd.uid b/Transporter.gd.uid new file mode 100644 index 0000000..317eb72 --- /dev/null +++ b/Transporter.gd.uid @@ -0,0 +1 @@ +uid://u8bqm1f03l2y diff --git a/Transporter.tscn b/Transporter.tscn new file mode 100644 index 0000000..d6555a6 --- /dev/null +++ b/Transporter.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://Transporter.gd" id="1"] + +[node name="Transporter" type="Node2D"] +script = ExtResource("1") diff --git a/TransporterField.gd b/TransporterField.gd new file mode 100644 index 0000000..c47ab7d --- /dev/null +++ b/TransporterField.gd @@ -0,0 +1,51 @@ +extends Node2D + +# Draws the transport vessels GameState is simulating, and only while they +# are somewhere the player could see them. Same arrangement as HaulerField, +# and for the same reason: the simulation is authoritative and the visuals +# are optional, so a run happening far away costs nothing but still moves +# ore. Position comes from the very same `progress` that decides when the +# ore actually changes hands. + +@export var visible_radius: float = 2600.0 + +@onready var fleet: Node2D = get_node("../Fleet") + +var transporter_scene: PackedScene = preload("res://Transporter.tscn") +# index into GameState.transporters -> live node +var active: Dictionary = {} + + +func _process(_delta: float) -> void: + var centre: Vector2 = fleet.get_centroid() + + for i in range(GameState.transporters.size()): + var t: Dictionary = GameState.transporters[i] + var pos: Vector2 = GameState.transporter_position(t) + if pos.distance_to(centre) > visible_radius: + _despawn(i) + continue + + var node: Node2D = active.get(i) + if node == null or not is_instance_valid(node): + node = transporter_scene.instantiate() + get_parent().add_child(node) + active[i] = node + + var previous: Vector2 = node.position + node.position = pos + node.set_laden(int(t.carrying) > 0) + var travel: Vector2 = pos - previous + if travel.length() > 0.5: + node.rotation = travel.angle() + PI / 2.0 + + for key in active.keys(): + if key >= GameState.transporters.size(): + _despawn(key) + + +func _despawn(index: int) -> void: + var node: Node2D = active.get(index) + if node != null and is_instance_valid(node): + node.queue_free() + active.erase(index) diff --git a/TransporterField.gd.uid b/TransporterField.gd.uid new file mode 100644 index 0000000..a42478f --- /dev/null +++ b/TransporterField.gd.uid @@ -0,0 +1 @@ +uid://mrx6p58dog66 diff --git a/tests/_build_menu_preview.gd.uid b/tests/_build_menu_preview.gd.uid new file mode 100644 index 0000000..5292ec5 --- /dev/null +++ b/tests/_build_menu_preview.gd.uid @@ -0,0 +1 @@ +uid://cuns23tjgn7fg diff --git a/tests/_cargo_readout_preview.gd.uid b/tests/_cargo_readout_preview.gd.uid new file mode 100644 index 0000000..8434c7f --- /dev/null +++ b/tests/_cargo_readout_preview.gd.uid @@ -0,0 +1 @@ +uid://djp66g3saubh4 diff --git a/tests/_craft_menu_preview.gd b/tests/_craft_menu_preview.gd new file mode 100644 index 0000000..73dca1f --- /dev/null +++ b/tests/_craft_menu_preview.gd @@ -0,0 +1,27 @@ +extends Node2D + +# Preview, not a test. Opens the CraftMenu so its layout can be checked on a +# real viewport - the mothership's "Load ore" label was falling off the bottom +# of shorter screens, which no headless test can see. +# +# bash tests/screenshot.sh res://tests/_craft_menu_preview.tscn /tmp/m.png 20 720x1280 +# ... and again at 720x960 to check a shorter screen format. + + +func _ready() -> void: + GameState.buildings.clear() + GameState.extractors.clear() + GameState.transporters.clear() + GameState.rocks = 180 + GameState.fleet_cargo = 24 + GameState.unlocked = ["shipyards", "logistics", "freight"] + var main: Node = load("res://Main.tscn").instantiate() + add_child(main) + main.get_node("BuildingField").place(GameState.BUILDING_TRANSPORT_HUB, Vector2(400.0, 0.0)) + var bc: Node = main.get_node("BuildController") + bc.craft_building_id = 1 + main.get_node("UI/CraftMenu").visible = true + if "Craft" == "Craft": + bc._refresh_craft_status() + else: + bc._refresh_load_ore() diff --git a/tests/_craft_menu_preview.tscn b/tests/_craft_menu_preview.tscn new file mode 100644 index 0000000..677d6e0 --- /dev/null +++ b/tests/_craft_menu_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_craft_menu_preview.gd" id="1"] + +[node name="CraftMenuPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/_skill_menu_preview.gd b/tests/_skill_menu_preview.gd index 2cb092d..4f74dbc 100644 --- a/tests/_skill_menu_preview.gd +++ b/tests/_skill_menu_preview.gd @@ -1,29 +1,27 @@ extends Node2D -# Preview scene - NOT run by run_tests.sh (underscore prefix). Opens the -# mothership's research tree with one branch already bought and a node -# selected, so the laid-out tree, its state colours and the detail panel can -# all be checked in one render. +# Preview, not a test. Opens the SkillMenu so its layout can be checked on a +# real viewport - the mothership's "Load ore" label was falling off the bottom +# of shorter screens, which no headless test can see. # -# bash tests/screenshot.sh res://tests/_skill_menu_preview.tscn out.png 30 720x1280 +# bash tests/screenshot.sh res://tests/_skill_menu_preview.tscn /tmp/m.png 20 720x1280 +# ... and again at 720x960 to check a shorter screen format. func _ready() -> void: GameState.buildings.clear() GameState.extractors.clear() - GameState.reset_progression() - GameState.rocks = 60 - # One branch researched, so the render shows all three node states at - # once: done, reachable, and still locked further out. - GameState.unlock("shipyards") - GameState.unlock("coordination") - + GameState.transporters.clear() + GameState.rocks = 180 + GameState.fleet_cargo = 24 + GameState.unlocked = ["shipyards", "logistics", "freight"] var main: Node = load("res://Main.tscn").instantiate() add_child(main) - main.get_node("AsteroidField").set_process(false) - main.get_node("EnemySpawner").set_process(false) - - var controller: Node = main.get_node("BuildController") - controller.skill_menu.visible = true - controller.skill_tree.selected_id = "wing" - controller._refresh_skill_menu("wing") + main.get_node("BuildingField").place(GameState.BUILDING_TRANSPORT_HUB, Vector2(400.0, 0.0)) + var bc: Node = main.get_node("BuildController") + bc.craft_building_id = 1 + main.get_node("UI/SkillMenu").visible = true + if "Skill" == "Craft": + bc._refresh_craft_status() + else: + bc._refresh_load_ore() diff --git a/tests/_transporter_preview.gd b/tests/_transporter_preview.gd new file mode 100644 index 0000000..68d2b55 --- /dev/null +++ b/tests/_transporter_preview.gd @@ -0,0 +1,38 @@ +extends Node2D + +# Preview, not a test. A transporter mid-run between the mothership and a +# building it is supplying, so the #19 vessel can actually be looked at +# alongside the hub that stations it. Headless draws no pixels. +# +# bash tests/screenshot.sh res://tests/_transporter_preview.tscn /tmp/t.png 25 1280x720 + + +func _ready() -> void: + GameState.buildings.clear() + GameState.extractors.clear() + GameState.transporters.clear() + GameState.rocks = 200 + GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(-420.0, 0.0)) + GameState.add_building(GameState.BUILDING_TRANSPORT_HUB, Vector2(420.0, 0.0)) + + # One laden, mid-leg, and one running home empty, so both states show. + GameState.transporters.append({ + "hub_id": 2, "state": GameState.TRANSPORTER_TO_BUILDING, "target_id": 2, + "carrying": 8, "progress": 0.45, + "from": Vector2(-420.0, 0.0), "to": Vector2(420.0, 0.0), + }) + GameState.transporters.append({ + "hub_id": 2, "state": GameState.TRANSPORTER_RETURNING, "target_id": 0, + "carrying": 0, "progress": 0.5, + "from": Vector2(-380.0, 150.0), "to": Vector2(420.0, 150.0), + }) + + var main: Node = load("res://Main.tscn").instantiate() + add_child(main) + main.get_node("AsteroidField").set_process(false) + main.get_node("EnemySpawner").set_process(false) + # Freeze the simulation so the vessels stay where they were placed. + GameState.set_process(false) + main.get_node("Fleet").move_to(Vector2(0.0, 260.0)) + var cam: Camera2D = main.get_node("Camera2D") + cam.zoom = Vector2(0.85, 0.85) diff --git a/tests/_transporter_preview.gd.uid b/tests/_transporter_preview.gd.uid new file mode 100644 index 0000000..7fbf0dc --- /dev/null +++ b/tests/_transporter_preview.gd.uid @@ -0,0 +1 @@ +uid://cqc5de83g4tr2 diff --git a/tests/_transporter_preview.tscn b/tests/_transporter_preview.tscn new file mode 100644 index 0000000..f3c7618 --- /dev/null +++ b/tests/_transporter_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_transporter_preview.gd" id="1"] + +[node name="TransporterPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/test_building_stock_queue.gd.uid b/tests/test_building_stock_queue.gd.uid new file mode 100644 index 0000000..d21c584 --- /dev/null +++ b/tests/test_building_stock_queue.gd.uid @@ -0,0 +1 @@ +uid://b0364sukgpwow diff --git a/tests/test_cargo_readout.gd.uid b/tests/test_cargo_readout.gd.uid new file mode 100644 index 0000000..9d85839 --- /dev/null +++ b/tests/test_cargo_readout.gd.uid @@ -0,0 +1 @@ +uid://bpldq0su02w1v diff --git a/tests/test_manual_supply_route.gd.uid b/tests/test_manual_supply_route.gd.uid new file mode 100644 index 0000000..1b3c8ab --- /dev/null +++ b/tests/test_manual_supply_route.gd.uid @@ -0,0 +1 @@ +uid://bjkqin5w5tah2 diff --git a/tests/test_transport_hub_building.gd.uid b/tests/test_transport_hub_building.gd.uid new file mode 100644 index 0000000..4599336 --- /dev/null +++ b/tests/test_transport_hub_building.gd.uid @@ -0,0 +1 @@ +uid://ctplk4adbsi3a diff --git a/tests/test_transporter_supply.gd b/tests/test_transporter_supply.gd new file mode 100644 index 0000000..dbc43e4 --- /dev/null +++ b/tests/test_transporter_supply.gd @@ -0,0 +1,207 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_transporter_supply.gd +# Forgejo #19 (part 6 of #12): transporters stationed at a hub fetch ore from +# the mothership and deliver it to whichever building is waiting on it, with +# no player input at all. +# +# Verifies: +# 1. a transporter is built at the CRAFTER and takes itself to the nearest +# hub, rather than appearing at the hub out of nowhere +# 2. a queued order the crafter cannot pay for is fulfilled end to end +# automatically - the whole point of the ticket +# 3. ore is conserved across the entire route (bank + hold + every +# building's stock), at every step, not just at the end +# 4. ore leaves the bank when it is LOADED, not when the order is placed - +# the vessel is carrying something real +# 5. transporters are not ordered when there is no hub to station them at +# 6. no unbounded node accumulation: the fleet of transporters is exactly +# what was built, however many runs they make + +var game_state: Node +var scene: Node +var fleet: Node +var field: Node +var crafter_id: int = 0 +var hub_id: int = 0 +var stage: int = 0 +var elapsed: float = 0.0 +var total_at_start: int = 0 +var bank_when_ordered: int = 0 +var conservation_broken: bool = false +# Ore legitimately consumed by completed production. Conservation means +# "nothing vanishes in TRANSIT", not "the total never falls" - building a +# vessel spends ore, and that is the one way it is allowed to leave. +var consumed: int = 0 + +const MOTHERSHIP_AT: Vector2 = Vector2(0.0, 0.0) +const HUB_AT: Vector2 = Vector2(600.0, 200.0) +const CRAFTER_AT: Vector2 = Vector2(900.0, -300.0) + + +func _total() -> int: + var sum: int = game_state.rocks + game_state.fleet_cargo + for b in game_state.buildings: + sum += int(b.get("stock", 0)) + for t in game_state.transporters: + sum += int(t.get("carrying", 0)) + return sum + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.rocks = 200 + game_state.fleet_cargo = 0 + game_state.ships_owed = 0 + game_state.unlocked = ["shipyards", "logistics", "freight", "coordination", "wing", "squadron", "armada"] + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + fleet = scene.get_node("Fleet") + field = scene.get_node("BuildingField") + game_state.order_completed.connect(_on_order_completed) + + +func _on_order_completed(_building_id: int, product: String) -> void: + consumed += game_state.product_cost(product) + + +func _process(delta: float) -> bool: + elapsed += delta + + # --- 3. conservation, checked EVERY tick ------------------------------ + # Ore in flight is the easy thing to lose: a leg that loads without + # debiting, or delivers without crediting, would show up here and nowhere + # else. Checked continuously rather than at the end, so it cannot be + # masked by a later correction. + if stage >= 2 and _total() + consumed != total_at_start: + conservation_broken = true + + match stage: + 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + for a in get_nodes_in_group("asteroids"): + if is_instance_valid(a): + a.queue_free() + + field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT) + field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT) + crafter_id = int(game_state.buildings[-1].id) + + # --- 5. no hub yet: a transporter cannot be ordered ---------- + if game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER): + print("TEST_TRANSPORTER_SUPPLY: FAIL (ordered a transporter with no hub to station it at)") + quit(1) + return true + print("no hub -> transporter order refused") + + field.place(GameState.BUILDING_TRANSPORT_HUB, HUB_AT) + hub_id = int(game_state.buildings[-1].id) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.2: + return false + # --- 1. build a transporter at the crafter ------------------ + if not game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER): + print("TEST_TRANSPORTER_SUPPLY: FAIL (could not order a transporter with a hub built)") + quit(1) + return true + game_state.deposit_to_building(crafter_id, GameState.TRANSPORTER_COST) + elapsed = 0.0 + stage = 2 + total_at_start = _total() + return false + 2: + if elapsed < 0.3: + return false + if game_state.transporters.size() != 1: + print("TEST_TRANSPORTER_SUPPLY: FAIL (built %d transporters from one order)" % game_state.transporters.size()) + quit(1) + return true + var t: Dictionary = game_state.transporters[0] + if int(t.hub_id) != hub_id: + print("TEST_TRANSPORTER_SUPPLY: FAIL (transporter did not take the nearest hub)") + quit(1) + return true + # It must fly FROM the crafter TO the hub, not appear at the hub. + # Checked on the leg's endpoints rather than its current position, + # which is already partway there by now - and being partway is + # the correct behaviour. + if (t.from as Vector2).distance_to(CRAFTER_AT) > 1.0: + print("TEST_TRANSPORTER_SUPPLY: FAIL (transporter's first leg starts at %s, not at the crafter that built it)" % t.from) + quit(1) + return true + if (t.to as Vector2).distance_to(HUB_AT) > 1.0: + print("TEST_TRANSPORTER_SUPPLY: FAIL (transporter's first leg does not end at the hub: %s)" % t.to) + quit(1) + return true + print("transporter built at the crafter, heading for hub %d" % hub_id) + elapsed = 0.0 + stage = 3 + return false + 3: + # Give it time to reach the hub and go idle. + if elapsed < 4.0: + return false + if game_state.transporters[0].state != GameState.TRANSPORTER_IDLE: + print("TEST_TRANSPORTER_SUPPLY: FAIL (never settled at its hub: state=%s)" % game_state.transporters[0].state) + quit(1) + return true + print("stationed at the hub, idle") + + # --- 2 + 4. an order it cannot pay for ---------------------- + game_state.queue_order(crafter_id, GameState.PRODUCT_SHIP) + bank_when_ordered = game_state.rocks + elapsed = 0.0 + stage = 4 + return false + 4: + # One tick: placing the order must not touch the bank. + if elapsed < 0.1: + return false + if game_state.rocks != bank_when_ordered: + print("TEST_TRANSPORTER_SUPPLY: FAIL (bank moved on placing the order, before anything was loaded)") + quit(1) + return true + print("order placed, bank untouched at %d" % game_state.rocks) + elapsed = 0.0 + stage = 5 + return false + 5: + # --- 2. the whole run, with no player input ----------------- + if game_state.pending_orders(crafter_id) > 0: + if elapsed > 25.0: + print("TEST_TRANSPORTER_SUPPLY: FAIL (order never fulfilled: transporter state=%s carrying=%d crafter stock=%d)" % [ + game_state.transporters[0].state, + int(game_state.transporters[0].carrying), + game_state.building_stock(crafter_id), + ]) + quit(1) + return true + return false + if conservation_broken: + print("TEST_TRANSPORTER_SUPPLY: FAIL (ore created or destroyed somewhere along the route)") + quit(1) + return true + print("order fulfilled automatically in %.1fs, no player input" % elapsed) + + # --- 6. no vessel accumulation ------------------------------ + if game_state.transporters.size() != 1: + print("TEST_TRANSPORTER_SUPPLY: FAIL (%d transporters exist after the run, one was built)" % game_state.transporters.size()) + quit(1) + return true + print("still exactly 1 transporter after a full round trip") + print("bank=%d crafter stock=%d total=%d + %d consumed = %d (started %d)" % [ + game_state.rocks, game_state.building_stock(crafter_id), _total(), + consumed, _total() + consumed, total_at_start + ]) + print("TEST_TRANSPORTER_SUPPLY: PASS") + quit(0) + return true + return false diff --git a/tests/test_transporter_supply.gd.uid b/tests/test_transporter_supply.gd.uid new file mode 100644 index 0000000..bfc9fcf --- /dev/null +++ b/tests/test_transporter_supply.gd.uid @@ -0,0 +1 @@ +uid://dxu5orxbivfpa