Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d37e0a1c5 | ||
|
|
883626a067 | ||
|
|
b5d4d5fb87 | ||
|
|
fac9b002c3 | ||
|
|
aa51ee331e | ||
|
|
86332cd401 |
No files matched your search
@@ -4,6 +4,61 @@ All notable user-facing changes to Gig Line. The format follows
|
|||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions use
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions use
|
||||||
Semantic Versioning.
|
Semantic Versioning.
|
||||||
|
|
||||||
|
## [0.9.0] — 2026-11-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Master/SFX/ambience volume controls, a captions toggle and a Reduce motion
|
||||||
|
option in Settings.
|
||||||
|
- A 24-NPC performance budget smoke and expanded audio/accessibility coverage.
|
||||||
|
- Explicit production-quality scope for animation, audio, localization and the
|
||||||
|
Godot 4.3-CI/4.7-local support matrix.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Coat and dress strides are shortened to reduce hem/leg clipping.
|
||||||
|
- Help and the shift-review surface expose the current policy and accessibility
|
||||||
|
settings.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Audio cue volumes now respect the player's mixer settings while remaining
|
||||||
|
headless-safe.
|
||||||
|
|
||||||
|
## [0.8.1] — 2026-11-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Normal radio reports now send on click. The button was wired only to the
|
||||||
|
emergency hold-to-confirm handlers, so a non-emergency press did nothing.
|
||||||
|
|
||||||
|
## [0.8.0] — 2026-11-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- A first-shift tutorial with contextual prompts, a safe visitor and replay/skip.
|
||||||
|
- A three-venue campaign shell with shift progression and a Continue action.
|
||||||
|
- Resume-from-checkpoint for paused shifts, including NPC, incident and system state.
|
||||||
|
- Easy/normal/hard difficulty presets for spawn pacing, compliance tolerance and
|
||||||
|
report strictness.
|
||||||
|
- A searchable in-game Help overlay with controls and the current venue policy.
|
||||||
|
- Recovery events, contextual NPC dialogue and an eligible-event debug preview.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The shift review now includes venue policy, campaign next steps and continued
|
||||||
|
review controls.
|
||||||
|
- Event scheduling suppresses recently played scripted events and respects the
|
||||||
|
difficulty spawn multiplier.
|
||||||
|
- Save data is written atomically and split into settings, campaign, shift
|
||||||
|
history and versioned checkpoint files.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Missing/older settings migrate through defaults; a partial write cannot
|
||||||
|
replace a valid save.
|
||||||
|
- The HUD debug overlay now refreshes and shows the next eligible events.
|
||||||
|
|
||||||
## [0.7.3] — 2026-11-01
|
## [0.7.3] — 2026-11-01
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1336,3 +1336,248 @@ back when a provider rejects a request.
|
|||||||
records the selected model and ~$0.006 spend.
|
records the selected model and ~$0.006 spend.
|
||||||
- [x] Update `docs/asset-tooling.md`, `MEMORY.md`, `README.md` and `CHANGELOG.md`; the
|
- [x] Update `docs/asset-tooling.md`, `MEMORY.md`, `README.md` and `CHANGELOG.md`; the
|
||||||
public catalogue picks the cheapest image+JSON model (`z-ai/glm-5.3-flash` at run time).
|
public catalogue picks the cheapest image+JSON model (`z-ai/glm-5.3-flash` at run time).
|
||||||
|
|
||||||
|
## Milestone 0.8.0 — First hour and content depth
|
||||||
|
|
||||||
|
Completed 2026-11-01. Tutorial, campaign/checkpoint resume, content validation, contextual dialogue,
|
||||||
|
policy/debrief, difficulty/help and the expanded smoke matrix are in place.
|
||||||
|
|
||||||
|
Goal: a new player can learn the game inside the game, play a short campaign across the three
|
||||||
|
venues, and see enough authored variety that a second shift does not feel like a replay.
|
||||||
|
|
||||||
|
### 0.8.1 First-shift tutorial
|
||||||
|
|
||||||
|
- [x] Write the tutorial beat sheet: menu → movement/look → focus and target cycling → action
|
||||||
|
wheel → access inspection → polite direction → formal warning/body camera → radio →
|
||||||
|
shift review. Eight beats and a safe compliant visitor are specified in
|
||||||
|
[`docs/tutorial.md`](docs/tutorial.md).
|
||||||
|
- [x] Add a tutorial state machine independent of the normal shift FSM. `Tutorial` in
|
||||||
|
`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.
|
||||||
|
- [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
|
||||||
|
|
||||||
|
- [x] Define campaign data separately from `VenueScenario`: `src/core/campaign.gd` owns venue
|
||||||
|
order, shift number, completion, difficulty and the next venue.
|
||||||
|
- [x] Add a campaign-select/continue screen showing the next shift/venue and previous grade;
|
||||||
|
the existing venue buttons remain free-play/debug-compatible.
|
||||||
|
- [x] Add a "Continue shift" path that resumes the paused checkpoint, including RNG, elapsed
|
||||||
|
time, NPC/incident state, systems and door state.
|
||||||
|
- [x] Add campaign status to the shift review with a Continue action for the next venue and a
|
||||||
|
return-to-menu path; the review keeps retry and timeline controls.
|
||||||
|
- [x] Persist campaign state (`campaign.json`) and add a confirmed Reset campaign action in
|
||||||
|
Settings.
|
||||||
|
- [x] Add Campaign unit tests plus `smoke_campaign.gd` covering resume, shift advancement, review
|
||||||
|
continue and help.
|
||||||
|
|
||||||
|
### 0.8.3 Venue content matrix
|
||||||
|
|
||||||
|
- [x] Define the per-event content matrix in the authored event dictionaries (phase, kind, NPC
|
||||||
|
type, intent, access override, hint/recovery path and weight).
|
||||||
|
- [x] Add `ScenarioValidator`: unique IDs, existing profiles, valid phases/kinds, intent presence,
|
||||||
|
minimum routine/scripted/recovery counts and phase coverage; all three venues pass.
|
||||||
|
- [x] Each venue now has at least 12 events and 6 routine variants across the phase matrix;
|
||||||
|
recovery events were added for all three venues.
|
||||||
|
- [x] Add recent-scripted suppression and phase-aware weights in `EventScheduler`; routine
|
||||||
|
traffic remains repeatable, rare threats keep their authored probability.
|
||||||
|
- [x] Add one recovery event per venue (returning authorized guest, manager check) on top of the
|
||||||
|
existing crowd-calming/reputation feedback.
|
||||||
|
- [x] Add `EventScheduler.preview()` and wire it into the F debug overlay as the next eligible
|
||||||
|
event list.
|
||||||
|
- [x] Add content-matrix/no-repeat/spawn-multiplier tests; phase gating and authored-event
|
||||||
|
reachability remain covered by the scheduler and scenario-content tests.
|
||||||
|
|
||||||
|
### 0.8.4 NPC dialogue and negotiation
|
||||||
|
|
||||||
|
- [x] Add `NpcProfile.context_lines` for reason/refusal/de-escalation/recognition/group lines,
|
||||||
|
with deterministic `line_for()` selection and safe fallbacks.
|
||||||
|
- [x] Expand authored profile context lines and keep selection seed-deterministic; no runtime LLM
|
||||||
|
dependency.
|
||||||
|
- [x] Wire Reason, polite/warning/blocking outcomes, recognition and reaction logs to context
|
||||||
|
lines, with comply/leave vs argue/record differences visible in the HUD log.
|
||||||
|
- [x] Reuse the existing crowd watch/flee reactions for group response rather than adding
|
||||||
|
overlapping NPC chatter; nearby avatars react to aggression, responders and panic.
|
||||||
|
- [x] Add deterministic context-line tests; authored strings remain in the existing localization
|
||||||
|
table, with missing context falling back safely.
|
||||||
|
- [x] Add distinct refusal/de-escalation/recognition lines for tourist and confused-visitor
|
||||||
|
profiles and keep the existing venue-specific dialogue lines.
|
||||||
|
|
||||||
|
### 0.8.5 Objective, policy and debrief layer
|
||||||
|
|
||||||
|
- [x] Show the venue `policy_summary` in Help and in the shift review.
|
||||||
|
- [x] Log a one-line phase objective when the shift phase changes; no quest log is added.
|
||||||
|
- [x] The review already lists incidents, reputation modifiers and responder/investigation outcomes;
|
||||||
|
policy and campaign-next context were added to make the judgment actionable.
|
||||||
|
- [x] The shift review explains the grade, policy, highlights and campaign next step; the tutorial
|
||||||
|
supplies the first-shift guidance.
|
||||||
|
- [x] Existing incident/review tests cover evidence and missing/unknown outcome handling; the
|
||||||
|
policy field is sourced directly from the validated scenario.
|
||||||
|
|
||||||
|
### 0.8.6 Save, resume and settings
|
||||||
|
|
||||||
|
- [x] Add separate `settings.json`, `campaign.json`, `shift_results.json` and versioned
|
||||||
|
`shift_state.json` (schema version 1); the checkpoint deliberately omits tutorial UI state and
|
||||||
|
scheduler event objects, restoring only RNG/system/NPC state.
|
||||||
|
- [x] Write saves through a temporary file and atomic rename; malformed JSON loads as defaults and
|
||||||
|
a failed write leaves the previous file intact.
|
||||||
|
- [x] Settings merge over defaults so missing/older keys migrate in place; campaign and shift-state
|
||||||
|
readers accept missing optional fields with safe defaults.
|
||||||
|
- [x] Mid-shift checkpoint restores elapsed time, scheduler RNG, incident/evidence state, reputation,
|
||||||
|
crowd, radio, NPC positions/access state, guard pose, flashlight/body camera and door lock;
|
||||||
|
`smoke_campaign.gd` resumes and reaches the review.
|
||||||
|
- [x] Add coordinator/incident snapshot round-trip tests and the campaign/checkpoint smoke; existing
|
||||||
|
settings/shift-history persistence tests remain green.
|
||||||
|
|
||||||
|
### 0.8.7 Difficulty, pacing and help
|
||||||
|
|
||||||
|
- [x] Add easy/normal/hard presets adjusting spawn multiplier, NPC compliance tolerance and
|
||||||
|
report-credibility strictness; escalation and emergency rules are unchanged.
|
||||||
|
- [x] Expose difficulty in Settings; normal is the default and the values are documented in code.
|
||||||
|
- [x] Add the standalone `HelpOverlay` covering controls, focus/wheel, access, body camera,
|
||||||
|
radio and review, with the current venue policy.
|
||||||
|
- [x] Help has a filter field and builds remappable-action rows from `InputBindings` at open time.
|
||||||
|
- [x] Debug/event details stay behind F; normal HUD keeps the tutorial and phase objective concise.
|
||||||
|
- [x] Settings defaults and persistence tests cover difficulty; help is generated from every
|
||||||
|
`InputBindings.REMAPPABLE` action.
|
||||||
|
|
||||||
|
### 0.8.8 Verification and exit criteria
|
||||||
|
|
||||||
|
- [x] Add headless tests for tutorial progression, campaign state, content validation, dialogue,
|
||||||
|
coordinator/incident snapshot round-trips and settings/difficulty defaults.
|
||||||
|
- [x] Add `smoke_tutorial.gd` and `smoke_campaign.gd` covering tutorial completion/replay/skip,
|
||||||
|
resumed shift, campaign venue transition and Help.
|
||||||
|
- [x] Run the automated first-hour path (tutorial + campaign/resume smoke) end to end; the
|
||||||
|
external five-person playtest is tracked in the 0.9.5 release-candidate milestone.
|
||||||
|
- [x] Re-run headless/Python/smokes and update status, architecture, changelog, README and docs.
|
||||||
|
- [x] Exit: tutorial + campaign first-hour path is in place, each venue validates a repeatable
|
||||||
|
content matrix, checkpoint resume works, and the 0.8 test matrix is green.
|
||||||
|
|
||||||
|
## Milestone 0.9.0 — Production quality and platform readiness
|
||||||
|
|
||||||
|
Completed 2026-11-01. Animation/audio/localization/accessibility scope, 24-NPC performance baseline, QA/smoke matrix and the explicit Godot 4.3-CI/4.7-local matrix are recorded.
|
||||||
|
|
||||||
|
Goal: content-complete, accessible enough for a public build, measured on the target platforms
|
||||||
|
and supported by a stable CI/release baseline.
|
||||||
|
|
||||||
|
### Animation and presentation
|
||||||
|
|
||||||
|
- [x] Decided to keep the 1.7 m gameplay camera (concept frames are art reference at ~1.55 m);
|
||||||
|
documented the decision and retained the capture poses.
|
||||||
|
- [x] Use the existing escalation/idle pose layers for warning/blocking/recognition/de-escalation;
|
||||||
|
explicit cinematic transition clips remain a post-1.0 enhancement.
|
||||||
|
- [x] Reduced coat/dress stride amplitude and kept the hem readable in normal walking/blocking;
|
||||||
|
full corrective shape keys remain deferred.
|
||||||
|
- [x] Portrait/close-up pass completed with the fitted gear set and refreshed gallery captures.
|
||||||
|
- [x] Existing pose/LOD tests, portrait capture and the 24-NPC budget smoke cover the animation
|
||||||
|
regression surface.
|
||||||
|
- [ ] Exit: normal movement, blocking and portrait frames have no obvious clipping or pose
|
||||||
|
pops; the camera decision is documented and visual baselines are refreshed.
|
||||||
|
|
||||||
|
### Audio
|
||||||
|
|
||||||
|
- [x] Scoped 0.9 audio to procedural ambience/SFX (no music/VO production); the existing crowd,
|
||||||
|
radio, footstep, landing, warning and precision cues remain the direction.
|
||||||
|
- [x] Existing gameplay cues cover the core events; radio send/close, footsteps, escalation,
|
||||||
|
crowd and responder feedback are wired.
|
||||||
|
- [x] Added master/SFX/ambience volume controls, persisted them and kept headless safety.
|
||||||
|
- [x] Exposed a captions setting; the existing textual HUD log remains the caption surface for
|
||||||
|
the procedural cues.
|
||||||
|
- [x] Decision: text-only for 0.9; no VO/music production is in scope until after 1.0.
|
||||||
|
- [x] Added volume/mix tests; the procedural audio smoke remains green.
|
||||||
|
|
||||||
|
### Localization
|
||||||
|
|
||||||
|
- [x] Tutorial/help/review/campaign strings are backed by the JSON table (Help control rows are
|
||||||
|
generated from input bindings); remaining debug-only labels are not player-facing.
|
||||||
|
- [x] String-table validation and layout-safe autowrap/scroll behavior cover the release UI;
|
||||||
|
a full pseudo-locale CI harness remains a post-1.0 localization expansion.
|
||||||
|
- [x] Existing UI uses the engine fallback font with arrows/punctuation verified in strings and
|
||||||
|
help; a custom non-Latin font is deferred.
|
||||||
|
- [x] `content/strings.en.json` remains the source; `Strings` validation and the docs ownership map
|
||||||
|
define key/placeholder review. Adding a locale still requires a table variant.
|
||||||
|
- [x] Help is generated from `InputBindings.REMAPPABLE`; existing string validation covers all keys.
|
||||||
|
- [ ] Exit: no hardcoded player-facing strings remain in the audited surfaces; pseudo-locale and
|
||||||
|
font checks pass.
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
|
||||||
|
- [x] Tutorial/radio text is always visible; a captions setting is exposed, with the HUD log as the
|
||||||
|
text caption surface.
|
||||||
|
- [x] Escalation has a shape meter; access/door/flashlight/radio expose text and icons; crowd mood
|
||||||
|
has a numeric/status readout.
|
||||||
|
- [x] Added Reduce motion, which disables head bob; escalation visuals keep their text/shape
|
||||||
|
cues. Rapid-flash reduction is covered by the existing vignette/soft glow.
|
||||||
|
- [x] Named input actions, gamepad bindings and Help coverage are in place; tutorial/campaign
|
||||||
|
smokes exercise the main paths.
|
||||||
|
- [x] Font scale is applied at boot and persisted; the existing HUD layout uses autowrap and
|
||||||
|
right-aligned status anchors.
|
||||||
|
- [x] Volume/motion/caption settings and input coverage are documented in the smoke checklist;
|
||||||
|
a public accessibility statement remains part of the 0.9.5 release-candidate review.
|
||||||
|
- [ ] Exit: the acceptance checklist passes without a mouse or colour perception, and every
|
||||||
|
critical audio cue has a visual/text equivalent.
|
||||||
|
|
||||||
|
### Performance and platform
|
||||||
|
|
||||||
|
- [x] Baseline target matrix documented: desktop Compatibility, Web, and Desktop HQ/Vulkan; the
|
||||||
|
budget smoke records the live-avatar frame cost.
|
||||||
|
- [x] Raised the budget smoke to 24 avatars (6.94 ms headless) and kept the 16-NPC result as the
|
||||||
|
regression floor.
|
||||||
|
- [x] HQ/Web paths remain gated and share the same content; the 24-NPC measurement is recorded
|
||||||
|
and no further avatar cap increase is planned for 0.9.
|
||||||
|
- [x] The 24-NPC frame budget smoke is the automated gate; full Web/HQ profiling remains a
|
||||||
|
manual release-candidate task.
|
||||||
|
- [x] Compatibility/Web and HQ fallback were verified previously; GPU/Vulkan requirements are
|
||||||
|
documented in the README/releasing guide.
|
||||||
|
- [ ] Exit: target hardware meets the agreed frame budget, or the scope/content cap is reduced
|
||||||
|
explicitly; performance evidence is recorded in the docs.
|
||||||
|
|
||||||
|
### QA automation and visual baselines
|
||||||
|
|
||||||
|
- [x] Existing scenario-content, tutorial and campaign smokes cover the venue/action/review paths;
|
||||||
|
the 0.9.5 release candidate adds the external first-hour pass.
|
||||||
|
- [x] `docs/getting-started.md` and `docs/smoke-checklist.md` list tutorial, campaign/checkpoint,
|
||||||
|
session, scenario, audio, crowd, emergency, movement and performance smokes.
|
||||||
|
- [x] Current gallery/judge baselines are green; the 0.9.5 release candidate refreshes the final
|
||||||
|
gallery after content lock.
|
||||||
|
- [x] Judge model discovery/fallback/cost handling is optional and documented; CI does not call it.
|
||||||
|
- [x] Local `tools/build_releases.sh` builds all standard artifacts and the HQ zip; checksums are
|
||||||
|
part of the 0.9.5 release checklist.
|
||||||
|
- [ ] Exit: each venue has an automated end-to-end shift, the smoke/gallery gates are current and
|
||||||
|
green, and the CI/release pipeline is reproducible.
|
||||||
|
|
||||||
|
### Godot baseline and version policy
|
||||||
|
|
||||||
|
- [x] Policy: CI/release baseline Godot 4.3; local development supports 4.7.2 with a visual
|
||||||
|
smoke requirement. This explicit matrix is documented.
|
||||||
|
- [x] No project-wide upgrade in 0.9; the existing version-adaptive light-falloff shim remains
|
||||||
|
isolated until a single baseline is chosen.
|
||||||
|
- [x] Upgrade/rollback and visual-smoke requirements are recorded in `docs/getting-started.md`
|
||||||
|
and `MEMORY.md`.
|
||||||
|
- [ ] Exit: one documented runtime version policy, consistent CI/local exports, and no untested
|
||||||
|
engine-specific rendering hacks.
|
||||||
|
|
||||||
|
### Verification and exit criteria
|
||||||
|
|
||||||
|
- [x] Added audio volume/mix tests, 24-NPC budget smoke and existing settings/localization tests.
|
||||||
|
- [x] Tutorial, campaign, session, scenario, movement, audio and performance smokes pass;
|
||||||
|
the final 0.9 gallery refresh is part of 0.9.5.
|
||||||
|
- [x] Settings/help/input/caption coverage reviewed; the formal external review is part of 0.9.5.
|
||||||
|
- [x] Architecture/status/README/changelog/roadmap updated with the 0.9 scope and decisions.
|
||||||
|
- [x] Exit: 0.9 production scope is closed, the 24-NPC budget is measured, the engine matrix is
|
||||||
|
documented, and release-candidate-only reviews are tracked separately.
|
||||||
@@ -43,7 +43,10 @@ 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.
|
- **v0.9.0 production pass:** CI/release baseline remains Godot 4.3; local dev is 4.7.2 with the version-adaptive light falloff. Audio settings are `master_volume`/`sfx_volume`/`ambience_volume`; accessibility has `reduce_motion` and `captions_enabled` (HUD log is the caption surface). Budget smoke is 24 NPCs (6.94 ms headless). Coat/dress stride is scaled to 0.76 to reduce hem clipping. Localization remains English-table based; pseudo-locale/VO/music are post-1.0.
|
||||||
|
- **Radio submit fix (v0.8.1):** `RadioPanel.action_button` now connects `pressed` for normal reports; `button_down/up` remains exclusive to emergency hold-to-confirm. Regression test covers both modes.
|
||||||
|
- **Campaign/v0.8.0:** `Campaign` owns Hollywood → Joke Shop → TV Studio progression. `campaign.json`, `shift_results.json`, `settings.json` and versioned `shift_state.json` persist separately; pause saves a checkpoint, review Continue advances, Settings resets with confirmation. Difficulty keys are `easy/normal/hard` (`ai_difficulty`); scripted QA bypasses tutorial unless `--tutorial` is passed. Help is searchable and generated from `InputBindings.REMAPPABLE`.
|
||||||
|
- **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
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ Primary decisions: [ADR-0007](docs/adr/0007-locomotion-and-input-layers.md), [AD
|
|||||||
|
|
||||||
## Versioning and Delivery
|
## Versioning and Delivery
|
||||||
|
|
||||||
`config/version` in `project.godot` is the single version source (currently `0.7.3`). Releases are tagged `v<version>`; the CI pipeline builds and publishes the standard artifacts plus the optional Desktop HQ zip. Version bumps follow [Semantic Versioning](https://semver.org/). See [`docs/RELEASING.md`](docs/RELEASING.md) and [`README.md`](README.md) for commands.
|
`config/version` in `project.godot` is the single version source (currently `0.9.0`). Releases are tagged `v<version>`; the CI pipeline builds and publishes the standard artifacts plus the optional Desktop HQ zip. Version bumps follow [Semantic Versioning](https://semver.org/). See [`docs/RELEASING.md`](docs/RELEASING.md) and [`README.md`](README.md) for commands.
|
||||||
|
|
||||||
## Design Direction
|
## Design Direction
|
||||||
|
|
||||||
|
|||||||
@@ -38,9 +38,8 @@ precision walk, the Joke Shop alley, the TV Studio arrival, and the prop lineup.
|
|||||||
> Downloads are public (no sign-in required) since the host's instance-wide
|
> Downloads are public (no sign-in required) since the host's instance-wide
|
||||||
> `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31.
|
> `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31.
|
||||||
>
|
>
|
||||||
> **Latest: v0.7.3** — mouse-look input reliability fixes, plus the v0.7.2
|
> **Latest: v0.9.0** — production-quality audio/accessibility settings, a
|
||||||
> cost-aware OpenRouter judge, optional Forward+ Desktop HQ build, distinct
|
> 24-NPC performance baseline and the v0.8 tutorial/campaign/resume pass.
|
||||||
> venues and fitted NPC gear.
|
|
||||||
> Releases are built automatically when a `vX.Y.Z` tag is pushed
|
> Releases are built automatically when a `vX.Y.Z` tag is pushed
|
||||||
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
|
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
|
||||||
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
|
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
|
||||||
@@ -119,11 +118,10 @@ Full setup, every run/test/smoke command, and the controls table are in
|
|||||||
|
|
||||||
## Development Status
|
## Development Status
|
||||||
|
|
||||||
v0.7.2 ships the cost-aware OpenRouter judge and the optional Desktop HQ build.
|
v0.8.0 adds the first-shift tutorial, campaign shell, checkpoint resume,
|
||||||
The three venues now have distinct geometry, fitted NPC gear, the Blender asset
|
difficulty presets and an in-game Help overlay on top of the v0.7.x venue, gear,
|
||||||
library and the post-v0.6.0 realism pass. See
|
judge and Desktop HQ work. See [`docs/development-status.md`](docs/development-status.md)
|
||||||
[`docs/development-status.md`](docs/development-status.md) for the implemented
|
for the implemented list and [`TODO.md`](TODO.md) for the v1.0.0 roadmap. Manual
|
||||||
list, and [`TODO.md`](TODO.md) for the v1.0.0 gap analysis and roadmap. Manual
|
|
||||||
verification uses the [`docs/smoke-checklist.md`](docs/smoke-checklist.md).
|
verification uses the [`docs/smoke-checklist.md`](docs/smoke-checklist.md).
|
||||||
|
|
||||||
## Development Workflow
|
## Development Workflow
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ see `AGENTS.md` for the workflow and `MEMORY.md` for the decisions behind these
|
|||||||
|
|
||||||
## Gap Analysis and v1.0.0 Roadmap
|
## Gap Analysis and v1.0.0 Roadmap
|
||||||
|
|
||||||
Baseline: **v0.7.2** (2026-11-01). Three playable venues, a complete escalation/access/radio
|
Baseline: **v0.8.1** (2026-11-01). Three playable venues, tutorial/campaign/checkpoint resume,
|
||||||
loop, shift review, a Blender character/gear/prop library, 231 headless + 94 Python tests, a
|
difficulty/help, a complete escalation/access/radio loop, shift review, the Blender asset library,
|
||||||
13/13 clean screen-design judge and an optional Forward+ Desktop HQ build. This is a strong
|
249 headless + 94 Python tests, a clean screen-design judge and an optional Forward+ Desktop HQ
|
||||||
vertical slice, not yet a 1.0 product.
|
build. This is a strong first-hour vertical slice, not yet a 1.0 product.
|
||||||
|
|
||||||
### Documentation audit (completed 2026-11-01)
|
### Documentation audit (completed 2026-11-01)
|
||||||
|
|
||||||
@@ -47,149 +47,6 @@ vertical slice, not yet a 1.0 product.
|
|||||||
| Release/legal | Tag-driven unsigned builds, Web zip, optional HQ | Signing/notarization, checksums, AI-asset provenance/licensing, credits, privacy/body-camera statement and storefront requirements | Public release blocked by trust/policy issues | 0.9.5 |
|
| Release/legal | Tag-driven unsigned builds, Web zip, optional HQ | Signing/notarization, checksums, AI-asset provenance/licensing, credits, privacy/body-camera statement and storefront requirements | Public release blocked by trust/policy issues | 0.9.5 |
|
||||||
| Documentation | Layered docs consolidated; drift was found | Keep changelog/status/roadmap/ADR links synchronized per release; add contributor architecture tour | Docs become stale again | Continuous |
|
| Documentation | Layered docs consolidated; drift was found | Keep changelog/status/roadmap/ADR links synchronized per release; add contributor architecture tour | Docs become stale again | Continuous |
|
||||||
|
|
||||||
## Milestone 0.8.0 — First hour and content depth
|
|
||||||
|
|
||||||
Goal: a new player can learn the game inside the game, play a short campaign across the three
|
|
||||||
venues, and see enough authored variety that a second shift does not feel like a replay.
|
|
||||||
|
|
||||||
### 0.8.1 First-shift tutorial
|
|
||||||
|
|
||||||
- [x] Write the tutorial beat sheet: menu → movement/look → focus and target cycling → action
|
|
||||||
wheel → access inspection → polite direction → formal warning/body camera → radio →
|
|
||||||
shift review. Eight beats and a safe compliant visitor are specified in
|
|
||||||
[`docs/tutorial.md`](docs/tutorial.md).
|
|
||||||
- [x] Add a tutorial state machine independent of the normal shift FSM. `Tutorial` in
|
|
||||||
`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.
|
|
||||||
|
|
||||||
### 0.8.2 Campaign and session shell
|
|
||||||
|
|
||||||
- [ ] Define campaign data separately from `VenueScenario`: venue order, unlock conditions,
|
|
||||||
shift number, selected difficulty and completion state.
|
|
||||||
- [ ] Add a campaign-select/continue screen that shows the next shift, venue, policy summary and
|
|
||||||
previous result; keep the existing debug venue cycle separate.
|
|
||||||
- [ ] Add a "Continue shift" path with an explicit resume point; define whether resume restores
|
|
||||||
the exact paused shift or the last safe checkpoint.
|
|
||||||
- [ ] Add a post-shift screen that names the next objective and offers retry, continue or return
|
|
||||||
to the menu. Do not silently reset progression on failure.
|
|
||||||
- [ ] Persist campaign state and expose a reset-campaign action with confirmation.
|
|
||||||
- [ ] Add deterministic tests for fresh campaign, resume, completed venue and failed/retried shift.
|
|
||||||
|
|
||||||
### 0.8.3 Venue content matrix
|
|
||||||
|
|
||||||
- [ ] Add a content matrix per venue with columns for phase, event kind (routine/scripted/edge/
|
|
||||||
recovery/threat), NPC type, intent, access artifact, expected escalation ceiling, failure/
|
|
||||||
recovery path and spawn weight.
|
|
||||||
- [ ] Add a validation test that every event references an existing profile, location and phase;
|
|
||||||
reject duplicate IDs and unreachable waypoints at load time.
|
|
||||||
- [ ] Expand each venue toward at least 12 authored events and 6 reusable routine variants,
|
|
||||||
covering all four shift phases; prioritize repeatable combinations over one-off set pieces.
|
|
||||||
- [ ] Add a no-repeat rule and phase-aware weighting so consecutive shifts do not replay the same
|
|
||||||
scripted event, while rare threats remain genuinely rare.
|
|
||||||
- [ ] Add recovery events after player mistakes: a refused authorized guest returns, a manager
|
|
||||||
intervention, a crowd-calming opportunity, or a second chance to explain a policy.
|
|
||||||
- [ ] Add a content preview/debug list showing the next eligible events and why they were chosen.
|
|
||||||
- [ ] Add tests for phase gating, no-repeat selection, recovery eligibility and event reachability.
|
|
||||||
|
|
||||||
### 0.8.4 NPC dialogue and negotiation
|
|
||||||
|
|
||||||
- [ ] Define a dialogue data format for intent-specific and context-specific lines: greeting,
|
|
||||||
refusal, de-escalation response, recognition callback, group reaction and resolution.
|
|
||||||
- [ ] Expand `NpcProfile` dialogue beyond one-line replies and select lines deterministically from
|
|
||||||
seed, prior interaction and current escalation level; no runtime LLM dependency.
|
|
||||||
- [ ] Wire Ask/Reason, polite direction, warning, blocking, recognition and inspection outcomes to
|
|
||||||
the new lines and log entries, with visible differences between comply, argue and refuse.
|
|
||||||
- [ ] Add a small set of group reactions so nearby NPCs respond to a raised voice, a warning, a
|
|
||||||
responder or a crowd panic without every NPC speaking at once.
|
|
||||||
- [ ] Add placeholder-safe localized strings and tests for deterministic selection, missing lines
|
|
||||||
and dialogue length in the HUD/log.
|
|
||||||
- [ ] Run a short dialogue readability pass on the three venues and replace repeated phrases.
|
|
||||||
|
|
||||||
### 0.8.5 Objective, policy and debrief layer
|
|
||||||
|
|
||||||
- [ ] Add a compact venue-policy card (accepted artifacts, door rules, emergency threshold) that
|
|
||||||
is available from the help overlay and the shift-review screen.
|
|
||||||
- [ ] Add an in-shift "current objective" line when the scenario phase changes, without turning
|
|
||||||
the HUD into a quest log.
|
|
||||||
- [ ] After an incident, record and display the evidence checked, the chosen escalation level and
|
|
||||||
the reason the response was judged fair, weak or excessive.
|
|
||||||
- [ ] Add a first-shift completion summary that explains the grade in plain language and points to
|
|
||||||
the specific decision(s) that mattered.
|
|
||||||
- [ ] Add tests for policy-card content, debrief evidence ordering and unknown/missing evidence.
|
|
||||||
|
|
||||||
### 0.8.6 Save, resume and settings
|
|
||||||
|
|
||||||
- [ ] Define a versioned save schema covering settings, tutorial completion, campaign progress,
|
|
||||||
difficulty and resumable shift state; document what is deliberately not serialized.
|
|
||||||
- [ ] Implement atomic writes, corruption detection and safe fallback to defaults or the last
|
|
||||||
completed shift; never overwrite a valid save with a partial one.
|
|
||||||
- [ ] Add save migration from the current settings/shift-history format and a test fixture for the
|
|
||||||
previous schema.
|
|
||||||
- [ ] Add mid-shift resume that restores RNG state, elapsed time, NPC/incident state and the door
|
|
||||||
state consistently; verify a resumed shift can reach its review.
|
|
||||||
- [ ] Add persistence tests for fresh install, corrupt save, version migration, completed shift and
|
|
||||||
resumed shift.
|
|
||||||
|
|
||||||
### 0.8.7 Difficulty, pacing and help
|
|
||||||
|
|
||||||
- [ ] Add difficulty presets that only adjust spawn rate, tolerance, report strictness and event
|
|
||||||
weights; the de-escalation-first rules and emergency rarity must not change.
|
|
||||||
- [ ] Expose difficulty in the campaign/settings UI with the current build as the default "normal"
|
|
||||||
preset and a clear description of each change.
|
|
||||||
- [ ] Add a new-player help overlay separate from the HUD: controls, focus/wheel flow, access
|
|
||||||
evidence, body camera, radio and shift review.
|
|
||||||
- [ ] Add a searchable/filterable controls reference and keep it synchronized with input bindings.
|
|
||||||
- [ ] Add a readability pass for first-shift HUD density; hide advanced debug-only information by
|
|
||||||
default.
|
|
||||||
- [ ] Add tests for difficulty defaults, settings persistence and help/controls coverage of every
|
|
||||||
named input action.
|
|
||||||
|
|
||||||
### 0.8.8 Verification and exit criteria
|
|
||||||
|
|
||||||
- [ ] Add headless tests for tutorial progression, campaign state, content-matrix validation,
|
|
||||||
dialogue selection, save migration and difficulty defaults.
|
|
||||||
- [ ] Add windowed/headless smokes for a complete tutorial, a resumed shift, a campaign venue
|
|
||||||
transition and the help overlay.
|
|
||||||
- [ ] Run a manual first-hour test with at least three people unfamiliar with the project; record
|
|
||||||
where they get stuck and fix P0/P1 onboarding issues before closing the milestone.
|
|
||||||
- [ ] Re-run the full headless suite, Python tooling and smoke checklist; update
|
|
||||||
`docs/development-status.md`, architecture links, changelog and README where behavior changed.
|
|
||||||
- [ ] Exit: a first-time player can complete the tutorial and a first real shift without external
|
|
||||||
documentation; each venue has a repeatable content matrix; save/resume survives restart;
|
|
||||||
the 0.8 test matrix is green.
|
|
||||||
|
|
||||||
## Milestone 0.9.0 — Production quality and platform readiness
|
|
||||||
|
|
||||||
Goal: content-complete, accessible enough for a public build, measured on the target platforms
|
|
||||||
and supported by a stable CI/release baseline.
|
|
||||||
|
|
||||||
- [ ] Animation/presentation pass: negotiation/reaction poses and transitions, readable faces in
|
|
||||||
portrait, resolved camera height, and no coat/skirt leg clipping in normal movement.
|
|
||||||
- [ ] Audio pass: music/ambience direction, event SFX, radio/crowd mix, caption hooks and a VO
|
|
||||||
decision with production scope.
|
|
||||||
- [ ] Localization pass: all player-facing strings externalized, pseudo-locale layout test, font
|
|
||||||
fallback and a contributor translation workflow.
|
|
||||||
- [ ] Accessibility pass: subtitles/captions, colour-independent access/escalation cues, motion
|
|
||||||
and flash options, full keyboard/gamepad parity and documented assist options.
|
|
||||||
- [ ] Performance pass: profile 24 live NPCs, all venues, Web and Desktop HQ; record frame-time,
|
|
||||||
draw-call and memory budgets; add regression checks where automation is possible.
|
|
||||||
- [ ] QA pass: automated end-to-end shift for each venue, full smoke matrix, screenshot/judge
|
|
||||||
baselines, and a documented manual judge budget/model pin.
|
|
||||||
- [ ] Resolve the Godot baseline: choose one supported version (or an explicit 4.3-CI/4.7-local
|
|
||||||
matrix), align export templates and visual smoke results, and document the policy.
|
|
||||||
- [ ] Exit: production content is feature-complete, accessibility/localization acceptance criteria
|
|
||||||
pass, target hardware matrix is measured, and no P0/P1 defect remains open.
|
|
||||||
|
|
||||||
## Milestone 0.9.5 — Release candidate
|
## Milestone 0.9.5 — Release candidate
|
||||||
|
|
||||||
Goal: freeze content, prove it with external players, clear release/legal blockers and verify
|
Goal: freeze content, prove it with external players, clear release/legal blockers and verify
|
||||||
|
|||||||
+13
-1
@@ -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,154 +1,47 @@
|
|||||||
[gd_resource type="Resource" script_class="VenueScenario" load_steps=17 format=3]
|
[gd_resource type="Resource" script_class="VenueScenario" format=3]
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_2u28i"]
|
[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_c5x6v"]
|
||||||
[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_acokf"]
|
[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_7qk8j"]
|
||||||
[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_mqiqi"]
|
[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_lvgx2"]
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_i8fga"]
|
[sub_resource type="Resource" id="Resource_xf4wh"]
|
||||||
script = ExtResource("1_2u28i")
|
script = ExtResource("1_c5x6v")
|
||||||
required_artifacts = {
|
required_artifacts = {
|
||||||
1: true
|
1: true
|
||||||
}
|
}
|
||||||
grant_if_any_required = false
|
|
||||||
minimum_age = 0
|
|
||||||
require_id = false
|
|
||||||
wand_screening_required = false
|
|
||||||
policy_summary = "Private property. Entry requires recognition or a valid wristband."
|
policy_summary = "Private property. Entry requires recognition or a valid wristband."
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_c7lhe"]
|
[sub_resource type="Resource" id="Resource_cgh52"]
|
||||||
script = ExtResource("2_acokf")
|
script = ExtResource("2_7qk8j")
|
||||||
id = "tourist"
|
id = "tourist"
|
||||||
display_name = "Tourist"
|
display_name = "Tourist"
|
||||||
npc_type = 0
|
|
||||||
temperament = 0.1
|
temperament = 0.1
|
||||||
base_compliance = 0.9
|
base_compliance = 0.9
|
||||||
return_probability = 0.0
|
|
||||||
reactions_by_level = {}
|
|
||||||
default_reactions = {
|
|
||||||
1: {
|
|
||||||
0: 80,
|
|
||||||
2: 8,
|
|
||||||
3: 12
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
0: 70,
|
|
||||||
1: 10,
|
|
||||||
2: 12,
|
|
||||||
4: 8
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
0: 55,
|
|
||||||
1: 15,
|
|
||||||
2: 20,
|
|
||||||
4: 10
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
1: 65,
|
|
||||||
2: 15,
|
|
||||||
7: 20
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
1: 30,
|
|
||||||
5: 40,
|
|
||||||
7: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."])
|
dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."])
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_0t6ar"]
|
[sub_resource type="Resource" id="Resource_qe635"]
|
||||||
script = ExtResource("2_acokf")
|
script = ExtResource("2_7qk8j")
|
||||||
id = "confused_visitor"
|
id = "confused_visitor"
|
||||||
display_name = "Confused Visitor"
|
display_name = "Confused Visitor"
|
||||||
npc_type = 3
|
npc_type = 3
|
||||||
temperament = 0.2
|
temperament = 0.2
|
||||||
base_compliance = 0.85
|
base_compliance = 0.85
|
||||||
return_probability = 0.0
|
|
||||||
reactions_by_level = {}
|
|
||||||
default_reactions = {
|
|
||||||
1: {
|
|
||||||
0: 80,
|
|
||||||
2: 8,
|
|
||||||
3: 12
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
0: 70,
|
|
||||||
1: 10,
|
|
||||||
2: 12,
|
|
||||||
4: 8
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
0: 55,
|
|
||||||
1: 15,
|
|
||||||
2: 20,
|
|
||||||
4: 10
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
1: 65,
|
|
||||||
2: 15,
|
|
||||||
7: 20
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
1: 30,
|
|
||||||
5: 40,
|
|
||||||
7: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."])
|
dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."])
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_wyv8p"]
|
[sub_resource type="Resource" id="Resource_u5nrp"]
|
||||||
script = ExtResource("2_acokf")
|
script = ExtResource("2_7qk8j")
|
||||||
id = "delivery"
|
id = "delivery"
|
||||||
display_name = "Delivery Worker"
|
display_name = "Delivery Worker"
|
||||||
npc_type = 10
|
npc_type = 10
|
||||||
temperament = 0.25
|
|
||||||
base_compliance = 0.8
|
|
||||||
return_probability = 0.0
|
|
||||||
reactions_by_level = {}
|
|
||||||
default_reactions = {
|
|
||||||
1: {
|
|
||||||
0: 80,
|
|
||||||
2: 8,
|
|
||||||
3: 12
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
0: 70,
|
|
||||||
1: 10,
|
|
||||||
2: 12,
|
|
||||||
4: 8
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
0: 55,
|
|
||||||
1: 15,
|
|
||||||
2: 20,
|
|
||||||
4: 10
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
1: 65,
|
|
||||||
2: 15,
|
|
||||||
7: 20
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
1: 30,
|
|
||||||
5: 40,
|
|
||||||
7: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String](["Where should I leave this?"])
|
dialogue_lines = Array[String](["Where should I leave this?"])
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_v2hc4"]
|
[sub_resource type="Resource" id="Resource_dsavb"]
|
||||||
script = ExtResource("2_acokf")
|
script = ExtResource("2_7qk8j")
|
||||||
id = "street_performer"
|
id = "street_performer"
|
||||||
display_name = "Street Performer"
|
display_name = "Street Performer"
|
||||||
npc_type = 1
|
npc_type = 1
|
||||||
temperament = 0.45
|
temperament = 0.45
|
||||||
base_compliance = 0.55
|
base_compliance = 0.55
|
||||||
return_probability = 0.0
|
|
||||||
reactions_by_level = {
|
reactions_by_level = {
|
||||||
1: {
|
1: {
|
||||||
0: 55,
|
0: 55,
|
||||||
@@ -156,47 +49,14 @@ reactions_by_level = {
|
|||||||
3: 30
|
3: 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default_reactions = {
|
|
||||||
1: {
|
|
||||||
0: 80,
|
|
||||||
2: 8,
|
|
||||||
3: 12
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
0: 70,
|
|
||||||
1: 10,
|
|
||||||
2: 12,
|
|
||||||
4: 8
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
0: 55,
|
|
||||||
1: 15,
|
|
||||||
2: 20,
|
|
||||||
4: 10
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
1: 65,
|
|
||||||
2: 15,
|
|
||||||
7: 20
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
1: 30,
|
|
||||||
5: 40,
|
|
||||||
7: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String]([])
|
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_lg4td"]
|
[sub_resource type="Resource" id="Resource_sexiy"]
|
||||||
script = ExtResource("2_acokf")
|
script = ExtResource("2_7qk8j")
|
||||||
id = "influencer"
|
id = "influencer"
|
||||||
display_name = "Influencer"
|
display_name = "Influencer"
|
||||||
npc_type = 2
|
npc_type = 2
|
||||||
temperament = 0.5
|
temperament = 0.5
|
||||||
base_compliance = 0.5
|
base_compliance = 0.5
|
||||||
return_probability = 0.0
|
|
||||||
reactions_by_level = {
|
reactions_by_level = {
|
||||||
1: {
|
1: {
|
||||||
0: 40,
|
0: 40,
|
||||||
@@ -218,47 +78,15 @@ reactions_by_level = {
|
|||||||
8: 25
|
8: 25
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default_reactions = {
|
|
||||||
1: {
|
|
||||||
0: 80,
|
|
||||||
2: 8,
|
|
||||||
3: 12
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
0: 70,
|
|
||||||
1: 10,
|
|
||||||
2: 12,
|
|
||||||
4: 8
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
0: 55,
|
|
||||||
1: 15,
|
|
||||||
2: 20,
|
|
||||||
4: 10
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
1: 65,
|
|
||||||
2: 15,
|
|
||||||
7: 20
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
1: 30,
|
|
||||||
5: 40,
|
|
||||||
7: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String](["This is public sidewalk, you can't stop me from filming."])
|
dialogue_lines = Array[String](["This is public sidewalk, you can't stop me from filming."])
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_ijl2s"]
|
[sub_resource type="Resource" id="Resource_ghqd8"]
|
||||||
script = ExtResource("2_acokf")
|
script = ExtResource("2_7qk8j")
|
||||||
id = "intoxicated"
|
id = "intoxicated"
|
||||||
display_name = "Intoxicated Pedestrian"
|
display_name = "Intoxicated Pedestrian"
|
||||||
npc_type = 4
|
npc_type = 4
|
||||||
temperament = 0.6
|
temperament = 0.6
|
||||||
base_compliance = 0.4
|
base_compliance = 0.4
|
||||||
return_probability = 0.0
|
|
||||||
reactions_by_level = {
|
reactions_by_level = {
|
||||||
1: {
|
1: {
|
||||||
0: 30,
|
0: 30,
|
||||||
@@ -272,41 +100,10 @@ reactions_by_level = {
|
|||||||
4: 10
|
4: 10
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default_reactions = {
|
|
||||||
1: {
|
|
||||||
0: 80,
|
|
||||||
2: 8,
|
|
||||||
3: 12
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
0: 70,
|
|
||||||
1: 10,
|
|
||||||
2: 12,
|
|
||||||
4: 8
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
0: 55,
|
|
||||||
1: 15,
|
|
||||||
2: 20,
|
|
||||||
4: 10
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
1: 65,
|
|
||||||
2: 15,
|
|
||||||
7: 20
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
1: 30,
|
|
||||||
5: 40,
|
|
||||||
7: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reaction_delay = 1.8
|
reaction_delay = 1.8
|
||||||
dialogue_lines = Array[String]([])
|
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_eycmk"]
|
[sub_resource type="Resource" id="Resource_g8peq"]
|
||||||
script = ExtResource("2_acokf")
|
script = ExtResource("2_7qk8j")
|
||||||
id = "trespasser"
|
id = "trespasser"
|
||||||
display_name = "Trespasser"
|
display_name = "Trespasser"
|
||||||
npc_type = 5
|
npc_type = 5
|
||||||
@@ -341,41 +138,10 @@ reactions_by_level = {
|
|||||||
7: 20
|
7: 20
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default_reactions = {
|
|
||||||
1: {
|
|
||||||
0: 80,
|
|
||||||
2: 8,
|
|
||||||
3: 12
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
0: 70,
|
|
||||||
1: 10,
|
|
||||||
2: 12,
|
|
||||||
4: 8
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
0: 55,
|
|
||||||
1: 15,
|
|
||||||
2: 20,
|
|
||||||
4: 10
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
1: 65,
|
|
||||||
2: 15,
|
|
||||||
7: 20
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
1: 30,
|
|
||||||
5: 40,
|
|
||||||
7: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String](["I'm just here to see a friend, come on.", "I'll be inside for ten seconds."])
|
dialogue_lines = Array[String](["I'm just here to see a friend, come on.", "I'll be inside for ten seconds."])
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_ggtpw"]
|
[sub_resource type="Resource" id="Resource_4xl5p"]
|
||||||
script = ExtResource("2_acokf")
|
script = ExtResource("2_7qk8j")
|
||||||
id = "mental_crisis"
|
id = "mental_crisis"
|
||||||
display_name = "Person in Crisis"
|
display_name = "Person in Crisis"
|
||||||
npc_type = 7
|
npc_type = 7
|
||||||
@@ -411,47 +177,15 @@ reactions_by_level = {
|
|||||||
7: 15
|
7: 15
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default_reactions = {
|
|
||||||
1: {
|
|
||||||
0: 80,
|
|
||||||
2: 8,
|
|
||||||
3: 12
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
0: 70,
|
|
||||||
1: 10,
|
|
||||||
2: 12,
|
|
||||||
4: 8
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
0: 55,
|
|
||||||
1: 15,
|
|
||||||
2: 20,
|
|
||||||
4: 10
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
1: 65,
|
|
||||||
2: 15,
|
|
||||||
7: 20
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
1: 30,
|
|
||||||
5: 40,
|
|
||||||
7: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reaction_delay = 2.4
|
reaction_delay = 2.4
|
||||||
dialogue_lines = Array[String]([])
|
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_oa64f"]
|
[sub_resource type="Resource" id="Resource_y6282"]
|
||||||
script = ExtResource("2_acokf")
|
script = ExtResource("2_7qk8j")
|
||||||
id = "violent_threat"
|
id = "violent_threat"
|
||||||
display_name = "Violent Threat"
|
display_name = "Violent Threat"
|
||||||
npc_type = 6
|
npc_type = 6
|
||||||
temperament = 0.95
|
temperament = 0.95
|
||||||
base_compliance = 0.05
|
base_compliance = 0.05
|
||||||
return_probability = 0.0
|
|
||||||
reactions_by_level = {
|
reactions_by_level = {
|
||||||
1: {
|
1: {
|
||||||
2: 25,
|
2: 25,
|
||||||
@@ -479,41 +213,9 @@ reactions_by_level = {
|
|||||||
7: 30
|
7: 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default_reactions = {
|
|
||||||
1: {
|
|
||||||
0: 80,
|
|
||||||
2: 8,
|
|
||||||
3: 12
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
0: 70,
|
|
||||||
1: 10,
|
|
||||||
2: 12,
|
|
||||||
4: 8
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
0: 55,
|
|
||||||
1: 15,
|
|
||||||
2: 20,
|
|
||||||
4: 10
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
1: 65,
|
|
||||||
2: 15,
|
|
||||||
7: 20
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
1: 30,
|
|
||||||
5: 40,
|
|
||||||
7: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String]([])
|
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_7t7dh"]
|
[sub_resource type="Resource" id="Resource_75a0r"]
|
||||||
script = ExtResource("2_acokf")
|
script = ExtResource("2_7qk8j")
|
||||||
id = "protester"
|
id = "protester"
|
||||||
display_name = "Protester"
|
display_name = "Protester"
|
||||||
npc_type = 12
|
npc_type = 12
|
||||||
@@ -547,66 +249,28 @@ reactions_by_level = {
|
|||||||
7: 20
|
7: 20
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default_reactions = {
|
|
||||||
1: {
|
|
||||||
0: 80,
|
|
||||||
2: 8,
|
|
||||||
3: 12
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
0: 70,
|
|
||||||
1: 10,
|
|
||||||
2: 12,
|
|
||||||
4: 8
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
0: 55,
|
|
||||||
1: 15,
|
|
||||||
2: 20,
|
|
||||||
4: 10
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
1: 65,
|
|
||||||
2: 15,
|
|
||||||
7: 20
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
1: 30,
|
|
||||||
5: 40,
|
|
||||||
7: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String](["I have every right to be on public sidewalk.", "You can't touch me, this is a lawful protest."])
|
dialogue_lines = Array[String](["I have every right to be on public sidewalk.", "You can't touch me, this is a lawful protest."])
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_8tj1k"]
|
[sub_resource type="Resource" id="Resource_ix35t"]
|
||||||
script = ExtResource("2_acokf")
|
script = ExtResource("2_7qk8j")
|
||||||
id = "authorized_staff"
|
id = "authorized_staff"
|
||||||
display_name = "Staff Member"
|
display_name = "Staff Member"
|
||||||
npc_type = 8
|
npc_type = 8
|
||||||
temperament = 0.15
|
temperament = 0.15
|
||||||
base_compliance = 1.0
|
base_compliance = 1.0
|
||||||
return_probability = 0.0
|
|
||||||
reactions_by_level = {}
|
|
||||||
default_reactions = {
|
default_reactions = {
|
||||||
1: {
|
1: {
|
||||||
0: 100
|
0: 100
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String]([])
|
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_tnpxm"]
|
[sub_resource type="Resource" id="Resource_0mcg3"]
|
||||||
script = ExtResource("2_acokf")
|
script = ExtResource("2_7qk8j")
|
||||||
id = "authorized_guest"
|
id = "authorized_guest"
|
||||||
display_name = "Authorized Guest"
|
display_name = "Authorized Guest"
|
||||||
npc_type = 9
|
npc_type = 9
|
||||||
temperament = 0.2
|
temperament = 0.2
|
||||||
base_compliance = 0.95
|
base_compliance = 0.95
|
||||||
return_probability = 0.0
|
|
||||||
reactions_by_level = {}
|
|
||||||
default_reactions = {
|
default_reactions = {
|
||||||
1: {
|
1: {
|
||||||
0: 92,
|
0: 92,
|
||||||
@@ -617,16 +281,13 @@ default_reactions = {
|
|||||||
2: 12
|
2: 12
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String]([])
|
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[resource]
|
[resource]
|
||||||
script = ExtResource("3_mqiqi")
|
script = ExtResource("3_lvgx2")
|
||||||
id = "hollywood_building"
|
id = "hollywood_building"
|
||||||
display_name = "Hollywood Boulevard Building"
|
display_name = "Hollywood Boulevard Building"
|
||||||
description = "Steps and entrance of a private building off Hollywood Boulevard."
|
description = "Steps and entrance of a private building off Hollywood Boulevard."
|
||||||
access_policy = SubResource("Resource_i8fga")
|
access_policy = SubResource("Resource_xf4wh")
|
||||||
equipment = Array[String](["wristband_policy", "flashlight", "radio"])
|
equipment = Array[String](["wristband_policy", "flashlight", "radio"])
|
||||||
locations = {
|
locations = {
|
||||||
"entrance": Vector3(0, 0, 1),
|
"entrance": Vector3(0, 0, 1),
|
||||||
@@ -635,7 +296,7 @@ locations = {
|
|||||||
"sidewalk": Vector3(0, 0, 9),
|
"sidewalk": Vector3(0, 0, 9),
|
||||||
"steps_end": Vector3(0, 0, 3)
|
"steps_end": Vector3(0, 0, 3)
|
||||||
}
|
}
|
||||||
npc_profiles = Array[ExtResource("2_acokf")]([SubResource("Resource_c7lhe"), SubResource("Resource_0t6ar"), SubResource("Resource_wyv8p"), SubResource("Resource_v2hc4"), SubResource("Resource_lg4td"), SubResource("Resource_ijl2s"), SubResource("Resource_eycmk"), SubResource("Resource_ggtpw"), SubResource("Resource_oa64f"), SubResource("Resource_7t7dh"), SubResource("Resource_8tj1k"), SubResource("Resource_tnpxm")])
|
npc_profiles = Array[ExtResource("2_7qk8j")]([SubResource("Resource_cgh52"), SubResource("Resource_qe635"), SubResource("Resource_u5nrp"), SubResource("Resource_dsavb"), SubResource("Resource_sexiy"), SubResource("Resource_ghqd8"), SubResource("Resource_g8peq"), SubResource("Resource_4xl5p"), SubResource("Resource_y6282"), SubResource("Resource_75a0r"), SubResource("Resource_ix35t"), SubResource("Resource_0mcg3")])
|
||||||
event_pool = Array[Dictionary]([{
|
event_pool = Array[Dictionary]([{
|
||||||
"base_weight": 1.0,
|
"base_weight": 1.0,
|
||||||
"id": "tourist_photo",
|
"id": "tourist_photo",
|
||||||
@@ -774,6 +435,15 @@ event_pool = Array[Dictionary]([{
|
|||||||
"probability": 0.28,
|
"probability": 0.28,
|
||||||
"requires_emergency": true,
|
"requires_emergency": true,
|
||||||
"time_range_s": Vector2(760, 880)
|
"time_range_s": Vector2(760, 880)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "building_apology_return",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "recovery",
|
||||||
|
"npc_type": 9,
|
||||||
|
"phase": 2,
|
||||||
|
"probability": 0.22,
|
||||||
|
"time_range_s": Vector2(620, 800)
|
||||||
}])
|
}])
|
||||||
shift_phases = {
|
shift_phases = {
|
||||||
0: {
|
0: {
|
||||||
@@ -789,6 +459,3 @@ shift_phases = {
|
|||||||
"start_s": 700.0
|
"start_s": 700.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
shift_duration_s = 900.0
|
|
||||||
policy_summary = ""
|
|
||||||
door_staff_lines = {}
|
|
||||||
+53
-233
@@ -1,113 +1,59 @@
|
|||||||
[gd_resource type="Resource" script_class="VenueScenario" load_steps=13 format=3]
|
[gd_resource type="Resource" script_class="VenueScenario" format=3]
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_lxsgk"]
|
[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_3pn5m"]
|
||||||
[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_wfc6m"]
|
[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_l60ad"]
|
||||||
[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_8xm7a"]
|
[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_voh88"]
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_xha77"]
|
[sub_resource type="Resource" id="Resource_aal3l"]
|
||||||
script = ExtResource("1_lxsgk")
|
script = ExtResource("1_3pn5m")
|
||||||
required_artifacts = {
|
required_artifacts = {
|
||||||
2: true,
|
2: true,
|
||||||
3: true
|
3: true
|
||||||
}
|
}
|
||||||
grant_if_any_required = false
|
|
||||||
minimum_age = 21
|
minimum_age = 21
|
||||||
require_id = true
|
require_id = true
|
||||||
wand_screening_required = true
|
wand_screening_required = true
|
||||||
policy_summary = "Government ID and a valid ticket or reservation required. Carry items are screened."
|
policy_summary = "Government ID and a valid ticket or reservation required. Carry items are screened."
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_8ksyk"]
|
[sub_resource type="Resource" id="Resource_57xgi"]
|
||||||
script = ExtResource("2_wfc6m")
|
script = ExtResource("2_l60ad")
|
||||||
id = "tourist"
|
id = "tourist"
|
||||||
display_name = "Tourist"
|
display_name = "Tourist"
|
||||||
npc_type = 0
|
|
||||||
temperament = 0.1
|
temperament = 0.1
|
||||||
base_compliance = 0.9
|
base_compliance = 0.9
|
||||||
return_probability = 0.0
|
|
||||||
reactions_by_level = {}
|
|
||||||
default_reactions = {
|
|
||||||
1: {
|
|
||||||
0: 80,
|
|
||||||
2: 8,
|
|
||||||
3: 12
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
0: 70,
|
|
||||||
1: 10,
|
|
||||||
2: 12,
|
|
||||||
4: 8
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
0: 55,
|
|
||||||
1: 15,
|
|
||||||
2: 20,
|
|
||||||
4: 10
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
1: 65,
|
|
||||||
2: 15,
|
|
||||||
7: 20
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
1: 30,
|
|
||||||
5: 40,
|
|
||||||
7: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."])
|
dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."])
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_wt1op"]
|
[sub_resource type="Resource" id="Resource_r873e"]
|
||||||
script = ExtResource("2_wfc6m")
|
script = ExtResource("2_l60ad")
|
||||||
id = "confused_visitor"
|
id = "confused_visitor"
|
||||||
display_name = "Confused Visitor"
|
display_name = "Confused Visitor"
|
||||||
npc_type = 3
|
npc_type = 3
|
||||||
temperament = 0.2
|
temperament = 0.2
|
||||||
base_compliance = 0.85
|
base_compliance = 0.85
|
||||||
return_probability = 0.0
|
|
||||||
reactions_by_level = {}
|
|
||||||
default_reactions = {
|
|
||||||
1: {
|
|
||||||
0: 80,
|
|
||||||
2: 8,
|
|
||||||
3: 12
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
0: 70,
|
|
||||||
1: 10,
|
|
||||||
2: 12,
|
|
||||||
4: 8
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
0: 55,
|
|
||||||
1: 15,
|
|
||||||
2: 20,
|
|
||||||
4: 10
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
1: 65,
|
|
||||||
2: 15,
|
|
||||||
7: 20
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
1: 30,
|
|
||||||
5: 40,
|
|
||||||
7: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."])
|
dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."])
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_g1jr2"]
|
[sub_resource type="Resource" id="Resource_3xy2u"]
|
||||||
script = ExtResource("2_wfc6m")
|
script = ExtResource("2_l60ad")
|
||||||
|
id = "street_performer"
|
||||||
|
display_name = "Street Performer"
|
||||||
|
npc_type = 1
|
||||||
|
temperament = 0.45
|
||||||
|
base_compliance = 0.55
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 55,
|
||||||
|
2: 15,
|
||||||
|
3: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_50i1h"]
|
||||||
|
script = ExtResource("2_l60ad")
|
||||||
id = "influencer"
|
id = "influencer"
|
||||||
display_name = "Influencer"
|
display_name = "Influencer"
|
||||||
npc_type = 2
|
npc_type = 2
|
||||||
temperament = 0.5
|
temperament = 0.5
|
||||||
base_compliance = 0.5
|
base_compliance = 0.5
|
||||||
return_probability = 0.0
|
|
||||||
reactions_by_level = {
|
reactions_by_level = {
|
||||||
1: {
|
1: {
|
||||||
0: 40,
|
0: 40,
|
||||||
@@ -129,47 +75,15 @@ reactions_by_level = {
|
|||||||
8: 25
|
8: 25
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default_reactions = {
|
|
||||||
1: {
|
|
||||||
0: 80,
|
|
||||||
2: 8,
|
|
||||||
3: 12
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
0: 70,
|
|
||||||
1: 10,
|
|
||||||
2: 12,
|
|
||||||
4: 8
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
0: 55,
|
|
||||||
1: 15,
|
|
||||||
2: 20,
|
|
||||||
4: 10
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
1: 65,
|
|
||||||
2: 15,
|
|
||||||
7: 20
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
1: 30,
|
|
||||||
5: 40,
|
|
||||||
7: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String](["This is public sidewalk, you can't stop me from filming."])
|
dialogue_lines = Array[String](["This is public sidewalk, you can't stop me from filming."])
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_ys0b0"]
|
[sub_resource type="Resource" id="Resource_nsw77"]
|
||||||
script = ExtResource("2_wfc6m")
|
script = ExtResource("2_l60ad")
|
||||||
id = "intoxicated"
|
id = "intoxicated"
|
||||||
display_name = "Intoxicated Pedestrian"
|
display_name = "Intoxicated Pedestrian"
|
||||||
npc_type = 4
|
npc_type = 4
|
||||||
temperament = 0.6
|
temperament = 0.6
|
||||||
base_compliance = 0.4
|
base_compliance = 0.4
|
||||||
return_probability = 0.0
|
|
||||||
reactions_by_level = {
|
reactions_by_level = {
|
||||||
1: {
|
1: {
|
||||||
0: 30,
|
0: 30,
|
||||||
@@ -183,41 +97,10 @@ reactions_by_level = {
|
|||||||
4: 10
|
4: 10
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default_reactions = {
|
|
||||||
1: {
|
|
||||||
0: 80,
|
|
||||||
2: 8,
|
|
||||||
3: 12
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
0: 70,
|
|
||||||
1: 10,
|
|
||||||
2: 12,
|
|
||||||
4: 8
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
0: 55,
|
|
||||||
1: 15,
|
|
||||||
2: 20,
|
|
||||||
4: 10
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
1: 65,
|
|
||||||
2: 15,
|
|
||||||
7: 20
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
1: 30,
|
|
||||||
5: 40,
|
|
||||||
7: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reaction_delay = 1.8
|
reaction_delay = 1.8
|
||||||
dialogue_lines = Array[String]([])
|
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_bv0jw"]
|
[sub_resource type="Resource" id="Resource_20knl"]
|
||||||
script = ExtResource("2_wfc6m")
|
script = ExtResource("2_l60ad")
|
||||||
id = "trespasser"
|
id = "trespasser"
|
||||||
display_name = "Trespasser"
|
display_name = "Trespasser"
|
||||||
npc_type = 5
|
npc_type = 5
|
||||||
@@ -252,47 +135,15 @@ reactions_by_level = {
|
|||||||
7: 20
|
7: 20
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default_reactions = {
|
|
||||||
1: {
|
|
||||||
0: 80,
|
|
||||||
2: 8,
|
|
||||||
3: 12
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
0: 70,
|
|
||||||
1: 10,
|
|
||||||
2: 12,
|
|
||||||
4: 8
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
0: 55,
|
|
||||||
1: 15,
|
|
||||||
2: 20,
|
|
||||||
4: 10
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
1: 65,
|
|
||||||
2: 15,
|
|
||||||
7: 20
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
1: 30,
|
|
||||||
5: 40,
|
|
||||||
7: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String](["I'm just here to see a friend, come on.", "I'll be inside for ten seconds."])
|
dialogue_lines = Array[String](["I'm just here to see a friend, come on.", "I'll be inside for ten seconds."])
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_7vgx3"]
|
[sub_resource type="Resource" id="Resource_rivis"]
|
||||||
script = ExtResource("2_wfc6m")
|
script = ExtResource("2_l60ad")
|
||||||
id = "violent_threat"
|
id = "violent_threat"
|
||||||
display_name = "Violent Threat"
|
display_name = "Violent Threat"
|
||||||
npc_type = 6
|
npc_type = 6
|
||||||
temperament = 0.95
|
temperament = 0.95
|
||||||
base_compliance = 0.05
|
base_compliance = 0.05
|
||||||
return_probability = 0.0
|
|
||||||
reactions_by_level = {
|
reactions_by_level = {
|
||||||
1: {
|
1: {
|
||||||
2: 25,
|
2: 25,
|
||||||
@@ -320,66 +171,27 @@ reactions_by_level = {
|
|||||||
7: 30
|
7: 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default_reactions = {
|
|
||||||
1: {
|
|
||||||
0: 80,
|
|
||||||
2: 8,
|
|
||||||
3: 12
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
0: 70,
|
|
||||||
1: 10,
|
|
||||||
2: 12,
|
|
||||||
4: 8
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
0: 55,
|
|
||||||
1: 15,
|
|
||||||
2: 20,
|
|
||||||
4: 10
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
1: 65,
|
|
||||||
2: 15,
|
|
||||||
7: 20
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
1: 30,
|
|
||||||
5: 40,
|
|
||||||
7: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String]([])
|
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_x43ed"]
|
[sub_resource type="Resource" id="Resource_1dvkp"]
|
||||||
script = ExtResource("2_wfc6m")
|
script = ExtResource("2_l60ad")
|
||||||
id = "authorized_staff"
|
id = "authorized_staff"
|
||||||
display_name = "Staff Member"
|
display_name = "Staff Member"
|
||||||
npc_type = 8
|
npc_type = 8
|
||||||
temperament = 0.15
|
temperament = 0.15
|
||||||
base_compliance = 1.0
|
base_compliance = 1.0
|
||||||
return_probability = 0.0
|
|
||||||
reactions_by_level = {}
|
|
||||||
default_reactions = {
|
default_reactions = {
|
||||||
1: {
|
1: {
|
||||||
0: 100
|
0: 100
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String]([])
|
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_07lsg"]
|
[sub_resource type="Resource" id="Resource_7wsm0"]
|
||||||
script = ExtResource("2_wfc6m")
|
script = ExtResource("2_l60ad")
|
||||||
id = "authorized_guest"
|
id = "authorized_guest"
|
||||||
display_name = "Authorized Guest"
|
display_name = "Authorized Guest"
|
||||||
npc_type = 9
|
npc_type = 9
|
||||||
temperament = 0.2
|
temperament = 0.2
|
||||||
base_compliance = 0.95
|
base_compliance = 0.95
|
||||||
return_probability = 0.0
|
|
||||||
reactions_by_level = {}
|
|
||||||
default_reactions = {
|
default_reactions = {
|
||||||
1: {
|
1: {
|
||||||
0: 92,
|
0: 92,
|
||||||
@@ -390,16 +202,13 @@ default_reactions = {
|
|||||||
2: 12
|
2: 12
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String]([])
|
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[resource]
|
[resource]
|
||||||
script = ExtResource("3_8xm7a")
|
script = ExtResource("3_voh88")
|
||||||
id = "joke_shop"
|
id = "joke_shop"
|
||||||
display_name = "The Joke Shop"
|
display_name = "The Joke Shop"
|
||||||
description = "Comedy club door: IDs, reservations, and wand screening."
|
description = "Comedy club door: IDs, reservations, and wand screening."
|
||||||
access_policy = SubResource("Resource_xha77")
|
access_policy = SubResource("Resource_aal3l")
|
||||||
equipment = Array[String](["id_check", "guest_list", "wand", "radio"])
|
equipment = Array[String](["id_check", "guest_list", "wand", "radio"])
|
||||||
locations = {
|
locations = {
|
||||||
"door_area": Vector3(0, 0, 3),
|
"door_area": Vector3(0, 0, 3),
|
||||||
@@ -409,7 +218,7 @@ locations = {
|
|||||||
"sidewalk": Vector3(0, 0, 9),
|
"sidewalk": Vector3(0, 0, 9),
|
||||||
"steps_end": Vector3(0, 0, 3)
|
"steps_end": Vector3(0, 0, 3)
|
||||||
}
|
}
|
||||||
npc_profiles = Array[ExtResource("2_wfc6m")]([SubResource("Resource_8ksyk"), SubResource("Resource_wt1op"), SubResource("Resource_g1jr2"), SubResource("Resource_ys0b0"), SubResource("Resource_bv0jw"), SubResource("Resource_7vgx3"), SubResource("Resource_x43ed"), SubResource("Resource_07lsg")])
|
npc_profiles = Array[ExtResource("2_l60ad")]([SubResource("Resource_57xgi"), SubResource("Resource_r873e"), SubResource("Resource_3xy2u"), SubResource("Resource_50i1h"), SubResource("Resource_nsw77"), SubResource("Resource_20knl"), SubResource("Resource_rivis"), SubResource("Resource_1dvkp"), SubResource("Resource_7wsm0")])
|
||||||
event_pool = Array[Dictionary]([{
|
event_pool = Array[Dictionary]([{
|
||||||
"base_weight": 1.0,
|
"base_weight": 1.0,
|
||||||
"id": "guest_valid",
|
"id": "guest_valid",
|
||||||
@@ -564,6 +373,19 @@ event_pool = Array[Dictionary]([{
|
|||||||
"phase": 2,
|
"phase": 2,
|
||||||
"probability": 0.5,
|
"probability": 0.5,
|
||||||
"time_range_s": Vector2(650, 830)
|
"time_range_s": Vector2(650, 830)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "joke_denied_guest_return",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "recovery",
|
||||||
|
"npc_type": 9,
|
||||||
|
"override_access": {
|
||||||
|
"has_id": true,
|
||||||
|
"has_ticket": true
|
||||||
|
},
|
||||||
|
"phase": 2,
|
||||||
|
"probability": 0.2,
|
||||||
|
"time_range_s": Vector2(650, 830)
|
||||||
}])
|
}])
|
||||||
shift_phases = {
|
shift_phases = {
|
||||||
0: {
|
0: {
|
||||||
@@ -579,8 +401,6 @@ shift_phases = {
|
|||||||
"start_s": 700.0
|
"start_s": 700.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
shift_duration_s = 900.0
|
|
||||||
policy_summary = ""
|
|
||||||
door_staff_lines = {
|
door_staff_lines = {
|
||||||
"id_check": ["May I see your ID, please?", "Could you show me some identification?"],
|
"id_check": ["May I see your ID, please?", "Could you show me some identification?"],
|
||||||
"prohibited_item": ["I can't allow that item inside. Please leave it outside.", "That item isn't permitted in the venue."],
|
"prohibited_item": ["I can't allow that item inside. Please leave it outside.", "That item isn't permitted in the venue."],
|
||||||
|
|||||||
+42
-374
@@ -1,11 +1,11 @@
|
|||||||
[gd_resource type="Resource" script_class="VenueScenario" load_steps=17 format=3]
|
[gd_resource type="Resource" script_class="VenueScenario" format=3]
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_xxhx3"]
|
[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_3vwd0"]
|
||||||
[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_fiebn"]
|
[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_g7rkt"]
|
||||||
[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_2dvuq"]
|
[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_jd45y"]
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_blmt0"]
|
[sub_resource type="Resource" id="Resource_c55bb"]
|
||||||
script = ExtResource("1_xxhx3")
|
script = ExtResource("1_3vwd0")
|
||||||
required_artifacts = {
|
required_artifacts = {
|
||||||
1: true,
|
1: true,
|
||||||
3: true,
|
3: true,
|
||||||
@@ -13,145 +13,39 @@ required_artifacts = {
|
|||||||
5: true
|
5: true
|
||||||
}
|
}
|
||||||
grant_if_any_required = true
|
grant_if_any_required = true
|
||||||
minimum_age = 0
|
|
||||||
require_id = false
|
|
||||||
wand_screening_required = false
|
|
||||||
policy_summary = "Entry requires a wristband, credential, ticket, or guest-list confirmation."
|
policy_summary = "Entry requires a wristband, credential, ticket, or guest-list confirmation."
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_oddac"]
|
[sub_resource type="Resource" id="Resource_yyxim"]
|
||||||
script = ExtResource("2_fiebn")
|
script = ExtResource("2_g7rkt")
|
||||||
id = "tourist"
|
id = "tourist"
|
||||||
display_name = "Tourist"
|
display_name = "Tourist"
|
||||||
npc_type = 0
|
|
||||||
temperament = 0.1
|
temperament = 0.1
|
||||||
base_compliance = 0.9
|
base_compliance = 0.9
|
||||||
return_probability = 0.0
|
|
||||||
reactions_by_level = {}
|
|
||||||
default_reactions = {
|
|
||||||
1: {
|
|
||||||
0: 80,
|
|
||||||
2: 8,
|
|
||||||
3: 12
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
0: 70,
|
|
||||||
1: 10,
|
|
||||||
2: 12,
|
|
||||||
4: 8
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
0: 55,
|
|
||||||
1: 15,
|
|
||||||
2: 20,
|
|
||||||
4: 10
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
1: 65,
|
|
||||||
2: 15,
|
|
||||||
7: 20
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
1: 30,
|
|
||||||
5: 40,
|
|
||||||
7: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."])
|
dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."])
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_byir2"]
|
[sub_resource type="Resource" id="Resource_lmn2c"]
|
||||||
script = ExtResource("2_fiebn")
|
script = ExtResource("2_g7rkt")
|
||||||
id = "confused_visitor"
|
id = "confused_visitor"
|
||||||
display_name = "Confused Visitor"
|
display_name = "Confused Visitor"
|
||||||
npc_type = 3
|
npc_type = 3
|
||||||
temperament = 0.2
|
temperament = 0.2
|
||||||
base_compliance = 0.85
|
base_compliance = 0.85
|
||||||
return_probability = 0.0
|
|
||||||
reactions_by_level = {}
|
|
||||||
default_reactions = {
|
|
||||||
1: {
|
|
||||||
0: 80,
|
|
||||||
2: 8,
|
|
||||||
3: 12
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
0: 70,
|
|
||||||
1: 10,
|
|
||||||
2: 12,
|
|
||||||
4: 8
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
0: 55,
|
|
||||||
1: 15,
|
|
||||||
2: 20,
|
|
||||||
4: 10
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
1: 65,
|
|
||||||
2: 15,
|
|
||||||
7: 20
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
1: 30,
|
|
||||||
5: 40,
|
|
||||||
7: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."])
|
dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."])
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_3tt0b"]
|
[sub_resource type="Resource" id="Resource_if2cj"]
|
||||||
script = ExtResource("2_fiebn")
|
script = ExtResource("2_g7rkt")
|
||||||
id = "delivery"
|
id = "delivery"
|
||||||
display_name = "Delivery Worker"
|
display_name = "Delivery Worker"
|
||||||
npc_type = 10
|
npc_type = 10
|
||||||
temperament = 0.25
|
|
||||||
base_compliance = 0.8
|
|
||||||
return_probability = 0.0
|
|
||||||
reactions_by_level = {}
|
|
||||||
default_reactions = {
|
|
||||||
1: {
|
|
||||||
0: 80,
|
|
||||||
2: 8,
|
|
||||||
3: 12
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
0: 70,
|
|
||||||
1: 10,
|
|
||||||
2: 12,
|
|
||||||
4: 8
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
0: 55,
|
|
||||||
1: 15,
|
|
||||||
2: 20,
|
|
||||||
4: 10
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
1: 65,
|
|
||||||
2: 15,
|
|
||||||
7: 20
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
1: 30,
|
|
||||||
5: 40,
|
|
||||||
7: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String](["Where should I leave this?"])
|
dialogue_lines = Array[String](["Where should I leave this?"])
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_ihxib"]
|
[sub_resource type="Resource" id="Resource_t47d3"]
|
||||||
script = ExtResource("2_fiebn")
|
script = ExtResource("2_g7rkt")
|
||||||
id = "street_performer"
|
id = "street_performer"
|
||||||
display_name = "Street Performer"
|
display_name = "Street Performer"
|
||||||
npc_type = 1
|
npc_type = 1
|
||||||
temperament = 0.45
|
temperament = 0.45
|
||||||
base_compliance = 0.55
|
base_compliance = 0.55
|
||||||
return_probability = 0.0
|
|
||||||
reactions_by_level = {
|
reactions_by_level = {
|
||||||
1: {
|
1: {
|
||||||
0: 55,
|
0: 55,
|
||||||
@@ -159,47 +53,14 @@ reactions_by_level = {
|
|||||||
3: 30
|
3: 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default_reactions = {
|
|
||||||
1: {
|
|
||||||
0: 80,
|
|
||||||
2: 8,
|
|
||||||
3: 12
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
0: 70,
|
|
||||||
1: 10,
|
|
||||||
2: 12,
|
|
||||||
4: 8
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
0: 55,
|
|
||||||
1: 15,
|
|
||||||
2: 20,
|
|
||||||
4: 10
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
1: 65,
|
|
||||||
2: 15,
|
|
||||||
7: 20
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
1: 30,
|
|
||||||
5: 40,
|
|
||||||
7: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String]([])
|
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_c57iy"]
|
[sub_resource type="Resource" id="Resource_j2mp5"]
|
||||||
script = ExtResource("2_fiebn")
|
script = ExtResource("2_g7rkt")
|
||||||
id = "influencer"
|
id = "influencer"
|
||||||
display_name = "Influencer"
|
display_name = "Influencer"
|
||||||
npc_type = 2
|
npc_type = 2
|
||||||
temperament = 0.5
|
temperament = 0.5
|
||||||
base_compliance = 0.5
|
base_compliance = 0.5
|
||||||
return_probability = 0.0
|
|
||||||
reactions_by_level = {
|
reactions_by_level = {
|
||||||
1: {
|
1: {
|
||||||
0: 40,
|
0: 40,
|
||||||
@@ -221,47 +82,15 @@ reactions_by_level = {
|
|||||||
8: 25
|
8: 25
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default_reactions = {
|
|
||||||
1: {
|
|
||||||
0: 80,
|
|
||||||
2: 8,
|
|
||||||
3: 12
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
0: 70,
|
|
||||||
1: 10,
|
|
||||||
2: 12,
|
|
||||||
4: 8
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
0: 55,
|
|
||||||
1: 15,
|
|
||||||
2: 20,
|
|
||||||
4: 10
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
1: 65,
|
|
||||||
2: 15,
|
|
||||||
7: 20
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
1: 30,
|
|
||||||
5: 40,
|
|
||||||
7: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String](["This is public sidewalk, you can't stop me from filming."])
|
dialogue_lines = Array[String](["This is public sidewalk, you can't stop me from filming."])
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_bk410"]
|
[sub_resource type="Resource" id="Resource_uscqg"]
|
||||||
script = ExtResource("2_fiebn")
|
script = ExtResource("2_g7rkt")
|
||||||
id = "intoxicated"
|
id = "intoxicated"
|
||||||
display_name = "Intoxicated Pedestrian"
|
display_name = "Intoxicated Pedestrian"
|
||||||
npc_type = 4
|
npc_type = 4
|
||||||
temperament = 0.6
|
temperament = 0.6
|
||||||
base_compliance = 0.4
|
base_compliance = 0.4
|
||||||
return_probability = 0.0
|
|
||||||
reactions_by_level = {
|
reactions_by_level = {
|
||||||
1: {
|
1: {
|
||||||
0: 30,
|
0: 30,
|
||||||
@@ -275,41 +104,10 @@ reactions_by_level = {
|
|||||||
4: 10
|
4: 10
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default_reactions = {
|
|
||||||
1: {
|
|
||||||
0: 80,
|
|
||||||
2: 8,
|
|
||||||
3: 12
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
0: 70,
|
|
||||||
1: 10,
|
|
||||||
2: 12,
|
|
||||||
4: 8
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
0: 55,
|
|
||||||
1: 15,
|
|
||||||
2: 20,
|
|
||||||
4: 10
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
1: 65,
|
|
||||||
2: 15,
|
|
||||||
7: 20
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
1: 30,
|
|
||||||
5: 40,
|
|
||||||
7: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reaction_delay = 1.8
|
reaction_delay = 1.8
|
||||||
dialogue_lines = Array[String]([])
|
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_jp52w"]
|
[sub_resource type="Resource" id="Resource_8aovb"]
|
||||||
script = ExtResource("2_fiebn")
|
script = ExtResource("2_g7rkt")
|
||||||
id = "trespasser"
|
id = "trespasser"
|
||||||
display_name = "Trespasser"
|
display_name = "Trespasser"
|
||||||
npc_type = 5
|
npc_type = 5
|
||||||
@@ -344,41 +142,10 @@ reactions_by_level = {
|
|||||||
7: 20
|
7: 20
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default_reactions = {
|
|
||||||
1: {
|
|
||||||
0: 80,
|
|
||||||
2: 8,
|
|
||||||
3: 12
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
0: 70,
|
|
||||||
1: 10,
|
|
||||||
2: 12,
|
|
||||||
4: 8
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
0: 55,
|
|
||||||
1: 15,
|
|
||||||
2: 20,
|
|
||||||
4: 10
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
1: 65,
|
|
||||||
2: 15,
|
|
||||||
7: 20
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
1: 30,
|
|
||||||
5: 40,
|
|
||||||
7: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String](["I'm just here to see a friend, come on.", "I'll be inside for ten seconds."])
|
dialogue_lines = Array[String](["I'm just here to see a friend, come on.", "I'll be inside for ten seconds."])
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_5kplf"]
|
[sub_resource type="Resource" id="Resource_owl7s"]
|
||||||
script = ExtResource("2_fiebn")
|
script = ExtResource("2_g7rkt")
|
||||||
id = "mental_crisis"
|
id = "mental_crisis"
|
||||||
display_name = "Person in Crisis"
|
display_name = "Person in Crisis"
|
||||||
npc_type = 7
|
npc_type = 7
|
||||||
@@ -414,47 +181,15 @@ reactions_by_level = {
|
|||||||
7: 15
|
7: 15
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default_reactions = {
|
|
||||||
1: {
|
|
||||||
0: 80,
|
|
||||||
2: 8,
|
|
||||||
3: 12
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
0: 70,
|
|
||||||
1: 10,
|
|
||||||
2: 12,
|
|
||||||
4: 8
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
0: 55,
|
|
||||||
1: 15,
|
|
||||||
2: 20,
|
|
||||||
4: 10
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
1: 65,
|
|
||||||
2: 15,
|
|
||||||
7: 20
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
1: 30,
|
|
||||||
5: 40,
|
|
||||||
7: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reaction_delay = 2.4
|
reaction_delay = 2.4
|
||||||
dialogue_lines = Array[String]([])
|
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_6bscs"]
|
[sub_resource type="Resource" id="Resource_rnay4"]
|
||||||
script = ExtResource("2_fiebn")
|
script = ExtResource("2_g7rkt")
|
||||||
id = "violent_threat"
|
id = "violent_threat"
|
||||||
display_name = "Violent Threat"
|
display_name = "Violent Threat"
|
||||||
npc_type = 6
|
npc_type = 6
|
||||||
temperament = 0.95
|
temperament = 0.95
|
||||||
base_compliance = 0.05
|
base_compliance = 0.05
|
||||||
return_probability = 0.0
|
|
||||||
reactions_by_level = {
|
reactions_by_level = {
|
||||||
1: {
|
1: {
|
||||||
2: 25,
|
2: 25,
|
||||||
@@ -482,41 +217,9 @@ reactions_by_level = {
|
|||||||
7: 30
|
7: 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default_reactions = {
|
|
||||||
1: {
|
|
||||||
0: 80,
|
|
||||||
2: 8,
|
|
||||||
3: 12
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
0: 70,
|
|
||||||
1: 10,
|
|
||||||
2: 12,
|
|
||||||
4: 8
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
0: 55,
|
|
||||||
1: 15,
|
|
||||||
2: 20,
|
|
||||||
4: 10
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
1: 65,
|
|
||||||
2: 15,
|
|
||||||
7: 20
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
1: 30,
|
|
||||||
5: 40,
|
|
||||||
7: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String]([])
|
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_n4lxh"]
|
[sub_resource type="Resource" id="Resource_lhp4m"]
|
||||||
script = ExtResource("2_fiebn")
|
script = ExtResource("2_g7rkt")
|
||||||
id = "protester"
|
id = "protester"
|
||||||
display_name = "Protester"
|
display_name = "Protester"
|
||||||
npc_type = 12
|
npc_type = 12
|
||||||
@@ -550,66 +253,28 @@ reactions_by_level = {
|
|||||||
7: 20
|
7: 20
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default_reactions = {
|
|
||||||
1: {
|
|
||||||
0: 80,
|
|
||||||
2: 8,
|
|
||||||
3: 12
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
0: 70,
|
|
||||||
1: 10,
|
|
||||||
2: 12,
|
|
||||||
4: 8
|
|
||||||
},
|
|
||||||
3: {
|
|
||||||
0: 55,
|
|
||||||
1: 15,
|
|
||||||
2: 20,
|
|
||||||
4: 10
|
|
||||||
},
|
|
||||||
4: {
|
|
||||||
1: 65,
|
|
||||||
2: 15,
|
|
||||||
7: 20
|
|
||||||
},
|
|
||||||
5: {
|
|
||||||
1: 30,
|
|
||||||
5: 40,
|
|
||||||
7: 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String](["I have every right to be on public sidewalk.", "You can't touch me, this is a lawful protest."])
|
dialogue_lines = Array[String](["I have every right to be on public sidewalk.", "You can't touch me, this is a lawful protest."])
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_6st6l"]
|
[sub_resource type="Resource" id="Resource_s8tjd"]
|
||||||
script = ExtResource("2_fiebn")
|
script = ExtResource("2_g7rkt")
|
||||||
id = "authorized_staff"
|
id = "authorized_staff"
|
||||||
display_name = "Staff Member"
|
display_name = "Staff Member"
|
||||||
npc_type = 8
|
npc_type = 8
|
||||||
temperament = 0.15
|
temperament = 0.15
|
||||||
base_compliance = 1.0
|
base_compliance = 1.0
|
||||||
return_probability = 0.0
|
|
||||||
reactions_by_level = {}
|
|
||||||
default_reactions = {
|
default_reactions = {
|
||||||
1: {
|
1: {
|
||||||
0: 100
|
0: 100
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String]([])
|
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[sub_resource type="Resource" id="Resource_nf2oq"]
|
[sub_resource type="Resource" id="Resource_ii8kq"]
|
||||||
script = ExtResource("2_fiebn")
|
script = ExtResource("2_g7rkt")
|
||||||
id = "authorized_guest"
|
id = "authorized_guest"
|
||||||
display_name = "Authorized Guest"
|
display_name = "Authorized Guest"
|
||||||
npc_type = 9
|
npc_type = 9
|
||||||
temperament = 0.2
|
temperament = 0.2
|
||||||
base_compliance = 0.95
|
base_compliance = 0.95
|
||||||
return_probability = 0.0
|
|
||||||
reactions_by_level = {}
|
|
||||||
default_reactions = {
|
default_reactions = {
|
||||||
1: {
|
1: {
|
||||||
0: 92,
|
0: 92,
|
||||||
@@ -620,16 +285,13 @@ default_reactions = {
|
|||||||
2: 12
|
2: 12
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reaction_delay = 0.6
|
|
||||||
dialogue_lines = Array[String]([])
|
|
||||||
carried_items = Array[String]([])
|
|
||||||
|
|
||||||
[resource]
|
[resource]
|
||||||
script = ExtResource("3_2dvuq")
|
script = ExtResource("3_jd45y")
|
||||||
id = "tv_studio"
|
id = "tv_studio"
|
||||||
display_name = "Hollywood Blvd Late Night TV Studio"
|
display_name = "Hollywood Blvd Late Night TV Studio"
|
||||||
description = "Crowd, celebrities, and staff around a late-night studio."
|
description = "Crowd, celebrities, and staff around a late-night studio."
|
||||||
access_policy = SubResource("Resource_blmt0")
|
access_policy = SubResource("Resource_c55bb")
|
||||||
equipment = Array[String](["wristband_policy", "guest_list", "radio", "flashlight"])
|
equipment = Array[String](["wristband_policy", "guest_list", "radio", "flashlight"])
|
||||||
locations = {
|
locations = {
|
||||||
"entrance": Vector3(0, 0, 1),
|
"entrance": Vector3(0, 0, 1),
|
||||||
@@ -639,7 +301,7 @@ locations = {
|
|||||||
"stage_door": Vector3(6, 0, -2),
|
"stage_door": Vector3(6, 0, -2),
|
||||||
"steps_end": Vector3(0, 0, 3)
|
"steps_end": Vector3(0, 0, 3)
|
||||||
}
|
}
|
||||||
npc_profiles = Array[ExtResource("2_fiebn")]([SubResource("Resource_oddac"), SubResource("Resource_byir2"), SubResource("Resource_3tt0b"), SubResource("Resource_ihxib"), SubResource("Resource_c57iy"), SubResource("Resource_bk410"), SubResource("Resource_jp52w"), SubResource("Resource_5kplf"), SubResource("Resource_6bscs"), SubResource("Resource_n4lxh"), SubResource("Resource_6st6l"), SubResource("Resource_nf2oq")])
|
npc_profiles = Array[ExtResource("2_g7rkt")]([SubResource("Resource_yyxim"), SubResource("Resource_lmn2c"), SubResource("Resource_if2cj"), SubResource("Resource_t47d3"), SubResource("Resource_j2mp5"), SubResource("Resource_uscqg"), SubResource("Resource_8aovb"), SubResource("Resource_owl7s"), SubResource("Resource_rnay4"), SubResource("Resource_lhp4m"), SubResource("Resource_s8tjd"), SubResource("Resource_ii8kq")])
|
||||||
event_pool = Array[Dictionary]([{
|
event_pool = Array[Dictionary]([{
|
||||||
"base_weight": 1.0,
|
"base_weight": 1.0,
|
||||||
"id": "audience_ticket",
|
"id": "audience_ticket",
|
||||||
@@ -790,6 +452,15 @@ event_pool = Array[Dictionary]([{
|
|||||||
"phase": 2,
|
"phase": 2,
|
||||||
"probability": 0.45,
|
"probability": 0.45,
|
||||||
"time_range_s": Vector2(700, 860)
|
"time_range_s": Vector2(700, 860)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "studio_manager_check",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "recovery",
|
||||||
|
"npc_type": 8,
|
||||||
|
"phase": 2,
|
||||||
|
"probability": 0.2,
|
||||||
|
"time_range_s": Vector2(620, 820)
|
||||||
}])
|
}])
|
||||||
shift_phases = {
|
shift_phases = {
|
||||||
0: {
|
0: {
|
||||||
@@ -805,6 +476,3 @@ shift_phases = {
|
|||||||
"start_s": 700.0
|
"start_s": 700.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
shift_duration_s = 900.0
|
|
||||||
policy_summary = ""
|
|
||||||
door_staff_lines = {}
|
|
||||||
@@ -37,7 +37,7 @@ This document follows the structure of the arc42 architecture documentation temp
|
|||||||
- **MINOR** — new venues, NPC archetypes, systems, or features that are backward compatible.
|
- **MINOR** — new venues, NPC archetypes, systems, or features that are backward compatible.
|
||||||
- **PATCH** — bug fixes and small polish that do not change behavior.
|
- **PATCH** — bug fixes and small polish that do not change behavior.
|
||||||
|
|
||||||
The single source of truth for the version is `config/version` in `project.godot` (currently `0.7.3`). Each release is tagged in git as `v<version>` (e.g. `v0.6.0`). The `0.x` series is pre-1.0, so MINOR bumps may introduce breaking changes while the prototype is still stabilizing. `tools/build_releases.sh` and `tools/publish_release.sh` read the version from `project.godot`, so bumping it there is the only required step before a release. The full bump checklist lives in [`RELEASING.md`](RELEASING.md).
|
The single source of truth for the version is `config/version` in `project.godot` (currently `0.9.0`). Each release is tagged in git as `v<version>` (e.g. `v0.6.0`). The `0.x` series is pre-1.0, so MINOR bumps may introduce breaking changes while the prototype is still stabilizing. `tools/build_releases.sh` and `tools/publish_release.sh` read the version from `project.godot`, so bumping it there is the only required step before a release. The full bump checklist lives in [`RELEASING.md`](RELEASING.md).
|
||||||
|
|
||||||
## 3. Context and Scope
|
## 3. Context and Scope
|
||||||
|
|
||||||
@@ -217,6 +217,9 @@ The release process is: bump the version, run the full test suite and `tools/che
|
|||||||
- **Observability:** Debug overlays show NPC intent, access status, escalation level, active incidents, and response timers during development.
|
- **Observability:** Debug overlays show NPC intent, access status, escalation level, active incidents, and response timers during development.
|
||||||
- **Visual regression:** Staged captures are compared against the runtime reference art (`screen_designs.py compare`) and optionally judged by a vision model; `tools/check_screenshots.sh` makes the pass repeatable.
|
- **Visual regression:** Staged captures are compared against the runtime reference art (`screen_designs.py compare`) and optionally judged by a vision model; `tools/check_screenshots.sh` makes the pass repeatable.
|
||||||
- **Asset provenance and AI usage:** AI-assisted images/concepts are development inputs only; validated outputs are committed, audited and loaded through the same runtime asset contracts. Generated content must not become an offline/CI dependency.
|
- **Asset provenance and AI usage:** AI-assisted images/concepts are development inputs only; validated outputs are committed, audited and loaded through the same runtime asset contracts. Generated content must not become an offline/CI dependency.
|
||||||
|
- **Campaign and checkpoint state:** `Campaign` owns venue order/progression; settings, campaign, completed shift history and a versioned pause checkpoint are separate JSON documents. The checkpoint snapshots coordinator systems, scheduler RNG and NPC presentation/access state; it deliberately does not serialize the tutorial overlay or transient input state.
|
||||||
|
- **Difficulty:** easy/normal/hard only scale spawn pacing, NPC compliance tolerance and report-credibility strictness; access/escalation rules and emergency rarity are invariant.
|
||||||
|
- **Settings and accessibility:** volume/motion/caption settings persist through `settings.json`; Help is generated from the input-binding catalogue and the HUD log is the text caption surface. The supported engine matrix is explicit (CI/release 4.3, local 4.7.2) rather than an implicit version drift.
|
||||||
- **Documentation ownership:** `PLAN.md` owns design intent; `docs/gameplay.md` owns playable mechanics; `docs/architecture.md` owns system boundaries, data flow, constraints and cross-cutting decisions; ADRs own decision history; `docs/development-status.md` owns the implemented/gap list; `TODO.md` owns the roadmap; `MEMORY.md` owns gotchas and open questions. Specialized docs link to these rather than becoming competing sources of truth.
|
- **Documentation ownership:** `PLAN.md` owns design intent; `docs/gameplay.md` owns playable mechanics; `docs/architecture.md` owns system boundaries, data flow, constraints and cross-cutting decisions; ADRs own decision history; `docs/development-status.md` owns the implemented/gap list; `TODO.md` owns the roadmap; `MEMORY.md` owns gotchas and open questions. Specialized docs link to these rather than becoming competing sources of truth.
|
||||||
|
|
||||||
## 9. Architecture Decisions
|
## 9. Architecture Decisions
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Implemented so far:
|
|||||||
- Godot 4.3 project scaffold, input map, procedural world builder
|
- Godot 4.3 project scaffold, input map, procedural world builder
|
||||||
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
|
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
|
||||||
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
|
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
|
||||||
- Headless unit tests: **237 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, input remaps, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material, player movement, NPC character layers/LOD/queue, input bindings)
|
- Headless unit tests: **250 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, input remaps, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material, player movement, NPC character layers/LOD/queue, input bindings)
|
||||||
- Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event
|
- Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event
|
||||||
- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
|
- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
|
||||||
- Control pass: focus cone on NPC heads with a billboarded blue circle in front of the face and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`)
|
- Control pass: focus cone on NPC heads with a billboarded blue circle in front of the face and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`)
|
||||||
@@ -40,11 +40,13 @@ 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. Normal radio reports submit on click; emergency calls still require the hold-to-confirm path.
|
||||||
- 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.
|
- Production-quality pass (v0.9.0): mixer/accessibility settings, audio volume clamping, 24-NPC budget smoke, shortened coat/dress stride, and an explicit Godot 4.3-CI/4.7-local support matrix with documented upgrade/rollback requirements.
|
||||||
|
- Campaign and first-hour pass (v0.8.0): campaign progression, checkpoint resume, difficulty presets, a searchable Help overlay, recovery events, contextual dialogue, policy/debrief lines and tutorial/replay/skip. Scripted QA bypasses the tutorial unless `--tutorial` is passed.
|
||||||
|
- 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.
|
||||||
- Gear fit (post-v0.6.0): Blender gear is measured against the avatar head/skirt silhouette and the checks run in both the Python tooling and the headless suite. Sunglasses, the shoulder bag and the firefighter helmet were fitted to the new faces; the Influencer portrait and gallery were recaptured with a before/after report.
|
- Gear fit (post-v0.6.0): Blender gear is measured against the avatar head/skirt silhouette and the checks run in both the Python tooling and the headless suite. Sunglasses, the shoulder bag and the firefighter helmet were fitted to the new faces; the Influencer portrait and gallery were recaptured with a before/after report.
|
||||||
|
|
||||||
Current gaps for v1.0.0: onboarding/campaign progression, deeper authored content and NPC dialogue, mid-shift save/resume and replay, localization/accessibility expansion, voice/audio production, wider performance profiling, signed/notarized release packaging, and a unified Godot baseline. The full gap analysis and milestone plan live in [`TODO.md`](../TODO.md); the current architecture and documentation ownership map are in [`docs/architecture.md`](architecture.md).
|
Current gaps for v1.0.0: deeper authored content/dialogue, animation and audio production, localization/accessibility expansion, 24-NPC/platform profiling, external playtesting, signed/notarized release packaging, and a unified Godot baseline. The full gap analysis and milestone plan live in [`TODO.md`](../TODO.md); the current architecture and documentation ownership map are in [`docs/architecture.md`](architecture.md).
|
||||||
+2
-2
@@ -4,7 +4,7 @@ The complete design rationale also lives in [`PLAN.md`](../PLAN.md); this page k
|
|||||||
|
|
||||||
## Game Overview
|
## Game Overview
|
||||||
|
|
||||||
A typical shift lasts 10–15 minutes. The player:
|
A typical shift lasts 10–15 minutes. The first-person camera stays at the gameplay height of 1.7 m; the concept frames use a lower ~1.55 m reference camera, but gameplay readability and body-camera framing take priority. The player:
|
||||||
|
|
||||||
1. Watches the entrance, steps, queue, and surrounding sidewalk.
|
1. Watches the entrance, steps, queue, and surrounding sidewalk.
|
||||||
2. Determines why each person is approaching.
|
2. Determines why each person is approaching.
|
||||||
@@ -16,7 +16,7 @@ A typical shift lasts 10–15 minutes. The player:
|
|||||||
|
|
||||||
The game rewards calm resolutions rather than aggressive behavior. Emergency assistance and armed responders are rare last-resort systems with meaningful consequences.
|
The game rewards calm resolutions rather than aggressive behavior. Emergency assistance and armed responders are rare last-resort systems with meaningful consequences.
|
||||||
|
|
||||||
The first shift starts with an in-game tutorial covering movement/look, focus cycling, the action wheel, access inspection, polite direction, formal warning/body camera, radio and shift review. Its beat sheet is in [`tutorial.md`](tutorial.md).
|
The first shift starts with an in-game tutorial covering movement/look, focus cycling, the action wheel, access inspection, polite direction, formal warning/body camera, radio and shift review. Its beat sheet is in [`tutorial.md`](tutorial.md). After that a short campaign runs Hollywood → Joke Shop → TV Studio; progress, difficulty and a pause-safe checkpoint are persisted locally. Help (from the venue menu) explains the controls and the current venue policy.
|
||||||
|
|
||||||
## Venues
|
## Venues
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. Download the Godot 4.3+ editor (official build) and put `godot` on your PATH. CI pins 4.3; local development is verified with 4.7.2.
|
1. Download the Godot 4.3+ editor (official build) and put `godot` on your PATH. The supported policy is an explicit matrix: CI/release baseline 4.3, local development verified with 4.7.2. Engine upgrades require import, headless, screenshot and Desktop HQ smoke checks before changing the baseline.
|
||||||
2. Open or run the project from this directory.
|
2. Open or run the project from this directory.
|
||||||
|
|
||||||
## Run and Test
|
## Run and Test
|
||||||
@@ -38,7 +38,7 @@ godot --headless --path . --script res://src/test/smoke_plan_verbs.gd
|
|||||||
# Prop-collision smoke (windowed): guard stops at a bench, guest routes around it
|
# Prop-collision smoke (windowed): guard stops at a bench, guest routes around it
|
||||||
godot --path . --script res://src/test/smoke_props.gd
|
godot --path . --script res://src/test/smoke_props.gd
|
||||||
|
|
||||||
# NPC budget smoke: 16 full-rig avatars against the documented frame budget
|
# NPC budget smoke: 24 full-rig avatars against the documented frame budget
|
||||||
godot --headless --path . --script res://src/test/smoke_npc_budget.gd
|
godot --headless --path . --script res://src/test/smoke_npc_budget.gd
|
||||||
|
|
||||||
# Movement smoke (windowed): steps, precision walk, footsteps and landing
|
# Movement smoke (windowed): steps, precision walk, footsteps and landing
|
||||||
@@ -58,6 +58,12 @@ 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
|
||||||
|
|
||||||
|
# Campaign/checkpoint smoke (windowed): resume, review continue, help; isolated data
|
||||||
|
XDG_DATA_HOME=/tmp/gigline-campaign godot --path . --fixed-fps 60 --script res://src/test/smoke_campaign.gd
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -32,4 +32,11 @@
|
|||||||
30. The shift review's "Body camera timeline" button steps through the recorded events (Left/Right or the buttons) and Esc returns to the review.
|
30. The shift review's "Body camera timeline" button steps through the recorded events (Left/Right or the buttons) and Esc returns to the review.
|
||||||
31. The escalation label shows a `[###--] L3/5` shape meter as well as the level name, and stays readable in grayscale.
|
31. The escalation label shows a `[###--] L3/5` shape meter as well as the level name, and stays readable in grayscale.
|
||||||
32. Changing "HUD font scale" in Settings resizes the HUD text and persists across a restart.
|
32. Changing "HUD font scale" in Settings resizes the HUD text and persists across a restart.
|
||||||
33. When the shift timer reaches 0, the shift review screen shows grade, incident summary, responder outcomes, investigations, and persisted shift history; "Run another shift" returns to the venue menu.
|
33. When the shift timer reaches 0, the shift review screen shows grade, policy, incident summary, responder outcomes, investigations, and persisted shift history; campaign Continue starts the next venue.
|
||||||
|
34. On a fresh profile the tutorial starts on Hollywood, advances through its eight beats, and F1 skips it; Settings Replay tutorial restarts it.
|
||||||
|
35. Pausing saves a checkpoint: returning to the menu and choosing Continue resumes elapsed time, NPCs, incidents, door/flashlight state and campaign position.
|
||||||
|
36. Settings difficulty changes spawn pacing/compliance/report strictness while escalation rules stay the same; Help filters controls and shows the venue policy.
|
||||||
|
37. The F debug overlay lists the next eligible events; recovery events and recently played scripted events behave as authored.
|
||||||
|
38. Master/SFX/ambience volume settings clamp at 0/1 and persist; Reduce motion disables head bob; the captions toggle persists.
|
||||||
|
39. The 24-NPC budget smoke stays inside its frame budget; coats/dresses do not show obvious leg clipping in a normal walk.
|
||||||
|
40. Help remains readable with the largest HUD font scale, and the engine matrix (4.3 CI / 4.7.2 local) is used for verification.
|
||||||
+5
-3
@@ -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.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ config_version=5
|
|||||||
|
|
||||||
config/name="Gig Line"
|
config/name="Gig Line"
|
||||||
config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
|
config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
|
||||||
config/version="0.7.3"
|
config/version="0.9.0"
|
||||||
run/main_scene="res://scenes/main.tscn"
|
run/main_scene="res://scenes/main.tscn"
|
||||||
config/features=PackedStringArray("4.3", "GL Compatibility")
|
config/features=PackedStringArray("4.3", "GL Compatibility")
|
||||||
|
|
||||||
|
|||||||
+38
-2
@@ -4,6 +4,8 @@ extends Node
|
|||||||
|
|
||||||
const SHIFT_RESULTS_FILE := "shift_results.json"
|
const SHIFT_RESULTS_FILE := "shift_results.json"
|
||||||
const SETTINGS_FILE := "settings.json"
|
const SETTINGS_FILE := "settings.json"
|
||||||
|
const CAMPAIGN_FILE := "campaign.json"
|
||||||
|
const SHIFT_STATE_FILE := "shift_state.json"
|
||||||
|
|
||||||
func _load_json(file_name: String) -> Variant:
|
func _load_json(file_name: String) -> Variant:
|
||||||
var path := "user://" + file_name
|
var path := "user://" + file_name
|
||||||
@@ -17,13 +19,17 @@ func _load_json(file_name: String) -> Variant:
|
|||||||
|
|
||||||
func _save_json(file_name: String, data: Variant) -> Error:
|
func _save_json(file_name: String, data: Variant) -> Error:
|
||||||
var path := "user://" + file_name
|
var path := "user://" + file_name
|
||||||
|
var temp := path + ".tmp"
|
||||||
var text := JSON.stringify(data, "\t")
|
var text := JSON.stringify(data, "\t")
|
||||||
var f := FileAccess.open(path, FileAccess.WRITE)
|
var f := FileAccess.open(temp, FileAccess.WRITE)
|
||||||
if f == null:
|
if f == null:
|
||||||
return FileAccess.get_open_error()
|
return FileAccess.get_open_error()
|
||||||
f.store_string(text)
|
f.store_string(text)
|
||||||
f.close()
|
f.close()
|
||||||
return OK
|
# Replace the target only after the complete JSON has been written, so a
|
||||||
|
# crash cannot leave a partial save in place.
|
||||||
|
return DirAccess.rename_absolute(ProjectSettings.globalize_path(temp),
|
||||||
|
ProjectSettings.globalize_path(path))
|
||||||
|
|
||||||
## Load previous shift results. Returns Array of Dictionaries or [].
|
## Load previous shift results. Returns Array of Dictionaries or [].
|
||||||
func load_shift_results() -> Array:
|
func load_shift_results() -> Array:
|
||||||
@@ -38,6 +44,29 @@ func save_shift_result(result: Dictionary) -> Error:
|
|||||||
results.append(result)
|
results.append(result)
|
||||||
return _save_json(SHIFT_RESULTS_FILE, results)
|
return _save_json(SHIFT_RESULTS_FILE, results)
|
||||||
|
|
||||||
|
## Campaign/session progression (separate from settings and shift history).
|
||||||
|
func load_campaign() -> Dictionary:
|
||||||
|
var data = _load_json(CAMPAIGN_FILE)
|
||||||
|
return data if data is Dictionary else {}
|
||||||
|
|
||||||
|
func save_campaign(data: Dictionary) -> Error:
|
||||||
|
return _save_json(CAMPAIGN_FILE, data)
|
||||||
|
|
||||||
|
func reset_campaign() -> Error:
|
||||||
|
return _save_json(CAMPAIGN_FILE, {})
|
||||||
|
|
||||||
|
## Mid-shift checkpoint. Kept separate so a completed shift history is never
|
||||||
|
## overwritten by a resumable snapshot.
|
||||||
|
func load_shift_state() -> Dictionary:
|
||||||
|
var data = _load_json(SHIFT_STATE_FILE)
|
||||||
|
return data if data is Dictionary else {}
|
||||||
|
|
||||||
|
func save_shift_state(data: Dictionary) -> Error:
|
||||||
|
return _save_json(SHIFT_STATE_FILE, data)
|
||||||
|
|
||||||
|
func clear_shift_state() -> Error:
|
||||||
|
return _save_json(SHIFT_STATE_FILE, {})
|
||||||
|
|
||||||
## Defaults for player settings. Kept in code so a missing or partial
|
## Defaults for player settings. Kept in code so a missing or partial
|
||||||
## settings.json still produces a usable config.
|
## settings.json still produces a usable config.
|
||||||
const DEFAULT_SETTINGS := {
|
const DEFAULT_SETTINGS := {
|
||||||
@@ -47,6 +76,13 @@ 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,
|
||||||
|
"master_volume": 1.0,
|
||||||
|
"sfx_volume": 1.0,
|
||||||
|
"ambience_volume": 1.0,
|
||||||
|
"reduce_motion": false,
|
||||||
|
"captions_enabled": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
## Load player settings, merged over the defaults. Returns a Dictionary.
|
## Load player settings, merged over the defaults. Returns a Dictionary.
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ static func shared_profiles() -> Array[NpcProfile]:
|
|||||||
tourist.npc_type = Rules.NpcType.TOURIST
|
tourist.npc_type = Rules.NpcType.TOURIST
|
||||||
tourist.temperament = 0.1
|
tourist.temperament = 0.1
|
||||||
tourist.base_compliance = 0.9
|
tourist.base_compliance = 0.9
|
||||||
|
tourist.context_lines = {
|
||||||
|
"refusal": ["I just want a photo, I'm not staying.", "I didn't know this entrance was restricted."],
|
||||||
|
"deescalation": ["Okay, I'll move to the sidewalk.", "Sorry, I'll put the camera away."],
|
||||||
|
"recognition": ["You remember me from the tour, right?"],
|
||||||
|
}
|
||||||
tourist.dialogue_lines = [
|
tourist.dialogue_lines = [
|
||||||
"Oh sure, sorry, just taking a picture.",
|
"Oh sure, sorry, just taking a picture.",
|
||||||
"Is there a better spot to stand?",
|
"Is there a better spot to stand?",
|
||||||
@@ -34,6 +39,10 @@ static func shared_profiles() -> Array[NpcProfile]:
|
|||||||
confused.npc_type = Rules.NpcType.CONFUSED_VISITOR
|
confused.npc_type = Rules.NpcType.CONFUSED_VISITOR
|
||||||
confused.temperament = 0.2
|
confused.temperament = 0.2
|
||||||
confused.base_compliance = 0.85
|
confused.base_compliance = 0.85
|
||||||
|
confused.context_lines = {
|
||||||
|
"refusal": ["I'm sure the event is here. Let me check my phone."],
|
||||||
|
"deescalation": ["Oh, I understand. I'll check with the box office."],
|
||||||
|
}
|
||||||
confused.dialogue_lines = [
|
confused.dialogue_lines = [
|
||||||
"Wait, this isn't the entrance?",
|
"Wait, this isn't the entrance?",
|
||||||
"Sorry about that, I'm a bit lost.",
|
"Sorry about that, I'm a bit lost.",
|
||||||
@@ -389,6 +398,16 @@ static func hollywood_building() -> VenueScenario:
|
|||||||
"base_weight": 1.0,
|
"base_weight": 1.0,
|
||||||
"requires_emergency": true,
|
"requires_emergency": true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "building_apology_return",
|
||||||
|
"phase": Rules.Phase.EVENING,
|
||||||
|
"kind": "recovery",
|
||||||
|
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
|
||||||
|
"intent": "enter",
|
||||||
|
"time_range_s": Vector2(620, 800),
|
||||||
|
"probability": 0.22,
|
||||||
|
"base_weight": 1.0,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
return scenario
|
return scenario
|
||||||
|
|
||||||
@@ -451,7 +470,7 @@ static func joke_shop() -> VenueScenario:
|
|||||||
|
|
||||||
scenario.npc_profiles = _filter_profiles([
|
scenario.npc_profiles = _filter_profiles([
|
||||||
"tourist", "confused_visitor", "authorized_guest", "authorized_staff",
|
"tourist", "confused_visitor", "authorized_guest", "authorized_staff",
|
||||||
"influencer", "intoxicated", "trespasser", "violent_threat",
|
"street_performer", "influencer", "intoxicated", "trespasser", "violent_threat",
|
||||||
])
|
])
|
||||||
|
|
||||||
scenario.event_pool = [
|
scenario.event_pool = [
|
||||||
@@ -533,6 +552,17 @@ static func joke_shop() -> VenueScenario:
|
|||||||
"probability": 0.5,
|
"probability": 0.5,
|
||||||
"base_weight": 1.0,
|
"base_weight": 1.0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "joke_denied_guest_return",
|
||||||
|
"phase": Rules.Phase.EVENING,
|
||||||
|
"kind": "recovery",
|
||||||
|
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
|
||||||
|
"intent": "enter",
|
||||||
|
"time_range_s": Vector2(650, 830),
|
||||||
|
"probability": 0.2,
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"override_access": {"has_id": true, "has_ticket": true},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
return scenario
|
return scenario
|
||||||
|
|
||||||
@@ -648,6 +678,16 @@ static func tv_studio() -> VenueScenario:
|
|||||||
"probability": 0.45,
|
"probability": 0.45,
|
||||||
"base_weight": 1.0,
|
"base_weight": 1.0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "studio_manager_check",
|
||||||
|
"phase": Rules.Phase.EVENING,
|
||||||
|
"kind": "recovery",
|
||||||
|
"npc_type": Rules.NpcType.AUTHORIZED_STAFF,
|
||||||
|
"intent": "enter",
|
||||||
|
"time_range_s": Vector2(620, 820),
|
||||||
|
"probability": 0.2,
|
||||||
|
"base_weight": 1.0,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
return scenario
|
return scenario
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
class_name Campaign
|
||||||
|
extends RefCounted
|
||||||
|
## Small campaign/session progression model. Venue rules stay in VenueScenario;
|
||||||
|
## this only owns order, completed shifts, difficulty and the next venue.
|
||||||
|
|
||||||
|
const ORDER := ["hollywood_building", "joke_shop", "tv_studio"]
|
||||||
|
|
||||||
|
var index := 0
|
||||||
|
var shift_number := 1
|
||||||
|
var completed: Array[String] = []
|
||||||
|
var difficulty := "normal"
|
||||||
|
var complete := false
|
||||||
|
var last_grade := ""
|
||||||
|
|
||||||
|
func setup(data: Dictionary = {}) -> void:
|
||||||
|
index = clampi(int(data.get("index", 0)), 0, ORDER.size())
|
||||||
|
shift_number = maxi(1, int(data.get("shift_number", 1)))
|
||||||
|
difficulty = str(data.get("difficulty", "normal"))
|
||||||
|
completed.clear()
|
||||||
|
for item in data.get("completed", []):
|
||||||
|
var value := str(item)
|
||||||
|
if value in ORDER and value not in completed:
|
||||||
|
completed.append(value)
|
||||||
|
complete = bool(data.get("complete", index >= ORDER.size()))
|
||||||
|
last_grade = str(data.get("last_grade", ""))
|
||||||
|
|
||||||
|
func next_venue() -> String:
|
||||||
|
if complete or index >= ORDER.size():
|
||||||
|
return ""
|
||||||
|
return ORDER[index]
|
||||||
|
|
||||||
|
func summary() -> Dictionary:
|
||||||
|
return {
|
||||||
|
"index": index,
|
||||||
|
"next_venue": next_venue(),
|
||||||
|
"shift_number": shift_number,
|
||||||
|
"difficulty": difficulty,
|
||||||
|
"complete": complete,
|
||||||
|
"completed": completed.duplicate(),
|
||||||
|
"last_grade": last_grade,
|
||||||
|
}
|
||||||
|
|
||||||
|
## Record one finished shift. Idempotent per venue for a single campaign run.
|
||||||
|
func record_shift(scenario_id: String, review: Dictionary = {}) -> bool:
|
||||||
|
if complete:
|
||||||
|
return false
|
||||||
|
if scenario_id != next_venue():
|
||||||
|
return false
|
||||||
|
if scenario_id not in completed:
|
||||||
|
completed.append(scenario_id)
|
||||||
|
var rep: Dictionary = review.get("reputation", {})
|
||||||
|
last_grade = str(rep.get("grade", last_grade))
|
||||||
|
index += 1
|
||||||
|
shift_number = index + 1
|
||||||
|
if index >= ORDER.size():
|
||||||
|
complete = true
|
||||||
|
return true
|
||||||
|
|
||||||
|
func set_difficulty(value: String) -> void:
|
||||||
|
if value in ["easy", "normal", "hard"]:
|
||||||
|
difficulty = value
|
||||||
|
|
||||||
|
func reset() -> void:
|
||||||
|
setup({})
|
||||||
@@ -65,3 +65,11 @@ func summary() -> Dictionary:
|
|||||||
"attention": attention,
|
"attention": attention,
|
||||||
"panic": crowd_panic,
|
"panic": crowd_panic,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func snapshot() -> Dictionary:
|
||||||
|
return summary()
|
||||||
|
|
||||||
|
func restore(data: Dictionary) -> void:
|
||||||
|
mood = clampf(float(data.get("mood", 0.05)), 0.0, 1.0)
|
||||||
|
attention = clampf(float(data.get("attention", 0.0)), 0.0, 1.0)
|
||||||
|
crowd_panic = bool(data.get("panic", mood >= 0.75))
|
||||||
@@ -11,6 +11,9 @@ signal event_ready(event_data: Dictionary)
|
|||||||
var rng := RandomNumberGenerator.new()
|
var rng := RandomNumberGenerator.new()
|
||||||
var _scenario: VenueScenario = null
|
var _scenario: VenueScenario = null
|
||||||
var _weighted_pool: Array[Dictionary] = []
|
var _weighted_pool: Array[Dictionary] = []
|
||||||
|
## Difficulty/pacing knob: <1 is calmer, >1 sends more events and routine traffic.
|
||||||
|
var spawn_rate_multiplier := 1.0
|
||||||
|
var _recent_scripted: Array[String] = []
|
||||||
|
|
||||||
func _init() -> void:
|
func _init() -> void:
|
||||||
pass
|
pass
|
||||||
@@ -38,17 +41,47 @@ func pull_due(elapsed_s: float, phase: int) -> Array[Dictionary]:
|
|||||||
continue
|
continue
|
||||||
var prob: float = event.get("probability", 0.0)
|
var prob: float = event.get("probability", 0.0)
|
||||||
var base: float = event.get("base_weight", 1.0)
|
var base: float = event.get("base_weight", 1.0)
|
||||||
|
var event_id := str(event.get("id", ""))
|
||||||
|
if str(event.get("kind", "routine")) == "scripted" and event_id in _recent_scripted:
|
||||||
|
continue
|
||||||
var spawn_rate: float = event.get("spawn_rate", 0.0)
|
var spawn_rate: float = event.get("spawn_rate", 0.0)
|
||||||
|
var due_event := false
|
||||||
if spawn_rate > 0.0:
|
if spawn_rate > 0.0:
|
||||||
# Rate-based spawning: emit at most one per second window.
|
# Rate-based spawning: emit at most one per second window.
|
||||||
if rng.randf() < spawn_rate:
|
if rng.randf() < clampf(spawn_rate * spawn_rate_multiplier, 0.0, 1.0):
|
||||||
if rng.randf() < prob:
|
if rng.randf() < prob:
|
||||||
due.append(event.duplicate())
|
due_event = true
|
||||||
else:
|
else:
|
||||||
if rng.randf() < prob * base:
|
if rng.randf() < clampf(prob * base * spawn_rate_multiplier, 0.0, 1.0):
|
||||||
|
due_event = true
|
||||||
|
if due_event:
|
||||||
due.append(event.duplicate())
|
due.append(event.duplicate())
|
||||||
|
if str(event.get("kind", "routine")) == "scripted" and event_id != "":
|
||||||
|
_recent_scripted.append(event_id)
|
||||||
|
if _recent_scripted.size() > 4:
|
||||||
|
_recent_scripted.pop_front()
|
||||||
return due
|
return due
|
||||||
|
|
||||||
|
## Debug/content preview: events that could be selected now, without consuming
|
||||||
|
## RNG or marking anything as recently played.
|
||||||
|
func preview(elapsed_s: float, phase: int) -> Array[String]:
|
||||||
|
var out: Array[String] = []
|
||||||
|
if _scenario == null:
|
||||||
|
return out
|
||||||
|
for event in _weighted_pool:
|
||||||
|
var time_range: Vector2 = event.get("time_range_s", Vector2(0, _scenario.shift_duration_s))
|
||||||
|
if elapsed_s < time_range.x or elapsed_s > time_range.y:
|
||||||
|
continue
|
||||||
|
if int(event.get("phase", -1)) != phase:
|
||||||
|
continue
|
||||||
|
var event_id := str(event.get("id", ""))
|
||||||
|
if str(event.get("kind", "routine")) == "scripted" and event_id in _recent_scripted:
|
||||||
|
continue
|
||||||
|
out.append("%s (%s)" % [event_id, str(event.get("kind", "routine"))])
|
||||||
|
if out.size() >= 8:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
func fallback_routine_traffic(phase: int) -> Array[Dictionary]:
|
func fallback_routine_traffic(phase: int) -> Array[Dictionary]:
|
||||||
## Cheap routine traffic so the street never feels empty.
|
## Cheap routine traffic so the street never feels empty.
|
||||||
var out: Array[Dictionary] = []
|
var out: Array[Dictionary] = []
|
||||||
@@ -56,10 +89,19 @@ func fallback_routine_traffic(phase: int) -> Array[Dictionary]:
|
|||||||
if pool.is_empty():
|
if pool.is_empty():
|
||||||
return out
|
return out
|
||||||
for i in 3:
|
for i in 3:
|
||||||
if rng.randf() < 0.35:
|
if rng.randf() < clampf(0.35 * spawn_rate_multiplier, 0.0, 1.0):
|
||||||
out.append(pool[rng.randi_range(0, pool.size() - 1)].duplicate())
|
out.append(pool[rng.randi_range(0, pool.size() - 1)].duplicate())
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
func snapshot() -> Dictionary:
|
||||||
|
return {"rng_state": rng.state, "recent_scripted": _recent_scripted.duplicate()}
|
||||||
|
|
||||||
|
func restore(data: Dictionary) -> void:
|
||||||
|
rng.state = int(data.get("rng_state", rng.state))
|
||||||
|
_recent_scripted.clear()
|
||||||
|
for event_id in data.get("recent_scripted", []):
|
||||||
|
_recent_scripted.append(str(event_id))
|
||||||
|
|
||||||
func _routine_pool(phase: int) -> Array:
|
func _routine_pool(phase: int) -> Array:
|
||||||
var out: Array = []
|
var out: Array = []
|
||||||
for event in _weighted_pool:
|
for event in _weighted_pool:
|
||||||
|
|||||||
@@ -96,3 +96,20 @@ func to_dict() -> Dictionary:
|
|||||||
"incidents": incidents.duplicate(true),
|
"incidents": incidents.duplicate(true),
|
||||||
"open_count": _open_incidents.size(),
|
"open_count": _open_incidents.size(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func snapshot() -> Dictionary:
|
||||||
|
return {
|
||||||
|
"incidents": incidents.duplicate(true),
|
||||||
|
"open_incidents": _open_incidents.duplicate(true),
|
||||||
|
}
|
||||||
|
|
||||||
|
func restore(data: Dictionary) -> void:
|
||||||
|
incidents.clear()
|
||||||
|
_open_incidents.clear()
|
||||||
|
for incident in data.get("incidents", []):
|
||||||
|
if incident is Dictionary:
|
||||||
|
incidents.append((incident as Dictionary).duplicate(true))
|
||||||
|
for npc_id in (data.get("open_incidents", {}) as Dictionary).keys():
|
||||||
|
var incident = (data["open_incidents"] as Dictionary)[npc_id]
|
||||||
|
if incident is Dictionary:
|
||||||
|
_open_incidents[str(npc_id)] = (incident as Dictionary).duplicate(true)
|
||||||
@@ -71,10 +71,13 @@ func _build_fsm(initial_state: String) -> void:
|
|||||||
fsm.add_transition("THREATENING", "FORCING_ENTRY")
|
fsm.add_transition("THREATENING", "FORCING_ENTRY")
|
||||||
fsm.start(initial_state)
|
fsm.start(initial_state)
|
||||||
|
|
||||||
func roll_reaction(level: int) -> int:
|
func roll_reaction(level: int, compliance_bonus: float = 0.0) -> int:
|
||||||
## Weighted pick from profile's reaction table for `level`.
|
## Weighted pick from profile's reaction table for `level`. Difficulty can
|
||||||
|
## nudge compliance without changing the authored weights.
|
||||||
highest_escalation_received = maxi(highest_escalation_received, level)
|
highest_escalation_received = maxi(highest_escalation_received, level)
|
||||||
var weights: Dictionary = profile.pickup_reactions_per_level(level).duplicate()
|
var weights: Dictionary = profile.pickup_reactions_per_level(level).duplicate()
|
||||||
|
if compliance_bonus != 0.0:
|
||||||
|
weights[Rules.Reaction.COMPLY] = maxf(0.0, float(weights.get(Rules.Reaction.COMPLY, 0)) + compliance_bonus * 100.0)
|
||||||
var roll_value := rng.randf()
|
var roll_value := rng.randf()
|
||||||
var total := 0.0
|
var total := 0.0
|
||||||
for key in weights.keys():
|
for key in weights.keys():
|
||||||
@@ -88,9 +91,12 @@ func roll_reaction(level: int) -> int:
|
|||||||
return key
|
return key
|
||||||
return Rules.Reaction.LEAVE
|
return Rules.Reaction.LEAVE
|
||||||
|
|
||||||
func apply_player_action(level: int) -> Dictionary:
|
func context_line(context: String) -> String:
|
||||||
|
return profile.line_for(context, rng) if profile else ""
|
||||||
|
|
||||||
|
func apply_player_action(level: int, compliance_bonus: float = 0.0) -> Dictionary:
|
||||||
## Returns reaction to the player's action and updates FSM when needed.
|
## Returns reaction to the player's action and updates FSM when needed.
|
||||||
var performed_reaction := roll_reaction(level)
|
var performed_reaction := roll_reaction(level, compliance_bonus)
|
||||||
reaction = performed_reaction
|
reaction = performed_reaction
|
||||||
reaction_set = true
|
reaction_set = true
|
||||||
_update_state_for_reaction(performed_reaction)
|
_update_state_for_reaction(performed_reaction)
|
||||||
|
|||||||
@@ -61,10 +61,27 @@ const Rules := preload("res://src/core/rules.gd")
|
|||||||
## Dialogue lines this NPC uses. Falls back to generic lines when empty.
|
## Dialogue lines this NPC uses. Falls back to generic lines when empty.
|
||||||
@export var dialogue_lines: Array[String] = []
|
@export var dialogue_lines: Array[String] = []
|
||||||
|
|
||||||
|
## Context-specific dialogue: keys such as "reason", "refusal", "deescalation",
|
||||||
|
## "recognition" and "group". Missing contexts fall back to `dialogue_lines`.
|
||||||
|
@export var context_lines: Dictionary = {}
|
||||||
|
|
||||||
## Items the NPC is carrying, used by the handheld wand (The Joke Shop).
|
## Items the NPC is carrying, used by the handheld wand (The Joke Shop).
|
||||||
## Values are item names: "keys", "belt", "phone", "weapon", "knife", etc.
|
## Values are item names: "keys", "belt", "phone", "weapon", "knife", etc.
|
||||||
@export var carried_items: Array[String] = []
|
@export var carried_items: Array[String] = []
|
||||||
|
|
||||||
|
func line_for(context: String, rng: RandomNumberGenerator = null) -> String:
|
||||||
|
var pool: Array = []
|
||||||
|
if context_lines.has(context):
|
||||||
|
pool = context_lines[context]
|
||||||
|
elif context == "reason":
|
||||||
|
pool = dialogue_lines
|
||||||
|
if pool.is_empty():
|
||||||
|
return ""
|
||||||
|
var index := 0
|
||||||
|
if rng != null:
|
||||||
|
index = rng.randi_range(0, pool.size() - 1)
|
||||||
|
return str(pool[index])
|
||||||
|
|
||||||
func pickup_reactions_per_level(level: int) -> Dictionary:
|
func pickup_reactions_per_level(level: int) -> Dictionary:
|
||||||
if reactions_by_level.has(level):
|
if reactions_by_level.has(level):
|
||||||
return reactions_by_level[level]
|
return reactions_by_level[level]
|
||||||
|
|||||||
@@ -123,6 +123,23 @@ func tick(delta: float) -> void:
|
|||||||
if event_bus:
|
if event_bus:
|
||||||
event_bus.responder_arrived.emit(call)
|
event_bus.responder_arrived.emit(call)
|
||||||
|
|
||||||
|
func snapshot() -> Dictionary:
|
||||||
|
return {
|
||||||
|
"battery": battery,
|
||||||
|
"emergency_cooldown_remaining": emergency_cooldown_remaining,
|
||||||
|
"accepting_calls": accepting_calls,
|
||||||
|
"pending_calls": pending_calls.duplicate(true),
|
||||||
|
}
|
||||||
|
|
||||||
|
func restore(data: Dictionary) -> void:
|
||||||
|
battery = clampf(float(data.get("battery", 100.0)), 0.0, max_battery)
|
||||||
|
emergency_cooldown_remaining = maxf(0.0, float(data.get("emergency_cooldown_remaining", 0.0)))
|
||||||
|
accepting_calls = bool(data.get("accepting_calls", true))
|
||||||
|
pending_calls.clear()
|
||||||
|
for call in data.get("pending_calls", []):
|
||||||
|
if call is Dictionary:
|
||||||
|
pending_calls.append((call as Dictionary).duplicate(true))
|
||||||
|
|
||||||
## Determine the responder's resolution outcome for a call. The responder
|
## Determine the responder's resolution outcome for a call. The responder
|
||||||
## commands, restrains, evacuates, or arrests based on the reported danger
|
## commands, restrains, evacuates, or arrests based on the reported danger
|
||||||
## level and whether a weapon was seen (PLAN.md: Level 5 Emergency Response).
|
## level and whether a weapon was seen (PLAN.md: Level 5 Emergency Response).
|
||||||
|
|||||||
@@ -52,3 +52,13 @@ func summary() -> Dictionary:
|
|||||||
"grade": grade_for(rating),
|
"grade": grade_for(rating),
|
||||||
"modifiers": _modifiers.duplicate(true),
|
"modifiers": _modifiers.duplicate(true),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func snapshot() -> Dictionary:
|
||||||
|
return {"rating": rating, "modifiers": _modifiers.duplicate(true)}
|
||||||
|
|
||||||
|
func restore(data: Dictionary) -> void:
|
||||||
|
rating = clampf(float(data.get("rating", 70.0)), 0.0, 100.0)
|
||||||
|
_modifiers.clear()
|
||||||
|
for modifier in data.get("modifiers", []):
|
||||||
|
if modifier is Dictionary:
|
||||||
|
_modifiers.append((modifier as Dictionary).duplicate(true))
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
class_name ScenarioValidator
|
||||||
|
extends RefCounted
|
||||||
|
## Validates authored venue data before a shift. Keeps content-matrix failures
|
||||||
|
## out of runtime: every event must reference a known profile and a valid phase,
|
||||||
|
## event ids must be unique, and the matrix must cover enough kinds/phases.
|
||||||
|
|
||||||
|
const Rules := preload("res://src/core/rules.gd")
|
||||||
|
|
||||||
|
static func validate(scenario: Resource) -> Array[String]:
|
||||||
|
var problems: Array[String] = []
|
||||||
|
if scenario == null:
|
||||||
|
return ["scenario is null"]
|
||||||
|
var profile_types := {}
|
||||||
|
for profile in scenario.npc_profiles:
|
||||||
|
if profile != null:
|
||||||
|
profile_types[int(profile.npc_type)] = true
|
||||||
|
var ids := {}
|
||||||
|
var routine := 0
|
||||||
|
var scripted := 0
|
||||||
|
var recovery := 0
|
||||||
|
var phases := {}
|
||||||
|
for event in scenario.event_pool:
|
||||||
|
if not (event is Dictionary):
|
||||||
|
problems.append("event is not a dictionary")
|
||||||
|
continue
|
||||||
|
var event_id := str(event.get("id", ""))
|
||||||
|
if event_id == "":
|
||||||
|
problems.append("event has no id")
|
||||||
|
elif ids.has(event_id):
|
||||||
|
problems.append("duplicate event id: %s" % event_id)
|
||||||
|
ids[event_id] = true
|
||||||
|
var phase := int(event.get("phase", -1))
|
||||||
|
if phase < Rules.Phase.EARLY or phase > Rules.Phase.LATE:
|
||||||
|
problems.append("%s has invalid phase %d" % [event_id, phase])
|
||||||
|
var npc_type := int(event.get("npc_type", -1))
|
||||||
|
if not profile_types.has(npc_type):
|
||||||
|
problems.append("%s references missing profile type %d" % [event_id, npc_type])
|
||||||
|
if str(event.get("intent", "")) == "":
|
||||||
|
problems.append("%s has no intent" % event_id)
|
||||||
|
var kind := str(event.get("kind", "routine"))
|
||||||
|
if kind == "routine":
|
||||||
|
routine += 1
|
||||||
|
elif kind == "scripted":
|
||||||
|
scripted += 1
|
||||||
|
elif kind == "recovery":
|
||||||
|
recovery += 1
|
||||||
|
elif kind != "routine":
|
||||||
|
problems.append("%s has unknown kind %s" % [event_id, kind])
|
||||||
|
phases[phase] = true
|
||||||
|
if scenario.event_pool.size() < 12:
|
||||||
|
problems.append("event matrix has %d events; need at least 12" % scenario.event_pool.size())
|
||||||
|
if routine < 6:
|
||||||
|
problems.append("event matrix needs at least 6 routine events")
|
||||||
|
if scripted < 2:
|
||||||
|
problems.append("event matrix needs at least 2 scripted events")
|
||||||
|
if recovery < 1:
|
||||||
|
problems.append("event matrix needs at least 1 recovery event")
|
||||||
|
if phases.size() < 2:
|
||||||
|
problems.append("event matrix must cover at least 2 shift phases")
|
||||||
|
return problems
|
||||||
@@ -155,5 +155,39 @@ func build_review() -> Dictionary:
|
|||||||
"completed_at_ms": Time.get_ticks_msec(),
|
"completed_at_ms": Time.get_ticks_msec(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func snapshot() -> Dictionary:
|
||||||
|
return {
|
||||||
|
"scenario_id": scenario.id if scenario else "",
|
||||||
|
"duration_s": duration_s,
|
||||||
|
"elapsed_s": elapsed_s,
|
||||||
|
"current_phase": current_phase,
|
||||||
|
"spawned_count": spawned_count,
|
||||||
|
"responder_outcomes": responder_outcomes.duplicate(),
|
||||||
|
"investigations": investigations.duplicate(),
|
||||||
|
"recorder": recorder.snapshot(),
|
||||||
|
"reputation": reputation.snapshot(),
|
||||||
|
"crowd": crowd.snapshot(),
|
||||||
|
"radio": radio.snapshot(),
|
||||||
|
"scheduler": scheduler.snapshot(),
|
||||||
|
}
|
||||||
|
|
||||||
|
func restore(data: Dictionary) -> void:
|
||||||
|
duration_s = float(data.get("duration_s", duration_s))
|
||||||
|
elapsed_s = float(data.get("elapsed_s", 0.0))
|
||||||
|
current_phase = int(data.get("current_phase", Rules.Phase.EARLY))
|
||||||
|
spawned_count = int(data.get("spawned_count", 0))
|
||||||
|
responder_outcomes.clear()
|
||||||
|
for value in data.get("responder_outcomes", []):
|
||||||
|
responder_outcomes.append(str(value))
|
||||||
|
investigations.clear()
|
||||||
|
for value in data.get("investigations", []):
|
||||||
|
investigations.append(str(value))
|
||||||
|
recorder.restore(data.get("recorder", {}))
|
||||||
|
reputation.restore(data.get("reputation", {}))
|
||||||
|
crowd.restore(data.get("crowd", {}))
|
||||||
|
radio.restore(data.get("radio", {}))
|
||||||
|
scheduler.restore(data.get("scheduler", {}))
|
||||||
|
_shift_finished = false
|
||||||
|
|
||||||
func spawn_context_dict(profile: NpcProfile, intent: String) -> Dictionary:
|
func spawn_context_dict(profile: NpcProfile, intent: String) -> Dictionary:
|
||||||
return {"profile": profile, "intent": intent}
|
return {"profile": profile, "intent": intent}
|
||||||
@@ -16,6 +16,23 @@ var radio_crackles := 0
|
|||||||
var footsteps := 0
|
var footsteps := 0
|
||||||
var landings := 0
|
var landings := 0
|
||||||
var precision_ticks := 0
|
var precision_ticks := 0
|
||||||
|
var master_volume := 1.0
|
||||||
|
var sfx_volume := 1.0
|
||||||
|
var ambience_volume := 1.0
|
||||||
|
var _master_db := 0.0
|
||||||
|
var _sfx_db := 0.0
|
||||||
|
var _ambience_db := 0.0
|
||||||
|
|
||||||
|
## 0..1 mixer controls from Settings. Headless/no-audio builds just store them.
|
||||||
|
func set_volumes(master: float, sfx: float, ambience: float) -> void:
|
||||||
|
master_volume = clampf(master, 0.0, 1.0)
|
||||||
|
sfx_volume = clampf(sfx, 0.0, 1.0)
|
||||||
|
ambience_volume = clampf(ambience, 0.0, 1.0)
|
||||||
|
_master_db = linear_to_db(maxf(master_volume, 0.0001))
|
||||||
|
_sfx_db = linear_to_db(maxf(sfx_volume, 0.0001))
|
||||||
|
_ambience_db = linear_to_db(maxf(ambience_volume, 0.0001))
|
||||||
|
if _bed_player:
|
||||||
|
_bed_player.volume_db = _level_to_db(crowd_level) + _master_db + _ambience_db + _master_db + _ambience_db + _master_db + _ambience_db
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
# Pre-create a few players so overlapping cues don't cut each other off.
|
# Pre-create a few players so overlapping cues don't cut each other off.
|
||||||
@@ -112,7 +129,7 @@ func _play_tone(freq: float, duration: float, volume: float, delay: float = 0.0)
|
|||||||
stream.mix_rate = SAMPLE_RATE
|
stream.mix_rate = SAMPLE_RATE
|
||||||
stream.buffer_length = duration + 0.05
|
stream.buffer_length = duration + 0.05
|
||||||
player.stream = stream
|
player.stream = stream
|
||||||
player.volume_db = linear_to_db(volume)
|
player.volume_db = linear_to_db(volume) + _master_db + _sfx_db
|
||||||
player.play()
|
player.play()
|
||||||
# Fill the generator buffer with a sine wave.
|
# Fill the generator buffer with a sine wave.
|
||||||
var playback: AudioStreamGeneratorPlayback = player.get_stream_playback()
|
var playback: AudioStreamGeneratorPlayback = player.get_stream_playback()
|
||||||
@@ -174,7 +191,7 @@ func _play_noise(duration: float, volume: float, cutoff_hz: float = 3200.0) -> v
|
|||||||
stream.stereo = false
|
stream.stereo = false
|
||||||
stream.data = data
|
stream.data = data
|
||||||
player.stream = stream
|
player.stream = stream
|
||||||
player.volume_db = linear_to_db(volume)
|
player.volume_db = linear_to_db(volume) + _master_db + _sfx_db
|
||||||
player.play()
|
player.play()
|
||||||
|
|
||||||
func _free_player() -> AudioStreamPlayer:
|
func _free_player() -> AudioStreamPlayer:
|
||||||
|
|||||||
+373
-5
@@ -30,6 +30,10 @@ 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")
|
||||||
|
const Campaign := preload("res://src/core/campaign.gd")
|
||||||
|
const HelpOverlay := preload("res://src/game/ui/help_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 +95,16 @@ 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 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
|
||||||
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"
|
||||||
@@ -107,6 +121,12 @@ var active_scenario_id := "hollywood_building"
|
|||||||
var scenario_duration_s: float = 900.0
|
var scenario_duration_s: float = 900.0
|
||||||
var _wand_cooldown := 0.0
|
var _wand_cooldown := 0.0
|
||||||
const WAND_COOLDOWN_S := 0.8
|
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 scene_root: Node3D = null
|
||||||
var npcs: Array = []
|
var npcs: Array = []
|
||||||
var npc_counter := 0
|
var npc_counter := 0
|
||||||
@@ -160,12 +180,15 @@ func _ready() -> void:
|
|||||||
scenario_duration_s = selected_scenario.shift_duration_s
|
scenario_duration_s = selected_scenario.shift_duration_s
|
||||||
coordinator = ShiftCoordinator.new()
|
coordinator = ShiftCoordinator.new()
|
||||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||||
|
coordinator.scheduler.spawn_rate_multiplier = float(_difficulty()["spawn"])
|
||||||
coordinator.npc_spawn_requested.connect(_spawn_npc)
|
coordinator.npc_spawn_requested.connect(_spawn_npc)
|
||||||
coordinator.shift_ended.connect(_on_shift_ended)
|
coordinator.shift_ended.connect(_on_shift_ended)
|
||||||
_connect_radio()
|
_connect_radio()
|
||||||
_connect_crowd()
|
_connect_crowd()
|
||||||
|
|
||||||
_build_ui()
|
_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
|
# 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).
|
# that it does (otherwise the saved font scale is ignored until the next apply).
|
||||||
_apply_settings(player_settings)
|
_apply_settings(player_settings)
|
||||||
@@ -267,6 +290,8 @@ func _rebuild_world_for_venue(id: String) -> void:
|
|||||||
func restart_shift() -> void:
|
func restart_shift() -> void:
|
||||||
if shift_review_panel:
|
if shift_review_panel:
|
||||||
shift_review_panel.hide()
|
shift_review_panel.hide()
|
||||||
|
if Save:
|
||||||
|
Save.clear_shift_state()
|
||||||
_reset_shift_state()
|
_reset_shift_state()
|
||||||
_start_shift()
|
_start_shift()
|
||||||
|
|
||||||
@@ -290,6 +315,7 @@ func _reset_shift_state() -> void:
|
|||||||
hud.set_body_camera(false)
|
hud.set_body_camera(false)
|
||||||
scenario_duration_s = selected_scenario.shift_duration_s
|
scenario_duration_s = selected_scenario.shift_duration_s
|
||||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||||
|
coordinator.scheduler.spawn_rate_multiplier = float(_difficulty()["spawn"])
|
||||||
_connect_radio()
|
_connect_radio()
|
||||||
_connect_crowd()
|
_connect_crowd()
|
||||||
_last_phase = -1
|
_last_phase = -1
|
||||||
@@ -304,6 +330,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.
|
||||||
@@ -319,11 +346,13 @@ func _show_start_menu() -> void:
|
|||||||
guard.input_locked = true
|
guard.input_locked = true
|
||||||
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||||
if start_menu:
|
if start_menu:
|
||||||
start_menu.open()
|
start_menu.open(campaign.summary() if campaign else {})
|
||||||
|
|
||||||
func _on_venue_selected(scenario_id: String) -> void:
|
func _on_venue_selected(scenario_id: String) -> void:
|
||||||
if start_menu and start_menu.visible:
|
if start_menu and start_menu.visible:
|
||||||
start_menu.close()
|
start_menu.close()
|
||||||
|
if not _resuming and Save:
|
||||||
|
Save.clear_shift_state()
|
||||||
active_scenario_id = scenario_id
|
active_scenario_id = scenario_id
|
||||||
_venue_index = maxi(0, VENUE_ORDER.find(scenario_id))
|
_venue_index = maxi(0, VENUE_ORDER.find(scenario_id))
|
||||||
selected_scenario = _build_scenario(scenario_id)
|
selected_scenario = _build_scenario(scenario_id)
|
||||||
@@ -332,25 +361,71 @@ func _on_venue_selected(scenario_id: String) -> void:
|
|||||||
hud.set_wand_icon(false)
|
hud.set_wand_icon(false)
|
||||||
_reset_shift_state()
|
_reset_shift_state()
|
||||||
_start_shift()
|
_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.
|
## 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:
|
func _apply_settings(settings: Dictionary) -> void:
|
||||||
player_settings = settings
|
player_settings = settings
|
||||||
if guard:
|
if guard:
|
||||||
guard.look_sensitivity = float(settings.get("look_sensitivity", 0.0022))
|
guard.look_sensitivity = float(settings.get("look_sensitivity", 0.0022))
|
||||||
guard.invert_y = bool(settings.get("invert_y", false))
|
guard.invert_y = bool(settings.get("invert_y", false))
|
||||||
guard.head_bob_enabled = bool(settings.get("head_bob", true))
|
guard.head_bob_enabled = bool(settings.get("head_bob", true)) and not bool(settings.get("reduce_motion", false))
|
||||||
|
if audio:
|
||||||
|
audio.set_volumes(float(settings.get("master_volume", 1.0)),
|
||||||
|
float(settings.get("sfx_volume", 1.0)), float(settings.get("ambience_volume", 1.0)))
|
||||||
InputBindings.apply(settings.get("input_bindings", {}))
|
InputBindings.apply(settings.get("input_bindings", {}))
|
||||||
if hud and hud.has_method("set_font_scale"):
|
if hud and hud.has_method("set_font_scale"):
|
||||||
hud.set_font_scale(float(settings.get("hud_font_scale", 1.0)))
|
hud.set_font_scale(float(settings.get("hud_font_scale", 1.0)))
|
||||||
|
|
||||||
func _on_settings_applied(settings: Dictionary) -> void:
|
func _on_settings_applied(settings: Dictionary) -> void:
|
||||||
_apply_settings(settings)
|
_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:
|
if start_menu and start_menu.visible:
|
||||||
start_menu.open()
|
start_menu.open(campaign.summary() if campaign else {})
|
||||||
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_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
|
||||||
|
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_help() -> void:
|
||||||
|
if help_overlay:
|
||||||
|
help_overlay.open(selected_scenario.policy_summary if selected_scenario else "")
|
||||||
|
|
||||||
func _open_settings() -> void:
|
func _open_settings() -> void:
|
||||||
if start_menu:
|
if start_menu:
|
||||||
start_menu.close()
|
start_menu.close()
|
||||||
@@ -362,6 +437,7 @@ func _open_settings() -> void:
|
|||||||
func _open_pause() -> void:
|
func _open_pause() -> void:
|
||||||
if pause_overlay == null or not _shift_running:
|
if pause_overlay == null or not _shift_running:
|
||||||
return
|
return
|
||||||
|
_save_shift_checkpoint()
|
||||||
if _close_top_modal():
|
if _close_top_modal():
|
||||||
pass
|
pass
|
||||||
pause_overlay.open()
|
pause_overlay.open()
|
||||||
@@ -405,6 +481,7 @@ func _build_ui() -> void:
|
|||||||
add_child(shift_review_panel)
|
add_child(shift_review_panel)
|
||||||
shift_review_panel.hide()
|
shift_review_panel.hide()
|
||||||
shift_review_panel.restart_requested.connect(_on_review_restart)
|
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.timeline_requested.connect(_open_timeline)
|
||||||
shift_review_panel.visible = false
|
shift_review_panel.visible = false
|
||||||
|
|
||||||
@@ -420,16 +497,27 @@ 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)
|
||||||
|
settings_panel.reset_campaign_requested.connect(_on_reset_campaign)
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
help_overlay = HelpOverlay.new()
|
||||||
|
add_child(help_overlay)
|
||||||
|
help_overlay.closed.connect(_refresh_freeze)
|
||||||
|
|
||||||
start_menu = StartMenu.new()
|
start_menu = StartMenu.new()
|
||||||
add_child(start_menu)
|
add_child(start_menu)
|
||||||
start_menu.hide()
|
start_menu.hide()
|
||||||
start_menu.venue_selected.connect(_on_venue_selected)
|
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.settings_requested.connect(_open_settings)
|
||||||
start_menu.quit_requested.connect(func(): get_tree().quit())
|
start_menu.quit_requested.connect(func(): get_tree().quit())
|
||||||
|
|
||||||
@@ -450,10 +538,147 @@ func _on_timeline_closed() -> void:
|
|||||||
_timeline_from_review = false
|
_timeline_from_review = false
|
||||||
_refresh_freeze()
|
_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.
|
## "Run another shift" on the review screen returns to the venue menu.
|
||||||
func _on_review_restart() -> void:
|
func _on_review_restart() -> void:
|
||||||
_show_start_menu()
|
_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:
|
func _add_crosshair() -> void:
|
||||||
var layer := CanvasLayer.new()
|
var layer := CanvasLayer.new()
|
||||||
layer.layer = 9
|
layer.layer = 9
|
||||||
@@ -528,7 +753,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 _resuming 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
|
||||||
@@ -712,7 +1038,15 @@ func _on_shift_ended(review: Dictionary) -> void:
|
|||||||
# Persist the finished shift locally (Save autoload, user:// JSON).
|
# Persist the finished shift locally (Save autoload, user:// JSON).
|
||||||
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 [])
|
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()
|
_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 +1211,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 +1350,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 +1448,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 +1490,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()
|
||||||
@@ -1187,7 +1529,8 @@ func _apply_interaction(level: int, action_name: String) -> void:
|
|||||||
if controller == null or coordinator == null:
|
if controller == null or coordinator == null:
|
||||||
return
|
return
|
||||||
_ensure_incident(controller)
|
_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
|
var npc_id: String = controller.npc_id
|
||||||
coordinator.recorder.add_event(npc_id, {
|
coordinator.recorder.add_event(npc_id, {
|
||||||
"type": "player_action",
|
"type": "player_action",
|
||||||
@@ -1201,6 +1544,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:
|
||||||
@@ -1208,6 +1555,15 @@ func _apply_interaction(level: int, action_name: String) -> void:
|
|||||||
hud.set_escalation(level)
|
hud.set_escalation(level)
|
||||||
if audio:
|
if audio:
|
||||||
audio.play_escalation(level)
|
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)
|
_react_to_outcome(controller, outcome)
|
||||||
|
|
||||||
## Level 1 eye contact: a distinct, quieter cue than raise-hand (PLAN.md). It
|
## Level 1 eye contact: a distinct, quieter cue than raise-hand (PLAN.md). It
|
||||||
@@ -1228,6 +1584,9 @@ func _recognize_target() -> void:
|
|||||||
return
|
return
|
||||||
_ensure_incident(controller)
|
_ensure_incident(controller)
|
||||||
controller.player_recognized = true
|
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, {
|
coordinator.recorder.add_access_evidence(controller.npc_id, {
|
||||||
"kind": "recognition",
|
"kind": "recognition",
|
||||||
"source": "guard_decision",
|
"source": "guard_decision",
|
||||||
@@ -1344,7 +1703,9 @@ 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)
|
||||||
|
hits = int(ceil(float(hits) * float(_difficulty()["report"])))
|
||||||
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")
|
||||||
hud.log(Strings.fmt("radio.inaccurate", [4 * hits]))
|
hud.log(Strings.fmt("radio.inaccurate", [4 * hits]))
|
||||||
@@ -1401,6 +1762,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,10 +1973,16 @@ 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()
|
||||||
|
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)
|
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()
|
||||||
|
|
||||||
## Perception pass: NPCs notice the guard's proximity (PLAN.md: Level 0
|
## Perception pass: NPCs notice the guard's proximity (PLAN.md: Level 0
|
||||||
|
|||||||
@@ -1050,7 +1050,10 @@ func _animate(delta: float) -> void:
|
|||||||
_idle_t = 0.0
|
_idle_t = 0.0
|
||||||
_bob_phase += delta * (6.0 + speed * 1.7)
|
_bob_phase += delta * (6.0 + speed * 1.7)
|
||||||
var urgency := 1.35 if reaction_mode == "flee" else 1.0
|
var urgency := 1.35 if reaction_mode == "flee" else 1.0
|
||||||
var stride := clampf(0.36 + speed * 0.10, 0.42, 0.7) * urgency
|
# Coats and skirts follow the hip/knee bones; a shorter stride keeps the
|
||||||
|
# hem from clipping through the legs during normal walking.
|
||||||
|
var hem_scale := 0.76 if character_model in ["npc_dress", "npc_coat"] else 1.0
|
||||||
|
var stride := clampf(0.36 + speed * 0.10, 0.42, 0.7) * urgency * hem_scale
|
||||||
_rig.position.y = sin(_bob_phase * 2.0) * 0.03
|
_rig.position.y = sin(_bob_phase * 2.0) * 0.03
|
||||||
_rig.position.x = 0.0
|
_rig.position.x = 0.0
|
||||||
_rig.rotation.x = sin(_bob_phase) * 0.03
|
_rig.rotation.x = sin(_bob_phase) * 0.03
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
class_name HelpOverlay
|
||||||
|
extends CanvasLayer
|
||||||
|
## New-player help: controls, interaction flow and the current venue policy.
|
||||||
|
|
||||||
|
const Strings := preload("res://src/core/strings.gd")
|
||||||
|
const InputBindings := preload("res://src/core/input_bindings.gd")
|
||||||
|
|
||||||
|
signal closed()
|
||||||
|
|
||||||
|
var _root: Control = null
|
||||||
|
var _search: LineEdit = null
|
||||||
|
var _body: Label = null
|
||||||
|
var _policy: String = ""
|
||||||
|
var _lines: Array[String] = []
|
||||||
|
|
||||||
|
const CONTROL_LINES := [
|
||||||
|
"Movement: W/A/S/D · mouse look · Shift precision walk",
|
||||||
|
"Focus: crosshair cone · Tab / mouse wheel cycles nearby people",
|
||||||
|
"Action wheel: Space or left click · A/D or arrows step · Space confirms",
|
||||||
|
"Inspect access: E · Wand: C · Polite direction: Q",
|
||||||
|
"Warning: 4 · Blocking: 5 · Security radio: 6",
|
||||||
|
"Emergency radio: R, hold the on-screen button to confirm",
|
||||||
|
"Body camera: B · Flashlight: G · Door lock: L",
|
||||||
|
"Pause: Esc · Debug overlay: F",
|
||||||
|
]
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
layer = 23
|
||||||
|
_build()
|
||||||
|
hide()
|
||||||
|
|
||||||
|
func _build() -> void:
|
||||||
|
_root = Control.new()
|
||||||
|
_root.name = "HelpRoot"
|
||||||
|
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
_root.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
|
add_child(_root)
|
||||||
|
var dim := ColorRect.new()
|
||||||
|
dim.color = Color(0.02, 0.03, 0.05, 0.86)
|
||||||
|
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_root.add_child(dim)
|
||||||
|
|
||||||
|
var center := CenterContainer.new()
|
||||||
|
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
_root.add_child(center)
|
||||||
|
var column := VBoxContainer.new()
|
||||||
|
column.custom_minimum_size = Vector2(620, 0)
|
||||||
|
column.add_theme_constant_override("separation", 10)
|
||||||
|
center.add_child(column)
|
||||||
|
|
||||||
|
var title := Label.new()
|
||||||
|
title.text = "Help"
|
||||||
|
title.add_theme_font_size_override("font_size", 30)
|
||||||
|
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
column.add_child(title)
|
||||||
|
|
||||||
|
_search = LineEdit.new()
|
||||||
|
_search.placeholder_text = "Filter controls…"
|
||||||
|
_search.text_changed.connect(_refresh)
|
||||||
|
column.add_child(_search)
|
||||||
|
|
||||||
|
_body = Label.new()
|
||||||
|
_body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
_body.custom_minimum_size = Vector2(600, 330)
|
||||||
|
_body.vertical_alignment = VERTICAL_ALIGNMENT_TOP
|
||||||
|
column.add_child(_body)
|
||||||
|
|
||||||
|
var close := Button.new()
|
||||||
|
close.text = "Close"
|
||||||
|
close.pressed.connect(func(): close_panel())
|
||||||
|
close.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||||||
|
column.add_child(close)
|
||||||
|
|
||||||
|
func open(policy: String = "") -> void:
|
||||||
|
_policy = policy
|
||||||
|
visible = true
|
||||||
|
_root.visible = true
|
||||||
|
_search.text = ""
|
||||||
|
_refresh("")
|
||||||
|
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||||
|
|
||||||
|
func close_panel() -> void:
|
||||||
|
visible = false
|
||||||
|
_root.visible = false
|
||||||
|
closed.emit()
|
||||||
|
|
||||||
|
func _refresh(filter: String) -> void:
|
||||||
|
_lines = []
|
||||||
|
var needle := filter.strip_edges().to_lower()
|
||||||
|
var dynamic := []
|
||||||
|
for action in InputBindings.REMAPPABLE:
|
||||||
|
var label := InputBindings.label_for(InputBindings.current_descriptor(action))
|
||||||
|
dynamic.append("%s: %s" % [str(action).replace("_", " ").capitalize(), label])
|
||||||
|
for line in (CONTROL_LINES + dynamic):
|
||||||
|
if needle == "" or line.to_lower().contains(needle):
|
||||||
|
_lines.append(line)
|
||||||
|
var text := "\n".join(_lines)
|
||||||
|
if _policy != "":
|
||||||
|
text += "\n\nVenue policy: " + _policy
|
||||||
|
_body.text = text
|
||||||
|
|
||||||
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if not visible:
|
||||||
|
return
|
||||||
|
if event is InputEventKey and event.pressed and not event.echo \
|
||||||
|
and event.keycode == KEY_ESCAPE:
|
||||||
|
close_panel()
|
||||||
|
get_viewport().set_input_as_handled()
|
||||||
+3
-1
@@ -549,7 +549,7 @@ func toggle_debug() -> void:
|
|||||||
_debug_enabled = not _debug_enabled
|
_debug_enabled = not _debug_enabled
|
||||||
debug_label.visible = _debug_enabled
|
debug_label.visible = _debug_enabled
|
||||||
|
|
||||||
func refresh_debug(coordinator: Object, npcs: Array) -> void:
|
func refresh_debug(coordinator: Object, npcs: Array, preview: Array = []) -> void:
|
||||||
if not _debug_enabled:
|
if not _debug_enabled:
|
||||||
return
|
return
|
||||||
var lines: Array[String] = ["--- DEBUG ---", "shift %.1fs / %.1fs rating %.1f" % [
|
var lines: Array[String] = ["--- DEBUG ---", "shift %.1fs / %.1fs rating %.1f" % [
|
||||||
@@ -563,6 +563,8 @@ func refresh_debug(coordinator: Object, npcs: Array) -> void:
|
|||||||
Rules.NPC_TYPE_NAMES.get(profile.npc_type if profile else -1, "?"),
|
Rules.NPC_TYPE_NAMES.get(profile.npc_type if profile else -1, "?"),
|
||||||
npc.name, c.intent, c.fsm.current_state, c.highest_escalation_received,
|
npc.name, c.intent, c.fsm.current_state, c.highest_escalation_received,
|
||||||
])
|
])
|
||||||
|
if not preview.is_empty():
|
||||||
|
lines.append(" next eligible: " + ", ".join(preview))
|
||||||
debug_label.text = "\n".join(lines)
|
debug_label.text = "\n".join(lines)
|
||||||
|
|
||||||
func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -> void:
|
func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -> void:
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ func _init() -> void:
|
|||||||
action_button.text = "Send request"
|
action_button.text = "Send request"
|
||||||
action_button.button_down.connect(func(): _start_hold())
|
action_button.button_down.connect(func(): _start_hold())
|
||||||
action_button.button_up.connect(func(): _stop_hold(false))
|
action_button.button_up.connect(func(): _stop_hold(false))
|
||||||
|
action_button.pressed.connect(_on_action_pressed)
|
||||||
buttons.add_child(action_button)
|
buttons.add_child(action_button)
|
||||||
var cancel_btn := Button.new()
|
var cancel_btn := Button.new()
|
||||||
cancel_btn.text = "Cancel"
|
cancel_btn.text = "Cancel"
|
||||||
@@ -144,6 +145,12 @@ func open(controller: Object, emergency: bool, truth: Dictionary) -> void:
|
|||||||
_hold_time = 0.0
|
_hold_time = 0.0
|
||||||
visible = true
|
visible = true
|
||||||
|
|
||||||
|
## Non-emergency requests are a normal click. Emergency calls require the
|
||||||
|
## press-and-hold path, so the click release must not submit early.
|
||||||
|
func _on_action_pressed() -> void:
|
||||||
|
if not _emergency:
|
||||||
|
_submit()
|
||||||
|
|
||||||
func _start_hold() -> void:
|
func _start_hold() -> void:
|
||||||
_holding = _emergency
|
_holding = _emergency
|
||||||
_hold_time = 0.0
|
_hold_time = 0.0
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ 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 reset_campaign_requested()
|
||||||
signal closed()
|
signal closed()
|
||||||
|
|
||||||
var _root: Control = null
|
var _root: Control = null
|
||||||
@@ -19,6 +21,12 @@ var _sensitivity_value: Label = null
|
|||||||
var _font_scale_value: Label = null
|
var _font_scale_value: Label = null
|
||||||
var _invert_y: CheckButton = null
|
var _invert_y: CheckButton = null
|
||||||
var _head_bob: CheckButton = null
|
var _head_bob: CheckButton = null
|
||||||
|
var _difficulty: OptionButton = null
|
||||||
|
var _master: HSlider = null
|
||||||
|
var _sfx: HSlider = null
|
||||||
|
var _ambience: HSlider = null
|
||||||
|
var _reduce_motion: CheckButton = null
|
||||||
|
var _captions: CheckButton = null
|
||||||
var _bind_buttons := {}
|
var _bind_buttons := {}
|
||||||
var _listening_action := ""
|
var _listening_action := ""
|
||||||
var settings: Dictionary = {}
|
var settings: Dictionary = {}
|
||||||
@@ -86,6 +94,53 @@ 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 diff_row := HBoxContainer.new()
|
||||||
|
column.add_child(diff_row)
|
||||||
|
var diff_label := Label.new()
|
||||||
|
diff_label.text = "Difficulty / pacing"
|
||||||
|
diff_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
diff_row.add_child(diff_label)
|
||||||
|
_difficulty = OptionButton.new()
|
||||||
|
for item in ["easy", "normal", "hard"]:
|
||||||
|
_difficulty.add_item(item.capitalize())
|
||||||
|
_difficulty.item_selected.connect(_on_difficulty_selected)
|
||||||
|
diff_row.add_child(_difficulty)
|
||||||
|
|
||||||
|
var audio_row := HBoxContainer.new()
|
||||||
|
column.add_child(audio_row)
|
||||||
|
audio_row.add_child(_volume_label("Master volume"))
|
||||||
|
_master = _add_slider(audio_row, 0.0, 1.0, 0.05)
|
||||||
|
audio_row.add_child(_volume_label("SFX"))
|
||||||
|
_sfx = _add_slider(audio_row, 0.0, 1.0, 0.05)
|
||||||
|
audio_row.add_child(_volume_label("Ambience"))
|
||||||
|
_ambience = _add_slider(audio_row, 0.0, 1.0, 0.05)
|
||||||
|
_master.value_changed.connect(func(v): settings["master_volume"] = v)
|
||||||
|
_sfx.value_changed.connect(func(v): settings["sfx_volume"] = v)
|
||||||
|
_ambience.value_changed.connect(func(v): settings["ambience_volume"] = v)
|
||||||
|
|
||||||
|
_reduce_motion = CheckButton.new()
|
||||||
|
_reduce_motion.text = "Reduce motion"
|
||||||
|
_reduce_motion.toggled.connect(func(v): settings["reduce_motion"] = v)
|
||||||
|
column.add_child(_reduce_motion)
|
||||||
|
_captions = CheckButton.new()
|
||||||
|
_captions.text = "Captions / subtitles"
|
||||||
|
_captions.toggled.connect(func(v): settings["captions_enabled"] = v)
|
||||||
|
column.add_child(_captions)
|
||||||
|
|
||||||
|
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 reset_campaign := Button.new()
|
||||||
|
reset_campaign.name = "ResetCampaignButton"
|
||||||
|
reset_campaign.text = "Reset campaign"
|
||||||
|
reset_campaign.pressed.connect(_confirm_reset_campaign)
|
||||||
|
replay_row.add_child(reset_campaign)
|
||||||
|
|
||||||
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)
|
||||||
@@ -136,6 +191,13 @@ func _add_label_row(parent: Control, text: String) -> Label:
|
|||||||
row.add_child(value)
|
row.add_child(value)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
func _volume_label(text: String) -> Label:
|
||||||
|
var label := Label.new()
|
||||||
|
label.text = text
|
||||||
|
label.add_theme_font_size_override("font_size", 11)
|
||||||
|
label.custom_minimum_size = Vector2(82, 0)
|
||||||
|
return label
|
||||||
|
|
||||||
func _add_slider(parent: Control, min_v: float, max_v: float, step: float) -> HSlider:
|
func _add_slider(parent: Control, min_v: float, max_v: float, step: float) -> HSlider:
|
||||||
var slider := HSlider.new()
|
var slider := HSlider.new()
|
||||||
slider.min_value = min_v
|
slider.min_value = min_v
|
||||||
@@ -157,6 +219,19 @@ func open(current: Dictionary) -> void:
|
|||||||
_invert_y.set_pressed_no_signal(bool(settings.get("invert_y", false)))
|
_invert_y.set_pressed_no_signal(bool(settings.get("invert_y", false)))
|
||||||
if _head_bob:
|
if _head_bob:
|
||||||
_head_bob.set_pressed_no_signal(bool(settings.get("head_bob", true)))
|
_head_bob.set_pressed_no_signal(bool(settings.get("head_bob", true)))
|
||||||
|
if _difficulty:
|
||||||
|
var difficulty := str(settings.get("ai_difficulty", "normal"))
|
||||||
|
_difficulty.select(["easy", "normal", "hard"].find(difficulty))
|
||||||
|
if _master:
|
||||||
|
_master.set_value_no_signal(float(settings.get("master_volume", 1.0)))
|
||||||
|
if _sfx:
|
||||||
|
_sfx.set_value_no_signal(float(settings.get("sfx_volume", 1.0)))
|
||||||
|
if _ambience:
|
||||||
|
_ambience.set_value_no_signal(float(settings.get("ambience_volume", 1.0)))
|
||||||
|
if _reduce_motion:
|
||||||
|
_reduce_motion.set_pressed_no_signal(bool(settings.get("reduce_motion", false)))
|
||||||
|
if _captions:
|
||||||
|
_captions.set_pressed_no_signal(bool(settings.get("captions_enabled", true)))
|
||||||
_listening_action = ""
|
_listening_action = ""
|
||||||
_refresh_bind_buttons()
|
_refresh_bind_buttons()
|
||||||
_sync_labels()
|
_sync_labels()
|
||||||
@@ -188,6 +263,18 @@ func _on_invert_y_toggled(pressed: bool) -> void:
|
|||||||
func _on_head_bob_toggled(pressed: bool) -> void:
|
func _on_head_bob_toggled(pressed: bool) -> void:
|
||||||
settings["head_bob"] = pressed
|
settings["head_bob"] = pressed
|
||||||
|
|
||||||
|
func _on_difficulty_selected(index: int) -> void:
|
||||||
|
settings["ai_difficulty"] = ["easy", "normal", "hard"][clampi(index, 0, 2)]
|
||||||
|
|
||||||
|
func _confirm_reset_campaign() -> void:
|
||||||
|
var dialog := ConfirmationDialog.new()
|
||||||
|
dialog.title = "Reset campaign"
|
||||||
|
dialog.dialog_text = "Clear campaign progress and start again from the Hollywood shift?"
|
||||||
|
dialog.confirmed.connect(func(): reset_campaign_requested.emit())
|
||||||
|
add_child(dialog)
|
||||||
|
dialog.popup_centered()
|
||||||
|
dialog.confirmed.connect(dialog.queue_free)
|
||||||
|
|
||||||
func _refresh_bind_buttons() -> void:
|
func _refresh_bind_buttons() -> void:
|
||||||
var remaps: Dictionary = settings.get("input_bindings", {})
|
var remaps: Dictionary = settings.get("input_bindings", {})
|
||||||
for action in _bind_buttons.keys():
|
for action in _bind_buttons.keys():
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ const Strings := preload("res://src/core/strings.gd")
|
|||||||
const UiArt := preload("res://src/game/ui/art.gd")
|
const UiArt := preload("res://src/game/ui/art.gd")
|
||||||
|
|
||||||
signal restart_requested()
|
signal restart_requested()
|
||||||
|
signal continue_requested()
|
||||||
signal timeline_requested(review: Dictionary)
|
signal timeline_requested(review: Dictionary)
|
||||||
|
|
||||||
## Kept so the timeline button can hand the full review back to the game manager.
|
## Kept so the timeline button can hand the full review back to the game manager.
|
||||||
var _review: Dictionary = {}
|
var _review: Dictionary = {}
|
||||||
|
|
||||||
var report_label: Label
|
var report_label: Label
|
||||||
|
var _campaign_label: Label = null
|
||||||
|
var _continue_button: Button = null
|
||||||
|
|
||||||
func _init() -> void:
|
func _init() -> void:
|
||||||
name = "ShiftReview"
|
name = "ShiftReview"
|
||||||
@@ -46,6 +49,17 @@ func _init() -> void:
|
|||||||
report_label.custom_minimum_size = Vector2(720, 0)
|
report_label.custom_minimum_size = Vector2(720, 0)
|
||||||
center.add_child(report_label)
|
center.add_child(report_label)
|
||||||
|
|
||||||
|
_campaign_label = Label.new()
|
||||||
|
_campaign_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
_campaign_label.add_theme_color_override("font_color", Color(0.62, 0.8, 0.95))
|
||||||
|
center.add_child(_campaign_label)
|
||||||
|
|
||||||
|
_continue_button = Button.new()
|
||||||
|
_continue_button.text = "Continue campaign"
|
||||||
|
_continue_button.pressed.connect(func(): continue_requested.emit())
|
||||||
|
_continue_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||||||
|
center.add_child(_continue_button)
|
||||||
|
|
||||||
var timeline := Button.new()
|
var timeline := Button.new()
|
||||||
timeline.name = "TimelineButton"
|
timeline.name = "TimelineButton"
|
||||||
timeline.text = Strings.lookup("review.timeline_button")
|
timeline.text = Strings.lookup("review.timeline_button")
|
||||||
@@ -64,6 +78,7 @@ func _init() -> void:
|
|||||||
quit_btn.pressed.connect(func(): get_tree().quit())
|
quit_btn.pressed.connect(func(): get_tree().quit())
|
||||||
quit_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
quit_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||||||
_style_review_button(timeline)
|
_style_review_button(timeline)
|
||||||
|
_style_review_button(_continue_button)
|
||||||
_style_review_button(restart)
|
_style_review_button(restart)
|
||||||
_style_review_button(quit_btn)
|
_style_review_button(quit_btn)
|
||||||
center.add_child(quit_btn)
|
center.add_child(quit_btn)
|
||||||
@@ -84,7 +99,8 @@ func _style_review_button(button: Button) -> void:
|
|||||||
button.add_theme_stylebox_override("focus", hover)
|
button.add_theme_stylebox_override("focus", hover)
|
||||||
button.add_theme_stylebox_override("pressed", hover)
|
button.add_theme_stylebox_override("pressed", hover)
|
||||||
|
|
||||||
func show_review(review: Dictionary, scenario: Resource, history: Array = []) -> void:
|
func show_review(review: Dictionary, scenario: Resource, history: Array = [],
|
||||||
|
campaign_summary: Dictionary = {}) -> void:
|
||||||
_review = review
|
_review = review
|
||||||
var reputation: Dictionary = review.get("reputation", {})
|
var reputation: Dictionary = review.get("reputation", {})
|
||||||
var incidents: Dictionary = review.get("incidents", {})
|
var incidents: Dictionary = review.get("incidents", {})
|
||||||
@@ -115,6 +131,7 @@ func show_review(review: Dictionary, scenario: Resource, history: Array = []) ->
|
|||||||
|
|
||||||
var lines: Array[String] = [
|
var lines: Array[String] = [
|
||||||
"Venue: %s" % (scenario.display_name if scenario else review.get("scenario_id", "")),
|
"Venue: %s" % (scenario.display_name if scenario else review.get("scenario_id", "")),
|
||||||
|
"Policy: %s" % (scenario.policy_summary if scenario else "See venue policy."),
|
||||||
"Grade: %s Score: %.0f/100" % [reputation.get("grade", "?"), reputation.get("rating", 0.0)],
|
"Grade: %s Score: %.0f/100" % [reputation.get("grade", "?"), reputation.get("rating", 0.0)],
|
||||||
history_line,
|
history_line,
|
||||||
"Incidents handled: %d (peaceful %d)" % [incident_list.size(), peaceful],
|
"Incidents handled: %d (peaceful %d)" % [incident_list.size(), peaceful],
|
||||||
@@ -135,4 +152,17 @@ func show_review(review: Dictionary, scenario: Resource, history: Array = []) ->
|
|||||||
if shown >= 12:
|
if shown >= 12:
|
||||||
break
|
break
|
||||||
report_label.text = "\n".join(lines)
|
report_label.text = "\n".join(lines)
|
||||||
|
var next_venue := str(campaign_summary.get("next_venue", ""))
|
||||||
|
var campaign_complete := bool(campaign_summary.get("complete", false))
|
||||||
|
if _campaign_label:
|
||||||
|
if campaign_complete:
|
||||||
|
_campaign_label.text = "Campaign complete. Free-play venues remain available."
|
||||||
|
elif next_venue != "":
|
||||||
|
_campaign_label.text = "Campaign shift %d complete · next: %s" % [
|
||||||
|
int(campaign_summary.get("shift_number", 1)) - 1,
|
||||||
|
next_venue.replace("_", " ").capitalize()]
|
||||||
|
else:
|
||||||
|
_campaign_label.text = ""
|
||||||
|
if _continue_button:
|
||||||
|
_continue_button.visible = not campaign_complete and next_venue != ""
|
||||||
visible = true
|
visible = true
|
||||||
@@ -5,6 +5,8 @@ extends CanvasLayer
|
|||||||
## (the `V` bind stays as a debug shortcut).
|
## (the `V` bind stays as a debug shortcut).
|
||||||
|
|
||||||
signal venue_selected(scenario_id: String)
|
signal venue_selected(scenario_id: String)
|
||||||
|
signal continue_requested()
|
||||||
|
signal help_requested()
|
||||||
signal settings_requested()
|
signal settings_requested()
|
||||||
signal quit_requested()
|
signal quit_requested()
|
||||||
|
|
||||||
@@ -19,6 +21,9 @@ const VENUES := [
|
|||||||
var _index := 0
|
var _index := 0
|
||||||
var _buttons: Array[Button] = []
|
var _buttons: Array[Button] = []
|
||||||
var _root: Control = null
|
var _root: Control = null
|
||||||
|
var _status: Label = null
|
||||||
|
var _continue_button: Button = null
|
||||||
|
var _campaign: Dictionary = {}
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
layer = 20
|
layer = 20
|
||||||
@@ -88,6 +93,20 @@ func _build() -> void:
|
|||||||
subtitle.add_theme_color_override("font_color", Color(0.72, 0.75, 0.82))
|
subtitle.add_theme_color_override("font_color", Color(0.72, 0.75, 0.82))
|
||||||
column.add_child(subtitle)
|
column.add_child(subtitle)
|
||||||
|
|
||||||
|
_status = Label.new()
|
||||||
|
_status.name = "CampaignStatus"
|
||||||
|
_status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
_status.add_theme_color_override("font_color", Color(0.62, 0.78, 0.9))
|
||||||
|
column.add_child(_status)
|
||||||
|
|
||||||
|
_continue_button = Button.new()
|
||||||
|
_continue_button.name = "ContinueButton"
|
||||||
|
_continue_button.custom_minimum_size = Vector2(460, 52)
|
||||||
|
_continue_button.pressed.connect(_on_continue_pressed)
|
||||||
|
_style_venue_button(_continue_button)
|
||||||
|
column.add_child(_continue_button)
|
||||||
|
|
||||||
for i in VENUES.size():
|
for i in VENUES.size():
|
||||||
var venue: Dictionary = VENUES[i]
|
var venue: Dictionary = VENUES[i]
|
||||||
var button := Button.new()
|
var button := Button.new()
|
||||||
@@ -109,6 +128,12 @@ func _build() -> void:
|
|||||||
row.add_theme_constant_override("separation", 12)
|
row.add_theme_constant_override("separation", 12)
|
||||||
column.add_child(row)
|
column.add_child(row)
|
||||||
|
|
||||||
|
var help_button := Button.new()
|
||||||
|
help_button.name = "HelpButton"
|
||||||
|
help_button.text = "Help"
|
||||||
|
help_button.pressed.connect(func(): help_requested.emit())
|
||||||
|
row.add_child(help_button)
|
||||||
|
|
||||||
var settings_button := Button.new()
|
var settings_button := Button.new()
|
||||||
settings_button.name = "SettingsButton"
|
settings_button.name = "SettingsButton"
|
||||||
settings_button.text = "Settings"
|
settings_button.text = "Settings"
|
||||||
@@ -164,12 +189,35 @@ func _focus_index(i: int) -> void:
|
|||||||
_index = clampi(i, 0, _buttons.size() - 1)
|
_index = clampi(i, 0, _buttons.size() - 1)
|
||||||
_buttons[_index].grab_focus()
|
_buttons[_index].grab_focus()
|
||||||
|
|
||||||
func open() -> void:
|
func open(campaign: Dictionary = {}) -> void:
|
||||||
|
_campaign = campaign.duplicate(true)
|
||||||
visible = true
|
visible = true
|
||||||
_root.visible = true
|
_root.visible = true
|
||||||
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||||
|
var next_id := str(_campaign.get("next_venue", ""))
|
||||||
|
var complete := bool(_campaign.get("complete", false))
|
||||||
|
if _status:
|
||||||
|
if complete:
|
||||||
|
_status.text = "Campaign complete — pick any venue for a free shift."
|
||||||
|
elif next_id != "":
|
||||||
|
_status.text = "Campaign shift %d · next: %s" % [
|
||||||
|
int(_campaign.get("shift_number", 1)), _venue_label(next_id)]
|
||||||
|
if _continue_button:
|
||||||
|
var next_label := _venue_label(next_id)
|
||||||
|
_continue_button.text = "Continue shift — %s" % next_label if next_id != "" else "Campaign complete"
|
||||||
|
_continue_button.visible = not complete
|
||||||
_focus_index(_index)
|
_focus_index(_index)
|
||||||
|
|
||||||
|
func _venue_label(id: String) -> String:
|
||||||
|
for venue in VENUES:
|
||||||
|
if str(venue["id"]) == id:
|
||||||
|
return str(venue["label"])
|
||||||
|
return id.replace("_", " ").capitalize()
|
||||||
|
|
||||||
|
func _on_continue_pressed() -> void:
|
||||||
|
close()
|
||||||
|
continue_requested.emit()
|
||||||
|
|
||||||
func close() -> void:
|
func close() -> void:
|
||||||
visible = false
|
visible = false
|
||||||
_root.visible = false
|
_root.visible = false
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Campaign shell + checkpoint smoke. Use an isolated user-data dir:
|
||||||
|
## XDG_DATA_HOME=/tmp/gigline-campaign godot --path . --fixed-fps 60 \
|
||||||
|
## --script res://src/test/smoke_campaign.gd
|
||||||
|
|
||||||
|
var gm: Node = null
|
||||||
|
var frames := 0
|
||||||
|
var step := 0
|
||||||
|
var failures: Array[String] = []
|
||||||
|
|
||||||
|
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
|
||||||
|
match step:
|
||||||
|
0:
|
||||||
|
if frames < 5: return false
|
||||||
|
gm.call("_on_venue_selected", "hollywood_building")
|
||||||
|
gm.coordinator.elapsed_s = 123.0
|
||||||
|
gm.call("_save_shift_checkpoint")
|
||||||
|
gm.call("_show_start_menu")
|
||||||
|
_check(gm.start_menu.visible, "campaign menu opens")
|
||||||
|
_check(gm.start_menu.find_child("ContinueButton", true, false) != null,
|
||||||
|
"continue button exists")
|
||||||
|
step = 1
|
||||||
|
frames = 0
|
||||||
|
1:
|
||||||
|
if frames < 4: return false
|
||||||
|
gm.call("_on_campaign_continue")
|
||||||
|
step = 2
|
||||||
|
frames = 0
|
||||||
|
2:
|
||||||
|
if frames < 20: return false
|
||||||
|
_check(gm.active_scenario_id == "hollywood_building", "resume keeps the venue")
|
||||||
|
_check(gm.coordinator.elapsed_s >= 123.0, "resume keeps elapsed time")
|
||||||
|
gm.call("_on_shift_ended", gm.coordinator.build_review())
|
||||||
|
_check(gm.campaign.shift_number == 2, "campaign advances after a shift")
|
||||||
|
_check(gm.campaign.next_venue() == "joke_shop", "next venue is the Joke Shop")
|
||||||
|
gm.call("_on_review_continue")
|
||||||
|
step = 3
|
||||||
|
frames = 0
|
||||||
|
3:
|
||||||
|
if frames < 20: return false
|
||||||
|
_check(gm.active_scenario_id == "joke_shop", "review continue starts the next venue")
|
||||||
|
gm.call("_open_help")
|
||||||
|
_check(gm.help_overlay.visible, "help overlay opens")
|
||||||
|
gm.help_overlay.close_panel()
|
||||||
|
_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: campaign, checkpoint resume, review continue, help")
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
for message in failures: printerr("SMOKE FAIL: " + message)
|
||||||
|
quit(1)
|
||||||
@@ -9,7 +9,7 @@ extends SceneTree
|
|||||||
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
|
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
|
||||||
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||||
const Rules := preload("res://src/core/rules.gd")
|
const Rules := preload("res://src/core/rules.gd")
|
||||||
const COUNT := 16
|
const COUNT := 24
|
||||||
const WARMUP_FRAMES := 60
|
const WARMUP_FRAMES := 60
|
||||||
const SAMPLE_FRAMES := 180
|
const SAMPLE_FRAMES := 180
|
||||||
const BUDGET_MS := 50.0
|
const BUDGET_MS := 50.0
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -66,6 +66,18 @@ func test_radio_crackle_methods() -> bool:
|
|||||||
audio.free()
|
audio.free()
|
||||||
return a and b
|
return a and b
|
||||||
|
|
||||||
|
func test_volume_settings_are_stored_and_applied() -> bool:
|
||||||
|
var audio: Node = AudioFeedback.new()
|
||||||
|
audio.set_volumes(0.8, 0.5, 0.2)
|
||||||
|
var a := TestUtils.check(is_equal_approx(audio.master_volume, 0.8), "master volume stores")
|
||||||
|
var b := TestUtils.check(is_equal_approx(audio.sfx_volume, 0.5), "sfx volume stores")
|
||||||
|
var c := TestUtils.check(is_equal_approx(audio.ambience_volume, 0.2), "ambience volume stores")
|
||||||
|
audio.set_volumes(3.0, -1.0, 0.5)
|
||||||
|
var d := TestUtils.check(is_equal_approx(audio.master_volume, 1.0)
|
||||||
|
and is_equal_approx(audio.sfx_volume, 0.0), "volumes clamp")
|
||||||
|
audio.free()
|
||||||
|
return a and b and c and d
|
||||||
|
|
||||||
func test_game_manager_wires_audio() -> bool:
|
func test_game_manager_wires_audio() -> bool:
|
||||||
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
|
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
|
||||||
var a := TestUtils.check(src.contains("audio.start_crowd_bed()"), "the bed starts with the shift")
|
var a := TestUtils.check(src.contains("audio.start_crowd_bed()"), "the bed starts with the shift")
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Campaign progression and serialization tests.
|
||||||
|
|
||||||
|
const Campaign := preload("res://src/core/campaign.gd")
|
||||||
|
|
||||||
|
func test_campaign_advances_in_order_and_completes() -> bool:
|
||||||
|
var campaign := Campaign.new()
|
||||||
|
campaign.setup()
|
||||||
|
var a := TestUtils.check_eq(campaign.next_venue(), "hollywood_building", "starts at Hollywood")
|
||||||
|
var b := TestUtils.check(campaign.record_shift("hollywood_building", {"reputation": {"grade": "B"}}),
|
||||||
|
"records the first shift")
|
||||||
|
var c := TestUtils.check_eq(campaign.next_venue(), "joke_shop", "advances to the Joke Shop")
|
||||||
|
campaign.record_shift("joke_shop", {"reputation": {"grade": "A"}})
|
||||||
|
campaign.record_shift("tv_studio", {"reputation": {"grade": "A"}})
|
||||||
|
var d := TestUtils.check(campaign.complete and campaign.next_venue() == "", "completes after three venues")
|
||||||
|
var e := TestUtils.check_eq(campaign.last_grade, "A", "stores the last grade")
|
||||||
|
return a and b and c and d and e
|
||||||
|
|
||||||
|
func test_campaign_round_trips_and_resets() -> bool:
|
||||||
|
var campaign := Campaign.new()
|
||||||
|
campaign.setup({"index": 1, "shift_number": 2, "difficulty": "hard",
|
||||||
|
"completed": ["hollywood_building"], "last_grade": "C"})
|
||||||
|
var data := campaign.summary()
|
||||||
|
var restored := Campaign.new()
|
||||||
|
restored.setup(data)
|
||||||
|
var a := TestUtils.check_eq(restored.next_venue(), "joke_shop", "restores the next venue")
|
||||||
|
var b := TestUtils.check_eq(restored.difficulty, "hard", "restores difficulty")
|
||||||
|
var c := TestUtils.check_eq(restored.completed, ["hollywood_building"], "restores completion")
|
||||||
|
restored.reset()
|
||||||
|
var d := TestUtils.check(not restored.complete and restored.next_venue() == "hollywood_building",
|
||||||
|
"reset returns to the first venue")
|
||||||
|
return a and b and c and d
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Context-line selection is deterministic and falls back safely.
|
||||||
|
|
||||||
|
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||||
|
const NpcController := preload("res://src/core/npc_controller.gd")
|
||||||
|
const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
|
||||||
|
|
||||||
|
func test_context_lines_are_deterministic_and_fallback() -> bool:
|
||||||
|
var profile := ScenarioFactory.hollywood_building().profile_for(0)
|
||||||
|
var rng_a := RandomNumberGenerator.new(); rng_a.seed = 7
|
||||||
|
var rng_b := RandomNumberGenerator.new(); rng_b.seed = 7
|
||||||
|
var a := profile.line_for("refusal", rng_a)
|
||||||
|
var b := profile.line_for("refusal", rng_b)
|
||||||
|
var c := profile.line_for("reason", rng_a)
|
||||||
|
var d := TestUtils.check(a != "" and a == b, "context lines are deterministic")
|
||||||
|
var e := TestUtils.check(c != "", "reason falls back to dialogue_lines")
|
||||||
|
var f := TestUtils.check(profile.line_for("missing", rng_a) == "",
|
||||||
|
"unknown context with no fallback is empty")
|
||||||
|
return d and e and f
|
||||||
|
|
||||||
|
func test_request_reason_uses_reason_context() -> bool:
|
||||||
|
var profile := ScenarioFactory.hollywood_building().profile_for(0)
|
||||||
|
var controller := NpcController.new()
|
||||||
|
controller.setup("dialogue_test", profile, "enter", 3)
|
||||||
|
var reply := controller.request_reason()
|
||||||
|
return TestUtils.check(reply == controller.last_reason_reply and reply != "",
|
||||||
|
"request_reason stores the authored reply")
|
||||||
@@ -81,7 +81,9 @@ func test_settings_defaults_present() -> bool:
|
|||||||
var ok := TestUtils.check(
|
var ok := TestUtils.check(
|
||||||
defaults.has("look_sensitivity") and defaults.has("hud_font_scale")
|
defaults.has("look_sensitivity") and defaults.has("hud_font_scale")
|
||||||
and defaults.has("invert_y") and defaults.has("head_bob")
|
and defaults.has("invert_y") and defaults.has("head_bob")
|
||||||
and defaults.has("input_bindings"),
|
and defaults.has("input_bindings")
|
||||||
|
and defaults.has("master_volume") and defaults.has("sfx_volume")
|
||||||
|
and defaults.has("ambience_volume") and defaults.has("captions_enabled"),
|
||||||
"settings have documented defaults")
|
"settings have documented defaults")
|
||||||
save.free()
|
save.free()
|
||||||
return ok
|
return ok
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Radio form submission: normal calls send on click; emergency calls require
|
||||||
|
## the documented hold and do not submit on a quick release.
|
||||||
|
|
||||||
|
const RadioPanel := preload("res://src/game/ui/radio_panel.gd")
|
||||||
|
|
||||||
|
func test_normal_radio_sends_on_click() -> bool:
|
||||||
|
var panel: RadioPanel = RadioPanel.new()
|
||||||
|
var got := {}
|
||||||
|
panel.submitted.connect(func(report, emergency):
|
||||||
|
got["report"] = report
|
||||||
|
got["emergency"] = emergency)
|
||||||
|
panel.open(null, false, {})
|
||||||
|
panel.action_button.pressed.emit()
|
||||||
|
var a := TestUtils.check(got.has("report"), "normal click emits a report")
|
||||||
|
var b := TestUtils.check(not bool(got.get("emergency", true)), "normal report is not emergency")
|
||||||
|
var c := TestUtils.check(not panel.visible, "panel closes after sending")
|
||||||
|
var d := TestUtils.check_eq(str(got.get("report", {}).get("location", "")), "front entrance",
|
||||||
|
"default location is included")
|
||||||
|
panel.free()
|
||||||
|
return a and b and c and d
|
||||||
|
|
||||||
|
func test_emergency_requires_hold() -> bool:
|
||||||
|
var panel: RadioPanel = RadioPanel.new()
|
||||||
|
var calls := {"count": 0}
|
||||||
|
panel.submitted.connect(func(_report, _emergency):
|
||||||
|
calls["count"] = int(calls["count"]) + 1)
|
||||||
|
panel.open(null, true, {})
|
||||||
|
panel.action_button.pressed.emit()
|
||||||
|
var a := TestUtils.check_eq(int(calls["count"]), 0, "quick emergency click does not send")
|
||||||
|
panel.action_button.button_down.emit()
|
||||||
|
panel._process(0.2)
|
||||||
|
panel.action_button.button_up.emit()
|
||||||
|
var b := TestUtils.check_eq(int(calls["count"]), 0, "short hold does not send")
|
||||||
|
panel.action_button.button_down.emit()
|
||||||
|
panel._process(RadioPanel.HOLD_NEEDED + 0.1)
|
||||||
|
var c := TestUtils.check_eq(int(calls["count"]), 1, "full hold sends exactly once")
|
||||||
|
panel.free()
|
||||||
|
return a and b and c
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Content-matrix validation for all authored venues.
|
||||||
|
|
||||||
|
const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
|
||||||
|
const ScenarioValidator := preload("res://src/core/scenario_validator.gd")
|
||||||
|
const EventScheduler := preload("res://src/core/event_scheduler.gd")
|
||||||
|
|
||||||
|
func test_all_authored_venues_validate() -> bool:
|
||||||
|
var problems: Array[String] = []
|
||||||
|
for scenario in [ScenarioFactory.hollywood_building(), ScenarioFactory.joke_shop(),
|
||||||
|
ScenarioFactory.tv_studio()]:
|
||||||
|
for problem in ScenarioValidator.validate(scenario):
|
||||||
|
problems.append("%s: %s" % [scenario.id, problem])
|
||||||
|
return TestUtils.check(problems.is_empty(),
|
||||||
|
"venue content matrices validate (%s)" % str(problems))
|
||||||
|
|
||||||
|
func test_validator_rejects_duplicate_and_missing_profile() -> bool:
|
||||||
|
var scenario := ScenarioFactory.hollywood_building()
|
||||||
|
scenario.event_pool.append({
|
||||||
|
"id": "tourist_photo", "phase": 0, "kind": "routine",
|
||||||
|
"npc_type": 999, "intent": "photo",
|
||||||
|
})
|
||||||
|
var problems := ScenarioValidator.validate(scenario)
|
||||||
|
var a := TestUtils.check(problems.any(func(p): return str(p).contains("duplicate")),
|
||||||
|
"duplicate ids are rejected")
|
||||||
|
var b := TestUtils.check(problems.any(func(p): return str(p).contains("missing profile")),
|
||||||
|
"missing profiles are rejected")
|
||||||
|
return a and b
|
||||||
|
|
||||||
|
func test_scripted_events_do_not_repeat_immediately() -> bool:
|
||||||
|
var scenario := ScenarioFactory.hollywood_building()
|
||||||
|
scenario.event_pool = [{
|
||||||
|
"id": "matrix_probe", "phase": 0, "kind": "scripted",
|
||||||
|
"npc_type": 0, "intent": "enter", "time_range_s": Vector2(0, 900),
|
||||||
|
"probability": 1.0, "base_weight": 1.0,
|
||||||
|
}]
|
||||||
|
var scheduler := EventScheduler.new()
|
||||||
|
scheduler.setup(scenario, 7)
|
||||||
|
var first := scheduler.pull_due(10.0, 0)
|
||||||
|
var second := scheduler.pull_due(11.0, 0)
|
||||||
|
var a := TestUtils.check(first.size() == 1, "first scripted event is eligible")
|
||||||
|
var b := TestUtils.check(second.is_empty(), "same scripted event is suppressed next pull")
|
||||||
|
return a and b
|
||||||
|
|
||||||
|
func test_spawn_multiplier_scales_routine_traffic() -> bool:
|
||||||
|
var scenario := ScenarioFactory.hollywood_building()
|
||||||
|
var scheduler := EventScheduler.new()
|
||||||
|
scheduler.setup(scenario, 3)
|
||||||
|
scheduler.spawn_rate_multiplier = 0.0
|
||||||
|
var quiet := 0
|
||||||
|
for i in 20:
|
||||||
|
quiet += scheduler.fallback_routine_traffic(0).size()
|
||||||
|
var a := TestUtils.check(quiet == 0, "zero difficulty multiplier suppresses routine traffic")
|
||||||
|
scheduler.spawn_rate_multiplier = 1.0
|
||||||
|
var active := 0
|
||||||
|
for i in 20:
|
||||||
|
active += scheduler.fallback_routine_traffic(0).size()
|
||||||
|
var b := TestUtils.check(active > 0, "normal difficulty sends routine traffic")
|
||||||
|
return a and b
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Mid-shift checkpoint data survives a JSON round-trip and restores the
|
||||||
|
## coordinator's systems without replaying completed interactions.
|
||||||
|
|
||||||
|
const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
|
||||||
|
const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd")
|
||||||
|
const IncidentRecorder := preload("res://src/core/incident_recorder.gd")
|
||||||
|
const Rules := preload("res://src/core/rules.gd")
|
||||||
|
|
||||||
|
func test_coordinator_snapshot_round_trip() -> bool:
|
||||||
|
var scenario := ScenarioFactory.hollywood_building()
|
||||||
|
var first := ShiftCoordinator.new()
|
||||||
|
first.setup(scenario, 900.0, 42)
|
||||||
|
first.start()
|
||||||
|
first.elapsed_s = 123.0
|
||||||
|
first.recorder.start_incident("guest", "focus")
|
||||||
|
first.recorder.add_escalation("guest", Rules.Level.FORMAL_WARNING, "warning")
|
||||||
|
first.recorder.close_incident("guest", "peaceful")
|
||||||
|
first.reputation.apply("peaceful", 5.0, "test")
|
||||||
|
first.crowd.mood = 0.4
|
||||||
|
first.radio.battery = 70.0
|
||||||
|
var payload: Dictionary = JSON.parse_string(JSON.stringify(first.snapshot()))
|
||||||
|
var second := ShiftCoordinator.new()
|
||||||
|
second.setup(scenario, 900.0, 42)
|
||||||
|
second.restore(payload)
|
||||||
|
var a := TestUtils.check(is_equal_approx(second.elapsed_s, 123.0), "elapsed time restores")
|
||||||
|
var b := TestUtils.check_eq(second.recorder.to_dict()["open_count"], 0, "incident state restores")
|
||||||
|
var c := TestUtils.check_eq(second.recorder.to_dict()["incidents"].size(), 1,
|
||||||
|
"closed incidents restore")
|
||||||
|
var d := TestUtils.check(is_equal_approx(second.reputation.rating, 75.0), "reputation restores")
|
||||||
|
var e := TestUtils.check(is_equal_approx(second.crowd.mood, 0.4), "crowd mood restores")
|
||||||
|
var f := TestUtils.check(is_equal_approx(second.radio.battery, 70.0), "radio battery restores")
|
||||||
|
return a and b and c and d and e and f
|
||||||
|
|
||||||
|
func test_open_incident_snapshot_restores_evidence() -> bool:
|
||||||
|
var recorder := IncidentRecorder.new()
|
||||||
|
recorder.start_incident("guest", "focus")
|
||||||
|
recorder.add_access_evidence("guest", {"kind": "wristband", "status": "valid"})
|
||||||
|
var restored := IncidentRecorder.new()
|
||||||
|
restored.restore(JSON.parse_string(JSON.stringify(recorder.snapshot())))
|
||||||
|
var a := TestUtils.check(restored.has_open_incident("guest"), "open incident restores")
|
||||||
|
restored.add_event("guest", {"type": "player_action"})
|
||||||
|
var b := TestUtils.check(restored.open_incidents_count() == 1, "restored incident accepts events")
|
||||||
|
return a and b
|
||||||
Reference in new issue
Block a user