Compare commits

..
11 changed files with 14 additions and 451 deletions

View file

@ -63,7 +63,6 @@ var context_actions_expanded: bool = false
func _ready() -> void:
get_viewport().size_changed.connect(_on_viewport_resized)
_seat_hud_columns()
build_button.pressed.connect(_on_build_pressed)
action_button.pressed.connect(_on_context_action_pressed)
@ -98,22 +97,6 @@ func _ready() -> void:
_layout_context_buttons()
# HudScale reapplies each control's authored offsets whenever the window
# changes, which wipes the two layouts this controller writes at runtime.
# Re-running them after a resize is what keeps a desktop window that gets
# dragged to another size from leaving the context row where it was.
func _on_viewport_resized() -> void:
# Deferred: HudScale reapplies on the same signal, and which of two
# siblings connected first is not a thing to depend on. By the end of the
# frame its offsets are in, and these two can seat against them.
_relayout_hud.call_deferred()
func _relayout_hud() -> void:
_seat_hud_columns()
_layout_context_buttons()
func _seat_hud_columns() -> void:
var readout: ResourceReadout = get_node_or_null("../UI/ResourceReadout")
if readout == null:
@ -126,10 +109,7 @@ func _seat_hud_columns() -> void:
highest = minf(highest, button.position.y)
if is_inf(highest):
return
# The margin is authored in the same canvas units as everything else, so
# it scales with the HUD (#228) or it would be the one gap that stayed
# phone-sized on a desktop.
var shift: float = readout.content_bottom() + HUD_COLUMN_MARGIN * readout.scale.y - highest
var shift: float = readout.content_bottom() + HUD_COLUMN_MARGIN - highest
if shift <= 0.0:
return
for button in buttons:
@ -242,14 +222,7 @@ func _clear_detected_context() -> void:
func _layout_context_buttons() -> void:
# position is in the UI layer's space and size is in the button's own,
# and since #228 those differ by the HUD scale on a short canvas. Every
# gap and stride below is therefore scaled, while the RECTS keep their
# authored size - HudScale renders them down rather than shrinking them.
# Mixing the two is what this function got wrong twice before (see
# test_hud_layout), and a desktop canvas makes it wrong again.
var k: float = build_button.scale.y
var row_top: float = build_button.position.y + build_button.size.y * k + HUD_ROW_GAP * k
var row_top: float = build_button.position.y + build_button.size.y + HUD_ROW_GAP
var row_height: float = build_button.size.y
action_button.offset_top = row_top
action_button.offset_bottom = row_top + row_height
@ -261,8 +234,8 @@ func _layout_context_buttons() -> void:
for i in range(buttons.size()):
var button: Control = buttons[i]
button.visible = true
button.offset_left = (188.0 + float(i) * 164.0) * k
button.offset_right = button.offset_left + 148.0
button.offset_left = 188 + i * 164
button.offset_right = button.offset_left + 148
button.offset_top = row_top
button.offset_bottom = row_top + row_height

View file

@ -20,10 +20,6 @@ const CATALOG_CHECK = preload("res://core/CatalogCheck.gd")
# survive the trip has to live here instead.
var has_return_position: bool = false
# Set by the menu when a save has just been loaded, cleared by whoever acts
# on it - the same one-shot handoff as has_return_position above, and
# deliberately NOT saved: it describes this launch, not the run.
var resumed_from_save: bool = false
var surviving_ship_count: int = STARTING_SHIP_COUNT
var surviving_vessel_types: PackedStringArray = PackedStringArray()
# #106: a mirror of what is flying, pushed by Fleet._sync_composition_state()

View file

