Refine context actions and preserve fleet landings

This commit is contained in:
Loïc Wisniewski 2026-08-14 23:15:12 +02:00
commit 663648d949
20 changed files with 197 additions and 103 deletions

View file

@ -0,0 +1,37 @@
extends Node
# Visual-only phone preview for the top-level context controls. It composes a
# mothership action set with a landing action so the horizontal foldout and
# the independent Transfer shortcut can be inspected in one screenshot.
func _ready() -> void:
GameState.buildings.clear()
GameState.extractors.clear()
var main: Node2D = load("res://Main.tscn").instantiate()
add_child(main)
main.get_node("AsteroidField").set_process(false)
main.get_node("EnemySpawner").set_process(false)
main.get_node("LandingController").set_process(false)
var building: Node2D = load("res://Building.tscn").instantiate()
building.definition_id = GameState.BUILDING_MOTHERSHIP
building.position = Vector2(0, 420)
main.add_child(building)
var controller: Node2D = main.get_node("BuildController")
controller.set_process(false)
controller.current_building = building
var planet: Node2D = Node2D.new()
main.add_child(planet)
controller.current_landing_planet = planet
controller.building_action_button.icon = int(building.definition.action_icon)
controller.transfer_button.visible = controller._has_transfer_context()
controller.action_button.visible = true
controller.action_button.icon = IconButton.Icon.ACTION_MENU
controller.context_actions_expanded = true
controller._layout_context_buttons()
var label: Label = main.get_node("UI/PlanetNameLabel")
label.text = "PLANET ACTIONS"
label.visible = true

View file

@ -0,0 +1 @@
uid://r4kyx6d4s60i

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/_context_actions_preview.gd" id="1"]
[node name="ContextActionsPreview" type="Node"]
script = ExtResource("1")

View file

@ -21,8 +21,12 @@ func _initialize() -> void:
])
return
var provider: Node = building.visual.get_node_or_null("ActionProvider")
if provider == null or not provider.actions.has("transfer"):
_fail("%s has no composed action provider" % definition.definition_id)
if definition.definition_id == GameState.BUILDING_TRANSPORT_HUB:
if provider != null or not building.available_actions().is_empty():
_fail("transport hub composed a manual action")
return
elif provider == null or not provider.actions.has("transfer"):
_fail("%s has no composed transfer action" % definition.definition_id)
return
building.free()

View file

@ -96,10 +96,9 @@ func _process(delta: float) -> bool:
print("TEST_CRAFTER_FEEDBACK: FAIL (fleet parked on the crafter never registered)")
quit(1)
return true
# A crafter now has both Craft and Transfer, so the shared action
# button unfolds those choices before the craft action is selected.
# Transfer has its own shortcut beside Build, leaving Craft as the
# single contextual action that fires directly.
_tap(controller.action_button)
_tap(controller.building_action_button)
if not controller.craft_menu.visible:
print("TEST_CRAFTER_FEEDBACK: FAIL (the action button did not open the crafter panel)")
quit(1)

View file

@ -48,11 +48,13 @@ func _process(delta: float) -> bool:
1:
if elapsed < 1.2:
return false
if controller.current_extractor_index != extractor_index or not controller.action_button.visible:
return _fail("extractor did not expose a contextual action")
if not scene.get_node("UI/EnterAtmosphereButton").visible:
return _fail("extractor action hid the separate landing action")
controller._on_context_action_pressed()
if controller.current_extractor_index != extractor_index or not controller.transfer_button.visible:
return _fail("extractor did not expose the transfer shortcut")
if not controller.action_button.visible \
or controller.action_button.icon != controller.landing_button.icon \
or controller.landing_button.visible:
return _fail("landing was not folded into the shared action control")
controller._on_transfer_pressed()
if not controller.transfer_menu.visible or controller.transfer_list.extractor_index != extractor_index:
return _fail("transfer panel did not bind to the extractor")
var before: int = state.fleet_resource(resource_type)
@ -61,7 +63,7 @@ func _process(delta: float) -> bool:
return _fail("fleet did not fetch five resources from extractor")
if state.extractor_resource(extractor_index, resource_type) < 7:
return _fail("extractor transfer removed too much stock")
print("fleet fetched %s from extractor while landing remained available" % resource_type)
print("fleet fetched %s while landing remained the contextual action" % resource_type)
print("TEST_EXTRACTOR_TRANSFER: PASS")
quit(0)
return true

View file

@ -63,24 +63,30 @@ func _process(delta: float) -> bool:
_fail("hangar allowed the last deployed ship to be stored")
return true
# Context actions are compact until expanded. A mothership has three
# choices: research, transfer cargo and hangar, which unfold in their
# own non-overlapping berths to the right only after expansion.
# Context actions are compact until expanded. A mothership unfolds
# research and hangar; transfer stays as a persistent shortcut beside
# Build and is not duplicated in the foldout.
var mothership: Node2D = load("res://Building.tscn").instantiate()
mothership.set("building_type", GameState.BUILDING_MOTHERSHIP)
mothership._ready()
controller.current_building = mothership
controller.context_actions_expanded = true
controller.transfer_button.visible = controller._has_transfer_context()
controller._layout_context_buttons()
var buttons: Array = [controller.building_action_button, controller.transfer_button, controller.hangar_button]
var buttons: Array = [controller.building_action_button, controller.hangar_button]
for i in range(buttons.size() - 1):
if buttons[i].offset_right > buttons[i + 1].offset_left:
_fail("context buttons %d and %d overlap" % [i, i + 1])
return true
controller._collapse_context_actions()
if controller.building_action_button.visible or controller.transfer_button.visible or controller.hangar_button.visible:
if controller.building_action_button.visible or controller.hangar_button.visible:
_fail("collapsing the context button left an action visible")
return true
if not controller.transfer_button.visible \
or controller.transfer_button.offset_top != controller.build_button.offset_top \
or controller.transfer_button.offset_left <= controller.build_button.offset_left:
_fail("transfer shortcut is not beside Build")
return true
mothership.free()
print("hangar crews and stores typed vessels; contextual actions expand and collapse")
print("TEST_HANGAR_MANAGEMENT: PASS")

View file

@ -24,7 +24,13 @@ var target_chunk: Vector2i
var position_before_leaving: Vector2
var cached_fade_duration: float = 0.4
var returned_ship_count: int = -1
var returned_vessel_types: PackedStringArray = PackedStringArray()
var game_state: Node
var expected_vessel_types: PackedStringArray = PackedStringArray([
GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD,
GameState.VESSEL_TRANSPORTER, GameState.VESSEL_SHIELD,
GameState.VESSEL_INTERCEPTOR,
])
func _initialize() -> void:
@ -58,7 +64,7 @@ func _initialize() -> void:
landing_controller = main_scene.get_node("LandingController")
asteroid_field_ref = main_scene.get_node("AsteroidField")
planet_name_label = main_scene.get_node("UI/PlanetNameLabel")
enter_button = main_scene.get_node("UI/EnterAtmosphereButton")
enter_button = main_scene.get_node("UI/BuildActionButton")
func _process(delta: float) -> bool:
@ -85,6 +91,10 @@ func _process(delta: float) -> bool:
# collide with a ship mid-test. Clear the existing ones too.
for a in get_nodes_in_group("asteroids"):
a.queue_free()
# A mixed fleet proves the handoff preserves composition rather
# than merely reconstructing the same number of interceptors.
fleet.spawn_ships(expected_vessel_types.size(), fleet.get_centroid(),
expected_vessel_types)
# Get the fleet into the target chunk's vicinity first so
# PlanetField's spawn radius covers it - the planet's own exact
@ -151,12 +161,13 @@ func _process(delta: float) -> bool:
var surface_fleet: Node = current_scene.get_node("Fleet")
var ship_count_ok: bool = surface_fleet.ships.size() == 5
var composition_ok: bool = surface_fleet.vessel_types() == expected_vessel_types
var no_asteroids: bool = get_nodes_in_group("asteroids").is_empty()
var no_enemies: bool = get_nodes_in_group("enemies").is_empty()
print("ship_count_ok=%s (count=%d) no_asteroids=%s no_enemies=%s" % [
ship_count_ok, surface_fleet.ships.size(), no_asteroids, no_enemies
])
if not (ship_count_ok and no_asteroids and no_enemies):
if not (ship_count_ok and composition_ok and no_asteroids and no_enemies):
print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (surface scene contents wrong)")
quit(1)
return true
@ -217,6 +228,7 @@ func _process(delta: float) -> bool:
for a in get_nodes_in_group("asteroids"):
a.queue_free()
returned_ship_count = space_fleet.ships.size()
returned_vessel_types = space_fleet.vessel_types()
# Ships respawn in a wedge and then steer into formation, so the
# centroid only means anything once they have actually stopped.
@ -236,12 +248,13 @@ func _process(delta: float) -> bool:
# across correctly and a fleet came back.
var handoff_ok: bool = game_state.surviving_ship_count == 5
var ship_count_ok: bool = handoff_ok and returned_ship_count >= 1
var composition_ok: bool = returned_vessel_types == expected_vessel_types
var position_ok: bool = space_fleet.get_centroid().distance_to(position_before_leaving) < 50.0
print("handoff=%d returned_ships=%d ship_count_ok=%s position_ok=%s centroid=%s expected=%s" % [
game_state.surviving_ship_count, returned_ship_count,
ship_count_ok, position_ok, space_fleet.get_centroid(), position_before_leaving
])
if ship_count_ok and position_ok:
if ship_count_ok and composition_ok and position_ok:
print("TEST_PLANET_ATMOSPHERE_TRANSITION: PASS")
quit(0)
else:

