From b641fa58918a5c9e381515f96a6ad915a3894c6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Tue, 11 Aug 2026 15:00:41 +0200 Subject: [PATCH 01/11] Skill tree, node-based building animation, bigger UI, and a 19x faster test suite Gameplay and UI - Research tree at the mothership gates which structures can be built and grants permanent fleet-capacity upgrades (GameState.SKILL_TREE, SkillTreeView). Node depth is derived from prerequisites, so the layout cannot drift out of step with the dependencies. - Building info popup before committing to a build, with per-building cost and description. - Ship crafter redrawn as a space factory with an assembly animation; mothership redrawn as a radial flagship; command centre as a plotting hub. - Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build. - UI and font scaled up for phone use, via a custom Theme resource - gui/theme/default_font_size does not reach the default theme. - Asteroids no longer spawn on top of the fleet's start position. - Fleet formation origin derived from the layout's own offsets rather than the centroid, fixing the lurch when a lopsided formation started moving. - Fleet.stop() brakes from the ship's TRUE motion (velocity + fleet_velocity). Ship zeroes its velocity when seated in its slot even while the anchor drags that slot forward, so braking from velocity alone stopped the fleet dead instead of coasting - and whether it did depended on the frame rate. Animation architecture (issue #10) - Buildings, projectiles and explosion VFX no longer animate by redrawing. Static geometry is rasterised once in _draw(); moving parts are NeonPiece children animated through node properties, which reuse the cached geometry. Every NeonDraw call expands into four antialiased polylines, so redrawing to move something re-tessellated all of it. Test suite (issue #8): 1075s -> ~55s - Runs from a copy on Termux's own filesystem. The project lives on Android's FUSE bridge, read through proot, where load("res://Main.tscn") alone cost 8.5s per test against ~2s natively - and nearly every test loads it. - Runs 6 tests at a time, in a single proot-distro login, with output buffered per test and printed only on failure. - --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's result no longer depends on how busy the machine is. Without it, parallelism made two tests fail on 2 of 3 runs. - A test passes only if it both exits 0 and prints ": PASS". - 38 tests, green on three consecutive runs. Bundles Carrois Gothic SC (Apache 2.0) with its licence text. Co-Authored-By: Claude Opus 5 --- AsteroidExplosionVFX.gd | 18 +- AsteroidField.gd | 23 +- BuildController.gd | 143 +++++++-- Building.gd | 379 ++++++++++++++++------- ExplosionParticle.gd | 22 ++ ExplosionParticle.gd.uid | 1 + ExplosionVFXBase.gd | 46 ++- Fleet.gd | 30 +- FormationEditor.gd | 4 +- GameState.gd | 137 ++++++++ IconButton.gd | 33 +- LightBurstVFX.gd | 10 +- Main.tscn | 203 +++++++----- NeonDraw.gd | 4 +- NeonNumber.gd | 14 +- NeonPiece.gd | 47 +++ NeonPiece.gd.uid | 1 + PlanetSurface.tscn | 22 +- Projectile.gd | 15 +- SkillTreeView.gd | 121 ++++++++ SkillTreeView.gd.uid | 1 + fonts/CarroisGothicSC-Regular.ttf | Bin 0 -> 40356 bytes fonts/CarroisGothicSC-Regular.ttf.import | 36 +++ fonts/LICENSE.txt | 202 ++++++++++++ fonts/README.md | 15 + tests/_crafter_preview.gd | 2 +- tests/_font_probe.gd | 3 +- tests/_profile_startup.gd | 40 +++ tests/_profile_startup.gd.uid | 1 + tests/_run_inside.sh | 93 ++++++ tests/_skill_menu_preview.gd | 29 ++ tests/_skill_menu_preview.gd.uid | 1 + tests/_skill_menu_preview.tscn | 6 + tests/_vfx_preview.gd | 47 +++ tests/_vfx_preview.gd.uid | 1 + tests/_vfx_preview.tscn | 6 + tests/run_tests.sh | 80 ++++- tests/test_asteroid_safe_spawn.gd | 114 +++++++ tests/test_asteroid_safe_spawn.gd.uid | 1 + tests/test_build_info_popup.gd | 6 + tests/test_building_redraw_cost.gd | 110 +++++++ tests/test_building_redraw_cost.gd.uid | 1 + tests/test_extractor_building.gd | 5 + tests/test_extractor_income.gd | 8 +- tests/test_joystick_control.gd | 14 +- tests/test_skill_tree.gd | 169 ++++++++++ tests/test_skill_tree.gd.uid | 1 + ui_theme.tres | 15 +- 48 files changed, 1981 insertions(+), 299 deletions(-) create mode 100644 ExplosionParticle.gd create mode 100644 ExplosionParticle.gd.uid create mode 100644 NeonPiece.gd create mode 100644 NeonPiece.gd.uid create mode 100644 SkillTreeView.gd create mode 100644 SkillTreeView.gd.uid create mode 100644 fonts/CarroisGothicSC-Regular.ttf create mode 100644 fonts/CarroisGothicSC-Regular.ttf.import create mode 100644 fonts/LICENSE.txt create mode 100644 fonts/README.md create mode 100644 tests/_profile_startup.gd create mode 100644 tests/_profile_startup.gd.uid create mode 100644 tests/_run_inside.sh create mode 100644 tests/_skill_menu_preview.gd create mode 100644 tests/_skill_menu_preview.gd.uid create mode 100644 tests/_skill_menu_preview.tscn create mode 100644 tests/_vfx_preview.gd create mode 100644 tests/_vfx_preview.gd.uid create mode 100644 tests/_vfx_preview.tscn create mode 100644 tests/test_asteroid_safe_spawn.gd create mode 100644 tests/test_asteroid_safe_spawn.gd.uid create mode 100644 tests/test_building_redraw_cost.gd create mode 100644 tests/test_building_redraw_cost.gd.uid create mode 100644 tests/test_skill_tree.gd create mode 100644 tests/test_skill_tree.gd.uid diff --git a/AsteroidExplosionVFX.gd b/AsteroidExplosionVFX.gd index 1396edb..9830458 100644 --- a/AsteroidExplosionVFX.gd +++ b/AsteroidExplosionVFX.gd @@ -17,13 +17,21 @@ func _random_particle_size() -> float: return randf_range(3.0, 7.0) -func _draw_particle(p: Dictionary, alpha: float) -> void: +# Debris tumbles independently of its flight path, unlike the light-burst +# streaks - each chunk needs its own evolving rotation, so the base class +# must keep writing visual.rotation every frame instead of leaving it fixed. +func _particle_spins() -> bool: + return true + + +func _configure_particle(visual: ExplosionParticle, p: Dictionary) -> void: var s: float = p["size"] + # Unrotated local points - the child node's own `rotation` (driven by + # the base class from p["rot"]) supplies the tumble that used to be + # baked into a fresh pt.rotated(p["rot"]) call every frame. var local_pts: PackedVector2Array = PackedVector2Array([ Vector2(-s, -s * 0.6), Vector2(s * 0.7, -s * 0.5), Vector2(s, s * 0.5), Vector2(-s * 0.6, s), ]) - var world_pts: PackedVector2Array = PackedVector2Array() - for pt in local_pts: - world_pts.append(p["pos"] + pt.rotated(p["rot"])) - draw_colored_polygon(world_pts, Color(COLOR.r, COLOR.g, COLOR.b, alpha)) + visual.draw_callable = func(item: CanvasItem) -> void: + item.draw_colored_polygon(local_pts, COLOR) diff --git a/AsteroidField.gd b/AsteroidField.gd index efb7b39..5b10d7b 100644 --- a/AsteroidField.gd +++ b/AsteroidField.gd @@ -7,6 +7,10 @@ extends Node2D @export var check_interval: float = 1.0 @export var spawn_radius_chunks: int = 2 +# No asteroid may be created within this distance of the fleet. At cruise +# speed this is well over a second of warning, so anything that reaches the +# fleet was visible on its way in. +@export var safe_spawn_radius: float = 420.0 @export var despawn_radius_chunks: int = 4 @export var asteroids_per_chunk: int = 6 @export var min_speed: float = 12.0 @@ -63,14 +67,31 @@ func _chebyshev_chunk_distance(a: Vector2i, b: Vector2i) -> int: return max(abs(a.x - b.x), abs(a.y - b.y)) +func _safe_position(candidate: Vector2, centre: Vector2) -> Vector2: + # Nothing may materialise on top of the fleet. Chunks are populated around + # wherever the fleet happens to be - including the chunk it is standing in + # - so without this an asteroid could appear directly on a ship and + # destroy it with no chance to react. That is worst on the frame a scene + # loads: arriving back from a planet could cost a ship instantly. + if candidate.distance_to(centre) >= safe_spawn_radius: + return candidate + # Push it out to the edge of the bubble rather than dropping it, so the + # field keeps its density near the fleet instead of thinning out. + var away: Vector2 = candidate - centre + if away.length() < 0.001: + away = Vector2.RIGHT.rotated(randf() * TAU) + return centre + away.normalized() * safe_spawn_radius + + func _spawn_chunk_asteroids(chunk: Vector2i) -> void: var origin: Vector2 = WorldGen.chunk_origin(chunk) var spawned: Array = [] + var fleet_centre: Vector2 = fleet.get_centroid() for i in range(asteroids_per_chunk): var local_pos: Vector2 = Vector2(randf() * WorldGen.CHUNK_SIZE, randf() * WorldGen.CHUNK_SIZE) var asteroid: Node2D = asteroid_scene.instantiate() - asteroid.position = origin + local_pos + asteroid.position = _safe_position(origin + local_pos, fleet_centre) var angle: float = randf() * TAU var speed: float = randf_range(min_speed, max_speed) asteroid.velocity = Vector2(cos(angle), sin(angle)) * speed diff --git a/BuildController.gd b/BuildController.gd index 1931cae..1872226 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -19,7 +19,6 @@ extends Node2D @onready var world_map: Control = get_node("../UI/WorldMap") @onready var build_button: IconButton = get_node("../UI/BuildButton") @onready var action_button: IconButton = get_node("../UI/BuildActionButton") -@onready var action_label: Label = get_node("../UI/BuildActionLabel") @onready var build_menu: Control = get_node("../UI/BuildMenu") @onready var build_mothership_button: IconButton = get_node("../UI/BuildMenu/MothershipButton") @@ -41,6 +40,14 @@ extends Node2D @onready var craft_close_button: IconButton = get_node("../UI/CraftMenu/CloseButton") @onready var craft_status: Label = get_node("../UI/CraftMenu/StatusLabel") +@onready var skill_menu: Control = get_node("../UI/SkillMenu") +@onready var skill_tree: Control = get_node("../UI/SkillMenu/Tree") +@onready var skill_detail: Label = get_node("../UI/SkillMenu/DetailLabel") +@onready var skill_cost: NeonNumber = get_node("../UI/SkillMenu/CostNumber") +@onready var skill_status: Label = get_node("../UI/SkillMenu/StatusLabel") +@onready var skill_unlock_button: IconButton = get_node("../UI/SkillMenu/UnlockButton") +@onready var skill_close_button: IconButton = get_node("../UI/SkillMenu/CloseButton") + @onready var formation_menu: Control = get_node("../UI/FormationMenu") @onready var formation_editor: Control = get_node("../UI/FormationMenu/Editor") @onready var formation_close_button: IconButton = get_node("../UI/FormationMenu/CloseButton") @@ -101,24 +108,34 @@ func _ready() -> void: formation_reset_button.pressed.connect(_on_reset_formation) formation_editor.formation_changed.connect(_on_formation_changed) + skill_close_button.pressed.connect(func(): skill_menu.visible = false) + skill_unlock_button.pressed.connect(_on_unlock_pressed) + skill_tree.node_picked.connect(_on_skill_node_picked) + build_menu.visible = false info_popup.visible = false craft_menu.visible = false formation_menu.visible = false + skill_menu.visible = false action_button.visible = false - action_label.visible = false # True while a building prompt or panel owns the shared action slot, so the # enter-atmosphere prompt knows to stay out of the way. func is_showing_building_prompt() -> bool: - return current_building != null or build_menu.visible or craft_menu.visible or formation_menu.visible + return current_building != null or build_menu.visible or craft_menu.visible \ + or formation_menu.visible or skill_menu.visible func _unhandled_input(event: InputEvent) -> void: - # The formation editor is driven from here rather than via _gui_input, - # for the same reason as the joystick: its Control must not swallow - # touches, or dragging a slot would never reach it. + # The formation editor and the skill tree are driven from here rather than + # via _gui_input, for the same reason as the joystick: a Control that + # consumes touches through the GUI system swallows them before they get + # here, which is what made dragging a formation slot silently do nothing. + if skill_menu.visible: + if event is InputEventScreenTouch and skill_tree.handle_touch(event): + get_viewport().set_input_as_handled() + return if not formation_menu.visible: return if event is InputEventScreenTouch and formation_editor.handle_touch(event): @@ -133,20 +150,19 @@ func _process(delta: float) -> void: if build_menu.visible: # Keep the greyed state live while the menu is open, so flying onto # a planet lights the option up without reopening it. - build_extractor_button.enabled = _planet_under_fleet() != null + build_extractor_button.enabled = _planet_under_fleet() != null \ + and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR) - if world_map.visible or build_menu.visible or craft_menu.visible or formation_menu.visible: + if world_map.visible or build_menu.visible or craft_menu.visible \ + or formation_menu.visible or skill_menu.visible: current_building = null pending_building = null action_button.visible = false - action_label.visible = false return # Debounced exactly like the enter-atmosphere prompt, so the action # button can't flicker while the fleet drifts on the interact boundary. var detected: Node2D = building_field.get_building_fleet_is_on() - if detected != null and detected.building_type == GameState.BUILDING_MOTHERSHIP: - detected = null # nothing to do at a mothership beyond respawning if detected != pending_building: pending_building = detected @@ -159,13 +175,21 @@ func _process(delta: float) -> void: current_building = pending_building action_button.visible = current_building != null - action_label.visible = current_building != null if current_building != null: - action_button.icon = IconButton.Icon.SHIP if current_building.building_type == GameState.BUILDING_SHIP_CRAFTER else IconButton.Icon.MAP - action_label.text = current_building.get_display_name() + action_button.icon = _action_icon_for(current_building.building_type) action_button.queue_redraw() +func _action_icon_for(type: String) -> int: + match type: + GameState.BUILDING_SHIP_CRAFTER: + return IconButton.Icon.SHIP + GameState.BUILDING_MOTHERSHIP: + return IconButton.Icon.MOTHERSHIP + _: + return IconButton.Icon.COMMAND_CENTER + + func _check_fleet_wipe() -> void: if not fleet.ships.is_empty(): return @@ -178,7 +202,10 @@ func _check_fleet_wipe() -> void: func _on_action_pressed() -> void: if current_building == null: return - if current_building.building_type == GameState.BUILDING_SHIP_CRAFTER: + if current_building.building_type == GameState.BUILDING_MOTHERSHIP: + skill_menu.visible = true + _refresh_skill_menu() + elif current_building.building_type == GameState.BUILDING_SHIP_CRAFTER: craft_menu.visible = true _refresh_craft_status() elif current_building.building_type == GameState.BUILDING_COMMAND_CENTER: @@ -199,10 +226,14 @@ func _planet_under_fleet() -> Node2D: func _refresh_build_status() -> void: - # The extractor option is always listed, but greyed unless the fleet is - # actually over a planet - showing it only sometimes would leave the - # player wondering whether the game has it at all. - build_extractor_button.enabled = _planet_under_fleet() != null + # Every option is always listed, greyed when it cannot be used - showing + # them only sometimes would leave the player wondering whether the game + # has them at all. Greyed buttons still emit `pressed`, so each one can + # explain itself in the info popup. + build_crafter_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER) + build_command_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_COMMAND_CENTER) + build_extractor_button.enabled = _planet_under_fleet() != null \ + and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR) # Prices are shown under each option now, so this only needs to say # whether the fleet is somewhere an extractor could go. @@ -248,9 +279,15 @@ func _show_building_info(type: String) -> void: var lines: Array = [info.body] var can_build: bool = true + # Locked structures still list themselves, greyed, so the player can see + # what exists and what research would open it up. + if not GameState.is_building_unlocked(type): + lines.append("Locked. Research %s at your mothership." % GameState.research_for_building(type)) + can_build = false + # Anything that would stop this particular placement is spelled out in # the same popup, rather than silently failing after the press. - if type == GameState.BUILDING_EXTRACTOR: + if can_build and type == GameState.BUILDING_EXTRACTOR: var planet: Node2D = _planet_under_fleet() if planet == null: lines.append("No planet in reach - fly over one.") @@ -260,7 +297,7 @@ func _show_building_info(type: String) -> void: can_build = false else: lines.append("Will mine %s." % planet.planet_name) - elif type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership(): + elif can_build and type == GameState.BUILDING_MOTHERSHIP and GameState.has_mothership(): lines.append("WARNING: only one may exist - this destroys your current one.") if can_build and not GameState.can_afford(cost): @@ -290,6 +327,9 @@ func _confirm_build() -> void: # Everything is re-checked here rather than trusted from when the popup # opened: the fleet keeps flying while it is up, so it can drift off the # planet an extractor was going to mine. + if not GameState.is_building_unlocked(type): + return + if type == GameState.BUILDING_EXTRACTOR: var planet: Node2D = _planet_under_fleet() if planet == null or GameState.has_extractor_at(planet.planet_name): @@ -324,15 +364,70 @@ func _try_place(type: String, cost: int) -> void: build_menu.visible = false +# --- research (the mothership's skill tree) -------------------------------- + +func _on_skill_node_picked(id: String) -> void: + _refresh_skill_menu(id) + + +func _refresh_skill_menu(id: String = "") -> void: + if id == "": + id = skill_tree.selected_id + if id == "": + # Nothing chosen yet: point the player at the tree rather than + # leaving the panel blank, which reads as broken. + skill_detail.text = "Select a node to see what it unlocks." + skill_cost.amount = 0 + skill_status.text = "Fleet capacity %d" % GameState.max_fleet_size() + skill_unlock_button.visible = false + return + + var node: Dictionary = GameState.skill_node(id) + if node.is_empty(): + return + skill_detail.text = "%s\n%s" % [node.name, node.desc] + skill_cost.amount = int(node.cost) + + if GameState.is_unlocked(id): + skill_status.text = "Researched" + skill_unlock_button.visible = false + elif not GameState.prerequisite_met(id): + skill_status.text = "Needs %s first" % GameState.skill_node(node.requires).name + skill_unlock_button.visible = false + elif not GameState.can_afford(int(node.cost)): + skill_status.text = "Not enough rocks - you have %d" % GameState.rocks + skill_unlock_button.visible = false + else: + skill_status.text = "" + skill_unlock_button.visible = true + + +func _on_unlock_pressed() -> void: + var id: String = skill_tree.selected_id + if id == "": + return + if not GameState.unlock(id): + # Re-check rather than trust the button: rocks can be spent elsewhere + # while this menu is open, since haulers keep delivering. + _refresh_skill_menu(id) + return + # A capacity upgrade has to reach the fleet that is already flying, not + # just ones rebuilt later. + if int(GameState.skill_node(id).get("fleet_bonus", 0)) > 0: + fleet.apply_formation() + skill_tree.queue_redraw() + _refresh_skill_menu(id) + + func _refresh_craft_status() -> void: craft_status.text = "Interceptor %d rocks You have %d Fleet %d/%d" % [ - GameState.SHIP_COST, GameState.rocks, fleet.ships.size(), GameState.formation.size() + GameState.SHIP_COST, GameState.rocks, fleet.ships.size(), GameState.max_fleet_size() ] func _on_craft_interceptor() -> void: - if fleet.ships.size() >= GameState.formation.size(): - craft_status.text = "Fleet is full (%d slots)" % GameState.formation.size() + if fleet.ships.size() >= GameState.max_fleet_size(): + craft_status.text = "Fleet is full (%d slots)" % GameState.max_fleet_size() return if not GameState.spend(GameState.SHIP_COST): craft_status.text = "Not enough rocks (need %d, have %d)" % [GameState.SHIP_COST, GameState.rocks] diff --git a/Building.gd b/Building.gd index e9fe17a..5164b70 100644 --- a/Building.gd +++ b/Building.gd @@ -39,6 +39,7 @@ const CORE_CENTRE: Vector2 = Vector2(-58.0, 12.0) const BAY_CENTRE: Vector2 = Vector2(46.0, 12.0) const RIB_RADIUS: float = 36.0 const RAIL_Y: float = -32.0 +const FLASH_RING_RADIUS: float = 20.0 # The hull in four pieces, attached in this order. Each carries the point # the crane welds it on at, which is where the sparks fly from. @@ -66,26 +67,56 @@ var phase_time: float = 0.0 var _hull_parts: Array[PackedVector2Array] = [] +# Every moving element is its own NeonPiece, rasterised once when the +# building is created and thereafter only moved, scaled, faded or hidden. +# Nothing here ever calls queue_redraw() again: the building's own _draw() +# lays down the fixed structure a single time, and the animation is pure +# node-property work from then on. +var _core_cell: NeonPiece = null +var _satellites: Node2D = null +var _beacons: Array = [] +var _clamps: NeonPiece = null +var _crane: Node2D = null +var _hoist: NeonPiece = null +var _sparks: Node2D = null +var _hull_holder: Node2D = null +var _hull_pieces: Array = [] +var _flash_ring: NeonPiece = null +var _wash: NeonPiece = null + func _ready() -> void: add_to_group("buildings") z_index = -1 # The crafter runs an assembly line and the mothership holds a live - # reactor with satellites in orbit, so both redraw. The command centre is - # static art and is drawn exactly once - per-frame redraws of art that - # never changes were a real framerate cost earlier on. + # reactor with satellites in orbit, so both animate. The command centre is + # entirely static and needs no per-frame work at all. var animated: bool = building_type != GameState.BUILDING_COMMAND_CENTER set_process(animated) if building_type == GameState.BUILDING_SHIP_CRAFTER: for part in SHIP_PARTS: _hull_parts.append(PackedVector2Array(part.points)) + _build_crafter_pieces() + elif building_type == GameState.BUILDING_MOTHERSHIP: + _build_mothership_pieces() queue_redraw() func _process(delta: float) -> void: anim_time = fmod(anim_time + delta, CYCLE) phase_time += delta - queue_redraw() + apply_animation() + + +# Pushes the current clock onto the movable pieces. Normally driven from +# _process; public so a preview or a test can pin the clock and see exactly +# that frame without having to run the animation up to it in real time. +func apply_animation() -> void: + match building_type: + GameState.BUILDING_MOTHERSHIP: + _animate_mothership() + GameState.BUILDING_SHIP_CRAFTER: + _animate_crafter() # Where the satellites are, and how open the reactor core is. Both are read @@ -109,14 +140,25 @@ func get_display_name() -> String: return "Command Center" +# How many times this building's geometry has actually been rasterised. The +# whole point of the NeonPiece decomposition is that this stays at 1 for the +# building's lifetime however long it animates, so it is worth being able to +# assert rather than assume. +var draw_count: int = 0 + + func _draw() -> void: + draw_count += 1 + # Runs exactly once per building: this is the fixed structure. Everything + # that moves is a NeonPiece child, animated through node properties, so + # nothing here ever asks for a redraw again. match building_type: GameState.BUILDING_COMMAND_CENTER: _draw_command_center() GameState.BUILDING_MOTHERSHIP: - _draw_mothership() + _draw_mothership_static(self) _: - _draw_crafter() + _draw_crafter_static(self) # --- shared helpers ------------------------------------------------------- @@ -139,17 +181,57 @@ func _arc(centre: Vector2, radius: float, from_a: float, to_a: float, steps: int -func _draw_command_center() -> void: - # Diamond with three formation pips inside - reads as "arrangement". - var body: PackedVector2Array = PackedVector2Array([ - Vector2(0, -62), Vector2(58, 0), Vector2(0, 62), Vector2(-58, 0), +# --- command centre: a tactical plotting hub ------------------------------ +# The mothership is radial-and-swept, the crafter is an asymmetric assembly +# line, so the command centre reads best as the odd one out: rigid, hexagonal, +# a wheel rather than a claw. Six spokes tie a hex rim to a central diamond +# readout, and every spoke ends in a formation port - half of them holding a +# token, half left open - so the structure itself shows fleet slots waiting +# to be filled, which is exactly what the drag-and-drop editor inside does. + +const CMD_HEX_RADIUS: float = 88.0 +const CMD_DECK_RADIUS: float = 66.0 +const CMD_HUB_RADIUS: float = 30.0 +const CMD_SCREEN_RADIUS: float = 14.0 +const CMD_PORT_COUNT: int = 6 +const CMD_PORT_LENGTH: float = 16.0 +const CMD_TOKEN_RADIUS: float = 9.0 +const CMD_STUD_RADIUS: float = 7.0 + + +func _diamond(centre: Vector2, r: float) -> PackedVector2Array: + return PackedVector2Array([ + centre + Vector2(0, -r), centre + Vector2(r, 0), + centre + Vector2(0, r), centre + Vector2(-r, 0), ]) - NeonDraw.draw_glowing_polygon(self, body, COMMAND_COLOR, 1.0) - for p in [Vector2(0, -18), Vector2(-20, 16), Vector2(20, 16)]: - var pip: PackedVector2Array = PackedVector2Array([ - p + Vector2(0, -9), p + Vector2(-8, 8), p + Vector2(8, 8), - ]) - NeonDraw.draw_glowing_polygon(self, pip, COMMAND_COLOR, 1.0) + + +func _draw_command_center() -> void: + var c: Color = COMMAND_COLOR + var readout: Color = Color(0.82, 0.86, 1.0) + + # The rim: a hex table holding the whole layout together, with a fainter + # deck ring nested just inside it - the same "plating has layers" read the + # mothership gets from its nested reactor rings. + NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, CMD_HEX_RADIUS, CMD_PORT_COUNT, PI / 6.0), c, 1.0) + NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, CMD_DECK_RADIUS, CMD_PORT_COUNT, PI / 6.0), c, 0.5, NeonDraw.DEFAULT_CORE, 0.7) + + for i in range(CMD_PORT_COUNT): + var a: float = (float(i) / CMD_PORT_COUNT) * TAU + PI / 6.0 + var dir: Vector2 = Vector2(cos(a), sin(a)) + # Spoke bracing the hub to the rim. + NeonDraw.draw_glowing_line(self, dir * CMD_HUB_RADIUS, dir * CMD_HEX_RADIUS, c, 0.65, 0.36) + # Socket where the mast plugs into the rim. + NeonDraw.draw_glowing_polygon(self, _ring(dir * CMD_HEX_RADIUS, CMD_STUD_RADIUS, 6, a), c, 0.85) + # Port mast standing proud of the rim; every other one is manned. + var port_tip: Vector2 = dir * (CMD_HEX_RADIUS + CMD_PORT_LENGTH) + NeonDraw.draw_glowing_line(self, dir * CMD_HEX_RADIUS, port_tip, c, 0.8, 0.4) + if i % 2 == 0: + NeonDraw.draw_glowing_polygon(self, _diamond(port_tip, CMD_TOKEN_RADIUS), c, 1.0) + + # Command core: a diamond hub with a lit readout set inside it. + NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_HUB_RADIUS), c, 1.0) + NeonDraw.draw_glowing_polygon(self, _diamond(Vector2.ZERO, CMD_SCREEN_RADIUS), readout, 0.9) # --- mothership: a radial flagship ---------------------------------------- @@ -187,49 +269,65 @@ func _talon(base_angle: float) -> PackedVector2Array: return pts -func _draw_mothership() -> void: +func _draw_mothership_static(canvas: CanvasItem) -> void: var c: Color = MOTHERSHIP_COLOR - var core_color: Color = Color(0.75, 1.0, 0.85) - for i in range(ARM_COUNT): var base: float = (float(i) / float(ARM_COUNT)) * TAU - NeonDraw.draw_glowing_polygon(self, _talon(base), c, 0.95) + NeonDraw.draw_glowing_polygon(canvas, _talon(base), c, 0.95) # Crystal panel set into the gap behind each arm. var pa: float = base - 0.30 - NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + NeonDraw.draw_glowing_polygon(canvas, PackedVector2Array([ Vector2(cos(pa - 0.11), sin(pa - 0.11)) * 54.0, Vector2(cos(pa + 0.11), sin(pa + 0.11)) * 54.0, Vector2(cos(pa + 0.15), sin(pa + 0.15)) * 86.0, Vector2(cos(pa - 0.15), sin(pa - 0.15)) * 86.0, ]), c, 0.7) - # Reactor core: nested rings around a cell that breathes. - NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, 38.0, 6, PI / 6.0), c, 1.0) - NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, 24.0, 6, 0.0), c, 0.85) - var breath: float = core_pulse() - NeonDraw.draw_glowing_polygon(self, _ring(Vector2.ZERO, lerpf(9.0, 15.0, breath), 6, PI / 6.0), - core_color, lerpf(0.55, 1.0, breath)) + # Reactor housing: the nested rings around the cell that breathes. + NeonDraw.draw_glowing_polygon(canvas, _ring(Vector2.ZERO, 38.0, 6, PI / 6.0), c, 1.0) + NeonDraw.draw_glowing_polygon(canvas, _ring(Vector2.ZERO, 24.0, 6, 0.0), c, 0.85) - # Satellites holding station around it, drifting slowly round. - var orbit: float = orbit_angle() + +const CORE_CELL_RADIUS: float = 12.0 + + +func _build_mothership_pieces() -> void: + var core_color: Color = Color(0.75, 1.0, 0.85) + + # The breathing cell: drawn at a reference size and then scaled, rather + # than rebuilt at a new radius every frame. + _core_cell = NeonPiece.create(self, Vector2.ZERO, core_color) + _core_cell.add_polygon(_ring(Vector2.ZERO, CORE_CELL_RADIUS, 6, PI / 6.0)) + + # Satellites sit at fixed offsets inside a holder; the orbit is the + # holder's rotation, so four hexagon pairs become one transform update. + _satellites = Node2D.new() + add_child(_satellites) for i in range(SATELLITE_COUNT): - var a: float = orbit + (float(i) / float(SATELLITE_COUNT)) * TAU + var a: float = (float(i) / float(SATELLITE_COUNT)) * TAU var at: Vector2 = Vector2(cos(a), sin(a)) * SATELLITE_ORBIT - NeonDraw.draw_glowing_polygon(self, _ring(at, 10.0, 6, PI / 6.0), c, 0.85) - NeonDraw.draw_glowing_polygon(self, _ring(at, 4.5, 6, PI / 6.0), core_color, 0.8) + var shell: NeonPiece = NeonPiece.create(_satellites, at, MOTHERSHIP_COLOR) + shell.add_polygon(_ring(Vector2.ZERO, 10.0, 6, PI / 6.0), 0.85) + var cell: NeonPiece = NeonPiece.create(_satellites, at, core_color) + cell.add_polygon(_ring(Vector2.ZERO, 4.5, 6, PI / 6.0), 0.8) + + +func _animate_mothership() -> void: + var breath: float = core_pulse() + var radius: float = lerpf(9.0, 15.0, breath) + _core_cell.scale = Vector2.ONE * (radius / CORE_CELL_RADIUS) + _core_cell.modulate.a = lerpf(0.55, 1.0, breath) + _satellites.rotation = orbit_angle() # --- ship crafter: an orbital yard with a hull in the cradle -------------- -func _draw_crafter() -> void: +func _draw_crafter_static(canvas: CanvasItem) -> void: var c: Color = CRAFTER_COLOR - var t: float = anim_time / CYCLE - # Fabricator core: a reactor housing whose inner cell pulses as it feeds - # the yard. This replaces the chimney - nothing vents smoke in vacuum. - NeonDraw.draw_glowing_polygon(self, _ring(CORE_CENTRE, 26.0, 6, PI / 6.0), c, 1.0) - var reactor_glow: float = 0.45 + 0.55 * (0.5 + 0.5 * sin(phase_time * 4.0)) - NeonDraw.draw_glowing_polygon(self, _ring(CORE_CENTRE, 12.0, 6, PI / 6.0), Color(1.0, 0.9, 0.6), reactor_glow) + # Fabricator core housing. This replaces the chimney - nothing vents + # smoke in vacuum. + NeonDraw.draw_glowing_polygon(canvas, _ring(CORE_CENTRE, 26.0, 6, PI / 6.0), c, 1.0) # Radiator fins above and below the core, slatted like real ones. for dir_y in [-1.0, 1.0]: @@ -237,44 +335,114 @@ func _draw_crafter() -> void: CORE_CENTRE + Vector2(-16, 30 * dir_y), CORE_CENTRE + Vector2(18, 34 * dir_y), CORE_CENTRE + Vector2(24, 54 * dir_y), CORE_CENTRE + Vector2(-10, 50 * dir_y), ]) - NeonDraw.draw_glowing_polygon(self, fin, c, 0.75) + NeonDraw.draw_glowing_polygon(canvas, fin, c, 0.75) for f in [0.35, 0.68]: - NeonDraw.draw_glowing_line(self, + NeonDraw.draw_glowing_line(canvas, fin[0].lerp(fin[3], f), fin[1].lerp(fin[2], f), c, 0.5, 0.28) # Truss spine linking the core to the cradle: two rails with X-bracing, # the standard "this was assembled in orbit" read. var spine_top: float = 4.0 var spine_bottom: float = 22.0 - NeonDraw.draw_glowing_line(self, Vector2(-32, spine_top), Vector2(14, spine_top), c, 0.9, 0.4) - NeonDraw.draw_glowing_line(self, Vector2(-32, spine_bottom), Vector2(14, spine_bottom), c, 0.9, 0.4) + NeonDraw.draw_glowing_line(canvas, Vector2(-32, spine_top), Vector2(14, spine_top), c, 0.9, 0.4) + NeonDraw.draw_glowing_line(canvas, Vector2(-32, spine_bottom), Vector2(14, spine_bottom), c, 0.9, 0.4) for i in range(3): var x0: float = -32.0 + float(i) * 15.0 var x1: float = x0 + 15.0 - NeonDraw.draw_glowing_line(self, Vector2(x0, spine_top), Vector2(x1, spine_bottom), c, 0.55, 0.28) - NeonDraw.draw_glowing_line(self, Vector2(x0, spine_bottom), Vector2(x1, spine_top), c, 0.55, 0.28) + NeonDraw.draw_glowing_line(canvas, Vector2(x0, spine_top), Vector2(x1, spine_bottom), c, 0.55, 0.28) + NeonDraw.draw_glowing_line(canvas, Vector2(x0, spine_bottom), Vector2(x1, spine_top), c, 0.55, 0.28) # Construction cradle: two open ribs holding the hull, with the crane # rail overhead. Open at the top so the finished hull can clear it. - NeonDraw.draw_glowing_path(self, _arc(BAY_CENTRE, RIB_RADIUS, 2.10, 4.18, 7), c, 1.0, 0.7) - NeonDraw.draw_glowing_path(self, _arc(BAY_CENTRE, RIB_RADIUS, -1.04, 1.04, 7), c, 1.0, 0.7) - NeonDraw.draw_glowing_line(self, Vector2(10, RAIL_Y), Vector2(82, RAIL_Y), c, 0.9, 0.55) + NeonDraw.draw_glowing_path(canvas, _arc(BAY_CENTRE, RIB_RADIUS, 2.10, 4.18, 7), c, 1.0, 0.7) + NeonDraw.draw_glowing_path(canvas, _arc(BAY_CENTRE, RIB_RADIUS, -1.04, 1.04, 7), c, 1.0, 0.7) + NeonDraw.draw_glowing_line(canvas, Vector2(10, RAIL_Y), Vector2(82, RAIL_Y), c, 0.9, 0.55) for side in [-1.0, 1.0]: - NeonDraw.draw_glowing_line(self, + NeonDraw.draw_glowing_line(canvas, BAY_CENTRE + Vector2(RIB_RADIUS * side, -6.0), Vector2(BAY_CENTRE.x + 44.0 * side, RAIL_Y), c, 0.7, 0.3) + + +func _build_crafter_pieces() -> void: + var c: Color = CRAFTER_COLOR + + # The core's inner cell: fades with the reactor rather than being redrawn + # at a new brightness each frame. + _core_cell = NeonPiece.create(self, CORE_CENTRE, Color(1.0, 0.9, 0.6)) + _core_cell.add_polygon(_ring(Vector2.ZERO, 12.0, 6, PI / 6.0)) + # Docking beacons, blinking out of step with each other. for i in range(2): var lamp: Vector2 = BAY_CENTRE + Vector2(-RIB_RADIUS + 2.0 + float(i) * (RIB_RADIUS * 2.0 - 4.0), 30.0) - var blink: float = 0.15 + 0.45 * (0.5 + 0.5 * sin(phase_time * 6.0 + float(i) * PI)) - NeonDraw.draw_glowing_polygon(self, _ring(lamp, 3.0, 4), Color(1.0, 0.95, 0.75), blink) + var beacon: NeonPiece = NeonPiece.create(self, lamp, Color(1.0, 0.95, 0.75)) + beacon.add_polygon(_ring(Vector2.ZERO, 3.0, 4)) + _beacons.append(beacon) + + # Clamp arms reaching in from each rib. They let go as the hull launches, + # which is a fade rather than a redraw. + _clamps = NeonPiece.create(self, Vector2.ZERO, c) + for side in [-1.0, 1.0]: + var outer: Vector2 = BAY_CENTRE + Vector2(RIB_RADIUS * side, 6.0) + var inner: Vector2 = BAY_CENTRE + Vector2(24.0 * side, 6.0) + _clamps.add_line(outer, inner, 0.35) + _clamps.add_line(inner + Vector2(0, -5), inner + Vector2(0, 5), 0.3) + + # Crane: the head rides the rail, so only its x moves. The hoist is drawn + # one unit long and stretched with scale.y to reach whatever seam is + # being welded - stretching a line needs no redraw, redrawing it would. + _crane = Node2D.new() + add_child(_crane) + var head: NeonPiece = NeonPiece.create(_crane, Vector2.ZERO, c) + head.add_polygon(PackedVector2Array([ + Vector2(-10, -7), Vector2(10, -7), Vector2(6, 6), Vector2(-6, 6), + ])) + head.add_line(Vector2(0, -7), Vector2(0, RAIL_Y - (RAIL_Y + 13.0)), 0.35, 0.8) + _hoist = NeonPiece.create(_crane, Vector2(0, 6), c, 0.5) + _hoist.add_line(Vector2.ZERO, Vector2(0, 1), 0.3) + + # Weld sparks: four spokes on a holder that spins and stretches. The + # jitter is genuinely procedural, but rotating and scaling a holder gives + # the same flicker without re-tessellating the spokes every frame. + _sparks = Node2D.new() + add_child(_sparks) + for i in range(4): + var a: float = float(i) * TAU / 4.0 + var spark: NeonPiece = NeonPiece.create(_sparks, Vector2.ZERO, Color(1.0, 0.95, 0.7)) + spark.add_line(Vector2.ZERO, Vector2(cos(a), sin(a)) * 11.0, 0.3) + + # The hull under construction. Each part is its own piece so attaching one + # is a `visible` flip, and the launch is the holder's position and alpha. + _hull_holder = Node2D.new() + add_child(_hull_holder) + for i in range(_hull_parts.size()): + var part: NeonPiece = NeonPiece.create(_hull_holder, Vector2.ZERO, HULL_COLOR) + part.add_polygon(_hull_parts[i]) + part.visible = false + _hull_pieces.append(part) + + # Engine wash under the hull, and the completion ring. The ring is drawn + # at a reference radius and scaled outwards. + _wash = NeonPiece.create(_hull_holder, Vector2.ZERO, Color(1.0, 0.85, 0.45), 0.0) + for dx in [-4.0, 4.0]: + _wash.add_line(Vector2(dx, 14), Vector2(dx * 1.6, 30.0), 0.45) + + _flash_ring = NeonPiece.create(self, BAY_CENTRE, HULL_COLOR, 0.0) + _flash_ring.add_polygon(_ring(Vector2.ZERO, FLASH_RING_RADIUS, 6, PI / 6.0)) + + +func _animate_crafter() -> void: + var t: float = anim_time / CYCLE + + _core_cell.modulate.a = 0.45 + 0.55 * (0.5 + 0.5 * sin(phase_time * 4.0)) + for i in range(_beacons.size()): + _beacons[i].modulate.a = 0.15 + 0.45 * (0.5 + 0.5 * sin(phase_time * 6.0 + float(i) * PI)) if t < ASSEMBLE_END: - _draw_assembly(t / ASSEMBLE_END, c) + _animate_assembly(t / ASSEMBLE_END) elif t < FLASH_END: - _draw_completion_flash((t - ASSEMBLE_END) / (FLASH_END - ASSEMBLE_END), c) + _animate_completion_flash((t - ASSEMBLE_END) / (FLASH_END - ASSEMBLE_END)) else: - _draw_launch((t - FLASH_END) / (1.0 - FLASH_END)) + _animate_launch((t - FLASH_END) / (1.0 - FLASH_END)) # How many pieces are on the hull at this point through the assembly phase. @@ -287,78 +455,73 @@ func attached_part_count(p: float) -> int: return n -func _draw_clamps(c: Color, pulse: float) -> void: - # Arms reaching in from each rib to hold the hull steady. They let go - # once it launches, so this is not drawn during the launch phase. - for side in [-1.0, 1.0]: - var outer: Vector2 = BAY_CENTRE + Vector2(RIB_RADIUS * side, 6.0) - var inner: Vector2 = BAY_CENTRE + Vector2(24.0 * side, 6.0) - NeonDraw.draw_glowing_line(self, outer, inner, c, pulse, 0.35) - NeonDraw.draw_glowing_line(self, inner + Vector2(0, -5), inner + Vector2(0, 5), c, pulse, 0.3) +func _place_hull(centre: Vector2, alpha: float) -> void: + # The parts are defined around their own origin, so the holder goes + # straight to the point they should be centred on. + _hull_holder.position = centre + _hull_holder.modulate.a = alpha -func _draw_assembly(p: float, c: Color) -> void: - _draw_clamps(c, 0.8) +func _animate_assembly(p: float) -> void: + _clamps.visible = true + _clamps.modulate.a = 0.8 + _flash_ring.modulate.a = 0.0 + _wash.modulate.a = 0.0 + _place_hull(BAY_CENTRE, 1.0) - # Everything welded on so far, newest part still glowing from the seam. var next_part: int = attached_part_count(p) - for i in range(next_part): - # Freshly attached parts flare, then settle to full brightness. - var age: float = p - float(SHIP_PARTS[i].at) - var pulse: float = 1.0 if age > 0.08 else lerpf(0.3, 1.0, age / 0.08) - _draw_hull_part(i, BAY_CENTRE, pulse) + for i in range(_hull_pieces.size()): + var attached: bool = i < next_part + _hull_pieces[i].visible = attached + if attached: + # Freshly attached parts flare, then settle to full brightness. + var age: float = p - float(SHIP_PARTS[i].at) + _hull_pieces[i].modulate.a = 1.0 if age > 0.08 else lerpf(0.3, 1.0, age / 0.08) # The crane rides the rail over whichever seam is being worked, and # retracts once there is nothing left to attach. var weld: Vector2 = BAY_CENTRE + (SHIP_PARTS[mini(next_part, SHIP_PARTS.size() - 1)].weld as Vector2) - if next_part >= _hull_parts.size(): + if next_part >= _hull_pieces.size(): weld.y = RAIL_Y + 16.0 - var head: Vector2 = Vector2(weld.x, RAIL_Y + 13.0) - NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ - head + Vector2(-10, -7), head + Vector2(10, -7), - head + Vector2(6, 6), head + Vector2(-6, 6), - ]), c, 1.0) - NeonDraw.draw_glowing_line(self, Vector2(head.x, RAIL_Y), head + Vector2(0, -7), c, 0.8, 0.35) - NeonDraw.draw_glowing_line(self, head + Vector2(0, 6), weld, c, 0.5, 0.3) + _crane.position = Vector2(weld.x, RAIL_Y + 13.0) + _hoist.scale.y = maxf(weld.y - (RAIL_Y + 19.0), 1.0) - if next_part < _hull_parts.size(): - # Weld sparks: short spokes whose angles jitter frame to frame. + var welding: bool = next_part < _hull_pieces.size() + _sparks.visible = welding + if welding: var flicker: float = 0.45 + 0.55 * absf(sin(phase_time * 34.0)) - for i in range(4): - var a: float = phase_time * 7.0 + float(i) * TAU / 4.0 - var length: float = 6.0 + 5.0 * flicker - NeonDraw.draw_glowing_line(self, weld, weld + Vector2(cos(a), sin(a)) * length, - Color(1.0, 0.95, 0.7), flicker, 0.3) + _sparks.position = weld + _sparks.rotation = phase_time * 7.0 + _sparks.scale = Vector2.ONE * (0.55 + 0.45 * flicker) + _sparks.modulate.a = flicker -func _draw_completion_flash(p: float, c: Color) -> void: +func _animate_completion_flash(p: float) -> void: # Whole hull, clamps springing open, and a ring pushing outwards to mark # it as finished. - _draw_clamps(c, 1.0 - p) - for i in range(_hull_parts.size()): - _draw_hull_part(i, BAY_CENTRE, 1.0) - NeonDraw.draw_glowing_polygon(self, _ring(BAY_CENTRE, lerpf(18.0, 56.0, p), 6, PI / 6.0), HULL_COLOR, 1.0 - p) + _clamps.modulate.a = 1.0 - p + _sparks.visible = false + _place_hull(BAY_CENTRE, 1.0) + for piece in _hull_pieces: + piece.visible = true + piece.modulate.a = 1.0 + # Retracted, so it does not hang over the finished hull. + _crane.position = Vector2(BAY_CENTRE.x, RAIL_Y + 13.0) + _hoist.scale.y = 1.0 + _flash_ring.modulate.a = 1.0 - p + _flash_ring.scale = Vector2.ONE * (lerpf(18.0, 56.0, p) / FLASH_RING_RADIUS) -func _draw_launch(p: float) -> void: +func _animate_launch(p: float) -> void: # Clears the cradle and fades out; the next cycle starts from bare ribs. + _clamps.modulate.a = 0.0 + _sparks.visible = false + _flash_ring.modulate.a = 0.0 var eased: float = p * p - var centre: Vector2 = BAY_CENTRE + Vector2(0.0, -124.0 * eased) var fade: float = clampf(1.0 - p * 1.15, 0.0, 1.0) - for i in range(_hull_parts.size()): - _draw_hull_part(i, centre, fade) + _place_hull(BAY_CENTRE + Vector2(0.0, -124.0 * eased), fade) + for piece in _hull_pieces: + piece.visible = true + piece.modulate.a = 1.0 # Engine wash, strongest just after it breaks free of the clamps. - var thrust: float = fade * (0.4 + 0.6 * sin(minf(p * 3.2, PI))) - for dx in [-4.0, 4.0]: - NeonDraw.draw_glowing_line(self, centre + Vector2(dx, 14), - centre + Vector2(dx * 1.6, 26.0 + 16.0 * thrust), - Color(1.0, 0.85, 0.45), thrust, 0.45) - - -func _draw_hull_part(index: int, centre: Vector2, pulse: float) -> void: - if pulse <= 0.01: - return - var moved: PackedVector2Array = PackedVector2Array() - for point in _hull_parts[index]: - moved.append(centre + point) - NeonDraw.draw_glowing_polygon(self, moved, HULL_COLOR, pulse) + _wash.modulate.a = 0.4 + 0.6 * sin(minf(p * 3.2, PI)) diff --git a/ExplosionParticle.gd b/ExplosionParticle.gd new file mode 100644 index 0000000..6a5591f --- /dev/null +++ b/ExplosionParticle.gd @@ -0,0 +1,22 @@ +class_name ExplosionParticle +extends Node2D + +# One debris chunk / energy streak inside an explosion. Draws its fixed +# local-space shape exactly once (like Ship.gd/Asteroid.gd draw once and +# animate via node properties), then ExplosionVFXBase drives it every frame +# purely through `position` and `rotation` - no further redraw needed. +# +# The shape itself is supplied by the owning VFX class via a Callable rather +# than being known here, since different explosion styles (glowing streak +# vs. rock-chunk polygon) draw completely differently; this class only owns +# the "draw once, then just move" mechanics. + +var draw_callable: Callable + + +func _ready() -> void: + queue_redraw() + + +func _draw() -> void: + draw_callable.call(self) diff --git a/ExplosionParticle.gd.uid b/ExplosionParticle.gd.uid new file mode 100644 index 0000000..ed360bf --- /dev/null +++ b/ExplosionParticle.gd.uid @@ -0,0 +1 @@ +uid://dm2m305hlrv6l diff --git a/ExplosionVFXBase.gd b/ExplosionVFXBase.gd index 1196585..457c72a 100644 --- a/ExplosionVFXBase.gd +++ b/ExplosionVFXBase.gd @@ -22,14 +22,25 @@ func _ready() -> void: for i in range(particle_count): var angle: float = randf() * TAU var speed: float = randf_range(min_speed, max_speed) - particles.append({ + var p: Dictionary = { "pos": Vector2.ZERO, "vel": Vector2(cos(angle), sin(angle)) * speed, "rot": randf() * TAU, "rot_speed": randf_range(-6.0, 6.0), "size": _random_particle_size(), - }) - queue_redraw() + } + # Each particle is its own child CanvasItem, drawn once at its own + # fixed local shape (subclass decides the shape via + # _configure_particle). From here on the "animation" is just this + # child's position/rotation, exactly like Ship.gd/Asteroid.gd - no + # redraw, no re-tessellating four polylines per particle per frame. + var visual: ExplosionParticle = ExplosionParticle.new() + _configure_particle(visual, p) + if _particle_spins(): + visual.rotation = p["rot"] + add_child(visual) + p["visual"] = visual + particles.append(p) func _process(delta: float) -> void: @@ -37,22 +48,35 @@ func _process(delta: float) -> void: if age >= lifetime: queue_free() return + # CanvasItem modulate is inherited down the tree, so setting it once + # here fades every particle's already-drawn shape for free - no need + # to recompute or re-draw a single one of them. + modulate.a = clamp(1.0 - (age / lifetime), 0.0, 1.0) for p in particles: p["pos"] += p["vel"] * delta p["vel"] *= 0.9 p["rot"] += p["rot_speed"] * delta - queue_redraw() - - -func _draw() -> void: - var alpha: float = clamp(1.0 - (age / lifetime), 0.0, 1.0) - for p in particles: - _draw_particle(p, alpha) + var visual: ExplosionParticle = p["visual"] + visual.position = p["pos"] + if _particle_spins(): + visual.rotation = p["rot"] func _random_particle_size() -> float: return randf_range(10.0, 20.0) -func _draw_particle(_p: Dictionary, _alpha: float) -> void: +# Called once per particle at spawn time. Subclasses set visual.draw_callable +# to a closure that draws their own fixed local shape (a streak, a rock +# chunk, ...) - it must not depend on `p` beyond values fixed at spawn (size, +# initial velocity direction), since it never runs again. +func _configure_particle(_visual: ExplosionParticle, _p: Dictionary) -> void: pass + + +# True for particle styles whose shape itself spins independently of its +# direction of travel (tumbling debris). Energy streaks are drawn already +# aligned to their (fixed) velocity direction, so giving them this unrelated +# per-particle spin as well would visibly rotate them off their travel line. +func _particle_spins() -> bool: + return false diff --git a/Fleet.gd b/Fleet.gd index e19a06d..8b4475b 100644 --- a/Fleet.gd +++ b/Fleet.gd @@ -39,7 +39,7 @@ func spawn_ships(count: int, center: Vector2 = Vector2.ZERO) -> void: ships.clear() formation_offsets.clear() - var n: int = clamp(count, 1, GameState.formation.size()) + var n: int = clamp(count, 1, GameState.max_fleet_size()) for i in range(n): var offset: Vector2 = GameState.formation[i] var ship: Area2D = ship_scene.instantiate() @@ -58,7 +58,7 @@ func spawn_ships(count: int, center: Vector2 = Vector2.ZERO) -> void: func add_ship() -> bool: # Adds one vessel in the next free formation slot (bought from a ship # crafter). Returns false when the wedge is already full. - if ships.size() >= GameState.formation.size(): + if ships.size() >= GameState.max_fleet_size(): return false var offset: Vector2 = GameState.formation[ships.size()] var ship: Area2D = ship_scene.instantiate() @@ -82,7 +82,7 @@ func apply_formation() -> void: # Without this, editing the layout at a command centre (or pivoting it) # only affected ships built later, which read as the edit doing nothing. for i in range(ships.size()): - if i >= GameState.formation.size(): + if i >= GameState.max_fleet_size(): break formation_offsets[i] = GameState.formation[i] _assign_slot_targets() @@ -138,12 +138,32 @@ func stop() -> void: # since that lands inside arrive_radius immediately). fleet_target = get_formation_origin() for ship in ships: - var speed: float = ship.velocity.length() + # A ship's own `velocity` is NOT how fast it is really travelling + # while the fleet cruises. Ship snaps to its slot and reports zero + # the moment it is within arrive_radius of it, even though the + # anchor is dragging that slot forward at cruise_speed underneath + # it. Its true motion is its catch-up velocity PLUS the fleet's, + # which it already carries as `fleet_velocity` (zero for a + # point-to-point move_to, where `velocity` alone is the whole + # story). + # + # Braking from `velocity` alone therefore stopped the fleet dead + # on any frame where the ships happened to be seated - and how + # often that happens depends on the frame rate, because the anchor + # advances cruise_speed * delta per frame. At ~25fps it steps far + # enough that ships always lag and always report motion, which is + # the only reason releasing the stick looked right. + var motion: Vector2 = ship.velocity + ship.fleet_velocity + # Context that is no longer true: the fleet is not cruising any + # more, so leaving it set would have Ship face a heading it is not + # travelling on. + ship.fleet_velocity = Vector2.ZERO + var speed: float = motion.length() if speed <= 0.001: ship.set_move_target(ship.position) continue var braking_distance: float = (speed * speed) / (2.0 * ship.acceleration) - ship.set_move_target(ship.position + ship.velocity.normalized() * braking_distance) + ship.set_move_target(ship.position + motion.normalized() * braking_distance) func get_move_targets() -> Array: diff --git a/FormationEditor.gd b/FormationEditor.gd index a4da8bc..9ecab2a 100644 --- a/FormationEditor.gd +++ b/FormationEditor.gd @@ -67,7 +67,7 @@ func handle_touch(event: InputEventScreenTouch) -> bool: return true if dragging_index != -1: return false - for i in range(GameState.formation.size()): + for i in range(GameState.max_fleet_size()): if local.distance_to(_slot_screen_pos(i)) <= marker_radius * 1.4: dragging_index = i drag_touch_index = event.index @@ -126,7 +126,7 @@ func _draw() -> void: NeonDraw.draw_glowing_line(self, centre + Vector2(-tick, 0), centre + Vector2(tick, 0), Color(0.4, 0.45, 0.5), 0.5, 0.3) NeonDraw.draw_glowing_line(self, centre + Vector2(0, -tick), centre + Vector2(0, tick), Color(0.4, 0.45, 0.5), 0.5, 0.3) - for i in range(GameState.formation.size()): + for i in range(GameState.max_fleet_size()): var p: Vector2 = _slot_screen_pos(i) var held: bool = i == dragging_index # Ship glyph, so the layout reads the way it will look in flight. diff --git a/GameState.gd b/GameState.gd index 4bc80dd..dfd45e2 100644 --- a/GameState.gd +++ b/GameState.gd @@ -48,6 +48,138 @@ const EXTRACTOR_COST: int = 25 var buildings: Array = [] +# --- Skill tree ------------------------------------------------------------ + +# Researched at the mothership. Two things hang off it: which structures the +# player is allowed to build at all, and permanent upgrades that have no +# building of their own. The mothership itself is deliberately NOT in the +# tree - it is the thing you research AT, so gating it behind research would +# be a deadlock. +# +# `x` is a horizontal position on a 0..1 grid; the vertical position is NOT +# stored, it is derived from how many prerequisites deep the node is, so the +# tree always reads root-at-the-bottom and cannot drift out of step with the +# dependencies when nodes are added. `requires` is a single prerequisite id +# ("" for a root). +const SKILL_TREE: Array = [ + { + "id": "shipyards", "name": "Shipyards", "cost": 15, "requires": "", + "x": 0.5, "unlocks": BUILDING_SHIP_CRAFTER, + "desc": "Build ship crafters, and buy extra vessels at them.", + }, + { + "id": "logistics", "name": "Logistics", "cost": 25, "requires": "shipyards", + "x": 0.18, "unlocks": BUILDING_EXTRACTOR, + "desc": "Build extractors on planets. They mine ore whether or not you are nearby.", + }, + { + "id": "coordination", "name": "Coordination", "cost": 20, "requires": "shipyards", + "x": 0.82, "unlocks": BUILDING_COMMAND_CENTER, + "desc": "Build command centers, where the fleet formation is arranged.", + }, + { + "id": "wing", "name": "Wing Doctrine", "cost": 30, "requires": "coordination", + "x": 0.82, "fleet_bonus": 1, + "desc": "Permanent: +1 vessel in the fleet.", + }, + { + "id": "squadron", "name": "Squadron Doctrine", "cost": 70, "requires": "wing", + "x": 0.65, "fleet_bonus": 1, + "desc": "Permanent: +1 vessel in the fleet.", + }, + { + "id": "armada", "name": "Armada Doctrine", "cost": 150, "requires": "squadron", + "x": 0.3, "fleet_bonus": 1, + "desc": "Permanent: +1 vessel in the fleet.", + }, +] + +const BASE_FLEET_SIZE: int = 5 + +var unlocked: Array = [] + + +func skill_node(id: String) -> Dictionary: + for node in SKILL_TREE: + if node.id == id: + return node + return {} + + +func skill_depth(id: String) -> int: + # How many prerequisites deep this node sits. Drives its height in the + # tree view: roots at the bottom, everything that depends on something + # above it. + var depth: int = 0 + var node: Dictionary = skill_node(id) + while not node.is_empty() and node.requires != "": + depth += 1 + node = skill_node(node.requires) + if depth > SKILL_TREE.size(): + break # a cycle in the data would otherwise hang here + return depth + + +func max_skill_depth() -> int: + var deepest: int = 0 + for node in SKILL_TREE: + deepest = maxi(deepest, skill_depth(node.id)) + return deepest + + +func is_unlocked(id: String) -> bool: + return id in unlocked + + +func prerequisite_met(id: String) -> bool: + var node: Dictionary = skill_node(id) + if node.is_empty(): + return false + return node.requires == "" or is_unlocked(node.requires) + + +func can_unlock(id: String) -> bool: + return not is_unlocked(id) and prerequisite_met(id) and can_afford(int(skill_node(id).cost)) + + +func unlock(id: String) -> bool: + if not can_unlock(id): + return false + if not spend(int(skill_node(id).cost)): + return false + unlocked.append(id) + return true + + +func is_building_unlocked(type: String) -> bool: + # The mothership is always buildable: it is where research happens, so + # locking it would leave a player with no mothership unable to ever get + # one. + if type == BUILDING_MOTHERSHIP: + return true + for node in SKILL_TREE: + if node.get("unlocks", "") == type: + return is_unlocked(node.id) + return true + + +func research_for_building(type: String) -> String: + for node in SKILL_TREE: + if node.get("unlocks", "") == type: + return node.name + return "" + + +func max_fleet_size() -> int: + var n: int = BASE_FLEET_SIZE + for id in unlocked: + n += int(skill_node(id).get("fleet_bonus", 0)) + return mini(n, DEFAULT_FORMATION.size()) + + +func reset_progression() -> void: + unlocked.clear() + func add_building(type: String, at_position: Vector2) -> void: buildings.append({"type": type, "position": at_position}) @@ -84,8 +216,13 @@ func get_mothership_position() -> Variant: # centre. Lives here (not as a const on Fleet) so a layout the player has # arranged survives the trip to a planet's surface and back, and so a # rebuilt fleet keeps their arrangement. +# Slots beyond BASE_FLEET_SIZE only become usable once the matching fleet +# capacity upgrade is researched, but they are defined up front so the wedge +# keeps a deliberate shape as it grows rather than having positions invented +# at unlock time. const DEFAULT_FORMATION: Array = [ Vector2(0, -48), Vector2(-30, -8), Vector2(30, -8), Vector2(-60, 32), Vector2(60, 32), + Vector2(-90, 72), Vector2(90, 72), Vector2(0, 56), ] var formation: Array = DEFAULT_FORMATION.duplicate() diff --git a/IconButton.gd b/IconButton.gd index dc91c31..93cc525 100644 --- a/IconButton.gd +++ b/IconButton.gd @@ -227,18 +227,31 @@ func _draw_crafter_icon(center: Vector2, pulse: float, s: float) -> void: func _draw_command_icon(center: Vector2, pulse: float, s: float) -> void: - # Diamond with three formation pips - "arrangement". - var d: float = 14.0 * s + # Hex plotting rim with spokes to a diamond hub, echoing the placed + # structure's silhouette: half the ports carry a formation token. + var hex_r: float = 11.0 * s + var hub_r: float = 4.0 * s + var hex: PackedVector2Array = PackedVector2Array() + for i in range(6): + var a: float = (float(i) / 6.0) * TAU + PI / 6.0 + hex.append(center + Vector2(cos(a), sin(a)) * hex_r) + NeonDraw.draw_glowing_polygon(self, hex, _active_color(), pulse, core_color) + + for i in range(6): + var a: float = (float(i) / 6.0) * TAU + PI / 6.0 + var dir: Vector2 = Vector2(cos(a), sin(a)) + NeonDraw.draw_glowing_line(self, center + dir * hub_r, center + dir * hex_r, _active_color(), pulse, 0.32 * s, core_color) + if i % 2 == 0: + var tip: Vector2 = center + dir * hex_r + var t: float = 2.6 * s + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + tip + Vector2(0, -t), tip + Vector2(t, 0), tip + Vector2(0, t), tip + Vector2(-t, 0), + ]), _active_color(), pulse, core_color) + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ - center + Vector2(0, -d), center + Vector2(d, 0), - center + Vector2(0, d), center + Vector2(-d, 0), + center + Vector2(0, -hub_r), center + Vector2(hub_r, 0), + center + Vector2(0, hub_r), center + Vector2(-hub_r, 0), ]), _active_color(), pulse, core_color) - for p in [Vector2(0, -5), Vector2(-5, 4), Vector2(5, 4)]: - NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ - center + (p + Vector2(0, -3)) * s, - center + (p + Vector2(-2.6, 2.4)) * s, - center + (p + Vector2(2.6, 2.4)) * s, - ]), _active_color(), pulse, core_color) func _draw_extractor_icon(center: Vector2, pulse: float, s: float) -> void: diff --git a/LightBurstVFX.gd b/LightBurstVFX.gd index 63b4f5e..5a87a8d 100644 --- a/LightBurstVFX.gd +++ b/LightBurstVFX.gd @@ -7,9 +7,13 @@ extends ExplosionVFXBase var color: Color = Color(1, 1, 1) -func _draw_particle(p: Dictionary, alpha: float) -> void: +func _configure_particle(visual: ExplosionParticle, p: Dictionary) -> void: + # Direction only ever scales (vel *= 0.9 each frame in the base class), + # it never turns - so the streak's local orientation is safe to fix + # once here rather than recomputed from vel every frame. var vel: Vector2 = p["vel"] var dir: Vector2 = vel.normalized() if vel.length() > 0.01 else Vector2.RIGHT var half: Vector2 = dir * p["size"] * 0.5 - var pos: Vector2 = p["pos"] - NeonDraw.draw_glowing_line(self, pos - half, pos + half, color, alpha, 0.6) + var line_color: Color = color + visual.draw_callable = func(item: CanvasItem) -> void: + NeonDraw.draw_glowing_line(item, -half, half, line_color, 1.0, 0.6) diff --git a/Main.tscn b/Main.tscn index d7e5378..7cf3a8e 100644 --- a/Main.tscn +++ b/Main.tscn @@ -20,6 +20,7 @@ [ext_resource type="Script" path="res://HaulerField.gd" id="18"] [ext_resource type="Script" path="res://NeonNumber.gd" id="19"] [ext_resource type="Script" path="res://NeonPanel.gd" id="20"] +[ext_resource type="Script" path="res://SkillTreeView.gd" id="21"] [sub_resource type="ShaderMaterial" id="1"] shader = ExtResource("5") @@ -83,10 +84,10 @@ layer = 1 mouse_filter = 2 offset_left = 28 offset_top = 24 -offset_right = 520 -offset_bottom = 140 +offset_right = 320 +offset_bottom = 92 script = ExtResource("16") -digit_height = 100.0 +digit_height = 60.0 [node name="PlanetNameLabel" type="Label" parent="UI"] visible = false @@ -129,19 +130,6 @@ offset_bottom = -572 script = ExtResource("10") icon = 2 -[node name="BuildActionLabel" type="Label" parent="UI"] -visible = false -mouse_filter = 2 -anchor_top = 1.0 -anchor_bottom = 1.0 -anchor_right = 1.0 -offset_left = 24 -offset_right = -24 -offset_top = -566 -offset_bottom = -440 -text = "" -horizontal_alignment = 2 - [node name="MapButton" type="Control" parent="UI"] anchor_left = 1.0 anchor_right = 1.0 @@ -201,19 +189,6 @@ offset_bottom = -24 script = ExtResource("10") icon = 6 -[node name="NavLabel" type="Label" parent="UI"] -mouse_filter = 2 -anchor_left = 1.0 -anchor_right = 1.0 -anchor_top = 1.0 -anchor_bottom = 1.0 -offset_left = -344 -offset_right = -24 -offset_top = -322 -offset_bottom = -196 -text = "Pivot" -horizontal_alignment = 1 - [node name="WorldMap" type="Control" parent="UI"] visible = false anchor_right = 1.0 @@ -287,8 +262,8 @@ anchor_top = 0.5 anchor_bottom = 0.5 offset_left = -214 offset_right = -66 -offset_top = -224 -offset_bottom = -76 +offset_top = -254 +offset_bottom = -106 script = ExtResource("10") icon = 8 glow_color = Color(0.45, 0.9, 0.55, 1) @@ -301,9 +276,9 @@ anchor_top = 0.5 anchor_bottom = 0.5 offset_left = -290 offset_right = 10 -offset_top = -70 -offset_bottom = 60 -theme_override_font_sizes/font_size = 44 +offset_top = -96 +offset_bottom = 4 +theme_override_font_sizes/font_size = 36 autowrap_mode = 2 text = "Mothership" horizontal_alignment = 1 @@ -314,13 +289,13 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -220 -offset_right = -60 -offset_top = 70 -offset_bottom = 150 +offset_left = -204 +offset_right = -64 +offset_top = 14 +offset_bottom = 78 script = ExtResource("19") amount = 20 -digit_height = 66.0 +digit_height = 52.0 [node name="CrafterButton" type="Control" parent="UI/BuildMenu"] anchor_left = 0.5 @@ -329,8 +304,8 @@ anchor_top = 0.5 anchor_bottom = 0.5 offset_left = 66 offset_right = 214 -offset_top = -224 -offset_bottom = -76 +offset_top = -254 +offset_bottom = -106 script = ExtResource("10") icon = 9 glow_color = Color(1, 0.72, 0.25, 1) @@ -343,9 +318,9 @@ anchor_top = 0.5 anchor_bottom = 0.5 offset_left = -10 offset_right = 290 -offset_top = -70 -offset_bottom = 60 -theme_override_font_sizes/font_size = 44 +offset_top = -96 +offset_bottom = 4 +theme_override_font_sizes/font_size = 36 autowrap_mode = 2 text = "Ship Crafter" horizontal_alignment = 1 @@ -356,13 +331,13 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = 60 -offset_right = 220 -offset_top = 70 -offset_bottom = 150 +offset_left = 76 +offset_right = 216 +offset_top = 14 +offset_bottom = 78 script = ExtResource("19") amount = 12 -digit_height = 66.0 +digit_height = 52.0 [node name="CommandButton" type="Control" parent="UI/BuildMenu"] anchor_left = 0.5 @@ -371,8 +346,8 @@ anchor_top = 0.5 anchor_bottom = 0.5 offset_left = -214 offset_right = -66 -offset_top = 175 -offset_bottom = 323 +offset_top = 118 +offset_bottom = 266 script = ExtResource("10") icon = 10 glow_color = Color(0.55, 0.6, 1, 1) @@ -385,9 +360,9 @@ anchor_top = 0.5 anchor_bottom = 0.5 offset_left = -290 offset_right = 10 -offset_top = 333 -offset_bottom = 463 -theme_override_font_sizes/font_size = 44 +offset_top = 276 +offset_bottom = 376 +theme_override_font_sizes/font_size = 36 autowrap_mode = 2 text = "Command Center" horizontal_alignment = 1 @@ -398,13 +373,13 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -220 -offset_right = -60 -offset_top = 473 -offset_bottom = 553 +offset_left = -204 +offset_right = -64 +offset_top = 386 +offset_bottom = 450 script = ExtResource("19") amount = 15 -digit_height = 66.0 +digit_height = 52.0 [node name="ExtractorButton" type="Control" parent="UI/BuildMenu"] anchor_left = 0.5 @@ -413,8 +388,8 @@ anchor_top = 0.5 anchor_bottom = 0.5 offset_left = 66 offset_right = 214 -offset_top = 175 -offset_bottom = 323 +offset_top = 118 +offset_bottom = 266 script = ExtResource("10") icon = 11 glow_color = Color(0, 0.62, 0.7, 1) @@ -427,9 +402,9 @@ anchor_top = 0.5 anchor_bottom = 0.5 offset_left = -10 offset_right = 290 -offset_top = 333 -offset_bottom = 463 -theme_override_font_sizes/font_size = 44 +offset_top = 276 +offset_bottom = 376 +theme_override_font_sizes/font_size = 36 autowrap_mode = 2 text = "Extractor" horizontal_alignment = 1 @@ -440,13 +415,13 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = 60 -offset_right = 220 -offset_top = 473 -offset_bottom = 553 +offset_left = 76 +offset_right = 216 +offset_top = 386 +offset_bottom = 450 script = ExtResource("19") amount = 25 -digit_height = 66.0 +digit_height = 52.0 [node name="CloseButton" type="Control" parent="UI/BuildMenu"] anchor_left = 1.0 @@ -696,3 +671,93 @@ anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 color = Color(0, 0, 0, 0) + +[node name="SkillMenu" type="Control" parent="UI"] +visible = false +mouse_filter = 2 +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Backdrop" type="ColorRect" parent="UI/SkillMenu"] +mouse_filter = 2 +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.03, 0.03, 0.08, 0.94) + +[node name="TitleLabel" type="Label" parent="UI/SkillMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 24.0 +offset_top = 70.0 +offset_right = -24.0 +offset_bottom = 196.0 +text = "Research" +horizontal_alignment = 1 + +[node name="Tree" type="Control" parent="UI/SkillMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 24.0 +offset_top = 210.0 +offset_right = -24.0 +offset_bottom = 760.0 +script = ExtResource("21") + +[node name="DetailLabel" type="Label" parent="UI/SkillMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 32.0 +offset_top = 780.0 +offset_right = -32.0 +offset_bottom = 926.0 +theme_override_font_sizes/font_size = 40 +autowrap_mode = 2 +text = "" +horizontal_alignment = 1 +vertical_alignment = 1 + +[node name="CostNumber" type="Control" parent="UI/SkillMenu"] +mouse_filter = 2 +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -64.0 +offset_top = 936.0 +offset_right = 76.0 +offset_bottom = 1000.0 +script = ExtResource("19") +amount = 0 +digit_height = 52.0 + +[node name="StatusLabel" type="Label" parent="UI/SkillMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 32.0 +offset_top = 1006.0 +offset_right = -32.0 +offset_bottom = 1070.0 +theme_override_font_sizes/font_size = 36 +autowrap_mode = 2 +text = "" +horizontal_alignment = 1 + +[node name="UnlockButton" type="Control" parent="UI/SkillMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -74.0 +offset_top = 1090.0 +offset_right = 74.0 +offset_bottom = 1238.0 +script = ExtResource("10") +icon = 5 +glow_color = Color(0.45, 0.9, 0.55, 1) + +[node name="CloseButton" type="Control" parent="UI/SkillMenu"] +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -172.0 +offset_top = 24.0 +offset_right = -24.0 +offset_bottom = 172.0 +script = ExtResource("10") +icon = 1 +glow_color = Color(0.55, 0.6, 1, 1) diff --git a/NeonDraw.gd b/NeonDraw.gd index b18f19e..8a0e22a 100644 --- a/NeonDraw.gd +++ b/NeonDraw.gd @@ -16,10 +16,10 @@ extends RefCounted const DEFAULT_CORE: Color = Color(1.0, 1.0, 1.0) -static func draw_glowing_polygon(item: CanvasItem, points: PackedVector2Array, glow_color: Color, pulse: float = 1.0, core_color: Color = DEFAULT_CORE) -> void: +static func draw_glowing_polygon(item: CanvasItem, points: PackedVector2Array, glow_color: Color, pulse: float = 1.0, core_color: Color = DEFAULT_CORE, width_scale: float = 1.0) -> void: var closed: PackedVector2Array = points.duplicate() closed.append(points[0]) - _draw_layers(item, closed, glow_color, pulse, 1.0, core_color) + _draw_layers(item, closed, glow_color, pulse, width_scale, core_color) static func draw_glowing_line(item: CanvasItem, from: Vector2, to: Vector2, glow_color: Color, pulse: float = 1.0, width_scale: float = 1.0, core_color: Color = DEFAULT_CORE) -> void: diff --git a/NeonNumber.gd b/NeonNumber.gd index 7333b36..162261a 100644 --- a/NeonNumber.gd +++ b/NeonNumber.gd @@ -16,6 +16,13 @@ extends Control @export var digit_height: float = 30.0 @export var digit_gap: float = 9.0 +# Segment weight as a fraction of digit height, so the strokes keep the same +# visual heft at any size. This used to be a fixed width regardless of the +# digit height, which left large readouts looking hairline next to the solid +# ore hexagon beside them. +const STROKE_RATIO: float = 0.014 +const HEX_STROKE_RATIO: float = 0.030 + # Which of the seven segments each digit lights, in the usual order: # top, top-right, bottom-right, bottom, bottom-left, top-left, middle. const DIGIT_SEGMENTS: Array = [ @@ -60,8 +67,8 @@ func _draw_hexagon(centre: Vector2, radius: float) -> void: for i in range(6): var a: float = (float(i) / 6.0) * TAU - PI / 2.0 pts.append(centre + Vector2(cos(a), sin(a)) * radius) - NeonDraw.draw_glowing_polygon(self, pts, glow_color, 1.0, core_color) - NeonDraw.draw_glowing_line(self, pts[5], pts[2], glow_color, 0.6, 0.26, core_color) + NeonDraw.draw_glowing_polygon(self, pts, glow_color, 1.0, core_color, radius * HEX_STROKE_RATIO) + NeonDraw.draw_glowing_line(self, pts[5], pts[2], glow_color, 0.6, radius * HEX_STROKE_RATIO * 0.8, core_color) func _draw_digit(value: int, at: Vector2, w: float, h: float) -> void: @@ -78,7 +85,8 @@ func _draw_digit(value: int, at: Vector2, w: float, h: float) -> void: [Vector2(0.0, 0.0), Vector2(0.0, mid)], # top-left [Vector2(0.0, mid), Vector2(w, mid)], # middle ] + var stroke: float = h * STROKE_RATIO for i in range(seg.size()): if not on[i]: continue - NeonDraw.draw_glowing_line(self, at + seg[i][0], at + seg[i][1], glow_color, 1.0, 0.3, core_color) + NeonDraw.draw_glowing_line(self, at + seg[i][0], at + seg[i][1], glow_color, 1.0, stroke, core_color) diff --git a/NeonPiece.gd b/NeonPiece.gd new file mode 100644 index 0000000..a4fc9b7 --- /dev/null +++ b/NeonPiece.gd @@ -0,0 +1,47 @@ +class_name NeonPiece +extends Node2D + +# One movable piece of neon art, rasterised ONCE and thereafter animated +# only through node properties. +# +# Godot re-runs _draw() solely when a redraw is requested; changing +# position/rotation/scale/modulate/visible reuses the cached geometry +# instead. Since every NeonDraw call expands into four antialiased +# polylines, redrawing a shape each frame just to move or fade it is the +# expensive way to do the cheapest kind of animation. Ship.gd and +# Asteroid.gd already work this way - this is the same idea packaged so a +# building can be assembled out of independently movable parts. +# +# Geometry is defined in local space around the piece's own origin, so the +# owner can move, spin, scale and fade it without touching the shape. + +var polygons: Array = [] +var lines: Array = [] +var glow_color: Color = Color.WHITE +var core_color: Color = NeonDraw.DEFAULT_CORE +var pulse: float = 1.0 + + +static func create(parent: Node, at: Vector2, colour: Color, alpha: float = 1.0) -> NeonPiece: + var piece: NeonPiece = NeonPiece.new() + piece.position = at + piece.glow_color = colour + piece.modulate.a = alpha + parent.add_child(piece) + return piece + + +func add_polygon(points: PackedVector2Array, piece_pulse: float = 1.0) -> void: + polygons.append({"points": points, "pulse": piece_pulse}) + + +func add_line(from: Vector2, to: Vector2, width: float, piece_pulse: float = 1.0) -> void: + lines.append({"from": from, "to": to, "width": width, "pulse": piece_pulse}) + + +func _draw() -> void: + for poly in polygons: + NeonDraw.draw_glowing_polygon(self, poly.points, glow_color, poly.pulse * pulse, core_color) + for line in lines: + NeonDraw.draw_glowing_line(self, line.from, line.to, glow_color, + line.pulse * pulse, line.width, core_color) diff --git a/NeonPiece.gd.uid b/NeonPiece.gd.uid new file mode 100644 index 0000000..2b99e96 --- /dev/null +++ b/NeonPiece.gd.uid @@ -0,0 +1 @@ +uid://b8r2lwqg3syw2 diff --git a/PlanetSurface.tscn b/PlanetSurface.tscn index b2a7a16..aa4a117 100644 --- a/PlanetSurface.tscn +++ b/PlanetSurface.tscn @@ -58,10 +58,10 @@ layer = 1 mouse_filter = 2 offset_left = 28 offset_top = 24 -offset_right = 520 -offset_bottom = 140 +offset_right = 320 +offset_bottom = 92 script = ExtResource("9") -digit_height = 100.0 +digit_height = 60.0 glow_color = Color(0, 0.42, 0.48, 1) core_color = Color(0, 0.08, 0.1, 1) @@ -143,22 +143,6 @@ icon = 6 glow_color = Color(0.06, 0.32, 0.5, 1) core_color = Color(0.02, 0.09, 0.15, 1) -[node name="NavLabel" type="Label" parent="UI"] -mouse_filter = 2 -anchor_left = 1.0 -anchor_right = 1.0 -anchor_top = 1.0 -anchor_bottom = 1.0 -offset_left = -344 -offset_right = -24 -offset_top = -322 -offset_bottom = -196 -text = "Pivot" -theme_override_colors/font_color = Color(0.08, 0.08, 0.11, 1) -theme_override_colors/font_outline_color = Color(0.95, 0.95, 0.96, 0.85) -theme_override_constants/outline_size = 5 -horizontal_alignment = 1 - [node name="FadeOverlay" type="CanvasLayer" parent="."] layer = 10 diff --git a/Projectile.gd b/Projectile.gd index ed05f23..ba7d629 100644 --- a/Projectile.gd +++ b/Projectile.gd @@ -12,6 +12,13 @@ var age: float = 0.0 func _ready() -> void: add_to_group("player_projectiles") area_entered.connect(_on_area_entered) + # Draw once. The earlier invisible-bolt bug came from _draw() deriving + # the bolt's look from `age` - drawing only at age==0 froze it at + # whatever the age==0 case rendered as. _draw() below is now a fixed + # shape with no age dependency, so one draw is safe and matches the + # Ship.gd/Asteroid.gd pattern: position/rotation are transform + # properties Godot applies at render time without re-tessellating. + queue_redraw() func launch(direction: Vector2) -> void: @@ -22,14 +29,6 @@ func launch(direction: Vector2) -> void: func _physics_process(delta: float) -> void: position += velocity * delta age += delta - # Redrawn every frame. In principle a fixed shape only needs drawing - # once (see Asteroid.gd, where dropping this was a large win), but - # doing that here left bolts invisible on screen - verified by - # rendering a frame mid-combat and finding zero pixels of the bolt - # colour. Rather than ship invisible shots a second time, this keeps - # the redraw: projectiles are few and live under two seconds, so the - # cost is negligible. - queue_redraw() if age >= lifetime: queue_free() diff --git a/SkillTreeView.gd b/SkillTreeView.gd new file mode 100644 index 0000000..84def69 --- /dev/null +++ b/SkillTreeView.gd @@ -0,0 +1,121 @@ +extends Control + +# The mothership's research tree, drawn in the game's own neon style rather +# than with Godot UI widgets, for the same reason everything else here is +# hand-drawn: a themed Button would look nothing like the rest of the game. +# +# Nodes are laid out from the normalised `pos` in GameState.SKILL_TREE, so +# the tree's shape is data next to the upgrades themselves instead of being +# duplicated here. +# +# Input arrives through BuildController._unhandled_input, NOT _gui_input - +# a Control that consumes touches through the GUI system swallows them +# before the menu can act on them, which is the bug that made the formation +# editor's drag silently do nothing. Everything in this menu must stay +# mouse_filter = IGNORE. + +signal node_picked(id: String) + +const LOCKED_COLOR: Color = Color(0.36, 0.39, 0.5) +const READY_COLOR: Color = Color(0.55, 0.6, 1.0) +const DONE_COLOR: Color = Color(0.45, 0.9, 0.55) +const SELECTED_RING: Color = Color(1.0, 0.85, 0.4) +# A locked node needs a dark core as well as a dim glow. NeonDraw tops every +# shape with a near-white core stroke, which at this size dominates the glow +# entirely - so a "dim" colour alone still came out the brightest thing on +# screen, making unreachable nodes look like the highlighted ones. +const LOCKED_CORE: Color = Color(0.17, 0.19, 0.26) +const LOCKED_PULSE: float = 0.5 + +@export var node_radius: float = 46.0 + +var selected_id: String = "" + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + + +func node_screen_pos(node: Dictionary) -> Vector2: + # Height comes from dependency depth, not from the data: the root sits at + # the bottom and each further prerequisite climbs a row, so the tree grows + # upwards as it is researched and stays correct when nodes are added. + var deepest: int = maxi(GameState.max_skill_depth(), 1) + var climb: float = float(GameState.skill_depth(node.id)) / float(deepest) + # Inset so a node on row 0 or column 0 is not clipped in half. + var margin: float = node_radius * 1.4 + return Vector2( + lerpf(margin, size.x - margin, float(node.x)), + lerpf(size.y - margin, margin, climb) + ) + + +func _state_color(node: Dictionary) -> Color: + if GameState.is_unlocked(node.id): + return DONE_COLOR + if GameState.prerequisite_met(node.id): + return READY_COLOR + return LOCKED_COLOR + + +# Returns true when the press landed on a node, so the caller knows the +# touch was consumed. +func handle_touch(event: InputEventScreenTouch) -> bool: + if not event.pressed or not visible: + return false + var local: Vector2 = event.position - global_position + for node in GameState.SKILL_TREE: + if local.distance_to(node_screen_pos(node)) <= node_radius * 1.25: + selected_id = node.id + queue_redraw() + node_picked.emit(node.id) + return true + return false + + +func _draw() -> void: + # Links first, so nodes sit on top of them. + for node in GameState.SKILL_TREE: + if node.requires == "": + continue + var parent: Dictionary = GameState.skill_node(node.requires) + if parent.is_empty(): + continue + var from: Vector2 = node_screen_pos(parent) + var to: Vector2 = node_screen_pos(node) + # A link reads as "live" only once the step it leads to is actually + # reachable, so the eye follows the researched path outwards. + var lit: bool = GameState.is_unlocked(node.id) + var reachable: bool = GameState.prerequisite_met(node.id) + var link_color: Color = DONE_COLOR if lit else (READY_COLOR if reachable else LOCKED_COLOR) + var trim: Vector2 = (to - from).normalized() * node_radius + NeonDraw.draw_glowing_line(self, from + trim, to - trim, link_color, + 1.0 if reachable else LOCKED_PULSE, 0.4, + NeonDraw.DEFAULT_CORE if reachable else LOCKED_CORE) + + for node in GameState.SKILL_TREE: + var centre: Vector2 = node_screen_pos(node) + var colour: Color = _state_color(node) + var reach: bool = GameState.prerequisite_met(node.id) + var pulse: float = 1.0 if reach else LOCKED_PULSE + var core_stroke: Color = NeonDraw.DEFAULT_CORE if reach else LOCKED_CORE + var hexagon: PackedVector2Array = PackedVector2Array() + for i in range(6): + var a: float = (float(i) / 6.0) * TAU + PI / 6.0 + hexagon.append(centre + Vector2(cos(a), sin(a)) * node_radius) + NeonDraw.draw_glowing_polygon(self, hexagon, colour, pulse, core_stroke) + + # Researched nodes get a filled-in core; the rest stay hollow. + if GameState.is_unlocked(node.id): + var core: PackedVector2Array = PackedVector2Array() + for i in range(6): + var a: float = (float(i) / 6.0) * TAU + PI / 6.0 + core.append(centre + Vector2(cos(a), sin(a)) * node_radius * 0.42) + NeonDraw.draw_glowing_polygon(self, core, colour, 1.0) + + if node.id == selected_id: + var ring: PackedVector2Array = PackedVector2Array() + for i in range(6): + var a: float = (float(i) / 6.0) * TAU + ring.append(centre + Vector2(cos(a), sin(a)) * node_radius * 1.32) + NeonDraw.draw_glowing_polygon(self, ring, SELECTED_RING, 0.9) diff --git a/SkillTreeView.gd.uid b/SkillTreeView.gd.uid new file mode 100644 index 0000000..b4906ff --- /dev/null +++ b/SkillTreeView.gd.uid @@ -0,0 +1 @@ +uid://ob1hc06hf21t diff --git a/fonts/CarroisGothicSC-Regular.ttf b/fonts/CarroisGothicSC-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..c3634546cbfbddfacc1a87500df4ec0618144b0c GIT binary patch literal 40356 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 a@vZAFD_ZJf_&j?DyWpO9OZ+$f>i>V#*qOlq literal 0 HcmV?d00001 diff --git a/fonts/CarroisGothicSC-Regular.ttf.import b/fonts/CarroisGothicSC-Regular.ttf.import new file mode 100644 index 0000000..82d1e1f --- /dev/null +++ b/fonts/CarroisGothicSC-Regular.ttf.import @@ -0,0 +1,36 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://co1qw50ehq2tl" +path="res://.godot/imported/CarroisGothicSC-Regular.ttf-2daeda99538728662c384d82b610204e.fontdata" + +[deps] + +source_file="res://fonts/CarroisGothicSC-Regular.ttf" +dest_files=["res://.godot/imported/CarroisGothicSC-Regular.ttf-2daeda99538728662c384d82b610204e.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +modulate_color_glyphs=false +hinting=3 +subpixel_positioning=4 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/fonts/LICENSE.txt b/fonts/LICENSE.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/fonts/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/fonts/README.md b/fonts/README.md new file mode 100644 index 0000000..13e01d9 --- /dev/null +++ b/fonts/README.md @@ -0,0 +1,15 @@ +# Bundled fonts + +## Carrois Gothic SC + +`CarroisGothicSC-Regular.ttf` — designed by Ralph du Carrois, distributed via +Google Fonts. + +Licensed under the Apache License, Version 2.0. The full licence text is in +`LICENSE.txt` alongside this file; it ships with the game because Apache 2.0 +requires a copy of the licence to accompany any distribution of the work. + +It is the project-wide UI typeface, wired up through `ui_theme.tres` +(`default_font`), which `project.godot` loads as `gui/theme/custom`. Note that +the `gui/theme/default_font_size` project setting does **not** reach the +default theme — the size lives on the theme resource too. diff --git a/tests/_crafter_preview.gd b/tests/_crafter_preview.gd index 9a85a15..dc89fe4 100644 --- a/tests/_crafter_preview.gd +++ b/tests/_crafter_preview.gd @@ -33,7 +33,7 @@ func _ready() -> void: if cell.has("phase"): b.set_process(false) b.anim_time = float(cell.phase) * b.CYCLE - b.queue_redraw() + b.apply_animation() var cam: Camera2D = Camera2D.new() cam.zoom = Vector2(1.65, 1.65) diff --git a/tests/_font_probe.gd b/tests/_font_probe.gd index b03b453..24331f5 100644 --- a/tests/_font_probe.gd +++ b/tests/_font_probe.gd @@ -19,8 +19,7 @@ func _initialize() -> void: for path in ["UI/BuildMenu/InfoPopup/Panel/TitleLabel", "UI/BuildMenu/InfoPopup/Panel/MessageLabel", "UI/BuildMenu/MothershipLabel", - "UI/BuildMenu/TitleLabel", - "UI/NavLabel"]: + "UI/BuildMenu/TitleLabel"]: var label: Label = scene.get_node_or_null(path) if label == null: print("%s: MISSING" % path) diff --git a/tests/_profile_startup.gd b/tests/_profile_startup.gd new file mode 100644 index 0000000..098098b --- /dev/null +++ b/tests/_profile_startup.gd @@ -0,0 +1,40 @@ +extends SceneTree + +# Not a test - a stopwatch. Splits the fixed cost every scene-based test pays +# before it can assert anything, so tuning aims at the part that is actually +# expensive. +# godot --headless -s res://tests/_profile_startup.gd + +var scene: Node +var t0: int = 0 +var frames: int = 0 +var frame_start: int = 0 + + +func _initialize() -> void: + var boot: int = Time.get_ticks_msec() + print("engine boot to _initialize: %d ms" % boot) + + t0 = Time.get_ticks_msec() + var packed: PackedScene = load("res://Main.tscn") + print("load(Main.tscn): %d ms" % (Time.get_ticks_msec() - t0)) + + t0 = Time.get_ticks_msec() + scene = packed.instantiate() + print("instantiate(): %d ms" % (Time.get_ticks_msec() - t0)) + + t0 = Time.get_ticks_msec() + root.add_child(scene) + print("add_child() (_ready): %d ms" % (Time.get_ticks_msec() - t0)) + + frame_start = Time.get_ticks_msec() + + +func _process(_delta: float) -> bool: + frames += 1 + if frames < 60: + return false + var span: int = Time.get_ticks_msec() - frame_start + print("60 frames: %d ms (%.1f fps)" % [span, 60000.0 / maxf(span, 1)]) + quit(0) + return true diff --git a/tests/_profile_startup.gd.uid b/tests/_profile_startup.gd.uid new file mode 100644 index 0000000..5b4d374 --- /dev/null +++ b/tests/_profile_startup.gd.uid @@ -0,0 +1 @@ +uid://dvfdj3y0xg3sj diff --git a/tests/_run_inside.sh b/tests/_run_inside.sh new file mode 100644 index 0000000..c6f68e6 --- /dev/null +++ b/tests/_run_inside.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# Runs INSIDE the proot-distro Ubuntu container - do not call this directly, +# call tests/run_tests.sh from Termux instead. +# +# It exists as its own file so the whole suite runs in ONE `proot-distro +# login`: entering the container costs ~0.5s, which used to be paid 38 times +# over. Everything below is the container-side half of the runner. +# +# Args: ... + +set -uo pipefail + +PROJECT_DIR="$1"; shift +JOBS="$1"; shift +VERBOSE="$1"; shift + +GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64" +cd "$PROJECT_DIR" || exit 2 + +tests=("$@") +total=${#tests[@]} +if (( total == 0 )); then + echo "no tests matched" + exit 2 +fi + +outdir="$(mktemp -d)" +trap 'rm -rf "$outdir"' EXIT + +# Import once, serially, before anything runs in parallel. Godot writes to +# .godot/ while importing; several instances discovering a cold cache at the +# same moment would race on it. After this the workers only read it. +"$GODOT_BIN" --headless --import --quit >/dev/null 2>&1 + +run_one() { + local name="$1" + local log="$outdir/$name.log" + local start end + start=$(date +%s%N) + # --fixed-fps pins delta to exactly 1/60 instead of measuring the wall + # clock. Without it a test's `elapsed` counts REAL seconds, so running + # several at once stretches every frame and the gameplay under test sees + # deltas it never sees in the game: ships overshoot their braking curve, + # settle late, and tests time out for no reason but load. That made + # test_camera_settles and test_planet_prompt_map_interaction fail on 2 of + # 3 parallel runs. Pinned, a test simulates the same 60fps whatever else + # the phone is doing, and its result no longer depends on how busy the + # machine was. + "$GODOT_BIN" --headless --fixed-fps 60 -s "res://tests/$name" >"$log" 2>&1 + local rc=$? + end=$(date +%s%N) + local ms=$(( (end - start) / 1000000 )) + + # Two independent criteria, because either one alone can lie: a test that + # crashes before reaching its verdict exits non-zero without ever printing + # FAIL, and a hypothetical `quit(0)` on a failing path would exit clean + # while the log says otherwise. + if (( rc == 0 )) && grep -q ": PASS" "$log"; then + printf 'PASS %6dms %s\n' "$ms" "$name" > "$outdir/$name.status" + else + printf 'FAIL %6dms %s (exit %d)\n' "$ms" "$name" "$rc" > "$outdir/$name.status" + fi + cat "$outdir/$name.status" +} + +running=0 +for name in "${tests[@]}"; do + run_one "$name" & + running=$(( running + 1 )) + if (( running >= JOBS )); then + wait -n + running=$(( running - 1 )) + fi +done +wait + +failed=0 +for name in "${tests[@]}"; do + if ! grep -q '^PASS' "$outdir/$name.status" 2>/dev/null; then + failed=$(( failed + 1 )) + echo + echo "=== $name FAILED, full output ===" + cat "$outdir/$name.log" 2>/dev/null + elif [[ "$VERBOSE" == "1" ]]; then + echo + echo "=== $name ===" + cat "$outdir/$name.log" 2>/dev/null + fi +done + +echo +echo "$(( total - failed ))/$total passed" +(( failed == 0 )) diff --git a/tests/_skill_menu_preview.gd b/tests/_skill_menu_preview.gd new file mode 100644 index 0000000..2cb092d --- /dev/null +++ b/tests/_skill_menu_preview.gd @@ -0,0 +1,29 @@ +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. +# +# bash tests/screenshot.sh res://tests/_skill_menu_preview.tscn out.png 30 720x1280 + + +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") + + 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") diff --git a/tests/_skill_menu_preview.gd.uid b/tests/_skill_menu_preview.gd.uid new file mode 100644 index 0000000..4160b66 --- /dev/null +++ b/tests/_skill_menu_preview.gd.uid @@ -0,0 +1 @@ +uid://d3n86yp86b755 diff --git a/tests/_skill_menu_preview.tscn b/tests/_skill_menu_preview.tscn new file mode 100644 index 0000000..fe8181c --- /dev/null +++ b/tests/_skill_menu_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_skill_menu_preview.gd" id="1"] + +[node name="SkillMenuPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/_vfx_preview.gd b/tests/_vfx_preview.gd new file mode 100644 index 0000000..55d5cc8 --- /dev/null +++ b/tests/_vfx_preview.gd @@ -0,0 +1,47 @@ +extends Node2D + +# Preview scene - NOT run by run_tests.sh (underscore prefix). Renders +# projectiles and every explosion type so the node-animation conversion +# (issue #10) can be checked by eye. Bolts once went INVISIBLE after a +# similar optimisation and headless tests did not catch it, because +# --headless has no GPU - so this exists to make that failure visible. +# +# bash tests/screenshot.sh res://tests/_vfx_preview.tscn out.png 20 + +const VFX_SCRIPTS: Array = [ + "res://ShipExplosionVFX.gd", "res://EnemyExplosionVFX.gd", + "res://AsteroidExplosionVFX.gd", "res://LightBurstVFX.gd", +] + + +func _ready() -> void: + RenderingServer.set_default_clear_color(Color(0.02, 0.03, 0.06)) + + # Bolts at several headings, held still so they can be inspected. + var bolt_scene: PackedScene = load("res://Projectile.tscn") + for i in range(6): + var bolt: Area2D = bolt_scene.instantiate() + bolt.position = Vector2(-460.0 + float(i) * 90.0, -220.0) + bolt.rotation = float(i) * 0.35 + add_child(bolt) + bolt.set_physics_process(false) + + # One of each explosion, stepped forward by hand then frozen. Their real + # lifetime is under a second, and a softpipe frame takes far longer than + # that in wall-clock, so they would expire before the capture. + for i in range(VFX_SCRIPTS.size()): + var script: Script = load(VFX_SCRIPTS[i]) + if script == null: + continue + var vfx: Node2D = Node2D.new() + vfx.set_script(script) + vfx.position = Vector2(-390.0 + float(i) * 260.0, 90.0) + vfx.lifetime = 999.0 + add_child(vfx) + for step in range(8): + vfx._process(0.05) + vfx.set_process(false) + + var cam: Camera2D = Camera2D.new() + add_child(cam) + cam.make_current() diff --git a/tests/_vfx_preview.gd.uid b/tests/_vfx_preview.gd.uid new file mode 100644 index 0000000..d4f13ac --- /dev/null +++ b/tests/_vfx_preview.gd.uid @@ -0,0 +1 @@ +uid://dv32ajs2bxrwd diff --git a/tests/_vfx_preview.tscn b/tests/_vfx_preview.tscn new file mode 100644 index 0000000..6e87acb --- /dev/null +++ b/tests/_vfx_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_vfx_preview.gd" id="1"] + +[node name="VFXPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/run_tests.sh b/tests/run_tests.sh index c252284..6e5e7d8 100644 --- a/tests/run_tests.sh +++ b/tests/run_tests.sh @@ -1,20 +1,78 @@ #!/data/data/com.termux/files/usr/bin/bash # Runs the headless GDScript gameplay tests from Termux, via the Godot # 4.7.1 Linux arm64 build installed inside the proot-distro Ubuntu container. -# Usage: bash tests/run_tests.sh +# +# bash tests/run_tests.sh # everything +# bash tests/run_tests.sh formation # only tests matching a substring +# JOBS=1 bash tests/run_tests.sh # serial, for debugging a hang +# VERBOSE=1 bash tests/run_tests.sh # print passing tests' output too +# IN_PLACE=1 bash tests/run_tests.sh # run from the project dir itself +# +# Three things make this fast, in descending order of how much they matter +# (measured, not guessed - see tests/_profile_startup.gd): +# +# 1. It runs from a COPY on Termux's own filesystem, not from the project +# directory. The project lives under ~/storage/shared, which is Android's +# FUSE bridge; read through proot on top of that, `load("res://Main.tscn")` +# alone took 8.5s per test, against ~2s from a native path. Nearly every +# test loads Main.tscn, and none of that time is gameplay. The copy is +# ~1.4s for the whole suite and its .godot import cache is kept between +# runs. +# 2. Tests run JOBS at a time. Each is its own Godot process - they call +# quit() and stamp on the GameState autoload, so they cannot share one - +# which makes the suite a pile of independent, single-core-bound jobs on +# an 8-core phone. +# 3. The whole run happens in ONE `proot-distro login`. Entering the +# container costs ~0.5s, which used to be paid once per test. +# +# Output is buffered per test and printed only for failures; six interleaved +# logs are unreadable. + +set -uo pipefail -set -e PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64" -status=0 -for test_script in "$PROJECT_DIR"/tests/test_*.gd; do - name="$(basename "$test_script")" - echo "=== Running $name ===" - if ! proot-distro login ubuntu -- bash -c "cd '$PROJECT_DIR' && '$GODOT_BIN' --headless -s res://tests/$name"; then - status=1 - fi - echo +# Leave headroom: Godot is memory-hungry and this is a phone, not a build +# server. Override with JOBS= if you know better. +JOBS="${JOBS:-6}" +VERBOSE="${VERBOSE:-0}" +IN_PLACE="${IN_PLACE:-0}" +WORK_DIR="${WORK_DIR:-$HOME/.cache/spaceship-tests}" + +filter="${1:-}" +tests=() +for path in "$PROJECT_DIR"/tests/test_*.gd; do + name="$(basename "$path")" + if [[ -z "$filter" || "$name" == *"$filter"* ]]; then + tests+=("$name") + fi done +if (( ${#tests[@]} == 0 )); then + echo "no tests match '$filter'" + exit 2 +fi + +start=$(date +%s) + +run_dir="$PROJECT_DIR" +if [[ "$IN_PLACE" != "1" ]]; then + run_dir="$WORK_DIR" + mkdir -p "$run_dir" + # Clear last run's sources but keep .godot, so imports stay warm. Without + # the clear, a test deleted from the project would live on in the copy and + # keep being run. + find "$run_dir" -mindepth 1 -maxdepth 1 ! -name '.godot' -exec rm -rf {} + + # android/ and builds/ are export artefacts no test touches, and the .apk + # is 30MB of nothing useful here. + tar -C "$PROJECT_DIR" \ + --exclude=./.godot --exclude=./android --exclude=./builds --exclude='*.apk' \ + -cf - . 2>/dev/null | tar -C "$run_dir" -xf - +fi + +proot-distro login ubuntu -- bash "$run_dir/tests/_run_inside.sh" \ + "$run_dir" "$JOBS" "$VERBOSE" "${tests[@]}" +status=$? + +echo "in $(( $(date +%s) - start ))s with JOBS=$JOBS" exit $status diff --git a/tests/test_asteroid_safe_spawn.gd b/tests/test_asteroid_safe_spawn.gd new file mode 100644 index 0000000..700ecf1 --- /dev/null +++ b/tests/test_asteroid_safe_spawn.gd @@ -0,0 +1,114 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_asteroid_safe_spawn.gd +# Asteroid chunks are populated around wherever the fleet is - including the +# chunk it is standing in - and positions used to be uniform within a chunk. +# So an asteroid could be created directly on top of a ship and destroy it +# with no chance to react. Worst on the frame a scene loads: coming back from +# a planet's surface could cost a vessel instantly, which is also what made +# test_return_position_matches_planet fail intermittently. +# +# Verifies: +# 1. _safe_position never returns a point inside the bubble, for candidates +# all around and including the exact centre. This is checked directly +# rather than by looking at where asteroids happened to land: with ~54 +# asteroids spread over 25 chunks, any single run only rarely produces a +# near miss, so an observational check passes just as happily with the +# guard removed and proves nothing. +# 2. candidates already outside the bubble are left exactly where they are, +# so the field is not distorted away from the fleet +# 3. the field still spawns a full complement, and a stationary fleet loses +# no ships + +var scene: Node +var fleet: Node +var field: Node +var stage: int = 0 +var elapsed: float = 0.0 +var ships_at_start: int = 0 + + +func _initialize() -> void: + root.get_node("GameState").reset_formation() + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + fleet = scene.get_node("Fleet") + field = scene.get_node("AsteroidField") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene.get_node("EnemySpawner").set_process(false) + ships_at_start = fleet.ships.size() + elapsed = 0.0 + stage = 1 + return false + 1: + # Let the field populate around the fleet a few times over. + if elapsed < 1.5: + return false + + var centre: Vector2 = fleet.get_centroid() + var safe: float = field.safe_spawn_radius + + # --- the guard itself, deterministically ---------------------- + var worst_inside: float = 1e9 + var moved_an_outside_one: bool = false + for i in range(72): + var a: float = (float(i) / 72.0) * TAU + var dir: Vector2 = Vector2(cos(a), sin(a)) + # Candidates well inside, just inside, and exactly on centre. + for d in [0.0, 1.0, safe * 0.25, safe * 0.99]: + var got: Vector2 = field._safe_position(centre + dir * d, centre) + worst_inside = minf(worst_inside, got.distance_to(centre)) + # Candidates already outside must be returned untouched. + var outside: Vector2 = centre + dir * (safe * 1.5) + if field._safe_position(outside, centre).distance_to(outside) > 0.001: + moved_an_outside_one = true + print("closest result for an inside candidate: %.1f (safe radius %.1f)" % [ + worst_inside, safe + ]) + if worst_inside < safe - 0.001: + print("TEST_ASTEROID_SAFE_SPAWN: FAIL (guard let a spawn land inside the fleet's bubble)") + quit(1) + return true + if moved_an_outside_one: + print("TEST_ASTEROID_SAFE_SPAWN: FAIL (guard displaced an asteroid that was already clear)") + quit(1) + return true + + var asteroids: Array = get_nodes_in_group("asteroids") + var closest: float = 1e9 + for a in asteroids: + if is_instance_valid(a): + closest = minf(closest, a.position.distance_to(centre)) + + print("%d asteroids, closest is %.1f units from the fleet (safe radius %.1f)" % [ + asteroids.size(), closest, safe + ]) + if asteroids.is_empty(): + print("TEST_ASTEROID_SAFE_SPAWN: FAIL (field spawned nothing - test proves nothing)") + quit(1) + return true + # Asteroids drift, so one may have travelled inward since it was + # created. Allow for that, but nothing should be anywhere near + # on top of the fleet. + if closest < safe * 0.5: + print("TEST_ASTEROID_SAFE_SPAWN: FAIL (asteroid created on top of the fleet)") + quit(1) + return true + + var kept_ships: bool = fleet.ships.size() == ships_at_start + print("ships %d -> %d (kept=%s)" % [ships_at_start, fleet.ships.size(), kept_ships]) + if not kept_ships: + print("TEST_ASTEROID_SAFE_SPAWN: FAIL (a stationary fleet lost a ship to a spawn)") + quit(1) + return true + + print("TEST_ASTEROID_SAFE_SPAWN: PASS") + quit(0) + return true + return false diff --git a/tests/test_asteroid_safe_spawn.gd.uid b/tests/test_asteroid_safe_spawn.gd.uid new file mode 100644 index 0000000..5336bb6 --- /dev/null +++ b/tests/test_asteroid_safe_spawn.gd.uid @@ -0,0 +1 @@ +uid://ci8vodtuvwod7 diff --git a/tests/test_build_info_popup.gd b/tests/test_build_info_popup.gd index 57f950e..ef1d708 100644 --- a/tests/test_build_info_popup.gd +++ b/tests/test_build_info_popup.gd @@ -39,6 +39,12 @@ func _initialize() -> void: game_state.rocks = 500 game_state.buildings.clear() game_state.extractors.clear() + # Structures are gated behind the mothership's research tree. That gate + # is test_skill_tree's subject, not this one's - here every option needs + # to be available so the popup itself can be exercised. + game_state.reset_progression() + for node in GameState.SKILL_TREE: + game_state.unlocked.append(node.id) scene = load("res://Main.tscn").instantiate() root.add_child(scene) diff --git a/tests/test_building_redraw_cost.gd b/tests/test_building_redraw_cost.gd new file mode 100644 index 0000000..c083754 --- /dev/null +++ b/tests/test_building_redraw_cost.gd @@ -0,0 +1,110 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_building_redraw_cost.gd +# Animated buildings must not re-run _draw() to animate (Forgejo issues #6 +# and #10). +# +# Every NeonDraw call expands into four antialiased polylines, so redrawing +# a whole shipyard each frame to advance a crane re-tessellates all of it. +# The moving elements are now separate NeonPiece children, rasterised once +# and thereafter only moved, scaled, faded or hidden - operations that reuse +# the cached geometry. Ship.gd and Asteroid.gd already worked this way. +# +# Verifies: +# 1. an animated building's geometry is rasterised exactly ONCE, however +# long it animates +# 2. it is genuinely still animating (so this cannot be "passed" by +# freezing the animation) +# 3. the moving parts really do move - the crane travels and the +# mothership's satellites orbit - proving the animation reaches the +# nodes rather than being silently dropped +# 4. the static command centre needs no per-frame work either + +var scene: Node +var field: Node +var crafter: Node2D +var mothership: Node2D +var command: Node2D +var stage: int = 0 +var elapsed: float = 0.0 +var crane_x_start: float = 0.0 +var satellite_rotation_start: float = 0.0 + + +func _initialize() -> void: + var game_state: Node = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + field = scene.get_node("BuildingField") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(600.0, 0.0)) + field.place(GameState.BUILDING_MOTHERSHIP, Vector2(-600.0, 0.0)) + field.place(GameState.BUILDING_COMMAND_CENTER, Vector2(0.0, 600.0)) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.3: + return false + for b in field.spawned: + if not is_instance_valid(b): + continue + match b.building_type: + GameState.BUILDING_SHIP_CRAFTER: + crafter = b + GameState.BUILDING_MOTHERSHIP: + mothership = b + GameState.BUILDING_COMMAND_CENTER: + command = b + if crafter == null or mothership == null or command == null: + print("TEST_BUILDING_REDRAW_COST: FAIL (buildings never spawned)") + quit(1) + return true + + crane_x_start = crafter._crane.position.x + satellite_rotation_start = mothership._satellites.rotation + elapsed = 0.0 + stage = 2 + return false + 2: + # Long enough to cross several assembly phases and a good arc of + # the satellites' orbit. + if elapsed < 2.5: + return false + + print("rasterisations after %.1fs of animation - crafter:%d mothership:%d command:%d" % [ + elapsed, crafter.draw_count, mothership.draw_count, command.draw_count + ]) + for b in [crafter, mothership, command]: + if b.draw_count != 1: + print("TEST_BUILDING_REDRAW_COST: FAIL (%s redrew its geometry %d times)" % [ + b.building_type, b.draw_count + ]) + quit(1) + return true + + var animating: bool = crafter.anim_time > 0.0 and mothership.phase_time > 0.0 + var crane_moved: bool = absf(crafter._crane.position.x - crane_x_start) > 1.0 + var orbit_moved: bool = absf(mothership._satellites.rotation - satellite_rotation_start) > 0.05 + print("still animating=%s crane travelled=%s satellites orbited=%s" % [ + animating, crane_moved, orbit_moved + ]) + if not (animating and crane_moved and orbit_moved): + print("TEST_BUILDING_REDRAW_COST: FAIL (nothing actually moved - not a fix)") + quit(1) + return true + + print("TEST_BUILDING_REDRAW_COST: PASS") + quit(0) + return true + return false diff --git a/tests/test_building_redraw_cost.gd.uid b/tests/test_building_redraw_cost.gd.uid new file mode 100644 index 0000000..a65db84 --- /dev/null +++ b/tests/test_building_redraw_cost.gd.uid @@ -0,0 +1 @@ +uid://ihnpl5ogoxi1 diff --git a/tests/test_extractor_building.gd b/tests/test_extractor_building.gd index 20e9607..13b2567 100644 --- a/tests/test_extractor_building.gd +++ b/tests/test_extractor_building.gd @@ -42,6 +42,11 @@ func _initialize() -> void: game_state.rocks = 200 game_state.buildings.clear() game_state.extractors.clear() + # Extractors are gated behind research (see test_skill_tree). This test + # is about the planet-proximity rule, so grant the research up front. + game_state.reset_progression() + for node in GameState.SKILL_TREE: + game_state.unlocked.append(node.id) main_scene = load("res://Main.tscn").instantiate() root.add_child(main_scene) diff --git a/tests/test_extractor_income.gd b/tests/test_extractor_income.gd index f11e9b8..b559bf5 100644 --- a/tests/test_extractor_income.gd +++ b/tests/test_extractor_income.gd @@ -14,7 +14,13 @@ extends SceneTree var game_state: Node var stage: int = 0 var elapsed: float = 0.0 -var mothership_pos: Vector2 = Vector2(4000.0, 0.0) +# The test has to sit through a whole there-and-back hauler cycle, and the +# cycle length is distance/HAULER_SPEED - so this fixture distance IS the +# test's runtime. 600 units is ~4.6s of simulated round trip, against ~31s +# for the 4000 it used to be. Nothing here asserts on the distance itself, +# only on payout and drawn position tracking the same `progress`, so a short +# route exercises exactly the same code. +var mothership_pos: Vector2 = Vector2(600.0, 0.0) var planet_pos: Vector2 = Vector2.ZERO var rocks_before_arrival: int = 0 diff --git a/tests/test_joystick_control.gd b/tests/test_joystick_control.gd index 855a192..1a3e41a 100644 --- a/tests/test_joystick_control.gd +++ b/tests/test_joystick_control.gd @@ -120,9 +120,17 @@ func _process(delta: float) -> bool: for ship in fleet.ships: speed = max(speed, ship.velocity.length()) var stopped: bool = speed < 1.0 - var coasted: bool = fleet.get_centroid().x > moving_centroid.x - print("after release: max_speed=%.3f stopped=%s coasted_further=%s" % [ - speed, stopped, coasted + # Not merely "moved forward at all": the fleet cruises at + # cruise_speed and brakes at Ship.acceleration, so the coast is + # cruise_speed^2 / (2 * acceleration) ~ 75 units. Asserting a + # real distance is what makes this a test of coasting - the old + # `> moving_centroid.x` was satisfied by any float wobble, and + # passed happily while the fleet stopped dead. + var coast: float = fleet.get_centroid().x - moving_centroid.x + var coasted: bool = coast > 40.0 + print("after release: max_speed=%.3f stopped=%s coasted=%.1f units (expected ~%.0f)" % [ + speed, stopped, coast, + (fleet.cruise_speed * fleet.cruise_speed) / (2.0 * fleet.ships[0].acceleration) ]) if stopped and coasted: print("TEST_JOYSTICK_CONTROL: PASS") diff --git a/tests/test_skill_tree.gd b/tests/test_skill_tree.gd new file mode 100644 index 0000000..92e818f --- /dev/null +++ b/tests/test_skill_tree.gd @@ -0,0 +1,169 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_skill_tree.gd +# The mothership's research tree (Forgejo issue #4). It gates which +# structures may be built at all, and carries permanent upgrades that have +# no building of their own. +# +# Verifies: +# 1. from a fresh start only the mothership is buildable - the others are +# greyed, and pressing one explains which research opens it rather than +# silently failing +# 2. a locked option cannot be built even if the confirm is forced +# 3. research costs rocks, needs its prerequisite, and unlocks its building +# 4. the mothership's action button opens the tree (it used to have no +# action at all) +# 5. fleet capacity upgrades raise the cap, and the extra vessel can +# actually be crafted afterwards + +var scene: Node +var game_state: Node +var controller: Node +var fleet: Node +var field: Node +var popup: Control +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.rocks = 500 + game_state.buildings.clear() + game_state.extractors.clear() + game_state.reset_progression() + game_state.reset_formation() + + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + controller = scene.get_node("BuildController") + fleet = scene.get_node("Fleet") + field = scene.get_node("BuildingField") + popup = scene.get_node("UI/BuildMenu/InfoPopup") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + field.place(GameState.BUILDING_MOTHERSHIP, Vector2(400.0, 0.0)) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.2: + return false + + # --- everything but the mothership starts locked -------------- + var mothership_free: bool = game_state.is_building_unlocked(GameState.BUILDING_MOTHERSHIP) + var crafter_locked: bool = not game_state.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER) + print("fresh start: mothership buildable=%s crafter locked=%s" % [ + mothership_free, crafter_locked + ]) + if not (mothership_free and crafter_locked): + print("TEST_SKILL_TREE: FAIL (wrong things buildable at the start)") + quit(1) + return true + + controller._on_build_pressed() + var greyed: bool = not scene.get_node("UI/BuildMenu/CrafterButton").enabled + controller._on_place_crafter() + var explains: bool = popup.visible \ + and "research" in popup.get_node("Panel/MessageLabel").text.to_lower() + var offered: bool = popup.get_node("Panel/BuildButton").visible + print("locked crafter: greyed=%s explains=%s confirm_offered=%s" % [ + greyed, explains, offered + ]) + if not (greyed and explains) or offered: + print("TEST_SKILL_TREE: FAIL (locked option did not explain itself)") + quit(1) + return true + + # Forcing the confirm must still build nothing. + var before: int = game_state.buildings.size() + controller.pending_build_type = GameState.BUILDING_SHIP_CRAFTER + controller._confirm_build() + if game_state.buildings.size() != before: + print("TEST_SKILL_TREE: FAIL (built a locked structure)") + quit(1) + return true + controller._close_info() + + # --- the mothership opens the tree ---------------------------- + var mothership: Node2D = null + for b in field.spawned: + if is_instance_valid(b) and b.building_type == GameState.BUILDING_MOTHERSHIP: + mothership = b + controller.current_building = mothership + controller._on_action_pressed() + var tree_open: bool = scene.get_node("UI/SkillMenu").visible + print("mothership action opens research: %s" % tree_open) + if not tree_open: + print("TEST_SKILL_TREE: FAIL (mothership has no research action)") + quit(1) + return true + + # --- prerequisites -------------------------------------------- + if game_state.can_unlock("wing"): + print("TEST_SKILL_TREE: FAIL (deep node unlockable with no prerequisite)") + quit(1) + return true + + # --- research costs rocks and unlocks its building ------------ + var rocks_before: int = game_state.rocks + var cost: int = int(game_state.skill_node("shipyards").cost) + var ok: bool = game_state.unlock("shipyards") + var charged: bool = game_state.rocks == rocks_before - cost + var now_buildable: bool = game_state.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER) + print("research shipyards: ok=%s rocks %d->%d charged=%s crafter unlocked=%s" % [ + ok, rocks_before, game_state.rocks, charged, now_buildable + ]) + if not (ok and charged and now_buildable): + print("TEST_SKILL_TREE: FAIL (research did not unlock its building)") + quit(1) + return true + + # The menu must show it as buildable now, without reopening. + controller._on_build_pressed() + if not scene.get_node("UI/BuildMenu/CrafterButton").enabled: + print("TEST_SKILL_TREE: FAIL (build menu still greys an unlocked option)") + quit(1) + return true + controller._close_info() + scene.get_node("UI/BuildMenu").visible = false + + # --- fleet capacity ------------------------------------------- + var base_cap: int = game_state.max_fleet_size() + game_state.unlock("coordination") + var wing_ok: bool = game_state.unlock("wing") + var raised_cap: int = game_state.max_fleet_size() + print("fleet cap %d -> %d after Wing Doctrine (unlocked=%s)" % [ + base_cap, raised_cap, wing_ok + ]) + if not (wing_ok and raised_cap == base_cap + 1): + print("TEST_SKILL_TREE: FAIL (capacity upgrade did not raise the cap)") + quit(1) + return true + + # And the extra vessel must actually be craftable. + fleet.spawn_ships(base_cap, Vector2.ZERO) + var added: bool = fleet.add_ship() + print("fleet %d ships, add one more -> %s (now %d)" % [ + base_cap, added, fleet.ships.size() + ]) + if not (added and fleet.ships.size() == base_cap + 1): + print("TEST_SKILL_TREE: FAIL (could not crew the extra slot)") + quit(1) + return true + if fleet.add_ship(): + print("TEST_SKILL_TREE: FAIL (cap not enforced beyond the upgrade)") + quit(1) + return true + + print("TEST_SKILL_TREE: PASS") + quit(0) + return true + return false diff --git a/tests/test_skill_tree.gd.uid b/tests/test_skill_tree.gd.uid new file mode 100644 index 0000000..92d320c --- /dev/null +++ b/tests/test_skill_tree.gd.uid @@ -0,0 +1 @@ +uid://db2nf67mtlg27 diff --git a/ui_theme.tres b/ui_theme.tres index 38c105d..89bb6a1 100644 --- a/ui_theme.tres +++ b/ui_theme.tres @@ -1,15 +1,8 @@ -[gd_resource type="Theme" format=3] +[gd_resource type="Theme" load_steps=2 format=3] -; Project-wide default font size for every Control. -; -; This exists because the `gui/theme/default_font_size` project setting does -; NOT reach the default theme in this build - labels without an explicit -; override resolve to the engine fallback of 16 no matter what that setting -; says, which is why earlier attempts to enlarge the UI text did nothing. -; A custom Theme assigned to `gui/theme/custom` does apply. -; -; Individual labels that cannot fit their own text at this size carry a -; smaller theme_override_font_sizes/font_size in the scene. +[ext_resource type="FontFile" path="res://fonts/CarroisGothicSC-Regular.ttf" id="1"] [resource] +default_font = ExtResource("1") default_font_size = 78 +Label/colors/font_color = Color(0.36, 0.9, 1, 1) From 8dd5d7b78f8e2563883ced22b49491fea0f1c5cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Tue, 11 Aug 2026 15:29:56 +0200 Subject: [PATCH 02/11] Show buildings on the world map, and extractors on their planets Forgejo #11: buildings the player has placed now appear on the world map. They are placed freely in space rather than tied to a chunk, so they get their own pass in WorldMapContent._draw() rather than riding along with the chunk markers. Each type has its own shape and colour, mirroring Building.gd's constants so a marker reads as the same building you built. Forgejo #9: a planet with an extractor built on it now shows it, in both places you look at a planet - an amber derrick on its outline in space, and a matching badge on its world-map marker. The extractor lives on PlanetSurface.tscn, a scene you are not looking at from space, so an already-mined planet was otherwise indistinguishable from an untouched one until you landed on it. The positioning steps are factored out (building_markers(), chunk_has_extractor_badge(), extractor_derrick_tip()) so headless tests can assert on them - --headless has no GPU and draws no pixels, so there is nothing to check in _draw() itself. Both features were also rendered for real via tests/screenshot.sh and looked at. extractor_derrick_tip() exists because the first version of the "derrick is not buried inside the planet" check measured the furthest of all four derrick points, which passes a derrick pointing straight into the ground: the base corners sit a half-width off the rim vertex and clear the outline on their own. Mutation-testing caught it. screenshot.sh now clears a stale /tmp/.X99-lock. A run killed mid-screenshot leaves it behind and Xvfb then refuses :99, which resurfaces later as Godot's misleading "X11 Display is not available". Co-Authored-By: Claude Opus 5 --- Planet.gd | 56 +++++++++ WorldMapContent.gd | 86 ++++++++++++++ tests/_marker_preview.gd | 45 +++++++ tests/_marker_preview.gd.uid | 1 + tests/_marker_preview.tscn | 6 + tests/_planet_derrick_preview.gd | 25 ++++ tests/_planet_derrick_preview.gd.uid | 1 + tests/_planet_derrick_preview.tscn | 6 + tests/screenshot.sh | 7 ++ tests/test_map_building_markers.gd | 99 ++++++++++++++++ tests/test_map_building_markers.gd.uid | 1 + tests/test_planet_extractor_marker.gd | 138 ++++++++++++++++++++++ tests/test_planet_extractor_marker.gd.uid | 1 + 13 files changed, 472 insertions(+) create mode 100644 tests/_marker_preview.gd create mode 100644 tests/_marker_preview.gd.uid create mode 100644 tests/_marker_preview.tscn create mode 100644 tests/_planet_derrick_preview.gd create mode 100644 tests/_planet_derrick_preview.gd.uid create mode 100644 tests/_planet_derrick_preview.tscn create mode 100644 tests/test_map_building_markers.gd create mode 100644 tests/test_map_building_markers.gd.uid create mode 100644 tests/test_planet_extractor_marker.gd create mode 100644 tests/test_planet_extractor_marker.gd.uid diff --git a/Planet.gd b/Planet.gd index 4882311..b682030 100644 --- a/Planet.gd +++ b/Planet.gd @@ -20,6 +20,20 @@ extends Node2D var shape_points: PackedVector2Array = PackedVector2Array() +# An extractor is built down on this planet's SURFACE, in a different scene +# the player is no longer looking at, so from space nothing distinguished a +# planet already being mined from an untouched one - you had to land to find +# out. This is a derrick planted on the rim: the same amber the world map +# uses for its extractor badge, so the two read as the same fact. +const EXTRACTOR_MARKER_COLOR: Color = Color(1.0, 0.85, 0.3) +const DERRICK_HEIGHT: float = 44.0 +const DERRICK_HALF_WIDTH: float = 16.0 + +# Resolved once in _ready() rather than polled: building an extractor happens +# on PlanetSurface.tscn, and coming back to space rebuilds Main.tscn from +# scratch, so a Planet node never outlives a change to its own answer. +var shows_extractor: bool = false + func _ready() -> void: add_to_group("planets") @@ -32,8 +46,50 @@ func _ready() -> void: var angle: float = (float(i) / point_count) * TAU var radius: float = rng.randf_range(70.0, 100.0) shape_points.append(Vector2(cos(angle), sin(angle)) * radius) + shows_extractor = GameState.has_extractor_at(planet_name) queue_redraw() +func extractor_derrick_anchor() -> Vector2: + # The rim vertex the derrick stands on: the shape's own topmost point, + # rather than a fixed radius, so it sits ON the outline whatever lumpy + # shape the seed drew instead of floating off it or sinking inside it. + var base: Vector2 = Vector2(0.0, -100.0) + for p in shape_points: + if p.y < base.y: + base = p + return base + + +func extractor_derrick_tip() -> Vector2: + # Exposed separately from the polygon because "does the derrick clear the + # planet's outline" is a question about the TIP alone. The base corners + # sit a half-width off to either side, so they are sqrt(|base|^2 + w^2) + # from the centre and can clear the rim on their own even when the tower + # points straight into the ground. + var base: Vector2 = extractor_derrick_anchor() + var out: Vector2 = base.normalized() + if out == Vector2.ZERO: + out = Vector2.UP + return base + out * DERRICK_HEIGHT + + +func extractor_derrick_points() -> PackedVector2Array: + var base: Vector2 = extractor_derrick_anchor() + var out: Vector2 = base.normalized() + if out == Vector2.ZERO: + out = Vector2.UP + var side: Vector2 = Vector2(-out.y, out.x) + var tip: Vector2 = extractor_derrick_tip() + return PackedVector2Array([ + base - side * DERRICK_HALF_WIDTH, + tip - side * (DERRICK_HALF_WIDTH * 0.35), + tip + side * (DERRICK_HALF_WIDTH * 0.35), + base + side * DERRICK_HALF_WIDTH, + ]) + + func _draw() -> void: NeonDraw.draw_glowing_polygon(self, shape_points, Color(0.6, 0.55, 0.5), 1.0) + if shows_extractor: + NeonDraw.draw_glowing_polygon(self, extractor_derrick_points(), EXTRACTOR_MARKER_COLOR, 1.0) diff --git a/WorldMapContent.gd b/WorldMapContent.gd index b0fe79d..1398021 100644 --- a/WorldMapContent.gd +++ b/WorldMapContent.gd @@ -2,6 +2,23 @@ extends Node2D const WORLD_TO_LOCAL: float = 0.04 +# Mirrors Building.gd's own MOTHERSHIP_COLOR/CRAFTER_COLOR/COMMAND_COLOR so a +# marker on the map reads as "the same building" at a glance. Duplicated +# rather than cross-referenced - the same colour is already independently +# duplicated between Ship.gd, Fleet.gd and Building.gd's HULL_COLOR, so this +# follows the codebase's existing convention rather than inventing a new one. +const MOTHERSHIP_MARKER_COLOR: Color = Color(0.45, 0.9, 0.55) +const CRAFTER_MARKER_COLOR: Color = Color(1.0, 0.72, 0.25) +const COMMAND_MARKER_COLOR: Color = Color(0.55, 0.6, 1.0) + +# A structure built on a planet's surface (see GameState.extractors) is not +# itself a map marker - it is a small badge on the existing planet marker, +# in a colour distinct from both the planets and the space buildings so it +# reads as "something is mined here" rather than another building type. +const EXTRACTOR_BADGE_COLOR: Color = Color(1.0, 0.85, 0.3) +const EXTRACTOR_BADGE_OFFSET: Vector2 = Vector2(8.0, -8.0) +const EXTRACTOR_BADGE_RADIUS: float = 5.0 + @onready var tracker: Node2D = get_node("../../../ExplorationTracker") @onready var fleet: Node2D = get_node("../../../Fleet") @@ -11,6 +28,59 @@ func _process(_delta: float) -> void: queue_redraw() +# Local-space marker data for every player-placed building (GameState. +# buildings), one entry per building with its colour already resolved. Kept +# as a separate, callable step (rather than inlined in _draw()) so a +# headless test - which has no GPU and cannot check drawn pixels - can +# verify a built building actually produces a marker. +func building_markers() -> Array: + var markers: Array = [] + for b in GameState.buildings: + markers.append({ + "position": b.position * WORLD_TO_LOCAL, + "type": b.type, + "color": _building_marker_color(b.type), + }) + return markers + + +func _building_marker_color(type: String) -> Color: + match type: + GameState.BUILDING_MOTHERSHIP: + return MOTHERSHIP_MARKER_COLOR + GameState.BUILDING_SHIP_CRAFTER: + return CRAFTER_MARKER_COLOR + _: + return COMMAND_MARKER_COLOR + + +# Whether the planet in this (explored) chunk has an extractor on it, per +# GameState.has_extractor_at(). A separate predicate for the same +# GPU-less-testability reason as building_markers(). +func chunk_has_extractor_badge(chunk: Vector2i) -> bool: + if not WorldGen.has_planet_at_chunk(chunk): + return false + return GameState.has_extractor_at(WorldGen.planet_data_at_chunk(chunk).name) + + +func _regular_polygon(center: Vector2, radius: float, sides: int, phase: float = 0.0) -> PackedVector2Array: + var pts: PackedVector2Array = PackedVector2Array() + for i in range(sides): + var a: float = (float(i) / float(sides)) * TAU + phase + pts.append(center + Vector2(cos(a), sin(a)) * radius) + return pts + + +# Filled shape plus a thin white outline - the same construction the fleet +# marker below already uses, just factored out since buildings now need it +# three times over (once per type) and the extractor badge needs it too. +func _draw_marker_polygon(pts: PackedVector2Array, color: Color) -> void: + draw_colored_polygon(pts, color) + var closed: PackedVector2Array = pts.duplicate() + closed.append(pts[0]) + draw_polyline(closed, Color(1, 1, 1), 1.5, true) + + func _draw() -> void: var chunk_size_local: float = WorldGen.CHUNK_SIZE * WORLD_TO_LOCAL @@ -32,6 +102,22 @@ func _draw() -> void: draw_circle(planet_local, 5.0, Color(0.7, 0.65, 0.55)) draw_arc(planet_local, 5.0, 0.0, TAU, 16, Color(1.0, 1.0, 1.0, 0.9), 1.0, true) + if chunk_has_extractor_badge(chunk): + var badge_pts: PackedVector2Array = _regular_polygon( + planet_local + EXTRACTOR_BADGE_OFFSET, EXTRACTOR_BADGE_RADIUS, 4, PI / 4.0) + _draw_marker_polygon(badge_pts, EXTRACTOR_BADGE_COLOR) + + # Buildings are placed freely in space rather than tied to a chunk, so + # they get their own pass rather than living inside the chunk loop above. + for marker in building_markers(): + match marker.type: + GameState.BUILDING_MOTHERSHIP: + _draw_marker_polygon(_regular_polygon(marker.position, 8.0, 6, PI / 6.0), marker.color) + GameState.BUILDING_SHIP_CRAFTER: + _draw_marker_polygon(_regular_polygon(marker.position, 7.0, 4, 0.0), marker.color) + _: + _draw_marker_polygon(_regular_polygon(marker.position, 6.0, 4, PI / 4.0), marker.color) + var fleet_local: Vector2 = fleet.get_centroid() * WORLD_TO_LOCAL var marker_points: PackedVector2Array = PackedVector2Array([ fleet_local + Vector2(0, -10), diff --git a/tests/_marker_preview.gd b/tests/_marker_preview.gd new file mode 100644 index 0000000..465cb20 --- /dev/null +++ b/tests/_marker_preview.gd @@ -0,0 +1,45 @@ +extends Node2D + +# Preview, not a test (underscore prefix keeps it out of run_tests.sh). +# Renders the world map with buildings placed and an extractor built, so the +# issue #11 building markers and the issue #9 extractor badge can actually be +# LOOKED at. --headless draws no pixels, so no headless test can tell whether +# a marker is visible - see tests/screenshot.sh. +# +# bash tests/screenshot.sh res://tests/_marker_preview.tscn /tmp/map.png 20 720x1280 + + +func _ready() -> void: + GameState.buildings.clear() + GameState.extractors.clear() + GameState.explored_chunks.clear() + + # Near the origin, where the fleet starts, so everything lands on screen. + GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(900.0, -700.0)) + GameState.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(-1100.0, 300.0)) + GameState.add_building(GameState.BUILDING_COMMAND_CENTER, Vector2(400.0, 1200.0)) + + # Reveal a patch of map, and put an extractor on whichever planet is + # nearest the origin so its badge is in frame too. + var best: Vector2i = Vector2i.ZERO + var best_distance: int = 1 << 30 + for x in range(-6, 7): + for y in range(-6, 7): + var chunk: Vector2i = Vector2i(x, y) + GameState.explored_chunks[chunk] = true + if WorldGen.has_planet_at_chunk(chunk): + var d: int = absi(x) + absi(y) + if d < best_distance: + best_distance = d + best = chunk + if best_distance < (1 << 30): + var data: Dictionary = WorldGen.planet_data_at_chunk(best) + GameState.add_extractor(data.name, WorldGen.chunk_origin(best) + data.local_pos) + print("extractor on '%s' at chunk %s" % [data.name, best]) + + var main: Node = load("res://Main.tscn").instantiate() + add_child(main) + # open_map(), not `visible = true` - opening also recentres the view on + # the fleet, and without that the map is drawn from its own origin in the + # corner of the screen with everything near the fleet cropped off. + main.get_node("UI/WorldMap").open_map() diff --git a/tests/_marker_preview.gd.uid b/tests/_marker_preview.gd.uid new file mode 100644 index 0000000..f49b06c --- /dev/null +++ b/tests/_marker_preview.gd.uid @@ -0,0 +1 @@ +uid://blbow3jq36fdw diff --git a/tests/_marker_preview.tscn b/tests/_marker_preview.tscn new file mode 100644 index 0000000..85c61cb --- /dev/null +++ b/tests/_marker_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_marker_preview.gd" id="1"] + +[node name="MarkerPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/_planet_derrick_preview.gd b/tests/_planet_derrick_preview.gd new file mode 100644 index 0000000..bfcbc31 --- /dev/null +++ b/tests/_planet_derrick_preview.gd @@ -0,0 +1,25 @@ +extends Node2D + +# Preview, not a test. Two planets side by side in SPACE - the left one has +# an extractor built on it, the right one does not - so the issue #9 derrick +# can be compared against a plain planet and judged as actually visible. +# +# bash tests/screenshot.sh res://tests/_planet_derrick_preview.tscn /tmp/derrick.png 15 1280x720 + + +func _ready() -> void: + GameState.extractors.clear() + GameState.buildings.clear() + GameState.add_extractor("Mined World", Vector2.ZERO) + + _spawn("Mined World", Vector2(380.0, 380.0), 12345) + _spawn("Untouched World", Vector2(900.0, 380.0), 4242) + + +func _spawn(planet_name: String, at: Vector2, shape_seed: int) -> void: + var planet: Node2D = load("res://Planet.tscn").instantiate() + planet.planet_name = planet_name + planet.shape_seed = shape_seed + planet.position = at + add_child(planet) + print("%s: derrick=%s" % [planet_name, planet.shows_extractor]) diff --git a/tests/_planet_derrick_preview.gd.uid b/tests/_planet_derrick_preview.gd.uid new file mode 100644 index 0000000..053a54b --- /dev/null +++ b/tests/_planet_derrick_preview.gd.uid @@ -0,0 +1 @@ +uid://dl4abkye58bd6 diff --git a/tests/_planet_derrick_preview.tscn b/tests/_planet_derrick_preview.tscn new file mode 100644 index 0000000..cbbfb77 --- /dev/null +++ b/tests/_planet_derrick_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_planet_derrick_preview.gd" id="1"] + +[node name="PlanetDerrickPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/screenshot.sh b/tests/screenshot.sh index 739b163..8fa7168 100644 --- a/tests/screenshot.sh +++ b/tests/screenshot.sh @@ -28,6 +28,13 @@ WAIT_FRAMES="${3:-60}" RESOLUTION="${4:-1280x720}" proot-distro login ubuntu -- bash -c " +# A killed run (or a session that ended mid-screenshot) leaves /tmp/.X99-lock +# behind, and Xvfb then refuses to claim :99 - which surfaces much later and +# far less obviously as Godot's 'X11 Display is not available'. Nothing else +# uses :99 here, so a lock with no live Xvfb behind it is always stale. +if ! pgrep -x Xvfb >/dev/null; then + rm -f /tmp/.X99-lock /tmp/.X11-unix/X99 +fi Xvfb :99 -screen 0 ${RESOLUTION}x24 & XVFB_PID=\$! sleep 2 diff --git a/tests/test_map_building_markers.gd b/tests/test_map_building_markers.gd new file mode 100644 index 0000000..e92320a --- /dev/null +++ b/tests/test_map_building_markers.gd @@ -0,0 +1,99 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_map_building_markers.gd +# Forgejo issue #11: a building the player has placed must appear on the +# world map. Without it the map showed explored chunks, planets and the +# fleet, but nothing the player had actually built - so there was no way to +# find your own base again once you had flown away from it. +# +# Buildings are not tied to a chunk (they are placed freely in space), so +# they cannot ride along with the chunk markers and need their own pass. +# +# --headless has no GPU and draws no pixels, so this cannot assert on what +# was rendered. It asserts on building_markers(), the step that turns +# GameState.buildings into map-local marker data, which is the part that +# _draw() then simply stamps out. +# +# Verifies: +# 1. no buildings -> no markers (so a passing count is not just "always 3") +# 2. every placed building produces exactly one marker +# 3. markers land at the building's world position scaled into map space +# 4. the three building types are told apart by colour, rather than all +# being drawn as the same anonymous dot + +var scene: Node +var game_state: Node +var map_content: Node2D +var stage: int = 0 + +const MOTHERSHIP_AT: Vector2 = Vector2(2000.0, -1200.0) +const CRAFTER_AT: Vector2 = Vector2(-800.0, 400.0) +const COMMAND_AT: Vector2 = Vector2(150.0, 3000.0) + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + map_content = scene.get_node("UI/WorldMap/MapContent") + + +func _process(_delta: float) -> bool: + match stage: + 0: + # Nothing built yet: the map must show no building markers at all. + var empty: Array = map_content.building_markers() + if not empty.is_empty(): + print("TEST_MAP_BUILDING_MARKERS: FAIL (%d markers with nothing built)" % empty.size()) + quit(1) + return true + + game_state.add_building(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT) + game_state.add_building(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT) + game_state.add_building(GameState.BUILDING_COMMAND_CENTER, COMMAND_AT) + stage = 1 + return false + 1: + var markers: Array = map_content.building_markers() + print("markers=%d for %d buildings" % [markers.size(), game_state.buildings.size()]) + if markers.size() != 3: + print("TEST_MAP_BUILDING_MARKERS: FAIL (expected one marker per building)") + quit(1) + return true + + var scale: float = map_content.WORLD_TO_LOCAL + var expected: Dictionary = { + GameState.BUILDING_MOTHERSHIP: MOTHERSHIP_AT * scale, + GameState.BUILDING_SHIP_CRAFTER: CRAFTER_AT * scale, + GameState.BUILDING_COMMAND_CENTER: COMMAND_AT * scale, + } + var colours: Array = [] + for m in markers: + if not expected.has(m.type): + print("TEST_MAP_BUILDING_MARKERS: FAIL (unexpected marker type %s)" % m.type) + quit(1) + return true + var want: Vector2 = expected[m.type] + if m.position.distance_to(want) > 0.001: + print("%s marker at %s, expected %s" % [m.type, m.position, want]) + print("TEST_MAP_BUILDING_MARKERS: FAIL (marker is not where the building is)") + quit(1) + return true + colours.append(m.color) + + # Three buildings drawn in one indistinguishable colour would + # satisfy every check above and still be useless to read. + if colours[0] == colours[1] or colours[1] == colours[2] or colours[0] == colours[2]: + print("colours=%s" % [colours]) + print("TEST_MAP_BUILDING_MARKERS: FAIL (building types are not visually distinct)") + quit(1) + return true + + print("all 3 markers positioned correctly, colours distinct: %s" % [colours]) + print("TEST_MAP_BUILDING_MARKERS: PASS") + quit(0) + return true + return false diff --git a/tests/test_map_building_markers.gd.uid b/tests/test_map_building_markers.gd.uid new file mode 100644 index 0000000..ecae550 --- /dev/null +++ b/tests/test_map_building_markers.gd.uid @@ -0,0 +1 @@ +uid://bx3bce8ix4rbg diff --git a/tests/test_planet_extractor_marker.gd b/tests/test_planet_extractor_marker.gd new file mode 100644 index 0000000..2104da5 --- /dev/null +++ b/tests/test_planet_extractor_marker.gd @@ -0,0 +1,138 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_planet_extractor_marker.gd +# Forgejo issue #9: a planet with an extractor built on it must show that +# something is built there. The extractor lives on PlanetSurface.tscn, a +# scene the player is not looking at from space, so an already-mined planet +# was indistinguishable from an untouched one - you had to land to find out. +# +# Covered in both places the player looks at a planet: +# - in space, as a derrick on the planet's own outline (Planet.gd) +# - on the world map, as a badge on the planet marker (WorldMapContent.gd) +# +# --headless has no GPU, so this asserts on the state and geometry that +# _draw() consumes, not on pixels. tests/screenshot.sh covers whether it is +# actually visible. +# +# Verifies: +# 1. a planet with no extractor shows neither derrick nor badge (so a pass +# cannot just mean "always on") +# 2. building one turns both on, keyed by planet NAME - the same key +# GameState.has_extractor_at uses +# 3. the derrick sits outside the planet's outline rather than buried in +# it, which is the difference between visible and not + +var game_state: Node +var scene: Node +var map_content: Node2D +var planet_chunk: Vector2i +var planet_name: String = "" +var stage: int = 0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + + var found: bool = false + for x in range(-15, 15): + for y in range(-15, 15): + var chunk: Vector2i = Vector2i(x, y) + if WorldGen.has_planet_at_chunk(chunk): + planet_chunk = chunk + planet_name = WorldGen.planet_data_at_chunk(chunk).name + found = true + break + if found: + break + if not found: + print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (no planet chunk to test with)") + quit(1) + return + + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + map_content = scene.get_node("UI/WorldMap/MapContent") + + +func _spawn_planet() -> Node2D: + var planet: Node2D = load("res://Planet.tscn").instantiate() + planet.planet_name = planet_name + planet.shape_seed = 12345 + scene.add_child(planet) + return planet + + +func _process(_delta: float) -> bool: + match stage: + 0: + # --- nothing built there yet -------------------------------- + var clean: Node2D = _spawn_planet() + if clean.shows_extractor: + print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (derrick on a planet with no extractor)") + quit(1) + return true + if map_content.chunk_has_extractor_badge(planet_chunk): + print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (map badge with no extractor)") + quit(1) + return true + clean.queue_free() + print("no extractor -> no derrick, no badge (planet '%s')" % planet_name) + + game_state.add_extractor(planet_name, WorldGen.chunk_origin(planet_chunk)) + stage = 1 + return false + 1: + # --- extractor built ---------------------------------------- + var mined: Node2D = _spawn_planet() + if not mined.shows_extractor: + print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (no derrick after building an extractor)") + quit(1) + return true + if not map_content.chunk_has_extractor_badge(planet_chunk): + print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (no map badge after building an extractor)") + quit(1) + return true + + # The derrick has to clear the outline to be seen at all. Compare + # its tip against the planet's own radius in that direction, not + # against a constant - the outline is a lumpy seeded shape. + var derrick: PackedVector2Array = mined.extractor_derrick_points() + if derrick.size() < 3: + print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (derrick has no shape)") + quit(1) + return true + # Measure the TIP, not the whole polygon. The base corners sit a + # half-width to either side of the rim vertex, so they are + # sqrt(|base|^2 + w^2) from the centre and clear the rim on their + # own - a max over every point called a derrick pointing straight + # into the ground "visible", which is exactly the bug this check + # exists to catch. + var furthest: float = mined.extractor_derrick_tip().length() + var rim: float = 0.0 + for p in mined.shape_points: + rim = maxf(rim, p.length()) + print("derrick tip reaches %.1f, planet outline reaches %.1f" % [furthest, rim]) + if furthest <= rim: + print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (derrick buried inside the planet)") + quit(1) + return true + + # A different planet must NOT inherit the marker: the flag is keyed + # by name, and getting that wrong would light up every planet. + var other: Node2D = load("res://Planet.tscn").instantiate() + other.planet_name = planet_name + " II" + other.shape_seed = 999 + scene.add_child(other) + if other.shows_extractor: + print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (marker leaked onto a different planet)") + quit(1) + return true + + print("extractor -> derrick + badge, and only on the right planet") + print("TEST_PLANET_EXTRACTOR_MARKER: PASS") + quit(0) + return true + return false diff --git a/tests/test_planet_extractor_marker.gd.uid b/tests/test_planet_extractor_marker.gd.uid new file mode 100644 index 0000000..f1bd124 --- /dev/null +++ b/tests/test_planet_extractor_marker.gd.uid @@ -0,0 +1 @@ +uid://dcr7qb1yoewcy From 2eb779e5de7dedbf1ab46f71a8a918787c22c3f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Tue, 11 Aug 2026 15:43:43 +0200 Subject: [PATCH 03/11] Show the extractor derrick the moment it is built, not on respawn Follow-up to #9: the marker was resolved once in Planet._ready() on the assumption that extractors are built on PlanetSurface.tscn, so a Planet node could never outlive a change to its own answer. That assumption was wrong. Extractors are built from SPACE, via BuildController._planet_under_fleet() with the fleet parked on the planet, so the Planet node is already in the tree with _ready() long since run. The derrick therefore did not appear until the planet happened to despawn and respawn - building one read as doing nothing. GameState now emits extractor_added, and Planet keeps its marker in step with it, redrawing only when its own answer actually changes. The test missed this because it spawned a fresh planet after registering the extractor, which is not the order the game does it in. It now keeps a planet alive across the build and asserts the derrick appears on that node. Co-Authored-By: Claude Opus 5 --- GameState.gd | 8 ++++++++ Planet.gd | 19 ++++++++++++++++--- tests/test_planet_extractor_marker.gd | 19 ++++++++++++++++++- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/GameState.gd b/GameState.gd index dfd45e2..28cb105 100644 --- a/GameState.gd +++ b/GameState.gd @@ -270,12 +270,20 @@ func has_extractor_at(planet_name: String) -> bool: return false +signal extractor_added(planet_name: String) + + func add_extractor(planet_name: String, at_position: Vector2) -> void: extractors.append({ "position": at_position, "planet_name": planet_name, "progress": 0.0, }) + # An extractor is built from SPACE, with the fleet parked on the planet - + # so the Planet node is already in the tree with its _ready() long since + # run. Without this it kept showing no derrick until it happened to + # despawn and respawn, which reads as the build having done nothing. + extractor_added.emit(planet_name) func get_hauler_route_time(extractor: Dictionary) -> float: diff --git a/Planet.gd b/Planet.gd index b682030..f3dbc4a 100644 --- a/Planet.gd +++ b/Planet.gd @@ -29,9 +29,12 @@ const EXTRACTOR_MARKER_COLOR: Color = Color(1.0, 0.85, 0.3) const DERRICK_HEIGHT: float = 44.0 const DERRICK_HALF_WIDTH: float = 16.0 -# Resolved once in _ready() rather than polled: building an extractor happens -# on PlanetSurface.tscn, and coming back to space rebuilds Main.tscn from -# scratch, so a Planet node never outlives a change to its own answer. +# Kept in step with GameState.extractor_added rather than polled per frame. +# An extractor is built from SPACE (BuildController._planet_under_fleet(), +# with the fleet parked on the planet), so this node is already in the tree +# with its _ready() long since run when the answer changes - resolving it +# only at _ready() left the planet looking untouched until it happened to +# despawn and respawn. var shows_extractor: bool = false @@ -47,6 +50,16 @@ func _ready() -> void: var radius: float = rng.randf_range(70.0, 100.0) shape_points.append(Vector2(cos(angle), sin(angle)) * radius) shows_extractor = GameState.has_extractor_at(planet_name) + GameState.extractor_added.connect(_on_extractor_added) + queue_redraw() + + +func _on_extractor_added(built_on: String) -> void: + # Only the planet it was actually built on, and only a redraw when the + # answer really changed - every other planet in range gets this too. + if built_on != planet_name or shows_extractor: + return + shows_extractor = true queue_redraw() diff --git a/tests/test_planet_extractor_marker.gd b/tests/test_planet_extractor_marker.gd index 2104da5..ed4b4cb 100644 --- a/tests/test_planet_extractor_marker.gd +++ b/tests/test_planet_extractor_marker.gd @@ -29,6 +29,7 @@ var map_content: Node2D var planet_chunk: Vector2i var planet_name: String = "" var stage: int = 0 +var already_spawned: Node2D func _initialize() -> void: @@ -78,13 +79,29 @@ func _process(_delta: float) -> bool: print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (map badge with no extractor)") quit(1) return true - clean.queue_free() print("no extractor -> no derrick, no badge (planet '%s')" % planet_name) + # Deliberately keep this planet ALIVE across the build. An + # extractor is built from space with the fleet parked on the + # planet (BuildController._planet_under_fleet()), so the node is + # already in the tree with _ready() long since run. Resolving the + # marker only at _ready() meant the planet kept looking untouched + # until it happened to despawn and respawn - the build read as + # having done nothing at all. Spawning a fresh planet afterwards + # (as the checks below do) cannot catch that. + already_spawned = clean game_state.add_extractor(planet_name, WorldGen.chunk_origin(planet_chunk)) stage = 1 return false 1: + # --- the planet that was already on screen when it was built -- + if not already_spawned.shows_extractor: + print("TEST_PLANET_EXTRACTOR_MARKER: FAIL (a planet already in the tree never picked up its derrick - the build reads as doing nothing)") + quit(1) + return true + print("planet already in the tree picked up its derrick on build") + already_spawned.queue_free() + # --- extractor built ---------------------------------------- var mined: Node2D = _spawn_planet() if not mined.shows_extractor: From fc32e26d1b71f0e11533e44834586b8441104dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Tue, 11 Aug 2026 15:55:45 +0200 Subject: [PATCH 04/11] Fleet carries mined ore until it delivers it to the mothership Forgejo #14, part 1 of #12. Rock.gd credited GameState.rocks on pickup, so ore was banked instantly from anywhere on the map and a mining run had nothing at stake. Ore now goes into GameState.fleet_cargo and only becomes spendable when the fleet reaches the mothership. Delivery lives in BuildingField because that node already knows where the buildings are and already answers "is the fleet standing on one?", using the fleet CENTROID - the same stable single-number test Planet and Building use, rather than per-ship collision that flickers as ships jostle in formation. Cargo lives in the GameState autoload so it survives Main.tscn being torn down on every trip to a planet's surface - cargo mined ON a surface would otherwise be dropped the moment the player took off. test_resources_and_buildings asserted the old behaviour (gathering a rock raises `rocks`) and now asserts the new one. That is a spec change, not a weakened test - the gathering assertion itself is intact. Co-Authored-By: Claude Opus 5 --- BuildingField.gd | 13 ++ GameState.gd | 32 ++++- Rock.gd | 4 +- tests/test_fleet_cargo.gd | 179 ++++++++++++++++++++++++++ tests/test_resources_and_buildings.gd | 13 +- 5 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 tests/test_fleet_cargo.gd diff --git a/BuildingField.gd b/BuildingField.gd index cd27afe..ae0ea7d 100644 --- a/BuildingField.gd +++ b/BuildingField.gd @@ -17,6 +17,19 @@ func _ready() -> void: rebuild_all() +func _process(_delta: float) -> void: + # Unloading the fleet's hold (#14). Done here because this is the node + # that already knows where the buildings are and already answers "is the + # fleet standing on one?" - and it uses the fleet CENTROID, the same + # stable single-number test Planet and Building already use, rather than + # per-ship collision that flickers as ships jostle in formation. + if GameState.fleet_cargo <= 0: + return + var under: Node2D = get_building_fleet_is_on() + if under != null and under.building_type == GameState.BUILDING_MOTHERSHIP: + GameState.deliver_cargo() + + func rebuild_all() -> void: for b in spawned: if is_instance_valid(b): diff --git a/GameState.gd b/GameState.gd index 28cb105..b4f6d23 100644 --- a/GameState.gd +++ b/GameState.gd @@ -22,11 +22,41 @@ var explored_chunks: Dictionary = {} # --- Resources ------------------------------------------------------------- -# Rocks mined from asteroid-planet surfaces, spent on buildings and ships. +# Ore that has been BANKED at the mothership. This is the only pool anything +# can be bought with. # Starts stocked for play-testing so buildings can be tried without mining # first - drop this back to 0 for a real playthrough. var rocks: int = 500 +# Ore the fleet is physically carrying and has not delivered yet (#14). Mining +# fills this, not `rocks`: ore is cargo until the fleet brings it home, so a +# long mining run is a real trip with something to lose rather than instant +# income from anywhere on the map. +# +# Lives here, like everything else that has to survive Main.tscn being torn +# down and rebuilt on every trip to a planet's surface - and cargo mined ON a +# surface would otherwise be dropped the moment the player took off. +var fleet_cargo: int = 0 + +signal cargo_delivered(amount: int) + + +func collect_cargo(amount: int = 1) -> void: + fleet_cargo += amount + + +func deliver_cargo() -> int: + # Everything the fleet is carrying, banked at once. Returns how much moved + # so a caller can react to a delivery actually having happened rather than + # to merely being in range. + if fleet_cargo <= 0: + return 0 + var delivered: int = fleet_cargo + rocks += delivered + fleet_cargo = 0 + cargo_delivered.emit(delivered) + return delivered + const MOTHERSHIP_COST: int = 20 const SHIP_CRAFTER_COST: int = 12 const SHIP_COST: int = 6 diff --git a/Rock.gd b/Rock.gd index bc8ffac..a5a85bd 100644 --- a/Rock.gd +++ b/Rock.gd @@ -80,7 +80,9 @@ func _nearest_ship() -> Node2D: func _on_area_entered(area: Area2D) -> void: if area.is_in_group("player_ships"): - GameState.rocks += 1 + # Into the fleet's hold, not the bank (#14). It only counts as + # spendable once the fleet has flown it back to the mothership. + GameState.collect_cargo(1) collected.emit() queue_free() diff --git a/tests/test_fleet_cargo.gd b/tests/test_fleet_cargo.gd new file mode 100644 index 0000000..3b395ec --- /dev/null +++ b/tests/test_fleet_cargo.gd @@ -0,0 +1,179 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_fleet_cargo.gd +# Forgejo #14 (part 1 of #12): mined ore is CARGO the fleet is carrying, not +# instant global income. It only becomes spendable once the fleet flies it +# back to the mothership. +# +# Before this, Rock.gd did `GameState.rocks += 1` on pickup, so ore was +# banked instantly from anywhere on the map and a mining run had nothing to +# lose. +# +# Verifies: +# 1. mining fills the hold and leaves the bank alone +# 2. reaching the mothership banks the lot, and empties the hold +# 3. with no mothership there is nowhere to deliver, so cargo just +# accumulates - it is never quietly banked anyway +# 4. sitting on a DIFFERENT building is not a delivery +# 5. ore is conserved: nothing is created or destroyed in the transfer +# 6. cargo survives the trip to a planet's surface and back, which is +# exactly the trip a mining run is + +var game_state: Node +var scene: Node +var fleet: Node +var field: Node +var stage: int = 0 +var elapsed: float = 0.0 +var banked_before: int = 0 + +const MOTHERSHIP_AT: Vector2 = Vector2(1200.0, 0.0) +const CRAFTER_AT: Vector2 = Vector2(-4000.0, 0.0) + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.rocks = 100 + game_state.fleet_cargo = 0 + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + fleet = scene.get_node("Fleet") + field = scene.get_node("BuildingField") + + +func _park_fleet_on(at: Vector2) -> void: + for ship in fleet.ships: + ship.position = at + fleet.move_to(at) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + + # --- 1. mining fills the hold, not the bank ----------------- + # Through a REAL Rock, flown over by a real ship, rather than by + # calling collect_cargo() directly: the whole change is which + # pool Rock.gd credits on pickup, and a test that calls the new + # API by hand passes just as happily with Rock.gd left untouched. + banked_before = game_state.rocks + var rock: Area2D = load("res://Rock.tscn").instantiate() + rock.build_shape(1) + rock.global_position = fleet.ships[0].global_position + scene.add_child(rock) + elapsed = 0.0 + stage = 5 + return false + 5: + # A tick for the Area2D overlap to register. + if elapsed < 0.25: + return false + if game_state.fleet_cargo != 1: + print("TEST_FLEET_CARGO: FAIL (flying over a rock put %d in the hold)" % game_state.fleet_cargo) + quit(1) + return true + if game_state.rocks != banked_before: + print("TEST_FLEET_CARGO: FAIL (a mined rock was banked instantly: %d -> %d)" % [ + banked_before, game_state.rocks + ]) + quit(1) + return true + print("flew over a real rock -> hold=%d bank=%d (unchanged)" % [ + game_state.fleet_cargo, game_state.rocks + ]) + + game_state.collect_cargo(1) + game_state.collect_cargo(1) + if game_state.fleet_cargo != 3: + print("TEST_FLEET_CARGO: FAIL (mining did not fill the hold: %d)" % game_state.fleet_cargo) + quit(1) + return true + if game_state.rocks != banked_before: + print("TEST_FLEET_CARGO: FAIL (mining banked %d straight away)" % (game_state.rocks - banked_before)) + quit(1) + return true + print("mined 3 -> hold=%d bank=%d (unchanged)" % [game_state.fleet_cargo, game_state.rocks]) + + # --- 3. no mothership means nowhere to deliver -------------- + _park_fleet_on(Vector2.ZERO) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.4: + return false + if game_state.fleet_cargo != 3 or game_state.rocks != banked_before: + print("TEST_FLEET_CARGO: FAIL (delivered with no mothership built)") + quit(1) + return true + print("no mothership -> hold still %d, bank still %d" % [game_state.fleet_cargo, game_state.rocks]) + + # --- 4. a different building is not a delivery point -------- + field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT) + field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT) + _park_fleet_on(CRAFTER_AT) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.4: + return false + if game_state.fleet_cargo != 3: + print("TEST_FLEET_CARGO: FAIL (the ship crafter accepted a delivery)") + quit(1) + return true + print("parked on the crafter -> hold still %d" % game_state.fleet_cargo) + + # --- 2. the mothership banks it ----------------------------- + _park_fleet_on(MOTHERSHIP_AT) + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 0.4: + return false + var total_before: int = banked_before + 3 + print("delivered: hold=%d bank=%d (expected hold=0 bank=%d)" % [ + game_state.fleet_cargo, game_state.rocks, total_before + ]) + if game_state.fleet_cargo != 0: + print("TEST_FLEET_CARGO: FAIL (hold not emptied on delivery)") + quit(1) + return true + # --- 5. conservation ---------------------------------------- + if game_state.rocks != total_before: + print("TEST_FLEET_CARGO: FAIL (ore was created or destroyed in the transfer)") + quit(1) + return true + + # --- 6. cargo survives the scene swap ----------------------- + # Not simulated by loading PlanetSurface.tscn here: the point is + # that cargo lives in the autoload rather than in Main.tscn, so + # tearing Main.tscn down must not touch it. + # Fly off the mothership first, or this new cargo is (correctly) + # banked the moment BuildingField next runs, and the teardown + # check below would be measuring a delivery instead. + _park_fleet_on(CRAFTER_AT) + game_state.collect_cargo(5) + scene.queue_free() + elapsed = 0.0 + stage = 4 + return false + 4: + if elapsed < 0.3: + return false + if game_state.fleet_cargo != 5: + print("TEST_FLEET_CARGO: FAIL (cargo lost when the space scene was torn down: %d)" % game_state.fleet_cargo) + quit(1) + return true + print("cargo survived the scene teardown: hold=%d" % game_state.fleet_cargo) + print("TEST_FLEET_CARGO: PASS") + quit(0) + return true + return false diff --git a/tests/test_resources_and_buildings.gd b/tests/test_resources_and_buildings.gd index a78200c..6069a1b 100644 --- a/tests/test_resources_and_buildings.gd +++ b/tests/test_resources_and_buildings.gd @@ -3,7 +3,9 @@ extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_resources_and_buildings.gd # Covers the resource/building loop end to end: -# 1. flying a ship over a surface rock banks it in GameState +# 1. flying a ship over a surface rock loads it into the fleet's hold +# (banking it needs a trip to the mothership - see #14 and +# tests/test_fleet_cargo.gd) # 2. buildings are paid for, persist in GameState, and respawn with the # space scene (they must survive the trip to a planet and back) # 3. a ship crafter adds a vessel to the fleet for rocks @@ -21,6 +23,7 @@ var mothership_pos: Vector2 = Vector2(900.0, -400.0) func _initialize() -> void: game_state = root.get_node("GameState") game_state.rocks = 0 + game_state.fleet_cargo = 0 game_state.buildings.clear() game_state.has_return_position = false @@ -49,12 +52,14 @@ func _process(delta: float) -> bool: 1: if elapsed < 1.5: return false - if game_state.rocks != 1: - print("rocks=%d after flying a ship onto one" % game_state.rocks) + # Into the hold, not the bank: since #14 ore is cargo until the + # fleet delivers it to the mothership. + if game_state.fleet_cargo != 1: + print("fleet_cargo=%d after flying a ship onto one" % game_state.fleet_cargo) print("TEST_RESOURCES_AND_BUILDINGS: FAIL (rock not gathered)") quit(1) return true - print("gathered rock -> rocks=%d" % game_state.rocks) + print("gathered rock -> hold=%d bank=%d" % [game_state.fleet_cargo, game_state.rocks]) # Bank enough to build with, then move to the space scene. game_state.rocks = 60 From d1b82f4f5aa6fa83d2afa77b25e3417f0e83fb9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Tue, 11 Aug 2026 16:04:40 +0200 Subject: [PATCH 05/11] Show what the fleet is carrying next to what is banked Forgejo #15, part 2 of #12. Since #14 ore is cargo until the fleet delivers it to the mothership, so the banked readout does not move on pickup - mining looked broken, because gathering ore changed no number on screen until the fleet happened to fly home. Three things separate the carried readout from the banked one, since colour alone is a weak signal on a phone in a game that is mostly teal: amber rather than ore-teal (the same amber already used for ore in transit - the extractor derrick, the hauler route), smaller and sitting under the banked figure so it reads as subordinate, and present only while actually carrying, so an empty hold is not a permanent 0 parked beside the real total. Rendered via tests/screenshot.sh and looked at, not only asserted on: headless draws no pixels and cannot say whether two numbers read as two different things. Co-Authored-By: Claude Opus 5 --- CargoCounter.gd | 33 +++++++++ CargoCounter.gd.uid | 1 + Main.tscn | 10 +++ tests/_cargo_readout_preview.gd | 15 ++++ tests/_cargo_readout_preview.tscn | 6 ++ tests/test_cargo_readout.gd | 117 ++++++++++++++++++++++++++++++ tests/test_fleet_cargo.gd.uid | 1 + 7 files changed, 183 insertions(+) create mode 100644 CargoCounter.gd create mode 100644 CargoCounter.gd.uid create mode 100644 tests/_cargo_readout_preview.gd create mode 100644 tests/_cargo_readout_preview.tscn create mode 100644 tests/test_cargo_readout.gd create mode 100644 tests/test_fleet_cargo.gd.uid diff --git a/CargoCounter.gd b/CargoCounter.gd new file mode 100644 index 0000000..bf060f7 --- /dev/null +++ b/CargoCounter.gd @@ -0,0 +1,33 @@ +extends NeonNumber + +# What the fleet is CARRYING, shown under the banked total (#15, part 2 of +# #12). Since #14 ore is cargo until the fleet delivers it to the mothership, +# so without this, mining visibly does nothing - the banked figure above does +# not move until the fleet flies home, which reads as gathering being broken. +# +# Three things separate it from the banked readout, because colour alone is a +# weak signal on a phone screen in a game that is mostly teal: +# - amber rather than ore-teal, the same amber used elsewhere for ore in +# transit (the extractor derrick, the hauler route) +# - smaller, sitting UNDER the banked figure: subordinate to it, because +# this is the number that is not yours yet +# - present only while actually carrying something, so an empty hold is not +# a permanent zero taking up space next to the real total +# +# Reads GameState directly for the same reason ResourceCounter does: it stays +# correct across the space <-> planet-surface scene swap with nobody having +# to push updates into it. + +const CARGO_GLOW: Color = Color(1.0, 0.78, 0.25) +const CARGO_CORE: Color = Color(0.22, 0.12, 0.0) + + +func _ready() -> void: + glow_color = CARGO_GLOW + core_color = CARGO_CORE + + +func _process(_delta: float) -> void: + visible = GameState.fleet_cargo > 0 + if visible: + set_amount(GameState.fleet_cargo) diff --git a/CargoCounter.gd.uid b/CargoCounter.gd.uid new file mode 100644 index 0000000..dfef251 --- /dev/null +++ b/CargoCounter.gd.uid @@ -0,0 +1 @@ +uid://bmrcja23g5oi6 diff --git a/Main.tscn b/Main.tscn index 7cf3a8e..c830b41 100644 --- a/Main.tscn +++ b/Main.tscn @@ -21,6 +21,7 @@ [ext_resource type="Script" path="res://NeonNumber.gd" id="19"] [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"] [sub_resource type="ShaderMaterial" id="1"] shader = ExtResource("5") @@ -89,6 +90,15 @@ offset_bottom = 92 script = ExtResource("16") digit_height = 60.0 +[node name="CargoCounter" type="Control" parent="UI"] +mouse_filter = 2 +offset_left = 28 +offset_top = 96 +offset_right = 320 +offset_bottom = 146 +script = ExtResource("22") +digit_height = 42.0 + [node name="PlanetNameLabel" type="Label" parent="UI"] visible = false offset_left = 24 diff --git a/tests/_cargo_readout_preview.gd b/tests/_cargo_readout_preview.gd new file mode 100644 index 0000000..6e63b47 --- /dev/null +++ b/tests/_cargo_readout_preview.gd @@ -0,0 +1,15 @@ +extends Node2D + +# Preview, not a test. The resource readouts with the fleet carrying ore, so +# the #15 banked-vs-carried split can actually be looked at - headless draws +# no pixels and cannot say whether two numbers read as two different things. +# +# bash tests/screenshot.sh res://tests/_cargo_readout_preview.tscn /tmp/hud.png 20 720x1280 + + +func _ready() -> void: + GameState.buildings.clear() + GameState.extractors.clear() + GameState.rocks = 128 + GameState.fleet_cargo = 34 + add_child(load("res://Main.tscn").instantiate()) diff --git a/tests/_cargo_readout_preview.tscn b/tests/_cargo_readout_preview.tscn new file mode 100644 index 0000000..60c3692 --- /dev/null +++ b/tests/_cargo_readout_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_cargo_readout_preview.gd" id="1"] + +[node name="CargoReadoutPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/test_cargo_readout.gd b/tests/test_cargo_readout.gd new file mode 100644 index 0000000..03b2500 --- /dev/null +++ b/tests/test_cargo_readout.gd @@ -0,0 +1,117 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_cargo_readout.gd +# Forgejo #15 (part 2 of #12): what the fleet is carrying has to be visible +# separately from what is banked. +# +# Since #14, ore is cargo until the fleet delivers it to the mothership. The +# banked readout does not move on pickup, so without a second readout mining +# looks broken - you gather ore and no number on screen changes. +# +# --headless has no GPU and draws no pixels, so this asserts on the two +# readouts' state, not on their appearance. tests/screenshot.sh covers +# whether they are legible. +# +# Verifies: +# 1. the banked readout keeps showing the BANK, not the total +# 2. the cargo readout shows the hold, and appears only while carrying +# 3. an empty hold hides it rather than parking a permanent 0 beside the +# real total +# 4. delivering moves the number from one readout to the other +# 5. the two are actually told apart on screen, rather than being the same +# number twice in the same colour at the same place + +var game_state: Node +var scene: Node +var banked: Control +var cargo: Control +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.rocks = 42 + game_state.fleet_cargo = 0 + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + banked = scene.get_node("UI/ResourceCounter") + cargo = scene.get_node("UI/CargoCounter") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.2: + return false + # --- 3. nothing carried -> no cargo readout ----------------- + if cargo.visible: + print("TEST_CARGO_READOUT: FAIL (cargo readout showing with an empty hold)") + quit(1) + return true + if banked.amount != 42: + print("TEST_CARGO_READOUT: FAIL (banked readout shows %d, bank is 42)" % banked.amount) + quit(1) + return true + print("empty hold -> cargo readout hidden, banked shows %d" % banked.amount) + + game_state.collect_cargo(7) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.2: + return false + # --- 1 + 2. carried and banked are separate numbers --------- + if not cargo.visible: + print("TEST_CARGO_READOUT: FAIL (carrying 7 and the cargo readout is hidden)") + quit(1) + return true + if cargo.amount != 7: + print("TEST_CARGO_READOUT: FAIL (cargo readout shows %d, hold is 7)" % cargo.amount) + quit(1) + return true + if banked.amount != 42: + print("TEST_CARGO_READOUT: FAIL (banked readout moved to %d on a pickup - it is showing the total, not the bank)" % banked.amount) + quit(1) + return true + print("carrying 7 -> cargo=%d banked=%d" % [cargo.amount, banked.amount]) + + # --- 5. genuinely distinguishable --------------------------- + if cargo.glow_color == banked.glow_color: + print("TEST_CARGO_READOUT: FAIL (both readouts drawn in the same colour)") + quit(1) + return true + if cargo.position == banked.position: + print("TEST_CARGO_READOUT: FAIL (both readouts in the same place)") + quit(1) + return true + print("distinct: colours %s vs %s, at %s vs %s" % [ + cargo.glow_color, banked.glow_color, cargo.position, banked.position + ]) + + # --- 4. delivery moves it across ---------------------------- + game_state.deliver_cargo() + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.2: + return false + if cargo.visible: + print("TEST_CARGO_READOUT: FAIL (cargo readout still showing after delivery)") + quit(1) + return true + if banked.amount != 49: + print("TEST_CARGO_READOUT: FAIL (banked shows %d after delivering 7 into 42)" % banked.amount) + quit(1) + return true + print("delivered -> cargo hidden, banked shows %d" % banked.amount) + print("TEST_CARGO_READOUT: PASS") + quit(0) + return true + return false diff --git a/tests/test_fleet_cargo.gd.uid b/tests/test_fleet_cargo.gd.uid new file mode 100644 index 0000000..3c5062a --- /dev/null +++ b/tests/test_fleet_cargo.gd.uid @@ -0,0 +1 @@ +uid://c2y4e2f1rgera From 3573ccf44f431e65f064e5e1198d1b07e6d52b61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Tue, 11 Aug 2026 16:18:44 +0200 Subject: [PATCH 06/11] Buildings produce from their own stock, and queue orders until supplied Forgejo #16, part 3 of #12. BuildController deducted the cost from the bank at the moment of purchase and refused outright with "Not enough rocks", so nothing ever had to be delivered anywhere. Each building now carries its own stock, and an order it cannot pay for yet is QUEUED and completes by itself once ore arrives. Records get an `id` because a building has to be referable across the space <-> planet-surface scene swap, when every Building NODE has been destroyed and rebuilt. Position would nearly work and then fail on two buildings placed on the same spot. Production advances in the GameState autoload, the same way extractor haulers already do, so an order can complete while the player is down a gravity well. A finished vessel becomes `ships_owed` rather than being handed straight to a Fleet that may not exist, and the fleet claims what it is owed when it next has room. The craft menu remembers which crafter it was opened for: _process() clears current_building while any menu is open, so by the next frame the menu would no longer know. It shows the ore delivered HERE rather than the bank, since showing the bank would promise something the crafter cannot actually build. Ship crafting cannot be supplied until #17 lands - nothing yet moves ore into a building. Co-Authored-By: Claude Opus 5 --- BuildController.gd | 32 +++- Building.gd | 4 + BuildingField.gd | 1 + Fleet.gd | 14 ++ GameState.gd | 98 +++++++++++- tests/test_building_stock_queue.gd | 233 +++++++++++++++++++++++++++++ 6 files changed, 371 insertions(+), 11 deletions(-) create mode 100644 tests/test_building_stock_queue.gd diff --git a/BuildController.gd b/BuildController.gd index 1872226..f1cf834 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -83,6 +83,8 @@ const BUILDING_INFO: Dictionary = { # Which structure the popup is currently offering to build, or "" when it is # only explaining something that cannot be built right now. var pending_build_type: String = "" +# Which crafter the craft menu is showing (see _on_action_pressed). +var craft_building_id: int = 0 var current_building: Node2D = null var pending_building: Node2D = null @@ -153,6 +155,11 @@ func _process(delta: float) -> void: build_extractor_button.enabled = _planet_under_fleet() != null \ and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR) + if craft_menu.visible: + # Live, so a delivery arriving while the menu is open visibly works + # the queue down instead of looking stuck. + _refresh_craft_status() + if world_map.visible or build_menu.visible or craft_menu.visible \ or formation_menu.visible or skill_menu.visible: current_building = null @@ -420,19 +427,30 @@ func _on_unlock_pressed() -> void: func _refresh_craft_status() -> void: - craft_status.text = "Interceptor %d rocks You have %d Fleet %d/%d" % [ - GameState.SHIP_COST, GameState.rocks, fleet.ships.size(), GameState.max_fleet_size() + # "Here" is the ore delivered to THIS crafter, not the bank: since #16 a + # building builds out of its own stock, and showing the bank would say + # you can afford something the crafter cannot actually make. + 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() ] func _on_craft_interceptor() -> void: - if fleet.ships.size() >= GameState.max_fleet_size(): + if craft_building_id == 0: + 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) \ + >= GameState.max_fleet_size(): craft_status.text = "Fleet is full (%d slots)" % GameState.max_fleet_size() return - if not GameState.spend(GameState.SHIP_COST): - craft_status.text = "Not enough rocks (need %d, have %d)" % [GameState.SHIP_COST, GameState.rocks] - return - fleet.add_ship() + # No affordability check: an order the crafter cannot pay for yet is + # QUEUED, and completes by itself once ore is delivered here (#16). + GameState.queue_order(craft_building_id) _refresh_craft_status() diff --git a/Building.gd b/Building.gd index 5164b70..6dfc0b4 100644 --- a/Building.gd +++ b/Building.gd @@ -19,6 +19,10 @@ extends Node2D # never changes were a real framerate cost earlier on. @export var building_type: String = "mothership" +# Which GameState.buildings record this node is showing. Set by +# BuildingField when it spawns, so a node can be traced back to the stock +# and production queue that survive the scene swap (#16). +@export var building_id: int = 0 @export var interact_radius: float = 150.0 const MOTHERSHIP_COLOR: Color = Color(0.45, 0.9, 0.55) diff --git a/BuildingField.gd b/BuildingField.gd index ae0ea7d..076e5be 100644 --- a/BuildingField.gd +++ b/BuildingField.gd @@ -56,6 +56,7 @@ func _spawn(record: Dictionary) -> void: var b: Node2D = building_scene.instantiate() b.position = record.position b.building_type = record.type + b.building_id = int(record.get("id", 0)) # Deferred for the same reason as the other field spawners: this can run # from inside a parent's _ready() chain, where a direct add_child() is # rejected while the tree is still setting up children. diff --git a/Fleet.gd b/Fleet.gd index 8b4475b..6abccd5 100644 --- a/Fleet.gd +++ b/Fleet.gd @@ -113,6 +113,9 @@ func move_in_direction(direction: Vector2) -> void: func _process(delta: float) -> void: + # Before the steering early-out: a ship the crafter has already built and + # paid for is owed whether or not the fleet happens to be flying (#16). + _claim_owed_ships() if steer_direction == Vector2.ZERO: return # The anchor - the fleet's formation centre - travels at cruise_speed. @@ -122,6 +125,17 @@ func _process(delta: float) -> void: _assign_slot_targets() +func _claim_owed_ships() -> void: + # Production completes in the GameState autoload, which may well happen + # while the player is on a planet's surface with no Fleet node in + # existence to hand a vessel to. Those ships wait as `ships_owed` until a + # fleet with room can take them. + while GameState.ships_owed > 0 and ships.size() < GameState.max_fleet_size(): + if not add_ship(): + return + GameState.ships_owed -= 1 + + func _assign_slot_targets() -> void: for i in range(ships.size()): ships[i].set_move_target(fleet_target + formation_offsets[i]) diff --git a/GameState.gd b/GameState.gd index b4f6d23..f214914 100644 --- a/GameState.gd +++ b/GameState.gd @@ -211,8 +211,96 @@ func reset_progression() -> void: unlocked.clear() -func add_building(type: String, at_position: Vector2) -> void: - buildings.append({"type": type, "position": at_position}) +# --- building stock and production queue (#16) ----------------------------- + +# Since #12, a building cannot just deduct from the bank at the moment of +# purchase: what it produces has to be paid for out of resources physically +# delivered to it. So each building carries its own `stock`, and an order it +# cannot afford yet is QUEUED rather than refused, and completes by itself +# once the stock turns up. +# +# `id` exists because a building has to be referable across the space <-> +# planet-surface scene swap, when every Building NODE has been destroyed and +# rebuilt. Position would nearly work and then fail on two buildings placed +# on the same spot. +var next_building_id: int = 1 + +# Ships that have been paid for and built but not yet handed to the fleet. +# Production is simulated here, in the autoload, the same way extractor +# haulers are - so an order can complete while the player is down on a +# planet's surface, where there is no Fleet node to hand a ship to. The fleet +# claims them when it next can. +var ships_owed: int = 0 + +signal order_completed(building_id: int, type: String) + + +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, + }) + return id + + +func find_building(id: int) -> Dictionary: + for b in buildings: + if b.get("id", 0) == id: + return b + 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 + + +func building_stock(id: int) -> int: + var b: Dictionary = find_building(id) + return 0 if b.is_empty() else int(b.get("stock", 0)) + + +func pending_orders(id: int) -> int: + var b: Dictionary = find_building(id) + return 0 if b.is_empty() else int(b.get("queued", 0)) + + +func deposit_to_building(id: int, amount: int) -> int: + if amount <= 0: + return 0 + var b: Dictionary = find_building(id) + if b.is_empty(): + return 0 + b.stock = int(b.get("stock", 0)) + amount + return amount + + +func queue_order(id: int) -> bool: + var b: Dictionary = find_building(id) + if b.is_empty() or production_cost(b.type) <= 0: + return false + b.queued = int(b.get("queued", 0)) + 1 + return true + + +func _advance_production() -> void: + for b in buildings: + var cost: int = production_cost(b.type) + if cost <= 0: + 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: + b.stock = int(b.stock) - cost + b.queued = int(b.queued) - 1 + if b.type == BUILDING_SHIP_CRAFTER: + ships_owed += 1 + order_completed.emit(int(b.id), String(b.type)) func has_mothership() -> bool: @@ -340,8 +428,10 @@ func get_hauler_position(extractor: Dictionary) -> Variant: func _process(delta: float) -> void: - # Runs in the autoload, so extraction keeps earning while the player is - # on a planet's surface or nowhere near the route. + # Runs in the autoload, so extraction keeps earning - and queued orders + # keep completing - while the player is on a planet's surface or nowhere + # near either. + _advance_production() if extractors.is_empty(): return for e in extractors: diff --git a/tests/test_building_stock_queue.gd b/tests/test_building_stock_queue.gd new file mode 100644 index 0000000..6ab29c3 --- /dev/null +++ b/tests/test_building_stock_queue.gd @@ -0,0 +1,233 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_building_stock_queue.gd +# Forgejo #16 (part 3 of #12): a building produces out of its OWN stock, and +# an order it cannot pay for yet is queued rather than refused. +# +# Before this, BuildController deducted from the bank at the moment of +# purchase and refused outright with "Not enough rocks", so nothing ever had +# to be delivered anywhere. +# +# Verifies: +# 1. ordering with an empty stock queues instead of failing, and produces +# nothing yet +# 2. it does NOT quietly take the cost out of the bank - the whole point is +# that the building has to be supplied +# 3. delivering enough stock completes the order on its own +# 4. one delivery covering several queued orders clears them all, rather +# than dribbling out one per frame +# 5. a part-delivery is not enough: stock below the cost leaves the order +# queued and the ore untouched +# 6. stock and queue survive the space scene being torn down (the trip to +# a planet's surface) +# 7. production completes with NO fleet in existence, and the fleet picks +# the vessel up when it comes back - orders must not depend on which +# scene the player is standing in + +var game_state: Node +var scene: Node +var fleet: Node +var field: Node +var crafter_id: int = 0 +var stage: int = 0 +var elapsed: float = 0.0 +var ships_at_start: int = 0 +var bank_at_start: int = 0 +var ticks: int = 0 + +const CRAFTER_AT: Vector2 = Vector2(900.0, 0.0) + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.rocks = 500 + game_state.fleet_cargo = 0 + game_state.ships_owed = 0 + # Room to grow: the default cap is BASE_FLEET_SIZE (5) and the fleet + # starts at exactly 5, so without this every completed order would have + # nowhere to put its vessel and the test would be measuring the cap + # rather than the queue. + game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"] + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + fleet = scene.get_node("Fleet") + field = scene.get_node("BuildingField") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + fleet.spawn_ships(2, Vector2.ZERO) + field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT) + crafter_id = int(game_state.buildings[-1].id) + ships_at_start = fleet.ships.size() + bank_at_start = game_state.rocks + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.2: + return false + # --- 1 + 2. an unaffordable order queues, and costs nothing -- + if not game_state.queue_order(crafter_id): + print("TEST_BUILDING_STOCK_QUEUE: FAIL (the crafter refused an order outright)") + quit(1) + return true + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.3: + return false + if game_state.pending_orders(crafter_id) != 1: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (order did not stay queued: %d pending)" % game_state.pending_orders(crafter_id)) + quit(1) + return true + if fleet.ships.size() != ships_at_start: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (a vessel appeared with an empty stock)") + quit(1) + return true + if game_state.rocks != bank_at_start: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (queueing took %d out of the bank - the building is supposed to be supplied)" % (bank_at_start - game_state.rocks)) + quit(1) + return true + print("empty stock -> queued=%d ships=%d bank=%d (untouched)" % [ + game_state.pending_orders(crafter_id), fleet.ships.size(), game_state.rocks + ]) + + # --- 5. a part-delivery is not enough ----------------------- + game_state.deposit_to_building(crafter_id, GameState.SHIP_COST - 1) + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 0.3: + return false + if game_state.pending_orders(crafter_id) != 1 or fleet.ships.size() != ships_at_start: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (produced on a part-delivery of %d against a cost of %d)" % [ + GameState.SHIP_COST - 1, GameState.SHIP_COST + ]) + quit(1) + return true + if game_state.building_stock(crafter_id) != GameState.SHIP_COST - 1: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (part-delivered ore was consumed anyway)") + quit(1) + return true + print("part delivery (%d of %d) -> still queued, ore intact" % [ + game_state.building_stock(crafter_id), GameState.SHIP_COST + ]) + + # --- 3. topping it up completes the order ------------------- + game_state.deposit_to_building(crafter_id, 1) + elapsed = 0.0 + stage = 4 + return false + 4: + if elapsed < 0.4: + return false + if game_state.pending_orders(crafter_id) != 0: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (order not completed once the stock arrived)") + quit(1) + return true + if fleet.ships.size() != ships_at_start + 1: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (no vessel delivered: %d -> %d)" % [ + ships_at_start, fleet.ships.size() + ]) + quit(1) + return true + if game_state.building_stock(crafter_id) != 0: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (stock not consumed: %d left)" % game_state.building_stock(crafter_id)) + quit(1) + return true + print("supplied -> ships %d->%d, stock spent" % [ships_at_start, fleet.ships.size()]) + + # --- 4. one delivery clears ALL the orders it covers --------- + # Measured in TICKS, not seconds, and with more orders than the + # ticks allowed: production that handled one order per frame + # would still clear them all inside any generous wall-clock wait, + # so a timed check cannot tell "cleared at once" from "dribbled + # out". Three orders, two ticks - only clearing them together + # passes. + ships_at_start = fleet.ships.size() + game_state.queue_order(crafter_id) + game_state.queue_order(crafter_id) + game_state.queue_order(crafter_id) + game_state.deposit_to_building(crafter_id, GameState.SHIP_COST * 3) + ticks = 0 + stage = 5 + return false + 5: + ticks += 1 + if ticks < 2: + return false + if game_state.pending_orders(crafter_id) != 0 or fleet.ships.size() != ships_at_start + 3: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (a delivery covering 3 orders left %d queued after %d ticks, ships %d->%d)" % [ + game_state.pending_orders(crafter_id), ticks, ships_at_start, fleet.ships.size() + ]) + quit(1) + return true + print("one delivery cleared 3 orders in %d ticks -> ships %d->%d" % [ + ticks, ships_at_start, fleet.ships.size() + ]) + + # --- 6 + 7. no scene, no fleet: production still completes -- + # The scene has to be GONE before the order is placed. With a + # fleet still alive and under its cap it claims the vessel the + # same frame production finishes, which would leave ships_owed + # back at 0 and prove nothing about orders surviving without it. + ships_at_start = fleet.ships.size() + scene.queue_free() + elapsed = 0.0 + stage = 6 + return false + 6: + if elapsed < 0.3: + return false + if is_instance_valid(fleet): + return false + game_state.queue_order(crafter_id) + game_state.deposit_to_building(crafter_id, GameState.SHIP_COST) + elapsed = 0.0 + stage = 61 + return false + 61: + if elapsed < 0.4: + return false + # The queue and stock live in the autoload, so tearing the space + # scene down must not touch them - and the order must complete + # even with no Fleet node anywhere to hand the vessel to. + if game_state.pending_orders(crafter_id) != 0: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (order stalled once the space scene was gone)") + quit(1) + return true + if game_state.ships_owed < 1: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (vessel completed with no fleet present was lost, not owed)") + quit(1) + return true + print("no scene at all -> order completed, %d vessel owed" % game_state.ships_owed) + + # Come back to space: the fleet should claim what it is owed. + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + fleet = scene.get_node("Fleet") + elapsed = 0.0 + stage = 8 + return false + 8: + if elapsed < 0.5: + return false + if game_state.ships_owed != 0: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (returning fleet never claimed its %d owed vessel(s))" % game_state.ships_owed) + quit(1) + return true + print("fleet returned and claimed the owed vessel (ships=%d)" % fleet.ships.size()) + print("TEST_BUILDING_STOCK_QUEUE: PASS") + quit(0) + return true + return false From ff162faef5e64ed29ed2ca55d351576ab0f97fff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Tue, 11 Aug 2026 16:36:11 +0200 Subject: [PATCH 07/11] Supply a building by hand: withdraw at the mothership, unload on arrival Forgejo #17, part 4 of #12. #16 lets an order queue waiting for ore, so something has to be able to fulfil one. Until transporters exist (#19) this is the only route, which is why it lands before them rather than after: otherwise a player with a queued order and no Transport Hub is stuck forever. A "Load ore" button at the mothership moves a batch from the bank into the fleet's hold, and opening a building hands it whatever the fleet is carrying. Delivery at the mothership is now EDGE triggered on arrival rather than running every frame the fleet is parked there. Continuous delivery made withdrawing impossible - ore loaded into the hold was banked again on the very next frame, so the button would have looked broken. A batch rather than the whole bank, because the hold is the part that can be lost and emptying the bank in one tap would also strand research, which still spends from it. Both new logistics tests now clear existing asteroids: disabling AsteroidField only stops new chunks spawning, and a drifting rock destroying a parked ship changed the fleet size the tests assert on. That was a real intermittent failure, seen once in a full run. Co-Authored-By: Claude Opus 5 --- BuildController.gd | 15 +++ BuildingField.gd | 14 +- GameState.gd | 29 ++++ Main.tscn | 22 ++++ tests/test_building_stock_queue.gd | 14 ++ tests/test_manual_supply_route.gd | 205 +++++++++++++++++++++++++++++ 6 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 tests/test_manual_supply_route.gd diff --git a/BuildController.gd b/BuildController.gd index f1cf834..4747cf1 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -39,6 +39,8 @@ extends Node2D @onready var craft_interceptor_button: IconButton = get_node("../UI/CraftMenu/InterceptorButton") @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") +@onready var load_ore_label: Label = get_node("../UI/SkillMenu/LoadOreLabel") @onready var skill_menu: Control = get_node("../UI/SkillMenu") @onready var skill_tree: Control = get_node("../UI/SkillMenu/Tree") @@ -104,6 +106,7 @@ func _ready() -> void: action_button.pressed.connect(_on_action_pressed) craft_interceptor_button.pressed.connect(_on_craft_interceptor) + load_ore_button.pressed.connect(_on_load_ore) craft_close_button.pressed.connect(func(): craft_menu.visible = false) formation_close_button.pressed.connect(func(): formation_menu.visible = false) @@ -211,6 +214,7 @@ func _on_action_pressed() -> void: return if current_building.building_type == GameState.BUILDING_MOTHERSHIP: skill_menu.visible = true + _refresh_load_ore() _refresh_skill_menu() elif current_building.building_type == GameState.BUILDING_SHIP_CRAFTER: craft_menu.visible = true @@ -454,6 +458,17 @@ func _on_craft_interceptor() -> void: _refresh_craft_status() +func _refresh_load_ore() -> void: + 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() + + +func _on_load_ore() -> void: + GameState.withdraw_to_fleet() + _refresh_load_ore() + + func _on_formation_changed() -> void: fleet.apply_formation() diff --git a/BuildingField.gd b/BuildingField.gd index 076e5be..4db4fac 100644 --- a/BuildingField.gd +++ b/BuildingField.gd @@ -11,6 +11,9 @@ extends Node2D var building_scene: PackedScene = preload("res://Building.tscn") var spawned: Array = [] +# Whether the fleet was already standing on the mothership last frame - see +# the edge-triggered delivery in _process(). +var _was_on_mothership: bool = false func _ready() -> void: @@ -23,11 +26,16 @@ func _process(_delta: float) -> void: # fleet standing on one?" - and it uses the fleet CENTROID, the same # stable single-number test Planet and Building already use, rather than # per-ship collision that flickers as ships jostle in formation. - if GameState.fleet_cargo <= 0: - return + # EDGE-triggered on arrival, not continuous. Delivering every frame the + # fleet is parked on the mothership would make withdrawing impossible: + # ore loaded into the hold at the mothership (#17) would be banked again + # the very next frame, and the button would look broken. var under: Node2D = get_building_fleet_is_on() - if under != null and under.building_type == GameState.BUILDING_MOTHERSHIP: + var on_mothership: bool = under != null \ + and under.building_type == GameState.BUILDING_MOTHERSHIP + if on_mothership and not _was_on_mothership and GameState.fleet_cargo > 0: GameState.deliver_cargo() + _was_on_mothership = on_mothership func rebuild_all() -> void: diff --git a/GameState.gd b/GameState.gd index f214914..a7a793f 100644 --- a/GameState.gd +++ b/GameState.gd @@ -45,6 +45,35 @@ func collect_cargo(amount: int = 1) -> void: fleet_cargo += amount +# One press of "load ore" at the mothership. A batch rather than the whole +# bank: the hold is the part that can be lost, and emptying the bank in one +# tap would also strand research, which still spends from it. +const CARGO_LOAD_BATCH: int = 25 + + +func withdraw_to_fleet(amount: int = CARGO_LOAD_BATCH) -> int: + # Banked ore back into the hold, so the fleet can carry it to a building + # that is waiting on it (#17). + var taken: int = mini(amount, rocks) + if taken <= 0: + return 0 + rocks -= taken + fleet_cargo += taken + return taken + + +func unload_into_building(id: int) -> int: + # Everything the fleet is carrying, into this building's stock. Returns + # how much moved so a caller can tell the player something happened. + if fleet_cargo <= 0: + return 0 + var moved: int = fleet_cargo + if deposit_to_building(id, moved) <= 0: + return 0 + fleet_cargo = 0 + return moved + + func deliver_cargo() -> int: # Everything the fleet is carrying, banked at once. Returns how much moved # so a caller can react to a delivery actually having happened rather than diff --git a/Main.tscn b/Main.tscn index c830b41..dca95e7 100644 --- a/Main.tscn +++ b/Main.tscn @@ -761,6 +761,28 @@ 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_right = 0.5 +offset_left = -256.0 +offset_top = 1090.0 +offset_right = -108.0 +offset_bottom = 1238.0 +script = ExtResource("10") +icon = 4 +glow_color = Color(1, 0.78, 0.25, 1) + +[node name="LoadOreLabel" type="Label" parent="UI/SkillMenu"] +anchor_left = 0.5 +anchor_right = 0.5 +offset_left = -300.0 +offset_top = 1246.0 +offset_right = -64.0 +offset_bottom = 1296.0 +horizontal_alignment = 1 +autowrap_mode = 2 +text = "Load ore" + [node name="CloseButton" type="Control" parent="UI/SkillMenu"] anchor_left = 1.0 anchor_right = 1.0 diff --git a/tests/test_building_stock_queue.gd b/tests/test_building_stock_queue.gd index 6ab29c3..d116fcb 100644 --- a/tests/test_building_stock_queue.gd +++ b/tests/test_building_stock_queue.gd @@ -63,6 +63,7 @@ func _process(delta: float) -> bool: 0: scene.get_node("AsteroidField").set_process(false) scene.get_node("EnemySpawner").set_process(false) + _clear_hazards() fleet.spawn_ships(2, Vector2.ZERO) field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT) crafter_id = int(game_state.buildings[-1].id) @@ -231,3 +232,16 @@ func _process(delta: float) -> bool: quit(0) return true return false + + +func _clear_hazards() -> void: + # set_process(false) on AsteroidField only stops NEW chunks spawning - + # rocks already in the world keep drifting and can destroy a parked ship, + # which silently changes the fleet size this test asserts on. This is a + # logistics test, not a survival one. + for a in get_nodes_in_group("asteroids"): + if is_instance_valid(a): + a.queue_free() + for e in get_nodes_in_group("enemies"): + if is_instance_valid(e): + e.queue_free() diff --git a/tests/test_manual_supply_route.gd b/tests/test_manual_supply_route.gd new file mode 100644 index 0000000..338fd3e --- /dev/null +++ b/tests/test_manual_supply_route.gd @@ -0,0 +1,205 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_manual_supply_route.gd +# Forgejo #17 (part 4 of #12): the player can supply a building by hand - +# withdraw banked ore into the fleet's hold at the mothership, fly it over, +# and have the building take it. +# +# This is what keeps the game unblockable. #16 lets an order queue waiting +# for ore, and until transporters exist (#19) there is otherwise no way to +# fulfil one - a player with a queued order and no Transport Hub would be +# stuck forever. +# +# Verifies: +# 1. withdrawing moves ore bank -> hold, and conserves the total +# 2. withdrawing is capped by what is actually banked (no overdraft) +# 3. arriving at the mothership banks the hold, but doing so is EDGE +# triggered - ore withdrawn while parked there is not instantly taken +# back, which would make the button look broken +# 4. a building takes the fleet's whole hold when opened +# 5. end to end: a queued order the bank cannot reach is fulfilled using +# only the fleet, with no Transport Hub in existence +# 6. nothing is created or destroyed anywhere along the route + +var game_state: Node +var scene: Node +var fleet: Node +var field: Node +var controller: Node +var crafter_id: int = 0 +var stage: int = 0 +var elapsed: float = 0.0 +var ships_before: int = 0 +var total_before: int = 0 + +const MOTHERSHIP_AT: Vector2 = Vector2(0.0, 0.0) +const CRAFTER_AT: Vector2 = Vector2(3000.0, 0.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)) + return sum + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.rocks = 40 + game_state.fleet_cargo = 0 + game_state.ships_owed = 0 + game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"] + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + fleet = scene.get_node("Fleet") + field = scene.get_node("BuildingField") + controller = scene.get_node("BuildController") + + +func _park_fleet_on(at: Vector2) -> void: + for ship in fleet.ships: + ship.position = at + fleet.move_to(at) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + _clear_hazards() + fleet.spawn_ships(2, MOTHERSHIP_AT) + field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT) + field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT) + crafter_id = int(game_state.buildings[-1].id) + total_before = _total() + _park_fleet_on(CRAFTER_AT) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.3: + return false + # --- 5. an order the crafter cannot pay for ----------------- + ships_before = fleet.ships.size() + game_state.queue_order(crafter_id) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.3: + return false + if game_state.pending_orders(crafter_id) != 1: + print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (order did not queue)") + quit(1) + return true + + # --- 1 + 2. withdraw at the mothership ---------------------- + _park_fleet_on(MOTHERSHIP_AT) + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 0.3: + return false + var bank_before: int = game_state.rocks + var took: int = game_state.withdraw_to_fleet(GameState.SHIP_COST) + if took != GameState.SHIP_COST or game_state.fleet_cargo != GameState.SHIP_COST: + print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (withdrew %d into a hold of %d)" % [took, game_state.fleet_cargo]) + quit(1) + return true + if game_state.rocks != bank_before - GameState.SHIP_COST: + print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (bank did not pay for the withdrawal)") + quit(1) + return true + print("withdrew %d at the mothership -> bank=%d hold=%d" % [ + took, game_state.rocks, game_state.fleet_cargo + ]) + elapsed = 0.0 + stage = 4 + return false + 4: + # --- 3. still parked on the mothership ---------------------- + if elapsed < 0.5: + return false + if game_state.fleet_cargo != GameState.SHIP_COST: + print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (ore withdrawn while parked on the mothership was instantly banked again - delivery is not edge triggered)") + quit(1) + return true + print("still parked -> hold kept its %d (delivery is edge triggered)" % game_state.fleet_cargo) + + # --- 4. carry it to the crafter and open it ----------------- + _park_fleet_on(CRAFTER_AT) + elapsed = 0.0 + stage = 5 + return false + 5: + if elapsed < 0.6: + return false + var moved: int = game_state.unload_into_building(crafter_id) + if moved != GameState.SHIP_COST or game_state.fleet_cargo != 0: + print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (unloaded %d, hold left at %d)" % [moved, game_state.fleet_cargo]) + quit(1) + return true + print("unloaded %d into the crafter" % moved) + elapsed = 0.0 + stage = 6 + return false + 6: + if elapsed < 0.5: + return false + # --- 5. the queued order completed off the fleet's own ore --- + if game_state.pending_orders(crafter_id) != 0: + print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (order still queued after a hand delivery)") + quit(1) + return true + if fleet.ships.size() != ships_before + 1: + print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (no vessel: %d -> %d)" % [ships_before, fleet.ships.size()]) + quit(1) + return true + print("queued order fulfilled by hand, no transport hub: ships %d->%d" % [ + ships_before, fleet.ships.size() + ]) + + # --- 6. conservation ---------------------------------------- + var expected: int = total_before - GameState.SHIP_COST + if _total() != expected: + print("bank=%d hold=%d stocks total=%d, expected %d" % [ + game_state.rocks, game_state.fleet_cargo, _total(), expected + ]) + print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (ore created or destroyed along the route)") + quit(1) + return true + print("ore conserved: %d -> %d, exactly the %d the vessel cost" % [ + total_before, _total(), GameState.SHIP_COST + ]) + + # --- 2. no overdraft ---------------------------------------- + game_state.rocks = 3 + var over: int = game_state.withdraw_to_fleet(999) + if over != 3 or game_state.rocks != 0: + print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (overdrew: took %d from a bank of 3, leaving %d)" % [over, game_state.rocks]) + quit(1) + return true + print("withdrawing 999 from a bank of 3 took %d" % over) + print("TEST_MANUAL_SUPPLY_ROUTE: PASS") + quit(0) + return true + return false + + +func _clear_hazards() -> void: + # set_process(false) on AsteroidField only stops NEW chunks spawning - + # rocks already in the world keep drifting and can destroy a parked ship, + # which silently changes the fleet size this test asserts on. This is a + # logistics test, not a survival one. + for a in get_nodes_in_group("asteroids"): + if is_instance_valid(a): + a.queue_free() + for e in get_nodes_in_group("enemies"): + if is_instance_valid(e): + e.queue_free() From 823828f3b31cac568047f8b8c2441dac03a6d1ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Tue, 11 Aug 2026 17:45:07 +0200 Subject: [PATCH 08/11] Transport Hub building Forgejo #18, part 5 of #12. The building only - the vessels that do the hauling are #19. Placeable, costed at 30, gated behind a new "Freight Handling" research node hanging off Logistics, with its own info popup, build-menu glyph and map marker. Drawn as an octagonal dock ring with berth arms and a squared-off freight core: deliberately radial-but-flat so it does not read as a small mothership, which is the only structure with swept arms. Its stationed transports are NeonPiece children of one rotating holder, per #10 - the geometry is rasterised once and only the holder's rotation changes, so the traffic costs no redraws. Building._draw()'s match has a catch-all that renders anything unrecognised as a ship crafter, so a missing case is silent and looks almost right. The test checks for the hub's own _hub_traffic holder rather than trusting that something plausible appeared - which caught exactly that: a _ready() patch had silently not applied and the hub was rendering through the crafter branch. The build menu grew a third row. Every button, label and price moved: the old row occupied 332px against a 372px pitch, and three of those do not fit above a 1280px screen. Rows are now shorter (128px buttons, 40px price digits) at a 290px pitch, ending at y=1176. Rendered and looked at - the first attempt tightened the pitch alone and left every price overlapping the row beneath it, which no headless test would have seen. Co-Authored-By: Claude Opus 5 --- BuildController.gd | 15 +++ Building.gd | 65 +++++++++++ GameState.gd | 9 ++ IconButton.gd | 30 ++++- Main.tscn | 138 +++++++++++++++-------- WorldMapContent.gd | 5 + tests/_build_menu_preview.gd | 20 ++++ tests/_build_menu_preview.tscn | 6 + tests/_ingame_buildings_preview.gd | 7 +- tests/test_transport_hub_building.gd | 157 +++++++++++++++++++++++++++ 10 files changed, 400 insertions(+), 52 deletions(-) create mode 100644 tests/_build_menu_preview.gd create mode 100644 tests/_build_menu_preview.tscn create mode 100644 tests/test_transport_hub_building.gd diff --git a/BuildController.gd b/BuildController.gd index 4747cf1..e291e76 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -27,6 +27,7 @@ extends Node2D @onready var build_close_button: IconButton = get_node("../UI/BuildMenu/CloseButton") @onready var build_status: Label = get_node("../UI/BuildMenu/StatusLabel") @onready var build_extractor_button: IconButton = get_node("../UI/BuildMenu/ExtractorButton") +@onready var build_hub_button: IconButton = get_node("../UI/BuildMenu/TransportHubButton") @onready var planet_field: Node2D = get_node("../PlanetField") @onready var info_popup: Control = get_node("../UI/BuildMenu/InfoPopup") @onready var info_title: Label = get_node("../UI/BuildMenu/InfoPopup/Panel/TitleLabel") @@ -76,6 +77,10 @@ const BUILDING_INFO: Dictionary = { "title": "Command Center", "body": "Fly the fleet onto it to drag your fleet into a new formation. Rebuilt fleets keep it.", }, + GameState.BUILDING_TRANSPORT_HUB: { + "title": "Transport Hub", + "body": "Stations transport vessels. They fetch ore from your mothership and deliver it to whichever building is waiting on it.", + }, GameState.BUILDING_EXTRACTOR: { "title": "Extractor", "body": "Mines a planet and runs a hauler to your mothership. It keeps earning while you are away.", @@ -100,6 +105,7 @@ func _ready() -> void: build_crafter_button.pressed.connect(_on_place_crafter) build_command_button.pressed.connect(_on_place_command_center) build_extractor_button.pressed.connect(_on_place_extractor) + build_hub_button.pressed.connect(_on_place_transport_hub) info_close_button.pressed.connect(_close_info) info_build_button.pressed.connect(_confirm_build) @@ -194,6 +200,8 @@ func _action_icon_for(type: String) -> int: match type: GameState.BUILDING_SHIP_CRAFTER: return IconButton.Icon.SHIP + GameState.BUILDING_TRANSPORT_HUB: + return IconButton.Icon.TRANSPORT_HUB GameState.BUILDING_MOTHERSHIP: return IconButton.Icon.MOTHERSHIP _: @@ -243,6 +251,7 @@ func _refresh_build_status() -> void: # explain itself in the info popup. build_crafter_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER) build_command_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_COMMAND_CENTER) + build_hub_button.enabled = GameState.is_building_unlocked(GameState.BUILDING_TRANSPORT_HUB) build_extractor_button.enabled = _planet_under_fleet() != null \ and GameState.is_building_unlocked(GameState.BUILDING_EXTRACTOR) @@ -266,6 +275,10 @@ func _on_place_command_center() -> void: _show_building_info(GameState.BUILDING_COMMAND_CENTER) +func _on_place_transport_hub() -> void: + _show_building_info(GameState.BUILDING_TRANSPORT_HUB) + + func _on_place_extractor() -> void: # The option stays pressable while greyed, so this is also how a # not-over-a-planet extractor explains itself. @@ -274,6 +287,8 @@ func _on_place_extractor() -> void: func cost_of(type: String) -> int: match type: + GameState.BUILDING_TRANSPORT_HUB: + return GameState.TRANSPORT_HUB_COST GameState.BUILDING_MOTHERSHIP: return GameState.MOTHERSHIP_COST GameState.BUILDING_SHIP_CRAFTER: diff --git a/Building.gd b/Building.gd index 6dfc0b4..54e5d1a 100644 --- a/Building.gd +++ b/Building.gd @@ -28,6 +28,19 @@ extends Node2D const MOTHERSHIP_COLOR: Color = Color(0.45, 0.9, 0.55) 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) + +# Transport hub: an octagonal dock ring with berths around it and a freight +# core in the middle. Deliberately radial-but-flat, so it does not read as a +# small mothership - the mothership is the only thing with swept arms. +const HUB_RING_RADIUS: float = 74.0 +const HUB_DECK_RADIUS: float = 54.0 +const HUB_CORE_RADIUS: float = 22.0 +const HUB_BERTH_COUNT: int = 8 +const HUB_BERTH_LENGTH: float = 20.0 +const HUB_DOCKED_COUNT: int = 3 +const HUB_DOCKED_ORBIT: float = 96.0 +const HUB_SPIN_SPEED: float = 0.22 # The vessel on the assembly line is drawn in the fleet's own colour, so it # reads as "one of yours being built" rather than as shipyard machinery. const HULL_COLOR: Color = Color(0.25, 0.85, 1.0) @@ -85,6 +98,8 @@ var _hoist: NeonPiece = null var _sparks: Node2D = null var _hull_holder: Node2D = null var _hull_pieces: Array = [] +# Rotating holder for the transports stationed at a transport hub (#18). +var _hub_traffic: Node2D = null var _flash_ring: NeonPiece = null var _wash: NeonPiece = null @@ -103,6 +118,8 @@ func _ready() -> void: _build_crafter_pieces() elif building_type == GameState.BUILDING_MOTHERSHIP: _build_mothership_pieces() + elif building_type == GameState.BUILDING_TRANSPORT_HUB: + _build_hub_pieces() queue_redraw() @@ -121,6 +138,8 @@ func apply_animation() -> void: _animate_mothership() GameState.BUILDING_SHIP_CRAFTER: _animate_crafter() + GameState.BUILDING_TRANSPORT_HUB: + _animate_transport_hub() # Where the satellites are, and how open the reactor core is. Both are read @@ -161,6 +180,8 @@ func _draw() -> void: _draw_command_center() GameState.BUILDING_MOTHERSHIP: _draw_mothership_static(self) + GameState.BUILDING_TRANSPORT_HUB: + _draw_transport_hub_static(self) _: _draw_crafter_static(self) @@ -203,6 +224,50 @@ const CMD_TOKEN_RADIUS: float = 9.0 const CMD_STUD_RADIUS: float = 7.0 +# --- transport hub -------------------------------------------------------- + +func _draw_transport_hub_static(target: CanvasItem) -> void: + var c: Color = TRANSPORT_HUB_COLOR + + # Dock ring and the deck nested inside it. + NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_RING_RADIUS, HUB_BERTH_COUNT, PI / 8.0), c, 1.0) + NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_DECK_RADIUS, HUB_BERTH_COUNT, PI / 8.0), c, 0.45, NeonDraw.DEFAULT_CORE, 0.7) + + # Freight core: a squared-off hold rather than a reactor, so it reads as + # storage and not as another power plant. + NeonDraw.draw_glowing_polygon(target, _ring(Vector2.ZERO, HUB_CORE_RADIUS, 4, PI / 4.0), c, 0.95) + + for i in range(HUB_BERTH_COUNT): + var a: float = (float(i) / HUB_BERTH_COUNT) * TAU + PI / 8.0 + var dir: Vector2 = Vector2(cos(a), sin(a)) + NeonDraw.draw_glowing_line(target, dir * HUB_CORE_RADIUS, dir * HUB_DECK_RADIUS, c, 0.5, 0.32) + # Berth arm standing off the ring, where a transporter parks. + NeonDraw.draw_glowing_line(target, dir * HUB_RING_RADIUS, + dir * (HUB_RING_RADIUS + HUB_BERTH_LENGTH), c, 0.75, 0.38) + + +func _build_hub_pieces() -> void: + # Stationed transports, as NeonPiece children of one holder that turns. + # Per #10 the geometry is rasterised once and only the holder's rotation + # changes, so the traffic costs no redraws at all. + _hub_traffic = Node2D.new() + add_child(_hub_traffic) + for i in range(HUB_DOCKED_COUNT): + var a: float = (float(i) / HUB_DOCKED_COUNT) * TAU + var at: Vector2 = Vector2(cos(a), sin(a)) * HUB_DOCKED_ORBIT + var piece: NeonPiece = NeonPiece.create(_hub_traffic, at, TRANSPORT_HUB_COLOR) + # A stubby freighter: blunt, boxy, nothing like the fleet's darts. + piece.add_polygon(PackedVector2Array([ + Vector2(-9, -5), Vector2(9, -5), Vector2(12, 0), Vector2(9, 5), Vector2(-9, 5), + ])) + piece.rotation = a + PI / 2.0 + piece.queue_redraw() + + +func _animate_transport_hub() -> void: + _hub_traffic.rotation = phase_time * HUB_SPIN_SPEED + + func _diamond(centre: Vector2, r: float) -> PackedVector2Array: return PackedVector2Array([ centre + Vector2(0, -r), centre + Vector2(r, 0), diff --git a/GameState.gd b/GameState.gd index a7a793f..8e6827e 100644 --- a/GameState.gd +++ b/GameState.gd @@ -101,9 +101,13 @@ const BUILDING_COMMAND_CENTER: String = "command_center" # Extractors are not placed in space like the others - they are built ON a # planet's surface and tracked separately (see `extractors`). const BUILDING_EXTRACTOR: String = "extractor" +# Hosts the transport vessels that supply other buildings (#18). Built in +# space like the rest; it is the transporters (#19) that do the moving. +const BUILDING_TRANSPORT_HUB: String = "transport_hub" const COMMAND_CENTER_COST: int = 15 const EXTRACTOR_COST: int = 25 +const TRANSPORT_HUB_COST: int = 30 var buildings: Array = [] @@ -136,6 +140,11 @@ const SKILL_TREE: Array = [ "x": 0.82, "unlocks": BUILDING_COMMAND_CENTER, "desc": "Build command centers, where the fleet formation is arranged.", }, + { + "id": "freight", "name": "Freight Handling", "cost": 40, "requires": "logistics", + "x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB, + "desc": "Build transport hubs, which supply your other buildings with ore.", + }, { "id": "wing", "name": "Wing Doctrine", "cost": 30, "requires": "coordination", "x": 0.82, "fleet_bonus": 1, diff --git a/IconButton.gd b/IconButton.gd index 93cc525..2fdc552 100644 --- a/IconButton.gd +++ b/IconButton.gd @@ -15,7 +15,7 @@ enum Icon { # build menu used to pick from the generic set, which left the mothership # and the command centre sharing a folded-map glyph and neither of them # looking anything like the structure they build. - MOTHERSHIP, CRAFTER, COMMAND_CENTER, EXTRACTOR, + MOTHERSHIP, CRAFTER, COMMAND_CENTER, EXTRACTOR, TRANSPORT_HUB, } # Size the glyphs were originally drawn against. Every glyph dimension is @@ -119,6 +119,8 @@ func _draw() -> void: _draw_command_icon(center, pulse, s) Icon.EXTRACTOR: _draw_extractor_icon(center, pulse, s) + Icon.TRANSPORT_HUB: + _draw_transport_hub_icon(center, pulse, s) func _draw_close(center: Vector2, pulse: float, s: float) -> void: @@ -204,6 +206,32 @@ func _draw_mothership_icon(center: Vector2, pulse: float, s: float) -> void: NeonDraw.draw_glowing_polygon(self, ring, _active_color(), pulse, core_color) +func _draw_transport_hub_icon(center: Vector2, pulse: float, s: float) -> void: + # A ring of docking cradles with a freight box in the middle: reads as + # "things are stationed here and go out from here", which is what the hub + # does, and shares no silhouette with the crafter's open cradle. + var ring_r: float = 11.0 * s + var ring: PackedVector2Array = PackedVector2Array() + for i in range(8): + var a: float = (float(i) / 8.0) * TAU + PI / 8.0 + ring.append(center + Vector2(cos(a), sin(a)) * ring_r) + NeonDraw.draw_glowing_polygon(self, ring, _active_color(), pulse, core_color) + + var box: float = 4.2 * s + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + center + Vector2(-box, -box), center + Vector2(box, -box), + center + Vector2(box, box), center + Vector2(-box, box), + ]), _active_color(), pulse, core_color) + + # Three stubby berths, so the ring reads as occupied rather than as a + # plain octagon. + for i in range(3): + var a: float = (float(i) / 3.0) * TAU - PI / 2.0 + var dir: Vector2 = Vector2(cos(a), sin(a)) + NeonDraw.draw_glowing_line(self, center + dir * (box * 1.5), + center + dir * (ring_r - 1.5 * s), _active_color(), pulse, 0.35 * s, core_color) + + func _draw_crafter_icon(center: Vector2, pulse: float, s: float) -> void: # Reactor core, truss spine, and an open cradle holding a hull. var core_centre: Vector2 = center + Vector2(-11, 0) * s diff --git a/Main.tscn b/Main.tscn index dca95e7..38e9f7e 100644 --- a/Main.tscn +++ b/Main.tscn @@ -270,10 +270,10 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -214 -offset_right = -66 -offset_top = -254 -offset_bottom = -106 +offset_left = -204 +offset_right = -76 +offset_top = -300 +offset_bottom = -172 script = ExtResource("10") icon = 8 glow_color = Color(0.45, 0.9, 0.55, 1) @@ -286,9 +286,9 @@ anchor_top = 0.5 anchor_bottom = 0.5 offset_left = -290 offset_right = 10 -offset_top = -96 -offset_bottom = 4 -theme_override_font_sizes/font_size = 36 +offset_top = -166 +offset_bottom = -100 +theme_override_font_sizes/font_size = 32 autowrap_mode = 2 text = "Mothership" horizontal_alignment = 1 @@ -299,23 +299,23 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -204 -offset_right = -64 -offset_top = 14 -offset_bottom = 78 +offset_left = -200 +offset_right = -80 +offset_top = -96 +offset_bottom = -44 script = ExtResource("19") amount = 20 -digit_height = 52.0 +digit_height = 40.0 [node name="CrafterButton" type="Control" parent="UI/BuildMenu"] anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = 66 -offset_right = 214 -offset_top = -254 -offset_bottom = -106 +offset_left = 76 +offset_right = 204 +offset_top = -300 +offset_bottom = -172 script = ExtResource("10") icon = 9 glow_color = Color(1, 0.72, 0.25, 1) @@ -328,9 +328,9 @@ anchor_top = 0.5 anchor_bottom = 0.5 offset_left = -10 offset_right = 290 -offset_top = -96 -offset_bottom = 4 -theme_override_font_sizes/font_size = 36 +offset_top = -166 +offset_bottom = -100 +theme_override_font_sizes/font_size = 32 autowrap_mode = 2 text = "Ship Crafter" horizontal_alignment = 1 @@ -341,23 +341,23 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = 76 -offset_right = 216 -offset_top = 14 -offset_bottom = 78 +offset_left = 80 +offset_right = 200 +offset_top = -96 +offset_bottom = -44 script = ExtResource("19") amount = 12 -digit_height = 52.0 +digit_height = 40.0 [node name="CommandButton" type="Control" parent="UI/BuildMenu"] anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -214 -offset_right = -66 -offset_top = 118 -offset_bottom = 266 +offset_left = -204 +offset_right = -76 +offset_top = -10 +offset_bottom = 118 script = ExtResource("10") icon = 10 glow_color = Color(0.55, 0.6, 1, 1) @@ -370,9 +370,9 @@ anchor_top = 0.5 anchor_bottom = 0.5 offset_left = -290 offset_right = 10 -offset_top = 276 -offset_bottom = 376 -theme_override_font_sizes/font_size = 36 +offset_top = 124 +offset_bottom = 190 +theme_override_font_sizes/font_size = 32 autowrap_mode = 2 text = "Command Center" horizontal_alignment = 1 @@ -383,23 +383,23 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -204 -offset_right = -64 -offset_top = 386 -offset_bottom = 450 +offset_left = -200 +offset_right = -80 +offset_top = 194 +offset_bottom = 246 script = ExtResource("19") amount = 15 -digit_height = 52.0 +digit_height = 40.0 [node name="ExtractorButton" type="Control" parent="UI/BuildMenu"] anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = 66 -offset_right = 214 -offset_top = 118 -offset_bottom = 266 +offset_left = 76 +offset_right = 204 +offset_top = -10 +offset_bottom = 118 script = ExtResource("10") icon = 11 glow_color = Color(0, 0.62, 0.7, 1) @@ -412,9 +412,9 @@ anchor_top = 0.5 anchor_bottom = 0.5 offset_left = -10 offset_right = 290 -offset_top = 276 -offset_bottom = 376 -theme_override_font_sizes/font_size = 36 +offset_top = 124 +offset_bottom = 190 +theme_override_font_sizes/font_size = 32 autowrap_mode = 2 text = "Extractor" horizontal_alignment = 1 @@ -425,13 +425,55 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = 76 -offset_right = 216 -offset_top = 386 -offset_bottom = 450 +offset_left = 80 +offset_right = 200 +offset_top = 194 +offset_bottom = 246 script = ExtResource("19") amount = 25 -digit_height = 52.0 +digit_height = 40.0 + +[node name="TransportHubButton" 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 = 280 +offset_bottom = 408 +script = ExtResource("10") +icon = 12 +glow_color = Color(0.95, 0.55, 0.85, 1) + +[node name="TransportHubLabel" 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 = 414 +offset_bottom = 480 +theme_override_font_sizes/font_size = 32 +autowrap_mode = 2 +text = "Transport Hub" +horizontal_alignment = 1 + +[node name="TransportHubCost" 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 = 484 +offset_bottom = 536 +script = ExtResource("19") +amount = 30 +digit_height = 40.0 [node name="CloseButton" type="Control" parent="UI/BuildMenu"] anchor_left = 1.0 diff --git a/WorldMapContent.gd b/WorldMapContent.gd index 1398021..b8b1ad2 100644 --- a/WorldMapContent.gd +++ b/WorldMapContent.gd @@ -10,6 +10,7 @@ const WORLD_TO_LOCAL: float = 0.04 const MOTHERSHIP_MARKER_COLOR: Color = Color(0.45, 0.9, 0.55) const CRAFTER_MARKER_COLOR: Color = Color(1.0, 0.72, 0.25) const COMMAND_MARKER_COLOR: Color = Color(0.55, 0.6, 1.0) +const TRANSPORT_HUB_MARKER_COLOR: Color = Color(0.95, 0.55, 0.85) # A structure built on a planet's surface (see GameState.extractors) is not # itself a map marker - it is a small badge on the existing planet marker, @@ -50,6 +51,8 @@ func _building_marker_color(type: String) -> Color: return MOTHERSHIP_MARKER_COLOR GameState.BUILDING_SHIP_CRAFTER: return CRAFTER_MARKER_COLOR + GameState.BUILDING_TRANSPORT_HUB: + return TRANSPORT_HUB_MARKER_COLOR _: return COMMAND_MARKER_COLOR @@ -115,6 +118,8 @@ func _draw() -> void: _draw_marker_polygon(_regular_polygon(marker.position, 8.0, 6, PI / 6.0), marker.color) GameState.BUILDING_SHIP_CRAFTER: _draw_marker_polygon(_regular_polygon(marker.position, 7.0, 4, 0.0), marker.color) + GameState.BUILDING_TRANSPORT_HUB: + _draw_marker_polygon(_regular_polygon(marker.position, 7.5, 8, PI / 8.0), marker.color) _: _draw_marker_polygon(_regular_polygon(marker.position, 6.0, 4, PI / 4.0), marker.color) diff --git a/tests/_build_menu_preview.gd b/tests/_build_menu_preview.gd new file mode 100644 index 0000000..5132bbf --- /dev/null +++ b/tests/_build_menu_preview.gd @@ -0,0 +1,20 @@ +extends Node2D + +# Preview, not a test. Opens the build menu itself, so the three-row layout +# added for the Transport Hub (#18) can be checked for fit on the real phone +# viewport - every button, label and price moved, and a bad offset is +# invisible to a headless test. +# +# bash tests/screenshot.sh res://tests/_build_menu_preview.tscn /tmp/menu.png 20 720x1280 + + +func _ready() -> void: + GameState.buildings.clear() + GameState.extractors.clear() + GameState.rocks = 500 + # Everything researched, so nothing is greyed out and the whole menu is + # legible at full strength. + GameState.unlocked = ["shipyards", "logistics", "freight", "coordination"] + var main: Node = load("res://Main.tscn").instantiate() + add_child(main) + main.get_node("UI/BuildMenu").visible = true diff --git a/tests/_build_menu_preview.tscn b/tests/_build_menu_preview.tscn new file mode 100644 index 0000000..419b3ea --- /dev/null +++ b/tests/_build_menu_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_build_menu_preview.gd" id="1"] + +[node name="BuildMenuPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/_ingame_buildings_preview.gd b/tests/_ingame_buildings_preview.gd index aa7f619..d586710 100644 --- a/tests/_ingame_buildings_preview.gd +++ b/tests/_ingame_buildings_preview.gd @@ -13,8 +13,9 @@ func _ready() -> void: GameState.buildings.clear() GameState.extractors.clear() GameState.reset_formation() - GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(-230.0, 0.0)) - GameState.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(240.0, 0.0)) + GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(-330.0, 0.0)) + GameState.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(140.0, 0.0)) + GameState.add_building(GameState.BUILDING_TRANSPORT_HUB, Vector2(480.0, 0.0)) var main: Node = load("res://Main.tscn").instantiate() add_child(main) @@ -24,4 +25,4 @@ func _ready() -> void: # far enough to see them whole. main.get_node("Fleet").move_to(Vector2(0.0, 210.0)) var cam: Camera2D = main.get_node("Camera2D") - cam.zoom = Vector2(1.35, 1.35) + cam.zoom = Vector2(1.0, 1.0) diff --git a/tests/test_transport_hub_building.gd b/tests/test_transport_hub_building.gd new file mode 100644 index 0000000..b2cc4eb --- /dev/null +++ b/tests/test_transport_hub_building.gd @@ -0,0 +1,157 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_transport_hub_building.gd +# Forgejo #18 (part 5 of #12): the Transport Hub is a placeable building of +# its own. Only the building here - the vessels that do the hauling are #19. +# +# Verifies: +# 1. it is gated behind its research node, like every other structure +# 2. researching it makes it buildable, and placing it charges its cost +# 3. it is drawn as ITSELF, not as the crafter - Building._draw()'s match +# has a catch-all that renders anything unrecognised as a ship crafter, +# so a missing case is silent and looks almost right +# 4. its geometry is rasterised ONCE however long it animates (#10), and +# its stationed transports really do move +# 5. it has its own build-menu icon and its own map marker rather than +# borrowing another building's - the menu was once unreadable because +# two buildings shared a glyph + +var game_state: Node +var scene: Node +var field: Node +var controller: Node +var map_content: Node2D +var hub: Node2D +var stage: int = 0 +var elapsed: float = 0.0 +var spin_at_start: float = 0.0 + +const HUB_AT: Vector2 = Vector2(700.0, 0.0) + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.reset_progression() + game_state.rocks = 500 + game_state.fleet_cargo = 0 + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + field = scene.get_node("BuildingField") + controller = scene.get_node("BuildController") + map_content = scene.get_node("UI/WorldMap/MapContent") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + + # --- 1. locked until researched ------------------------------ + if game_state.is_building_unlocked(GameState.BUILDING_TRANSPORT_HUB): + print("TEST_TRANSPORT_HUB_BUILDING: FAIL (buildable with no research at all)") + quit(1) + return true + var gate: String = game_state.research_for_building(GameState.BUILDING_TRANSPORT_HUB) + if gate == "": + print("TEST_TRANSPORT_HUB_BUILDING: FAIL (no research node gates it)") + quit(1) + return true + print("locked behind research: '%s'" % gate) + + # --- 5. its own icon, distinct from every other building ----- + var icons: Dictionary = {} + for type in [GameState.BUILDING_MOTHERSHIP, GameState.BUILDING_SHIP_CRAFTER, + GameState.BUILDING_COMMAND_CENTER, GameState.BUILDING_TRANSPORT_HUB]: + var icon: int = controller._action_icon_for(type) + if icons.has(icon): + print("TEST_TRANSPORT_HUB_BUILDING: FAIL (%s and %s share an icon)" % [type, icons[icon]]) + quit(1) + return true + icons[icon] = type + print("action icons distinct across %d buildings" % icons.size()) + + # --- 2. research it, then build it --------------------------- + game_state.unlocked = ["shipyards", "logistics", "freight"] + if not game_state.is_building_unlocked(GameState.BUILDING_TRANSPORT_HUB): + print("TEST_TRANSPORT_HUB_BUILDING: FAIL (still locked after researching '%s')" % gate) + quit(1) + return true + var before: int = game_state.rocks + field.place(GameState.BUILDING_TRANSPORT_HUB, HUB_AT) + game_state.spend(controller.cost_of(GameState.BUILDING_TRANSPORT_HUB)) + if game_state.rocks != before - GameState.TRANSPORT_HUB_COST: + print("TEST_TRANSPORT_HUB_BUILDING: FAIL (cost %d charged, expected %d)" % [ + before - game_state.rocks, GameState.TRANSPORT_HUB_COST + ]) + quit(1) + return true + print("researched and placed for %d ore" % GameState.TRANSPORT_HUB_COST) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.3: + return false + for b in field.spawned: + if is_instance_valid(b) and b.building_type == GameState.BUILDING_TRANSPORT_HUB: + hub = b + if hub == null: + print("TEST_TRANSPORT_HUB_BUILDING: FAIL (never spawned)") + quit(1) + return true + + # --- 3. drawn as itself, not via the crafter catch-all ------- + # _hub_traffic only exists if _ready() took the transport-hub + # branch; a missing case falls through to the crafter and would + # leave it null while still rendering something plausible. + if hub._hub_traffic == null: + print("TEST_TRANSPORT_HUB_BUILDING: FAIL (rendered through the crafter catch-all, not as a hub)") + quit(1) + return true + + # --- 5. its own map marker ---------------------------------- + var markers: Array = map_content.building_markers() + var found: bool = false + for m in markers: + if m.type == GameState.BUILDING_TRANSPORT_HUB: + found = true + if m.color == map_content.CRAFTER_MARKER_COLOR \ + or m.color == map_content.MOTHERSHIP_MARKER_COLOR \ + or m.color == map_content.COMMAND_MARKER_COLOR: + print("TEST_TRANSPORT_HUB_BUILDING: FAIL (map marker reuses another building's colour)") + quit(1) + return true + if not found: + print("TEST_TRANSPORT_HUB_BUILDING: FAIL (no map marker - see #11)") + quit(1) + return true + print("has its own map marker") + + spin_at_start = hub._hub_traffic.rotation + elapsed = 0.0 + stage = 2 + return false + 2: + # --- 4. animates without ever redrawing (#10) ---------------- + if elapsed < 2.0: + return false + print("rasterisations after %.1fs: %d" % [elapsed, hub.draw_count]) + if hub.draw_count != 1: + print("TEST_TRANSPORT_HUB_BUILDING: FAIL (redrew its geometry %d times to animate)" % hub.draw_count) + quit(1) + return true + var spun: float = absf(hub._hub_traffic.rotation - spin_at_start) + print("stationed transports turned %.3f rad" % spun) + if spun < 0.05: + print("TEST_TRANSPORT_HUB_BUILDING: FAIL (nothing moved - a frozen hub trivially passes the redraw check)") + quit(1) + return true + print("TEST_TRANSPORT_HUB_BUILDING: PASS") + quit(0) + return true + return false From d41128bc43b98b21485dbc5b32d06234e2cfdded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Tue, 11 Aug 2026 19:14:27 +0200 Subject: [PATCH 09/11] Transporter vessels, an explicit store button, and bottom-anchored menus Forgejo #19, part 6 of #12. Transporters are built at the ship crafter, fly themselves to the nearest hub and wait there. When a building is short of what its queue needs, the nearest idle transporter runs hub -> mothership -> building -> hub. Ore leaves the bank when it is loaded and lands in the building's stock on arrival, so what is drawn and what happens are the same event - the extractor hauler's rule. Production orders are now TYPED (interceptor / transporter) rather than a bare count, since the crafter builds two different things. One at a time is dispatched per building: without that, every idle transporter chases the same shortfall and they all arrive carrying ore nobody needs. Three fixes from play-testing feedback: - Storing ore into a building is now a deliberate button press, not a side effect of opening the menu. - The mothership's load-ore controls were positioned from the TOP of the screen, so on any viewport shorter than ~1300px the label fell off the bottom. They are anchored to the bottom edge now, and the label had no explicit font size so it inherited the theme's 78px and wrapped off screen. Checked at 720x960 and 720x1280. - The craft button looked live and did nothing. It was not the transport work: BASE_FLEET_SIZE is 5 and a new game starts with exactly 5 ships, so the fleet is at its cap from the first frame and every interceptor order was refused. The button is now visibly disabled and says why. The underlying balance (crafter useless until Wing Doctrine) is left alone - that is a design call, not a bug to quietly change. Co-Authored-By: Claude Opus 5 --- BuildController.gd | 58 +++++- GameState.gd | 230 +++++++++++++++++++++-- Main.tscn | 94 +++++++-- Transporter.gd | 38 ++++ Transporter.gd.uid | 1 + Transporter.tscn | 6 + TransporterField.gd | 51 +++++ TransporterField.gd.uid | 1 + tests/_build_menu_preview.gd.uid | 1 + tests/_cargo_readout_preview.gd.uid | 1 + tests/_craft_menu_preview.gd | 27 +++ tests/_craft_menu_preview.tscn | 6 + tests/_skill_menu_preview.gd | 36 ++-- tests/_transporter_preview.gd | 38 ++++ tests/_transporter_preview.gd.uid | 1 + tests/_transporter_preview.tscn | 6 + tests/test_building_stock_queue.gd.uid | 1 + tests/test_cargo_readout.gd.uid | 1 + tests/test_manual_supply_route.gd.uid | 1 + tests/test_transport_hub_building.gd.uid | 1 + tests/test_transporter_supply.gd | 207 ++++++++++++++++++++ tests/test_transporter_supply.gd.uid | 1 + 22 files changed, 753 insertions(+), 54 deletions(-) create mode 100644 Transporter.gd create mode 100644 Transporter.gd.uid create mode 100644 Transporter.tscn create mode 100644 TransporterField.gd create mode 100644 TransporterField.gd.uid create mode 100644 tests/_build_menu_preview.gd.uid create mode 100644 tests/_cargo_readout_preview.gd.uid create mode 100644 tests/_craft_menu_preview.gd create mode 100644 tests/_craft_menu_preview.tscn create mode 100644 tests/_transporter_preview.gd create mode 100644 tests/_transporter_preview.gd.uid create mode 100644 tests/_transporter_preview.tscn create mode 100644 tests/test_building_stock_queue.gd.uid create mode 100644 tests/test_cargo_readout.gd.uid create mode 100644 tests/test_manual_supply_route.gd.uid create mode 100644 tests/test_transport_hub_building.gd.uid create mode 100644 tests/test_transporter_supply.gd create mode 100644 tests/test_transporter_supply.gd.uid 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 From f19807618ab92dc876c9a4b5386770cb42882458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Tue, 11 Aug 2026 19:43:55 +0200 Subject: [PATCH 10/11] Transporter capacity per hub, and research to raise it Forgejo #20, part 7 of #12 and the last of it. A hub stations BASE_HUB_CAPACITY (2) transporters. Total berths are hubs x per-hub capacity, so building another hub or researching "Freight Yards" both raise it - which is the choice #12 asks the player to make. Capacity follows the same shape as max_fleet_size() (sum a bonus over `unlocked`) rather than being a second mechanism doing the same job, so research applies to hubs already built, not only to ones placed afterwards. A queued-but-unbuilt transporter holds its berth. Without that a player could order ten against two berths and only discover the problem when they refused to appear. Ordering past the cap is refused outright, and the two refusals - no hub at all, versus every berth taken - say which, because "nothing happened" is exactly the bug reported against the craft button. Co-Authored-By: Claude Opus 5 --- BuildController.gd | 15 ++-- GameState.gd | 60 ++++++++++++++++ tests/test_hub_capacity.gd | 143 +++++++++++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 tests/test_hub_capacity.gd diff --git a/BuildController.gd b/BuildController.gd index 6be8193..a134678 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -457,12 +457,14 @@ 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 Transporter %d Here %d%s" % [ - GameState.SHIP_COST, GameState.TRANSPORTER_COST, stock, queue_text + craft_status.text = "Interceptor %d Transporter %d Here %d%s Transporters %d/%d" % [ + GameState.SHIP_COST, GameState.TRANSPORTER_COST, stock, queue_text, + GameState.transporters_claimed(), GameState.transport_capacity() ] # 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.enabled = GameState.has_transport_hub() \ + and GameState.can_station_transporter() craft_transporter_button.queue_redraw() craft_store_label.text = "Store ore here\nHold %d" % GameState.fleet_cargo @@ -527,7 +529,12 @@ 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" + # Two different refusals, and the player needs to know WHICH. + if not GameState.has_transport_hub(): + craft_status.text = "Build a Transport Hub first - a transporter needs somewhere to be stationed" + else: + craft_status.text = "All %d berths are taken - build another hub, or research Freight Yards" % \ + GameState.transport_capacity() return _refresh_craft_status() diff --git a/GameState.gd b/GameState.gd index 98ffa8b..8728c91 100644 --- a/GameState.gd +++ b/GameState.gd @@ -145,6 +145,11 @@ const SKILL_TREE: Array = [ "x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB, "desc": "Build transport hubs, which supply your other buildings with ore.", }, + { + "id": "freight_yards", "name": "Freight Yards", "cost": 80, "requires": "freight", + "x": 0.05, "hub_bonus": 1, + "desc": "Permanent: +1 transporter stationed at every hub, including ones already built.", + }, { "id": "wing", "name": "Wing Doctrine", "cost": 30, "requires": "coordination", "x": 0.82, "fleet_bonus": 1, @@ -357,6 +362,9 @@ func queue_order(id: int, product: String = PRODUCT_SHIP) -> bool: # Nowhere to station it. Refused rather than queued, so the player is # told now instead of watching an order sit forever. return false + if product == PRODUCT_TRANSPORTER and not can_station_transporter(): + # Every berth is taken or spoken for (#20). + return false (b.orders as Array).append(product) return true @@ -374,6 +382,11 @@ func _advance_production() -> void: var cost: int = product_cost(product) if int(b.get("stock", 0)) < cost: break + if product == PRODUCT_TRANSPORTER and transporters.size() >= transport_capacity(): + # A hub was demolished after this was ordered, so the berth it + # was going to use is gone. Hold the order rather than + # overfilling what is left. + 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 @@ -548,6 +561,11 @@ func _process(delta: float) -> void: # "from", "to"}. const TRANSPORTER_SPEED: float = 340.0 +# How many transporters one hub can station. Raised for EVERY hub - built +# already or not - by research, which is the same shape as max_fleet_size() +# rather than a second mechanism doing the same job. +const BASE_HUB_CAPACITY: int = 2 + const TRANSPORTER_IDLE: String = "idle" const TRANSPORTER_TO_MOTHERSHIP: String = "to_mothership" const TRANSPORTER_TO_BUILDING: String = "to_building" @@ -556,6 +574,48 @@ const TRANSPORTER_RETURNING: String = "returning" var transporters: Array = [] +func hub_capacity() -> int: + # Per hub. + var n: int = BASE_HUB_CAPACITY + for id in unlocked: + n += int(skill_node(id).get("hub_bonus", 0)) + return n + + +func hub_count() -> int: + var n: int = 0 + for b in buildings: + if b.type == BUILDING_TRANSPORT_HUB: + n += 1 + return n + + +func transport_capacity() -> int: + # Total berths: more hubs, or more capacity per hub. Either raises it, + # which is exactly the choice #12 asks the player to make. + return hub_count() * hub_capacity() + + +func pending_transporter_orders() -> int: + var n: int = 0 + for b in buildings: + for o in b.get("orders", []): + if o == PRODUCT_TRANSPORTER: + n += 1 + return n + + +func transporters_claimed() -> int: + # Built plus already ordered: an order in the queue has a berth reserved, + # or a player could queue ten of them against two berths and only find out + # when they refused to appear. + return transporters.size() + pending_transporter_orders() + + +func can_station_transporter() -> bool: + return transporters_claimed() < transport_capacity() + + func has_transport_hub() -> bool: return not nearest_hub_to(Vector2.ZERO).is_empty() diff --git a/tests/test_hub_capacity.gd b/tests/test_hub_capacity.gd new file mode 100644 index 0000000..46bae11 --- /dev/null +++ b/tests/test_hub_capacity.gd @@ -0,0 +1,143 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_hub_capacity.gd +# Forgejo #20 (part 7 of #12): a hub stations a limited number of +# transporters. More hubs, or research, raise it. +# +# Verifies: +# 1. capacity is per hub, so a second hub raises the total +# 2. ordering past the cap is REFUSED, not silently swallowed - the ticket +# is explicit that the player has to be told +# 3. a queued-but-unbuilt transporter still holds its berth, or ten could +# be ordered against two and only fail on arrival +# 4. researching Freight Yards raises capacity for hubs ALREADY BUILT, not +# just ones placed afterwards +# 5. the extra berth is immediately usable + +var game_state: Node +var scene: Node +var field: Node +var crafter_id: int = 0 +var stage: int = 0 +var elapsed: float = 0.0 + +const CRAFTER_AT: Vector2 = Vector2(400.0, 0.0) +const HUB_A: Vector2 = Vector2(-500.0, 0.0) +const HUB_B: Vector2 = Vector2(-500.0, 600.0) + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.rocks = 900 + game_state.fleet_cargo = 0 + game_state.ships_owed = 0 + game_state.unlocked = ["shipyards", "logistics", "freight"] + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + field = scene.get_node("BuildingField") + + +func _fill_berths(n: int) -> void: + # Queue and immediately fund n transporters. + for i in range(n): + if game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER): + game_state.deposit_to_building(crafter_id, GameState.TRANSPORTER_COST) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + field.place(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO) + field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT) + crafter_id = int(game_state.buildings[-1].id) + field.place(GameState.BUILDING_TRANSPORT_HUB, HUB_A) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.3: + return false + var per_hub: int = game_state.hub_capacity() + if game_state.transport_capacity() != per_hub: + print("TEST_HUB_CAPACITY: FAIL (one hub gives %d berths, per-hub capacity is %d)" % [ + game_state.transport_capacity(), per_hub + ]) + quit(1) + return true + print("1 hub -> %d berths (%d per hub)" % [game_state.transport_capacity(), per_hub]) + + # --- 2 + 3. fill it, then try to exceed it ------------------ + _fill_berths(per_hub) + if game_state.transporters_claimed() != per_hub: + print("TEST_HUB_CAPACITY: FAIL (claimed %d berths after ordering %d)" % [ + game_state.transporters_claimed(), per_hub + ]) + quit(1) + return true + # Still only QUEUED at this point - the berth must already count. + if game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER): + print("TEST_HUB_CAPACITY: FAIL (queued a transporter past the %d-berth cap)" % per_hub) + quit(1) + return true + print("cap enforced at %d, including orders not yet built" % per_hub) + + # --- 1. a second hub raises the total ----------------------- + field.place(GameState.BUILDING_TRANSPORT_HUB, HUB_B) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.3: + return false + if game_state.transport_capacity() != game_state.hub_capacity() * 2: + print("TEST_HUB_CAPACITY: FAIL (2 hubs give %d berths, expected %d)" % [ + game_state.transport_capacity(), game_state.hub_capacity() * 2 + ]) + quit(1) + return true + print("2 hubs -> %d berths" % game_state.transport_capacity()) + if not game_state.can_station_transporter(): + print("TEST_HUB_CAPACITY: FAIL (second hub did not free a berth)") + quit(1) + return true + + # Fill the second hub too, so capacity is exhausted again. + _fill_berths(game_state.transport_capacity() - game_state.transporters_claimed()) + if game_state.can_station_transporter(): + print("TEST_HUB_CAPACITY: FAIL (still room after filling every berth)") + quit(1) + return true + + # --- 4. research raises it for hubs ALREADY built ----------- + var before: int = game_state.transport_capacity() + game_state.unlocked.append("freight_yards") + var after: int = game_state.transport_capacity() + print("researched Freight Yards: %d -> %d berths across %d existing hubs" % [ + before, after, game_state.hub_count() + ]) + if after != before + game_state.hub_count(): + print("TEST_HUB_CAPACITY: FAIL (research gave %d berths, expected +1 per existing hub)" % (after - before)) + quit(1) + return true + + # --- 5. and the new berth is usable straight away ----------- + if not game_state.can_station_transporter(): + print("TEST_HUB_CAPACITY: FAIL (research raised the number but left no usable berth)") + quit(1) + return true + if not game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER): + print("TEST_HUB_CAPACITY: FAIL (could not order into the berth research just added)") + quit(1) + return true + print("ordered into the newly researched berth") + print("TEST_HUB_CAPACITY: PASS") + quit(0) + return true + return false From da8f6a81eabb2d0004b9f0087adf39664593c421 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Tue, 11 Aug 2026 20:17:58 +0200 Subject: [PATCH 11/11] Block input behind open panels; keep craft-menu messages on screen Three play-testing reports. 1. Buttons behind an open panel were still clickable. There are two input paths and both leaked. GUI clicks: the research and formation panels cannot block with mouse_filter = STOP, because their contents are driven from raw _unhandled_input and a Control consuming touches through the GUI system swallows them first - so the panels stay transparent and IconButton now checks whether it is behind one, via a "modal_menus" group. Raw input: a touch landing on the research panel but missing a node was left unhandled, so it fell through to GameController - which sits on the ROOT node and sees whatever its children ignore - and moved the fleet or pinch-zoomed the world. 2. "Store ore here" looked dead. It was working: the confirmation was overwritten one frame later by the per-frame status refresh added in #16, so it flashed for a single frame. Craft-panel messages now outlive a refresh (3s), and the button stays ENABLED with an empty hold so pressing it can say why - a greyed button that cannot explain itself is the same dead-button problem. 3. The mothership's description could overlap the button below it. The description block was positioned from the TOP while the buttons were anchored to the BOTTOM, so the gap between them was whatever the screen height left over - overlapping at 1280 and separating at 960. It is one bottom-anchored stack now, checked at both. Co-Authored-By: Claude Opus 5 --- BuildController.gd | 62 ++++++++++---- IconButton.gd | 18 ++++ Main.tscn | 43 +++++----- tests/_craft_menu_preview.gd.uid | 1 + tests/_skill_menu_preview.gd | 4 + tests/test_hub_capacity.gd.uid | 1 + tests/test_modal_blocks_input.gd | 138 +++++++++++++++++++++++++++++++ 7 files changed, 233 insertions(+), 34 deletions(-) create mode 100644 tests/_craft_menu_preview.gd.uid create mode 100644 tests/test_hub_capacity.gd.uid create mode 100644 tests/test_modal_blocks_input.gd diff --git a/BuildController.gd b/BuildController.gd index a134678..ab878fc 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -95,6 +95,12 @@ const BUILDING_INFO: Dictionary = { var pending_build_type: String = "" # Which crafter the craft menu is showing (see _on_action_pressed). var craft_building_id: int = 0 +# The craft panel refreshes every frame while it is open (so a delivery +# arriving visibly works the queue down). That overwrote any message the +# moment after it was set, which made a working button look dead - the store +# button in particular. Messages therefore have to outlive a refresh. +var craft_message: String = "" +var craft_message_until: int = 0 var current_building: Node2D = null var pending_building: Node2D = null @@ -149,14 +155,25 @@ func _unhandled_input(event: InputEvent) -> void: # consumes touches through the GUI system swallows them before they get # here, which is what made dragging a formation slot silently do nothing. if skill_menu.visible: - if event is InputEventScreenTouch and skill_tree.handle_touch(event): - get_viewport().set_input_as_handled() - return - if not formation_menu.visible: - return - if event is InputEventScreenTouch and formation_editor.handle_touch(event): + if event is InputEventScreenTouch: + skill_tree.handle_touch(event) + # Handled either way. A touch that lands on the panel but misses a + # node is still a touch ON THE PANEL - letting it through moved the + # fleet and pinch-zoomed the world behind the menu, because + # GameController sits on the root node and sees whatever this leaves + # unhandled. get_viewport().set_input_as_handled() - elif event is InputEventScreenDrag and formation_editor.handle_drag(event): + return + if formation_menu.visible: + if event is InputEventScreenTouch: + formation_editor.handle_touch(event) + elif event is InputEventScreenDrag: + formation_editor.handle_drag(event) + get_viewport().set_input_as_handled() + return + if build_menu.visible or craft_menu.visible or world_map.visible: + # These panels have no raw-input handler of their own, but the world + # behind them must still not react. get_viewport().set_input_as_handled() @@ -450,6 +467,12 @@ func _on_unlock_pressed() -> void: _refresh_skill_menu(id) +func _say(message: String) -> void: + craft_message = message + craft_message_until = Time.get_ticks_msec() + 3000 + _refresh_craft_status() + + func _refresh_craft_status() -> void: # "Here" is the ore delivered to THIS crafter, not the bank: since #16 a # building builds out of its own stock, and showing the bank would say @@ -468,7 +491,10 @@ func _refresh_craft_status() -> void: 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 + # Deliberately left ENABLED with an empty hold: a greyed-out button that + # cannot say why is exactly the dead-button problem this panel already + # had. Pressing it explains instead. + craft_store_button.enabled = true craft_store_button.queue_redraw() # A full fleet cannot take another vessel, so the order would be refused. @@ -481,6 +507,11 @@ func _refresh_craft_status() -> void: 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 craft_message != "": + if Time.get_ticks_msec() < craft_message_until: + craft_status.text = "%s\n%s" % [craft_message, craft_status.text] + else: + craft_message = "" if room: craft_status.text += " Fleet %d/%d" % [fleet.ships.size(), GameState.max_fleet_size()] else: @@ -497,7 +528,7 @@ func _on_craft_interceptor() -> void: 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() + _say("Fleet is full (%d slots)" % GameState.max_fleet_size()) return # No affordability check: an order the crafter cannot pay for yet is # QUEUED, and completes by itself once ore is delivered here (#16). @@ -519,10 +550,11 @@ func _on_load_ore() -> void: func _on_store_ore() -> void: if craft_building_id == 0: return + if GameState.fleet_cargo <= 0: + _say("The fleet is carrying nothing - load ore at the mothership first") + 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] + _say("Stored %d ore here" % moved) func _on_craft_transporter() -> void: @@ -531,10 +563,10 @@ func _on_craft_transporter() -> void: if not GameState.queue_order(craft_building_id, GameState.PRODUCT_TRANSPORTER): # Two different refusals, and the player needs to know WHICH. if not GameState.has_transport_hub(): - craft_status.text = "Build a Transport Hub first - a transporter needs somewhere to be stationed" + _say("Build a Transport Hub first - a transporter needs somewhere to be stationed") else: - craft_status.text = "All %d berths are taken - build another hub, or research Freight Yards" % \ - GameState.transport_capacity() + _say("All %d berths are taken - build another hub, or research Freight Yards" % + GameState.transport_capacity()) return _refresh_craft_status() diff --git a/IconButton.gd b/IconButton.gd index 2fdc552..7dd8ea3 100644 --- a/IconButton.gd +++ b/IconButton.gd @@ -50,7 +50,25 @@ func _ready() -> void: queue_redraw() +func _blocked_by_modal() -> bool: + # A panel is open and this button is not part of it, so it is sitting + # BEHIND the panel and must not react. + # + # The panels cannot simply swallow GUI input with a mouse_filter of STOP: + # the skill tree and the formation editor are driven from raw + # _unhandled_input (see SkillTreeView), and a Control that consumes + # touches through the GUI system swallows them before they ever get + # there. So the panels stay transparent to input and the buttons check + # instead. + for menu in get_tree().get_nodes_in_group("modal_menus"): + if menu.visible and not menu.is_ancestor_of(self): + return true + return false + + func _gui_input(event: InputEvent) -> void: + if _blocked_by_modal(): + return if event is InputEventScreenTouch: _handle_press(event.pressed, event.position) accept_event() diff --git a/Main.tscn b/Main.tscn index f9242fe..88b0a2a 100644 --- a/Main.tscn +++ b/Main.tscn @@ -203,7 +203,7 @@ offset_bottom = -24 script = ExtResource("10") icon = 6 -[node name="WorldMap" type="Control" parent="UI"] +[node name="WorldMap" type="Control" parent="UI" groups=["modal_menus"]] visible = false anchor_right = 1.0 anchor_bottom = 1.0 @@ -237,7 +237,7 @@ offset_bottom = 172 script = ExtResource("10") icon = 1 -[node name="BuildMenu" type="Control" parent="UI"] +[node name="BuildMenu" type="Control" parent="UI" groups=["modal_menus"]] visible = false anchor_right = 1.0 anchor_bottom = 1.0 @@ -489,7 +489,7 @@ offset_bottom = 172 script = ExtResource("10") icon = 1 -[node name="InfoPopup" type="Control" parent="UI/BuildMenu"] +[node name="InfoPopup" type="Control" parent="UI/BuildMenu" groups=["modal_menus"]] visible = false anchor_right = 1.0 anchor_bottom = 1.0 @@ -576,7 +576,7 @@ script = ExtResource("10") icon = 1 glow_color = Color(0, 0.62, 0.7, 1) -[node name="CraftMenu" type="Control" parent="UI"] +[node name="CraftMenu" type="Control" parent="UI" groups=["modal_menus"]] visible = false anchor_right = 1.0 anchor_bottom = 1.0 @@ -700,7 +700,7 @@ offset_bottom = 172 script = ExtResource("10") icon = 1 -[node name="FormationMenu" type="Control" parent="UI"] +[node name="FormationMenu" type="Control" parent="UI" groups=["modal_menus"]] visible = false mouse_filter = 2 anchor_right = 1.0 @@ -784,7 +784,7 @@ grow_horizontal = 2 grow_vertical = 2 color = Color(0, 0, 0, 0) -[node name="SkillMenu" type="Control" parent="UI"] +[node name="SkillMenu" type="Control" parent="UI" groups=["modal_menus"]] visible = false mouse_filter = 2 anchor_right = 1.0 @@ -809,48 +809,53 @@ horizontal_alignment = 1 [node name="Tree" type="Control" parent="UI/SkillMenu"] mouse_filter = 2 anchor_right = 1.0 +anchor_bottom = 1.0 offset_left = 24.0 offset_top = 210.0 offset_right = -24.0 -offset_bottom = 760.0 +offset_bottom = -620.0 script = ExtResource("21") [node name="DetailLabel" type="Label" parent="UI/SkillMenu"] mouse_filter = 2 anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 offset_left = 32.0 -offset_top = 780.0 +offset_top = -610.0 offset_right = -32.0 -offset_bottom = 926.0 -theme_override_font_sizes/font_size = 40 +offset_bottom = -462.0 +theme_override_font_sizes/font_size = 34 autowrap_mode = 2 -text = "" horizontal_alignment = 1 -vertical_alignment = 1 +text = "" [node name="CostNumber" type="Control" parent="UI/SkillMenu"] mouse_filter = 2 anchor_left = 0.5 +anchor_top = 1.0 anchor_right = 0.5 +anchor_bottom = 1.0 offset_left = -64.0 -offset_top = 936.0 +offset_top = -456.0 offset_right = 76.0 -offset_bottom = 1000.0 +offset_bottom = -392.0 script = ExtResource("19") -amount = 0 digit_height = 52.0 [node name="StatusLabel" type="Label" parent="UI/SkillMenu"] mouse_filter = 2 anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 offset_left = 32.0 -offset_top = 1006.0 +offset_top = -386.0 offset_right = -32.0 -offset_bottom = 1070.0 -theme_override_font_sizes/font_size = 36 +offset_bottom = -318.0 +theme_override_font_sizes/font_size = 32 autowrap_mode = 2 -text = "" horizontal_alignment = 1 +text = "" [node name="UnlockButton" type="Control" parent="UI/SkillMenu"] anchor_left = 0.5 diff --git a/tests/_craft_menu_preview.gd.uid b/tests/_craft_menu_preview.gd.uid new file mode 100644 index 0000000..365ad46 --- /dev/null +++ b/tests/_craft_menu_preview.gd.uid @@ -0,0 +1 @@ +uid://dlhupvrh333dd diff --git a/tests/_skill_menu_preview.gd b/tests/_skill_menu_preview.gd index 4f74dbc..6a876b1 100644 --- a/tests/_skill_menu_preview.gd +++ b/tests/_skill_menu_preview.gd @@ -25,3 +25,7 @@ func _ready() -> void: bc._refresh_craft_status() else: bc._refresh_load_ore() + # Select a node so the description, price and status all have text - + # an empty description cannot collide with anything. + bc.skill_tree.selected_id = "freight" + bc._refresh_skill_menu("freight") diff --git a/tests/test_hub_capacity.gd.uid b/tests/test_hub_capacity.gd.uid new file mode 100644 index 0000000..abfc3d4 --- /dev/null +++ b/tests/test_hub_capacity.gd.uid @@ -0,0 +1 @@ +uid://dumdbx4iggydw diff --git a/tests/test_modal_blocks_input.gd b/tests/test_modal_blocks_input.gd new file mode 100644 index 0000000..19c3e78 --- /dev/null +++ b/tests/test_modal_blocks_input.gd @@ -0,0 +1,138 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_modal_blocks_input.gd +# With a panel open, nothing behind it may react to a touch. +# +# Two separate leaks, because there are two separate input paths: +# +# 1. GUI clicks. The skill and formation panels cannot block these the +# normal way (mouse_filter = STOP), because the skill tree and the +# formation editor are driven from raw _unhandled_input, and a Control +# that consumes touches through the GUI system swallows them before +# they ever get there. So the panels stay transparent and the buttons +# check whether they are behind one. +# 2. Raw input. A touch landing on the panel but missing a skill node was +# left unhandled, so it fell through to GameController - which sits on +# the ROOT node and therefore sees everything its children ignore - and +# moved the fleet or pinch-zoomed the world behind the menu. +# +# Verifies: +# 1. a button outside an open panel does not fire +# 2. a button INSIDE the open panel still does (or the panel is unusable) +# 3. with every panel closed, that same outside button fires normally, so +# a pass cannot just mean "the button never works" +# 4. a touch on an open panel does not reach the world behind it + +var scene: Node +var game_state: Node +var fleet: Node +var skill_menu: Control +var map_button: Control +var skill_close: Control +var stage: int = 0 +var elapsed: float = 0.0 +var outside_fired: bool = false +var inside_fired: bool = false + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + fleet = scene.get_node("Fleet") + skill_menu = scene.get_node("UI/SkillMenu") + map_button = scene.get_node("UI/MapButton") + skill_close = scene.get_node("UI/SkillMenu/CloseButton") + map_button.pressed.connect(func(): outside_fired = true) + skill_close.pressed.connect(func(): inside_fired = true) + + +func _tap(button: Control) -> void: + # Straight at the button's own _gui_input, which is where a real tap + # lands once Godot has routed it. + var centre: Vector2 = button.size / 2.0 + for pressed in [true, false]: + var ev: InputEventScreenTouch = InputEventScreenTouch.new() + ev.pressed = pressed + ev.position = centre + button._gui_input(ev) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.2: + return false + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + + # --- 3. baseline: it works with nothing open ---------------- + outside_fired = false + _tap(map_button) + if not outside_fired: + print("TEST_MODAL_BLOCKS_INPUT: FAIL (the button never fires even with no panel open - the test proves nothing)") + quit(1) + return true + print("no panel open -> outside button fires") + # That tap actually OPENED the world map, which is itself a modal + # panel - leave it open and it correctly blocks the research + # panel's own buttons, which is a true result but not the one + # being measured here. + scene.get_node("UI/WorldMap").visible = false + + skill_menu.visible = true + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.2: + return false + # --- 1. behind an open panel -------------------------------- + outside_fired = false + _tap(map_button) + if outside_fired: + print("TEST_MODAL_BLOCKS_INPUT: FAIL (a button behind the open research panel still fired)") + quit(1) + return true + print("research panel open -> button behind it is inert") + + # --- 2. inside it ------------------------------------------ + inside_fired = false + _tap(skill_close) + if not inside_fired: + print("TEST_MODAL_BLOCKS_INPUT: FAIL (the panel's own close button stopped working)") + quit(1) + return true + print("panel's own button still works") + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.2: + return false + # --- 4. raw input must not reach the world ------------------ + skill_menu.visible = true + var before: Vector2 = fleet.fleet_target + var controller: Node = scene.get_node("BuildController") + var ev: InputEventScreenTouch = InputEventScreenTouch.new() + ev.pressed = true + ev.position = Vector2(360.0, 900.0) + controller._unhandled_input(ev) + if not root.get_viewport().is_input_handled(): + print("TEST_MODAL_BLOCKS_INPUT: FAIL (a touch on the open panel was left unhandled - it falls through to the world behind)") + quit(1) + return true + if fleet.fleet_target != before: + print("TEST_MODAL_BLOCKS_INPUT: FAIL (the fleet moved from a touch on the panel)") + quit(1) + return true + print("touch on the panel consumed, fleet target unchanged") + print("TEST_MODAL_BLOCKS_INPUT: PASS") + quit(0) + return true + return false