diff --git a/controllers/BuildController.gd b/controllers/BuildController.gd index c765963..42cd7e6 100644 --- a/controllers/BuildController.gd +++ b/controllers/BuildController.gd @@ -63,6 +63,7 @@ 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) @@ -97,6 +98,22 @@ 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: @@ -109,7 +126,10 @@ func _seat_hud_columns() -> void: highest = minf(highest, button.position.y) if is_inf(highest): return - var shift: float = readout.content_bottom() + HUD_COLUMN_MARGIN - highest + # 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 if shift <= 0.0: return for button in buttons: @@ -222,7 +242,14 @@ func _clear_detected_context() -> void: func _layout_context_buttons() -> void: - var row_top: float = build_button.position.y + build_button.size.y + HUD_ROW_GAP + # 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_height: float = build_button.size.y action_button.offset_top = row_top action_button.offset_bottom = row_top + row_height @@ -234,8 +261,8 @@ func _layout_context_buttons() -> void: for i in range(buttons.size()): var button: Control = buttons[i] button.visible = true - button.offset_left = 188 + i * 164 - button.offset_right = button.offset_left + 148 + button.offset_left = (188.0 + float(i) * 164.0) * k + button.offset_right = button.offset_left + 148.0 button.offset_top = row_top button.offset_bottom = row_top + row_height diff --git a/core/GameState.gd b/core/GameState.gd index b8a8e5a..f804ecf 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -20,6 +20,10 @@ 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() diff --git a/planets/PlanetAtmosphereController.gd b/planets/PlanetAtmosphereController.gd index 75a93fb..7eb23e1 100644 --- a/planets/PlanetAtmosphereController.gd +++ b/planets/PlanetAtmosphereController.gd @@ -39,6 +39,23 @@ 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 diff --git a/tests/test_hangar_management.gd b/tests/test_hangar_management.gd index 4233217..135e6c3 100644 --- a/tests/test_hangar_management.gd +++ b/tests/test_hangar_management.gd @@ -83,8 +83,12 @@ 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): - if buttons[i].offset_right > buttons[i + 1].offset_left: + var ends: float = buttons[i].offset_left + buttons[i].size.x * buttons[i].scale.x + if ends > buttons[i + 1].offset_left: _fail("context buttons %d and %d overlap" % [i, i + 1]) return true controller._collapse_context_actions() diff --git a/tests/test_hud_layout.gd b/tests/test_hud_layout.gd index c6f7226..2fa4b8a 100644 --- a/tests/test_hud_layout.gd +++ b/tests/test_hud_layout.gd @@ -31,6 +31,9 @@ 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. @@ -52,23 +55,40 @@ func _process(delta: float) -> bool: return false scene.get_node("AsteroidField").set_process(false) scene.get_node("EnemySpawner").set_process(false) - if not _check("as laid out at load"): + # --- 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): return true # --- C. the path that broke it ------------------------------ controller.context_actions_expanded = false controller._layout_context_buttons() - if not _check("after _layout_context_buttons() collapsed"): + if not _check("collapsed, %s" % label): return true controller.context_actions_expanded = true controller._layout_context_buttons() - if not _check("after _layout_context_buttons() expanded"): + if not _check("expanded, %s" % label): return true - print("TEST_HUD_LAYOUT: PASS") - quit(0) - return true + if shapes.is_empty(): + print("TEST_HUD_LAYOUT: PASS") + quit(0) + return true + root.size = shapes.pop_front() + return false return false @@ -110,13 +130,21 @@ func _check(context: String) -> bool: var b: Control = buttons[b_name] if a == null or b == null: continue - if a.get_rect().intersects(b.get_rect()): + if _rendered(a).intersects(_rendered(b)): return _fail("%s %s overlaps %s %s (%s)" \ - % [a_name, a.get_rect(), b_name, b.get_rect(), context]) + % [a_name, _rendered(a), b_name, _rendered(b), 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]): diff --git a/tests/test_hud_scales_to_screen.gd b/tests/test_hud_scales_to_screen.gd new file mode 100644 index 0000000..33b8d12 --- /dev/null +++ b/tests/test_hud_scales_to_screen.gd @@ -0,0 +1,118 @@ +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 diff --git a/tests/test_resume_spawn.gd b/tests/test_resume_spawn.gd new file mode 100644 index 0000000..f32a09b --- /dev/null +++ b/tests/test_resume_spawn.gd @@ -0,0 +1,150 @@ +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 diff --git a/ui/HudScale.gd b/ui/HudScale.gd new file mode 100644 index 0000000..5d306ba --- /dev/null +++ b/ui/HudScale.gd @@ -0,0 +1,78 @@ +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) diff --git a/ui/MainMenu.gd b/ui/MainMenu.gd index 3d639c5..214d83f 100644 --- a/ui/MainMenu.gd +++ b/ui/MainMenu.gd @@ -140,6 +140,7 @@ 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") diff --git a/ui/ResourceReadout.gd b/ui/ResourceReadout.gd index a2f6d97..53fbe38 100644 --- a/ui/ResourceReadout.gd +++ b/ui/ResourceReadout.gd @@ -79,7 +79,13 @@ 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: - return position.y + rows_height() + # 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 func _add_glyph(resource_type: String, at: Vector2) -> void: diff --git a/world/Main.tscn b/world/Main.tscn index 31147f7..1d70f1b 100644 --- a/world/Main.tscn +++ b/world/Main.tscn @@ -17,6 +17,7 @@ [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"] @@ -151,6 +152,9 @@ 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