@ -39,23 +39,6 @@ func _ready() -> void:
camera.position = GameState.return_world_position
camera.zoom = Vector2(GameState.return_camera_zoom, GameState.return_camera_zoom)
GameState.has_return_position = false
elif GameState.resumed_from_save:
# #227: a resumed run used to fall through to the brand-new-game
# branch below, which put the STARTING fleet at the world origin -
# so loading a save stranded the player in empty space with the
# wrong ships while their base sat wherever they had left it.
#
# The mothership is where a wiped-out fleet is already rebuilt (see
# BuildController._check_fleet_wipe), so it is the run's home whether
# or not this is a resume. Falls back to the position the save was
# written at, and only then to the origin, because a run can have no
# mothership standing.
var home: Variant = GameState.get_mothership_position()
var at: Vector2 = home if home != null else GameState.fleet_position
fleet.spawn_ships(GameState.fleet_vessel_types.size(), at,
GameState.fleet_vessel_types)
camera.position = at
GameState.resumed_from_save = false
elif GameState.intro_active:
# #108: the prologue opens with a convoy, and crews it itself. This
# node still runs first, so spawning the ordinary opening fleet here

View file

@ -83,12 +83,8 @@ func _process(delta: float) -> bool:
controller.transfer_button.visible = controller._has_transfer_context()
controller._layout_context_buttons()
var buttons: Array = [controller.building_action_button, controller.hangar_button]
# Rendered width, not the authored rect: #228 shrinks the HUD on a
# short canvas by scaling the node and leaving its rect alone, so
# offset_right overstates where a button actually ends.
for i in range(buttons.size() - 1):
var ends: float = buttons[i].offset_left + buttons[i].size.x * buttons[i].scale.x
if ends > buttons[i + 1].offset_left:
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()

View file

@ -31,9 +31,6 @@ var controller: Node
var readout: Node
var stage: int = 0
var elapsed: float = 0.0
# A desktop canvas (1080 units) and a phone one (3413), where the HUD
# scale is 0.45 and 1.0 respectively.
var shapes: Array = [Vector2i(1920, 1080), Vector2i(720, 1280)]
# Deliberately share a row: a building either offers its own action or a
# hangar, never both, so their rects coinciding is the design.
@ -55,40 +52,23 @@ func _process(delta: float) -> bool:
return false
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
# --- E. at BOTH shapes ---------------------------------------
# #228 shrinks the HUD on a short canvas, so every relationship
# here now has a scale in it. A desktop canvas is what the
# default viewport gives, and the phone is where that scale is
# 1.0 - the arithmetic has to hold at both or one of them is
# being checked by accident.
root.size = shapes.pop_front()
stage = 1
return false
1:
if elapsed < 0.2:
return false
elapsed = 0.0
var label: String = "%.0f-unit canvas" % readout.get_viewport_rect().size.y
if not _check("as laid out at load, %s" % label):
if not _check("as laid out at load"):
return true
# --- C. the path that broke it ------------------------------
controller.context_actions_expanded = false
controller._layout_context_buttons()
if not _check("collapsed, %s" % label):
if not _check("after _layout_context_buttons() collapsed"):
return true
controller.context_actions_expanded = true
controller._layout_context_buttons()
if not _check("expanded, %s" % label):
if not _check("after _layout_context_buttons() expanded"):
return true
if shapes.is_empty():
print("TEST_HUD_LAYOUT: PASS")
quit(0)
return true
root.size = shapes.pop_front()
return false
print("TEST_HUD_LAYOUT: PASS")
quit(0)
return true
return false
@ -130,21 +110,13 @@ func _check(context: String) -> bool:
var b: Control = buttons[b_name]
if a == null or b == null:
continue
if _rendered(a).intersects(_rendered(b)):
if a.get_rect().intersects(b.get_rect()):
return _fail("%s %s overlaps %s %s (%s)" \
% [a_name, _rendered(a), b_name, _rendered(b), context])
% [a_name, a.get_rect(), b_name, b.get_rect(), context])
print("clear of the readout (reaches %.0f) and of each other - %s" % [readout_bottom, context])
return true
# Where a control actually lands, which since #228 is not its layout rect:
# the HUD shrinks on a short canvas by scaling the node and leaving the rect
# at its authored size, so position is in scaled parent units while size is
# in unscaled local ones.
func _rendered(c: Control) -> Rect2:
return Rect2(c.position, c.size * c.scale)
func _is_exclusive(a: String, b: String) -> bool:
for pair in EXCLUSIVE_PAIRS:
if (a == pair[0] and b == pair[1]) or (a == pair[1] and b == pair[0]):

