class_name Tutorial extends RefCounted ## Deterministic first-shift tutorial state machine. It observes player actions ## but owns no UI or simulation nodes, so it can be tested headlessly and wired ## into GameManager without changing the shift FSM. `advance()` only accepts the ## event for the current beat; wrong actions are ignored. signal beat_changed(index: int, beat: Dictionary) signal completed(skipped: bool) const BEATS := [ { "id": "arrive", "prompt_key": "tutorial.arrive", "prompt": "Walk forward and look around. The entrance is behind you.", "event": "moved", }, { "id": "focus", "prompt_key": "tutorial.focus", "prompt": "Look at the visitor. Press Tab or the mouse wheel to cycle targets.", "event": "cycled", }, { "id": "wheel", "prompt_key": "tutorial.wheel", "prompt": "Open the action wheel with Space or left click.", "event": "wheel_opened", }, { "id": "inspect", "prompt_key": "tutorial.inspect", "prompt": "Choose Inspect and read the access evidence.", "event": "inspected", }, { "id": "direction", "prompt_key": "tutorial.direction", "prompt": "Close the panel and give a polite direction.", "event": "polite_direction", }, { "id": "warning", "prompt_key": "tutorial.warning", "prompt": "Give a formal warning if the visitor persists. It arms the body camera.", "event": "formal_warning", "alternate_event": "body_camera", }, { "id": "radio", "prompt_key": "tutorial.radio", "prompt": "Open the assistance radio and submit a short report.", "event": "radio_submitted", }, { "id": "review", "prompt_key": "tutorial.review", "prompt": "Finish the shift and review what happened.", "event": "review_opened", }, ] var index := -1 var complete := false var skipped := false func start() -> Dictionary: index = 0 complete = false skipped = false var beat: Dictionary = BEATS[0] beat_changed.emit(index, beat) return beat func current() -> Dictionary: if index < 0 or index >= BEATS.size() or complete: return {} return BEATS[index] func prompt() -> String: return str(current().get("prompt", "")) func advance(event: String) -> bool: if complete: return false if index < 0: start() if index >= BEATS.size(): return false var beat: Dictionary = BEATS[index] var accepted: Array = [str(beat.get("event", ""))] if beat.has("alternate_event"): accepted.append(str(beat["alternate_event"])) if event not in accepted: return false index += 1 if index >= BEATS.size(): complete = true index = BEATS.size() completed.emit(false) else: beat_changed.emit(index, BEATS[index]) return true ## Skip is recorded separately so Settings/Help can offer "Replay tutorial". func skip() -> void: if complete: return skipped = true complete = true index = BEATS.size() completed.emit(true) func restart() -> Dictionary: return start() func is_complete() -> bool: return complete func is_skipped() -> bool: return skipped ## (current beat number, total beats); one-based for display, (total, total) ## after completion. func progress() -> Vector2i: return Vector2i(clampi(index + 1, 1, BEATS.size()), BEATS.size())