rimlike/scenes/ui/help_modal.gd
megaproxy 59ca6ba9c5 Phase 19 — onboarding: hint tour + Help modal + tooltip pass
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>
2026-05-16 17:36:18 +01:00

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