View file

@ -1,118 +0,0 @@
extends SceneTree
# Forgejo #228: the HUD must not be three times larger on a PC than on a
# phone.
#
# The layout is authored in CANVAS units. With stretch "canvas_items" and
# aspect "expand" over a 1920x1080 base, a 720x1280 phone scales by 0.375 and
# gets 1920x3413 canvas units, while ANY 16:9-or-wider screen lands on
# 1920x1080 units however many pixels it really has. The canvas is three
# times shorter, so an unscaled 148-unit button goes from 4.3% of the
# screen's height to 13.7% of it.
#
# Nothing collides - that was checked pair by pair on both canvases and the
# same six pairs overlap on each, all of them same-slot alternatives. The
# fault is purely that everything is enormous.
#
# Covers:
# A. on a phone the HUD is left exactly as authored
# B. on a desktop canvas a control takes a similar FRACTION of the screen
# rather than three times more
# C. the shrink is bounded, so a landscape phone keeps thumb-sized targets
# D. it follows a resize rather than only applying once at startup
const PHONE: Vector2i = Vector2i(720, 1280)
const DESKTOP: Vector2i = Vector2i(1920, 1080)
const BIG_DESKTOP: Vector2i = Vector2i(2560, 1440)
var scene: Node
var hud: Node
var button: Control
var frames: int = 0
var stage: int = 0
var phone_fraction: float = 0.0
func _initialize() -> void:
var gs: Node = root.get_node("GameState")
gs.buildings.clear()
gs.reset_new_game()
gs.intro_active = false
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
current_scene = scene
hud = scene.get_node("UI/HudScale")
button = scene.get_node("UI/MapButton")
func _fraction() -> float:
# Height of a HUD control as a share of the screen's height - the number
# that actually differs between the two, and the one a player sees.
# Rendered height, not the layout rect: the rect keeps its authored size
# and the node is scaled, so scale is where the shrink actually lives.
return (button.size.y * button.scale.y) / button.get_viewport_rect().size.y
func _process(_delta: float) -> bool:
frames += 1
if frames < 10:
return false
frames = 0
match stage:
0:
root.size = PHONE
stage = 1
return false
1:
# --- A. authored size on the shape it was authored for -------
var k: float = hud.hud_scale()
if not is_equal_approx(k, 1.0):
return _fail("the phone HUD is being scaled by %.3f, but it is the authored shape" % k)
phone_fraction = _fraction()
print("phone %s: canvas %.0f tall, scale %.2f, control is %.1f%% of the screen" % [
PHONE, button.get_viewport_rect().size.y, k, phone_fraction * 100.0])
root.size = DESKTOP
stage = 2
return false
2:
# --- B. a similar share of a desktop screen ------------------
var pc_fraction: float = _fraction()
var ratio: float = pc_fraction / maxf(phone_fraction, 0.0001)
print("desktop %s: canvas %.0f tall, scale %.2f, control is %.1f%% of the screen (%.2fx the phone)" % [
DESKTOP, button.get_viewport_rect().size.y, hud.hud_scale(),
pc_fraction * 100.0, ratio])
if ratio > 1.75:
return _fail("a HUD control is %.2fx its phone share on a desktop screen - that is the bug" % ratio)
# --- C. and not shrunk into uselessness ----------------------
if hud.hud_scale() < 0.4:
return _fail("the HUD shrank to %.2f, past anything pressable" % hud.hud_scale())
root.size = BIG_DESKTOP
stage = 3
return false
3:
# --- D. follows the resize -----------------------------------
var big: float = _fraction()
print("big desktop %s: control is %.1f%% of the screen" % [BIG_DESKTOP, big * 100.0])
if absf(big - phone_fraction) / maxf(phone_fraction, 0.0001) > 0.75:
return _fail("a bigger desktop screen did not keep the control proportional (%.1f%% vs %.1f%%)" \
% [big * 100.0, phone_fraction * 100.0])
# Back to the phone: the HUD must return, not stay shrunk.
root.size = PHONE
stage = 4
return false
4:
if not is_equal_approx(hud.hud_scale(), 1.0):
return _fail("returning to the phone shape left the HUD at %.3f" % hud.hud_scale())
if absf(_fraction() - phone_fraction) > 0.001:
return _fail("the HUD did not come back to its authored size on resize")
print("resizing back to the phone restored the authored size")
print("TEST_HUD_SCALES_TO_SCREEN: PASS")
quit(0)
return true
return false
func _fail(message: String) -> bool:
print("TEST_HUD_SCALES_TO_SCREEN: FAIL (%s)" % message)
quit(1)
return true

