Wire the first-shift tutorial into gameplay
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.
This commit is contained in:
1 parent
76eb5e3a79
commit
86332cd401
12 files changed
+392
-15
No files matched your search
@@ -43,7 +43,7 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
- RNG: NPC reactions and event scheduling are seedable for deterministic tests.
|
- RNG: NPC reactions and event scheduling are seedable for deterministic tests.
|
||||||
|
|
||||||
- **Mouse look (v0.7.3):** guard look is handled in `GuardController._input()` (before GUI), not `_unhandled_input`; focus/click-back recaptures the cursor and the HUD is made mouse-transparent recursively. Regression tests cover yaw/pitch and HUD filters.
|
- **Mouse look (v0.7.3):** guard look is handled in `GuardController._input()` (before GUI), not `_unhandled_input`; focus/click-back recaptures the cursor and the HUD is made mouse-transparent recursively. Regression tests cover yaw/pitch and HUD filters.
|
||||||
- **Tutorial foundation (0.8.0 work):** `docs/tutorial.md` is the eight-beat design; `src/game/tutorial.gd` is a pure `RefCounted` state machine with skip/restart and tests. It is not yet wired into `GameManager`; when wiring it, add an explicit QA bypass because captures/smokes would otherwise see prompts.
|
- **Tutorial (0.8.0 work):** `docs/tutorial.md` is the eight-beat design; `src/game/tutorial.gd` is the pure state machine, `TutorialOverlay` is the non-blocking card, and `GameManager` wires it with a safe visitor, suppressed spawns, persisted completion/skip and Settings replay. Scripted test/capture runs bypass it unless `--tutorial` is passed; `smoke_tutorial.gd` covers completion/replay/skip.
|
||||||
- **Documentation ownership (2026-11-01):** `docs/architecture.md` §12 defines the source-of-truth map and consolidation rules. Obsolete/historical plan documents are removed rather than kept; current work belongs in `TODO.md`. README/development-status/Blender AO/Forward+ stale claims were corrected. The v1.0.0 gap register and 0.8/0.9/0.9.5/1.0 roadmap live in `TODO.md`.
|
- **Documentation ownership (2026-11-01):** `docs/architecture.md` §12 defines the source-of-truth map and consolidation rules. Obsolete/historical plan documents are removed rather than kept; current work belongs in `TODO.md`. README/development-status/Blender AO/Forward+ stale claims were corrected. The v1.0.0 gap register and 0.8/0.9/0.9.5/1.0 roadmap live in `TODO.md`.
|
||||||
- **Vision judge (v0.7.2):** `tools/openrouter_judge.py` reads `/api/v1/models`, filters image-input models with `response_format`, and ranks by advertised prompt/image/completion prices. `--model auto` (default) uses the cheapest (`z-ai/glm-5.3-flash` on 2026-11-01) with fallbacks; explicit `--model`/`GIG_LINE_JUDGE_MODEL` wins. Auth errors abort, provider/model errors fall through. Full screen judge 13/13 match, 0 diff; A/B judge ~$0.006.
|
- **Vision judge (v0.7.2):** `tools/openrouter_judge.py` reads `/api/v1/models`, filters image-input models with `response_format`, and ranks by advertised prompt/image/completion prices. `--model auto` (default) uses the cheapest (`z-ai/glm-5.3-flash` on 2026-11-01) with fallbacks; explicit `--model`/`GIG_LINE_JUDGE_MODEL` wins. Auth errors abort, provider/model errors fall through. Full screen judge 13/13 match, 0 diff; A/B judge ~$0.006.
|
||||||
- **Desktop HQ preset (v0.7.1):** `packaging/desktop_hq/override.cfg` switches a
|
- **Desktop HQ preset (v0.7.1):** `packaging/desktop_hq/override.cfg` switches a
|
||||||
|
|||||||
@@ -62,15 +62,21 @@ venues, and see enough authored variety that a second shift does not feel like a
|
|||||||
`src/game/tutorial.gd` owns the eight beats, accepts only the current beat's action,
|
`src/game/tutorial.gd` owns the eight beats, accepts only the current beat's action,
|
||||||
supports skip/restart and has deterministic headless tests. GameManager/UI wiring starts
|
supports skip/restart and has deterministic headless tests. GameManager/UI wiring starts
|
||||||
with the contextual-prompt task.
|
with the contextual-prompt task.
|
||||||
- [ ] Add contextual prompts that advance on the performed action, not on a timer. Prompts must
|
- [x] Add contextual prompts that advance on the performed action, not on a timer. The
|
||||||
not block normal input or repeat after the player has learned the control.
|
non-blocking `TutorialOverlay` card updates from `Tutorial.beat_changed`; it is
|
||||||
- [ ] Stage a safe encounter with one compliant visitor; no emergency, threat or crowd panic in
|
mouse-transparent, skippable with F1 and does not repeat after completion/skip.
|
||||||
the tutorial. Reuse the real inspection/wheel/radio panels rather than a fake UI.
|
- [x] Stage a safe encounter with one compliant visitor; routine/scripted spawns are suppressed
|
||||||
- [ ] Persist tutorial completion in settings and add a "Replay tutorial" entry to the help/settings
|
while the tutorial is active. The tutorial uses the real wheel, inspection, warning/body
|
||||||
screen.
|
camera and radio panels.
|
||||||
- [ ] Add localized tutorial strings and caption-ready prompt hooks.
|
- [x] Persist tutorial completion/skip in `settings.json` and add a "Replay tutorial" action in
|
||||||
- [ ] Add a headless tutorial-state test and a windowed smoke test covering skip, replay and
|
Settings that clears both flags and restarts the Hollywood shift.
|
||||||
completion.
|
- [x] Add localized tutorial strings (`tutorial.*` keys) and caption-ready prompt hooks in the
|
||||||
|
overlay/state table.
|
||||||
|
- [x] Add headless tutorial-state tests and `smoke_tutorial.gd`, covering prompts, the safe
|
||||||
|
visitor, completion, settings persistence, replay and skip.
|
||||||
|
|
||||||
|
Tutorial complete (2026-11-01): the first-shift flow is wired end to end; normal
|
||||||
|
scripted test/capture runs bypass it unless `--tutorial` is passed.
|
||||||
|
|
||||||
### 0.8.2 Campaign and session shell
|
### 0.8.2 Campaign and session shell
|
||||||
|
|
||||||
|
|||||||
+13
-1
@@ -88,5 +88,17 @@
|
|||||||
"review.timeline_button": "Body camera timeline",
|
"review.timeline_button": "Body camera timeline",
|
||||||
"hud.nearby": "%d nearby · Tab or mouse wheel cycles targets",
|
"hud.nearby": "%d nearby · Tab or mouse wheel cycles targets",
|
||||||
"hud.precision": "Precision walk (Shift)",
|
"hud.precision": "Precision walk (Shift)",
|
||||||
"hud.controls_gamepad": "Left stick move · Right stick look · A wheel/confirm · B cancel · X inspect · Y radio · LB slow · RB wand"
|
"hud.controls_gamepad": "Left stick move · Right stick look · A wheel/confirm · B cancel · X inspect · Y radio · LB slow · RB wand",
|
||||||
|
"tutorial.header": "FIRST SHIFT",
|
||||||
|
"tutorial.skip_hint": "F1 skip tutorial",
|
||||||
|
"tutorial.arrive": "Walk forward and look around. The entrance is behind you.",
|
||||||
|
"tutorial.focus": "Look at the visitor. Press Tab or the mouse wheel to cycle targets.",
|
||||||
|
"tutorial.wheel": "Open the action wheel with Space or left click.",
|
||||||
|
"tutorial.inspect": "Choose Inspect and read the access evidence.",
|
||||||
|
"tutorial.direction": "Close the panel and give a polite direction.",
|
||||||
|
"tutorial.warning": "Give a formal warning if the visitor persists. It arms the body camera.",
|
||||||
|
"tutorial.radio": "Open the assistance radio and submit a short report.",
|
||||||
|
"tutorial.review": "Finish the shift and review what happened.",
|
||||||
|
"tutorial.completed": "Tutorial complete.",
|
||||||
|
"tutorial.skipped": "Tutorial skipped."
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ Implemented so far:
|
|||||||
- Visual alignment: start/venue menu, pause, settings, shift review, radio, wand, inspection, timeline, HUD target chip/escalation bar, and radial menu now use or match their AI references in `assets/images/ui/`. The runtime art loader (`src/game/ui/art.gd`) falls back cleanly when a file is missing.
|
- Visual alignment: start/venue menu, pause, settings, shift review, radio, wand, inspection, timeline, HUD target chip/escalation bar, and radial menu now use or match their AI references in `assets/images/ui/`. The runtime art loader (`src/game/ui/art.gd`) falls back cleanly when a file is missing.
|
||||||
- Realism adoption (post-v0.6.0): v2 per-phase skylines and surface tiles with normal maps, retuned sun/exposure/fog, wet asphalt, denser foliage, a visible additive flashlight beam, and refreshed staging captures. `tools/check_screenshots.sh` is the repeatable capture/compare gate; judge is clean of `diff` verdicts.
|
- Realism adoption (post-v0.6.0): v2 per-phase skylines and surface tiles with normal maps, retuned sun/exposure/fog, wet asphalt, denser foliage, a visible additive flashlight beam, and refreshed staging captures. `tools/check_screenshots.sh` is the repeatable capture/compare gate; judge is clean of `diff` verdicts.
|
||||||
- Input/reliability fixes: selecting a venue releases the start-menu input lock and recaptures the mouse; the body-camera overlay no longer covers the shift status. Mouse look now reads at `_input`, restores capture on window focus/click-back, and the HUD is mouse-transparent.
|
- Input/reliability fixes: selecting a venue releases the start-menu input lock and recaptures the mouse; the body-camera overlay no longer covers the shift status. Mouse look now reads at `_input`, restores capture on window focus/click-back, and the HUD is mouse-transparent.
|
||||||
- Tutorial foundation (0.8.0 work): the eight-beat first-shift design and a deterministic, skippable/restartable `Tutorial` state machine exist; the in-game prompt/visitor wiring is the next step.
|
- Tutorial pass (0.8.0 work): the eight-beat first shift is wired through a non-blocking prompt overlay, a safe compliant visitor, real wheel/inspection/warning/radio panels, persisted completion/skip and a Settings replay action. Scripted tests/captures bypass it unless `--tutorial` is passed; `smoke_tutorial.gd` covers the full flow.
|
||||||
- Vision judge (v0.7.2): discovers the cheapest current OpenRouter image+JSON model, reports the model/fallbacks used, and clears the full screen-design judge (13/13 match, 0 diff).
|
- Vision judge (v0.7.2): discovers the cheapest current OpenRouter image+JSON model, reports the model/fallbacks used, and clears the full screen-design judge (13/13 match, 0 diff).
|
||||||
- Desktop HQ (v0.7.1): optional Forward+ Windows/Linux zip with volumetric fog, SDFGI and a volumetric flashlight; the default Compatibility/Web builds and headless tests gate the effect off. A/B captures are generated locally under `docs/screens/realism/` (gitignored).
|
- Desktop HQ (v0.7.1): optional Forward+ Windows/Linux zip with volumetric fog, SDFGI and a volumetric flashlight; the default Compatibility/Web builds and headless tests gate the effect off. A/B captures are generated locally under `docs/screens/realism/` (gitignored).
|
||||||
- Venue geometry (v0.7.0): the Joke Shop now builds as a narrow brick alley with a warm wall lantern, shutters, dumpster/AC clutter and no skyline; the TV Studio builds a stone facade, bulb canopy, glossy black car and barrier line. `WorldBuilder.VENUE_SPECS` drives facade/street/nav dimensions, and `GameManager` rebuilds only the world container when the venue changes.
|
- Venue geometry (v0.7.0): the Joke Shop now builds as a narrow brick alley with a warm wall lantern, shutters, dumpster/AC clutter and no skyline; the TV Studio builds a stone facade, bulb canopy, glossy black car and barrier line. `WorldBuilder.VENUE_SPECS` drives facade/street/nav dimensions, and `GameManager` rebuilds only the world container when the venue changes.
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ godot --headless --path . --script res://src/test/smoke_audio.gd
|
|||||||
|
|
||||||
# Scenario-content smoke: wand refusal, celebrity crowd spike
|
# Scenario-content smoke: wand refusal, celebrity crowd spike
|
||||||
godot --headless --path . --script res://src/test/smoke_scenario_content.gd
|
godot --headless --path . --script res://src/test/smoke_scenario_content.gd
|
||||||
|
|
||||||
|
# Tutorial smoke (windowed): completion, replay and skip; isolated user data
|
||||||
|
XDG_DATA_HOME=/tmp/gigline-tutorial godot --path . --fixed-fps 60 --script res://src/test/smoke_tutorial.gd -- --tutorial
|
||||||
```
|
```
|
||||||
|
|
||||||
The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor.
|
The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor.
|
||||||
|
|||||||
+5
-3
@@ -1,7 +1,9 @@
|
|||||||
# First-shift tutorial
|
# First-shift tutorial
|
||||||
|
|
||||||
Status: planned for v0.8.0. The tutorial teaches the real controls and the real
|
Status: implemented for v0.8.0. The tutorial teaches the real controls and the real
|
||||||
inspection/wheel/radio panels; it is not a fake UI or a separate combat mode.
|
inspection/wheel/radio panels; it is not a fake UI or a separate combat mode.
|
||||||
|
Scripted test/capture runs bypass it unless `--tutorial` is passed; a windowed
|
||||||
|
smoke covers completion, replay and skip.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
@@ -9,8 +11,8 @@ inspection/wheel/radio panels; it is not a fake UI or a separate combat mode.
|
|||||||
event can interrupt the tutorial.
|
event can interrupt the tutorial.
|
||||||
- Prompts advance on the performed action, not on a timer. The player can keep
|
- Prompts advance on the performed action, not on a timer. The player can keep
|
||||||
moving/looking while a prompt is visible.
|
moving/looking while a prompt is visible.
|
||||||
- The tutorial is skippable at any point. Completion and skip are both persisted
|
- The tutorial is skippable at any point (F1). Completion and skip are both
|
||||||
separately; a skipped tutorial can be replayed from Help/Settings.
|
persisted separately; a skipped tutorial can be replayed from Settings.
|
||||||
- The tutorial state machine is independent of the shift FSM. It observes the
|
- The tutorial state machine is independent of the shift FSM. It observes the
|
||||||
same actions as normal play and must be deterministic for tests.
|
same actions as normal play and must be deterministic for tests.
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ const DEFAULT_SETTINGS := {
|
|||||||
"head_bob": true,
|
"head_bob": true,
|
||||||
"input_bindings": {},
|
"input_bindings": {},
|
||||||
"ai_difficulty": "normal",
|
"ai_difficulty": "normal",
|
||||||
|
"tutorial_completed": false,
|
||||||
|
"tutorial_skipped": false,
|
||||||
}
|
}
|
||||||
|
|
||||||
## Load player settings, merged over the defaults. Returns a Dictionary.
|
## Load player settings, merged over the defaults. Returns a Dictionary.
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const StartMenu := preload("res://src/game/ui/start_menu.gd")
|
|||||||
const PauseOverlay := preload("res://src/game/ui/pause_overlay.gd")
|
const PauseOverlay := preload("res://src/game/ui/pause_overlay.gd")
|
||||||
const SettingsPanel := preload("res://src/game/ui/settings_panel.gd")
|
const SettingsPanel := preload("res://src/game/ui/settings_panel.gd")
|
||||||
const EventTimeline := preload("res://src/game/ui/event_timeline.gd")
|
const EventTimeline := preload("res://src/game/ui/event_timeline.gd")
|
||||||
|
const Tutorial := preload("res://src/game/tutorial.gd")
|
||||||
|
const TutorialOverlay := preload("res://src/game/ui/tutorial_overlay.gd")
|
||||||
|
|
||||||
var world_builder: WorldBuilder = null
|
var world_builder: WorldBuilder = null
|
||||||
## Dedicated container for the procedural world, so switching venues can rebuild
|
## Dedicated container for the procedural world, so switching venues can rebuild
|
||||||
@@ -91,6 +93,12 @@ var start_menu: StartMenu = null
|
|||||||
var pause_overlay: PauseOverlay = null
|
var pause_overlay: PauseOverlay = null
|
||||||
var settings_panel: SettingsPanel = null
|
var settings_panel: SettingsPanel = null
|
||||||
var event_timeline: EventTimeline = null
|
var event_timeline: EventTimeline = null
|
||||||
|
var tutorial_overlay: TutorialOverlay = null
|
||||||
|
var tutorial: Tutorial = null
|
||||||
|
var tutorial_active := false
|
||||||
|
var _tutorial_start_pos := Vector3.ZERO
|
||||||
|
var _tutorial_start_yaw := 0.0
|
||||||
|
var _tutorial_start_pitch := 0.0
|
||||||
var _timeline_from_review := false
|
var _timeline_from_review := false
|
||||||
var _queue_tick := 0.0
|
var _queue_tick := 0.0
|
||||||
var _input_device := "keyboard"
|
var _input_device := "keyboard"
|
||||||
@@ -304,6 +312,7 @@ func _start_shift() -> void:
|
|||||||
coordinator.start()
|
coordinator.start()
|
||||||
if hud:
|
if hud:
|
||||||
hud.set_shift_state(coordinator, selected_scenario)
|
hud.set_shift_state(coordinator, selected_scenario)
|
||||||
|
_maybe_start_tutorial()
|
||||||
# Leaving the venue menu clears input_locked and returns the mouse to
|
# Leaving the venue menu clears input_locked and returns the mouse to
|
||||||
# captured mode; without this the guard stays frozen until another modal
|
# captured mode; without this the guard stays frozen until another modal
|
||||||
# opens and closes.
|
# opens and closes.
|
||||||
@@ -351,6 +360,19 @@ func _on_settings_applied(settings: Dictionary) -> void:
|
|||||||
elif _shift_running:
|
elif _shift_running:
|
||||||
_open_pause()
|
_open_pause()
|
||||||
|
|
||||||
|
## Clear the tutorial completion/skip flags and restart the Hollywood shift so
|
||||||
|
## the next player can replay the first-shift lesson from Settings.
|
||||||
|
func _on_replay_tutorial() -> void:
|
||||||
|
player_settings["tutorial_completed"] = false
|
||||||
|
player_settings["tutorial_skipped"] = false
|
||||||
|
if Save:
|
||||||
|
Save.save_settings(player_settings)
|
||||||
|
if settings_panel:
|
||||||
|
settings_panel.close()
|
||||||
|
if pause_overlay:
|
||||||
|
pause_overlay.close()
|
||||||
|
_on_venue_selected("hollywood_building")
|
||||||
|
|
||||||
func _open_settings() -> void:
|
func _open_settings() -> void:
|
||||||
if start_menu:
|
if start_menu:
|
||||||
start_menu.close()
|
start_menu.close()
|
||||||
@@ -420,12 +442,16 @@ func _build_ui() -> void:
|
|||||||
add_child(settings_panel)
|
add_child(settings_panel)
|
||||||
settings_panel.hide()
|
settings_panel.hide()
|
||||||
settings_panel.applied.connect(_on_settings_applied)
|
settings_panel.applied.connect(_on_settings_applied)
|
||||||
|
settings_panel.replay_tutorial_requested.connect(_on_replay_tutorial)
|
||||||
|
|
||||||
event_timeline = EventTimeline.new()
|
event_timeline = EventTimeline.new()
|
||||||
add_child(event_timeline)
|
add_child(event_timeline)
|
||||||
event_timeline.hide()
|
event_timeline.hide()
|
||||||
event_timeline.closed.connect(_on_timeline_closed)
|
event_timeline.closed.connect(_on_timeline_closed)
|
||||||
|
|
||||||
|
tutorial_overlay = TutorialOverlay.new()
|
||||||
|
add_child(tutorial_overlay)
|
||||||
|
|
||||||
start_menu = StartMenu.new()
|
start_menu = StartMenu.new()
|
||||||
add_child(start_menu)
|
add_child(start_menu)
|
||||||
start_menu.hide()
|
start_menu.hide()
|
||||||
@@ -528,7 +554,108 @@ func _apply_phase_lighting() -> void:
|
|||||||
# night lighting, so the backdrop always matches the phase.
|
# night lighting, so the backdrop always matches the phase.
|
||||||
world_builder.set_phase(phase)
|
world_builder.set_phase(phase)
|
||||||
|
|
||||||
|
## Start the first-shift tutorial only for a real player run: scripted tests,
|
||||||
|
## captures and smokes pass --script and are explicitly exempt. A tutorial smoke
|
||||||
|
## can opt back in with a user `--tutorial` argument.
|
||||||
|
func _maybe_start_tutorial() -> void:
|
||||||
|
if tutorial_active or active_scenario_id != "hollywood_building":
|
||||||
|
return
|
||||||
|
if bool(player_settings.get("tutorial_completed", false)) \
|
||||||
|
or bool(player_settings.get("tutorial_skipped", false)):
|
||||||
|
return
|
||||||
|
var scripted := false
|
||||||
|
var explicit := false
|
||||||
|
for arg in OS.get_cmdline_args():
|
||||||
|
scripted = scripted or str(arg) == "--script"
|
||||||
|
for arg in OS.get_cmdline_user_args():
|
||||||
|
explicit = explicit or str(arg) == "--tutorial"
|
||||||
|
if scripted and not explicit:
|
||||||
|
return
|
||||||
|
tutorial_active = true
|
||||||
|
tutorial = Tutorial.new()
|
||||||
|
tutorial.beat_changed.connect(_on_tutorial_beat)
|
||||||
|
tutorial.completed.connect(_on_tutorial_completed)
|
||||||
|
_tutorial_start_pos = guard.global_position if guard else Vector3.ZERO
|
||||||
|
_tutorial_start_yaw = guard.rotation.y if guard else 0.0
|
||||||
|
_tutorial_start_pitch = guard.head.rotation.x if guard else 0.0
|
||||||
|
_spawn_tutorial_visitor()
|
||||||
|
tutorial.start()
|
||||||
|
if tutorial_overlay:
|
||||||
|
tutorial_overlay.show_beat(_tutorial_prompt(tutorial.current()), tutorial.progress())
|
||||||
|
|
||||||
|
func _spawn_tutorial_visitor() -> void:
|
||||||
|
var p := NpcProfile.new()
|
||||||
|
p.id = "tutorial_visitor"
|
||||||
|
p.display_name = "Visitor"
|
||||||
|
p.npc_type = Rules.NpcType.AUTHORIZED_GUEST
|
||||||
|
p.temperament = 0.0
|
||||||
|
p.base_compliance = 1.0
|
||||||
|
p.reaction_delay = 0.15
|
||||||
|
p.dialogue_lines = ["I have a wristband for the event."]
|
||||||
|
_spawn_npc(p, "enter", "tutorial_visitor", {
|
||||||
|
"kind": "scripted",
|
||||||
|
"tutorial": true,
|
||||||
|
"override_access": {"has_wristband": true},
|
||||||
|
})
|
||||||
|
# Keep the one tutorial subject near the steps so focus/cycling is immediate.
|
||||||
|
if not npcs.is_empty():
|
||||||
|
var visitor: Node3D = npcs[npcs.size() - 1]
|
||||||
|
visitor.global_position = Vector3(2.0, 0.5, 6.0)
|
||||||
|
visitor.target_position = selected_scenario._location_or_default(
|
||||||
|
"entrance", Vector3(0.0, 0.5, 1.2))
|
||||||
|
|
||||||
|
func _tutorial_prompt(beat: Dictionary) -> String:
|
||||||
|
var key := str(beat.get("prompt_key", ""))
|
||||||
|
return Strings.lookup(key) if key != "" else str(beat.get("prompt", ""))
|
||||||
|
|
||||||
|
func _on_tutorial_beat(_index: int, beat: Dictionary) -> void:
|
||||||
|
if tutorial_overlay:
|
||||||
|
tutorial_overlay.show_beat(_tutorial_prompt(beat), tutorial.progress())
|
||||||
|
|
||||||
|
func _tutorial_advance(event: String) -> void:
|
||||||
|
if not tutorial_active or tutorial == null:
|
||||||
|
return
|
||||||
|
tutorial.advance(event)
|
||||||
|
|
||||||
|
func _on_tutorial_completed(skipped: bool) -> void:
|
||||||
|
if tutorial == null:
|
||||||
|
return
|
||||||
|
tutorial_active = false
|
||||||
|
player_settings["tutorial_completed"] = not skipped
|
||||||
|
player_settings["tutorial_skipped"] = skipped
|
||||||
|
if Save:
|
||||||
|
Save.save_settings(player_settings)
|
||||||
|
if tutorial_overlay:
|
||||||
|
tutorial_overlay.close()
|
||||||
|
if hud:
|
||||||
|
hud.log(Strings.lookup("tutorial.skipped" if skipped else "tutorial.completed"))
|
||||||
|
if not skipped:
|
||||||
|
# Let the visitor leave cleanly after the final lesson.
|
||||||
|
for avatar in npcs:
|
||||||
|
if is_instance_valid(avatar):
|
||||||
|
var controller: Object = avatar.get_npc_controller()
|
||||||
|
if controller and controller.npc_id == "tutorial_visitor":
|
||||||
|
controller.leave_requested = true
|
||||||
|
break
|
||||||
|
|
||||||
|
func _skip_tutorial() -> void:
|
||||||
|
if tutorial_active and tutorial:
|
||||||
|
tutorial.skip()
|
||||||
|
|
||||||
|
func _tutorial_tick() -> void:
|
||||||
|
if not tutorial_active or tutorial == null or guard == null:
|
||||||
|
return
|
||||||
|
if tutorial.current().get("id", "") != "arrive":
|
||||||
|
return
|
||||||
|
var moved := guard.global_position.distance_to(_tutorial_start_pos) > 0.75
|
||||||
|
var yaw := absf(wrapf(guard.rotation.y - _tutorial_start_yaw, -PI, PI))
|
||||||
|
var pitch := absf(guard.head.rotation.x - _tutorial_start_pitch)
|
||||||
|
if moved and (yaw > 0.05 or pitch > 0.05):
|
||||||
|
_tutorial_advance("moved")
|
||||||
|
|
||||||
func _spawn_npc(profile: NpcProfile, intent: String, npc_id: String, event: Dictionary = {}) -> void:
|
func _spawn_npc(profile: NpcProfile, intent: String, npc_id: String, event: Dictionary = {}) -> void:
|
||||||
|
if tutorial_active and not bool(event.get("tutorial", false)):
|
||||||
|
return
|
||||||
_purge_npcs()
|
_purge_npcs()
|
||||||
if npcs.size() >= MAX_LIVE_NPCS and event.get("kind", "routine") == "routine":
|
if npcs.size() >= MAX_LIVE_NPCS and event.get("kind", "routine") == "routine":
|
||||||
return
|
return
|
||||||
@@ -713,6 +840,7 @@ func _on_shift_ended(review: Dictionary) -> void:
|
|||||||
if Save:
|
if Save:
|
||||||
Save.save_shift_result(review)
|
Save.save_shift_result(review)
|
||||||
shift_review_panel.show_review(review, selected_scenario, Save.load_shift_results() if Save else [])
|
shift_review_panel.show_review(review, selected_scenario, Save.load_shift_results() if Save else [])
|
||||||
|
_tutorial_advance("review_opened")
|
||||||
_refresh_freeze()
|
_refresh_freeze()
|
||||||
|
|
||||||
## Keep the people waiting at the door in a deterministic queue: nearest first,
|
## Keep the people waiting at the door in a deterministic queue: nearest first,
|
||||||
@@ -877,6 +1005,9 @@ func _modal_blocks_radial() -> bool:
|
|||||||
|
|
||||||
func _handle_key(event: InputEvent) -> void:
|
func _handle_key(event: InputEvent) -> void:
|
||||||
var keycode: Key = event.keycode
|
var keycode: Key = event.keycode
|
||||||
|
if tutorial_active and keycode == KEY_F1:
|
||||||
|
_skip_tutorial()
|
||||||
|
return
|
||||||
if radial and radial.is_open:
|
if radial and radial.is_open:
|
||||||
if event.is_action_pressed("confirm"):
|
if event.is_action_pressed("confirm"):
|
||||||
_confirm_radial()
|
_confirm_radial()
|
||||||
@@ -1013,6 +1144,7 @@ func _open_radial() -> void:
|
|||||||
p_name = str(controller.profile.display_name)
|
p_name = str(controller.profile.display_name)
|
||||||
var wand_ok := selected_scenario != null and selected_scenario.equipment.has("wand")
|
var wand_ok := selected_scenario != null and selected_scenario.equipment.has("wand")
|
||||||
radial.open_menu(p_name, wand_ok)
|
radial.open_menu(p_name, wand_ok)
|
||||||
|
_tutorial_advance("wheel_opened")
|
||||||
if targeted_npc and targeted_npc.has_method("set_focus_visual"):
|
if targeted_npc and targeted_npc.has_method("set_focus_visual"):
|
||||||
targeted_npc.set_focus_visual(true, true)
|
targeted_npc.set_focus_visual(true, true)
|
||||||
_refresh_freeze()
|
_refresh_freeze()
|
||||||
@@ -1110,6 +1242,8 @@ func _set_body_camera(on: bool, reason: String) -> void:
|
|||||||
if body_camera_on == on:
|
if body_camera_on == on:
|
||||||
return
|
return
|
||||||
body_camera_on = on
|
body_camera_on = on
|
||||||
|
if on:
|
||||||
|
_tutorial_advance("body_camera")
|
||||||
if hud:
|
if hud:
|
||||||
hud.set_body_camera(on)
|
hud.set_body_camera(on)
|
||||||
var controller: Object = _targeted_controller()
|
var controller: Object = _targeted_controller()
|
||||||
@@ -1150,6 +1284,8 @@ func _cycle_nearby_target(direction: int = 1) -> void:
|
|||||||
if guard == null:
|
if guard == null:
|
||||||
return
|
return
|
||||||
var next_target: Node3D = guard.cycle_target(direction)
|
var next_target: Node3D = guard.cycle_target(direction)
|
||||||
|
if next_target != null:
|
||||||
|
_tutorial_advance("cycled")
|
||||||
if next_target == null:
|
if next_target == null:
|
||||||
hud.log(Strings.lookup("hud.no_one_nearby"))
|
hud.log(Strings.lookup("hud.no_one_nearby"))
|
||||||
_refresh_nearby_hint()
|
_refresh_nearby_hint()
|
||||||
@@ -1201,6 +1337,10 @@ func _apply_interaction(level: int, action_name: String) -> void:
|
|||||||
# light goes on: it creates a record of the interaction (PLAN Level 2).
|
# light goes on: it creates a record of the interaction (PLAN Level 2).
|
||||||
if level == Rules.Level.FORMAL_WARNING and not body_camera_on:
|
if level == Rules.Level.FORMAL_WARNING and not body_camera_on:
|
||||||
_set_body_camera(true, "formal warning")
|
_set_body_camera(true, "formal warning")
|
||||||
|
if level == Rules.Level.SIMPLE_DETERRENCE and action_name == "polite direction":
|
||||||
|
_tutorial_advance("polite_direction")
|
||||||
|
if level == Rules.Level.FORMAL_WARNING:
|
||||||
|
_tutorial_advance("formal_warning")
|
||||||
# Blocking also means physically standing between the person and the door
|
# Blocking also means physically standing between the person and the door
|
||||||
# (PLAN Level 3), not just sending the escalation level.
|
# (PLAN Level 3), not just sending the escalation level.
|
||||||
if level == Rules.Level.BLOCKING:
|
if level == Rules.Level.BLOCKING:
|
||||||
@@ -1344,6 +1484,7 @@ func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
|
|||||||
if not result.get("ok", false):
|
if not result.get("ok", false):
|
||||||
hud.log(Strings.fmt("radio.blocked", [result.get("reason", "unknown")]))
|
hud.log(Strings.fmt("radio.blocked", [result.get("reason", "unknown")]))
|
||||||
return
|
return
|
||||||
|
_tutorial_advance("radio_submitted")
|
||||||
var hits: int = coordinator.radio.credibility_hits(report, current_npc_truth)
|
var hits: int = coordinator.radio.credibility_hits(report, current_npc_truth)
|
||||||
if hits > 0:
|
if hits > 0:
|
||||||
coordinator.reputation.apply("false_report", -4.0 * hits, "inaccurate radio report")
|
coordinator.reputation.apply("false_report", -4.0 * hits, "inaccurate radio report")
|
||||||
@@ -1401,6 +1542,7 @@ func _toggle_inspection() -> void:
|
|||||||
var artifacts := _artifacts_for(controller)
|
var artifacts := _artifacts_for(controller)
|
||||||
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy, controller.screened)
|
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy, controller.screened)
|
||||||
hud.show_inspection(controller, artifacts, result)
|
hud.show_inspection(controller, artifacts, result)
|
||||||
|
_tutorial_advance("inspected")
|
||||||
_refresh_freeze()
|
_refresh_freeze()
|
||||||
# Venue door-staff flavor: an applicable check line (PLAN.md: door-staff dialogue).
|
# Venue door-staff flavor: an applicable check line (PLAN.md: door-staff dialogue).
|
||||||
var line := _door_context_line()
|
var line := _door_context_line()
|
||||||
@@ -1611,6 +1753,7 @@ func _physics_process(delta: float) -> void:
|
|||||||
if coordinator == null or not coordinator.running:
|
if coordinator == null or not coordinator.running:
|
||||||
return
|
return
|
||||||
coordinator.tick(delta)
|
coordinator.tick(delta)
|
||||||
|
_tutorial_tick()
|
||||||
if coordinator.current_phase != _last_phase:
|
if coordinator.current_phase != _last_phase:
|
||||||
_last_phase = coordinator.current_phase
|
_last_phase = coordinator.current_phase
|
||||||
_apply_phase_lighting()
|
_apply_phase_lighting()
|
||||||
|
|||||||
@@ -11,42 +11,50 @@ signal completed(skipped: bool)
|
|||||||
const BEATS := [
|
const BEATS := [
|
||||||
{
|
{
|
||||||
"id": "arrive",
|
"id": "arrive",
|
||||||
|
"prompt_key": "tutorial.arrive",
|
||||||
"prompt": "Walk forward and look around. The entrance is behind you.",
|
"prompt": "Walk forward and look around. The entrance is behind you.",
|
||||||
"event": "moved",
|
"event": "moved",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "focus",
|
"id": "focus",
|
||||||
|
"prompt_key": "tutorial.focus",
|
||||||
"prompt": "Look at the visitor. Press Tab or the mouse wheel to cycle targets.",
|
"prompt": "Look at the visitor. Press Tab or the mouse wheel to cycle targets.",
|
||||||
"event": "cycled",
|
"event": "cycled",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "wheel",
|
"id": "wheel",
|
||||||
|
"prompt_key": "tutorial.wheel",
|
||||||
"prompt": "Open the action wheel with Space or left click.",
|
"prompt": "Open the action wheel with Space or left click.",
|
||||||
"event": "wheel_opened",
|
"event": "wheel_opened",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "inspect",
|
"id": "inspect",
|
||||||
|
"prompt_key": "tutorial.inspect",
|
||||||
"prompt": "Choose Inspect and read the access evidence.",
|
"prompt": "Choose Inspect and read the access evidence.",
|
||||||
"event": "inspected",
|
"event": "inspected",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "direction",
|
"id": "direction",
|
||||||
|
"prompt_key": "tutorial.direction",
|
||||||
"prompt": "Close the panel and give a polite direction.",
|
"prompt": "Close the panel and give a polite direction.",
|
||||||
"event": "polite_direction",
|
"event": "polite_direction",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "warning",
|
"id": "warning",
|
||||||
|
"prompt_key": "tutorial.warning",
|
||||||
"prompt": "Give a formal warning if the visitor persists. It arms the body camera.",
|
"prompt": "Give a formal warning if the visitor persists. It arms the body camera.",
|
||||||
"event": "formal_warning",
|
"event": "formal_warning",
|
||||||
"alternate_event": "body_camera",
|
"alternate_event": "body_camera",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "radio",
|
"id": "radio",
|
||||||
|
"prompt_key": "tutorial.radio",
|
||||||
"prompt": "Open the assistance radio and submit a short report.",
|
"prompt": "Open the assistance radio and submit a short report.",
|
||||||
"event": "radio_submitted",
|
"event": "radio_submitted",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "review",
|
"id": "review",
|
||||||
|
"prompt_key": "tutorial.review",
|
||||||
"prompt": "Finish the shift and review what happened.",
|
"prompt": "Finish the shift and review what happened.",
|
||||||
"event": "review_opened",
|
"event": "review_opened",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const InputBindings := preload("res://src/core/input_bindings.gd")
|
|||||||
const UiArt := preload("res://src/game/ui/art.gd")
|
const UiArt := preload("res://src/game/ui/art.gd")
|
||||||
|
|
||||||
signal applied(settings: Dictionary)
|
signal applied(settings: Dictionary)
|
||||||
|
signal replay_tutorial_requested()
|
||||||
signal closed()
|
signal closed()
|
||||||
|
|
||||||
var _root: Control = null
|
var _root: Control = null
|
||||||
@@ -86,6 +87,15 @@ func _build() -> void:
|
|||||||
_head_bob.toggled.connect(_on_head_bob_toggled)
|
_head_bob.toggled.connect(_on_head_bob_toggled)
|
||||||
column.add_child(_head_bob)
|
column.add_child(_head_bob)
|
||||||
|
|
||||||
|
var replay_row := HBoxContainer.new()
|
||||||
|
replay_row.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||||
|
column.add_child(replay_row)
|
||||||
|
var replay := Button.new()
|
||||||
|
replay.name = "ReplayTutorialButton"
|
||||||
|
replay.text = "Replay tutorial"
|
||||||
|
replay.pressed.connect(func(): replay_tutorial_requested.emit())
|
||||||
|
replay_row.add_child(replay)
|
||||||
|
|
||||||
var controls_title := Label.new()
|
var controls_title := Label.new()
|
||||||
controls_title.text = "Controls (click a binding, then press a key or pad button)"
|
controls_title.text = "Controls (click a binding, then press a key or pad button)"
|
||||||
controls_title.add_theme_font_size_override("font_size", 13)
|
controls_title.add_theme_font_size_override("font_size", 13)
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
class_name TutorialOverlay
|
||||||
|
extends CanvasLayer
|
||||||
|
|
||||||
|
const Strings := preload("res://src/core/strings.gd")
|
||||||
|
## Non-blocking first-shift tutorial card. It never takes mouse input; F1 is
|
||||||
|
## handled by GameManager so the prompt can stay visible while the player moves.
|
||||||
|
|
||||||
|
var _panel: PanelContainer = null
|
||||||
|
var _prompt: Label = null
|
||||||
|
var _progress: Label = null
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
layer = 14
|
||||||
|
_build()
|
||||||
|
hide()
|
||||||
|
|
||||||
|
func _build() -> void:
|
||||||
|
var root := Control.new()
|
||||||
|
root.name = "TutorialRoot"
|
||||||
|
root.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
root.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
add_child(root)
|
||||||
|
|
||||||
|
_panel = PanelContainer.new()
|
||||||
|
_panel.name = "TutorialCard"
|
||||||
|
_panel.set_anchor(SIDE_LEFT, 0.5)
|
||||||
|
_panel.set_anchor(SIDE_RIGHT, 0.5)
|
||||||
|
_panel.set_anchor(SIDE_TOP, 0.08)
|
||||||
|
_panel.set_anchor(SIDE_BOTTOM, 0.08)
|
||||||
|
_panel.offset_left = -330
|
||||||
|
_panel.offset_right = 330
|
||||||
|
_panel.offset_top = 0
|
||||||
|
_panel.offset_bottom = 112
|
||||||
|
_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
var style := StyleBoxFlat.new()
|
||||||
|
style.bg_color = Color(0.035, 0.045, 0.06, 0.92)
|
||||||
|
style.border_color = Color(0.62, 0.69, 0.8, 0.55)
|
||||||
|
style.border_width_left = 1
|
||||||
|
style.border_width_top = 1
|
||||||
|
style.border_width_right = 1
|
||||||
|
style.border_width_bottom = 1
|
||||||
|
style.corner_radius_top_left = 10
|
||||||
|
style.corner_radius_top_right = 10
|
||||||
|
style.corner_radius_bottom_left = 10
|
||||||
|
style.corner_radius_bottom_right = 10
|
||||||
|
style.content_margin_left = 18
|
||||||
|
style.content_margin_right = 18
|
||||||
|
style.content_margin_top = 10
|
||||||
|
style.content_margin_bottom = 10
|
||||||
|
_panel.add_theme_stylebox_override("panel", style)
|
||||||
|
root.add_child(_panel)
|
||||||
|
|
||||||
|
var column := VBoxContainer.new()
|
||||||
|
column.add_theme_constant_override("separation", 3)
|
||||||
|
_panel.add_child(column)
|
||||||
|
|
||||||
|
var header := HBoxContainer.new()
|
||||||
|
column.add_child(header)
|
||||||
|
var title := Label.new()
|
||||||
|
title.text = Strings.lookup("tutorial.header")
|
||||||
|
title.add_theme_font_size_override("font_size", 12)
|
||||||
|
title.modulate = Color(0.6, 0.8, 1.0)
|
||||||
|
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
header.add_child(title)
|
||||||
|
_progress = Label.new()
|
||||||
|
_progress.add_theme_font_size_override("font_size", 12)
|
||||||
|
_progress.modulate = Color(0.65, 0.7, 0.78)
|
||||||
|
header.add_child(_progress)
|
||||||
|
|
||||||
|
_prompt = Label.new()
|
||||||
|
_prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
_prompt.add_theme_font_size_override("font_size", 15)
|
||||||
|
column.add_child(_prompt)
|
||||||
|
|
||||||
|
var hint := Label.new()
|
||||||
|
hint.text = Strings.lookup("tutorial.skip_hint")
|
||||||
|
hint.add_theme_font_size_override("font_size", 11)
|
||||||
|
hint.modulate = Color(0.55, 0.6, 0.68)
|
||||||
|
column.add_child(hint)
|
||||||
|
|
||||||
|
func show_beat(text: String, progress: Vector2i) -> void:
|
||||||
|
_prompt.text = text
|
||||||
|
_progress.text = "%d/%d" % [progress.x, progress.y]
|
||||||
|
visible = true
|
||||||
|
|
||||||
|
func close() -> void:
|
||||||
|
visible = false
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Windowed tutorial smoke: start the Hollywood shift with the tutorial enabled,
|
||||||
|
## complete all eight beats through the real action methods, verify persistence,
|
||||||
|
## then replay and skip it. Use an isolated user-data dir:
|
||||||
|
## XDG_DATA_HOME=/tmp/gigline-tutorial godot --path . --script res://src/test/smoke_tutorial.gd -- --tutorial
|
||||||
|
|
||||||
|
const Rules := preload("res://src/core/rules.gd")
|
||||||
|
const MAX_FRAMES := 900
|
||||||
|
var gm: Node = null
|
||||||
|
var frames := 0
|
||||||
|
var started := false
|
||||||
|
var failures: Array[String] = []
|
||||||
|
var step := 0
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
root.size = Vector2i(960, 540)
|
||||||
|
root.add_child(load("res://scenes/main.tscn").instantiate())
|
||||||
|
|
||||||
|
func _process(_delta: float) -> bool:
|
||||||
|
frames += 1
|
||||||
|
if gm == null:
|
||||||
|
gm = root.find_child("GameManager", true, false)
|
||||||
|
return false
|
||||||
|
if frames > MAX_FRAMES:
|
||||||
|
_fail("timeout")
|
||||||
|
return true
|
||||||
|
if not started:
|
||||||
|
# Ignore any saved completion from a previous local run.
|
||||||
|
gm.player_settings["tutorial_completed"] = false
|
||||||
|
gm.player_settings["tutorial_skipped"] = false
|
||||||
|
gm.call("_on_venue_selected", "hollywood_building")
|
||||||
|
started = true
|
||||||
|
return false
|
||||||
|
step += 1
|
||||||
|
match step:
|
||||||
|
5:
|
||||||
|
_check(gm.tutorial_active, "tutorial starts")
|
||||||
|
_check(gm.tutorial != null and gm.tutorial.current()["id"] == "arrive",
|
||||||
|
"starts at the arrival beat")
|
||||||
|
_check(gm.tutorial_overlay != null and gm.tutorial_overlay.visible,
|
||||||
|
"prompt card is visible")
|
||||||
|
_check(gm.npcs.size() == 1, "only the safe visitor is present")
|
||||||
|
gm.guard.global_position += Vector3(0, 0, 1.0)
|
||||||
|
gm.guard.rotation.y += 0.2
|
||||||
|
gm.call("_tutorial_tick")
|
||||||
|
_check(gm.tutorial.current()["id"] == "focus", "movement/look advances")
|
||||||
|
10:
|
||||||
|
gm.call("_cycle_nearby_target", 1)
|
||||||
|
_check(gm.tutorial.current()["id"] == "wheel", "target cycling advances")
|
||||||
|
gm.call("_open_radial")
|
||||||
|
_check(gm.tutorial.current()["id"] == "inspect", "wheel opens")
|
||||||
|
gm.call("_cancel_radial")
|
||||||
|
15:
|
||||||
|
gm.call("_toggle_inspection")
|
||||||
|
_check(gm.tutorial.current()["id"] == "direction", "inspection advances")
|
||||||
|
gm.call("_toggle_inspection")
|
||||||
|
gm.call("_apply_interaction", Rules.Level.SIMPLE_DETERRENCE, "polite direction")
|
||||||
|
_check(gm.tutorial.current()["id"] == "warning", "polite direction advances")
|
||||||
|
20:
|
||||||
|
gm.call("_apply_interaction", Rules.Level.FORMAL_WARNING, "formal warning")
|
||||||
|
_check(gm.tutorial.current()["id"] == "radio", "warning/body camera advances")
|
||||||
|
gm.call("_on_radio_submitted", {
|
||||||
|
"npc_id": "tutorial_visitor",
|
||||||
|
"location": "entrance",
|
||||||
|
"description": "Visitor at the door",
|
||||||
|
"behavior": "calm",
|
||||||
|
"weapons_seen": false,
|
||||||
|
"danger_level": 0,
|
||||||
|
}, false)
|
||||||
|
_check(gm.tutorial.current()["id"] == "review", "radio advances")
|
||||||
|
25:
|
||||||
|
gm.call("_on_shift_ended", gm.coordinator.build_review())
|
||||||
|
_check(gm.tutorial != null and gm.tutorial.is_complete(), "review completes tutorial")
|
||||||
|
_check(not gm.tutorial_active, "tutorial deactivates")
|
||||||
|
_check(bool(gm.player_settings.get("tutorial_completed", false)),
|
||||||
|
"completion is persisted in settings")
|
||||||
|
gm.call("_on_replay_tutorial")
|
||||||
|
30:
|
||||||
|
_check(gm.tutorial_active and gm.tutorial.current()["id"] == "arrive",
|
||||||
|
"replay starts a fresh tutorial")
|
||||||
|
gm.call("_skip_tutorial")
|
||||||
|
_check(bool(gm.player_settings.get("tutorial_skipped", false)),
|
||||||
|
"skip is persisted separately")
|
||||||
|
_finish()
|
||||||
|
return false
|
||||||
|
|
||||||
|
func _check(condition: bool, message: String) -> void:
|
||||||
|
if condition:
|
||||||
|
print("ok: " + message)
|
||||||
|
else:
|
||||||
|
_fail(message)
|
||||||
|
|
||||||
|
func _fail(message: String) -> void:
|
||||||
|
failures.append(message)
|
||||||
|
_finish()
|
||||||
|
|
||||||
|
func _finish() -> void:
|
||||||
|
if failures.is_empty():
|
||||||
|
print("SMOKE OK: tutorial prompts, visitor, completion, replay and skip")
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
for message in failures:
|
||||||
|
printerr("SMOKE FAIL: " + message)
|
||||||
|
quit(1)
|
||||||
Reference in new issue
Block a user