rimlike/scenes/ui/hint_overlay.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

182 lines
7.2 KiB
GDScript

class_name HintOverlay extends CanvasLayer
## Phase 19 — Top-center hint banner for the onboarding tour.
##
## Layer 22: above StorytellerModal (20) and DaySummaryCard (19) so hints are
## always visible. Non-blocking — only the banner itself catches input; the
## world below remains fully interactive while the hint is up.
##
## API:
## show_hint(hint_id, message) — populate text and slide in.
## hide_hint() — slide out and emit hint_dismissed.
##
## Registers itself with HintSystem in _ready so the autoload never calls
## find_child() on the tree.
const BANNER_W: int = 640
const BANNER_H: int = 80
const SLIDE_DUR: float = 0.30 # seconds for the slide-in/out tween
# Accent gold — matches MedievalTheme.C_ACCENT_GOLD.
const C_ACCENT_GOLD: Color = Color(0.92, 0.75, 0.22, 1.0)
# ── node refs ─────────────────────────────────────────────────────────────────
var _root_ctrl: Control = null # MOUSE_FILTER_IGNORE, full-rect anchor
var _panel: PanelContainer = null # the visible banner
var _icon_lbl: Label = null
var _msg_lbl: Label = null
var _got_it_btn: Button = null
# ── state ─────────────────────────────────────────────────────────────────────
var _current_hint_id: StringName = &""
var _tween: Tween = null
func _ready() -> void:
layer = 22
process_mode = Node.PROCESS_MODE_ALWAYS
_build_ui()
_set_panel_visible(false)
# Register with the HintSystem autoload so it can call show_hint/hide_hint.
if Engine.has_singleton("HintSystem") or has_node("/root/HintSystem"):
var hs: Node = get_node_or_null("/root/HintSystem")
if hs != null and hs.has_method("register_overlay"):
hs.register_overlay(self)
Audit.log("hint_overlay", "HintOverlay ready (layer %d)" % layer)
func _exit_tree() -> void:
pass # signal connections owned by HintSystem are cleaned up when it exits
# ── UI construction ───────────────────────────────────────────────────────────
func _build_ui() -> void:
# Transparent full-rect root — MOUSE_FILTER_IGNORE so world stays interactive.
_root_ctrl = Control.new()
_root_ctrl.name = "Root"
_root_ctrl.set_anchors_preset(Control.PRESET_FULL_RECT)
_root_ctrl.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_root_ctrl)
# Banner PanelContainer — top-center, fixed size.
_panel = PanelContainer.new()
_panel.name = "Banner"
_panel.custom_minimum_size = Vector2(BANNER_W, BANNER_H)
# Anchor to top-center.
_panel.set_anchors_preset(Control.PRESET_TOP_WIDE)
# Center horizontally: offset left/right to BANNER_W wide, centred.
_panel.anchor_left = 0.5
_panel.anchor_right = 0.5
_panel.anchor_top = 0.0
_panel.anchor_bottom = 0.0
_panel.offset_left = -BANNER_W / 2.0
_panel.offset_right = BANNER_W / 2.0
_panel.offset_top = 0.0
_panel.offset_bottom = BANNER_H
_panel.mouse_filter = Control.MOUSE_FILTER_STOP # banner catches its own taps
_panel.gui_input.connect(_on_banner_input)
_root_ctrl.add_child(_panel)
# Inner HBox: icon | message (expanding) | button
var hbox := HBoxContainer.new()
hbox.alignment = BoxContainer.ALIGNMENT_CENTER
hbox.add_theme_constant_override("separation", 12)
_panel.add_child(hbox)
# Icon label — bulb emoji as procedural indicator, gold-tinted.
_icon_lbl = Label.new()
_icon_lbl.name = "Icon"
_icon_lbl.text = "💡"
_icon_lbl.add_theme_font_size_override("font_size", 20)
_icon_lbl.add_theme_color_override("font_color", C_ACCENT_GOLD)
_icon_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
hbox.add_child(_icon_lbl)
# Message label — auto-wraps, expands to fill.
_msg_lbl = Label.new()
_msg_lbl.name = "Message"
_msg_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_msg_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_msg_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
hbox.add_child(_msg_lbl)
# "Got it" button — right side.
_got_it_btn = Button.new()
_got_it_btn.name = "GotItBtn"
_got_it_btn.text = Strings.t(&"hint.got_it")
_got_it_btn.custom_minimum_size = Vector2(80, 40)
_got_it_btn.focus_mode = Control.FOCUS_NONE
_got_it_btn.pressed.connect(hide_hint)
hbox.add_child(_got_it_btn)
# ── public API ────────────────────────────────────────────────────────────────
## Populate the banner with hint_id + message text then slide it in from above.
func show_hint(hint_id: StringName, message: String) -> void:
_current_hint_id = hint_id
_msg_lbl.text = message
_set_panel_visible(true)
var reduce_motion: bool = bool(GameState.settings.get("accessibility_reduce_motion", false))
if reduce_motion:
# Snap to final position immediately.
_panel.offset_top = 0.0
_panel.offset_bottom = BANNER_H
return
# Slide in from above: start fully off-screen, tween to resting position.
_panel.offset_top = -BANNER_H
_panel.offset_bottom = 0.0
if _tween != null and _tween.is_running():
_tween.kill()
_tween = create_tween()
_tween.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
_tween.tween_property(_panel, "offset_top", 0.0, SLIDE_DUR)
_tween.parallel().tween_property(_panel, "offset_bottom", float(BANNER_H), SLIDE_DUR)
## Slide the banner out upward and emit hint_dismissed with the current hint_id.
func hide_hint() -> void:
var id: StringName = _current_hint_id
var reduce_motion: bool = bool(GameState.settings.get("accessibility_reduce_motion", false))
if reduce_motion:
_set_panel_visible(false)
EventBus.hint_dismissed.emit(id)
return
if _tween != null and _tween.is_running():
_tween.kill()
_tween = create_tween()
_tween.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN)
_tween.tween_property(_panel, "offset_top", -float(BANNER_H), SLIDE_DUR)
_tween.parallel().tween_property(_panel, "offset_bottom", 0.0, SLIDE_DUR)
_tween.tween_callback(_on_slide_out_finished.bind(id))
# ── internal callbacks ────────────────────────────────────────────────────────
func _on_slide_out_finished(id: StringName) -> void:
_set_panel_visible(false)
EventBus.hint_dismissed.emit(id)
func _on_banner_input(event: InputEvent) -> void:
# Tap anywhere on the banner also dismisses it (touch-friendly).
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
hide_hint()
# ── helpers ───────────────────────────────────────────────────────────────────
func _set_panel_visible(v: bool) -> void:
if _panel != null:
_panel.visible = v
# Keep root_ctrl always present but invisible panel blocks nothing.
if _root_ctrl != null:
_root_ctrl.visible = true # always: root is MOUSE_FILTER_IGNORE anyway