View file

@ -1,150 +0,0 @@
extends SceneTree
# Forgejo #227: a resumed run comes back at its own mothership.
#
# Loading a save used to fall through to the brand-new-game branch in
# PlanetAtmosphereController, which spawns STARTING_SHIP_COUNT interceptors
# at the world origin. So a resume stranded the player in empty space with
# the wrong ships, while their base sat wherever they had left it - which on
# a charted map is thousands of units away.
#
# Covers:
# A. resuming puts the fleet at the mothership, not at the origin
# B. and crews it with the ships the save actually had
# C. with no mothership standing, it falls back to where the save was made
# D. a NEW game is untouched - still the starting fleet at the origin
const HOME: Vector2 = Vector2(7400.0, -3100.0)
const AWAY: Vector2 = Vector2(-5200.0, 900.0)
var game_state: Node
var scene: Node
var elapsed: float = 0.0
var stage: int = 0
var expected_crew: int = 0
func _initialize() -> void:
game_state = root.get_node("GameState")
SaveGame.clear()
func _fresh_state() -> void:
game_state.buildings.clear()
game_state.extractors.clear()
game_state.reset_progression()
game_state.reset_new_game()
game_state.reset_quests()
game_state.intro_active = false
func _load_scene() -> void:
if scene != null and is_instance_valid(scene):
scene.free()
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
current_scene = scene
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if elapsed < 0.3:
return false
# A run with a base at HOME and the fleet parked far from it.
_fresh_state()
game_state.add_building(GameState.BUILDING_MOTHERSHIP, HOME)
# Capacity for the crew below. Since #219 the fleet holds
# BASE_FLEET_SIZE (1) plus one berth per supplied fleet office,
# so a save with three ships flying is a save that had two
# offices standing - without them spawn_ships clamps to 1 and
# this would assert nothing.
game_state.add_building(GameState.BUILDING_FLEET_OFFICE, HOME + Vector2(400.0, 0.0))
game_state.add_building(GameState.BUILDING_FLEET_OFFICE, HOME + Vector2(800.0, 0.0))
game_state.fleet_vessel_types = PackedStringArray([
GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_INTERCEPTOR,
GameState.VESSEL_INTERCEPTOR])
game_state.fleet_position = AWAY
expected_crew = game_state.fleet_vessel_types.size()
if game_state.max_fleet_size() < expected_crew:
return _fail("the fleet cannot hold %d, so a resumed crew would prove nothing (cap %d)" \
% [expected_crew, game_state.max_fleet_size()])
if not SaveGame.save(game_state):
return _fail("could not write the save this test resumes from")
_fresh_state()
var result: Dictionary = SaveGame.load_into(game_state)
if not bool(result.get("ok", false)):
return _fail("the save would not load: %s" % result.get("reason", ""))
game_state.resumed_from_save = true
_load_scene()
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.6:
return false
var fleet: Node2D = scene.get_node("Fleet")
# --- A. at the mothership, not the origin -------------------
var at: Vector2 = fleet.get_centroid()
if at.distance_to(HOME) > 400.0:
return _fail("resumed at %s, but the mothership is at %s" % [at, HOME])
if at.distance_to(Vector2.ZERO) < 400.0:
return _fail("resumed at the world origin, which is the new-game behaviour")
print("resumed at the mothership (%.0f units from it)" % at.distance_to(HOME))
# --- B. with the crew the save had --------------------------
if fleet.ships.size() != expected_crew:
return _fail("resumed with %d ships, the save had %d" \
% [fleet.ships.size(), expected_crew])
print("resumed with the saved crew of %d" % fleet.ships.size())
if game_state.resumed_from_save:
return _fail("the resume flag was left raised, so the next scene would re-use it")
# --- C. no mothership: fall back to where the save was made --
_fresh_state()
game_state.fleet_vessel_types = PackedStringArray([GameState.VESSEL_INTERCEPTOR])
game_state.fleet_position = AWAY
game_state.resumed_from_save = true
_load_scene()
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.6:
return false
var fleet2: Node2D = scene.get_node("Fleet")
var at2: Vector2 = fleet2.get_centroid()
if at2.distance_to(AWAY) > 400.0:
return _fail("with no mothership, resumed at %s rather than the saved %s" % [at2, AWAY])
print("with no mothership, resumed where the save was written")
# --- D. a new game is untouched -----------------------------
_fresh_state()
game_state.resumed_from_save = false
_load_scene()
elapsed = 0.0
stage = 3
return false
3:
if elapsed < 0.6:
return false
var fleet3: Node2D = scene.get_node("Fleet")
if fleet3.get_centroid().distance_to(Vector2.ZERO) > 400.0:
return _fail("a new game no longer starts at the origin (%s)" % fleet3.get_centroid())
if fleet3.ships.size() != GameState.STARTING_SHIP_COUNT:
return _fail("a new game starts with %d ships, expected %d" \
% [fleet3.ships.size(), GameState.STARTING_SHIP_COUNT])
print("a new game still starts with %d ships at the origin" % fleet3.ships.size())
SaveGame.clear()
print("TEST_RESUME_SPAWN: PASS")
quit(0)
return true
return false
func _fail(message: String) -> bool:
print("TEST_RESUME_SPAWN: FAIL (%s)" % message)
quit(1)
return true