View file

@ -2,14 +2,8 @@ extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_planet_prompt_map_interaction.gd
# Regression test for a real bug: EnterAtmosphereButton (shown while
# standing on a planet, bottom-center) and the world map's own
# RecenterButton (also bottom-center) occupy the exact same screen
# position. Opening the map while standing on a planet must suppress the
# enter-atmosphere prompt entirely - not just let it sit hidden behind
# the map's backdrop, since closing the map would then abruptly reveal it
# at the same instant RecenterButton disappears (which is what most
# plausibly explains "buttons blinking/disappearing").
# Opening the map while standing on a planet must suppress the shared
# contextual action and planet label, then restore them after closing.
var main_scene: Node
var fleet: Node
@ -48,7 +42,7 @@ func _initialize() -> void:
world_map = main_scene.get_node("UI/WorldMap")
map_button = main_scene.get_node("UI/MapButton")
planet_name_label = main_scene.get_node("UI/PlanetNameLabel")
enter_button = main_scene.get_node("UI/EnterAtmosphereButton")
enter_button = main_scene.get_node("UI/BuildActionButton")
func _process(delta: float) -> bool:

View file

@ -47,7 +47,7 @@ func _initialize() -> void:
root.add_child(main_scene)
fleet = main_scene.get_node("Fleet")
planet_field = main_scene.get_node("PlanetField")
enter_button = main_scene.get_node("UI/EnterAtmosphereButton")
enter_button = main_scene.get_node("UI/BuildActionButton")
main_scene.get_node("AsteroidField").set_process(false)
main_scene.get_node("EnemySpawner").set_process(false)

View file

@ -7,8 +7,8 @@ extends SceneTree
# research screen.
#
# Verifies:
# 1. the transfer button appears alongside the action button whenever the
# fleet is standing on a building, and opens the transfer panel bound to
# 1. the transfer shortcut appears beside Build whenever the fleet is
# standing on a transferable building, and opens the panel bound to
# THAT building - the `== 0` guard failure that made every crafter
# button look dead is the same trap here
# 2. ore moves both ways at an ordinary building, capped by what is
@ -73,8 +73,6 @@ func _tap(button: Control) -> void:
func _open_transfer() -> void:
# Contextual choices collapse into one button until it is opened.
_tap(controller.action_button)
_tap(controller.transfer_button)
@ -108,6 +106,8 @@ func _process(delta: float) -> bool:
return false
_expect(controller.current_building != null,
"parking on the crafter never registered")
_expect(controller.transfer_button.visible,
"transfer shortcut did not appear beside Build")
_open_transfer()
_expect(controller.transfer_menu.visible,
"tapping the transfer button did not open the panel")

View file

@ -44,7 +44,7 @@ func _initialize() -> void:
fleet = main_scene.get_node("Fleet")
planet_field = main_scene.get_node("PlanetField")
landing_controller = main_scene.get_node("LandingController")
enter_button = main_scene.get_node("UI/EnterAtmosphereButton")
enter_button = main_scene.get_node("UI/BuildActionButton")
func _process(delta: float) -> bool:

View file

@ -24,6 +24,7 @@ var scene: Node
var field: Node
var controller: Node
var map_content: Node2D
var hub_building: Node2D
var hub: Node2D
var stage: int = 0
var elapsed: float = 0.0
@ -101,11 +102,19 @@ func _process(delta: float) -> bool:
return false
for b in field.spawned:
if is_instance_valid(b) and b.building_type == GameState.BUILDING_TRANSPORT_HUB:
hub_building = b
hub = b.visual
if hub == null:
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (never spawned)")
quit(1)
return true
if not hub_building.available_actions().is_empty():
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (offers a manual resource transfer)")
quit(1)
return true
for ship in scene.get_node("Fleet").ships:
ship.position = HUB_AT
scene.get_node("Fleet").move_to(HUB_AT)
# --- 3. drawn as itself, not via the crafter catch-all -------
# _hub_traffic only exists if _ready() took the transport-hub
@ -142,6 +151,14 @@ func _process(delta: float) -> bool:
# --- 4. animates without ever redrawing (#10) ----------------
if elapsed < 2.0:
return false
if controller.current_building != hub_building \
or controller.action_button.visible or controller.transfer_button.visible:
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (actionless hub context current=%s expected=%s action=%s transfer=%s)" % [
controller.current_building, hub_building, controller.action_button.visible,
controller.transfer_button.visible,
])
quit(1)
return true
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)