Ship the 0.8.0 first-hour and campaign milestone
Add the campaign shell, pause-safe checkpoint resume, post-shift campaign continue, confirmed campaign reset, difficulty presets, searchable Help, content-matrix validation, recovery events, contextual dialogue, policy/debrief context and eligible-event previews. Extend persistence with atomic separate files and add campaign/checkpoint/tutorial smokes. Headless 247/247, Python 94/94, tutorial/campaign/session/scenario smokes green.
This commit is contained in:
1 parent
86332cd401
commit
aa51ee331e
38 files changed
+1361
-1144
No files matched your search
+227
-5
@@ -32,6 +32,8 @@ 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")
|
||||
const Campaign := preload("res://src/core/campaign.gd")
|
||||
const HelpOverlay := preload("res://src/game/ui/help_overlay.gd")
|
||||
|
||||
var world_builder: WorldBuilder = null
|
||||
## Dedicated container for the procedural world, so switching venues can rebuild
|
||||
@@ -94,8 +96,12 @@ var pause_overlay: PauseOverlay = null
|
||||
var settings_panel: SettingsPanel = null
|
||||
var event_timeline: EventTimeline = null
|
||||
var tutorial_overlay: TutorialOverlay = null
|
||||
var help_overlay: HelpOverlay = null
|
||||
var tutorial: Tutorial = null
|
||||
var tutorial_active := false
|
||||
var campaign: Campaign = null
|
||||
var _resuming := false
|
||||
var _resume_pending: Dictionary = {}
|
||||
var _tutorial_start_pos := Vector3.ZERO
|
||||
var _tutorial_start_yaw := 0.0
|
||||
var _tutorial_start_pitch := 0.0
|
||||
@@ -115,6 +121,12 @@ var active_scenario_id := "hollywood_building"
|
||||
var scenario_duration_s: float = 900.0
|
||||
var _wand_cooldown := 0.0
|
||||
const WAND_COOLDOWN_S := 0.8
|
||||
## Difficulty only changes pacing/forgiveness; the de-escalation rules stay fixed.
|
||||
const DIFFICULTY := {
|
||||
"easy": {"spawn": 0.7, "compliance": 0.12, "report": 0.7},
|
||||
"normal": {"spawn": 1.0, "compliance": 0.0, "report": 1.0},
|
||||
"hard": {"spawn": 1.35, "compliance": -0.08, "report": 1.5},
|
||||
}
|
||||
var scene_root: Node3D = null
|
||||
var npcs: Array = []
|
||||
var npc_counter := 0
|
||||
@@ -168,12 +180,15 @@ func _ready() -> void:
|
||||
scenario_duration_s = selected_scenario.shift_duration_s
|
||||
coordinator = ShiftCoordinator.new()
|
||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||
coordinator.scheduler.spawn_rate_multiplier = float(_difficulty()["spawn"])
|
||||
coordinator.npc_spawn_requested.connect(_spawn_npc)
|
||||
coordinator.shift_ended.connect(_on_shift_ended)
|
||||
_connect_radio()
|
||||
_connect_crowd()
|
||||
|
||||
_build_ui()
|
||||
campaign = Campaign.new()
|
||||
campaign.setup(Save.load_campaign() if Save else {})
|
||||
# The HUD does not exist yet when settings are loaded, so push them again now
|
||||
# that it does (otherwise the saved font scale is ignored until the next apply).
|
||||
_apply_settings(player_settings)
|
||||
@@ -275,6 +290,8 @@ func _rebuild_world_for_venue(id: String) -> void:
|
||||
func restart_shift() -> void:
|
||||
if shift_review_panel:
|
||||
shift_review_panel.hide()
|
||||
if Save:
|
||||
Save.clear_shift_state()
|
||||
_reset_shift_state()
|
||||
_start_shift()
|
||||
|
||||
@@ -298,6 +315,7 @@ func _reset_shift_state() -> void:
|
||||
hud.set_body_camera(false)
|
||||
scenario_duration_s = selected_scenario.shift_duration_s
|
||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||
coordinator.scheduler.spawn_rate_multiplier = float(_difficulty()["spawn"])
|
||||
_connect_radio()
|
||||
_connect_crowd()
|
||||
_last_phase = -1
|
||||
@@ -328,11 +346,13 @@ func _show_start_menu() -> void:
|
||||
guard.input_locked = true
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||
if start_menu:
|
||||
start_menu.open()
|
||||
start_menu.open(campaign.summary() if campaign else {})
|
||||
|
||||
func _on_venue_selected(scenario_id: String) -> void:
|
||||
if start_menu and start_menu.visible:
|
||||
start_menu.close()
|
||||
if not _resuming and Save:
|
||||
Save.clear_shift_state()
|
||||
active_scenario_id = scenario_id
|
||||
_venue_index = maxi(0, VENUE_ORDER.find(scenario_id))
|
||||
selected_scenario = _build_scenario(scenario_id)
|
||||
@@ -341,8 +361,17 @@ func _on_venue_selected(scenario_id: String) -> void:
|
||||
hud.set_wand_icon(false)
|
||||
_reset_shift_state()
|
||||
_start_shift()
|
||||
if not _resume_pending.is_empty():
|
||||
var pending := _resume_pending
|
||||
_resume_pending = {}
|
||||
_restore_shift_state(pending)
|
||||
_resuming = false
|
||||
|
||||
## Apply loaded settings to the guard and HUD.
|
||||
func _difficulty() -> Dictionary:
|
||||
return DIFFICULTY.get(str(player_settings.get("ai_difficulty", "normal")),
|
||||
DIFFICULTY["normal"])
|
||||
|
||||
func _apply_settings(settings: Dictionary) -> void:
|
||||
player_settings = settings
|
||||
if guard:
|
||||
@@ -355,13 +384,30 @@ func _apply_settings(settings: Dictionary) -> void:
|
||||
|
||||
func _on_settings_applied(settings: Dictionary) -> void:
|
||||
_apply_settings(settings)
|
||||
if campaign:
|
||||
campaign.set_difficulty(str(settings.get("ai_difficulty", "normal")))
|
||||
if Save:
|
||||
Save.save_campaign(campaign.summary())
|
||||
if coordinator:
|
||||
coordinator.scheduler.spawn_rate_multiplier = float(_difficulty()["spawn"])
|
||||
if start_menu and start_menu.visible:
|
||||
start_menu.open()
|
||||
start_menu.open(campaign.summary() if campaign else {})
|
||||
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_reset_campaign() -> void:
|
||||
if Save:
|
||||
Save.reset_campaign()
|
||||
if campaign:
|
||||
campaign.setup({})
|
||||
if settings_panel:
|
||||
settings_panel.close()
|
||||
if pause_overlay:
|
||||
pause_overlay.close()
|
||||
_show_start_menu()
|
||||
|
||||
func _on_replay_tutorial() -> void:
|
||||
player_settings["tutorial_completed"] = false
|
||||
player_settings["tutorial_skipped"] = false
|
||||
@@ -373,6 +419,10 @@ func _on_replay_tutorial() -> void:
|
||||
pause_overlay.close()
|
||||
_on_venue_selected("hollywood_building")
|
||||
|
||||
func _open_help() -> void:
|
||||
if help_overlay:
|
||||
help_overlay.open(selected_scenario.policy_summary if selected_scenario else "")
|
||||
|
||||
func _open_settings() -> void:
|
||||
if start_menu:
|
||||
start_menu.close()
|
||||
@@ -384,6 +434,7 @@ func _open_settings() -> void:
|
||||
func _open_pause() -> void:
|
||||
if pause_overlay == null or not _shift_running:
|
||||
return
|
||||
_save_shift_checkpoint()
|
||||
if _close_top_modal():
|
||||
pass
|
||||
pause_overlay.open()
|
||||
@@ -427,6 +478,7 @@ func _build_ui() -> void:
|
||||
add_child(shift_review_panel)
|
||||
shift_review_panel.hide()
|
||||
shift_review_panel.restart_requested.connect(_on_review_restart)
|
||||
shift_review_panel.continue_requested.connect(_on_review_continue)
|
||||
shift_review_panel.timeline_requested.connect(_open_timeline)
|
||||
shift_review_panel.visible = false
|
||||
|
||||
@@ -443,6 +495,7 @@ func _build_ui() -> void:
|
||||
settings_panel.hide()
|
||||
settings_panel.applied.connect(_on_settings_applied)
|
||||
settings_panel.replay_tutorial_requested.connect(_on_replay_tutorial)
|
||||
settings_panel.reset_campaign_requested.connect(_on_reset_campaign)
|
||||
|
||||
event_timeline = EventTimeline.new()
|
||||
add_child(event_timeline)
|
||||
@@ -452,10 +505,16 @@ func _build_ui() -> void:
|
||||
tutorial_overlay = TutorialOverlay.new()
|
||||
add_child(tutorial_overlay)
|
||||
|
||||
help_overlay = HelpOverlay.new()
|
||||
add_child(help_overlay)
|
||||
help_overlay.closed.connect(_refresh_freeze)
|
||||
|
||||
start_menu = StartMenu.new()
|
||||
add_child(start_menu)
|
||||
start_menu.hide()
|
||||
start_menu.venue_selected.connect(_on_venue_selected)
|
||||
start_menu.continue_requested.connect(_on_campaign_continue)
|
||||
start_menu.help_requested.connect(_open_help)
|
||||
start_menu.settings_requested.connect(_open_settings)
|
||||
start_menu.quit_requested.connect(func(): get_tree().quit())
|
||||
|
||||
@@ -476,10 +535,147 @@ func _on_timeline_closed() -> void:
|
||||
_timeline_from_review = false
|
||||
_refresh_freeze()
|
||||
|
||||
## Pause-safe checkpoint: enough state to resume the current shift without
|
||||
## replaying completed interactions. The scheduler RNG is restored so future
|
||||
## routine traffic does not repeat the same sequence.
|
||||
func _save_shift_checkpoint() -> void:
|
||||
if Save == null or not _shift_running or coordinator == null:
|
||||
return
|
||||
var npc_rows: Array = []
|
||||
for avatar in npcs:
|
||||
if not is_instance_valid(avatar):
|
||||
continue
|
||||
var controller: Object = avatar.get_npc_controller() if avatar.has_method("get_npc_controller") else null
|
||||
var profile: Object = controller.profile if controller else null
|
||||
npc_rows.append({
|
||||
"npc_id": str(controller.npc_id) if controller else "",
|
||||
"npc_type": int(profile.npc_type) if profile else Rules.NpcType.TOURIST,
|
||||
"profile_id": str(profile.id) if profile else "",
|
||||
"intent": str(controller.intent) if controller else "roam",
|
||||
"position": _vec3_array(avatar.global_position),
|
||||
"target": _vec3_array(avatar.target_position),
|
||||
"carried_items": controller.profile.carried_items.duplicate() if controller and controller.profile else [],
|
||||
"leave_requested": bool(controller.leave_requested) if controller else false,
|
||||
"player_recognized": bool(controller.player_recognized) if controller else false,
|
||||
"access_override": (controller.access_override as Dictionary).duplicate(true) if controller else {},
|
||||
"ephemeral": bool(avatar.ephemeral),
|
||||
"lifetime_s": float(avatar.lifetime_s),
|
||||
})
|
||||
var _save_err := Save.save_shift_state({
|
||||
"version": 1,
|
||||
"scenario_id": active_scenario_id,
|
||||
"guard_position": _vec3_array(guard.global_position) if guard else [0.0, 0.0, 0.0],
|
||||
"guard_yaw": guard.rotation.y if guard else 0.0,
|
||||
"head_pitch": guard.head.rotation.x if guard else 0.0,
|
||||
"door_locked": door.locked,
|
||||
"flashlight_on": flashlight_on,
|
||||
"body_camera_on": body_camera_on,
|
||||
"npc_counter": npc_counter,
|
||||
"coordinator": coordinator.snapshot(),
|
||||
"npcs": npc_rows,
|
||||
})
|
||||
|
||||
func _vec3_array(value: Vector3) -> Array:
|
||||
return [value.x, value.y, value.z]
|
||||
|
||||
func _vec3_from(value: Variant, fallback: Vector3 = Vector3.ZERO) -> Vector3:
|
||||
if value is Vector3:
|
||||
return value
|
||||
if value is Array and (value as Array).size() >= 3:
|
||||
return Vector3(float(value[0]), float(value[1]), float(value[2]))
|
||||
return fallback
|
||||
|
||||
func _resume_shift(state: Dictionary) -> void:
|
||||
if state.is_empty() or not state.has("scenario_id"):
|
||||
return
|
||||
_resume_pending = state
|
||||
_resuming = true
|
||||
_on_venue_selected(str(state["scenario_id"]))
|
||||
|
||||
func _restore_shift_state(state: Dictionary) -> void:
|
||||
if coordinator == null or not state.has("coordinator"):
|
||||
return
|
||||
coordinator.restore(state.get("coordinator", {}))
|
||||
coordinator.running = true
|
||||
npc_counter = int(state.get("npc_counter", 0))
|
||||
npcs.clear()
|
||||
for row in state.get("npcs", []):
|
||||
if not (row is Dictionary):
|
||||
continue
|
||||
var data: Dictionary = row
|
||||
var profile: NpcProfile = selected_scenario.profile_for(int(data.get("npc_type", Rules.NpcType.TOURIST)))
|
||||
if profile == null:
|
||||
profile = NpcProfile.new()
|
||||
profile.id = str(data.get("profile_id", "guest"))
|
||||
profile.display_name = str(data.get("profile_id", "Guest")).replace("_", " ").capitalize()
|
||||
profile.npc_type = int(data.get("npc_type", Rules.NpcType.TOURIST))
|
||||
_spawn_npc(profile, str(data.get("intent", "roam")), str(data.get("npc_id", "resumed")),
|
||||
{"kind": "scripted"})
|
||||
if npcs.is_empty():
|
||||
continue
|
||||
var avatar: Node3D = npcs[npcs.size() - 1]
|
||||
avatar.global_position = _vec3_from(data.get("position"), avatar.global_position)
|
||||
avatar.target_position = _vec3_from(data.get("target"), avatar.target_position)
|
||||
var controller: Object = avatar.get_npc_controller()
|
||||
if controller:
|
||||
controller.leave_requested = bool(data.get("leave_requested", false))
|
||||
controller.player_recognized = bool(data.get("player_recognized", false))
|
||||
controller.access_override = (data.get("access_override", {}) as Dictionary).duplicate(true)
|
||||
controller.profile.carried_items = []
|
||||
for item in data.get("carried_items", []):
|
||||
controller.profile.carried_items.append(str(item))
|
||||
avatar.ephemeral = bool(data.get("ephemeral", false))
|
||||
avatar.lifetime_s = float(data.get("lifetime_s", 60.0))
|
||||
# Restore presentation state after the world/NPC reset.
|
||||
if guard:
|
||||
guard.global_position = _vec3_from(state.get("guard_position"), guard.global_position)
|
||||
guard.rotation.y = float(state.get("guard_yaw", guard.rotation.y))
|
||||
guard.head.rotation.x = float(state.get("head_pitch", 0.0))
|
||||
door.set_locked(bool(state.get("door_locked", false)))
|
||||
if world_builder:
|
||||
world_builder.set_door_locked(door.locked)
|
||||
flashlight_on = bool(state.get("flashlight_on", false))
|
||||
body_camera_on = bool(state.get("body_camera_on", false))
|
||||
if hud:
|
||||
hud.set_door(door.locked)
|
||||
hud.set_flashlight(flashlight_on)
|
||||
hud.set_body_camera(body_camera_on)
|
||||
if guard:
|
||||
var lamp := guard.get_node_or_null("Head/Flashlight") as SpotLight3D
|
||||
if lamp:
|
||||
lamp.visible = flashlight_on
|
||||
_last_phase = -1
|
||||
_apply_phase_lighting()
|
||||
_refresh_freeze()
|
||||
|
||||
## "Run another shift" on the review screen returns to the venue menu.
|
||||
func _on_review_restart() -> void:
|
||||
_show_start_menu()
|
||||
|
||||
## Campaign "continue" starts the next venue; completing the last venue returns
|
||||
## to the free-play menu.
|
||||
func _on_review_continue() -> void:
|
||||
if shift_review_panel:
|
||||
shift_review_panel.visible = false
|
||||
var next_id := campaign.next_venue() if campaign else ""
|
||||
if next_id == "":
|
||||
_show_start_menu()
|
||||
return
|
||||
_on_venue_selected(next_id)
|
||||
|
||||
func _on_campaign_continue() -> void:
|
||||
var saved := Save.load_shift_state() if Save else {}
|
||||
if not saved.is_empty() and saved.has("scenario_id"):
|
||||
_resume_shift(saved)
|
||||
return
|
||||
if campaign == null:
|
||||
return
|
||||
var next_id := campaign.next_venue()
|
||||
if next_id == "":
|
||||
_show_start_menu()
|
||||
else:
|
||||
_on_venue_selected(next_id)
|
||||
|
||||
func _add_crosshair() -> void:
|
||||
var layer := CanvasLayer.new()
|
||||
layer.layer = 9
|
||||
@@ -558,7 +754,7 @@ func _apply_phase_lighting() -> void:
|
||||
## 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":
|
||||
if tutorial_active or _resuming or active_scenario_id != "hollywood_building":
|
||||
return
|
||||
if bool(player_settings.get("tutorial_completed", false)) \
|
||||
or bool(player_settings.get("tutorial_skipped", false)):
|
||||
@@ -839,7 +1035,14 @@ func _on_shift_ended(review: Dictionary) -> void:
|
||||
# Persist the finished shift locally (Save autoload, user:// JSON).
|
||||
if Save:
|
||||
Save.save_shift_result(review)
|
||||
shift_review_panel.show_review(review, selected_scenario, Save.load_shift_results() if Save else [])
|
||||
Save.clear_shift_state()
|
||||
if campaign:
|
||||
campaign.record_shift(active_scenario_id, review)
|
||||
if Save:
|
||||
Save.save_campaign(campaign.summary())
|
||||
shift_review_panel.show_review(review, selected_scenario,
|
||||
Save.load_shift_results() if Save else [],
|
||||
campaign.summary() if campaign else {})
|
||||
_tutorial_advance("review_opened")
|
||||
_refresh_freeze()
|
||||
|
||||
@@ -1323,7 +1526,8 @@ func _apply_interaction(level: int, action_name: String) -> void:
|
||||
if controller == null or coordinator == null:
|
||||
return
|
||||
_ensure_incident(controller)
|
||||
var outcome: Dictionary = controller.apply_player_action(level)
|
||||
var outcome: Dictionary = controller.apply_player_action(
|
||||
level, float(_difficulty()["compliance"]))
|
||||
var npc_id: String = controller.npc_id
|
||||
coordinator.recorder.add_event(npc_id, {
|
||||
"type": "player_action",
|
||||
@@ -1348,6 +1552,15 @@ func _apply_interaction(level: int, action_name: String) -> void:
|
||||
hud.set_escalation(level)
|
||||
if audio:
|
||||
audio.play_escalation(level)
|
||||
var context := ""
|
||||
if outcome.get("reaction", -1) in [Rules.Reaction.COMPLY, Rules.Reaction.LEAVE]:
|
||||
context = "deescalation"
|
||||
elif outcome.get("reaction", -1) in [Rules.Reaction.ARGUE, Rules.Reaction.RECORD]:
|
||||
context = "refusal"
|
||||
if context != "":
|
||||
var line: String = controller.context_line(context)
|
||||
if line != "":
|
||||
hud.log("\"%s\"" % line)
|
||||
_react_to_outcome(controller, outcome)
|
||||
|
||||
## Level 1 eye contact: a distinct, quieter cue than raise-hand (PLAN.md). It
|
||||
@@ -1368,6 +1581,9 @@ func _recognize_target() -> void:
|
||||
return
|
||||
_ensure_incident(controller)
|
||||
controller.player_recognized = true
|
||||
var recognition_line: String = controller.context_line("recognition")
|
||||
if recognition_line != "":
|
||||
hud.log("\"%s\"" % recognition_line)
|
||||
coordinator.recorder.add_access_evidence(controller.npc_id, {
|
||||
"kind": "recognition",
|
||||
"source": "guard_decision",
|
||||
@@ -1486,6 +1702,7 @@ func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
|
||||
return
|
||||
_tutorial_advance("radio_submitted")
|
||||
var hits: int = coordinator.radio.credibility_hits(report, current_npc_truth)
|
||||
hits = int(ceil(float(hits) * float(_difficulty()["report"])))
|
||||
if hits > 0:
|
||||
coordinator.reputation.apply("false_report", -4.0 * hits, "inaccurate radio report")
|
||||
hud.log(Strings.fmt("radio.inaccurate", [4 * hits]))
|
||||
@@ -1757,7 +1974,12 @@ func _physics_process(delta: float) -> void:
|
||||
if coordinator.current_phase != _last_phase:
|
||||
_last_phase = coordinator.current_phase
|
||||
_apply_phase_lighting()
|
||||
if hud and not tutorial_active:
|
||||
hud.log("Objective: %s — keep the entrance clear and verify access." % str(
|
||||
Rules.PHASE_NAMES.get(coordinator.current_phase, "Early")))
|
||||
hud.set_shift_state(coordinator, selected_scenario)
|
||||
var preview := coordinator.scheduler.preview(coordinator.elapsed_s, coordinator.current_phase)
|
||||
hud.refresh_debug(coordinator, npcs, preview)
|
||||
_perception_pass()
|
||||
|
||||
## Perception pass: NPCs notice the guard's proximity (PLAN.md: Level 0
|
||||
|
||||
Reference in new issue
Block a user