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