View file

@ -1,78 +0,0 @@
extends Node
# Forgejo #228: the HUD is proportionally three times too big on a PC.
#
# The layout is authored in CANVAS units against a portrait phone. With
# stretch "canvas_items"/"expand" and a 1920x1080 base, a 720x1280 phone
# scales by 0.375 and so gets a canvas 1920x3413 units tall - while ANY
# 16:9-or-wider screen lands on 1920x1080 units whatever its pixel count.
# The canvas is three times shorter, so a 148-unit button goes from 4.3% of
# the screen's height to 13.7% of it. Nothing collides; everything is just
# enormous, and the thumb sticks in particular.
#
# Each HUD element's offsets are measured FROM ITS OWN ANCHOR, so scaling
# them scales the element's size and its distance from the edge it hangs off
# together. That is the whole trick: one multiplier per control, no layout
# rules to re-derive, and anchoring keeps working untouched.
# The canvas the HUD was drawn for. Not a magic number: 1280 / (720 / 1920).
const AUTHORED_CANVAS_HEIGHT: float = 3413.0
# Below this the controls stop being worth pressing. A landscape phone gets
# the same short canvas as a desktop, and there the sticks are still thumb
# targets - so the shrink is bounded rather than purely proportional.
const MIN_SCALE: float = 0.45
# Authored offsets and sizes, captured before anything is touched.
# Re-reading them after a resize would measure a previous scaling and let the
# HUD ratchet away to nothing - the same trap NeonPanel documents.
var _authored: Dictionary = {}
func _ready() -> void:
var layer: Node = get_parent()
for child in layer.get_children():
if child == self or not (child is Control):
continue
var c: Control = child
# Full-bleed things - the modal menus, the breach overlay - hang off
# every edge at once and have nothing to shrink.
if is_equal_approx(c.anchor_left, 0.0) and is_equal_approx(c.anchor_right, 1.0) \
and is_equal_approx(c.anchor_top, 0.0) and is_equal_approx(c.anchor_bottom, 1.0):
continue
_authored[c] = [c.offset_left, c.offset_top,
c.offset_right - c.offset_left, c.offset_bottom - c.offset_top]
get_viewport().size_changed.connect(_apply)
_apply()
func hud_scale() -> float:
var height: float = get_viewport().get_visible_rect().size.y
if height <= 0.0:
return 1.0
# Only ever shrinks. A canvas TALLER than the phone's is not a reason to
# inflate the HUD over the game.
return clampf(height / AUTHORED_CANVAS_HEIGHT, MIN_SCALE, 1.0)
func _apply() -> void:
var k: float = hud_scale()
for control in _authored:
if not is_instance_valid(control):
continue
var a: Array = _authored[control]
var c: Control = control
# Position moves toward the edge the element hangs off; the RECT keeps
# its authored size and the node is scaled instead.
#
# Scaling the rect alone was wrong: half the HUD draws its own
# contents at fixed canvas sizes - ResourceReadout lays its rows out
# in code - so a shrunken rect kept full-size content inside it, and
# the build button slid up underneath the resource list. Scaling the
# node takes the drawn content with it.
c.pivot_offset = Vector2.ZERO
c.offset_left = float(a[0]) * k
c.offset_top = float(a[1]) * k
c.offset_right = c.offset_left + float(a[2])
c.offset_bottom = c.offset_top + float(a[3])
c.scale = Vector2(k, k)

