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
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user