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>
This commit is contained in:
parent
bba1ce4334
commit
59ca6ba9c5
16 changed files with 844 additions and 22 deletions
|
|
@ -63,6 +63,7 @@ func _ready() -> void:
|
|||
|
||||
func open() -> void:
|
||||
_set_panel_visible(true)
|
||||
EventBus.ui_panel_opened.emit(&"build_drawer")
|
||||
Audit.log("build_drawer", "opened (tab=%d)" % _active_tab)
|
||||
|
||||
|
||||
|
|
@ -94,6 +95,7 @@ func _build_ui() -> void:
|
|||
_fab.text = "+"
|
||||
_fab.custom_minimum_size = Vector2(FAB_SIZE, FAB_SIZE)
|
||||
_fab.focus_mode = Control.FOCUS_NONE
|
||||
_fab.tooltip_text = Strings.t(&"tooltip.fab_build")
|
||||
_fab.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
|
||||
_fab.offset_left = -FAB_SIZE - 8
|
||||
_fab.offset_right = -8
|
||||
|
|
@ -177,11 +179,11 @@ func _build_designate_tab() -> Control:
|
|||
var flow := _make_flow_grid()
|
||||
box.add_child(flow)
|
||||
|
||||
_add_tool_btn(flow, Strings.t(&"tool.chop"), &"chop", func() -> void: _activate(&"chop", &"", Strings.t(&"tool.chop")))
|
||||
_add_tool_btn(flow, Strings.t(&"tool.mine"), &"mine", func() -> void: _activate(&"mine", &"", Strings.t(&"tool.mine")))
|
||||
_add_tool_btn(flow, Strings.t(&"tool.dig_grave"), &"dig_grave", func() -> void: _activate(&"dig_grave", &"", Strings.t(&"tool.dig_grave")))
|
||||
_add_tool_btn(flow, Strings.t(&"tool.no_roof"), &"no_roof", func() -> void: _activate(&"no_roof", &"", Strings.t(&"tool.no_roof")))
|
||||
_add_tool_btn(flow, Strings.t(&"tool.plant_tree"), &"plant_tree", func() -> void: _activate(&"plant_tree", &"", Strings.t(&"tool.plant_tree")))
|
||||
_add_tool_btn(flow, Strings.t(&"tool.chop"), &"chop", func() -> void: _activate(&"chop", &"", Strings.t(&"tool.chop")), Strings.t(&"tooltip.tool.chop"))
|
||||
_add_tool_btn(flow, Strings.t(&"tool.mine"), &"mine", func() -> void: _activate(&"mine", &"", Strings.t(&"tool.mine")), Strings.t(&"tooltip.tool.mine"))
|
||||
_add_tool_btn(flow, Strings.t(&"tool.dig_grave"), &"dig_grave", func() -> void: _activate(&"dig_grave", &"", Strings.t(&"tool.dig_grave")), Strings.t(&"tooltip.tool.dig_grave"))
|
||||
_add_tool_btn(flow, Strings.t(&"tool.no_roof"), &"no_roof", func() -> void: _activate(&"no_roof", &"", Strings.t(&"tool.no_roof")), Strings.t(&"tooltip.tool.no_roof"))
|
||||
_add_tool_btn(flow, Strings.t(&"tool.plant_tree"), &"plant_tree", func() -> void: _activate(&"plant_tree", &"", Strings.t(&"tool.plant_tree")), Strings.t(&"tooltip.tool.plant_tree"))
|
||||
|
||||
return box
|
||||
|
||||
|
|
@ -198,15 +200,15 @@ func _build_build_tab() -> Control:
|
|||
var st := _make_section_column(Strings.t(&"ui.build_drawer.section.structures"))
|
||||
var st_grid := st.get_child(1) as GridContainer
|
||||
_add_tool_btn(st_grid, Strings.t(&"tool.build_wall_stone"), &"build_wall_stone",
|
||||
func() -> void: _activate_wall(&"stone"))
|
||||
func() -> void: _activate_wall(&"stone"), Strings.t(&"tooltip.tool.build_wall_stone"))
|
||||
_add_tool_btn(st_grid, Strings.t(&"tool.build_wall_wood"), &"build_wall_wood",
|
||||
func() -> void: _activate_wall(&"wood"))
|
||||
func() -> void: _activate_wall(&"wood"), Strings.t(&"tooltip.tool.build_wall_wood"))
|
||||
_add_tool_btn(st_grid, Strings.t(&"tool.build_door"), &"build_door",
|
||||
func() -> void: _activate(&"build_door", &"", Strings.t(&"tool.build_door")))
|
||||
func() -> void: _activate(&"build_door", &"", Strings.t(&"tool.build_door")), Strings.t(&"tooltip.tool.build_door"))
|
||||
_add_tool_btn(st_grid, Strings.t(&"tool.build_floor_wood"), &"build_floor_wood",
|
||||
func() -> void: _activate_floor(&"wood"))
|
||||
func() -> void: _activate_floor(&"wood"), Strings.t(&"tooltip.tool.build_floor_wood"))
|
||||
_add_tool_btn(st_grid, Strings.t(&"tool.build_floor_stone"), &"build_floor_stone",
|
||||
func() -> void: _activate_floor(&"stone"))
|
||||
func() -> void: _activate_floor(&"stone"), Strings.t(&"tooltip.tool.build_floor_stone"))
|
||||
row.add_child(st)
|
||||
row.add_child(VSeparator.new())
|
||||
|
||||
|
|
@ -214,11 +216,11 @@ func _build_build_tab() -> Control:
|
|||
var fu := _make_section_column(Strings.t(&"ui.build_drawer.section.furniture"))
|
||||
var fu_grid := fu.get_child(1) as GridContainer
|
||||
_add_tool_btn(fu_grid, Strings.t(&"tool.build_crate"), &"build_crate",
|
||||
func() -> void: _activate(&"build_crate", &"", Strings.t(&"tool.build_crate")))
|
||||
func() -> void: _activate(&"build_crate", &"", Strings.t(&"tool.build_crate")), Strings.t(&"tooltip.tool.build_crate"))
|
||||
_add_tool_btn(fu_grid, Strings.t(&"tool.build_bed"), &"build_bed",
|
||||
func() -> void: _activate(&"build_bed", &"", Strings.t(&"tool.build_bed")))
|
||||
func() -> void: _activate(&"build_bed", &"", Strings.t(&"tool.build_bed")), Strings.t(&"tooltip.tool.build_bed"))
|
||||
_add_tool_btn(fu_grid, Strings.t(&"tool.build_torch"), &"build_torch",
|
||||
func() -> void: _activate(&"build_torch", &"", Strings.t(&"tool.build_torch")))
|
||||
func() -> void: _activate(&"build_torch", &"", Strings.t(&"tool.build_torch")), Strings.t(&"tooltip.tool.build_torch"))
|
||||
row.add_child(fu)
|
||||
row.add_child(VSeparator.new())
|
||||
|
||||
|
|
@ -227,24 +229,30 @@ func _build_build_tab() -> Control:
|
|||
var pr_grid := pr.get_child(1) as GridContainer
|
||||
_add_tool_btn(pr_grid, Strings.t(&"tool.workbench_carpenter"),
|
||||
&"build_workbench_carpenter",
|
||||
func() -> void: _activate(&"build_workbench_carpenter", &"", Strings.t(&"tool.workbench_carpenter")))
|
||||
func() -> void: _activate(&"build_workbench_carpenter", &"", Strings.t(&"tool.workbench_carpenter")),
|
||||
Strings.t(&"tooltip.tool.build_workbench_carpenter"))
|
||||
_add_tool_btn(pr_grid, Strings.t(&"tool.workbench_smelter"),
|
||||
&"build_workbench_smelter",
|
||||
func() -> void: _activate(&"build_workbench_smelter", &"", Strings.t(&"tool.workbench_smelter")))
|
||||
func() -> void: _activate(&"build_workbench_smelter", &"", Strings.t(&"tool.workbench_smelter")),
|
||||
Strings.t(&"tooltip.tool.build_workbench_smelter"))
|
||||
_add_tool_btn(pr_grid, Strings.t(&"tool.workbench_millstone"),
|
||||
&"build_workbench_millstone",
|
||||
func() -> void: _activate(&"build_workbench_millstone", &"", Strings.t(&"tool.workbench_millstone")))
|
||||
func() -> void: _activate(&"build_workbench_millstone", &"", Strings.t(&"tool.workbench_millstone")),
|
||||
Strings.t(&"tooltip.tool.build_workbench_millstone"))
|
||||
_add_tool_btn(pr_grid, Strings.t(&"tool.workbench_hearth"),
|
||||
&"build_workbench_hearth",
|
||||
func() -> void: _activate(&"build_workbench_hearth", &"", Strings.t(&"tool.workbench_hearth")))
|
||||
func() -> void: _activate(&"build_workbench_hearth", &"", Strings.t(&"tool.workbench_hearth")),
|
||||
Strings.t(&"tooltip.tool.build_workbench_hearth"))
|
||||
_add_tool_btn(pr_grid, Strings.t(&"tool.workbench_cremation_pyre"),
|
||||
&"build_workbench_cremation_pyre",
|
||||
func() -> void: _activate(&"build_workbench_cremation_pyre", &"", Strings.t(&"tool.workbench_cremation_pyre")))
|
||||
func() -> void: _activate(&"build_workbench_cremation_pyre", &"", Strings.t(&"tool.workbench_cremation_pyre")),
|
||||
Strings.t(&"tooltip.tool.build_workbench_cremation_pyre"))
|
||||
# Quarry — must be painted on a stone outcrop (BigRockNode); world.gd
|
||||
# rejects placements on plain ground.
|
||||
_add_tool_btn(pr_grid, Strings.t(&"tool.paint_quarry"),
|
||||
&"paint_quarry",
|
||||
func() -> void: _activate(&"paint_quarry", &"", Strings.t(&"tool.paint_quarry")))
|
||||
func() -> void: _activate(&"paint_quarry", &"", Strings.t(&"tool.paint_quarry")),
|
||||
Strings.t(&"tooltip.tool.paint_quarry"))
|
||||
row.add_child(pr)
|
||||
|
||||
return row
|
||||
|
|
@ -259,9 +267,11 @@ func _build_stockpile_tab() -> Control:
|
|||
box.add_child(flow)
|
||||
|
||||
_add_tool_btn(flow, Strings.t(&"tool.stockpile_general"), &"paint_stockpile",
|
||||
func() -> void: _activate(&"paint_stockpile", &"", Strings.t(&"tool.stockpile_general")))
|
||||
func() -> void: _activate(&"paint_stockpile", &"", Strings.t(&"tool.stockpile_general")),
|
||||
Strings.t(&"tooltip.tool.paint_stockpile"))
|
||||
_add_tool_btn(flow, Strings.t(&"tool.graveyard"), &"graveyard",
|
||||
func() -> void: _activate(&"graveyard", &"", Strings.t(&"tool.graveyard")))
|
||||
func() -> void: _activate(&"graveyard", &"", Strings.t(&"tool.graveyard")),
|
||||
Strings.t(&"tooltip.tool.graveyard"))
|
||||
|
||||
return box
|
||||
|
||||
|
|
@ -332,10 +342,13 @@ const _THUMB_SCRIPT: Script = preload("res://scenes/ui/build_drawer_thumb.gd")
|
|||
## Add a single tool button to `container`. The button is a VBoxContainer of
|
||||
## [thumb preview + Label] wrapped in a Button so the whole cell is one touch
|
||||
## target. `tool_id` drives the procedural preview shape (BuildDrawerThumb).
|
||||
func _add_tool_btn(container: Control, label_text: String, tool_id: StringName, callback: Callable) -> void:
|
||||
## `tooltip` is optional — set it for desktop discoverability; ignored on touch.
|
||||
func _add_tool_btn(container: Control, label_text: String, tool_id: StringName, callback: Callable, tooltip: String = "") -> void:
|
||||
var btn := Button.new()
|
||||
btn.custom_minimum_size = Vector2(BTN_SIZE, BTN_SIZE + LABEL_HEIGHT)
|
||||
btn.focus_mode = Control.FOCUS_NONE
|
||||
if tooltip != "":
|
||||
btn.tooltip_text = tooltip
|
||||
|
||||
var vb := VBoxContainer.new()
|
||||
vb.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
|
|
|
|||
260
scenes/ui/help_modal.gd
Normal file
260
scenes/ui/help_modal.gd
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
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
|
||||
1
scenes/ui/help_modal.gd.uid
Normal file
1
scenes/ui/help_modal.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bo78xe3et6bec
|
||||
182
scenes/ui/hint_overlay.gd
Normal file
182
scenes/ui/hint_overlay.gd
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
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
|
||||
1
scenes/ui/hint_overlay.gd.uid
Normal file
1
scenes/ui/hint_overlay.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://mxu0elb57bhw
|
||||
|
|
@ -29,6 +29,9 @@ var _sl_ambient: HSlider = null
|
|||
var _cb_large_text: CheckBox = null
|
||||
var _cb_reduce_motion: CheckBox = null
|
||||
|
||||
# Onboarding controls.
|
||||
var _cb_show_hints: CheckBox = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 26
|
||||
|
|
@ -136,6 +139,35 @@ func _build_ui() -> void:
|
|||
|
||||
_add_separator(vbox)
|
||||
|
||||
# ── Onboarding section ────────────────────────────────────────────────────
|
||||
var onboard_hdr := Label.new()
|
||||
onboard_hdr.text = Strings.t(&"ui.settings.section.onboarding")
|
||||
vbox.add_child(onboard_hdr)
|
||||
|
||||
_cb_show_hints = _make_checkbox(Strings.t(&"ui.settings.show_hints"), vbox)
|
||||
|
||||
var onboard_btns := HBoxContainer.new()
|
||||
onboard_btns.add_theme_constant_override("separation", 12)
|
||||
vbox.add_child(onboard_btns)
|
||||
|
||||
var help_btn := Button.new()
|
||||
help_btn.name = "HelpBtn"
|
||||
help_btn.text = Strings.t(&"ui.settings.help")
|
||||
help_btn.custom_minimum_size = Vector2(120, 48)
|
||||
help_btn.focus_mode = Control.FOCUS_NONE
|
||||
help_btn.pressed.connect(_on_help_pressed)
|
||||
onboard_btns.add_child(help_btn)
|
||||
|
||||
var reset_hints_btn := Button.new()
|
||||
reset_hints_btn.name = "ResetHintsBtn"
|
||||
reset_hints_btn.text = Strings.t(&"ui.settings.reset_hints")
|
||||
reset_hints_btn.custom_minimum_size = Vector2(140, 48)
|
||||
reset_hints_btn.focus_mode = Control.FOCUS_NONE
|
||||
reset_hints_btn.pressed.connect(_on_reset_hints_pressed)
|
||||
onboard_btns.add_child(reset_hints_btn)
|
||||
|
||||
_add_separator(vbox)
|
||||
|
||||
# ── Save / Cancel row ─────────────────────────────────────────────────────
|
||||
var btn_row := HBoxContainer.new()
|
||||
btn_row.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
|
|
@ -226,6 +258,8 @@ func _load_from_game_state() -> void:
|
|||
_cb_large_text.button_pressed = bool(s.get("accessibility_large_text", false))
|
||||
_cb_reduce_motion.button_pressed = bool(s.get("accessibility_reduce_motion", false))
|
||||
|
||||
_cb_show_hints.button_pressed = bool(s.get("show_hints", true))
|
||||
|
||||
|
||||
func _collect_to_dict() -> Dictionary:
|
||||
return {
|
||||
|
|
@ -240,6 +274,7 @@ func _collect_to_dict() -> Dictionary:
|
|||
"audio_ambient": _sl_ambient.value,
|
||||
"accessibility_large_text": _cb_large_text.button_pressed,
|
||||
"accessibility_reduce_motion": _cb_reduce_motion.button_pressed,
|
||||
"show_hints": _cb_show_hints.button_pressed,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -276,6 +311,24 @@ func _on_cancel_pressed() -> void:
|
|||
_set_visible(false)
|
||||
|
||||
|
||||
func _on_help_pressed() -> void:
|
||||
# Close settings first, then open help so layer 20 (HelpModal) draws above.
|
||||
_set_visible(false)
|
||||
EventBus.help_requested.emit()
|
||||
Audit.log("settings_menu", "help requested")
|
||||
|
||||
|
||||
func _on_reset_hints_pressed() -> void:
|
||||
if HintSystem != null and HintSystem.has_method("reset_tour"):
|
||||
HintSystem.reset_tour()
|
||||
else:
|
||||
# Fallback: direct dict mutation if HintSystem hasn't landed yet.
|
||||
GameState.settings["dismissed_hints"] = [] as Array
|
||||
GameState.settings["show_hints"] = true
|
||||
EventBus.alert_added.emit(&"info", Strings.t(&"ui.settings.hints_reset"), Vector2i(-1, -1))
|
||||
Audit.log("settings_menu", "hints reset")
|
||||
|
||||
|
||||
# ── visibility ────────────────────────────────────────────────────────────────
|
||||
|
||||
func _set_visible(v: bool) -> void:
|
||||
|
|
|
|||
|
|
@ -54,6 +54,14 @@ func _ready() -> void:
|
|||
ultra_btn.text = Strings.t(&"speed.ultra")
|
||||
save_btn.text = Strings.t(&"ui.save")
|
||||
load_btn.text = Strings.t(&"ui.load")
|
||||
|
||||
# Phase 19 — desktop hover tooltips (no-op on touch).
|
||||
pause_btn.tooltip_text = Strings.t(&"tooltip.pause")
|
||||
normal_btn.tooltip_text = Strings.t(&"tooltip.speed_normal")
|
||||
fast_btn.tooltip_text = Strings.t(&"tooltip.speed_fast")
|
||||
ultra_btn.tooltip_text = Strings.t(&"tooltip.speed_ultra")
|
||||
save_btn.tooltip_text = Strings.t(&"tooltip.save")
|
||||
load_btn.tooltip_text = Strings.t(&"tooltip.load")
|
||||
tick_label.text = "(boot)"
|
||||
clock_label.text = Strings.t(&"clock.format").format({"d": 1, "t": "06:00"})
|
||||
season_label.text = Strings.t(&"season.format").format({"s": Strings.t(&"season.spring"), "d": 1})
|
||||
|
|
@ -159,6 +167,7 @@ func _add_build_btn() -> void:
|
|||
build_btn.text = "🔨"
|
||||
build_btn.custom_minimum_size = Vector2(40, 40)
|
||||
build_btn.focus_mode = Control.FOCUS_NONE
|
||||
build_btn.tooltip_text = Strings.t(&"tooltip.build")
|
||||
build_btn.pressed.connect(_on_build_pressed)
|
||||
button_row.add_child(build_btn)
|
||||
Audit.log("top_bar", "Build button added to ButtonRow")
|
||||
|
|
@ -183,6 +192,7 @@ func _add_settings_btn() -> void:
|
|||
settings_btn.text = "⚙"
|
||||
settings_btn.custom_minimum_size = Vector2(40, 40)
|
||||
settings_btn.focus_mode = Control.FOCUS_NONE
|
||||
settings_btn.tooltip_text = Strings.t(&"tooltip.settings")
|
||||
settings_btn.pressed.connect(_on_settings_pressed)
|
||||
button_row.add_child(settings_btn)
|
||||
Audit.log("top_bar", "Settings button added to ButtonRow")
|
||||
|
|
@ -208,6 +218,7 @@ func _add_work_log_btns() -> void:
|
|||
work_btn.text = "👷"
|
||||
work_btn.custom_minimum_size = Vector2(40, 40)
|
||||
work_btn.focus_mode = Control.FOCUS_NONE
|
||||
work_btn.tooltip_text = Strings.t(&"tooltip.work")
|
||||
work_btn.pressed.connect(_on_work_pressed)
|
||||
button_row.add_child(work_btn)
|
||||
|
||||
|
|
@ -216,6 +227,7 @@ func _add_work_log_btns() -> void:
|
|||
_log_btn.text = "🔔"
|
||||
_log_btn.custom_minimum_size = Vector2(40, 40)
|
||||
_log_btn.focus_mode = Control.FOCUS_NONE
|
||||
_log_btn.tooltip_text = Strings.t(&"tooltip.log")
|
||||
_log_btn.pressed.connect(_on_log_pressed)
|
||||
button_row.add_child(_log_btn)
|
||||
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ func open() -> void:
|
|||
_rebuild_grid()
|
||||
visible = true
|
||||
_root.visible = true
|
||||
EventBus.ui_panel_opened.emit(&"work_matrix")
|
||||
Audit.log("work_priority_ui", "opened (pawns=%d)" % World.pawns.size())
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue