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

+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 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()