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