View file

@ -140,7 +140,6 @@ func _on_continue_pressed() -> void:
# A resumed run never replays the prologue, whatever it was doing when
# it was written.
GameState.intro_active = false
GameState.resumed_from_save = true
_start("res://world/Main.tscn")

View file

@ -79,13 +79,7 @@ func rows_height() -> float:
# Bottom edge of the rows in this Control's parent space - the line anything
# stacked below the readout has to clear.
func content_bottom() -> float:
# Rendered, not laid out. #228 scales the HUD down on a short canvas by
# scaling the NODE while leaving its rect at the authored size, so
# rows_height() is in unscaled local units while position is already in
# scaled parent ones. Mixing the two overstated this by more than a
# button's height and pushed the columns down to clear space that was
# not occupied.
return position.y + rows_height() * scale.y
return position.y + rows_height()
func _add_glyph(resource_type: String, at: Vector2) -> void:

View file

@ -17,7 +17,6 @@
[ext_resource type="Script" path="res://world/Starfield.gd" id="1"]
[ext_resource type="Script" path="res://ui/HudScale.gd" id="hud_scale"]
[ext_resource type="Script" path="res://vessels/Fleet.gd" id="2"]
[ext_resource type="Script" path="res://controllers/GameController.gd" id="3"]
[ext_resource type="Script" path="res://enemies/EnemySpawner.gd" id="4"]
@ -152,9 +151,6 @@ script = ExtResource("12")
[node name="UI" type="CanvasLayer" parent="."]
layer = 1
[node name="HudScale" type="Node" parent="UI"]
script = ExtResource("hud_scale")
[node name="ResourceReadout" type="Control" parent="UI"]
mouse_filter = 2
offset_left = 28