rimlike/autoload/hint_system.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

200 lines
7.1 KiB
GDScript

extends Node
## Phase 19 — Onboarding hint tour orchestrator.
##
## Reads GameState.settings["show_hints"] and ["dismissed_hints"] at _ready.
## Connects signal-based triggers for the 7-step tour; queues hints when the
## overlay is busy (max depth 3, oldest dropped first on overflow).
##
## The autoload name is HintSystem. HintOverlay registers itself here in its
## own _ready so this singleton never calls find_child() on the tree.
##
## Tour steps in order:
## welcome → pawn_select → build_drawer → stockpile_painted →
## work_matrix → day_ended → tour_complete (auto-fired after day_ended)
# ── tour definition ──────────────────────────────────────────────────────────
## Each entry: { id, message_key } — trigger wiring is in _connect_triggers().
const TOUR: Array = [
{ "id": &"welcome", "msg": &"hint.welcome" },
{ "id": &"pawn_select", "msg": &"hint.pawn_select" },
{ "id": &"build_drawer", "msg": &"hint.build_drawer" },
{ "id": &"stockpile_painted", "msg": &"hint.stockpile_painted" },
{ "id": &"work_matrix", "msg": &"hint.work_matrix" },
{ "id": &"day_ended", "msg": &"hint.day_ended" },
{ "id": &"tour_complete", "msg": &"hint.tour_complete" },
]
const MAX_QUEUE: int = 3
# ── state ─────────────────────────────────────────────────────────────────────
var _overlay: Node = null # HintOverlay — registered via register_overlay()
var _queue: Array = [] # Array[StringName] pending hint ids
var _overlay_busy: bool = false # true while overlay is sliding in or showing
var _enabled: bool = false # false if show_hints == false or all dismissed
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
var show: bool = bool(GameState.settings.get("show_hints", true))
if not show:
Audit.log("hint_system", "HintSystem disabled (show_hints=false)")
return
_enabled = true
_connect_triggers()
Audit.log("hint_system", "HintSystem ready")
# ── public API ───────────────────────────────────────────────────────────────
## Called by HintOverlay._ready() to register itself with this singleton.
func register_overlay(overlay: Node) -> void:
_overlay = overlay
Audit.log("hint_system", "HintOverlay registered")
## Reset the full tour: clear dismissed list, re-enable, refire welcome.
## Called by HelpModal's "Reset hints" button.
func reset_tour() -> void:
GameState.settings["dismissed_hints"] = [] as Array
_queue.clear()
_overlay_busy = false
_enabled = true
if not _are_triggers_connected():
_connect_triggers()
_try_show(&"welcome")
Audit.log("hint_system", "tour reset")
# ── trigger wiring ───────────────────────────────────────────────────────────
func _are_triggers_connected() -> bool:
return EventBus.pawn_selected.is_connected(_on_pawn_selected)
func _connect_triggers() -> void:
# welcome — deferred 2s timer, only if not already dismissed.
if not _is_dismissed(&"welcome"):
get_tree().create_timer(2.0).timeout.connect(_on_welcome_timer)
# pawn_select
if not EventBus.pawn_selected.is_connected(_on_pawn_selected):
EventBus.pawn_selected.connect(_on_pawn_selected)
# build_drawer + work_matrix share ui_panel_opened
if not EventBus.ui_panel_opened.is_connected(_on_ui_panel_opened):
EventBus.ui_panel_opened.connect(_on_ui_panel_opened)
# stockpile_painted
if not EventBus.designation_added.is_connected(_on_designation_added):
EventBus.designation_added.connect(_on_designation_added)
# day_ended
if not EventBus.day_ended.is_connected(_on_day_ended):
EventBus.day_ended.connect(_on_day_ended)
# hint_dismissed — HintOverlay emits this; we persist and dequeue.
if not EventBus.hint_dismissed.is_connected(_on_hint_dismissed):
EventBus.hint_dismissed.connect(_on_hint_dismissed)
# ── trigger handlers ─────────────────────────────────────────────────────────
func _on_welcome_timer() -> void:
_try_show(&"welcome")
func _on_pawn_selected(_pawn) -> void:
_try_show(&"pawn_select")
func _on_ui_panel_opened(panel_id: StringName) -> void:
match panel_id:
&"build_drawer":
_try_show(&"build_drawer")
&"work_matrix":
_try_show(&"work_matrix")
func _on_designation_added(_cell: Vector2i, tool: StringName) -> void:
if tool == &"paint_stockpile":
_try_show(&"stockpile_painted")
func _on_day_ended(_summary: Dictionary) -> void:
_try_show(&"day_ended")
func _on_hint_dismissed(hint_id: StringName) -> void:
# Persist dismissal (String, not StringName, for JSON-safe save).
var dismissed: Array = GameState.settings["dismissed_hints"]
var id_str: String = String(hint_id)
if not dismissed.has(id_str):
dismissed.append(id_str)
GameState.settings["dismissed_hints"] = dismissed
_overlay_busy = false
Audit.log("hint_system", "dismissed: %s total=%d" % [id_str, dismissed.size()])
# Auto-fire tour_complete after day_ended is dismissed.
if hint_id == &"day_ended":
_try_show(&"tour_complete")
# Drain the queue.
_drain_queue()
# ── show / queue logic ───────────────────────────────────────────────────────
func _try_show(hint_id: StringName) -> void:
if not _enabled:
return
if _is_dismissed(hint_id):
return
if _overlay_busy:
_enqueue(hint_id)
return
_show_now(hint_id)
func _show_now(hint_id: StringName) -> void:
if _overlay == null:
# Overlay not yet mounted — queue for later.
_enqueue(hint_id)
return
var msg_key: StringName = _msg_key_for(hint_id)
_overlay_busy = true
_overlay.show_hint(hint_id, Strings.t(msg_key))
func _enqueue(hint_id: StringName) -> void:
if _queue.has(hint_id):
return # already pending
if _queue.size() >= MAX_QUEUE:
_queue.pop_front() # drop oldest to cap backlog
_queue.append(hint_id)
func _drain_queue() -> void:
while _queue.size() > 0:
var next: StringName = _queue[0]
_queue.pop_front()
if _is_dismissed(next):
continue # skip already-dismissed entries
_show_now(next)
return # show one at a time; wait for next hint_dismissed
# ── helpers ──────────────────────────────────────────────────────────────────
func _is_dismissed(hint_id: StringName) -> bool:
var dismissed: Array = GameState.settings.get("dismissed_hints", [])
return dismissed.has(String(hint_id))
func _msg_key_for(hint_id: StringName) -> StringName:
for entry in TOUR:
if entry["id"] == hint_id:
return entry["msg"]
return &"hint.welcome" # fallback