diff --git a/core/GameState.gd b/core/GameState.gd index 9394e87..50a8afb 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -3012,6 +3012,12 @@ func active_quest() -> Dictionary: return QUESTS.at(quest_index) +# One quest by position in the chain, for anything listing what is behind the +# player (#140). Out of range answers {} rather than erroring. +func quest_at(index: int) -> Dictionary: + return QUESTS.at(index) + + # How far along one quest is, as (have, need). One line per goal kind, and # every one of them is answered from state this autoload already holds - a # goal that needed a node to ask would be a goal that stops working on a diff --git a/surface/PlanetSurface.tscn b/surface/PlanetSurface.tscn index 4dd37eb..12785b2 100644 --- a/surface/PlanetSurface.tscn +++ b/surface/PlanetSurface.tscn @@ -15,7 +15,7 @@ [ext_resource type="Script" path="res://surface/CaptureLabel.gd" id="15"] [ext_resource type="Script" path="res://ui/NarrationBanner.gd" id="banner"] [ext_resource type="Script" path="res://world/QuestDirector.gd" id="quest_dir"] -[ext_resource type="Script" path="res://ui/QuestTracker.gd" id="quest_hud"] +[ext_resource type="Script" path="res://ui/QuestPanel.gd" id="quest_panel"] [node name="PlanetSurface" type="Node2D"] script = ExtResource("1") @@ -127,17 +127,6 @@ offset_top = 300.0 offset_bottom = 428.0 script = ExtResource("banner") -[node name="QuestTracker" type="Control" parent="UI"] -visible = false -mouse_filter = 2 -anchor_left = 1.0 -anchor_right = 1.0 -offset_left = -440.0 -offset_right = -24.0 -offset_top = 350.0 -offset_bottom = 482.0 -script = ExtResource("quest_hud") - [node name="ExitButton" type="Control" parent="UI"] anchor_top = 1.0 anchor_bottom = 1.0 @@ -150,6 +139,37 @@ icon = 4 glow_color = Color(0.06, 0.32, 0.5, 1) core_color = Color(0.02, 0.09, 0.15, 1) +[node name="QuestButton" type="Control" parent="UI"] +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -172.0 +offset_right = -24.0 +offset_top = 160.0 +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") diff --git a/tests/test_quest_chain.gd b/tests/test_quest_chain.gd index 7333b31..11f0b25 100644 --- a/tests/test_quest_chain.gd +++ b/tests/test_quest_chain.gd @@ -29,7 +29,8 @@ extends SceneTree # I. lines survive having nobody to say them: a quest finished with no # banner alive is read out by the next one that is # J. a planet surface carries the chain too - a quest finished down there -# is announced down there, not on the way back up +# is announced down there, not on the way back up, and the objective is +# reachable from a button there as well # K. landing is recorded, both the planet and the kind of world it was const QUESTS = preload("res://core/Quests.gd") @@ -38,7 +39,8 @@ var game_state: Node var stage: int = 0 var elapsed: float = 0.0 var scene: Node -var tracker: Control +var panel: Control +var quest_button: Control var narration: Control @@ -194,7 +196,8 @@ func _process(delta: float) -> bool: scene = load("res://world/Main.tscn").instantiate() root.add_child(scene) current_scene = scene - tracker = scene.get_node("UI/QuestTracker") + panel = scene.get_node("UI/QuestPanel") + quest_button = scene.get_node("UI/QuestButton") narration = scene.get_node("UI/NarrationBanner") elapsed = 0.0 stage = 1 @@ -220,18 +223,35 @@ func _process(delta: float) -> bool: 2: if elapsed < 0.3: return false - if not tracker.visible: - return _fail("the objective tracker never came up") + # #140: the objective lives behind a button next to the map's, + # not on the HUD - it used to sit on top of the wave button. + if panel.visible: + return _fail("the objective panel is open before anyone pressed anything") + if not quest_button.visible: + return _fail("there is no quest button to press") + var map_button: Control = scene.get_node("UI/MapButton") + if quest_button.get_rect().intersects(map_button.get_rect()): + return _fail("the quest button is drawn on top of the map button") + var wave_button: Control = scene.get_node("UI/DevWaveButton") + if quest_button.get_rect().intersects(wave_button.get_rect()): + return _fail("the quest button is drawn on top of the wave button") + if absf(quest_button.get_rect().get_center().y - map_button.get_rect().get_center().y) > 1.0: + return _fail("the quest button does not sit beside the map button") + panel.open() + if not panel.visible: + return _fail("pressing the quest button opened nothing") var quest: Dictionary = game_state.active_quest() - if tracker.title_label.text != String(quest.title): - return _fail("the tracker shows \"%s\", the quest is \"%s\"" \ - % [tracker.title_label.text, quest.title]) - if tracker.objective_label.text != String(quest.objective): - return _fail("the tracker does not show the objective") - if tracker.progress != Vector2i(0, 5): - return _fail("the tracker reads %s, expected 0 of 5" % tracker.progress) - print("tracker: \"%s\" - %s (%d/%d)" % [tracker.title_label.text, - tracker.objective_label.text, tracker.progress.x, tracker.progress.y]) + if panel.quest_label.text != String(quest.title): + return _fail("the panel shows \"%s\", the quest is \"%s\"" \ + % [panel.quest_label.text, quest.title]) + if panel.objective_label.text != String(quest.objective): + return _fail("the panel does not show the objective") + if panel.progress != Vector2i(0, 5): + return _fail("the panel reads %s, expected 0 of 5" % panel.progress) + print("quest button beside the map, clear of the wave button; panel: \"%s\" - %s (%d/%d)" \ + % [panel.quest_label.text, panel.objective_label.text, + panel.progress.x, panel.progress.y]) + panel.close() # Collect what the quest asked for, the way the game does. game_state.collect_cargo(5) @@ -282,9 +302,10 @@ func _process(delta: float) -> bool: root.add_child(scene) current_scene = scene narration = scene.get_node_or_null("UI/NarrationBanner") - tracker = scene.get_node_or_null("UI/QuestTracker") - if narration == null or tracker == null: - return _fail("a planet surface has no banner and no objective tracker") + panel = scene.get_node_or_null("UI/QuestPanel") + quest_button = scene.get_node_or_null("UI/QuestButton") + if narration == null or panel == null or quest_button == null: + return _fail("a planet surface has no banner and no objective panel") game_state.complete_active_quest() elapsed = 0.0 stage = 5 @@ -306,9 +327,13 @@ func _process(delta: float) -> bool: 6: if elapsed < 0.3: return false - if not tracker.visible: - return _fail("the objective tracker is missing on a planet surface") - print("the surface carries the tracker too: \"%s\"" % tracker.title_label.text) + if not quest_button.visible: + return _fail("the objective button is missing on a planet surface") + panel.open() + if not panel.visible or panel.quest_label.text.strip_edges() == "": + return _fail("the objective panel says nothing on a planet surface") + print("the surface carries the objective too: \"%s\"" % panel.quest_label.text) + panel.close() # --- K. landing is recorded ---------------------------------- game_state.reset_quests() diff --git a/ui/IconButton.gd b/ui/IconButton.gd index 98ae7e7..aa59414 100644 --- a/ui/IconButton.gd +++ b/ui/IconButton.gd @@ -57,6 +57,10 @@ enum Icon { PLANNING_CENTER, # #116: the workshop and the drone it sends out. Appended, same rule. WORKSHOP, REPAIR_DRONE, + # #140: the standing objective. Appended, same rule as everything above - + # inserting anywhere earlier renumbers every icon int already stored in + # every scene. + QUEST, } # Size the glyphs were originally drawn against. Every glyph dimension is @@ -313,6 +317,30 @@ func _draw() -> void: _draw_workshop_icon(center, pulse, s) Icon.REPAIR_DRONE: _draw_repair_drone_icon(center, pulse, s) + Icon.QUEST: + _draw_quest_icon(center, pulse, s) + + +# #140: a checklist - a page with a tick against the top line. Reads as +# "what I have been asked to do" rather than as a map, a target or a flag, +# each of which this button sits next to. +func _draw_quest_icon(center: Vector2, pulse: float, s: float) -> void: + var half: Vector2 = Vector2(9.0, 12.0) * s + NeonDraw.draw_glowing_path(self, PackedVector2Array([ + center + Vector2(-half.x, -half.y), center + Vector2(half.x, -half.y), + center + Vector2(half.x, half.y), center + Vector2(-half.x, half.y), + center + Vector2(-half.x, -half.y), + ]), _active_color(), pulse, 0.5 * s, core_color) + # The done line, ticked; the two below it still open. + var tick: Vector2 = center + Vector2(-5.0, -5.0) * s + NeonDraw.draw_glowing_path(self, PackedVector2Array([ + tick + Vector2(-1.5, 0.0) * s, tick + Vector2(0.5, 2.0) * s, + tick + Vector2(3.5, -2.5) * s, + ]), _active_color(), pulse, 0.5 * s, core_color) + for row in [1.0, 5.0]: + NeonDraw.draw_glowing_line(self, center + Vector2(-5.5, row) * s, + center + Vector2(5.5, row) * s, _active_color(), pulse * 0.75, + 0.4 * s, core_color) func _draw_close(center: Vector2, pulse: float, s: float) -> void: diff --git a/ui/QuestPanel.gd b/ui/QuestPanel.gd new file mode 100644 index 0000000..e152675 --- /dev/null +++ b/ui/QuestPanel.gd @@ -0,0 +1,194 @@ +extends Control + +# Forgejo #140: "quests overlap the new wave button. create a quest button +# next to the map one." +# +# The standing tracker #132 put on the HUD sat in the right-hand column, on +# top of the wave button - and there is no free strip of that screen to move +# it to, because everything down that edge is already something. So the +# objective stops living on the HUD and lives behind a button, like the map: +# a mark next to the map's own, and a panel when you press it. +# +# Owned the way WorldMap owns its open button rather than wired through +# BuildController: the button does one thing and it is this panel's. +# +# What it shows is the objective and the road behind it - what is done, and +# what is being asked for now. NOT what comes next: the chain is meant to +# open the game up a step at a time (#133), and a list of everything still +# to come is that discovery given away on the first press. + +const GLOW: Color = Color(0.42, 0.86, 0.95) +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 +const TITLE_SIZE: int = 40 +const HEADING_SIZE: int = 22 +const QUEST_SIZE: int = 34 +const OBJECTIVE_SIZE: int = 26 +const DONE_SIZE: int = 22 +const BAR_HEIGHT: float = 14.0 +const ROW_HEIGHT: float = 34.0 + +@onready var open_button: IconButton = get_node("../QuestButton") +@onready var close_button: IconButton = get_node("CloseButton") + +var title_label: Label +var heading_label: Label +var quest_label: Label +var objective_label: Label +var count_label: Label +var done_heading: Label +var done_labels: Array[Label] = [] +var progress: Vector2i = Vector2i.ZERO +var bar_rect: Rect2 = Rect2() + + +func _ready() -> void: + visible = false + # Transparent to GUI input, like every other panel here - see + # IconButton._blocked_by_modal for why the buttons check instead. + mouse_filter = Control.MOUSE_FILTER_IGNORE + title_label = _make_label(TITLE_SIZE, TEXT) + heading_label = _make_label(HEADING_SIZE, GLOW) + quest_label = _make_label(QUEST_SIZE, GLOW) + objective_label = _make_label(OBJECTIVE_SIZE, TEXT) + objective_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + count_label = _make_label(HEADING_SIZE, TEXT) + count_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + done_heading = _make_label(HEADING_SIZE, GLOW) + if open_button != null: + open_button.pressed.connect(open) + if close_button != null: + close_button.pressed.connect(close) + resized.connect(_layout) + if open_button != null and GameState.intro_active: + # #128: a dialog stops the tree and the prologue opens on one, so the + # first _process frame never comes. Applied here too. + open_button.visible = false + _layout() + + +func _make_label(font_size: int, colour: Color) -> Label: + var label := Label.new() + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + label.add_theme_font_size_override("font_size", font_size) + label.add_theme_color_override("font_color", colour) + label.add_theme_color_override("font_outline_color", Color(0.01, 0.03, 0.07, 0.95)) + label.add_theme_constant_override("outline_size", 3) + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + add_child(label) + return label + + +func open() -> void: + _refresh() + visible = true + + +func close() -> void: + visible = false + + +func _layout() -> void: + if title_label == null: + return + 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 + heading_label.position = Vector2(PAD, y) + heading_label.size = Vector2(inner, float(HEADING_SIZE) * 1.4) + y += float(HEADING_SIZE) * 1.8 + quest_label.position = Vector2(PAD, y) + quest_label.size = Vector2(inner, float(QUEST_SIZE) * 1.3) + count_label.position = quest_label.position + count_label.size = quest_label.size + y += float(QUEST_SIZE) * 1.5 + objective_label.position = Vector2(PAD, y) + objective_label.size = Vector2(inner, float(OBJECTIVE_SIZE) * 2.6) + y += float(OBJECTIVE_SIZE) * 2.9 + bar_rect = Rect2(Vector2(PAD, y), Vector2(inner, BAR_HEIGHT)) + y += BAR_HEIGHT * 3.5 + done_heading.position = Vector2(PAD, y) + done_heading.size = Vector2(inner, float(HEADING_SIZE) * 1.4) + y += float(HEADING_SIZE) * 1.9 + for label in done_labels: + label.position = Vector2(PAD + 12.0, y) + label.size = Vector2(inner - 12.0, ROW_HEIGHT) + y += ROW_HEIGHT + queue_redraw() + + +func _refresh() -> void: + var quest: Dictionary = GameState.active_quest() + title_label.text = "Objectives" + if quest.is_empty(): + heading_label.text = "NOTHING OUTSTANDING" + quest_label.text = "The list has run out." + objective_label.text = "The search does not stop because there is nothing left to tick off." + count_label.text = "" + progress = Vector2i(1, 1) + else: + heading_label.text = "CURRENT OBJECTIVE %d OF %d" \ + % [GameState.quest_index + 1, GameState.quest_count()] + quest_label.text = String(quest.title) + objective_label.text = String(quest.objective) + progress = GameState.quest_progress(quest) + # A one-of-one goal is a yes/no, and "0 / 1" reads as a broken + # counter rather than as a task not started. + count_label.text = "%d / %d" % [progress.x, progress.y] if progress.y > 1 else "" + + # The road behind. Deliberately no list of what is still to come. + var completed: int = GameState.quest_index + done_heading.text = "DONE (%d)" % completed + while done_labels.size() < completed: + var label: Label = _make_label(DONE_SIZE, DONE) + done_labels.append(label) + for i in range(done_labels.size()): + var shown: bool = i < completed + done_labels[i].visible = shown + if shown: + done_labels[i].text = " ✓ %s" % String(GameState.quest_at(i).title) + _layout() + + +func _process(_delta: float) -> void: + # #122: the prologue leaves the player movement and nothing else, and + # this is a button like any other. Owned here rather than by + # BuildController, the same way the map owns its own. + if open_button != null: + var offered: bool = not GameState.intro_active + if open_button.visible != offered: + open_button.visible = offered + if not offered and visible: + close() + if visible: + # Live, so ore arriving while the panel is open works the bar up + # instead of looking stuck. + _refresh() + + +func _draw() -> void: + if not visible: + return + draw_rect(Rect2(Vector2.ZERO, size), Color(0.01, 0.04, 0.08, 0.93)) + NeonDraw.draw_glowing_path(self, PackedVector2Array([ + Vector2(0.0, 0.0), Vector2(size.x, 0.0), Vector2(size.x, size.y), + 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 + NeonDraw.draw_glowing_line(self, Vector2(PAD, rule_y), Vector2(size.x - PAD, rule_y), + Color(GLOW, 0.5), 0.6, 0.4) + + if bar_rect.size.x <= 0.0: + return + draw_rect(bar_rect, Color(GLOW, 0.16)) + var filled: float = 0.0 if progress.y <= 0 \ + else clampf(float(progress.x) / float(progress.y), 0.0, 1.0) + if filled <= 0.0: + return + draw_rect(Rect2(bar_rect.position, Vector2(bar_rect.size.x * filled, bar_rect.size.y)), + Color(DONE if filled >= 1.0 else GLOW, 0.85)) diff --git a/ui/QuestPanel.gd.uid b/ui/QuestPanel.gd.uid new file mode 100644 index 0000000..607aab4 --- /dev/null +++ b/ui/QuestPanel.gd.uid @@ -0,0 +1 @@ +uid://buu5pg1783c6k diff --git a/world/Main.tscn b/world/Main.tscn index 0cb0a8b..73e1cfe 100644 --- a/world/Main.tscn +++ b/world/Main.tscn @@ -40,7 +40,7 @@ [ext_resource type="Script" path="res://controllers/LaserController.gd" id="30"] [ext_resource type="Script" path="res://ui/SpeedFader.gd" id="31"] [ext_resource type="Script" path="res://world/QuestDirector.gd" id="quest_dir"] -[ext_resource type="Script" path="res://ui/QuestTracker.gd" id="quest_hud"] +[ext_resource type="Script" path="res://ui/QuestPanel.gd" id="quest_panel"] [ext_resource type="Script" path="res://ui/PriceTag.gd" id="price_tag"] [sub_resource type="ShaderMaterial" id="1"] @@ -279,16 +279,36 @@ offset_top = 188.0 offset_bottom = 252.0 script = ExtResource("32") -[node name="QuestTracker" type="Control" parent="UI"] -visible = false -mouse_filter = 2 +[node name="QuestButton" type="Control" parent="UI"] anchor_left = 1.0 anchor_right = 1.0 -offset_left = -440.0 +offset_left = -344.0 +offset_right = -196.0 +offset_top = 24.0 +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_top = 268.0 -offset_bottom = 400.0 -script = ExtResource("quest_hud") +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