Wire the first-shift tutorial into gameplay
Full build / Import + headless tests (push) Successful in 38s
Full build / Full desktop build + optional release (push) Skipped

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:
zwitschi committed 2026-11-01 21:05:59 +01:00
1 parent 76eb5e3a79
commit 86332cd401
12 files changed
+392 -15

No files matched your search

+1 -1
View File
@@ -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
+15 -9
View File
@@ -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
View File
@@ -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."
} }
+1 -1
View File
@@ -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.
+3
View File
@@ -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
View File
@@ -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.
+2
View File
@@ -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.
+143
View File
@@ -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()
+8
View File
@@ -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
View File
@@ -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)
+87
View File
@@ -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
+104
View File
@@ -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)