From 86332cd401d35636f39495019dbb7715920aa6bf Mon Sep 17 00:00:00 2001 From: Zwitschi Date: Sun, 1 Nov 2026 21:05:59 +0100 Subject: [PATCH] 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. --- MEMORY.md | 2 +- TODO.md | 24 ++++-- content/strings.en.json | 14 +++- docs/development-status.md | 2 +- docs/getting-started.md | 3 + docs/tutorial.md | 8 +- src/autoload/save.gd | 2 + src/game/game_manager.gd | 143 ++++++++++++++++++++++++++++++++ src/game/tutorial.gd | 8 ++ src/game/ui/settings_panel.gd | 10 +++ src/game/ui/tutorial_overlay.gd | 87 +++++++++++++++++++ src/test/smoke_tutorial.gd | 104 +++++++++++++++++++++++ 12 files changed, 392 insertions(+), 15 deletions(-) create mode 100644 src/game/ui/tutorial_overlay.gd create mode 100644 src/test/smoke_tutorial.gd diff --git a/MEMORY.md b/MEMORY.md index ecf1898..c242dc8 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -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. - **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`. - **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 diff --git a/TODO.md b/TODO.md index b27f299..b94c32b 100644 --- a/TODO.md +++ b/TODO.md @@ -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, supports skip/restart and has deterministic headless tests. GameManager/UI wiring starts with the contextual-prompt task. -- [ ] Add contextual prompts that advance on the performed action, not on a timer. Prompts must - not block normal input or repeat after the player has learned the control. -- [ ] Stage a safe encounter with one compliant visitor; no emergency, threat or crowd panic in - the tutorial. Reuse the real inspection/wheel/radio panels rather than a fake UI. -- [ ] Persist tutorial completion in settings and add a "Replay tutorial" entry to the help/settings - screen. -- [ ] Add localized tutorial strings and caption-ready prompt hooks. -- [ ] Add a headless tutorial-state test and a windowed smoke test covering skip, replay and - completion. +- [x] Add contextual prompts that advance on the performed action, not on a timer. The + non-blocking `TutorialOverlay` card updates from `Tutorial.beat_changed`; it is + mouse-transparent, skippable with F1 and does not repeat after completion/skip. +- [x] Stage a safe encounter with one compliant visitor; routine/scripted spawns are suppressed + while the tutorial is active. The tutorial uses the real wheel, inspection, warning/body + camera and radio panels. +- [x] Persist tutorial completion/skip in `settings.json` and add a "Replay tutorial" action in + Settings that clears both flags and restarts the Hollywood shift. +- [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 diff --git a/content/strings.en.json b/content/strings.en.json index a8b47d8..f288e60 100644 --- a/content/strings.en.json +++ b/content/strings.en.json @@ -88,5 +88,17 @@ "review.timeline_button": "Body camera timeline", "hud.nearby": "%d nearby · Tab or mouse wheel cycles targets", "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." } diff --git a/docs/development-status.md b/docs/development-status.md index 58a5c71..6839ea7 100644 --- a/docs/development-status.md +++ b/docs/development-status.md @@ -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. - 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. -- 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). - 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. diff --git a/docs/getting-started.md b/docs/getting-started.md index 7f34be0..3ed770b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -58,6 +58,9 @@ godot --headless --path . --script res://src/test/smoke_audio.gd # Scenario-content smoke: wand refusal, celebrity crowd spike 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. diff --git a/docs/tutorial.md b/docs/tutorial.md index 2ef1515..b624951 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -1,7 +1,9 @@ # 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. +Scripted test/capture runs bypass it unless `--tutorial` is passed; a windowed +smoke covers completion, replay and skip. ## 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. - Prompts advance on the performed action, not on a timer. The player can keep moving/looking while a prompt is visible. -- The tutorial is skippable at any point. Completion and skip are both persisted - separately; a skipped tutorial can be replayed from Help/Settings. +- The tutorial is skippable at any point (F1). Completion and skip are both + persisted separately; a skipped tutorial can be replayed from Settings. - The tutorial state machine is independent of the shift FSM. It observes the same actions as normal play and must be deterministic for tests. diff --git a/src/autoload/save.gd b/src/autoload/save.gd index acaf433..c6d5603 100644 --- a/src/autoload/save.gd +++ b/src/autoload/save.gd @@ -47,6 +47,8 @@ const DEFAULT_SETTINGS := { "head_bob": true, "input_bindings": {}, "ai_difficulty": "normal", + "tutorial_completed": false, + "tutorial_skipped": false, } ## Load player settings, merged over the defaults. Returns a Dictionary. diff --git a/src/game/game_manager.gd b/src/game/game_manager.gd index d85087d..478b5f0 100644 --- a/src/game/game_manager.gd +++ b/src/game/game_manager.gd @@ -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 SettingsPanel := preload("res://src/game/ui/settings_panel.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 ## 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 settings_panel: SettingsPanel = 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 _queue_tick := 0.0 var _input_device := "keyboard" @@ -304,6 +312,7 @@ func _start_shift() -> void: coordinator.start() if hud: hud.set_shift_state(coordinator, selected_scenario) + _maybe_start_tutorial() # Leaving the venue menu clears input_locked and returns the mouse to # captured mode; without this the guard stays frozen until another modal # opens and closes. @@ -351,6 +360,19 @@ func _on_settings_applied(settings: Dictionary) -> void: elif _shift_running: _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: if start_menu: start_menu.close() @@ -420,12 +442,16 @@ func _build_ui() -> void: add_child(settings_panel) settings_panel.hide() settings_panel.applied.connect(_on_settings_applied) + settings_panel.replay_tutorial_requested.connect(_on_replay_tutorial) event_timeline = EventTimeline.new() add_child(event_timeline) event_timeline.hide() event_timeline.closed.connect(_on_timeline_closed) + tutorial_overlay = TutorialOverlay.new() + add_child(tutorial_overlay) + start_menu = StartMenu.new() add_child(start_menu) start_menu.hide() @@ -528,7 +554,108 @@ func _apply_phase_lighting() -> void: # night lighting, so the backdrop always matches the 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: + if tutorial_active and not bool(event.get("tutorial", false)): + return _purge_npcs() if npcs.size() >= MAX_LIVE_NPCS and event.get("kind", "routine") == "routine": return @@ -713,6 +840,7 @@ func _on_shift_ended(review: Dictionary) -> void: if Save: Save.save_shift_result(review) shift_review_panel.show_review(review, selected_scenario, Save.load_shift_results() if Save else []) + _tutorial_advance("review_opened") _refresh_freeze() ## 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: var keycode: Key = event.keycode + if tutorial_active and keycode == KEY_F1: + _skip_tutorial() + return if radial and radial.is_open: if event.is_action_pressed("confirm"): _confirm_radial() @@ -1013,6 +1144,7 @@ func _open_radial() -> void: p_name = str(controller.profile.display_name) var wand_ok := selected_scenario != null and selected_scenario.equipment.has("wand") radial.open_menu(p_name, wand_ok) + _tutorial_advance("wheel_opened") if targeted_npc and targeted_npc.has_method("set_focus_visual"): targeted_npc.set_focus_visual(true, true) _refresh_freeze() @@ -1110,6 +1242,8 @@ func _set_body_camera(on: bool, reason: String) -> void: if body_camera_on == on: return body_camera_on = on + if on: + _tutorial_advance("body_camera") if hud: hud.set_body_camera(on) var controller: Object = _targeted_controller() @@ -1150,6 +1284,8 @@ func _cycle_nearby_target(direction: int = 1) -> void: if guard == null: return var next_target: Node3D = guard.cycle_target(direction) + if next_target != null: + _tutorial_advance("cycled") if next_target == null: hud.log(Strings.lookup("hud.no_one_nearby")) _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). if level == Rules.Level.FORMAL_WARNING and not body_camera_on: _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 # (PLAN Level 3), not just sending the escalation level. if level == Rules.Level.BLOCKING: @@ -1344,6 +1484,7 @@ func _on_radio_submitted(report: Dictionary, emergency: bool) -> void: if not result.get("ok", false): hud.log(Strings.fmt("radio.blocked", [result.get("reason", "unknown")])) return + _tutorial_advance("radio_submitted") var hits: int = coordinator.radio.credibility_hits(report, current_npc_truth) if hits > 0: 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 result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy, controller.screened) hud.show_inspection(controller, artifacts, result) + _tutorial_advance("inspected") _refresh_freeze() # Venue door-staff flavor: an applicable check line (PLAN.md: door-staff dialogue). var line := _door_context_line() @@ -1611,6 +1753,7 @@ func _physics_process(delta: float) -> void: if coordinator == null or not coordinator.running: return coordinator.tick(delta) + _tutorial_tick() if coordinator.current_phase != _last_phase: _last_phase = coordinator.current_phase _apply_phase_lighting() diff --git a/src/game/tutorial.gd b/src/game/tutorial.gd index 8717eef..558bc78 100644 --- a/src/game/tutorial.gd +++ b/src/game/tutorial.gd @@ -11,42 +11,50 @@ 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", }, diff --git a/src/game/ui/settings_panel.gd b/src/game/ui/settings_panel.gd index 01ef4d2..2271322 100644 --- a/src/game/ui/settings_panel.gd +++ b/src/game/ui/settings_panel.gd @@ -10,6 +10,7 @@ const InputBindings := preload("res://src/core/input_bindings.gd") const UiArt := preload("res://src/game/ui/art.gd") signal applied(settings: Dictionary) +signal replay_tutorial_requested() signal closed() var _root: Control = null @@ -86,6 +87,15 @@ func _build() -> void: _head_bob.toggled.connect(_on_head_bob_toggled) 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() controls_title.text = "Controls (click a binding, then press a key or pad button)" controls_title.add_theme_font_size_override("font_size", 13) diff --git a/src/game/ui/tutorial_overlay.gd b/src/game/ui/tutorial_overlay.gd new file mode 100644 index 0000000..c8e98bd --- /dev/null +++ b/src/game/ui/tutorial_overlay.gd @@ -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 diff --git a/src/test/smoke_tutorial.gd b/src/test/smoke_tutorial.gd new file mode 100644 index 0000000..0f4ca60 --- /dev/null +++ b/src/test/smoke_tutorial.gd @@ -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)