Three-agent fan-out (gdscript-refactor x3) ships the chosen Phase 19 approach: contextual hints during first session + a Help reference, plus a sweep of hover tooltips for desktop discoverability. - HintSystem (autoload) + HintOverlay (layer 22 top-center banner): 7-step tour gated on player events — welcome (boot+2s), pawn select, build drawer open, stockpile painted, work matrix open, day_ended, tour_complete. Per-hint dismissals persist as Array[String] in GameState.settings['dismissed_hints']. Max-3 FIFO queue if hints chain. Reduce-motion path snaps in/out instead of tweening. Reset_tour() public API for the Help modal. - HelpModal (layer 20): 5-tab static reference (Controls / Verbs / Priorities / Storyteller / Tips). Opens via EventBus.help_requested, dimmed backdrop, X/Esc/backdrop-tap dismiss. SettingsMenu gains an 'Onboarding' section: Show-hints checkbox, Help button (emits help_requested), Reset hints button (calls HintSystem.reset_tour with has_method guard). Pre-existing 'W' keybind reference fixed to 'P'. - Tooltip pass: tooltip_text via Strings.t on every TopBar button (10 buttons incl. speed shortcuts), BuildDrawer FAB, and every tool button in BuildDrawer (21 tools). _add_tool_btn extended with optional tooltip param. ~34 new tooltip.* string keys. Contracts pre-written (Opus): EventBus.help_requested, hint_dismissed, ui_panel_opened signals; GameState show_hints + dismissed_hints defaults; BuildDrawer.open + WorkPriorityMatrix.open emit ui_panel_opened so HintSystem can subscribe via one signal. Also recorded [MED] known bug in memory.md: drag-paint with active paint tool is eaten by camera drag-pan. MCP runtime verified: welcome banner fires 2s after boot, dismiss queues build_drawer hint on next ui_panel_opened, dismissed_hints persisted as ['welcome'], HelpModal opens via help_requested with tab switching working (Controls → Tips verified visually). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
260 lines
9.2 KiB
GDScript
260 lines
9.2 KiB
GDScript
class_name HelpModal extends CanvasLayer
|
|
## Phase 19 — Static reference modal opened via Settings → Help button.
|
|
##
|
|
## Layer 20 (same as StorytellerModal — mutually exclusive in practice; the
|
|
## Settings menu sits above both and dims the world before Help opens).
|
|
##
|
|
## Five sections: Controls / Verbs / Priorities / Storyteller / Tips.
|
|
## Tab row across the top of the content area; active tab tinted amber.
|
|
## ScrollContainer per-section so long copy doesn't overflow.
|
|
##
|
|
## Opened by EventBus.help_requested signal (emitted by SettingsMenu).
|
|
## Closed by: X button, tapping the dim backdrop, or Escape.
|
|
##
|
|
## CanvasLayer.visible is toggled (not just inner Control) so the dim
|
|
## MOUSE_FILTER_STOP backing doesn't eat world input when the modal is hidden.
|
|
## PROCESS_MODE_ALWAYS so it remains accessible while the sim is paused.
|
|
|
|
const LAYER_ORDER: int = 20
|
|
const MODAL_W: int = 720
|
|
const MODAL_H: int = 560
|
|
const TAB_COUNT: int = 5
|
|
const AMBER: Color = Color(1.0, 0.75, 0.2, 1.0)
|
|
|
|
# ── tab indices ──────────────────────────────────────────────────────────────
|
|
const TAB_CONTROLS: int = 0
|
|
const TAB_VERBS: int = 1
|
|
const TAB_PRIORITIES: int = 2
|
|
const TAB_STORYTELLER: int = 3
|
|
const TAB_TIPS: int = 4
|
|
|
|
# ── state ────────────────────────────────────────────────────────────────────
|
|
var _active_tab: int = TAB_CONTROLS
|
|
|
|
# ── node refs ────────────────────────────────────────────────────────────────
|
|
var _dim: ColorRect = null
|
|
var _panel: PanelContainer = null
|
|
var _tab_btns: Array[Button] = []
|
|
var _scroll: ScrollContainer = null
|
|
var _content_box: VBoxContainer = null
|
|
|
|
|
|
func _ready() -> void:
|
|
layer = LAYER_ORDER
|
|
process_mode = Node.PROCESS_MODE_ALWAYS
|
|
_build_ui()
|
|
_set_visible(false)
|
|
EventBus.help_requested.connect(_on_help_requested)
|
|
Audit.log("help_modal", "HelpModal ready (layer %d)" % layer)
|
|
|
|
|
|
func _exit_tree() -> void:
|
|
if EventBus.help_requested.is_connected(_on_help_requested):
|
|
EventBus.help_requested.disconnect(_on_help_requested)
|
|
|
|
|
|
# ── public API ────────────────────────────────────────────────────────────────
|
|
|
|
func open() -> void:
|
|
_active_tab = TAB_CONTROLS
|
|
_set_visible(true)
|
|
_refresh_tabs()
|
|
call_deferred("_show_section")
|
|
Audit.log("help_modal", "opened")
|
|
|
|
|
|
func close() -> void:
|
|
_set_visible(false)
|
|
Audit.log("help_modal", "closed")
|
|
|
|
|
|
# ── UI construction ───────────────────────────────────────────────────────────
|
|
|
|
func _build_ui() -> void:
|
|
# Dim backdrop — MOUSE_FILTER_STOP so click-outside dismisses.
|
|
_dim = ColorRect.new()
|
|
_dim.name = "Dim"
|
|
_dim.set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
_dim.color = Color(0.0, 0.0, 0.0, 0.55)
|
|
_dim.mouse_filter = Control.MOUSE_FILTER_STOP
|
|
_dim.gui_input.connect(_on_dim_input)
|
|
add_child(_dim)
|
|
|
|
# Centered modal panel.
|
|
_panel = PanelContainer.new()
|
|
_panel.name = "HelpDialog"
|
|
_panel.set_anchors_preset(Control.PRESET_CENTER)
|
|
_panel.custom_minimum_size = Vector2(MODAL_W, MODAL_H)
|
|
_panel.offset_left = -MODAL_W / 2
|
|
_panel.offset_right = MODAL_W / 2
|
|
_panel.offset_top = -MODAL_H / 2
|
|
_panel.offset_bottom = MODAL_H / 2
|
|
add_child(_panel)
|
|
|
|
var outer := VBoxContainer.new()
|
|
outer.add_theme_constant_override("separation", 0)
|
|
_panel.add_child(outer)
|
|
|
|
# ── Header row: title label + close button ────────────────────────────────
|
|
var header := HBoxContainer.new()
|
|
header.name = "Header"
|
|
header.add_theme_constant_override("separation", 8)
|
|
header.custom_minimum_size = Vector2(0, 48)
|
|
outer.add_child(header)
|
|
|
|
var title_lbl := Label.new()
|
|
title_lbl.name = "Title"
|
|
title_lbl.text = Strings.t(&"ui.help.title")
|
|
title_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
title_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
|
title_lbl.add_theme_font_size_override("font_size", 22)
|
|
header.add_child(title_lbl)
|
|
|
|
var close_btn := Button.new()
|
|
close_btn.name = "CloseBtn"
|
|
close_btn.text = Strings.t(&"ui.help.close")
|
|
close_btn.custom_minimum_size = Vector2(48, 48)
|
|
close_btn.focus_mode = Control.FOCUS_NONE
|
|
close_btn.pressed.connect(close)
|
|
header.add_child(close_btn)
|
|
|
|
outer.add_child(HSeparator.new())
|
|
|
|
# ── Tab row ───────────────────────────────────────────────────────────────
|
|
var tab_row := HBoxContainer.new()
|
|
tab_row.name = "TabRow"
|
|
tab_row.add_theme_constant_override("separation", 4)
|
|
outer.add_child(tab_row)
|
|
|
|
var tab_keys: Array[StringName] = [
|
|
&"ui.help.tab.controls",
|
|
&"ui.help.tab.verbs",
|
|
&"ui.help.tab.priorities",
|
|
&"ui.help.tab.storyteller",
|
|
&"ui.help.tab.tips",
|
|
]
|
|
|
|
_tab_btns.clear()
|
|
for i in range(TAB_COUNT):
|
|
var btn := Button.new()
|
|
btn.name = "Tab%d" % i
|
|
btn.text = Strings.t(tab_keys[i])
|
|
btn.custom_minimum_size = Vector2(0, 36)
|
|
btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
btn.focus_mode = Control.FOCUS_NONE
|
|
var idx: int = i # capture for lambda
|
|
btn.pressed.connect(func() -> void: _on_tab_pressed(idx))
|
|
tab_row.add_child(btn)
|
|
_tab_btns.append(btn)
|
|
|
|
outer.add_child(HSeparator.new())
|
|
|
|
# ── Scrollable content area ───────────────────────────────────────────────
|
|
_scroll = ScrollContainer.new()
|
|
_scroll.name = "ContentScroll"
|
|
_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
|
outer.add_child(_scroll)
|
|
|
|
_content_box = VBoxContainer.new()
|
|
_content_box.name = "ContentBox"
|
|
_content_box.add_theme_constant_override("separation", 8)
|
|
_content_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_scroll.add_child(_content_box)
|
|
|
|
|
|
# ── tab switching ─────────────────────────────────────────────────────────────
|
|
|
|
func _on_tab_pressed(idx: int) -> void:
|
|
_active_tab = idx
|
|
_refresh_tabs()
|
|
call_deferred("_show_section")
|
|
|
|
|
|
func _refresh_tabs() -> void:
|
|
for i in range(_tab_btns.size()):
|
|
if i == _active_tab:
|
|
_tab_btns[i].modulate = AMBER
|
|
else:
|
|
_tab_btns[i].modulate = Color.WHITE
|
|
|
|
|
|
func _show_section() -> void:
|
|
# Clear old content.
|
|
for child in _content_box.get_children():
|
|
child.queue_free()
|
|
|
|
# Reset scroll position.
|
|
_scroll.scroll_vertical = 0
|
|
|
|
var heading: String
|
|
var body: String
|
|
|
|
match _active_tab:
|
|
TAB_CONTROLS:
|
|
heading = Strings.t(&"help.controls.heading")
|
|
body = Strings.t(&"help.controls.body")
|
|
TAB_VERBS:
|
|
heading = Strings.t(&"help.verbs.heading")
|
|
body = Strings.t(&"help.verbs.body")
|
|
TAB_PRIORITIES:
|
|
heading = Strings.t(&"help.priorities.heading")
|
|
body = Strings.t(&"help.priorities.body")
|
|
TAB_STORYTELLER:
|
|
heading = Strings.t(&"help.storyteller.heading")
|
|
body = Strings.t(&"help.storyteller.body")
|
|
TAB_TIPS:
|
|
heading = Strings.t(&"help.tips.heading")
|
|
body = Strings.t(&"help.tips.body")
|
|
_:
|
|
heading = ""
|
|
body = ""
|
|
|
|
var heading_lbl := Label.new()
|
|
heading_lbl.name = "SectionHeading"
|
|
heading_lbl.text = heading
|
|
heading_lbl.add_theme_font_size_override("font_size", 16)
|
|
_content_box.add_child(heading_lbl)
|
|
|
|
_content_box.add_child(HSeparator.new())
|
|
|
|
var body_lbl := Label.new()
|
|
body_lbl.name = "SectionBody"
|
|
body_lbl.text = body
|
|
body_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
body_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
_content_box.add_child(body_lbl)
|
|
|
|
|
|
# ── input handling ────────────────────────────────────────────────────────────
|
|
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
if not visible:
|
|
return
|
|
if event.is_action_pressed("cancel"):
|
|
close()
|
|
get_viewport().set_input_as_handled()
|
|
|
|
|
|
func _on_dim_input(event: InputEvent) -> void:
|
|
# Clicking the dim backdrop closes the modal.
|
|
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
|
close()
|
|
|
|
|
|
# ── signal handler ────────────────────────────────────────────────────────────
|
|
|
|
func _on_help_requested() -> void:
|
|
open()
|
|
|
|
|
|
# ── visibility ────────────────────────────────────────────────────────────────
|
|
|
|
func _set_visible(v: bool) -> void:
|
|
# Toggle CanvasLayer.visible so the dim (MOUSE_FILTER_STOP) does not eat
|
|
# world input when the modal is hidden.
|
|
visible = v
|
|
if _dim != null:
|
|
_dim.visible = v
|
|
if _panel != null:
|
|
_panel.visible = v
|