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:
megaproxy 2026-05-16 17:36:18 +01:00
parent bba1ce4334
commit 59ca6ba9c5
16 changed files with 844 additions and 22 deletions

View file

@ -65,6 +65,11 @@ signal request_wolf_spawn(count: int) ## Phase 15 EventCatalog →
signal wolf_spawned(wolves: Array) ## Emitted by WolfSpawner AFTER a raid wave has been instantiated; carries the spawned Wolf nodes. Audio uses this for raid-warning sting.
signal day_ended(summary: Dictionary) ## Emitted by Clock at dusk→night boundary; carries the end-of-day recap dict.
# Phase 19 — Onboarding (hint system + help section).
signal help_requested ## Settings "Help" button → opens HelpModal.
signal hint_dismissed(hint_id: StringName) ## Emitted by HintOverlay when player closes a hint; HintSystem persists the dismissal.
signal ui_panel_opened(panel_id: StringName) ## Emitted by UI panels (build_drawer, work_matrix, ...) when they become visible. HintSystem subscribes for tour gating.
# Phase 18 — Alert wiring (dangling signals surfaced to AlertsLog).
signal no_stockpile_accepts(item_type: StringName, tile: Vector2i) ## Emitted by HaulingProvider when an item needs haul but no stockpile accepts it (rate-limited per item_type).
signal bill_blocked(recipe_label: String, reason: StringName, focus_tile: Vector2i) ## Emitted by CraftingProvider when a bill cannot proceed. reason: missing_ingredient | skill_too_low | no_workbench.

View file

