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>
182 lines
7.2 KiB
GDScript
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
|