Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2309ea1c2 | ||
|
|
a43fae5e28 | ||
|
|
93bfe62e76 |
15 changed files with 196 additions and 41 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
1
tests/_lab_planet_visual_preview.gd.uid
Normal file
1
tests/_lab_planet_visual_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bk3grd8nn8pj1
|
||||
1
tests/test_building_separation.gd.uid
Normal file
1
tests/test_building_separation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ckdulc4563wt5
|
||||
|
|
@ -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 -------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
tests/test_guidance_marker_redraw.gd.uid
Normal file
1
tests/test_guidance_marker_redraw.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bvb4aa4k1702
|
||||
1
tests/test_main_menu_continue.gd.uid
Normal file
1
tests/test_main_menu_continue.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ctxpryt5rqddw
|
||||
1
tests/test_save_game.gd.uid
Normal file
1
tests/test_save_game.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://ntytohsnghw3
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue