Add the non-blocking prompt card, safe compliant visitor, action-driven advancement through the real wheel/inspection/warning/radio panels, persisted completion/skip, a Settings replay action and localized tutorial strings. Scripted tests/captures bypass the tutorial unless --tutorial is passed. Add a windowed smoke covering the full flow. Headless 237/237, Python 94/94, tutorial smoke passes.
127 lines
3.1 KiB
GDScript
127 lines
3.1 KiB
GDScript
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())
|