Ship the 0.8.0 first-hour and campaign milestone
Full build / Import + headless tests (push) Successful in 41s
Full build / Full desktop build + optional release (push) Successful in 4m18s

Add the campaign shell, pause-safe checkpoint resume, post-shift campaign
continue, confirmed campaign reset, difficulty presets, searchable Help,
content-matrix validation, recovery events, contextual dialogue, policy/debrief
context and eligible-event previews. Extend persistence with atomic separate
files and add campaign/checkpoint/tutorial smokes. Headless 247/247, Python
94/94, tutorial/campaign/session/scenario smokes green.
This commit is contained in:
zwitschi committed 2026-11-01 21:21:43 +01:00
1 parent 86332cd401
commit aa51ee331e
38 files changed
+1361 -1144

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
class_name HelpOverlay
extends CanvasLayer
## New-player help: controls, interaction flow and the current venue policy.
const Strings := preload("res://src/core/strings.gd")
const InputBindings := preload("res://src/core/input_bindings.gd")
signal closed()
var _root: Control = null
var _search: LineEdit = null
var _body: Label = null
var _policy: String = ""
var _lines: Array[String] = []
const CONTROL_LINES := [
"Movement: W/A/S/D · mouse look · Shift precision walk",
"Focus: crosshair cone · Tab / mouse wheel cycles nearby people",
"Action wheel: Space or left click · A/D or arrows step · Space confirms",
"Inspect access: E · Wand: C · Polite direction: Q",
"Warning: 4 · Blocking: 5 · Security radio: 6",
"Emergency radio: R, hold the on-screen button to confirm",
"Body camera: B · Flashlight: G · Door lock: L",
"Pause: Esc · Debug overlay: F",
]
func _init() -> void:
layer = 23
_build()
hide()
func _build() -> void:
_root = Control.new()
_root.name = "HelpRoot"
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_root)
var dim := ColorRect.new()
dim.color = Color(0.02, 0.03, 0.05, 0.86)
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
_root.add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.add_child(center)
var column := VBoxContainer.new()
column.custom_minimum_size = Vector2(620, 0)
column.add_theme_constant_override("separation", 10)
center.add_child(column)
var title := Label.new()
title.text = "Help"
title.add_theme_font_size_override("font_size", 30)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
column.add_child(title)
_search = LineEdit.new()
_search.placeholder_text = "Filter controls…"
_search.text_changed.connect(_refresh)
column.add_child(_search)
_body = Label.new()
_body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_body.custom_minimum_size = Vector2(600, 330)
_body.vertical_alignment = VERTICAL_ALIGNMENT_TOP
column.add_child(_body)
var close := Button.new()
close.text = "Close"
close.pressed.connect(func(): close_panel())
close.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
column.add_child(close)
func open(policy: String = "") -> void:
_policy = policy
visible = true
_root.visible = true
_search.text = ""
_refresh("")
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
func close_panel() -> void:
visible = false
_root.visible = false
closed.emit()
func _refresh(filter: String) -> void:
_lines = []
var needle := filter.strip_edges().to_lower()
var dynamic := []
for action in InputBindings.REMAPPABLE:
var label := InputBindings.label_for(InputBindings.current_descriptor(action))
dynamic.append("%s: %s" % [str(action).replace("_", " ").capitalize(), label])
for line in (CONTROL_LINES + dynamic):
if needle == "" or line.to_lower().contains(needle):
_lines.append(line)
var text := "\n".join(_lines)
if _policy != "":
text += "\n\nVenue policy: " + _policy
_body.text = text
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
if event is InputEventKey and event.pressed and not event.echo \
and event.keycode == KEY_ESCAPE:
close_panel()
get_viewport().set_input_as_handled()
+3 -1
View File
@@ -549,7 +549,7 @@ func toggle_debug() -> void:
_debug_enabled = not _debug_enabled
debug_label.visible = _debug_enabled
func refresh_debug(coordinator: Object, npcs: Array) -> void:
func refresh_debug(coordinator: Object, npcs: Array, preview: Array = []) -> void:
if not _debug_enabled:
return
var lines: Array[String] = ["--- DEBUG ---", "shift %.1fs / %.1fs rating %.1f" % [
@@ -563,6 +563,8 @@ func refresh_debug(coordinator: Object, npcs: Array) -> void:
Rules.NPC_TYPE_NAMES.get(profile.npc_type if profile else -1, "?"),
npc.name, c.intent, c.fsm.current_state, c.highest_escalation_received,
])
if not preview.is_empty():
lines.append(" next eligible: " + ", ".join(preview))
debug_label.text = "\n".join(lines)
func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -> void:
+34
View File
@@ -11,6 +11,7 @@ const UiArt := preload("res://src/game/ui/art.gd")
signal applied(settings: Dictionary)
signal replay_tutorial_requested()
signal reset_campaign_requested()
signal closed()
var _root: Control = null
@@ -20,6 +21,7 @@ var _sensitivity_value: Label = null
var _font_scale_value: Label = null
var _invert_y: CheckButton = null
var _head_bob: CheckButton = null
var _difficulty: OptionButton = null
var _bind_buttons := {}
var _listening_action := ""
var settings: Dictionary = {}
@@ -87,6 +89,18 @@ func _build() -> void:
_head_bob.toggled.connect(_on_head_bob_toggled)
column.add_child(_head_bob)
var diff_row := HBoxContainer.new()
column.add_child(diff_row)
var diff_label := Label.new()
diff_label.text = "Difficulty / pacing"
diff_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
diff_row.add_child(diff_label)
_difficulty = OptionButton.new()
for item in ["easy", "normal", "hard"]:
_difficulty.add_item(item.capitalize())
_difficulty.item_selected.connect(_on_difficulty_selected)
diff_row.add_child(_difficulty)
var replay_row := HBoxContainer.new()
replay_row.alignment = BoxContainer.ALIGNMENT_CENTER
column.add_child(replay_row)
@@ -95,6 +109,11 @@ func _build() -> void:
replay.text = "Replay tutorial"
replay.pressed.connect(func(): replay_tutorial_requested.emit())
replay_row.add_child(replay)
var reset_campaign := Button.new()
reset_campaign.name = "ResetCampaignButton"
reset_campaign.text = "Reset campaign"
reset_campaign.pressed.connect(_confirm_reset_campaign)
replay_row.add_child(reset_campaign)
var controls_title := Label.new()
controls_title.text = "Controls (click a binding, then press a key or pad button)"
@@ -167,6 +186,9 @@ func open(current: Dictionary) -> void:
_invert_y.set_pressed_no_signal(bool(settings.get("invert_y", false)))
if _head_bob:
_head_bob.set_pressed_no_signal(bool(settings.get("head_bob", true)))
if _difficulty:
var difficulty := str(settings.get("ai_difficulty", "normal"))
_difficulty.select(["easy", "normal", "hard"].find(difficulty))
_listening_action = ""
_refresh_bind_buttons()
_sync_labels()
@@ -198,6 +220,18 @@ func _on_invert_y_toggled(pressed: bool) -> void:
func _on_head_bob_toggled(pressed: bool) -> void:
settings["head_bob"] = pressed
func _on_difficulty_selected(index: int) -> void:
settings["ai_difficulty"] = ["easy", "normal", "hard"][clampi(index, 0, 2)]
func _confirm_reset_campaign() -> void:
var dialog := ConfirmationDialog.new()
dialog.title = "Reset campaign"
dialog.dialog_text = "Clear campaign progress and start again from the Hollywood shift?"
dialog.confirmed.connect(func(): reset_campaign_requested.emit())
add_child(dialog)
dialog.popup_centered()
dialog.confirmed.connect(dialog.queue_free)
func _refresh_bind_buttons() -> void:
var remaps: Dictionary = settings.get("input_bindings", {})
for action in _bind_buttons.keys():
+31 -1
View File
@@ -6,12 +6,15 @@ const Strings := preload("res://src/core/strings.gd")
const UiArt := preload("res://src/game/ui/art.gd")
signal restart_requested()
signal continue_requested()
signal timeline_requested(review: Dictionary)
## Kept so the timeline button can hand the full review back to the game manager.
var _review: Dictionary = {}
var report_label: Label
var _campaign_label: Label = null
var _continue_button: Button = null
func _init() -> void:
name = "ShiftReview"
@@ -46,6 +49,17 @@ func _init() -> void:
report_label.custom_minimum_size = Vector2(720, 0)
center.add_child(report_label)
_campaign_label = Label.new()
_campaign_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_campaign_label.add_theme_color_override("font_color", Color(0.62, 0.8, 0.95))
center.add_child(_campaign_label)
_continue_button = Button.new()
_continue_button.text = "Continue campaign"
_continue_button.pressed.connect(func(): continue_requested.emit())
_continue_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
center.add_child(_continue_button)
var timeline := Button.new()
timeline.name = "TimelineButton"
timeline.text = Strings.lookup("review.timeline_button")
@@ -64,6 +78,7 @@ func _init() -> void:
quit_btn.pressed.connect(func(): get_tree().quit())
quit_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
_style_review_button(timeline)
_style_review_button(_continue_button)
_style_review_button(restart)
_style_review_button(quit_btn)
center.add_child(quit_btn)
@@ -84,7 +99,8 @@ func _style_review_button(button: Button) -> void:
button.add_theme_stylebox_override("focus", hover)
button.add_theme_stylebox_override("pressed", hover)
func show_review(review: Dictionary, scenario: Resource, history: Array = []) -> void:
func show_review(review: Dictionary, scenario: Resource, history: Array = [],
campaign_summary: Dictionary = {}) -> void:
_review = review
var reputation: Dictionary = review.get("reputation", {})
var incidents: Dictionary = review.get("incidents", {})
@@ -115,6 +131,7 @@ func show_review(review: Dictionary, scenario: Resource, history: Array = []) ->
var lines: Array[String] = [
"Venue: %s" % (scenario.display_name if scenario else review.get("scenario_id", "")),
"Policy: %s" % (scenario.policy_summary if scenario else "See venue policy."),
"Grade: %s Score: %.0f/100" % [reputation.get("grade", "?"), reputation.get("rating", 0.0)],
history_line,
"Incidents handled: %d (peaceful %d)" % [incident_list.size(), peaceful],
@@ -135,4 +152,17 @@ func show_review(review: Dictionary, scenario: Resource, history: Array = []) ->
if shown >= 12:
break
report_label.text = "\n".join(lines)
var next_venue := str(campaign_summary.get("next_venue", ""))
var campaign_complete := bool(campaign_summary.get("complete", false))
if _campaign_label:
if campaign_complete:
_campaign_label.text = "Campaign complete. Free-play venues remain available."
elif next_venue != "":
_campaign_label.text = "Campaign shift %d complete · next: %s" % [
int(campaign_summary.get("shift_number", 1)) - 1,
next_venue.replace("_", " ").capitalize()]
else:
_campaign_label.text = ""
if _continue_button:
_continue_button.visible = not campaign_complete and next_venue != ""
visible = true
+49 -1
View File
@@ -5,6 +5,8 @@ extends CanvasLayer
## (the `V` bind stays as a debug shortcut).
signal venue_selected(scenario_id: String)
signal continue_requested()
signal help_requested()
signal settings_requested()
signal quit_requested()
@@ -19,6 +21,9 @@ const VENUES := [
var _index := 0
var _buttons: Array[Button] = []
var _root: Control = null
var _status: Label = null
var _continue_button: Button = null
var _campaign: Dictionary = {}
func _ready() -> void:
layer = 20
@@ -88,6 +93,20 @@ func _build() -> void:
subtitle.add_theme_color_override("font_color", Color(0.72, 0.75, 0.82))
column.add_child(subtitle)
_status = Label.new()
_status.name = "CampaignStatus"
_status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_status.add_theme_color_override("font_color", Color(0.62, 0.78, 0.9))
column.add_child(_status)
_continue_button = Button.new()
_continue_button.name = "ContinueButton"
_continue_button.custom_minimum_size = Vector2(460, 52)
_continue_button.pressed.connect(_on_continue_pressed)
_style_venue_button(_continue_button)
column.add_child(_continue_button)
for i in VENUES.size():
var venue: Dictionary = VENUES[i]
var button := Button.new()
@@ -109,6 +128,12 @@ func _build() -> void:
row.add_theme_constant_override("separation", 12)
column.add_child(row)
var help_button := Button.new()
help_button.name = "HelpButton"
help_button.text = "Help"
help_button.pressed.connect(func(): help_requested.emit())
row.add_child(help_button)
var settings_button := Button.new()
settings_button.name = "SettingsButton"
settings_button.text = "Settings"
@@ -164,12 +189,35 @@ func _focus_index(i: int) -> void:
_index = clampi(i, 0, _buttons.size() - 1)
_buttons[_index].grab_focus()
func open() -> void:
func open(campaign: Dictionary = {}) -> void:
_campaign = campaign.duplicate(true)
visible = true
_root.visible = true
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
var next_id := str(_campaign.get("next_venue", ""))
var complete := bool(_campaign.get("complete", false))
if _status:
if complete:
_status.text = "Campaign complete — pick any venue for a free shift."
elif next_id != "":
_status.text = "Campaign shift %d · next: %s" % [
int(_campaign.get("shift_number", 1)), _venue_label(next_id)]
if _continue_button:
var next_label := _venue_label(next_id)
_continue_button.text = "Continue shift — %s" % next_label if next_id != "" else "Campaign complete"
_continue_button.visible = not complete
_focus_index(_index)
func _venue_label(id: String) -> String:
for venue in VENUES:
if str(venue["id"]) == id:
return str(venue["label"])
return id.replace("_", " ").capitalize()
func _on_continue_pressed() -> void:
close()
continue_requested.emit()
func close() -> void:
visible = false
_root.visible = false