@ -18,6 +18,10 @@ var settings: Dictionary = {
"pause_on_pawn_down": true,
"pause_on_modal": true,
"show_day_summary": true,
# Phase 19 — onboarding hint system.
# show_hints: master toggle; dismissed_hints: per-hint dismissal log (Array[String]).
"show_hints": true,
"dismissed_hints": [] as Array,
"audio_master": 1.0,
"audio_music": 1.0,
"audio_sfx": 1.0,

200
autoload/hint_system.gd Normal file
View file

@ -0,0 +1,200 @@
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

View file

@ -0,0 +1 @@
uid://g7bkgx0vfiq1

View file

@ -261,6 +261,72 @@ const TABLE: Dictionary = {
&"item.armor": "Armor",
&"item.corpse": "Corpse",
&"item.ash": "Ash",
# Phase 19 — Onboarding hint tour.
&"hint.welcome": "Welcome to your settlement. Drag to pan the camera, scroll or pinch to zoom.",
&"hint.pawn_select": "Tap a pawn to open their detail panel — HP, hunger, mood, skills, and work priorities.",
&"hint.build_drawer": "The Build drawer has four tabs: Designate orders, Build structures, paint Stockpiles, or Cancel a tool.",
&"hint.stockpile_painted": "Tap your new stockpile to set its accepted items and priority. Pawns will haul matching items here.",
&"hint.work_matrix": "Work priorities go 0 (off) to 4 (urgent). Pawns pick the highest-priority work they're capable of.",
&"hint.day_ended": "Day ends at dusk. Sleep, mood, and weather all carry into the next day. Plan ahead.",
&"hint.tour_complete": "Tour complete. Find more in Settings → Help anytime.",
&"hint.got_it": "Got it",
# Phase 19 — Help modal content.
&"ui.help.title": "Help",
&"ui.help.close": "X",
&"ui.help.tab.controls": "Controls",
&"ui.help.tab.verbs": "Verbs",
&"ui.help.tab.priorities": "Priorities",
&"ui.help.tab.storyteller": "Storyteller",
&"ui.help.tab.tips": "Tips",
&"help.controls.heading": "Camera & Selection",
&"help.controls.body": "Drag with mouse or finger to pan the camera.\nScroll wheel or pinch to zoom.\n\nWASD or arrow keys also pan; + / - zoom; Home or C re-centers.\n\nTap a pawn, workbench, or stockpile to open its detail panel.\nRight-click or Escape deselects.\n\nB toggles the Build drawer.\nP opens the Work priority matrix.\nL opens the Alerts log.\n, opens Settings.",
&"help.verbs.heading": "Player Verbs",
&"help.verbs.body": "Designate - orders pawns to chop trees, mine rocks, dig graves, prevent roofing, or plant new trees.\n\nBuild - queues construction: walls, floors, doors, beds, torches, crates, workbenches, and quarries.\n\nStockpile - paints a zone where items get hauled. Each zone has a filter (which item types) and a priority.\n\nCancel - clears a tool selection or pending designation.",
&"help.priorities.heading": "Work Priorities",
&"help.priorities.body": "Each pawn has priorities (0-4) per work category. The Work matrix grid lets you tune them.\n\n0 = Off. Pawn won't do this work.\n1 = Background. Only if nothing else fits.\n2 = Low.\n3 = Normal (default).\n4 = Urgent. Drops other work to take this.\n\nNeeds (rest, eat, sleep) always run regardless - you can't starve a pawn by setting priorities.",
&"help.storyteller.heading": "Storyteller Events",
&"help.storyteller.body": "Each in-game day at 6 AM the Storyteller may roll an event. Categories include nudges (gentle hints), threats (wolves, raids), wanderers (recruit offers), seasonal beats, disease, resource booms, lore, and milestones.\n\nThreat events auto-pause the sim and show a modal with a choice. Other events show a banner that fades in a few seconds.\n\nThe Storyteller's tension rises and falls based on what happens to your colony - calmer when nothing has happened recently, escalating after kills, damage, or near-misses.",
&"help.tips.heading": "Early-game Tips",
&"help.tips.body": "Build at least one bed and one wall before nightfall - pawns sleep on the ground and grow tired, and wolves come at night.\n\nKeep beds indoors (under a roof). Rain and cold ruin sleep quality.\n\nPaint a stockpile early - items left on the ground decay (corpses, food) or just clutter the map.\n\nWatch tension. If it climbs, slow down and reinforce. The Storyteller is reactive - give it nothing to react to and it stays quiet.\n\nFood priority order: Meal > Bread > Vegetable / Grain / Strawberry > raw Wheat. Cook before eating raw.",
# Phase 19 — Onboarding section in SettingsMenu.
&"ui.settings.section.onboarding": "Onboarding",
&"ui.settings.show_hints": "Show hints (first session)",
&"ui.settings.help": "Help",
&"ui.settings.reset_hints": "Reset hints",
&"ui.settings.hints_reset": "Hint tour reset. Welcome banner will reappear shortly.",
# Phase 19 — Hover tooltips for desktop discoverability.
&"tooltip.pause": "Pause sim (Space)",
&"tooltip.speed_normal": "Normal speed (1)",
&"tooltip.speed_fast": "Fast speed (2)",
&"tooltip.speed_ultra": "Ultra speed (3)",
&"tooltip.save": "Save game",
&"tooltip.load": "Load game",
&"tooltip.settings": "Open Settings (,)",
&"tooltip.build": "Open Build drawer (B)",
&"tooltip.work": "Open Work priorities (P)",
&"tooltip.log": "Open Alerts log (L)",
&"tooltip.fab_build": "Toggle Build drawer (B)",
&"tooltip.tool.chop": "Order pawns to chop selected trees for wood",
&"tooltip.tool.mine": "Order pawns to mine selected rocks for stone / ore",
&"tooltip.tool.dig_grave": "Mark a tile for grave digging",
&"tooltip.tool.no_roof": "Prevent auto-roofing on selected tiles",
&"tooltip.tool.plant_tree": "Plant a sapling (1 wood)",
&"tooltip.tool.build_wall_stone": "Build stone wall",
&"tooltip.tool.build_wall_wood": "Build wood wall",
&"tooltip.tool.build_floor_wood": "Build wood floor",
&"tooltip.tool.build_floor_stone": "Build stone floor",
&"tooltip.tool.build_door": "Build a door",
&"tooltip.tool.build_crate": "Build a storage crate",
&"tooltip.tool.build_bed": "Build a bed",
&"tooltip.tool.build_torch": "Build a torch",
&"tooltip.tool.build_workbench_carpenter": "Build a carpenter's bench",
&"tooltip.tool.build_workbench_smelter": "Build a smelter",
&"tooltip.tool.build_workbench_millstone": "Build a millstone",
&"tooltip.tool.build_workbench_hearth": "Build a hearth",
&"tooltip.tool.build_workbench_cremation_pyre": "Build a cremation pyre",
&"tooltip.tool.paint_quarry": "Paint a quarry on a stone outcrop",
&"tooltip.tool.paint_stockpile": "Paint a stockpile zone",
&"tooltip.tool.graveyard": "Paint a graveyard zone",
}

View file

@ -98,6 +98,7 @@ Reported from playtest. Triaged but not yet fixed. Plan: knock out as a bug-tria
Older bugs noted in passing but never fixed:
- [ ] **[LOW] Bed-claim failure for 2/3 pawns when beds are free** (logged 2026-05-11). `Bram bed claim failed at /root/Main/World/Bed — sleeping on floor` even when beds free. Doesn't gate progress; needs sleep-system audit.
- [ ] **[LOW] Save mid-INTERACT/mid-BUILD restarts toil from 0 on load** (Phase 16 known acceptable gap). Walk toil round-trips; multi-step interact does not. Tolerable per Phase 20 tuning note.
- [ ] **[MED] Drag-paint eaten by camera with paint tool active** (logged 2026-05-16). When a Designate/Build/Stockpile tool is active in BuildDrawer, the player should be able to click-and-drag to paint a rectangle of cells. Instead, camera_rig's drag-pan consumes the InputEventScreenDrag / MouseMotion and the paint stays single-cell. Contradicts the 2026-05-11 note that claimed drag-paint worked — that was selection drag-painting via Designation, not the camera-vs-paint priority case. Fix needs Selection or Designation to set `set_input_as_handled()` on drag events when a paint tool is active, or camera to skip pan when `Designation.active_tool != TOOL_NONE`.
## Open questions / TODOs

View file

@ -22,6 +22,7 @@ EventBus="*res://autoload/event_bus.gd"
Strings="*res://autoload/strings.gd"
Audit="*res://autoload/audit.gd"
GameState="*res://autoload/game_state.gd"
HintSystem="*res://autoload/hint_system.gd"
World="*res://autoload/world.gd"
Sim="*res://autoload/sim.gd"
Clock="*res://autoload/clock.gd"

View file

@ -34,6 +34,10 @@ const WORK_PRIORITY_MATRIX_SCRIPT: Script = preload("res://scenes/ui/work_prior
const ALERTS_LOG_SCRIPT: Script = preload("res://scenes/ui/alerts_log.gd")
# Phase 17 — DaySummaryCard end-of-day recap modal (layer 19).
const DAY_SUMMARY_CARD_SCRIPT: Script = preload("res://scenes/ui/day_summary_card.gd")
# Phase 19 — HintOverlay top-center banner (layer 22).
const HINT_OVERLAY_SCRIPT: Script = preload("res://scenes/ui/hint_overlay.gd")
# Phase 19 — HelpModal static reference (layer 20).
const HELP_MODAL_SCRIPT: Script = preload("res://scenes/ui/help_modal.gd")
func _ready() -> void:
@ -187,6 +191,23 @@ func _ready() -> void:
Audit.log("main", "Phase 17 — DaySummaryCard mounted.")
# Phase 19 — HintOverlay top-center banner (layer 22).
# HintOverlay._ready() registers itself with HintSystem autoload so the
# autoload never has to find_child() the tree at call time.
var hint_overlay := CanvasLayer.new()
hint_overlay.set_script(HINT_OVERLAY_SCRIPT)
hint_overlay.name = "HintOverlay"
add_child(hint_overlay)
# Phase 19 — HelpModal static reference panel (layer 20).
# Subscribes to EventBus.help_requested in its own _ready; no injection needed.
var help_modal := CanvasLayer.new()
help_modal.set_script(HELP_MODAL_SCRIPT)
help_modal.name = "HelpModal"
add_child(help_modal)
Audit.log("main", "Phase 19 — HintOverlay + HelpModal mounted.")
# Apply the medieval theme to every Control under each CanvasLayer.
# CanvasLayers interrupt the root-Window theme cascade so we have to seed
# each one explicitly. Defer one frame so panels that build their UI in

View file

@ -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
View 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

View file

@ -0,0 +1 @@
uid://bo78xe3et6bec

182
scenes/ui/hint_overlay.gd Normal file
View 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

View file

@ -0,0 +1 @@
uid://mxu0elb57bhw

View file

@ -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:

View file

@ -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)

View file

@ -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())