#140: the objective moves behind a button next to the map's

The standing tracker #132 put on the HUD sat in the right-hand column, on
top of the dev wave button - and there is no free strip of that edge to move
it to, because everything down it is already something. So the objective
stops living on the HUD: a quest button beside the map's, and a panel when
you press it.

The panel shows the current objective, its progress, and the road behind it.
Deliberately 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.

It owns its own button, the way WorldMap owns the map button rather than
being wired through BuildController, and it hides that button during the
prologue for the same reason and in the same two places (at _ready as well
as in _process, since #128 means a dialog stops the tree before the first
_process frame ever comes).

Both scenes carry it. On a planet surface there is no map button to sit
beside, so it goes under the planet's name.

The test now checks the button is beside the map's and clear of the wave
button by rectangle rather than by eye, which is the bug the ticket reported.
This commit is contained in:
Loïc Wisniewski 2026-08-28 15:37:13 +02:00
commit 970eafc1de
7 changed files with 332 additions and 38 deletions

View file

@ -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

View file

@ -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")

View file

@ -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()

View file

@ -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:

194
ui/QuestPanel.gd Normal file
View file

@ -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))

1
ui/QuestPanel.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://buu5pg1783c6k

View file

@ -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