Compare commits

..
15 changed files with 41 additions and 196 deletions

View file

@ -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) - each further berth is a supplied Fleet Office" \
hangar_menu.set_status("Fleet is full (%d/%d) - research capacity at the mothership" \
% [fleet.ships.size(), GameState.max_fleet_size()])
return
var stored_index: int = GameState.stored_vessels.find(vessel_type)

View file

@ -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()
# 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.
# #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
if not event.pressed:
return
# Wheel zoom, the desktop equivalent of the pinch.

View file

@ -951,16 +951,11 @@ 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 intro_fleet_size
return BASE_FLEET_SIZE + supplied_fleet_office_count()
return mini(intro_fleet_size, DEFAULT_FORMATION.size())
return mini(BASE_FLEET_SIZE + supplied_fleet_office_count(),
DEFAULT_FORMATION.size())
func reset_progression() -> void:
@ -2803,11 +2798,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 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.
# 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.
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),
@ -2819,43 +2814,9 @@ 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:
@ -2863,7 +2824,6 @@ 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 ------------------------------------------------------------

View file

@ -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", "formation_rotation", "stored_vessels",
"surviving_vessel_types", "formation", "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.

View file

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

View file

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

View file

@ -10,19 +10,16 @@ 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 - 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.
# 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.
#
# Covers:
# A. #118 - a click on the fader takes hold of it, dragging moves the
# boost, and releasing lets go
# 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
# B. the fader claims the press, so dragging the boost does not also zoom
# the world behind it
# 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
@ -70,31 +67,6 @@ 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
@ -140,29 +112,22 @@ 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)
_push_click(bottom, true)
_click(bottom, true, scene)
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
_push_move(top)
_move(top, scene)
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)
_push_click(top, false)
_click(top, false, scene)
if fader.is_active():
return _fail("the fader is still held after the button came up")
_push_move(bottom)
_move(bottom, scene)
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" \
@ -170,13 +135,10 @@ func _process(delta: float) -> bool:
# --- B. it claims the press ------------------------------
var zoom_before: float = scene.get_node("Camera2D").zoom.x
_push_click(bottom, true)
_push_click(bottom, false)
_click(bottom, true, scene)
_click(bottom, false, scene)
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 -------------------------

View file

@ -21,8 +21,7 @@ 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. 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
# E. capacity can never outrun the formation's defined positions
var game_state: Node
var scene: Node
@ -108,35 +107,10 @@ func _process(delta: float) -> bool:
GameState.BASE_FLEET_SIZE + i + 1])
print("3 supplied offices -> %d berths" % game_state.max_fleet_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])
# --- 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()])
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
@ -213,32 +187,6 @@ 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

View file

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

View file

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

View file

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

View file

@ -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 at %d — every berth past the first is a supplied Fleet Office" \
% GameState.max_fleet_size())
menu.set_status("Fleet is full — unlock all skills for up to %d slots" \
% GameState.DEFAULT_FORMATION.size())
func _on_fleet_remove_requested(vessel_type: String) -> void:

View file

@ -38,7 +38,7 @@ func _centre() -> Vector2:
func _slot_screen_pos(i: int) -> Vector2:
return _centre() + GameState.formation_slot(i) * scale_factor
return _centre() + GameState.formation[i] * scale_factor
func _screen_to_formation(p: Vector2) -> Vector2:

View file

@ -101,28 +101,6 @@ 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)

View file

@ -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_slot(i)
var offset: Vector2 = GameState.formation[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_slot(ships.size())
var offset: Vector2 = GameState.formation[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,7 +222,9 @@ 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()):
formation_offsets[i] = GameState.formation_slot(i)
if i >= GameState.max_fleet_size():
break
formation_offsets[i] = GameState.formation[i]
_assign_slot_targets()