diff --git a/buildings/BuildingHitbox.gd b/buildings/BuildingHitbox.gd index 531a143..c9cc451 100644 --- a/buildings/BuildingHitbox.gd +++ b/buildings/BuildingHitbox.gd @@ -15,12 +15,54 @@ extends Area2D # for. const STRIKE_INTERVAL: float = 0.7 +# #165: how fast a friendly vessel has to be going before touching a structure +# counts as hitting it. Comfortably above the speed a fleet drifts onto its own +# mothership to unload at, and below cruise - so ordinary docking is free and +# flying INTO a building is not. +const RAM_SPEED: float = 420.0 +const RAM_DAMAGE: int = 4 +const RAM_DAMAGE_MAX: int = 12 var _strike_timer: float = 0.0 func _ready() -> void: add_to_group("player_buildings") + area_entered.connect(_on_area_entered) + + +# #165: a vessel of the player's own, arriving fast enough, damages what it +# hits. +# +# On ENTRY, not on the strike tick above. The tick is right for a siege - an +# enemy parked on a structure is besieging it - but wrong for a collision: a +# vessel is only moving fast for the instant it arrives, and by the time a +# 0.7s tick came round it had already braked to nothing. Measured: a ship sent +# in at 900 did no damage at all until this moved to entry. +# +# Below the threshold it is docking, not a collision. The fleet sits on its own +# mothership constantly to bank ore, and if resting there hurt, the economy +# loop would grind the player's own buildings down. +func _on_area_entered(area: Area2D) -> void: + if not is_instance_valid(area) or not area.is_in_group("player_ships"): + return + # A vessel stationed at an outpost is not ramming it. It launches from + # there, patrols, and comes back - repeatedly, and at speed. Only the + # fleet can collide with a structure. + if area.is_in_group("garrison_ships"): + return + var speed: float = float(area.velocity.length()) if "velocity" in area else 0.0 + if speed < RAM_SPEED: + return + var id: int = building_id() + if id == 0: + return + # Scaled by how much faster than the threshold it was going, so a glancing + # touch above the line is not a full-speed charge, and capped so no engine + # upgrade turns a vessel into a wrecking ball. + var impact: int = mini(int(round(RAM_DAMAGE * speed / RAM_SPEED)), RAM_DAMAGE_MAX) + GameState.damage_building(id, impact) + area.take_damage(1) func building_id() -> int: diff --git a/controllers/BuildController.gd b/controllers/BuildController.gd index c0c3571..d454d63 100644 --- a/controllers/BuildController.gd +++ b/controllers/BuildController.gd @@ -43,7 +43,10 @@ const VESSEL_DEFINITIONS = preload("res://vessels/VesselDefinitions.gd") ] @onready var lab_detail: Label = get_node("../UI/LabMenu/DetailLabel") @onready var lab_status: Label = get_node("../UI/LabMenu/StatusLabel") -@onready var lab_buy_button: IconButton = get_node("../UI/LabMenu/BuyButton") +# What the buy button's `enabled` used to carry. The button is gone (#164) but +# the question it answered - is this option actually purchasable right now - +# still gates the confirming tap. +var lab_can_buy: bool = false @onready var lab_close_button: IconButton = get_node("../UI/LabMenu/CloseButton") @onready var lab_upgrade_buttons: Array = [ get_node("../UI/LabMenu/Upgrade0"), get_node("../UI/LabMenu/Upgrade1"), @@ -283,7 +286,6 @@ func _ready() -> void: planning_panel.changed.connect(_refresh_planning) _configure_build_menu_entries() lab_close_button.pressed.connect(func(): lab_menu.visible = false) - lab_buy_button.pressed.connect(_on_lab_buy) for i in range(lab_vessel_buttons.size()): lab_vessel_buttons[i].pressed.connect(_on_lab_vessel.bind(i)) for i in range(lab_upgrade_buttons.size()): @@ -717,6 +719,10 @@ const MENU_LABEL_SHARE: float = 0.46 const MENU_ONE_LINE_HALF: float = 120.0 const MENU_LABEL_H_ONE: float = 52.0 const MENU_LABEL_H_TWO: float = 68.0 +# Breathing room around a measured caption and a measured price, so glyphs +# never sit flush against the row below them. +const MENU_LABEL_PADDING: float = 10.0 +const MENU_COST_PADDING: float = 12.0 const MENU_SIDE_MARGIN: float = 24.0 const MENU_HEADER_GAP: float = 16.0 @@ -773,9 +779,51 @@ func menu_label_half(columns: int) -> float: return menu_column_spacing(columns) * MENU_LABEL_SHARE +# How tall a caption of `text` really is at this column width, measured +# against the font that will draw it. +# +# This used to be a guess from the column width alone: wide column, assume one +# line; narrow column, assume two. That is right for most names and wrong for +# the long ones - "Spacial Workshop" wrapped to two lines in a column the rule +# called wide enough for one, and the second line came down on top of the +# price underneath it (#164). A name is as tall as it is; asking the font is +# both simpler and correct for names nobody has written yet. +func measured_label_height(label: Label, text: String, columns: int) -> float: + var font: Font = label.get_theme_font("font") + var size: int = label.get_theme_font_size("font_size") + if font == null or size <= 0 or text == "": + return MENU_LABEL_H_ONE + var width: float = menu_label_half(columns) * 2.0 + var height: float = font.get_multiline_string_size(text, + HORIZONTAL_ALIGNMENT_CENTER, width, size).y + # A floor of the one-line height keeps single-line captions on the rhythm + # the menu already had, rather than tightening around the glyphs. + return maxf(MENU_LABEL_H_ONE, height + MENU_LABEL_PADDING) + + +# The tallest caption among the entries currently shown. The grid is a grid - +# every row uses the same offsets - so one long name sets the height for all +# of them, which is what keeps the columns lined up. func menu_label_height(columns: int) -> float: - return MENU_LABEL_H_ONE if menu_label_half(columns) >= MENU_ONE_LINE_HALF \ - else MENU_LABEL_H_TWO + var tallest: float = MENU_LABEL_H_ONE + for entry in build_menu_entries: + var label: Label = entry[2] + if label.visible: + tallest = maxf(tallest, measured_label_height(label, label.text, columns)) + return tallest + + +# The tallest price among the entries currently shown, asked of the tags +# themselves rather than assumed. A structure costing three resources stacks +# three lines (see PriceTag), and the fixed height this replaces was sized for +# two - so the third line ran into whatever the grid put underneath (#164). +func menu_cost_height() -> float: + var tallest: float = MENU_COST_H + for entry in build_menu_entries: + var tag: Control = entry[3] + if tag.visible and tag.has_method("wanted_height"): + tallest = maxf(tallest, float(tag.wanted_height()) + MENU_COST_PADDING) + return tallest func menu_cost_offset(columns: int) -> float: @@ -784,7 +832,7 @@ func menu_cost_offset(columns: int) -> float: # How tall one row is, end to end - the cost strip is the last thing in it. func menu_row_height(columns: int) -> float: - return menu_cost_offset(columns) + MENU_COST_H + return menu_cost_offset(columns) + menu_cost_height() # The fewest columns whose rows fit the band. Two is the shape the menu is @@ -840,6 +888,13 @@ func _layout_build_menu() -> void: entry[2].visible = visible entry[3].visible = visible if visible: + # Priced BEFORE anything is measured. The row's height now comes + # from the price tag's own wanted_height(), and _refresh_build_ + # status() sets prices only after calling this - so on the first + # layout every tag was still empty and every row was sized for a + # price that was not there yet. show_price() early-outs when the + # price has not changed, so restating it here costs nothing. + entry[3].show_price(GameState.building_costs(String(entry[0]))) shown.append(entry) var columns: int = menu_columns(shown.size()) @@ -848,6 +903,7 @@ func _layout_build_menu() -> void: var label_half: float = menu_label_half(columns) var label_h: float = menu_label_height(columns) var cost_offset: float = menu_cost_offset(columns) + var cost_height: float = menu_cost_height() for i in range(shown.size()): var row: int = i / columns @@ -859,7 +915,7 @@ func _layout_build_menu() -> void: var top: float = menu_row_top(row, rows, columns) _place_entry(shown[i][1], cx, top, MENU_BUTTON_HALF, MENU_BUTTON_H) _place_entry(shown[i][2], cx, top + MENU_LABEL_OFFSET, label_half, label_h) - _place_entry(shown[i][3], cx, top + cost_offset, MENU_COST_HALF, MENU_COST_H) + _place_entry(shown[i][3], cx, top + cost_offset, MENU_COST_HALF, cost_height) func _refresh_build_status() -> void: @@ -1260,6 +1316,78 @@ func _build_craft_price_strips() -> void: # option block is the same height it was and the row below it does not move. const LAB_TAG_H: float = 38.0 const LAB_LABEL_H: float = 40.0 +# Room around a stacked price, so its last line never sits flush against the +# option underneath. +const LAB_TAG_PADDING: float = 8.0 +# Between the bottom of one option's price and the top of the option below. +const LAB_ROW_GAP: float = 18.0 + + +# How tall the lab's price strips need to be: the tallest any of them wants, +# asked of the tags rather than assumed. Every strip gets the same height so +# the two columns stay level with each other. +# Re-seats the option grid around whatever the prices actually need. The two +# rows are authored at fixed offsets in the scene, which is fine while every +# price is one line and wrong the moment one stacks - the second row's buttons +# sat where the first row's price had grown into (#164). Row one keeps its +# authored place; row two is pushed to clear it. +# The vessel strip is authored at fixed offsets, which fits exactly as many +# vessels as there were when it was written. The catalog has grown since - the +# eighth ran off the right edge, its caption clipped in half - so the row is +# spaced from the panel's actual width and centred, the way every other grid +# in this menu already is. +const LAB_VESSEL_HALF: float = 30.0 +const LAB_VESSEL_NATURAL_SPACING: float = 90.0 +const LAB_VESSEL_MARGIN: float = 24.0 + + +func _layout_lab_vessels() -> void: + var shown: Array = [] + for i in range(lab_vessel_buttons.size()): + if lab_vessel_buttons[i].visible: + shown.append(i) + if shown.is_empty(): + return + var room: float = lab_menu.size.x - LAB_VESSEL_MARGIN * 2.0 + if room <= 0.0: + room = 672.0 + var spacing: float = minf(LAB_VESSEL_NATURAL_SPACING, room / float(shown.size())) + for slot in range(shown.size()): + var i: int = shown[slot] + var cx: float = (float(slot) - (float(shown.size()) - 1.0) * 0.5) * spacing + lab_vessel_buttons[i].offset_left = cx - LAB_VESSEL_HALF + lab_vessel_buttons[i].offset_right = cx + LAB_VESSEL_HALF + # The caption is allowed the full column, so two-word names wrap + # inside their own slot instead of over the vessel beside them. + lab_vessel_labels[i].offset_left = cx - spacing * 0.5 + lab_vessel_labels[i].offset_right = cx + spacing * 0.5 + + +func _resize_lab_price_tags() -> void: + var height: float = lab_tag_height() + for tag in lab_price_tags: + if tag != null: + tag.offset_bottom = tag.offset_top + height + if lab_price_tags.size() < 3 or lab_upgrade_buttons.size() < 3: + return + var first_row_bottom: float = lab_price_tags[0].offset_bottom + var shift: float = first_row_bottom + LAB_ROW_GAP - lab_upgrade_buttons[2].offset_top + if absf(shift) < 0.5: + return + for i in range(2, lab_upgrade_buttons.size()): + for node in [lab_upgrade_buttons[i], lab_upgrade_labels[i], lab_price_tags[i]]: + if node == null: + continue + node.offset_top += shift + node.offset_bottom += shift + + +func lab_tag_height() -> float: + var tallest: float = LAB_TAG_H + for tag in lab_price_tags: + if tag != null and tag.has_method("wanted_height"): + tallest = maxf(tallest, float(tag.wanted_height()) + LAB_TAG_PADDING) + return tallest func _build_lab_price_tags() -> void: @@ -1278,7 +1406,11 @@ func _build_lab_price_tags() -> void: # together occupy what the two-line caption used to. label.offset_bottom = label.offset_top + LAB_LABEL_H tag.offset_top = label.offset_bottom + 2.0 - tag.offset_bottom = tag.offset_top + LAB_TAG_H + # Tall enough for the tallest price any upgrade carries, not for the + # one the constant was written against. An upgrade costing ore AND a + # research point stacks two lines (see PriceTag), and the second was + # landing on the option below it (#164). + tag.offset_bottom = tag.offset_top + lab_tag_height() lab_menu.add_child(tag) lab_price_tags.append(tag) @@ -1388,11 +1520,20 @@ func _on_lab_vessel(index: int) -> void: _refresh_lab() +# #164: the separate buy button is gone, so the option itself is the control. +# Same rule as the research tree (#83): the first tap selects and fills in the +# detail below it, a second tap on the SAME option buys - no timed double-tap +# to miss, and never a blind commitment, because the first tap always shows +# what the second will cost. func _on_lab_upgrade(index: int) -> void: if index < 0 or index >= _lab_options().size(): return + var confirming: bool = index == lab_choice lab_choice = index lab_status.text = "" + if confirming and lab_can_buy: + _on_lab_buy() + return _refresh_lab() @@ -1456,10 +1597,16 @@ func _refresh_lab() -> void: lab_upgrade_buttons[i].highlighted = i == lab_choice lab_upgrade_buttons[i].enabled = true lab_upgrade_buttons[i].queue_redraw() + _layout_lab_vessels() + + # Re-height the strips now that every price on show is set. They are + # created before any price exists, so their built-in size is only a + # starting point. + _resize_lab_price_tags() + if options.is_empty(): lab_detail.text = "" - lab_buy_button.enabled = false - lab_buy_button.queue_redraw() + lab_can_buy = false return lab_choice = clampi(lab_choice, 0, options.size() - 1) @@ -1469,7 +1616,7 @@ func _refresh_lab() -> void: if choice_points > 0: lab_detail.text += "\nResearch points: %d/%d" % [GameState.research_points, choice_points] var queued: Dictionary = GameState.lab_research_order(lab_building_id) - lab_buy_button.enabled = queued.is_empty() and (choice.unlock or not GameState.upgrade_maxed(choice.id)) + lab_can_buy = queued.is_empty() and (choice.unlock or not GameState.upgrade_maxed(choice.id)) if not queued.is_empty(): var queued_points: int = GameState.research_point_cost(String(queued.id)) if not bool(queued.started) and GameState.research_points < queued_points: @@ -1480,7 +1627,6 @@ func _refresh_lab() -> void: % [queued_points, "" if queued_points == 1 else "s", GameState.research_points] else: lab_status.text = "%s %d%%" % ["Researching" if bool(queued.started) else "Waiting for resources", int(100.0 * float(queued.elapsed) / maxf(GameState.lab_research_time(), 0.001))] - lab_buy_button.queue_redraw() func _upgrade_icon(option: Dictionary) -> IconButton.Icon: diff --git a/core/GameState.gd b/core/GameState.gd index adcab6b..501960e 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -984,6 +984,17 @@ func is_outpost(building: Dictionary) -> bool: return building_has_capability(building, "garrison") +# #163: how many vessels are actually standing at outposts, across all of +# them. Counts the CREW rather than what was asked for - a vessel still in +# transit has not started holding anything yet. +func garrisoned_vessel_count() -> int: + var total: int = 0 + for building in buildings: + if is_outpost(building): + total += garrison_crew(int(building.id)).size() + return total + + func garrison_desired(id: int) -> Dictionary: var b: Dictionary = find_building(id) if b.is_empty() or not is_outpost(b): @@ -3132,6 +3143,9 @@ func quest_progress(quest: Dictionary) -> Vector2i: return Vector2i(1 if vessel_is_unlocked(String(goal.get("vessel", ""))) else 0, 1) QUESTS.GOAL_RESEARCH: return Vector2i(1 if is_unlocked(String(goal.get("node", ""))) else 0, 1) + QUESTS.GOAL_GARRISONED: + var stationed: int = int(goal.get("count", 1)) + return Vector2i(mini(garrisoned_vessel_count(), stationed), stationed) QUESTS.GOAL_BANKED: var banked: int = int(goal.get("amount", 1)) return Vector2i(mini(banked_resource(String(goal.get("resource", diff --git a/core/Quests.gd b/core/Quests.gd index 80143af..8ca948c 100644 --- a/core/Quests.gd +++ b/core/Quests.gd @@ -45,6 +45,11 @@ const GOAL_RESEARCH: String = "research" # player can lay hands on. GOAL_RESOURCE counts the hold as well, and the # hold is exactly what this quest is about being too small. const GOAL_BANKED: String = "banked" +# #163: a vessel actually STATIONED at an outpost. Building one and crewing it +# are two different acts - the chain teaches the second, because an empty +# outpost holds nothing and the player has no reason to discover the hangar +# that fills it. +const GOAL_GARRISONED: String = "garrisoned" const CHAIN: Array = [ { @@ -199,6 +204,14 @@ const CHAIN: Array = [ "brief": "We cannot be everywhere. Research Forward Bases and build an outpost - vessels stationed there hold ground we are not standing on.", "done": "The outpost is crewed. That is one place we no longer have to fly back to.", }, + { + "id": "garrison", + "title": "STANDING WATCH", + "objective": "Station a vessel at the outpost", + "goal": {"kind": GOAL_GARRISONED, "count": 1}, + "brief": "An empty outpost is a building. Open its hangar and station a vessel there - it holds that ground whether we are there or not.", + "done": "The outpost has a crew. It watches that approach now, and we do not have to.", + }, { "id": "worlds", "title": "THE SURVEY", diff --git a/surface/PlanetSurface.tscn b/surface/PlanetSurface.tscn index 7e3285a..ebb53d5 100644 --- a/surface/PlanetSurface.tscn +++ b/surface/PlanetSurface.tscn @@ -139,27 +139,6 @@ offset_bottom = 308.0 script = ExtResource("6") icon = 44 -[node name="QuestPanel" type="Control" parent="UI" groups=["modal_menus"]] -visible = false -mouse_filter = 2 -anchor_right = 1.0 -anchor_bottom = 1.0 -offset_left = 24.0 -offset_top = 24.0 -offset_right = -24.0 -offset_bottom = -24.0 -script = ExtResource("quest_panel") - -[node name="CloseButton" type="Control" parent="UI/QuestPanel"] -anchor_left = 1.0 -anchor_right = 1.0 -offset_left = -140.0 -offset_right = -24.0 -offset_top = 20.0 -offset_bottom = 136.0 -script = ExtResource("6") -icon = 1 - [node name="Joystick" type="Control" parent="UI"] mouse_filter = 2 script = ExtResource("7") @@ -310,6 +289,27 @@ offset_right = -252 offset_top = -130 offset_bottom = -26 +[node name="QuestPanel" type="Control" parent="UI" groups=["modal_menus"]] +visible = false +mouse_filter = 2 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 24.0 +offset_top = 24.0 +offset_right = -24.0 +offset_bottom = -24.0 +script = ExtResource("quest_panel") + +[node name="CloseButton" type="Control" parent="UI/QuestPanel"] +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -140.0 +offset_right = -24.0 +offset_top = 20.0 +offset_bottom = 136.0 +script = ExtResource("6") +icon = 1 + [node name="NarrationBanner" type="Control" parent="UI"] visible = false mouse_filter = 2 diff --git a/tests/_build_menu_preview.gd b/tests/_build_menu_preview.gd index 3074dc0..871915c 100644 --- a/tests/_build_menu_preview.gd +++ b/tests/_build_menu_preview.gd @@ -1,22 +1,22 @@ extends Node2D -# Preview, not a test. Opens the build menu itself, so the three-row layout -# added for the Transport Hub (#18) can be checked for fit on the real phone -# viewport - every button, label and price moved, and a bad offset is -# invisible to a headless test. +# Preview, not a test - the build menu with every structure unlocked, so the +# long captions and the three-resource prices are on screen together. # -# bash tests/screenshot.sh res://tests/_build_menu_preview.tscn /tmp/menu.png 20 720x1280 +# bash tests/screenshot.sh res://tests/_build_menu_preview.tscn /tmp/b.png 25 720x1280 func _ready() -> void: - GameState.buildings.clear() - GameState.extractors.clear() - GameState.rocks = 500 - # Everything researched, so nothing is greyed out and the whole menu is - # legible at full strength. - # Partly researched, to show the menu repacking around what is hidden. - GameState.unlocked = ["shipyards", "logistics"] + GameState.reset_progression() + for node in GameState.SKILL_TREE: + if not GameState.is_unlocked(String(node.id)): + GameState.unlocked.append(String(node.id)) + for resource_type in GameState.resource_types(): + GameState.bank_inventory.set_amount(resource_type, 1500) var main: Node = load("res://world/Main.tscn").instantiate() add_child(main) - main.get_node("UI/BuildMenu").visible = true - main.get_node("BuildController")._refresh_build_status() + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + var build: Node = main.get_node("BuildController") + build.build_menu.visible = true + build._refresh_build_status() diff --git a/tests/test_building_collateral.gd b/tests/test_building_collateral.gd new file mode 100644 index 0000000..2818594 --- /dev/null +++ b/tests/test_building_collateral.gd @@ -0,0 +1,134 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_building_collateral.gd +# Forgejo #165: a gas bomb and a vessel flying fast enough both damage a +# structure. The blast used to stop politely at anything the player owned, +# which made a bomb dropped on a besieged mothership a free way to sweep the +# attackers off it. +# +# Covers: +# A. a bomb landing on a structure damages it +# B. a bomb landing clear of it does not - the radius is the radius, and +# this is a weapon that has to be aimed +# C. a vessel resting against a structure does NOT damage it. The fleet +# sits on its own mothership constantly to bank ore; if that hurt, the +# game's own economy loop would grind its buildings down +# D. a vessel arriving fast does + +var game_state: Node +var main: Node +var fleet: Node +var building_id: int = 0 +var stage: int = 0 +var elapsed: float = 0.0 +var hp_before: int = 0 +const BUILDING_AT := Vector2(1200.0, 0.0) + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.reset_progression() + main = load("res://world/Main.tscn").instantiate() + root.add_child(main) + current_scene = main + fleet = main.get_node("Fleet") + + +func _hp() -> int: + return int(game_state.find_building(building_id).get("hp", 0)) + + +func _detonate_at(at: Vector2) -> void: + var bomb: Node2D = load("res://vfx/GasBombEffect.gd").new() + main.add_child(bomb) + bomb.target = at + bomb.damage = 6 + bomb._explode() + bomb.queue_free() + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.4: + return false + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"): + if is_instance_valid(hazard): + hazard.queue_free() + main.get_node("BuildingField").place(GameState.BUILDING_MOTHERSHIP, BUILDING_AT) + building_id = int(game_state.buildings[0].id) + if _hp() <= 0: + return _fail("the structure has no hit points to lose") + stage = 1 + elapsed = 0.0 + return false + 1: + if elapsed < 0.2: + return false + # --- B. a bomb well clear of it does nothing ------------------ + hp_before = _hp() + _detonate_at(BUILDING_AT + Vector2(900.0, 0.0)) + if _hp() != hp_before: + return _fail("a bomb 900 away still damaged the structure") + print("a bomb clear of the structure leaves it alone") + + # --- A. a bomb on top of it does -------------------------------- + _detonate_at(BUILDING_AT) + if _hp() >= hp_before: + return _fail("a bomb landing on the structure did not damage it (hp %d)" % _hp()) + print("a bomb on the structure took it from %d to %d" % [hp_before, _hp()]) + + # --- C. resting against it is free ------------------------------ + hp_before = _hp() + fleet.spawn_ships(1, BUILDING_AT, PackedStringArray([GameState.VESSEL_INTERCEPTOR])) + for ship in fleet.ships: + ship.global_position = BUILDING_AT + ship.velocity = Vector2.ZERO + stage = 2 + elapsed = 0.0 + return false + 2: + # Long enough for several strike ticks to have passed. + if elapsed < 2.0: + return false + for ship in fleet.ships: + ship.velocity = Vector2.ZERO + if _hp() != hp_before: + return _fail("a vessel resting on the structure damaged it (%d -> %d) - the fleet docks here to bank ore" \ + % [hp_before, _hp()]) + print("a vessel parked on the structure for %.1fs did nothing to it" % elapsed) + + # --- D. arriving fast does -------------------------------------- + hp_before = _hp() + for ship in fleet.ships: + # Placed clear and sent IN, because the impact is detected on + # entry - a ship spawned already overlapping never enters. + ship.global_position = BUILDING_AT - Vector2(400.0, 0.0) + ship.velocity = Vector2(900.0, 0.0) + ship.set_move_target(BUILDING_AT + Vector2(600.0, 0.0)) + stage = 3 + elapsed = 0.0 + return false + 3: + if elapsed < 1.2: + return false + if _hp() >= hp_before: + return _fail("a vessel arriving at 900 did not damage the structure") + print("a vessel arriving fast took it from %d to %d" % [hp_before, _hp()]) + + print("TEST_BUILDING_COLLATERAL: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_BUILDING_COLLATERAL: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_building_collateral.gd.uid b/tests/test_building_collateral.gd.uid new file mode 100644 index 0000000..8e30e19 --- /dev/null +++ b/tests/test_building_collateral.gd.uid @@ -0,0 +1 @@ +uid://bqsx7ba1p3y8s diff --git a/tests/test_intro_scene.gd b/tests/test_intro_scene.gd index aae8f1a..c2ccebd 100644 --- a/tests/test_intro_scene.gd +++ b/tests/test_intro_scene.gd @@ -455,6 +455,20 @@ func _process(delta: float) -> bool: print("skipping clears %d props and lands where playing it through does" \ % skipped_props.size()) + # #161: the prologue widens the formation into a freight convoy + # by writing over game_state.formation. Finishing has to put the + # fleet's own wedge back, or the player flies the convoy's + # 600-unit spread for the rest of the game - a shape nobody chose, + # which reads as the fleet having drifted apart. + for i in range(mini(game_state.formation.size(), + game_state.DEFAULT_FORMATION.size())): + if not game_state.formation[i].is_equal_approx(game_state.DEFAULT_FORMATION[i]): + print("TEST_INTRO_SCENE: FAIL (slot %d is %s after the prologue, expected the default %s)" \ + % [i, game_state.formation[i], game_state.DEFAULT_FORMATION[i]]) + quit(1) + return true + print("formation restored to the fleet's own wedge") + print("TEST_INTRO_SCENE: PASS") quit(0) return true diff --git a/tests/test_research_double_tap.gd b/tests/test_research_double_tap.gd index 3495ec7..044f2af 100644 --- a/tests/test_research_double_tap.gd +++ b/tests/test_research_double_tap.gd @@ -14,6 +14,10 @@ extends SceneTree # D. moving to a DIFFERENT node resets that - two taps on two nodes must # never add up to a purchase # E. tapping an unaffordable node twice buys nothing and says why +# F. one physical tap counts ONCE, even though the device delivers it twice +# - as a touch and as the mouse button emulated from it. It did not, and +# that is #162: the duplicate found the node already selected, took +# itself for the confirming tap, and bought the research on first touch var game_state: Node var main: Node @@ -36,20 +40,43 @@ func _initialize() -> void: tree_view = main.get_node("UI/SkillMenu/Tree") +func _node_position(id: String) -> Vector2: + return tree_view.global_position + tree_view.node_screen_pos(game_state.skill_node(id)) + + +# ONE physical tap, delivered the way a real device delivers it: as a touch +# AND as the mouse button Godot emulates from it. Both settings are on +# (emulate_mouse_from_touch and emulate_touch_from_mouse), and the menu's +# dispatcher routes both kinds to the tree - so a handler that is not +# idempotent per gesture sees every tap twice. It did, and that made the FIRST +# tap buy (#162). Going through both paths here is the whole point; tapping +# only one of them would pass while the game stayed broken. func _tap(id: String) -> void: - # Straight at the node's own position in the tree's local space, so this - # exercises the same hit test a finger does. - var node: Dictionary = game_state.skill_node(id) - var event := InputEventScreenTouch.new() - event.index = 1 - event.pressed = true - event.position = tree_view.global_position + tree_view.node_screen_pos(node) - tree_view.handle_touch(event) + var at: Vector2 = _node_position(id) + var press := InputEventScreenTouch.new() + press.index = 1 + press.pressed = true + press.position = at + tree_view.handle_touch(press) + + var emulated := InputEventMouseButton.new() + emulated.button_index = MOUSE_BUTTON_LEFT + emulated.pressed = true + emulated.position = at + emulated.global_position = at + tree_view.handle_mouse_button(emulated) + var release := InputEventScreenTouch.new() release.index = 1 release.pressed = false - release.position = event.position + release.position = at tree_view.handle_touch(release) + var emulated_release := InputEventMouseButton.new() + emulated_release.button_index = MOUSE_BUTTON_LEFT + emulated_release.pressed = false + emulated_release.position = at + emulated_release.global_position = at + tree_view.handle_mouse_button(emulated_release) func _process(delta: float) -> bool: diff --git a/tests/test_ship_lab.gd b/tests/test_ship_lab.gd index 446e3ed..560e55d 100644 --- a/tests/test_ship_lab.gd +++ b/tests/test_ship_lab.gd @@ -100,7 +100,6 @@ func _process(delta: float) -> bool: var lab_card_2: Control = controller.get_node("../UI/LabMenu/Upgrade2") var lab_card_3: Control = controller.get_node("../UI/LabMenu/Upgrade3") var lab_detail: Label = controller.get_node("../UI/LabMenu/DetailLabel") - var lab_buy: IconButton = controller.get_node("../UI/LabMenu/BuyButton") lab_menu.visible = true controller.call("_on_lab_vessel", 1) if not lab_card_0.visible or lab_card_1.visible: @@ -108,7 +107,11 @@ func _process(delta: float) -> bool: quit(1) return true controller.call("_on_lab_upgrade", 0) - if not "Unlock Shield Vessel" in lab_detail.text or not lab_buy.enabled: + # lab_can_buy, not a button's `enabled`: #164 removed the separate + # buy button, so the option itself is the control and a second tap + # on it is the purchase. What is being checked is unchanged - + # selecting the unlock card offers something actually buyable. + if not "Unlock Shield Vessel" in lab_detail.text or not controller.lab_can_buy: print("TEST_SHIP_LAB: FAIL (unlock card did not select a purchasable detail)") quit(1) return true diff --git a/ui/QuestPanel.gd b/ui/QuestPanel.gd index e152675..95dc285 100644 --- a/ui/QuestPanel.gd +++ b/ui/QuestPanel.gd @@ -22,6 +22,11 @@ const TEXT: Color = Color(0.82, 0.94, 1.0) const DONE: Color = Color(0.55, 1.0, 0.68) const MUTED: Color = Color(0.52, 0.66, 0.76) const PAD: float = 34.0 +# #160: the rule under the title used to sit at the title's own baseline, +# which is exactly where the window's close button is - the line ran straight +# through it. The header band is now as tall as the close button, so the rule +# clears it and the content starts below both. +const HEADER_HEIGHT: float = 150.0 const TITLE_SIZE: int = 40 const HEADING_SIZE: int = 22 const QUEST_SIZE: int = 34 @@ -96,8 +101,10 @@ func _layout() -> void: var inner: float = maxf(size.x - PAD * 2.0, 0.0) var y: float = PAD title_label.position = Vector2(PAD, y) - title_label.size = Vector2(inner, float(TITLE_SIZE) * 1.4) - y += float(TITLE_SIZE) * 2.2 + # Short of the close button, so a long title wraps rather than running + # underneath it. + title_label.size = Vector2(maxf(inner - 140.0, 0.0), float(TITLE_SIZE) * 1.4) + y = HEADER_HEIGHT + PAD * 0.6 heading_label.position = Vector2(PAD, y) heading_label.size = Vector2(inner, float(HEADING_SIZE) * 1.4) y += float(HEADING_SIZE) * 1.8 @@ -179,7 +186,7 @@ func _draw() -> void: Vector2(0.0, size.y), Vector2(0.0, 0.0), ]), Color(GLOW, 0.8), 0.8, 0.8) # A rule under the title, so the header reads as a header. - var rule_y: float = PAD + float(TITLE_SIZE) * 1.6 + var rule_y: float = HEADER_HEIGHT NeonDraw.draw_glowing_line(self, Vector2(PAD, rule_y), Vector2(size.x - PAD, rule_y), Color(GLOW, 0.5), 0.6, 0.4) diff --git a/ui/ResourceBar.gd b/ui/ResourceBar.gd index 019e57b..9bfcec6 100644 --- a/ui/ResourceBar.gd +++ b/ui/ResourceBar.gd @@ -21,6 +21,13 @@ const VALUE_FONT_SIZE: int = 24 const RULE_COLOR: Color = Color(0.25, 0.55, 0.7) const FLEET_COLOR: Color = Color(1.0, 0.78, 0.25) const LOCAL_COLOR: Color = Color(0.45, 0.9, 0.55) +# #164: research points belong up here with everything else the player is +# spending, rather than buried in a sentence at the bottom of whichever panel +# happens to mention them. They are NOT a resource - no inventory, no glyph in +# the catalog, nothing carries them - so they get a written mark and only the +# banked column, exactly the treatment PriceTag already gives them. +const POINT_COLOR: Color = Color(0.72, 0.62, 1.0) +const POINT_NAME: String = "RESEARCH" var _header_labels: Array[Label] = [] var _rows: Dictionary = {} @@ -29,6 +36,7 @@ var _rows: Dictionary = {} # list where it would only repeat another column - see _local_is_redundant(). const VALUE_KEYS: Array = ["bank", "fleet", "local"] var _local_shown: bool = true +var _points_row: Dictionary = {} func _ready() -> void: @@ -56,6 +64,12 @@ func _ready() -> void: "fleet": fleet_label, "local": local_label, } + var point_name := _make_label(POINT_NAME, NAME_FONT_SIZE, POINT_COLOR) + add_child(point_name) + var point_value := _make_value_label(POINT_COLOR) + add_child(point_value) + _points_row = {"name": point_name, "value": point_value} + resized.connect(_layout) _layout() queue_redraw() @@ -121,7 +135,8 @@ func _layout() -> void: _header_labels[i].position = Vector2(columns[i], 0.0) _header_labels[i].size = Vector2(widths[i], HEADER_HEIGHT) - var rows: float = maxf(1.0, float(_rows.size())) + # One row per resource, plus the research-point line. + var rows: float = maxf(1.0, float(_rows.size() + 1)) # The gap is taken OUT of the available height rather than added on top of # each row, so the table still ends exactly where its box does however many # resources the catalog grows to. @@ -143,6 +158,18 @@ func _layout() -> void: label.position = Vector2(columns[i + 1], y) label.size = Vector2(widths[i + 1], row_height) row_index += 1 + + if not _points_row.is_empty(): + var y: float = HEADER_HEIGHT + float(row_index) * row_height + var content_height: float = row_height - ROW_GAP + # Indented to where the resource NAMES start rather than where their + # glyphs do, so the column of words stays a column and the missing + # glyph reads as "not one of these" rather than as a gap. + _points_row.name.position = Vector2(content_height + 10.0, y) + _points_row.name.size = Vector2(resource_width - content_height - 10.0, row_height) + _points_row.value.position = Vector2(columns[1], y) + _points_row.value.size = Vector2(widths[1], row_height) + queue_redraw() @@ -161,6 +188,8 @@ func _process(_delta: float) -> void: row.fleet.text = str(GameState.fleet_resource(resource_type)) row.local.text = str(GameState.building_resource(building_id, resource_type)) \ if building_id != 0 else "—" + if not _points_row.is_empty(): + _points_row.value.text = str(GameState.research_points) static func natural_height() -> float: diff --git a/ui/SkillTreeView.gd b/ui/SkillTreeView.gd index 49951c6..82e67df 100644 --- a/ui/SkillTreeView.gd +++ b/ui/SkillTreeView.gd @@ -53,6 +53,25 @@ const WHEEL_STEP: float = 90.0 # the second one is never a blind commitment. signal node_confirmed(id: String) +# One physical tap arrives TWICE. Godot emulates a mouse button from every +# touch and a touch from every mouse button - both settings are on - and the +# menu's dispatcher routes InputEventScreenTouch and InputEventMouseButton +# here alike. The second arrival found the node already selected and took it +# for the confirming tap, so a single tap bought the research outright: the +# exact thing the two-tap rule exists to prevent (#162). Measured, not +# guessed - one tap produced two picks and two confirms. +# +# What is ignored is specifically the TWIN: a press at the same place, in the +# same frame, arriving through the OTHER input kind. Two presses of the same +# kind are always two taps however close together they are, so a player +# tapping twice quickly is never swallowed. Deduping on frame and position +# alone did swallow it, which the test caught. +enum PressSource { TOUCH, MOUSE } + +var _last_press_frame: int = -1 +var _last_press_at: Vector2 = Vector2.ZERO +var _last_press_source: int = -1 + var selected_id: String = "" var scroll_y: float = 0.0 var _dragging: bool = false @@ -144,7 +163,7 @@ func _state_color(node: Dictionary) -> Color: # Returns true when the press landed on a node, so the caller knows the # touch was consumed. func handle_touch(event: InputEventScreenTouch) -> bool: - return handle_press(event.pressed, event.position) + return handle_press(event.pressed, event.position, PressSource.TOUCH) func handle_drag(event: InputEventScreenDrag) -> bool: @@ -160,7 +179,7 @@ func handle_mouse_button(event: InputEventMouseButton) -> bool: return handle_pan(Vector2(0.0, -WHEEL_STEP)) if event.button_index != MOUSE_BUTTON_LEFT: return false - return handle_press(event.pressed, event.position) + return handle_press(event.pressed, event.position, PressSource.MOUSE) # Only while the button is held. A touch drag cannot arrive without a finger @@ -173,12 +192,14 @@ func handle_mouse_motion(event: InputEventMouseMotion) -> bool: return handle_pan(event.relative) -func handle_press(pressed: bool, at: Vector2) -> bool: +func handle_press(pressed: bool, at: Vector2, source: int = PressSource.TOUCH) -> bool: if not visible: return false if not pressed: _dragging = false return true + if _is_emulated_twin(at, source): + return true _dragging = true var local: Vector2 = at - global_position for node in GameState.SKILL_TREE: @@ -193,6 +214,20 @@ func handle_press(pressed: bool, at: Vector2) -> bool: return false +# True only for the emulated twin: same frame, same place, other input kind. +func _is_emulated_twin(at: Vector2, source: int) -> bool: + var frame: int = Engine.get_process_frames() + var twin: bool = frame == _last_press_frame \ + and source != _last_press_source \ + and at.distance_to(_last_press_at) < 24.0 + if twin: + return true + _last_press_frame = frame + _last_press_at = at + _last_press_source = source + return false + + func handle_pan(relative: Vector2) -> bool: if not visible: return false diff --git a/vessels/GarrisonField.gd b/vessels/GarrisonField.gd index a29e530..286a389 100644 --- a/vessels/GarrisonField.gd +++ b/vessels/GarrisonField.gd @@ -79,6 +79,11 @@ func _spawn(outpost_id: int, vessel_type: String, at: Vector2, index: int) -> No var spread: float = TAU * float(index) / 4.0 ship.position = at + Vector2(cos(spread), sin(spread)) * 170.0 add_child(ship) + # #165: marked as belonging to an outpost, so the ram rule leaves it + # alone. A stationed vessel orbits and re-docks with its own structure + # constantly; without this the collision damage meant for a charging + # fleet vessel destroyed the garrison the moment it launched. + ship.add_to_group("garrison_ships") var component: Node = GARRISON_COMPONENT.new() component.outpost_id = outpost_id component.station = at diff --git a/vfx/GasBombEffect.gd b/vfx/GasBombEffect.gd index a2758f2..e2e6361 100644 --- a/vfx/GasBombEffect.gd +++ b/vfx/GasBombEffect.gd @@ -69,11 +69,17 @@ func _explode() -> void: hit_count = 0 # Asteroids too - a blast that clears everything around it but leaves the # rocks untouched would need explaining, and they take damage the same way. - for enemy in get_tree().get_nodes_in_group("enemies") \ - + get_tree().get_nodes_in_group("asteroids"): - if is_instance_valid(enemy) and enemy is Node2D \ - and enemy.global_position.distance_to(target) <= AOE_RADIUS: - enemy.take_damage(damage) + # + # #165: and STRUCTURES. The blast used to stop politely at anything the + # player owned, which made a bomb dropped on a besieged mothership a free + # way to clear the attackers off it. Everything inside the radius is + # inside the radius; where it lands is the decision the weapon asks for. + for target_node in get_tree().get_nodes_in_group("enemies") \ + + get_tree().get_nodes_in_group("asteroids") \ + + get_tree().get_nodes_in_group("player_buildings"): + if is_instance_valid(target_node) and target_node is Node2D \ + and target_node.global_position.distance_to(target) <= AOE_RADIUS: + target_node.take_damage(damage) hit_count += 1 struck.emit(hit_count) diff --git a/world/IntroDirector.gd b/world/IntroDirector.gd index c30f6b9..4f47fd8 100644 --- a/world/IntroDirector.gd +++ b/world/IntroDirector.gd @@ -178,6 +178,15 @@ func finish() -> void: if not _is_starting_fleet(): fleet.spawn_ships(GameState.STARTING_SHIP_COUNT, fleet.get_centroid(), PackedStringArray([GameState.VESSEL_INTERCEPTOR])) + # #161: and the SHAPE goes back too. The prologue widens the formation + # into a freight convoy - a 600-unit box, roughly three times the fleet's + # own wedge - by writing over GameState.formation, and nothing put it + # back. The player was left flying the convoy's spread for the rest of the + # game, which is not a formation anyone chose and reads as the fleet + # having drifted apart. apply_formation() re-seats the ships that already + # exist, not only the ones built later. + GameState.reset_formation() + fleet.apply_formation() finished.emit() queue_free() diff --git a/world/Main.tscn b/world/Main.tscn index c6ea6ab..364bc0c 100644 --- a/world/Main.tscn +++ b/world/Main.tscn @@ -279,27 +279,6 @@ offset_bottom = 172.0 script = ExtResource("10") icon = 44 -[node name="QuestPanel" type="Control" parent="UI" groups=["modal_menus"]] -visible = false -mouse_filter = 2 -anchor_right = 1.0 -anchor_bottom = 1.0 -offset_left = 24.0 -offset_top = 24.0 -offset_right = -24.0 -offset_bottom = -24.0 -script = ExtResource("quest_panel") - -[node name="CloseButton" type="Control" parent="UI/QuestPanel"] -anchor_left = 1.0 -anchor_right = 1.0 -offset_left = -140.0 -offset_right = -24.0 -offset_top = 20.0 -offset_bottom = 136.0 -script = ExtResource("10") -icon = 1 - [node name="Joystick" type="Control" parent="UI"] mouse_filter = 2 script = ExtResource("13") @@ -498,18 +477,18 @@ horizontal_alignment = 1 mouse_filter = 2 anchor_right = 1.0 offset_left = 24.0 -offset_top = 148.0 +offset_top = 148 offset_right = -24.0 -offset_bottom = 284.0 +offset_bottom = 330 script = ExtResource("24") [node name="StatusLabel" type="Label" parent="UI/BuildMenu"] mouse_filter = 2 anchor_right = 1.0 offset_left = 24 -offset_top = 292 +offset_top = 338 offset_right = -24 -offset_bottom = 372 +offset_bottom = 418 theme_override_font_sizes/font_size = 40 autowrap_mode = 2 text = "" @@ -1421,7 +1400,7 @@ anchor_right = 1.0 offset_left = 24 offset_top = 148 offset_right = -24 -offset_bottom = 284 +offset_bottom = 330 script = ExtResource("24") [node name="VesselA" type="Control" parent="UI/LabMenu"] @@ -1429,8 +1408,8 @@ anchor_left = 0.5 anchor_right = 0.5 offset_left = -300 offset_right = -240 -offset_top = 300 -offset_bottom = 420 +offset_top = 346 +offset_bottom = 466 script = ExtResource("10") icon = 2 glow_color = Color(0.25, 0.85, 1, 1) @@ -1440,8 +1419,8 @@ anchor_left = 0.5 anchor_right = 0.5 offset_left = -210 offset_right = -150 -offset_top = 300 -offset_bottom = 420 +offset_top = 346 +offset_bottom = 466 script = ExtResource("10") icon = 2 glow_color = Color(0.45, 0.8, 1, 1) @@ -1451,8 +1430,8 @@ anchor_left = 0.5 anchor_right = 0.5 offset_left = -120 offset_right = -60 -offset_top = 300 -offset_bottom = 420 +offset_top = 346 +offset_bottom = 466 script = ExtResource("10") icon = 31 glow_color = Color(0.96, 0.32, 0.78, 1) @@ -1462,8 +1441,8 @@ anchor_left = 0.5 anchor_right = 0.5 offset_left = -30 offset_right = 30 -offset_top = 300 -offset_bottom = 420 +offset_top = 346 +offset_bottom = 466 script = ExtResource("10") icon = 12 glow_color = Color(0.95, 0.55, 0.85, 1) @@ -1473,8 +1452,8 @@ anchor_left = 0.5 anchor_right = 0.5 offset_left = 60 offset_right = 120 -offset_top = 300 -offset_bottom = 420 +offset_top = 346 +offset_bottom = 466 script = ExtResource("10") icon = 34 glow_color = Color(1, 0.78, 0.3, 1) @@ -1484,8 +1463,8 @@ anchor_left = 0.5 anchor_right = 0.5 offset_left = 150 offset_right = 210 -offset_top = 300 -offset_bottom = 420 +offset_top = 346 +offset_bottom = 466 script = ExtResource("10") icon = 35 glow_color = Color(0.3, 0.9, 1, 1) @@ -1495,8 +1474,8 @@ anchor_left = 0.5 anchor_right = 0.5 offset_left = 240 offset_right = 300 -offset_top = 300 -offset_bottom = 420 +offset_top = 346 +offset_bottom = 466 script = ExtResource("10") icon = 37 glow_color = Color(0.7, 0.3, 1, 1) @@ -1506,8 +1485,8 @@ anchor_left = 0.5 anchor_right = 0.5 offset_left = 320 offset_right = 380 -offset_top = 300 -offset_bottom = 420 +offset_top = 346 +offset_bottom = 466 script = ExtResource("10") icon = 43 glow_color = Color(0.5, 0.95, 0.8, 1) @@ -1517,9 +1496,9 @@ visible = false mouse_filter = 2 anchor_right = 1.0 offset_left = 24 -offset_top = 424 +offset_top = 470 offset_right = -24 -offset_bottom = 482 +offset_bottom = 528 theme_override_font_sizes/font_size = 34 horizontal_alignment = 1 text = "" @@ -1530,8 +1509,8 @@ anchor_left = 0.5 anchor_right = 0.5 offset_left = -310 offset_right = -230 -offset_top = 424 -offset_bottom = 496 +offset_top = 470 +offset_bottom = 542 theme_override_font_sizes/font_size = 15 autowrap_mode = 2 horizontal_alignment = 1 @@ -1543,8 +1522,8 @@ anchor_left = 0.5 anchor_right = 0.5 offset_left = -220 offset_right = -140 -offset_top = 424 -offset_bottom = 496 +offset_top = 470 +offset_bottom = 542 theme_override_font_sizes/font_size = 15 autowrap_mode = 2 horizontal_alignment = 1 @@ -1556,8 +1535,8 @@ anchor_left = 0.5 anchor_right = 0.5 offset_left = -130 offset_right = -50 -offset_top = 424 -offset_bottom = 496 +offset_top = 470 +offset_bottom = 542 theme_override_font_sizes/font_size = 15 autowrap_mode = 2 horizontal_alignment = 1 @@ -1569,8 +1548,8 @@ anchor_left = 0.5 anchor_right = 0.5 offset_left = -40 offset_right = 40 -offset_top = 424 -offset_bottom = 496 +offset_top = 470 +offset_bottom = 542 theme_override_font_sizes/font_size = 15 autowrap_mode = 2 horizontal_alignment = 1 @@ -1582,8 +1561,8 @@ anchor_left = 0.5 anchor_right = 0.5 offset_left = 50 offset_right = 130 -offset_top = 424 -offset_bottom = 496 +offset_top = 470 +offset_bottom = 542 theme_override_font_sizes/font_size = 15 autowrap_mode = 2 horizontal_alignment = 1 @@ -1595,8 +1574,8 @@ anchor_left = 0.5 anchor_right = 0.5 offset_left = 140 offset_right = 220 -offset_top = 424 -offset_bottom = 496 +offset_top = 470 +offset_bottom = 542 theme_override_font_sizes/font_size = 15 autowrap_mode = 2 horizontal_alignment = 1 @@ -1608,8 +1587,8 @@ anchor_left = 0.5 anchor_right = 0.5 offset_left = 230 offset_right = 310 -offset_top = 424 -offset_bottom = 496 +offset_top = 470 +offset_bottom = 542 theme_override_font_sizes/font_size = 15 autowrap_mode = 2 horizontal_alignment = 1 @@ -1621,8 +1600,8 @@ anchor_left = 0.5 anchor_right = 0.5 offset_left = 310 offset_right = 390 -offset_top = 424 -offset_bottom = 496 +offset_top = 470 +offset_bottom = 542 theme_override_font_sizes/font_size = 15 autowrap_mode = 2 horizontal_alignment = 1 @@ -1632,9 +1611,9 @@ text = "Repair Drone" mouse_filter = 2 anchor_right = 1.0 offset_left = 48 -offset_top = 502 +offset_top = 550 offset_right = -48 -offset_bottom = 506 +offset_bottom = 554 color = Color(0.25, 0.85, 1, 0.6) [node name="UpgradeList" type="Label" parent="UI/LabMenu"] @@ -1643,7 +1622,7 @@ mouse_filter = 2 anchor_right = 1.0 anchor_bottom = 1.0 offset_left = 32 -offset_top = 514 +offset_top = 562 offset_right = -32 offset_bottom = -390 theme_override_font_sizes/font_size = 30 @@ -1656,9 +1635,9 @@ anchor_right = 1.0 anchor_top = 1.0 anchor_bottom = 1.0 offset_left = 32 -offset_top = -380 +offset_top = -300 offset_right = -32 -offset_bottom = -280 +offset_bottom = -180 theme_override_font_sizes/font_size = 32 autowrap_mode = 2 horizontal_alignment = 1 @@ -1670,9 +1649,9 @@ anchor_right = 1.0 anchor_top = 1.0 anchor_bottom = 1.0 offset_left = 32 -offset_top = -274 +offset_top = -174 offset_right = -32 -offset_bottom = -226 +offset_bottom = -120 theme_override_font_sizes/font_size = 30 autowrap_mode = 2 horizontal_alignment = 1 @@ -1686,25 +1665,12 @@ anchor_right = 0.5 anchor_bottom = 1.0 offset_left = -230 offset_right = -110 -offset_top = -218 -offset_bottom = -108 +offset_top = -112 +offset_bottom = -22 script = ExtResource("10") icon = 6 glow_color = Color(0.55, 0.95, 0.9, 1) -[node name="BuyButton" type="Control" parent="UI/LabMenu"] -anchor_left = 0.5 -anchor_top = 1.0 -anchor_right = 0.5 -anchor_bottom = 1.0 -offset_left = 110 -offset_right = 230 -offset_top = -218 -offset_bottom = -108 -script = ExtResource("10") -icon = 4 -glow_color = Color(0.45, 0.9, 0.55, 1) - [node name="HintLabel" type="Label" parent="UI/LabMenu"] visible = false mouse_filter = 2 @@ -1725,8 +1691,8 @@ anchor_left = 0.5 anchor_right = 0.5 offset_left = -240 offset_right = -120 -offset_top = 514 -offset_bottom = 624 +offset_top = 562 +offset_bottom = 672 script = ExtResource("10") icon = 13 glow_color = Color(0.25, 0.85, 1, 1) @@ -1737,8 +1703,8 @@ anchor_left = 0.5 anchor_right = 0.5 offset_left = -310 offset_right = -50 -offset_top = 628 -offset_bottom = 698 +offset_top = 676 +offset_bottom = 746 theme_override_font_sizes/font_size = 22 autowrap_mode = 2 horizontal_alignment = 1 @@ -1749,8 +1715,8 @@ anchor_left = 0.5 anchor_right = 0.5 offset_left = 120 offset_right = 240 -offset_top = 514 -offset_bottom = 624 +offset_top = 562 +offset_bottom = 672 script = ExtResource("10") icon = 13 glow_color = Color(0.25, 0.85, 1, 1) @@ -1761,8 +1727,8 @@ anchor_left = 0.5 anchor_right = 0.5 offset_left = 50 offset_right = 310 -offset_top = 628 -offset_bottom = 698 +offset_top = 676 +offset_bottom = 746 theme_override_font_sizes/font_size = 22 autowrap_mode = 2 horizontal_alignment = 1 @@ -1773,8 +1739,8 @@ anchor_left = 0.5 anchor_right = 0.5 offset_left = -240 offset_right = -120 -offset_top = 706 -offset_bottom = 816 +offset_top = 790 +offset_bottom = 900 script = ExtResource("10") icon = 13 glow_color = Color(0.25, 0.85, 1, 1) @@ -1785,8 +1751,8 @@ anchor_left = 0.5 anchor_right = 0.5 offset_left = -310 offset_right = -50 -offset_top = 820 -offset_bottom = 890 +offset_top = 904 +offset_bottom = 974 theme_override_font_sizes/font_size = 22 autowrap_mode = 2 horizontal_alignment = 1 @@ -1797,8 +1763,8 @@ anchor_left = 0.5 anchor_right = 0.5 offset_left = 120 offset_right = 240 -offset_top = 706 -offset_bottom = 816 +offset_top = 790 +offset_bottom = 900 script = ExtResource("10") icon = 13 glow_color = Color(0.25, 0.85, 1, 1) @@ -1809,8 +1775,8 @@ anchor_left = 0.5 anchor_right = 0.5 offset_left = 50 offset_right = 310 -offset_top = 820 -offset_bottom = 890 +offset_top = 904 +offset_bottom = 974 theme_override_font_sizes/font_size = 22 autowrap_mode = 2 horizontal_alignment = 1 @@ -1853,16 +1819,16 @@ mouse_filter = 2 offset_left = 24 offset_top = 148 offset_right = -24 -offset_bottom = 284 +offset_bottom = 330 script = ExtResource("24") [node name="StatusLabel" type="Label" parent="UI/CraftMenu"] mouse_filter = 2 anchor_right = 1.0 offset_left = 24 -offset_top = 300 +offset_top = 344 offset_right = -200 -offset_bottom = 446 +offset_bottom = 490 theme_override_font_sizes/font_size = 36 autowrap_mode = 2 text = "" @@ -2409,9 +2375,9 @@ color = Color(0.03, 0.03, 0.08, 0.94) mouse_filter = 2 anchor_right = 1.0 offset_left = 24.0 -offset_top = 20.0 +offset_top = 20 offset_right = -160.0 -offset_bottom = 156.0 +offset_bottom = 202 script = ExtResource("24") [node name="Tree" type="Control" parent="UI/SkillMenu"] @@ -2420,7 +2386,7 @@ clip_contents = true anchor_right = 1.0 anchor_bottom = 1.0 offset_left = 24.0 -offset_top = 168.0 +offset_top = 214 offset_right = -24.0 offset_bottom = -280.0 script = ExtResource("21") @@ -2476,6 +2442,27 @@ offset_bottom = 136.0 script = ExtResource("10") icon = 1 glow_color = Color(0.55, 0.6, 1, 1) +[node name="QuestPanel" type="Control" parent="UI" groups=["modal_menus"]] +visible = false +mouse_filter = 2 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 24.0 +offset_top = 24.0 +offset_right = -24.0 +offset_bottom = -24.0 +script = ExtResource("quest_panel") + +[node name="CloseButton" type="Control" parent="UI/QuestPanel"] +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -140.0 +offset_right = -24.0 +offset_top = 20.0 +offset_bottom = 136.0 +script = ExtResource("10") +icon = 1 + [node name="NarrationBanner" type="Control" parent="UI"] visible = false mouse_filter = 2