diff --git a/autoload/event_bus.gd b/autoload/event_bus.gd index a4d4e58..4ceda64 100644 --- a/autoload/event_bus.gd +++ b/autoload/event_bus.gd @@ -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. diff --git a/autoload/game_state.gd b/autoload/game_state.gd index c5562a1..4c66ff9 100644 --- a/autoload/game_state.gd +++ b/autoload/game_state.gd @@ -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, diff --git a/autoload/hint_system.gd b/autoload/hint_system.gd new file mode 100644 index 0000000..344cfca --- /dev/null +++ b/autoload/hint_system.gd @@ -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 diff --git a/autoload/hint_system.gd.uid b/autoload/hint_system.gd.uid new file mode 100644 index 0000000..ae07e3a --- /dev/null +++ b/autoload/hint_system.gd.uid @@ -0,0 +1 @@ +uid://g7bkgx0vfiq1 diff --git a/autoload/strings.gd b/autoload/strings.gd index fafca34..584a799 100644 --- a/autoload/strings.gd +++ b/autoload/strings.gd @@ -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", } diff --git a/memory.md b/memory.md index ec0ba97..b70b281 100644 --- a/memory.md +++ b/memory.md @@ -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 diff --git a/project.godot b/project.godot index 7859330..fd92e54 100644 --- a/project.godot +++ b/project.godot @@ -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" diff --git a/scenes/main/main.gd b/scenes/main/main.gd index fa6d2d7..a4ffb98 100644 --- a/scenes/main/main.gd +++ b/scenes/main/main.gd @@ -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 diff --git a/scenes/ui/build_drawer.gd b/scenes/ui/build_drawer.gd index 33b0721..5f1c00a 100644 --- a/scenes/ui/build_drawer.gd +++ b/scenes/ui/build_drawer.gd @@ -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 diff --git a/scenes/ui/help_modal.gd b/scenes/ui/help_modal.gd new file mode 100644 index 0000000..507645e --- /dev/null +++ b/scenes/ui/help_modal.gd @@ -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 diff --git a/scenes/ui/help_modal.gd.uid b/scenes/ui/help_modal.gd.uid new file mode 100644 index 0000000..0193c50 --- /dev/null +++ b/scenes/ui/help_modal.gd.uid @@ -0,0 +1 @@ +uid://bo78xe3et6bec diff --git a/scenes/ui/hint_overlay.gd b/scenes/ui/hint_overlay.gd new file mode 100644 index 0000000..4add345 --- /dev/null +++ b/scenes/ui/hint_overlay.gd @@ -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 diff --git a/scenes/ui/hint_overlay.gd.uid b/scenes/ui/hint_overlay.gd.uid new file mode 100644 index 0000000..02afc9d --- /dev/null +++ b/scenes/ui/hint_overlay.gd.uid @@ -0,0 +1 @@ +uid://mxu0elb57bhw diff --git a/scenes/ui/settings_menu.gd b/scenes/ui/settings_menu.gd index 6590006..92d3462 100644 --- a/scenes/ui/settings_menu.gd +++ b/scenes/ui/settings_menu.gd @@ -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: diff --git a/scenes/ui/top_bar.gd b/scenes/ui/top_bar.gd index d778e8d..9645e42 100644 --- a/scenes/ui/top_bar.gd +++ b/scenes/ui/top_bar.gd @@ -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) diff --git a/scenes/ui/work_priority_matrix.gd b/scenes/ui/work_priority_matrix.gd index d09eb4a..1318392 100644 --- a/scenes/ui/work_priority_matrix.gd +++ b/scenes/ui/work_priority_matrix.gd @@ -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())