diff --git a/controllers/BuildController.gd b/controllers/BuildController.gd index 42cd7e6..b125acd 100644 --- a/controllers/BuildController.gd +++ b/controllers/BuildController.gd @@ -839,7 +839,7 @@ func _on_hangar_crew_type(selection: Variant) -> void: if vessel_type == "": return if fleet.ships.size() >= GameState.max_fleet_size(): - hangar_menu.set_status("Fleet is full (%d/%d) - research capacity at the mothership" \ + hangar_menu.set_status("Fleet is full (%d/%d) - each further berth is a supplied Fleet Office" \ % [fleet.ships.size(), GameState.max_fleet_size()]) return var stored_index: int = GameState.stored_vessels.find(vessel_type) diff --git a/controllers/GameController.gd b/controllers/GameController.gd index 6e2aa03..168774c 100644 --- a/controllers/GameController.gd +++ b/controllers/GameController.gd @@ -383,14 +383,14 @@ func _unhandled_input(event: InputEvent) -> void: _handle_key(event) elif event is InputEventMouseMotion: _note_keyboard() - if speed_fader != null: - speed_fader.handle_mouse_motion(event) elif event is InputEventMouseButton: _note_keyboard() - # #118: the fader gets first refusal, so dragging the boost does not - # also pan or zoom the world behind it. - if speed_fader != null and speed_fader.handle_mouse_button(event): - return + # The speed fader used to be driven from here, and could not work: + # MouseCombatController is dispatched before this node (it is a + # child, and this is the root) and marks every left press handled to + # fire the guns, so the press never arrived. It listens for its own + # mouse events now - see SpeedFader._unhandled_input - and claims + # them from the UI layer, ahead of the combat controllers. if not event.pressed: return # Wheel zoom, the desktop equivalent of the pinch. diff --git a/core/GameState.gd b/core/GameState.gd index f804ecf..15be5b5 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -951,11 +951,16 @@ func _advance_fleet_office_upkeep(delta: float) -> void: fleet_upkeep_failed.emit(i + BASE_FLEET_SIZE, vessel_type) +# However many berths the fleet offices are keeping supplied, with no +# ceiling above them: a player who builds and feeds fifty offices flies +# fifty vessels. There used to be one at DEFAULT_FORMATION.size(), which was +# a limit of the LAYOUT rather than a rule of the game - the wedge simply ran +# out of authored positions at twenty. It now continues by rule instead (see +# formation_slot), so there is nothing left for the cap to protect. func max_fleet_size() -> int: if intro_fleet_size > 0: - return mini(intro_fleet_size, DEFAULT_FORMATION.size()) - return mini(BASE_FLEET_SIZE + supplied_fleet_office_count(), - DEFAULT_FORMATION.size()) + return intro_fleet_size + return BASE_FLEET_SIZE + supplied_fleet_office_count() func reset_progression() -> void: @@ -2798,11 +2803,11 @@ func get_mothership_position() -> Variant: # 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. -# Twenty slots, because max_fleet_size() caps itself on this array's length - -# so the wedge has to be long enough for every doctrine to be worth buying. -# Each further pair continues the same V rather than starting a new shape: -# the rows step 30 out and 40 back, which is the rule the first ten already -# followed, so the wedge that arrives at twenty is the one that left at three. +# Twenty slots are AUTHORED. They are not a cap: the fleet grows as far as +# the offices supply it, and formation_slot() continues the wedge past the +# end of this array by the same rule the rows here already follow - each +# further pair steps 30 further out and 40 further back, so the wedge that +# arrives at fifty is the one that left at three. 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), @@ -2814,9 +2819,43 @@ const DEFAULT_FORMATION: Array = [ var formation: Array = DEFAULT_FORMATION.duplicate() +# Which way the wedge currently faces. Kept because slots are grown on +# demand: one that appears after the player has pivoted has to arrive +# already turned, or a fleet that outgrows the authored twenty would sprout +# hulls pointing back along the formation's original heading. +var formation_rotation: float = 0.0 + +# The rule the wedge continues by past the authored slots. Pairs start at +# index 8 - the point where the hand-placed rows settle into a regular +# V - with the even index on the left, and each pair after that steps out +# and back by FORMATION_PAIR_STEP. +const FORMATION_PAIR_FIRST: int = 8 +const FORMATION_PAIR_ORIGIN: Vector2 = Vector2(120.0, 112.0) +const FORMATION_PAIR_STEP: Vector2 = Vector2(30.0, 40.0) + func reset_formation() -> void: formation = DEFAULT_FORMATION.duplicate() + formation_rotation = 0.0 + + +# Where one slot sits, growing the wedge if the fleet has outgrown the +# positions stored so far. Every reader asks through this rather than +# indexing `formation` directly, because with no cap on fleet size there is +# no index the array is guaranteed to already hold. +func formation_slot(index: int) -> Vector2: + if index < 0: + return Vector2.ZERO + while formation.size() <= index: + formation.append(_grown_formation_slot(formation.size()).rotated(formation_rotation)) + return formation[index] + + +func _grown_formation_slot(index: int) -> Vector2: + var pair: int = (index - FORMATION_PAIR_FIRST) / 2 + var out: float = FORMATION_PAIR_ORIGIN.x + FORMATION_PAIR_STEP.x * float(pair) + return Vector2(out if index % 2 == 1 else -out, + FORMATION_PAIR_ORIGIN.y + FORMATION_PAIR_STEP.y * float(pair)) func rotate_formation(radians: float) -> void: @@ -2824,6 +2863,7 @@ func rotate_formation(radians: float) -> void: # and only changing which way it faces. for i in range(formation.size()): formation[i] = formation[i].rotated(radians) + formation_rotation = wrapf(formation_rotation + radians, -PI, PI) # --- Extractors ------------------------------------------------------------ diff --git a/core/SaveGame.gd b/core/SaveGame.gd index c1abc5d..7003d37 100644 --- a/core/SaveGame.gd +++ b/core/SaveGame.gd @@ -47,7 +47,7 @@ const FIELDS: Array = [ "unlocked", "research_points", "vessel_levels", # The fleet itself. "fleet_position", "fleet_vessel_types", "surviving_ship_count", - "surviving_vessel_types", "formation", "stored_vessels", + "surviving_vessel_types", "formation", "formation_rotation", "stored_vessels", "vessel_targets", "vessel_deliveries", "repair_drones", "max_fleet_speed_multiplier", "fleet_speed_multiplier", "build_time_scale", # The world as the player has seen it. diff --git a/tests/_lab_planet_visual_preview.gd.uid b/tests/_lab_planet_visual_preview.gd.uid new file mode 100644 index 0000000..ac21c5a --- /dev/null +++ b/tests/_lab_planet_visual_preview.gd.uid @@ -0,0 +1 @@ +uid://bk3grd8nn8pj1 diff --git a/tests/test_building_separation.gd.uid b/tests/test_building_separation.gd.uid new file mode 100644 index 0000000..a32d370 --- /dev/null +++ b/tests/test_building_separation.gd.uid @@ -0,0 +1 @@ +uid://ckdulc4563wt5 diff --git a/tests/test_desktop_controls.gd b/tests/test_desktop_controls.gd index 4fad953..895ff43 100644 --- a/tests/test_desktop_controls.gd +++ b/tests/test_desktop_controls.gd @@ -10,16 +10,19 @@ extends SceneTree # The boost was unreachable on a desktop and the formation could not be # rearranged at all. # -# Driven through the REAL routing in each case (GameController for the -# fader, BuildController for the editor) rather than by calling the controls -# directly, because the routing is what was missing - a test that called -# handle_mouse_button() itself would have passed against both reports. +# Driven through the REAL routing in each case - the viewport itself for the +# fader, BuildController for the editor - rather than by calling the +# controls directly, because the routing is what was missing: a test that +# called handle_mouse_button() itself would have passed against both +# reports. It did, in fact, and hid a second one: the fader was dispatched +# from Main, which is the LAST node to see an unhandled press, so +# MouseCombatController fired the guns with it and the knob never moved. # # Covers: # A. #118 - a click on the fader takes hold of it, dragging moves the # boost, and releasing lets go -# B. the fader claims the press, so dragging the boost does not also zoom -# the world behind it +# B. the press stops at the fader: not the camera behind it, and not the +# guns, which claim every other left press on a desktop # C. #121 - a click grabs a formation slot, moving drags it, and the # formation really changes # D. releasing drops the slot, and moving afterwards does not keep dragging @@ -67,6 +70,31 @@ func _fail(message: String) -> bool: return true +# Pushed through the viewport, not handed to a node's own _unhandled_input: +# WHICH node sees a press first is the thing under test. #118 shipped a fader +# that only worked when a test called it directly - MouseCombatController +# claims every left press to fire the guns, and it is dispatched well before +# Main's own handler, so on a real desktop the click never arrived. +# `true` keeps the coordinates in viewport space, which is the space the +# controls are laid out in. +func _push(event: InputEvent) -> void: + root.push_input(event, true) + + +func _push_click(at: Vector2, pressed: bool) -> void: + var event: InputEventMouseButton = InputEventMouseButton.new() + event.button_index = MOUSE_BUTTON_LEFT + event.pressed = pressed + event.position = at + _push(event) + + +func _push_move(to: Vector2) -> void: + var event: InputEventMouseMotion = InputEventMouseMotion.new() + event.position = to + _push(event) + + func _click(at: Vector2, pressed: bool, target: Node) -> void: var event: InputEventMouseButton = InputEventMouseButton.new() event.button_index = MOUSE_BUTTON_LEFT @@ -112,22 +140,29 @@ func _process(delta: float) -> bool: return _fail("the boost has no range to move through") # --- A. click, drag, release ----------------------------- + # With mouse aiming live, which is what a desktop always has: + # the guns are the other thing a left press means, and they used + # to take every one of them. + var mouse_combat: Node = scene.get_node("MouseCombatController") + mouse_combat.pointer_mode = mouse_combat.PointerMode.ON var top: Vector2 = fader.global_position + Vector2(fader.size.x * 0.5, fader.track_margin + 2.0) var bottom: Vector2 = fader.global_position + Vector2(fader.size.x * 0.5, fader.size.y - fader.track_margin - 2.0) - _click(bottom, true, scene) + _push_click(bottom, true) if not fader.is_active(): return _fail("a click on the fader did not take hold of it") + if mouse_combat.firing: + return _fail("the click that grabbed the fader also fired the fleet's guns") var at_bottom: float = game_state.fleet_speed_multiplier - _move(top, scene) + _push_move(top) var at_top: float = game_state.fleet_speed_multiplier if at_top <= at_bottom: return _fail("dragging the fader from bottom to top left the boost at %.2f" % at_top) - _click(top, false, scene) + _push_click(top, false) if fader.is_active(): return _fail("the fader is still held after the button came up") - _move(bottom, scene) + _push_move(bottom) if not is_equal_approx(game_state.fleet_speed_multiplier, at_top): return _fail("the fader kept following the pointer after release") print("fader: %.2fx at the bottom, %.2fx at the top, released cleanly" \ @@ -135,10 +170,13 @@ func _process(delta: float) -> bool: # --- B. it claims the press ------------------------------ var zoom_before: float = scene.get_node("Camera2D").zoom.x - _click(bottom, true, scene) - _click(bottom, false, scene) + _push_click(bottom, true) + _push_click(bottom, false) if not is_equal_approx(scene.get_node("Camera2D").zoom.x, zoom_before): return _fail("pressing the fader also moved the camera behind it") + if mouse_combat.firing: + return _fail("the fader's press reached the guns behind it") + mouse_combat.pointer_mode = mouse_combat.PointerMode.AUTO print("the press stops at the fader; the world behind it is untouched") # --- C/D/E. the formation editor ------------------------- diff --git a/tests/test_fleet_growth.gd b/tests/test_fleet_growth.gd index 0aa3fcc..d194576 100644 --- a/tests/test_fleet_growth.gd +++ b/tests/test_fleet_growth.gd @@ -21,7 +21,8 @@ extends SceneTree # B. each supplied office adds exactly one berth, and the cap bites # C. an office that cannot pay loses its vessel, and says so # D. a restocked office goes back to work without being rebuilt -# E. capacity can never outrun the formation's defined positions +# E. the wedge has a place for every slot, however far out it is +# F. and nothing caps the fleet at the twenty positions the wedge authors var game_state: Node var scene: Node @@ -107,10 +108,35 @@ func _process(delta: float) -> bool: GameState.BASE_FLEET_SIZE + i + 1]) print("3 supplied offices -> %d berths" % game_state.max_fleet_size()) - # --- E. and never more than the formation can seat ------------ - if game_state.max_fleet_size() > GameState.DEFAULT_FORMATION.size(): - return _fail("capacity %d exceeds the %d positions the formation defines" \ - % [game_state.max_fleet_size(), GameState.DEFAULT_FORMATION.size()]) + # --- E. the wedge has a place for every slot ------------------ + # The formation used to be the fleet's real ceiling: it defined + # twenty positions and capacity clamped itself to them. It now + # continues by rule instead, so what is pinned here is that the + # rule keeps the authored shape - the far slots are distinct, + # and each pair still steps 30 out and 40 back from the last. + var authored: int = GameState.DEFAULT_FORMATION.size() + var probe: int = authored * 3 + var seen: Dictionary = {} + for i in range(probe): + var slot: Vector2 = game_state.formation_slot(i) + var key: String = "%.1f,%.1f" % [slot.x, slot.y] + if seen.has(key): + return _fail("slot %d sits on top of slot %d at %s" % [i, int(seen[key]), key]) + seen[key] = i + for i in range(authored): + if not game_state.formation_slot(i).is_equal_approx(GameState.DEFAULT_FORMATION[i]): + return _fail("growing the wedge moved authored slot %d to %s" \ + % [i, game_state.formation_slot(i)]) + for i in range(authored, probe - 2): + var here: Vector2 = game_state.formation_slot(i) + var behind: Vector2 = game_state.formation_slot(i + 2) + var step: Vector2 = Vector2(GameState.FORMATION_PAIR_STEP.x * signf(here.x), + GameState.FORMATION_PAIR_STEP.y) + if not (behind - here).is_equal_approx(step): + return _fail("slot %d steps %s from slot %d, expected %s" \ + % [i + 2, behind - here, i, step]) + print("the wedge continues past its %d authored slots, %d checked and all distinct" \ + % [authored, probe]) scene = load("res://world/Main.tscn").instantiate() root.add_child(scene) @@ -187,6 +213,32 @@ func _process(delta: float) -> bool: return _fail("a supplied office lost its vessel anyway") print("restocked and re-crewed: the office pays and the vessel stays") + # --- F. and nothing caps the fleet at twenty ------------------ + # Offices past the wedge's authored positions buy real berths, + # and the fleet really flies them: every hull gets a place of + # its own rather than piling onto the last defined slot. + var target: int = GameState.DEFAULT_FORMATION.size() + 4 + while game_state.max_fleet_size() < target: + var extra: int = game_state.add_building(GameState.BUILDING_FLEET_OFFICE, + Vector2(600.0 * float(game_state.max_fleet_size()), 900.0)) + _stock(extra, 500) + if game_state.max_fleet_size() != target: + return _fail("%d supplied offices gave %d berths, expected %d" \ + % [game_state.fleet_offices().size(), game_state.max_fleet_size(), target]) + while fleet.ships.size() < target: + if not fleet.add_ship(GameState.VESSEL_INTERCEPTOR): + return _fail("berth %d could not be crewed" % (fleet.ships.size() + 1)) + var places: Dictionary = {} + for i in range(fleet.ships.size()): + var offset: Vector2 = fleet.formation_offsets[i] + var place: String = "%.1f,%.1f" % [offset.x, offset.y] + if places.has(place): + return _fail("vessel %d flies the same place as vessel %d" \ + % [i, int(places[place])]) + places[place] = i + print("%d berths, %d vessels, %d distinct places - no ceiling at %d" \ + % [target, fleet.ships.size(), places.size(), GameState.DEFAULT_FORMATION.size()]) + print("TEST_FLEET_GROWTH: PASS") quit(0) return true diff --git a/tests/test_guidance_marker_redraw.gd.uid b/tests/test_guidance_marker_redraw.gd.uid new file mode 100644 index 0000000..9d42fd9 --- /dev/null +++ b/tests/test_guidance_marker_redraw.gd.uid @@ -0,0 +1 @@ +uid://bvb4aa4k1702 diff --git a/tests/test_main_menu_continue.gd.uid b/tests/test_main_menu_continue.gd.uid new file mode 100644 index 0000000..0bafa51 --- /dev/null +++ b/tests/test_main_menu_continue.gd.uid @@ -0,0 +1 @@ +uid://ctxpryt5rqddw diff --git a/tests/test_save_game.gd.uid b/tests/test_save_game.gd.uid new file mode 100644 index 0000000..fb3efa0 --- /dev/null +++ b/tests/test_save_game.gd.uid @@ -0,0 +1 @@ +uid://ntytohsnghw3 diff --git a/ui/DevMenuButton.gd b/ui/DevMenuButton.gd index 22dc7f8..0d9e28b 100644 --- a/ui/DevMenuButton.gd +++ b/ui/DevMenuButton.gd @@ -37,8 +37,8 @@ func _on_fleet_add_requested(vessel_type: String) -> void: if fleet.add_ship(vessel_type): menu.set_status("Added %s" % GameState.vessel_name(vessel_type)) else: - menu.set_status("Fleet is full — unlock all skills for up to %d slots" \ - % GameState.DEFAULT_FORMATION.size()) + menu.set_status("Fleet is full at %d — every berth past the first is a supplied Fleet Office" \ + % GameState.max_fleet_size()) func _on_fleet_remove_requested(vessel_type: String) -> void: diff --git a/ui/FormationEditor.gd b/ui/FormationEditor.gd index 79a05b8..ea01f72 100644 --- a/ui/FormationEditor.gd +++ b/ui/FormationEditor.gd @@ -38,7 +38,7 @@ func _centre() -> Vector2: func _slot_screen_pos(i: int) -> Vector2: - return _centre() + GameState.formation[i] * scale_factor + return _centre() + GameState.formation_slot(i) * scale_factor func _screen_to_formation(p: Vector2) -> Vector2: diff --git a/ui/SpeedFader.gd b/ui/SpeedFader.gd index bebda79..7affc29 100644 --- a/ui/SpeedFader.gd +++ b/ui/SpeedFader.gd @@ -101,6 +101,28 @@ func handle_mouse_motion(event: InputEventMouseMotion) -> bool: return true +# ...and listening for those events HERE rather than being handed them by +# GameController, which is what #118 did. +# +# Unhandled input reaches the last nodes in the tree first, and Main - where +# that dispatch lives - is the root, so it is served last of all. +# MouseCombatController is a child of it and claims every left press to fire +# the fleet's guns, which meant a click on the fader shot at the fader +# instead of taking hold of it: the knob never moved on a desktop. The UI +# layer sits after the combat controllers, so asking for the event here puts +# the control the player is actually pointing at ahead of the guns behind it. +# +# Touch still comes through GameController: nothing competes for a finger, +# and the joystick/fire stick ordering it arbitrates is real. +func _unhandled_input(event: InputEvent) -> void: + if event is InputEventMouseButton: + if handle_mouse_button(event): + get_viewport().set_input_as_handled() + elif event is InputEventMouseMotion: + if handle_mouse_motion(event): + get_viewport().set_input_as_handled() + + func handle_touch(event: InputEventScreenTouch) -> bool: return _press(event.pressed, event.position, event.index) diff --git a/vessels/Fleet.gd b/vessels/Fleet.gd index 25422b2..19c5d28 100644 --- a/vessels/Fleet.gd +++ b/vessels/Fleet.gd @@ -96,7 +96,7 @@ func spawn_ships(count: int, center: Vector2 = Vector2.ZERO, spawn_types.append(GameState.VESSEL_INTERCEPTOR) var n: int = clamp(spawn_types.size(), 1, GameState.max_fleet_size()) for i in range(n): - var offset: Vector2 = GameState.formation[i] + var offset: Vector2 = GameState.formation_slot(i) var ship: Area2D = ship_scene.instantiate() ship.vessel_type = spawn_types[i] ship.position = center + offset @@ -178,7 +178,7 @@ func add_ship(vessel_type: String = GameState.VESSEL_INTERCEPTOR) -> bool: if not GameState.vessel_is_fleet_eligible(vessel_type) \ or ships.size() >= GameState.max_fleet_size(): return false - var offset: Vector2 = GameState.formation[ships.size()] + var offset: Vector2 = GameState.formation_slot(ships.size()) var ship: Area2D = ship_scene.instantiate() # Before add_child, so Ship._ready() builds the right vessel - the shield # ring is created there and only for a shield vessel. @@ -222,9 +222,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.max_fleet_size(): - break - formation_offsets[i] = GameState.formation[i] + formation_offsets[i] = GameState.formation_slot(i) _assign_slot_targets()