Compare commits

..
33 Commits
Author SHA1 Message Date
zwitschi f2863abeac Draw prop models with a single atlas material
Full build / Import + headless tests (push) Successful in 33s
Full build / Full desktop build + optional release (push) Successful in 3m39s
Each generated prop used one material per part (body, accent, metal...). The
model pipeline now packs a prop's palette into one power-of-two colour atlas plus a
matching metallic/roughness atlas and emits a single material:

- atlas_for(): 8x8 cells per palette role, colours in sRGB, roughness in the G and
  metallic in the B channel of the second atlas; NEAREST sampling keeps cells flat.
- build_glb(): every part merges into one primitive with constant per-vertex atlas
  UVs; the material uses baseColorTexture + metallicRoughnessTexture with factors 1.0.
- audit_glb(): fails unless a prop has exactly one material, both atlas textures,
  two images, and TEXCOORD_0 on every primitive.
- Removed the now-unused sRGB->linear factor helpers; colour travels in the texture.

Verification: headless 181 -> 182 (new "one material per prop scene" test), Python
tooling 53/53 (atlas determinism/size/UV/palette-override tests replace the old
factor tests), model audit OK for all six props, screenshots regenerated and
screen_designs.py compare still 6 good / 0 bad. Version 0.3.1.
2026-10-31 23:47:15 +01:00
zwitschi 7f92b8861d List the Web build in the README downloads table
Full build / Full desktop build + optional release (push) Skipped
Full build / Import + headless tests (push) Successful in 25s
The v0.3.0 release now carries step-line-web-0.3.0.zip (37.5 MB, 63 MB unzipped);
downloads anonymously and serves from any static host. Docs only.
2026-10-31 23:13:43 +01:00
zwitschi afa1d6beec v0.3.0: rendering pass, generated props, selection model and Web build
Full build / Import + headless tests (push) Successful in 36s
Full build / Full desktop build + optional release (push) Successful in 4m17s
Rendering and presentation
- Shared surface-material module (src/game/materials.gd) with per-surface
  roughness/metallic/uv profiles; ACES tonemapping, glow, fog and phase-driven sky
  energy. WorldBuilder rebuilt on those profiles with facade windows that light up,
  door trim/handles, curb, lane dashes, bollards, bench, bin and planter foliage.
- NPC silhouettes: per-role clothing palettes, caps/hoods/backpacks/sunglasses,
  hands and shoes, arm/leg walk swing.
- Six generated low-poly GLB props (bollards, bench, bin, planter, newspaper box,
  hydrant) built by tools/model_pipeline.py, loaded by WorldBuilder with a
  procedural fallback; ADR-0012.

Selection and movement
- Focus ranking by view angle then distance (NpcFocus.rank_indices); Tab and the
  mouse wheel cycle nearby people initially, right click clears focus with a
  re-acquire grace, HUD shows the candidate count, Shift walks at half speed.
  ADR-0011. Windowed smoke extended to cover wheel cycling and clearing.

QA tooling
- screen_designs.py catalogue grown to 19 screens (8 scene, 11 UI) plus a `list`
  subcommand; draft generation keeps existing AI references unless --force.

Web export
- New Web preset using the non-threaded Godot template (static hosting, no
  COOP/COEP). tools/build_releases.sh exports build/web and packages
  build/step-line-web-<ver>.zip; publish_release.sh uploads it with the desktop
  artifacts. test_web_export.gd covers the preset and release wiring.

Verification
- Headless suite 177 -> 181, Python tooling 53 tests, all eight smokes pass
  (including the windowed focus/wheel smoke). Web bundle serves index.html,
  index.wasm (35 MB) and index.pck (30 MB) with HTTP 200 from a static server.
- Version 0.3.0; README, PLAN, RELEASING, TODO/DONE and MEMORY updated.
2026-10-31 23:07:41 +01:00
zwitschi f763cff744 Export venues as editor-authorable .tres resources
Full build / Import + headless tests (push) Successful in 34s
Full build / Full desktop build + optional release (push) Successful in 3m51s
- tools/export_venues.gd (SceneTree script) saves the three factory scenarios to
  content/venues/<id>.tres via ResourceSaver. The factory stays the source of truth;
  the exported files are what an editor workflow would edit and ship.
- New tests load the files back and check that id, name, event pool, profiles,
  policy (ID/wand), equipment, door-staff lines, shift phases, and locations all
  survive. They also fail when the exported files are missing or stale, so the
  export is re-run after content changes.
- GUT is consciously not adopted: the custom headless runner already covers 157
  assertions without an editor plugin and keeps CI dependency-free. Recorded in
  DONE.md.
- TODO: the planned backlog is complete; only optional future ideas remain.
- README/MEMORY updated. Version 0.2.16.
2026-10-31 22:16:55 +01:00
zwitschi 72b9378e69 Add a vision-model screenshot judge to the QA tooling
Full build / Import + headless tests (push) Successful in 32s
Full build / Full desktop build + optional release (push) Successful in 3m30s
`tools/screen_designs.py judge` sends each capture together with its concept
mockup to a vision model and asks whether the capture realises the same screen and
layout intent. This covers what pixel/perceptual metrics cannot judge.

- Prompt explicitly ignores art style, fidelity, fonts, and placeholder text, since
  the references are stylised mockups.
- Verdicts are classed match / review (mismatch below --fail-confidence, default
  0.6) / diff (confident mismatch, exits non-zero) / unknown; --json, --only,
  --dry-run, --model supported.
- Reads the repo .env for OPENROUTER_API_KEY without overriding real env vars.
- Default judge model google/gemini-2.5-flash-lite; openai/gpt-4o-mini verified too.

Tests/docs
- 7 new offline tests in tools/test_screen_designs.py (request payload embeds both
  images, JSON/fenced/parts/non-JSON parsing, threshold classification, .env
  loading); 30 Python tests pass.
- Live run: 3 captures match, 3 review, 0 confident diffs.
- README, TODO section moved to DONE, MEMORY. Version 0.2.15.
2026-10-31 22:11:45 +01:00
zwitschi 74f14014b2 Enable anonymous downloads on the host and harden Strings.fmt
Full build / Import + headless tests (push) Successful in 33s
Full build / Full desktop build + optional release (push) Successful in 3m31s
Host (applied with root@192.168.88.30)
- [service] REQUIRE_SIGNIN_VIEW = false in /etc/gitea/app.ini (backup written
  alongside), Gitea restarted. Verified anonymously: repo page 200, API 200, and a
  release asset downloads at full size (50.8 MB AppImage).

Localization safety (Open Polish)
- Strings.placeholder_count() counts conversions and ignores %% escapes.
- Strings.fmt() validates the count and fails safe (warns, returns the template)
  instead of raising an engine error when a key's placeholders drift.
- Strings.validate() lints the table for a trailing %, unsupported conversions, and
  mixed positional/sequential placeholders.
- Tests cover the counter, the fail-safe path, a clean table, and a formatting pass
  over every placeholder key with type-correct arguments.

Docs
- TODO: distribution, release-pipeline, and open-polish sections are complete and
  moved to DONE; the v0.2.4-v0.2.8 backfill is closed as superseded.
- README/MEMORY updated. Version 0.2.14.
2026-10-31 22:02:24 +01:00
zwitschi a2a0ded527 Author the missing scenario content from the PLAN lists
Full build / Full desktop build + optional release (push) Successful in 3m26s
Full build / Import + headless tests (push) Successful in 35s
Joke Shop
- Reservation list without ID (guest list valid, ID missing) — denied.
- Borrowed/mismatched ID (ID valid but mismatched) — denied.
- Guest who refuses the wand screen: new `refuses_screening` event flag, copied
  onto NPCController, handled in _toggle_wand_screening (logs wand.refused, records
  a wand_refused event, small prop hit, panel stays closed).
- Late arrival during the performance (ID + ticket, "arrive" intent).

Studio
- Filming-boundary event and a later repeat, both using the new `film_boundary`
  intent which maps to `steps_end` so the influencer stands past the posted line.
- Celebrity arrival now pulls in a small crowd via a new `extra_spawns` event field
  (two tourists + an influencer), spawned through the normal path.

Building
- Filming-boundary event whose hint points at the body camera and request-reason
  verbs, plus an unclear-intent event that hints at asking for a reason.

Engine support
- Events may carry `hint` (string key logged on spawn), `extra_spawns`, and
  `refuses_screening`; 9 new localized hint strings.

Tests/docs
- Headless suite 139 -> 149 (test_scenario_content.gd) and a new
  smoke_scenario_content.gd that spawns the refusal and celebrity events through the
  real signal path.
- README, TODO section moved to DONE, MEMORY. Version 0.2.13.
2026-10-31 21:55:24 +01:00
zwitschi 922a990c7a Record verified CI publishing and host maintenance
Full build / Import + headless tests (push) Successful in 24s
Full build / Full desktop build + optional release (push) Skipped
- v0.2.12 was tagged and CI produced the release with all five artifacts by itself,
  confirming the checkout-credential fallback end to end.
- Host maintenance through root@192.168.88.30: apt cache cleaned and journald capped
  at 300M (journal ~133M now), ~7.6G free of 16G.
- TODO release-pipeline section is fully closed; MEMORY records the host state.
2026-10-31 21:51:41 +01:00
zwitschi f0cec192a7 Restore pruned releases and verify CI publishing with v0.2.12
Full build / Full desktop build + optional release (push) Successful in 3m29s
Full build / Import + headless tests (push) Successful in 32s
- Host root filesystem was expanded from 7.8G to 16G (~7.8G free), so the disk
  blocker is gone.
- Re-published v0.2.0 (4 artifacts) and v0.2.9 (5 artifacts) from the local build/
  outputs; both releases verify.
- Root SSH from the workstation is not available (publickey denied, password auth
  closes the banner); the host is administered via the zwitschi login.
- Version 0.2.12 exists to verify the tag pipeline end to end: the workflow should
  create the release via the checkout-credential fallback and upload all five
  artifacts.
2026-10-31 21:46:09 +01:00
zwitschi 2dba562c52 Publish v0.2.11 and document the host disk limit
Full build / Import + headless tests (push) Successful in 26s
Full build / Full desktop build + optional release (push) Skipped
- Pruned the superseded v0.2.0 and v0.2.9 releases through the API (~565 MB of
  assets) after the host root filesystem hit 100%, which blocked release uploads
  (HTTP 500 "no space left on device") and even git push ("unpacker error").
- Published v0.2.11 locally with the fixed script: all five artifacts verified on
  the release (Linux/Windows/macOS ports, NSIS installer, AppImage).
- TODO records the remaining ~150 MB headroom and the exact host cleanup commands;
  MEMORY and RELEASING describe the disk failure mode and the pruning.
2026-10-31 21:39:15 +01:00
zwitschi afd086ba11 Fail loudly when release uploads do not land
Full build / Import + headless tests (push) Successful in 35s
Full build / Full desktop build + optional release (push) Skipped
Follow-up to the publishing fix: CI created the v0.2.11 release and logged each
upload, but the assets never arrived, so the job looked successful while the
release was empty. The host root filesystem is full (7.8G, 100%), and Gitea
answered HTTP 500 "Attachments.Save: no space left on device".

- publish_release.sh now checks the HTTP status of every asset upload and prints the
  response body on failure, then verifies the release reports as many assets as were
  sent. Empty releases can no longer pass silently.
- Deleted the empty v0.2.11 release so the "latest" release (v0.2.10) still has all
  five artifacts; the tag itself remains.
- TODO/MEMORY record the host disk blocker with concrete cleanup commands and note
  that v0.2.11 artifacts are built locally in build/ for upload once space exists.
2026-10-31 21:38:12 +01:00
zwitschi b4d189c325 Publish releases with the runner's checkout credentials
Full build / Import + headless tests (push) Successful in 36s
Full build / Full desktop build + optional release (push) Successful in 3m37s
The v0.2.10 tag build still failed to publish: its env dump showed no token at all,
because this Gitea/act runner does not provide `github.token`, and the repo has no
GITEA_TOKEN secret.

publish_release.sh now resolves credentials in order: GITEA_TOKEN,
GITEA_AUTH_HEADER, ~/.git-credentials, then the local git extraheader that
actions/checkout writes for the job. That header carries the runner's basic-auth
credentials for the same server, so tag builds publish with no repo secret.

Verified by adding the extraheader locally and publishing v0.2.10 (all five
artifacts: Linux/Windows/macOS ports, NSIS installer, AppImage), then removing it.

Docs: RELEASING describes the four credential sources and warns that github.token is
not available here; MEMORY records both failed attempts and the final fix; TODO keeps
the "verify on the next tag" follow-up. Version 0.2.11.
2026-10-31 21:30:24 +01:00
zwitschi 6684d36739 Fix release publishing and add a CI token fallback
Full build / Import + headless tests (push) Successful in 26s
Full build / Full desktop build + optional release (push) Failing after 3m58s
Tag builds since v0.2.2 built all artifacts but never published a release:
- tools/publish_release.sh only assembled AUTH in the GITEA_TOKEN branch; the
  ~/.git-credentials branch set CRED and then used an unbound ${AUTH[@]} under
  `set -u`, so every API call failed and the job ended "could not create or find
  release". It now sets AUTH=(-u "$CRED").
- The repo has no GITEA_TOKEN secret and the runner has no git credentials, so the
  workflow now falls back to the built-in workflow token:
  GITEA_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}.

v0.2.9 was then built and published locally with all five artifacts (portable
Linux/Windows/macOS, NSIS installer, AppImage) and the release page verified via
the API. v0.2.4-v0.2.8 remain unpublished; they are superseded by the latest.

Docs
- docs/RELEASING.md records the incident and the local-publish fallback.
- TODO gains a release-pipeline follow-up (verify CI on the next tag).
- MEMORY notes the root cause, the missing secret, and which releases exist.
- Version 0.2.10.
2026-10-31 21:23:09 +01:00
zwitschi 3f4ac7e9aa Add accessibility pass: escalation meter and working HUD font scale
Full build / Import + headless tests (push) Successful in 33s
Full build / Full desktop build + optional release (push) Failing after 3m31s
- Escalation cue now includes a shape meter and numeric level
  ("Escalation: Blocking [###--] L3/5") in addition to the colour and name, so it
  reads in grayscale. Hud.escalation_meter(level) is pure and tested.
- HUD font scale from settings actually works: set_font_scale() resizes fonts via
  theme overrides instead of Control.scale, which pushed the right-anchored clock
  label off-screen. It now covers the top, log, target, hint, time, mode,
  escalation, cooldown, and debug labels.
- Settings load before the HUD exists, so _ready() re-applies player settings after
  _build_ui(); previously a saved font scale was ignored until the next apply.

Tests/docs
- Headless suite 134 -> 139 (test_accessibility.gd).
- Verified visually at 1.4x scale: no overflow, meter readable.
- README, TODO section moved to DONE, MEMORY. Version 0.2.9.
2026-10-31 21:17:42 +01:00
zwitschi 294ac17333 Add procedural crowd ambience and radio crackle
Full build / Import + headless tests (push) Successful in 45s
Full build / Full desktop build + optional release (push) Failing after 3m53s
- AudioFeedback.start_crowd_bed() builds a 4 s looping AudioStreamWAV at runtime
  (one-pole filtered noise plus a 68/103 Hz drone, seeded) and set_crowd_level()
  maps crowd mood 0..1 to -34..-12 dB, so the street gets louder as it gets
  agitated or panics. The bed starts with the shift and stops at shift end.
- play_radio_open()/play_radio_close() push short filtered-noise bursts; wired to
  opening the radio panel, submitting a report, cancelling, and closing the panel.
- Still asset-free and headless-safe: with a dummy audio server the calls no-op.
  _free_player() returns null before _ready and callers handle it.

Tests/docs
- Headless suite 127 -> 134 (test_audio_feedback.gd).
- New smoke_audio.gd: bed playing, level follows mood events, radio crackles once
  per open/close, bed stops at shift end. Verified windowed (real audio server).
- README, TODO section moved to DONE, MEMORY. Version 0.2.8.
2026-10-31 21:08:36 +01:00
zwitschi e2c2a0bf4c Add NPC theater poses: sit, photo, film, perform
Full build / Import + headless tests (push) Successful in 29s
Full build / Full desktop build + optional release (push) Failing after 3m44s
Once an NPC reaches its intent waypoint it now does something instead of standing
in an idle T-pose:

- sit: the rig settles 0.34 m into a seat and the legs swing forward on the steps
- photo/film: influencers and tourists raise their camera prop briefly (a sine
  pulse, so it reads as taking a shot rather than a permanently raised arm)
- perform: performers sway the sign board they carry

Pose selection lives in NpcAvatar.theater_pose() (intent + arrived); the math is in
pure helpers (crouch_for_pose, prop_lift_for_pose, sway_for_pose) so it is
headless-testable. Prop/leg nodes are captured at build time and animated from
their rest offsets. Performers also get a hand-held board and post.

Tests/docs
- Headless suite 120 -> 127 (test_npc_theater.gd).
- Verified visually with a staged sit/photo/perform lineup in front of the entrance.
- README, TODO section moved to DONE, MEMORY. Version 0.2.7.
2026-10-31 21:05:59 +01:00
zwitschi 1b976bf351 Add body-camera timeline to the shift review
Full build / Import + headless tests (push) Successful in 30s
Full build / Full desktop build + optional release (push) Failing after 3m38s
- New src/game/ui/event_timeline.gd flattens every recorded incident event
  (ADR-0005) into one time-ordered list and shows time, person, and a one-line
  summary. Step with Left/Right or the buttons; Esc returns to the review. No live
  3D replay in this first version.
- Shift review gains a "Body camera timeline" button that hands the review to the
  timeline; the review dim is hidden while it is open (stacked overlays made the
  screen nearly black) and restored on close.
- ShiftCoordinator records started_at_ms so the review can stamp events as mm:ss.
- 8 new string keys for the timeline UI and the review button.

Tests/docs
- Headless suite 113 -> 120 (test_event_timeline.gd), new smoke_timeline.gd that
  opens the timeline from the review, steps through every event, and closes back.
- README, TODO section moved to DONE, MEMORY notes. Version 0.2.6.
2026-10-31 21:02:25 +01:00
zwitschi 921bab796e Add emergency aftermath: evacuation, investigation, review copy
Full build / Import + headless tests (push) Successful in 26s
Full build / Full desktop build + optional release (push) Failing after 4m20s
- Responder outcomes now reach the shift review
  (ShiftCoordinator.responder_outcomes), and the review screen names them.
- "evacuation" (emergency call, low danger, no weapon) actually clears the entrance:
  the door is locked, every NPC flees and is marked as leaving, and a
  security_failure reputation hit applies.
- An emergency call the situation did not justify (no weapon, danger <= 1) appends
  an investigation note, records investigation_opened, and applies the existing
  false_report hit. The review lists the investigations.
- Fix dispatch dying after the first shift: coordinator.setup() builds a new
  RadioSystem, so the responder_arrived connection made in _ready was lost on every
  venue switch/restart. _connect_radio() re-wires it beside _connect_crowd().

Tests/docs
- Headless suite 105 -> 113 (test_emergency_aftermath.gd), new smoke_emergency.gd
  covering the investigation, evacuation aftermath, and review copy.
- README controls/status/smoke checklist, TODO section moved to DONE, MEMORY notes.
- Version 0.2.5.
2026-10-31 20:59:06 +01:00
zwitschi 71b0b53705 Add crowd presentation, fix NPC navigation, refresh screenshots
Full build / Import + headless tests (push) Successful in 27s
Full build / Full desktop build + optional release (push) Failing after 3m37s
NPC navigation (previously dead in the live game)
- Only assign NavigationAgent3D.target_position when it changes: re-assigning every
  frame invalidated the path, so get_next_path_position() kept returning the current
  point and avatars never moved.
- Follow the first path point that is closer to the destination than the avatars are.
  Nav points sit on the navmesh plane (y=0) while bodies stand at y~0.5, so the old
  3D distance test always looked "already there"; path[0] is also the old start point,
  so a naive "far enough away" pick steered backwards and oscillated.
- Fix fsm.state -> fsm.current_state in the idle-lifetime check.

Crowd presentation
- Aggressive behavior, visible weapons, and responder arrivals make idle bystanders
  stop and face the incident for 12 s; someone waiting at the entrance keeps focus.
- Panic (mood >= 0.75) walks people away from the door at 2.4 m/s, then they despawn.
- Wire reactions to EventBus.crowd_changed: CrowdSystem.apply_event() emits on the bus,
  not on its own `changed` signal (the first wiring received nothing).
- Measured a 16-NPC entrance at ~1.2 ms/frame (824 fps, vsync off), so no LOD/culling.

World and HUD
- Skyline is now a ring of four alpha-scissored planes at 200 m instead of a single
  plane; PlaneMesh defaults to horizontal, which is why the backdrop rendered as a band
  overhead. Larger ground plane, mipmapped texture, scissor threshold 0.5 (0.3 left
  mip dashes in the sky), lifted albedo so the city is not pure black.
- Flashlight aims straight ahead and lights the person/door instead of the ground.
- Shorter HUD controls line so it stops overlapping itself.

Tests, tooling, docs
- Headless suite 98 -> 105 (new test_crowd_presentation.gd, 7 tests).
- New smoke_crowd.gd: rubbernecking, panic, and flee-target sanity.
- capture_screenshots.gd starts a shift from the venue menu and stages the night set
  properly; all six README screenshots regenerated.
- README, TODO/DONE (crowd section moved), MEMORY updated. Version 0.2.4.
2026-10-31 20:55:10 +01:00
zwitschi f5ad1642b5 Add PLAN verbs and fix dead incident recording
Full build / Import + headless tests (push) Successful in 26s
Full build / Full desktop build + optional release (push) Failing after 3m31s
Verbs (PLAN levels 1-3)
- Eye contact: own Level 1 radial slice, logged as "eye contact".
- Recognition is a player decision: "Recognize" sets NPCController.player_recognized,
  writes access evidence, and GameManager only then appends a RECOGNITION artifact.
  It is no longer implied by NPC type.
- Body camera: real flag (off by default) driving the REC overlay; B toggles it and
  a formal warning arms it automatically, writing a body_camera incident event.
- Blocking stations the guard 0.9 m from the person toward the entrance and turns
  to face them, instead of only emitting the escalation level.
- Request reason: Level 3 ask answered from the NPC's seeded dialogue_lines.
- Radial grows to 12 slices (174 px outer radius, centered labels).

Incident recording fix
- IncidentRecorder.start_incident() was never called, so add_event /
  add_access_evidence / add_escalation silently no-opped and the shift review
  always reported zero incidents. Incidents now open on focus (guarded by
  has_open_incident), verbs call _ensure_incident, and ShiftCoordinator closes
  leftovers as "unresolved" before building the review.

Copy and docs
- 9 new string keys (eye contact, body camera, recognition, reason, station).
- README controls, 26-step smoke checklist, test count, status lists.
- TODO section moved to DONE; MEMORY records the verb design and the recorder bug.

Verification: headless 98/98, smoke_plan_verbs.gd + smoke_session_shell.gd +
windowed smoke_focus_radial.gd all pass. Version bumped to 0.2.3.
2026-10-31 20:29:50 +01:00
zwitschi 028f608298 Bump version to 0.2.2 for the session-shell release
Full build / Import + headless tests (push) Successful in 26s
Full build / Full desktop build + optional release (push) Failing after 3m42s
config/version, README latest-release note, TODO/MEMORY status refreshed for the
v0.2.2 tag. Headless suite 88/88.
2026-10-31 20:21:54 +01:00
zwitschi be429cab29 Add session shell and close world presentation follow-ups
Full build / Import + headless tests (push) Successful in 24s
Full build / Full desktop build + optional release (push) Skipped
Session shell
- Startup venue menu (src/game/ui/start_menu.gd): keyboard + mouse, replaces
  the live-shift boot and V as the player-facing venue path (V stays a debug bind).
- Pause overlay on Esc (src/game/ui/pause_overlay.gd): resume, restart shift,
  settings, quit to menu.
- Settings panel (src/game/ui/settings_panel.gd): mouse look sensitivity and
  HUD font scale, persisted via Save into user://settings.json and loaded on boot.
- Shift review "Run another shift" now returns to the venue menu.
- New smoke: src/test/smoke_session_shell.gd (menu -> shift -> pause -> settings
  -> menu), passes headless and windowed.

World and presentation
- Skyline backdrop restored with a transparent asset and an alpha-scissor
  unshaded material; fixes the opaque black slab from the earlier attempt and
  the unimported texture path.
- Steps and landing are a StaticBody3D with per-tread colliders, so the guard
  and NPCs walk up instead of clipping through.
- Marquee Label3D scaled up (72 -> 160) so STEP LINE reads from the sidewalk.
- Focus torus and name tag enlarged for 2-3 m readability.
- Routine traffic despawns after an idle lifetime (NpcAvatar.ephemeral), so the
  16-NPC cap no longer fills with standers; authored events stay for the shift.

Docs and tests
- PLAN.md test/CI section corrected to the custom headless runner and Gitea Actions.
- README controls table, smoke checklist, test count, and status lists refreshed.
- TODO/DONE/MEMORY updated; completed sections moved to DONE.md.
- Headless suite 77 -> 88 passing (settings round-trip, step colliders, skyline
  alpha, session-shell UI behavior, traffic lifetime).
2026-10-31 20:19:22 +01:00
zwitschi f11b441c07 Implement control pass: focus cone, action wheel, named inputs, boot fixes
Full build / Import + headless tests (push) Successful in 22s
Full build / Full desktop build + optional release (push) Skipped
- Venue-correct access artifacts (AccessLoadout) and screened-aware verify
- Cone focus on NPC heads with billboarded ring + display name (ADR-0008)
- Action wheel with de-escalation-first slices (ADR-0009); Esc/RMB cancel
- Procedural NPC face/hair/neck/shoes (ADR-0010); named input actions
- Fix GameManager parse error (radial typed as ActionRadial)
- Fix first-person camera height and guard spawning inside the building
- Move building volume back so the door/awning/marquee are visible
- Pull the event scheduler once per simulated second; cap live NPCs
- Regenerate README screenshots; update TODO/DONE/MEMORY/README; 77 tests

Adds src/test/smoke_focus_radial.gd for the windowed focus/radial smoke.
2026-10-31 15:58:01 +01:00
zwitschi 69484866db Add new game assets and update asset paths in code 2026-10-31 15:20:08 +01:00
zwitschi 53e98cf3fe Update documentation and regenerate screenshots; enhance asset integration details 2026-10-31 15:19:58 +01:00
zwitschi 8cf7745c80 Plan remaining gaps in TODO.md after a PLAN/code audit
Control pass (ADRs 0007-0010) stays first. Added the holes the audit found:
venue-correct access artifacts (override_access unread), session shell,
leftover PLAN verbs, crowd rubberneck, emergency aftermath, replay, NPC
poses, real ambience, a few Joke Shop/Studio events, and doc drift.
Host sign-in remains deferred. Generated-asset integration stays out
(already in DONE).
2026-10-31 15:17:02 +01:00
zwitschi 2b631ae6d9 Add ADRs 0007–0010 for locomotion, focus ring, radial menu, NPC presentation
- 0007: input layers so WASD/look never fire person-verbs; freeze while modal
- 0008: cone focus + billboarded head ring; no type names in the world
- 0009: doughnut action menu (LMB/Space open, click or A/D then Space, Esc/RMB
  cancel); emergency stays on R
- 0010: keep procedural rigs, add face/hair/neck; no Mixamo this pass
Linked from architecture.md, PLAN.md, TODO.md, MEMORY.md.
2026-10-31 15:04:12 +01:00
zwitschi 5662baa14d Plan focus cone, head ring, and radial action menu; defer host sign-in
Document the next playable pass: WASD/look stop fighting action keys, cone
focus with a billboarded head ring, doughnut HUD opened by LMB or Space
(click or A/D then Space to confirm, Esc/RMB cancel). Emergency stays on R.
Wand leaves W. NPC rigs stay procedural but get a face/hair/neck. Host
REQUIRE_SIGNIN_VIEW follow-up deferred.
2026-10-31 15:00:11 +01:00
zwitschi b704a8ed61 Move generalized game asset tooling tasks from TODO.md to DONE.md 2026-10-31 14:39:26 +01:00
zwitschi f2d218cc27 Fix publish_release.sh to upload installer + AppImage; document host REQUIRE_SIGNIN_VIEW
Full build / Import + headless tests (push) Successful in 23s
Full build / Full desktop build + optional release (push) Skipped
- Script uploaded only the three portable binaries; v0.2.1 release was
  missing step-line-setup-0.2.1.exe and the AppImage. Now uploads the full
  set (extras when present), reuses existing releases (adds missing assets),
  and falls back to ~/.git-credentials basic auth when GITEA_TOKEN is unset
- Back-filled both missing assets onto the live v0.2.1 release via API
  (release now carries all 5 artifacts)
- TODO/RELEASING/MEMORY document the findings and the host admin steps for
  anonymous downloads ([service] REQUIRE_SIGNIN_VIEW)
2026-10-31 14:37:56 +01:00
zwitschi 3377545a3e Add Python-related entries to .gitignore for improved environment management 2026-10-31 14:33:46 +01:00
zwitschi 7ff0c28c03 Note host sign-in regression for v0.2.1 tag page in the distribution follow-up
Full build / Import + headless tests (push) Successful in 22s
Full build / Full desktop build + optional release (push) Skipped
2026-10-31 14:28:48 +01:00
zwitschi 2ca0e18018 Close out installer/AppImage verification; note v0.2.1 release; document NSIS/appstream gotchas
Full build / Import + headless tests (push) Successful in 24s
Full build / Full desktop build + optional release (push) Skipped
- CI run 1110 proved NSIS + AppImage packaging and published v0.2.1
  (installers uploaded); Mark TODO installer/appimage items done (moved to
  DONE.md), README Latest -> v0.2.1, MEMORY records NSIS -D/absolute-path/
  BMP-ICO gotchas and appstreamcli strictness
2026-10-31 14:28:36 +01:00
143 changed files with 12744 additions and 431 deletions

No files matched your search

+3
View File
@@ -113,6 +113,9 @@ jobs:
- name: Publish Gitea release (tags only)
if: startsWith(github.ref, 'refs/tags/')
env:
# An explicit repo secret wins. Without one, publish_release.sh reuses
# the local git extraheader that actions/checkout configures for the job
# (the runner's own credentials), so tag builds still publish.
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
VERSION: ${{ github.ref_name }}
run: |
+24
View File
@@ -1,3 +1,27 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
*.pycache
*.pytest_cache
*.mypy_cache
*.coverage
coverage.xml
htmlcov/
dist/
build/
eggs/
*.egg-info/
.tox/
.nox/
*.venv/
venv/
ENV/
env/
env.bak/
venv.bak/
# Godot 4
.godot/
*.import
+401 -1
View File
@@ -173,6 +173,10 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every
- [x] `config/version` → `0.2.0`; `build/` artifacts rebuilt and Linux binary smoke-tested.
- [x] New world/lighting headless tests (56 total).
## Screenshot Refresh
- [x] Regenerate and verify the six README screenshots from the current build.
## 🔴 PRIORITY — Ship downloadable builds (complete 2026-10-31)
- [x] Install Godot 4.3 export templates locally.
@@ -216,7 +220,13 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every
ran it headless (0 script errors).
- [x] CI: NSIS + AppImage steps added to `.gitea/workflows/build.yml` build job; publish glob
already uploads `build/step-line-*<ver>*` (covers setup/AppImage names).
- [ ] (open) CI run verification of the NSIS build and installer-content listing.
- [x] CI run 1110 verified end-to-end (v0.2.1): NSIS installer `step-line-setup-0.2.1.exe`
built, `7z l` shows `step-line.exe` + `uninstall.exe`, AppImage built (33.9 MB),
release `v0.2.1` published, `Job succeeded`.
- Gotchas fixed on the way: makensis needs `-D` defines (not `/D`) and absolute paths
(it resolves relatives against the script dir); installed exe must be space-free
(`step-line.exe`); icon.ico must use classic BMP entries (ImageMagick; PIL emits
PNG-compressed ICOs NSIS rejects).
## 🎮 Assets & detailed graphics pass (textures, rigs, UI feedback)
@@ -278,3 +288,393 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every
- [x] Add visual and audio feedback per escalation level (colors, tones, shake).
- [x] Add audio for crowd ambience, radio responses, and confirmation prompts.
- [x] Externalize dialogue and UI text for localization.
## Generalized Game Asset Tooling
- [x] Reuse the OpenRouter image backend for role-aware arbitrary asset generation.
- [x] Add exact-dimension image crop and aspect-preserving resize commands.
- [x] Audit existing assets for role-specific dimensions, aspect, alpha, and tile seams;
report projection/perspective checks for visual review.
- [x] Add automated tests and document asset-pipeline usage.
## Generated Asset Integration
- [x] Regenerate the AI asset set at role-specific aspect ratios and dimensions.
- [x] Audit, process, and wire every generated asset into the game runtime.
- [x] Add screenshot auditing and refresh the README screenshot gallery.
## Access artifacts actually match the venue
Completed in the working tree (uncommitted with the control pass). Headless **74/74**.
- [x] Build artifacts from the event (`override_access`, carried items) and the venue policy,
not from `NpcType` alone. (`src/core/access_loadout.gd`; spawn signal is
`(profile, intent, npc_id, event)`.)
- [x] Joke Shop: valid ID+ticket, ID without ticket, expired/missing ID. Recognition does not
auto-grant on every `AUTHORIZED_GUEST`.
- [x] Wand venues roll `carried_items` per spawn (seeded); override can force a pocket.
- [x] Studio counterfeit / follow-through events carry `override_access`.
- [x] `AccessVerifier.verify(..., already_screened)` so a screened guest is not still denied.
- [x] Joke Shop / Studio `sidewalk` and `steps_end` waypoints so film/photo intents do not walk
to the entrance.
- [x] Wand shortcut is `C`, not `W`. Tests: `src/test/tests/test_access_loadout.gd`.
## Player control, focus circle, and radial action menu
Completed 2026-10-31 with ADR-0007–0010. Headless **77/77**, plus a windowed smoke
(`src/test/smoke_focus_radial.gd`) that opens the wheel and saves screenshots.
### Locomotion
- [x] Wand off `W` (now `C` + wheel). `A`/`D` allow/deny only while inspect/wand is open
(they step the wheel while it is open). `Q` remains a closed-menu polite shortcut.
- [x] Accel/decel on walk; gravity only when airborne; `floor_snap_length` 0.3.
- [x] Mouse look only while captured and `not input_locked`. Freeze walk+look while radial /
inspect / wand / radio / review is up. Recapture on close.
- [x] `cancel` (Esc / RMB) closes the top modal first; with nothing open, Esc toggles mouse
capture and RMB does nothing.
### Focus and focus circle
- [x] Focus cone in `src/game/npc_focus.gd` (~10 m, ~10°). Closest-to-forward wins; occlusion
ray skips building hits. `Tab` still cycles 12 m.
- [x] 8-frame miss grace so walk-bob does not drop focus.
- [x] Billboarded blue circle in front of the face on `Rig/Head`; brighter while the radial is
open; `Label3D` `display_name`. HUD target line uses the display name, not the node name.
- [x] Screen-center crosshair.
### Radial HUD
- [x] `src/game/ui/action_radial.gd`: LMB or `Space` opens when someone is focused. Opening
click ignored for 120 ms. Slices: raise hand, point, head shake, polite, inspect, wand
(grey if no wand), warning, block, radio assist. Emergency stays on `R`.
- [x] Highlight by mouse angle, or `A`/`D` / arrows. Click slice or `Space` confirms. `Esc` /
RMB closes with no action (no recorder event).
- [x] Inspect / wand / radio slices close the wheel then open the existing panels. Freeze until
that panel closes.
- [x] Number keys `1`–`6` still work when the wheel is closed.
### NPC display
- [x] Procedural rig: neck, shoes, hair cap from seed, two eye spheres. Props, height
variation, walk bob, notice-head-turn kept. Collision capsule scaled to rig height.
Named `Head` is the ring attach point. Group `npc`.
### Input, copy, tests
- [x] Named input map: `action_menu` (Space/LMB), `confirm` (Space/Enter), `cancel`
(Esc/RMB), `inspect` (E), `wand` (C). `GameManager` uses `is_action_pressed`.
- [x] Windowed smoke: focus circle + name, wheel open/step/confirm/cancel
(`src/test/smoke_focus_radial.gd`, exits non-zero on failure).
- [x] README controls table, smoke checklist, test count, implemented/not-yet lists.
## Visual polish — Skyline backdrop transparency
Found during the control smoke test; verified with PIL analysis.
- [x] Regenerated `assets/textures/game_ready/skyline.png` with transparent background
(commented out opaque border line 234 in `tools/generate_textures.py`).
- [x] Integrated backdrop into `WorldBuilder` via `_build_skyline_backdrop()`: creates a
PlaneMesh with T_SKYLINE texture at Vector3(-80, 12, -50) behind the building.
- [x] PIL verification confirms RGBA mode with transparent pixels in bottom rows.
## Runtime boot and spawn fixes
Found while running the windowed smoke; all verified with a boot run and regenerated
screenshots.
- [x] `GameManager` did not compile: `radial` was typed `CanvasLayer`, so
`radial.is_open` / `confirm_selected()` made `:=` inference fail. Typed as
`ActionRadial` (the parse error would have broken the whole main scene).
- [x] Camera sat at the guard's feet: `Head` had no height offset, and the new gravity pulled
the guard to the floor. `Head` is now at y=1.6, and the guard spawns on the landing.
- [x] Guard spawned inside the building collider and fell through the world. Building volume
moved back to z ∈ [-8, -4] so the door / awning / marquee sit on its front face; the
guard spawns in front of the door facing the street.
- [x] Steps rose away from the door; treads now rise toward the entrance.
- [x] Event scheduler was polled every physics frame (~60 Hz), spawning >1200 NPCs in
seconds. `ShiftCoordinator` now accumulates time and pulls once per simulated second;
regression test in `test_scenarios.gd`.
- [x] Live NPCs capped at `MAX_LIVE_NPCS` (16); routine traffic is dropped at the cap and a
scripted event evicts the oldest unfocused NPC.
- [x] Removed the skyline backdrop planes (opaque black texture). Tracked as a follow-up in
`TODO.md`.
- [x] README screenshots regenerated with the fixed eye height and entrance staging.
## World and presentation follow-ups (found during the control smoke)
Completed 2026-10-31 (working tree). Headless **88/88**; `src/test/smoke_session_shell.gd` and the windowed focus/radial smoke both pass.
- [x] The skyline backdrop was removed: `assets/textures/ai/game_ready/skyline.png` has an
opaque black background, so the planes rendered as black slabs with window dots. Re-add
the backdrop only with a transparent asset or a keyed shader.
- [x] Guard settles ~0.5 m above the ground after spawning on the landing. Check the capsule
offset and the spawn `y`, and verify eye height while walking the steps.
- [x] Steps and the landing are visual-only (no collider). The guard walks through them; add
simple step colliders or restage the landing so it does not intersect the walk path.
- [x] Marquee text (`STEP LINE`) is tiny from the sidewalk. Scale the Label3D up.
- [x] Focus circle and name tag are small at 2–3 m. Consider a larger circle / font after a
real playtest.
- [x] NPC crowd cap (`MAX_LIVE_NPCS`) silently drops routine traffic at 16. Add a short
despawn/lifetime for ordinary guests so the entrance does not fill up with standers.
## Session shell
Completed 2026-10-31 (working tree). Headless **88/88**; `src/test/smoke_session_shell.gd` and the windowed focus/radial smoke both pass.
The game boots into a live 900 s shift. `Save` declares `settings.json` and never uses it.
`V` cycles venues by restarting the shift.
- [x] Startup menu: pick venue, start shift, quit. Keyboard + click. Replaces `V` as the
player-facing path (`V` may stay as a debug bind).
- [x] Pause overlay when `Esc` releases the mouse (or a dedicated pause key): resume, restart
shift, settings, quit to menu.
- [x] Settings: mouse look sensitivity, persist through `Save` / `settings.json`. Load on boot.
- [x] Shift review "Run another shift" returns to the venue menu, not a silent restart of the
same scenario.
## Documentation drift
Completed 2026-10-31 (working tree).
- [x] README controls table, smoke checklist, test count, and implemented/not-yet lists
refreshed for the control pass (77 tests).
- [x] `PLAN.md` Tech Stack / Planned Commands still say GUT and GitHub Actions; the project
uses `src/test/test_runner.gd` and `.gitea/workflows/build.yml`.
- [x] MEMORY: door_staff_lines log through `GameManager`; 300 s default removed.
## Remaining PLAN verbs (radial exists)
Completed 2026-10-31 (working tree). Headless **98/98**; `src/test/smoke_plan_verbs.gd` exercises all five verbs.
- [x] Eye contact as a Level 1 action (PLAN / glossary). Distinct from raise-hand. Add a
radial slice if it ships.
- [x] "I recognize this person" is a player decision recorded as evidence, not a pre-attached
artifact on every staff/guest type. Staff still auto-recognize when `override_access` is
empty (`AccessLoadout`).
- [x] Formal warning can activate the body-camera / security-light flag (PLAN Level 2). Overlay
is currently always on; toggling should write an incident event.
- [x] Blocking stations the guard between the person and the door (PLAN Level 3). Today it only
sends `apply_player_action(BLOCKING)`.
- [x] "Request reason for entry" as a Level 3 ask, with a short NPC reply from `dialogue_lines`.
## Crowd presentation
Completed 2026-10-31 (working tree). Headless **105/105**; `src/test/smoke_crowd.gd` verifies rubbernecking and panic clearing. A 16-NPC entrance measured ~1.2 ms/frame (824 fps, vsync off), so no LOD/culling was added at the current cap.
`CrowdSystem` is a mood/attention float. PLAN wants people watching, gathering, and panicking.
- [x] On `aggressive_behavior` / `emergency_responder`, nearby idle NPCs stop and face the
incident (rubberneck). On `crowd_panic`, they move away from the door.
- [x] Profile a busy entrance with textured materials and props; add LOD or culling for distant
NPCs if frame time drops.
## Emergency aftermath
Completed 2026-10-31 (working tree). Headless **113/113**; `src/test/smoke_emergency.gd` covers the unjustified-call investigation, the evacuation aftermath, and the review copy.
Responder outcomes (`command` / `restraint` / `evacuation` / `arrest`) are log lines. PLAN also
wants evacuation, a closed street, and an investigation when the call was unjustified.
- [x] `evacuation` outcome actually clears the entrance (NPCs leave, door stays locked).
- [x] Unjustified emergency (low danger, no weapon) writes an investigation note on the shift
review and applies the existing false-report / reputation hit.
- [x] Review copy names the responder outcome, not only battery and crowd %.
## Body-camera replay
Completed 2026-10-31 (working tree). Headless **120/120**; `src/test/smoke_timeline.gd` opens the timeline from the review, steps through every event, and returns to the review.
- [x] Shift review: a timeline of recorded incident events (ADR-0005 already stores them).
Scrub or step through; no live 3D replay required in the first version.
## NPC theater
Completed 2026-10-31 (working tree). Headless **127/127**; verified visually with a staged sit/photo/perform lineup.
Intents `sit` / `photo` / `film` / `perform` already pick waypoints. ADR-0010 rig (face/hair)
is in the working tree. Bodies still idle-stand at those spots.
- [x] Sit pose at `steps_end`. Photo: brief raise of the influencer/tourist prop. Performer
stays put and plays the hat/sign.
## Audio
Completed 2026-10-31 (working tree). Headless **134/134**; `src/test/smoke_audio.gd` checks the bed follows mood and the radio crackles, headless and windowed (real audio server).
`AudioFeedback` is sine beeps. DONE called that "crowd ambience"; it is not.
- [x] Loop a low crowd bed that rises with `CrowdSystem.mood`.
- [x] Radio open/close crackle on the existing radio panel, still headless-safe (no-op without
an audio server).
## Accessibility
Completed 2026-10-31 (working tree). Headless **139/139**; verified visually at 1.4x font scale with the shape meter showing.
- [x] Escalation colors that still read in grayscale (shape or label, not hue alone).
- [x] HUD font scale in settings.
## Scenario content still missing vs PLAN lists
Completed 2026-10-31 (working tree). Headless **149/149**; `src/test/smoke_scenario_content.gd` spawns the refusal event and the celebrity arrival through the real signal path.
- [x] Joke Shop: guest on the reservation list without ID; borrowed/mismatched ID; guest
refuses wand screening; late arrival during a performance.
- [x] Studio: filming-boundary repeat (influencer ignores a posted line); celebrity-arrival
crowd spike (more rubberneckers, not only one `arrive` spawn).
- [x] Building: "activate body camera" and "request reason" once those verbs exist, used in
at least one authored event.
## Distribution follow-up (host config)
Completed 2026-10-31 (working tree). Headless **153/153**.
Verified via API+host credentials (2026-10-31):
- [x] Repo is **public** (`private:false`) and account has admin rights.
- [x] Release `v0.2.1` now carries all five artifacts (portable binaries + NSIS installer +
AppImage).
- [x] **Host setting applied**: `[service] REQUIRE_SIGNIN_VIEW = false` in `/etc/gitea/app.ini`
(backup written alongside), Gitea restarted. Verified anonymously: repo page 200, API 200,
and a release asset downloads with the full 50.8 MB AppImage.
## Release pipeline follow-up
Completed 2026-10-31 (working tree). Headless **153/153**.
- [x] Fix `tools/publish_release.sh`: the `~/.git-credentials` branch never set `AUTH`,
so every tag build since v0.2.2 failed to publish.
- [x] Add a `github.token` fallback in `.gitea/workflows/build.yml` (the repo has no
`GITEA_TOKEN` secret).
- [x] Fall back to the checkout's git extraheader when no token/credentials exist (the
Gitea runner does not expose `github.token`).
- [x] Uploads now fail loudly and the release is verified to carry every artifact
(a silent HTTP 4xx used to leave an empty release with a green job).
- [x] Host disk expanded to 16G; v0.2.0 and v0.2.9 releases restored from the local artifacts.
- [x] Host maintenance via `root@192.168.88.30`: apt cache cleaned and journald capped
(`SystemMaxUse=300M` in `/etc/systemd/journald.conf.d/99-size.conf`); ~7.6G free.
- [x] Verified end to end: tagging v0.2.12 produced a release with all five artifacts
(AppImage, Linux, macOS, NSIS installer, Windows) from CI, with no repo secret.
- [x] Backfill decision: v0.2.4–v0.2.8 stay superseded; their tags and source remain, and the
release page points at the current build. No rebuilds planned.
## Open Polish
Completed 2026-10-31 (working tree). Headless **153/153**.
- [x] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting).
## Screen design references (QA tooling)
Completed 2026-10-31 (working tree). 30 Python tooling tests pass; the live judge run classed 3 captures as match and 3 as review (concept mockups are more stylised), with no confident mismatch.
- [x] `tools/screen_designs.py generate` writes one reference design per screen
(from the `docs/` descriptions) to `docs/screens/designs/`, with an offline
`--draft` wireframe mode and an OpenRouter `--ai` mockup mode.
- [x] `tools/screen_designs.py compare` checks `docs/screenshots/*.png` against the
designs (aspect ratio, size, blank-frame, and layout/palette/detail drift).
- [x] Vision-model judge for semantic screenshot comparison: `judge` sends the
capture + reference design to a vision model, classes verdicts as
match/review/diff with a confidence threshold, supports `--dry-run`, `--json`,
`--only`, and `--model`, and reads the repo `.env` for the API key.
## Tooling (when an editor workflow exists)
Completed 2026-10-31 (working tree). Headless **157/157**; the exported `.tres` venues round-trip through the resource loader.
- [x] Author venues as `.tres` resources: `tools/export_venues.gd` writes
`content/venues/<id>.tres` from the factory, and a test loads them back to check
nothing is lost (events, profiles, policy, phases, door lines, locations).
- [x] GUT decision: **not adopted.** The custom headless runner (`src/test/test_runner.gd`)
already covers 157 assertions without an editor plugin, runs in CI, and keeps the
project dependency-free; adding GUT would duplicate coverage and require the editor.
## In-Game Scene Examples
Completed 2026-10-31 (v0.3.0 working tree). `python3 tools/screen_designs.py list` reports
19 screens (8 scene, 11 UI); the Python tooling tests pass.
- [x] Extend the `tools/screen_designs.py` catalogue with the screens that exist in code but
had no reference: wand screening, Joke Shop door queue, TV Studio celebrity arrival,
crowd panic, responder arrival, body-camera timeline, escalation HUD.
- [x] Add a `list` subcommand so the catalogue is inspectable without generating images.
- [x] Cover the new entries with `tools/test_screen_designs.py` assertions.
- [x] Regenerate the deterministic draft references under `docs/screens/designs/`.
(`generate` now keeps existing references unless `--force` is passed, so a draft run
cannot replace an AI mockup; `--only` selects a subset.)
## Rendering Quality
Completed 2026-10-31 (v0.3.0 working tree). Screenshots regenerated; `screen_designs.py
compare` reports 6 good / 0 bad.
- [x] Add a shared surface-material module (`src/game/materials.gd`) with
roughness/metallic/uv-scale profiles per surface and a single environment tune-up
(tonemap, glow, fog, shadows).
- [x] Rebuild `WorldBuilder` surfaces with the material profiles and add street detail:
window bands, door trim, bollards, bench, bin, curb, planter foliage.
- [x] Per-phase environment values (fog, sky energy, glow) so night reads darker and the
marquee glows.
- [x] Improve NPC silhouette and materials: per-role clothing palette, caps/hoods/backpacks,
sleeves and hands, walking leg swing.
- [x] Cover materials/environment/avatar helpers with headless tests
(`test_materials.gd`, `test_target_cycle.gd`).
## AI 3D Model Tooling
Completed 2026-10-31 (v0.3.0 working tree). `model_pipeline.py build` + `audit` pass for all
six props; `test_generated_props.gd` proves they import as scenes and that the world places
them.
- [x] Add `tools/model_pipeline.py`: prop catalogue, deterministic low-poly GLB builder,
OpenRouter concept-image generation, palette sampling and GLB audit.
- [x] Add `tools/test_model_pipeline.py` (valid GLB structure, deterministic output, audit
failures, sRGB→linear conversion, role assignment).
- [x] Generate the prop models into `assets/models/generated/` and import them.
- [x] Load the generated props in `WorldBuilder` with a procedural fallback when the GLB is
missing.
## Controls, Movement and NPC Selection
Completed 2026-10-31 (v0.3.0 working tree). The windowed smoke now covers wheel cycling and
right-click clearing; the headless suite passes 177/177.
- [x] Record the re-evaluated control model as ADR-0011.
- [x] Rank focus candidates by view angle then distance (`NpcFocus.rank_indices`) instead of
insertion order.
- [x] Add mouse-wheel target cycling next to `Tab`, right-click clearing focus, and a `Shift`
precision walk speed.
- [x] Show the nearby-candidate count and the cycle hint in the HUD.
- [x] Cover ranking, cycling and walk speed with tests, then re-run the windowed smoke.
## Web Export Build Target
Completed 2026-10-31 (v0.3.0). Headless **181/181**; `godot --headless --export-release "Web"`
produces a 63 MB static bundle and it serves locally with HTTP 200s for html/wasm/pck.
- [x] Add a `Web` preset to `export_presets.cfg` using `variant/thread_support=false` so the
build runs on plain static hosting without COOP/COEP headers.
- [x] Export to `build/web/` and package `build/step-line-web-<version>.zip` from
`tools/build_releases.sh` (zip or Python fallback).
- [x] Publish the web zip with the desktop artifacts (`tools/publish_release.sh`).
- [x] Cover the preset, the non-threaded choice, and the release wiring in
`src/test/tests/test_web_export.gd`.
- [x] Document the artifact in `docs/RELEASING.md`, the README gallery/status, and `PLAN.md`.
## Prop Texture Atlas
Completed 2026-10-31 (v0.3.1). Headless **182/182**, Python tooling **53/53**; screenshots
regenerated and `screen_designs.py compare` still reports 6 good / 0 bad.
- [x] `model_pipeline.atlas_for()` packs each prop's palette roles into one small power-of-two
PNG and a matching metallic/roughness atlas (roughness in G, metallic in B), with nearest
sampling so a palette cell stays a flat colour.
- [x] `build_glb()` merges every part into a single primitive with per-vertex atlas UVs and one
material (`baseColorTexture` + `metallicRoughnessTexture`), so a prop is one draw material
instead of one per part.
- [x] `audit_glb()` checks the single-material/atlas contract (one material, both textures,
TEXCOORD_0 on every primitive, two images).
- [x] Tests: atlas determinism and power-of-two sizing, UV range, palette override reaching the
atlas, and a Godot test that each imported prop scene uses exactly one material.
- [x] Removed the now-unused sRGB→linear factor helpers (colours travel in the sRGB texture).
+315 -13
View File
@@ -10,22 +10,281 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
- Eventing: no autoload signal bus. `ShiftCoordinator` owns an `EventBus` instance (`src/core/event_bus.gd`) passed to subsystems; subsystems emit only when a bus is assigned. Autoloads at parse time are unreliable under `--script`.
- Static signals are not available in Godot 4.3 GDScript ("Expected func or var after static") — do not use them.
- GDScript lambdas capture local variables **by value**; mutating a captured local inside a lambda does not affect the outer variable. Use member functions for accumulators in tests.
- Prototype shift duration defaults to 300 s for testing; production targets 10-15 minutes per PLAN.md.
- Shift length is the scenario's `shift_duration_s` (900 s in all three venues). Tests and the screenshot tool override elapsed time; they do not change the default.
- RNG: NPC reactions and event scheduling are seedable for deterministic tests.
## Handoff (read this first)
- Repo: `/run/media/system/DATA/Documents/06-games/security-game` (also reachable at
`/var/home/zwitschi/Documents/06-games/security-game`). Godot: `~/.local/bin/godot` (4.3.stable).
Tests: `godot --headless --path . --script res://src/test/test_runner.gd` → **177/177**.
Python tooling tests: `cd tools && python3 -m unittest test_asset_pipeline test_screen_designs test_model_pipeline` → **53**.
Session shell smoke: `godot --headless --path . --script res://src/test/smoke_session_shell.gd` → exit 0.
Windowed control smoke: `godot --path . --script res://src/test/smoke_focus_radial.gd` → exit 0
(now also covers wheel cycling and right-click clearing).
- `main` is in sync with origin. **v0.2.2** tags the session-shell + world-follow-up commit;
CI builds the portable binaries, NSIS installer, and AppImage on the tag push.
- **v0.3.0 (2026-10-31, working tree):** rendering pass (`src/game/materials.gd`, phase-driven
environment), generated `.glb` props (`tools/model_pipeline.py`, `assets/models/generated/`),
aim-free selection with wheel cycling and Shift precision walk (ADR-0011/0012), and a **Web**
export target. `export_presets.cfg` gains a `Web` preset with `variant/thread_support=false`
(static hosting, no COOP/COEP); `tools/build_releases.sh` exports `build/web/` and zips it to
`build/step-line-web-<ver>.zip`, and `publish_release.sh` uploads it. Covered by
`src/test/tests/test_web_export.gd`.
- **Prop atlas (v0.3.1):** `model_pipeline.atlas_for()` builds a per-prop power-of-two PNG with
one 8×8 cell per palette role, plus a metallic/roughness atlas (G=roughness, B=metallic). The
GLB merges all parts into one primitive with constant per-vertex UVs (cell centre) and a single
material (`baseColorTexture` + `metallicRoughnessTexture`, NEAREST sampling). Colours are sRGB in
the texture, so the old `srgb_to_linear`/`_linear_rgba` helpers were removed. `audit_glb` fails
if a prop is not exactly one material or misses TEXCOORD_0/images.
- **Backlog complete:** every planned TODO section is done and moved to `DONE.md`; `TODO.md`
now only lists optional future ideas (live 3D replay, NPC LOD, more venues, real audio, prop
atlas, AI mockups for the new catalogue entries, image-to-mesh props). GUT was consciously not
adopted (custom runner covers it without an editor plugin).
- Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg.
## Current State
- **Release v0.2.0 is LIVE**: pushing the `v0.2.0` tag made Gitea Actions run the build job
- **v0.3.0 pass (2026-10-31, uncommitted):** rendering, generated prop models, screen
catalogue, aim-free selection. See ADR-0011 (selection) and ADR-0012 (materials/props).
- `src/game/materials.gd` (`SurfaceMaterials`) is now the single source of surface
recipes + `configure_environment()`. `GameManager.PHASE_LIGHTING` gained
`exposure/glow/fog_color/fog_density/fog_energy/contrast/saturation/ambient_energy`.
Renderer is **GL Compatibility**, so SSAO/SSIL are unavailable; glow+fog+ACES are used
instead and unsupported settings are ignored rather than gated in code.
- World: scaled brick (uv 22,10), framed windows with per-window night emission
(`i % 3 != 2` lit), stone base course, door trim/handles, curb, lane dashes (the old
loop had `Vector3(i * 0.0, …)` and stacked every dash at x=0), street lamps use the
metal profile. The two flat planters that clipped the steps were removed.
- `tools/model_pipeline.py` builds `assets/models/generated/*.glb` (bollard, bench,
trash_bin, planter_box, newspaper_box, fire_hydrant) from box/cylinder specs;
`WorldBuilder._add_prop_model()` loads them with a procedural fallback and records
`loaded_models` / `fallback_props`. **Gotcha:** glTF `baseColorFactor` is LINEAR —
writing sRGB made every prop pastel; `srgb_to_linear()` is applied in `build_glb`.
**Gotcha:** a new/changed `.glb` needs `godot --headless --path . --import` before
`ResourceLoader.exists()` / `load()` sees it (test_generated_props fails otherwise).
- `model_pipeline.py concept` (OpenRouter, reads repo `.env`) writes per-prop concept
sheets to `assets/models/concepts/`; `dominant_colors()` centre-crops and weights by
saturation (a grey studio backdrop otherwise wins), `assign_roles()` maps colours by
hue/value. Delete a concept file to fall back to the catalogue palette.
- NPC avatars: `outfit_for(type, seed)` (pure) gives shirt/trousers/hair/skin +
accessory (cap, hood, backpack, sunglasses, lanyard) and height scale; `_arms` and
`_legs` now swing (`limb_swing`) while walking. Keep `Rig/Head` named for focus.
- Selection: `NpcFocus.rank_indices` (angle then distance) — `pick_index` = first.
`GuardController.cycle_target(direction)` walks nearest-first `nearby_candidates()` and
sets `target_pinned`, which makes `_update_target()` skip the cone. `clear_target()`
sets `focus_suppress_s = 0.75` so the cone does not instantly re-acquire the person.
New input actions `walk_slow` (Shift), `cycle_target_next` / `cycle_target_prev`
(wheel down / up). Right click with nothing open clears focus (Esc still pauses).
- HUD: `nearby_label` + `set_nearby_hint(count)` show `hud.nearby`; `hud.controls` and
`hud.targeting` mention the wheel and Shift.
- `tools/screen_designs.py`: catalogue now 19 screens (added wand_screening,
joke_shop_queue, studio_arrival, crowd_panic, responder_arrival,
body_camera_timeline, escalation_hud), new `list` command, `generate --only/--force`
(existing designs are kept by default so a draft run cannot clobber AI mockups),
and `judge --dry-run` no longer raises `KeyError: 'class'`.
- Screenshot tool leak: `capture_screenshots.gd` intermittently prints
`ObjectDB instances leaked at exit`. Verified against a `git worktree` of HEAD~ — the
baseline leaks more often (3/3 vs 1/3), so it is a pre-existing quit-timing artifact,
not a regression. Normal boots are clean.
- Access artifacts: `src/core/access_loadout.gd` builds from event `override_access` + venue policy.
Spawn signal is now `(profile, intent, npc_id, event)`. Recognition is not auto-attached to every
guest. `AccessVerifier.verify(..., already_screened=false)` so Joke Shop scoring uses
`controller.screened`. Joke Shop/Studio gained `sidewalk`/`steps_end` waypoints. Wand venues roll
`carried_items` per spawn (seeded). Tests in `src/test/tests/test_access_loadout.gd`.
- Wand shortcut is **`C`**, not `W` (`W` is walk). `radio_emergency` input map no longer includes W.
`hud.controls` / `hud.screen_first` updated.
- Control pass (in tree, uncommitted, smoke-tested): cone focus via `src/game/npc_focus.gd`,
accel/decel, gravity only airborne, `input_locked` freeze, Esc no longer on the guard.
`NpcAvatar` in group `npc`; neck/hair/eyes/shoes; blue focus circle + `Label3D` name; `set_focus_visual`.
`src/game/ui/action_radial.gd` doughnut (LMB/Space open, A/D/arrows, Space/click confirm, Esc/RMB
cancel). Crosshair CanvasLayer. Emergency still `R` only. Number keys 1–6 when the wheel is closed.
- Named input actions now exist and are used: `action_menu`, `confirm`, `cancel` (Esc + RMB),
`inspect` (E), `wand` (C). The old unused `interact` action was removed.
- Runtime fixes from the smoke: `Head` is +1.6 above the guard root (root sits on the floor); guard
spawns in front of the door facing the street; building volume moved to z ∈ [-8,-4] so the door,
awning, and marquee are visible; step treads rise toward the door; `ShiftCoordinator` pulls the
event scheduler once per simulated second (was every physics frame → >1200 NPCs in seconds);
`GameManager.MAX_LIVE_NPCS = 16` caps live avatars.
- **Session shell (2026-10-31, uncommitted):** the game now boots into `StartMenu`
(`src/game/ui/start_menu.gd`, layer 20) instead of a live shift. `GameManager._on_venue_selected()`
builds the scenario and calls `_start_shift()`; `_shift_running` gates input and `_refresh_freeze()`.
Esc with nothing open now pauses (`PauseOverlay`, layer 18) → resume / restart / settings /
quit-to-menu. `SettingsPanel` (layer 22) writes `look_sensitivity` + `hud_font_scale` through
`Save.save_settings()` → `user://settings.json`; `Save.load_settings()` merges over
`Save.DEFAULT_SETTINGS` on boot and `GameManager._apply_settings()` pushes them into the guard/HUD.
Shift review "Run another shift" now calls `_show_start_menu()` (not a silent restart). `V` stays a
debug venue bind. Smoke: `src/test/smoke_session_shell.gd` (menu → shift → pause → settings → menu).
- **World follow-ups closed (2026-10-31):** skyline backdrop restored with `T_SKYLINE`
(`assets/textures/skyline.png`, regenerated RGBA with a transparent sky) and an
`TRANSPARENCY_ALPHA_SCISSOR` unshaded material (`_skyline_mat`); steps + landing are a
`StaticBody3D` with per-tread `BoxShape3D` colliders; marquee `font_size` 160 / outline 24; focus
circle (blue disc in front of the face) + name tag 48px; `NpcAvatar.ephemeral`/`lifetime_s` despawns routine traffic
(authored events stay). Guard spawn height verified on the landing (guard_y=0.84).
- **PLAN verbs (2026-10-31, uncommitted):** the radial now has 12 slices
(`Stop, Look, Point, No, Ask, Inspect, Wand, Warn, Block, Reason, Recognize, Radio`).
`eye_contact` reuses the Level 1 reaction table via `_apply_interaction`. Recognition is a player
decision: `NPCController.player_recognized` + `IncidentRecorder.add_access_evidence`, and
`GameManager._artifacts_for()` prepends a `RECOGNITION` artifact only after the player declares
it. The body camera is a real flag (`GameManager.body_camera_on`, `Hud.set_body_camera`), off by
default, `B` toggles it, and a formal warning arms it and writes a `body_camera` incident event.
Blocking also calls `_station_between()` to physically place the guard 0.9 m from the NPC toward
the entrance. `_request_reason()` logs the NPC's seeded `dialogue_lines` pick.
- **NPC navigation was dead (fixed 2026-10-31):** avatars never moved in the live game, for three
stacked reasons. (1) `agent.target_position` was re-assigned every physics frame, which
invalidates the path so `get_next_path_position()` keeps returning the current point — only set
it when the target changes. (2) Nav path points sit on the navmesh plane (y=0) while bodies stand
at y≈0.5, so a 3D "are we there" test always looked 0.5 m away and the horizontal direction was
zero — compare horizontally. (3) `path[0]` is the old start point, so picking any point "far
enough away" can pick the point behind you and oscillate — pick the first point that is
**closer to the goal** than the current position. `_nav_try_frames`/`_move_direct` fallback is
still there.
- **Crowd reactions listen on the EventBus:** `CrowdSystem.apply_event()` emits
`event_bus.crowd_changed`; its own `changed` signal is never emitted. `GameManager._connect_crowd()`
therefore connects to `coordinator.event_bus.crowd_changed` (reconnect after every
`coordinator.setup()`, which recreates both crowd and event bus).
- **Crowd presentation:** `NpcAvatar.start_rubberneck(at)` (idle bystanders away from the door stop
and face the incident for 12 s) and `start_flee(from)` (panic: walk away at 2.4 m/s, then free
themselves). Someone `awaiting_interaction` within 3 m of the door is "engaged" and does not
rubberneck. Use `position` (not `global_position`) for that distance check so it also works in
headless tests outside the tree.
- **Perf:** a busy 16-NPC entrance measured ~1.2 ms/frame (824 fps with vsync off, 800 samples),
so LOD/culling is not warranted at `MAX_LIVE_NPCS = 16`. Re-measure before raising the cap.
- **Incident recording was dead:** `IncidentRecorder.start_incident()` was never called, so every
`add_event`/`add_access_evidence` silently no-opped and the review always showed 0 incidents.
`_begin_incident()` now starts one per focused NPC (`has_open_incident()` guard), verbs call
`_ensure_incident()`, and `ShiftCoordinator._finish_shift()` closes leftovers as `unresolved`
before building the review.
- **Editor-ready venues (2026-10-31):** `tools/export_venues.gd` (a SceneTree script) saves the
three factory scenarios to `content/venues/<id>.tres` with `ResourceSaver`; the `.tres` files
round-trip through `ResourceLoader` (events, profiles, policy, shift phases, door lines,
locations). `test_venue_resources.gd` also fails if the exported files are missing or stale
relative to the factory, so re-run the export after changing `ScenarioFactory`.
- **Vision judge (2026-10-31):** `python3 tools/screen_designs.py judge` sends the capture plus
its concept mockup to a vision model (`DEFAULT_JUDGE_MODEL = google/gemini-2.5-flash-lite`,
override with `--model`; `openai/gpt-4o-mini` also works). Reads the repo `.env` via
`_load_env_file()` (never overrides real env). Verdicts are classed `match` / `review`
(mismatch below `--fail-confidence`, default 0.6) / `diff` (confident mismatch → exit 1) /
`unknown`. `--dry-run`, `--json`, `--only` supported. The prompt explicitly ignores art style,
because the references are stylised mockups; the live run gave 3 match, 3 review, 0 diff.
Python tests: `cd tools && python3 -m unittest test_asset_pipeline test_screen_designs`.
- **String formatting safety (2026-10-31):** `Strings.placeholder_count()` counts `%` specs
(ignoring `%%`), `fmt()` warns and returns the template when the arg count does not match
(instead of raising an engine error), and `validate()` lints the table for trailing `%`,
unsupported conversions, and mixed positional/sequential placeholders. Tests cover all three
plus a formatting pass over every placeholder key with type-correct dummy args.
- **Authored scenario content (2026-10-31):** events can now carry `hint` (a string key logged
when the avatar spawns), `extra_spawns` (celebrity arrivals pull in tourists/influencers via
`_spawn_npc` with `kind: routine`), and `refuses_screening` (copied to
`NPCController.refuses_screening`; `_toggle_wand_screening` logs `wand.refused`, records a
`wand_refused` event, applies a small prop hit, and does not open the panel). `film_boundary`
is a new intent that maps to `steps_end` so the influencer stands past the posted line.
- **Accessibility (2026-10-31):** escalation text is `"<name> [###--] L3/5"` via
`Hud.escalation_meter(level)` (pure, tested) so the cue survives grayscale. HUD font scale uses
`add_theme_font_size_override` on the core labels (`set_font_scale`), **not** `Control.scale`,
which pushed the right-anchored clock label off-screen. Settings load before `_build_ui()`, so
`_ready()` re-applies player settings after the HUD exists.
- **Audio (2026-10-31):** `AudioFeedback.start_crowd_bed()` builds a 4 s looping `AudioStreamWAV`
(one-pole-filtered noise + 68/103 Hz drone, seeded) and `set_crowd_level(mood)` maps 0..1 to
-34..-12 dB. `play_radio_open/close` push short filtered-noise bursts. All procedural; with a
dummy (headless) audio server the calls are no-ops. `_free_player()` returns null when `_ready`
has not run (tests), and callers handle null.
- **NPC theater (2026-10-31):** `NpcAvatar.theater_pose()` maps `intent` + `arrived` to
idle/sit/photo/film/perform. Pure helpers `crouch_for_pose`, `prop_lift_for_pose`,
`sway_for_pose` keep the math testable. Sit lowers the rig by 0.34 and swings the legs forward
(negative X pitch; positive looked like kneeling). `_photo_prop` is set for influencer/tourist
cameras, `_sign_prop` for protester/performer boards; their base Y values are captured at build
time so the animation offsets from rest.
- **Body-camera timeline (2026-10-31):** `src/game/ui/event_timeline.gd` flattens
`review.incidents.incidents[].events` into one time-ordered list (`flatten`), formats each row
with `describe`, and steps with Left/Right or the buttons. `ShiftCoordinator.started_at_ms`
gives the review an absolute start for mm:ss stamps. `GameManager._open_timeline` hides the
review dim (two stacked overlays made the screen nearly black) and `_timeline_from_review`
restores it on close. Smoke: `src/test/smoke_timeline.gd`.
- **Radio dispatch was dead after the first shift:** `coordinator.setup()` builds a new
`RadioSystem`, so the `responder_arrived` connection made in `_ready` was lost on every venue
switch/restart (responders never resolved anything). `_connect_radio()` now re-wires it next to
`_connect_crowd()` after every setup. Same class of bug as the crowd signal.
- **Emergency aftermath:** `ShiftCoordinator.responder_outcomes` / `investigations` feed the review.
`evacuation` (emergency call, low danger, no weapon) locks the door, makes every NPC flee, marks
them `leave_requested`, and applies a `security_failure` hit. An emergency placed while
`current_npc_truth` has no weapon and `danger_level <= 1` appends an investigation note, records
`investigation_opened`, and applies `false_report` -6.
- **Skyline backdrop (2026-10-31):** `PlaneMesh` defaults to a **horizontal** XZ plane — set
`mesh.orientation = PlaneMesh.FACE_Z` or the "backdrop" renders as a band overhead. The skyline
is now a ring of four 400x60 planes at 200 m (center y=30, so building bases meet the ground),
`TEXTURE_FILTER` default, `TRANSPARENCY_ALPHA_SCISSOR` with threshold **0.5** (0.3 left faint
mip dashes across the sky), unshaded with a lifted albedo (`Color(2.6,2.7,3.0)`) so it is not
pure black. Ground is 500x500 so no sky gap shows under the buildings. The `skyline.png` import
needs `mipmaps/generate=true`.
- **Asset gotcha:** `assets/textures/game_ready/` is *not* imported (no `.import`). Use the
imported `assets/textures/skyline.png` path. Untracked duplicate folder should not be referenced.
- Host sign-in follow-up is **done**: `REQUIRE_SIGNIN_VIEW = false` in `/etc/gitea/app.ini`
(backup `app.ini.bak-*` alongside), Gitea restarted; anonymous repo page/API return 200 and
release assets download in full. Reverse by setting it back to `true` and restarting.
- **Release publishing was broken since v0.2.2 (fixed 2026-10-31):** `publish_release.sh`
only built `AUTH` in the `GITEA_TOKEN` branch; the `~/.git-credentials` branch set `CRED`
and then used unbound `${AUTH[@]}` under `set -u`, so every API call failed and the tag
job ended "could not create or find release". The repo also has **no** `GITEA_TOKEN`
secret (`/actions/secrets` is empty) and the runner has no git credentials. Fixes: the
script sets `AUTH=(-u "$CRED")`; the workflow falls back to `github.token`
(`secrets.GITEA_TOKEN || github.token`) — but `github.token` is **not** provided by this
Gitea/act runner, so the env dump showed no token and the v0.2.10 tag build failed again.
Final fix: `publish_release.sh` falls back to the **local git extraheader** that
`actions/checkout` writes (`git config --local --get-regexp '^http\..*\.extraheader$'`),
which carries the runner's basic-auth credentials for the same server. Verified locally by
adding that extraheader and publishing v0.2.10 (all five artifacts). **v0.2.9 and v0.2.10
are published**; v0.2.4–v0.2.8 tags have no artifacts. Verify the CI path on the next tag.
- **CI publishing verified (2026-10-31):** tagging v0.2.12 produced a release with all five
artifacts automatically (no repo secret), confirming the checkout-extraheader fallback.
- **Host access (2026-10-31):** `root@192.168.88.30` now works from this workstation (key auth),
host name `gitea`, Gitea 28.2.0. Maintenance applied: `apt-get clean`, journald capped to
`SystemMaxUse=300M` (`/etc/systemd/journald.conf.d/99-size.conf`, journal ~133M), disk at
~7.6G free of 16G.
- **Host disk resolved (2026-10-31):** the Gitea VM root filesystem was expanded from 7.8G to
16G (`/dev/mapper/pve-vm--500--disk--0`, now ~7.8G free). The pruned **v0.2.0** and **v0.2.9**
releases were restored from the local `build/` artifacts and verified (4 and 5 assets).
`git.allucanget.biz` runs on this host as user `zwitschi`; root@192.168.88.30 is **not**
reachable from this workstation (publickey denied; password auth closes the banner), so host
maintenance must go through a `zwitschi` login with sudo.
- **Host disk full (2026-10-31, historical):** the v0.2.11 tag build created release id 27 and logged the
uploads, but they failed with `HTTP 500 Attachments.Save: write … no space left on device`
(`/dev/mapper/pve-vm--500--disk--0`, 7.8G, 100%, 0 free). `git push` then failed too
("unpacker error"). Unblocked by deleting the superseded **v0.2.0** and **v0.2.9** releases
via the API (~565 MB of assets), then publishing v0.2.11 locally with the fixed script
(verified 5 assets). Remaining ~150 MB is tight — the next release needs host cleanup on
192.168.88.30: `sudo journalctl --vacuum-size=100M` (journal 837 MB), `sudo apt-get clean`
(91 MB), clear `/var/lib/gitea/data/attachments/tmp`. SSH works as `zwitschi`; `sudo -n`
needs a password. **Releases now:** v0.2.11, v0.2.10, v0.2.1 (5 assets each); v0.2.0 and
v0.2.9 entries were pruned. PLAN.md + ADRs 0007–0010 still describe the intended UX.
- **Release v0.2.1 is LIVE** (2026-10-31): CI run 1110 built the NSIS installer + AppImage
and published — installers/AppImage are on the releases page. Releasing = bump version → tag → push.
- NSIS gotchas (validated in a local ubuntu:24.04 docker repro): makensis uses `-D` defines
(never `/D`), resolves relative `-DICON`/`-DARTIFACT` against the **script directory** so CI
passes `$PWD`-absolute paths; installed exe must be space-free (`step-line.exe`); ICO must be
classic BMP entries (ImageMagick `icon:auto-resize=48,32,16`; PIL emits PNG-compressed ICOs NSIS
cannot load).
- appimagetool runs system `appstreamcli` if present (strict, exit 3 on warnings) — project
metadata now passes `--pedantic`.
- Distribution facts (verified via Gitea API + host creds): repo is **public**, account is
admin; `v0.2.1` has all 5 assets; the installers were initially missing from the release
because `publish_release.sh` uploaded only portables — now fixed (uploads installer +
AppImage; falls back to `~/.git-credentials` when no `GITEA_TOKEN`).
- Host sign-in: instance `[service] REQUIRE_SIGNIN_VIEW=true` blocks anonymous browse/download
(not a repo/API setting). `~/.git-credentials` holds the Gitea creds (basic-auth usable);
`.env` only holds `OPENROUTER_API_KEY`.
- **Release v0.2.0 was LIVE**: pushing the `v0.2.0` tag made Gitea Actions run the build job
and publish automatically (host has the `GITEA_TOKEN` secret). Tag page and `/releases` are
public (HTTP 200). Caveat: anonymous **asset downloads** redirect to login on this host
(host setting — tracked in TODO). Future releases = bump `config/version`, `git tag vX.Y.Z`,
push. No local token needed.
- Core simulation, three scenarios, headless tests (**57 passing**), and a first-person prototype with interaction HUD, access inspection, radio (assist + emergency hold-to-confirm), debug overlay, and shift review are implemented and green.
- Core simulation, three scenarios, v0.2.1 installers/AppImage, first-person prototype (inspect/wand/radio, shift review, Tab targeting, V venue cycle) are on local `main`. Tagged v0.2.1 was 57 tests; this working tree is **74/74**.
- NPC avatars now walk to per-intent waypoints (`VenueScenario.waypoint_for_intent`): enter/trespass/deliver/arrive → entrance, photo/film/perform/wander/roam → sidewalk, sit → steps_end. `NpcAvatar.setup` takes an explicit `door_position`.
- Returning-trespasser behavior: `NpcProfile.return_probability` (trespasser 0.6, mental-crisis 0.4, others 0). `NPCController._maybe_schedule_return()` moves a directed-away NPC to `LOITERING_DISTANT`; `tick_return(delta)` fires after 20-60s and transitions back to `APPROACH`. `NpcAvatar._advance_fsm` re-targets the door on return.
- Perception pass: `NPCController.perceive_guard(distance)` returns `{noticed, band, reaction}` with bands `unaware` (>6m), `aware` (2-6m), `near` (<2m). Near + temperament ≥0.6 → ARGUE, else COMPLY. `GameManager._perception_pass()` feeds real guard distance each physics tick and logs first-time notice.
- Responder dispatch: `RadioSystem` tracks `pending_calls` and decrements `arrival_delay_s` in `tick`; emits `responder_arrived` when a call's delay elapses. `RadioSystem.responder_outcome(call)` maps danger/weapons → command/restraint/evacuation/arrest. `GameManager._on_responder_arrived` spawns a `RESPONDER` avatar at the entrance and logs the outcome. HUD shows an emergency-radio cooldown label via `_update_radio_cooldown`.
- Wand screening (Joke Shop): `WandScreener` (new `src/core/wand_screener.gd`) classifies carried items as prohibited (`weapon/knife/gun/blade`) vs false-alarm (`keys/belt/phone/coins/watch/buckle`) vs clear. `NpcProfile.carried_items` holds guest items. `GameManager` binds `W` to `_toggle_wand_screening` and `A`/`D` to `decide_wand`; `decide_access` blocks admission at wand venues until `controller.screened` is true. `VenueScenario.door_staff_lines` holds venue-specific door-staff dialogue (Joke Shop has id/ticket/wand/refusal/prohibited lines).
- Wand screening (Joke Shop): `WandScreener` classifies carried items as prohibited vs false-alarm vs clear. `NpcProfile.carried_items` is filled at spawn on wand venues. `GameManager` binds **`C`** to `_toggle_wand_screening` and `A`/`D` to `decide_wand` while inspect/wand is open. `decide_access` still blocks admission until `controller.screened` is true, and `verify` now takes that flag. Door-staff lines log through GameManager.
- Design change (2026-10-31): the selectable dialogue tone-of-voice system was **removed**.
`Rules.Tone`/`TONE_NAMES`, `NpcProfile.tone_modifiers`, and the tone param on
`NPCController.roll_reaction/apply_player_action` no longer exist; `NpcProfile.dialogue_lines`
@@ -59,6 +318,11 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
- Assets pipeline: `python3 tools/generate_textures.py` produces all authored PNGs under
`assets/textures/` (≤1024px, pow2); regenerate after changing the script, then run
`godot --headless --import` before tests. 57 headless tests guard the pipeline.
- General image pipeline: `python3 tools/asset_pipeline.py` defaults to
`openai/gpt-5-image-mini`, requests supported 1:1/3:2 sources, then fits each role's target.
It provides crop, resize, tile edge blending, alpha-glyph recoloring, and role-aware audits.
Screenshot auditing checks 1280x720 minimum, 16:9 aspect, and contrast; visual composition
remains a manual check. Python tools require Pillow from `requirements-tools.txt`.
- AI texture mode: `python3 tools/generate_textures.py --ai` generates textures via the
OpenRouter image API (`POST https://openrouter.ai/api/v1/images`, model
`bytedance-seed/seedream-4.5` by default, base64 PNG in `data[].b64_json`). Requires
@@ -67,12 +331,14 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
default batch. Output is downscaled to ≤1024px. NOTE: seedream rejects sizes below
3,686,400 px (1920×1920), so the script requests `GEN_SIZE=2048` and downscales — do not
lower `GEN_SIZE` below 1920. Verified end-to-end 2026-10-31 (3 textures generated).
`AI_TEXTURES` now covers all 15 game textures (world surfaces, awning, skyline, vignette,
4 HUD icons, 5 access cards) with a `transparent` flag per entry. NOTE: seedream-4.5
ignores `background: transparent` and returns fully opaque images (alpha=255), so the
`ai/` icons/vignette/skyline are opaque — they are NOT drop-in replacements for the
authored transparent PNGs. Use a transparency-capable model (or post-process) if alpha
is needed.
Legacy `--ai` remains on Seedream 4.5 and still ignores `background: transparent`; do not
use it for alpha-dependent assets. Gemini 3.1 Flash Image advertised broad ratios but the
current provider rejected authentication. The validated role-aware model is GPT-5 Image Mini.
- Runtime assets: all 15 loaded world/HUD/card textures now live in
`assets/textures/ai/game_ready/`; each passes role audit. Generated tile edges are blended,
icon alpha masks are recolored white where needed, and the vignette uses the existing
deterministic generator because the API returned an empty transparent image. The six
`docs/screenshots/` captures were regenerated after wiring and pass screenshot audit.
- Detailed graphics: textured facade/awning/skyline, humanoid NPC rigs + props + walk bob +
attention head-turn, inspection artifact cards, mode icons, escalation vignette, body-cam
overlay (REC + shift clock). Screenshot set regenerated (`docs/screenshots/`, day + night).
@@ -80,6 +346,17 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
- NOTE: collaborators occasionally add uncommitted work to this shared tree (e.g. the radio
easter egg); check `git status` before committing and leave foreign changes untouched unless asked.
- Screen-design references (`tools/screen_designs.py`, tests in `tools/test_screen_designs.py`):
`generate` turns the screen descriptions in `docs/` (README captions, ADRs 0007–0010,
PLAN, architecture) into reference images under `docs/screens/designs/` — `--draft`
draws offline Pillow wireframes, `--ai` generates realistic 3D scenes / in-game UI via
OpenRouter (default model `openai/gpt-5-image-mini`). Screens are tagged `scene` or
`ui`; prompts get a shared art-direction suffix, and requests use a 3:2 landscape
source cropped to 16:9 (not a square centre-crop). `compare` checks `docs/screenshots/*.png` for aspect/size/
blank-frame and coarse layout/palette/detail drift. Drift distances are warnings, not
failures: a mockup never matches a real render, so semantic composition is a follow-up
vision-model judge (tracked in TODO).
## Known Issues / Notes
- Reference cycles are fixed: `FSM` holds its owner via `WeakRef`, `ShiftCoordinator` is
@@ -92,16 +369,41 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
- Steps are visual-only (no collider) so NPCs can path to the entrance; the guard spawns on the landing so it never needs to climb.
- Persistence tests write into the real `user://shift_results.json` (harmless local pollution of the demo history).
- macOS export required `rendering/textures/vram_compression/import_etc2_astc=true` in `project.godot` (universal build demands ETC2 ASTC enabled).
- Joke Shop `door_staff_lines` authored but not yet surfaced in the prototype UI (tracked in TODO).
- Joke Shop `door_staff_lines` are logged through `GameManager` (inspect / wand / refuse). They are not a separate dialogue UI.
- Access loadout is implemented in the working tree (see Current State); do not re-fix `override_access` as if it were still unread.
- `Save.SETTINGS_FILE` is declared and unused; no mouse-sensitivity or pause menu yet.
- Headless suite is **77/77**; the windowed focus/radial smoke passes. Remaining visual follow-ups are at the top of `TODO.md`.
- Typed spawn signal extra args: handlers must accept the 4th `event: Dictionary` or Godot will not call them (`test_shift_coordinator_runs_a_shift` failed until `_on_spawn` took four args).
- GDScript inference trap that broke the main scene: a `var` typed as a base class
(`radial: CanvasLayer`) exposes script members as `Variant`, so `var x := radial.is_open` is a
parse error. Type it as the script type (`ActionRadial`) or annotate the local explicitly.
`--check-only --script` cannot see the `Save` autoload; use a real boot (`--quit-after 180`) to
verify a full-scene compile.
- Do not snapshot right after changing a transform in the same frame: `capture_screenshots.gd`
needs a state/frame gap or the capture uses the old camera orientation.
- `EventScheduler` probabilities are per simulated second, not per call. Never poll it per frame.
- Screenshot staging lives in `src/test/capture_screenshots.gd` (guard at z≈10.5 facing the door).
`docs/screenshots/` copies are manual: copy from `user://screenshots/`.
## Open Questions
- Add a vision-model (LLM) judge on top of the screen-design comparison: pixel and
perceptual metrics cannot decide whether a screenshot's composition is correct.
- Whether the responder avatar should be playable/controllable or purely scripted.
- Localization target languages beyond English.
- macOS notarization approach for broad distribution.
## Plan vs TODO Gaps (evaluated 2026-10-31, all resolved)
## Plan vs code (re-evaluated)
All previously identified gaps are now implemented and covered by tests/smoke items: PROTESTER archetype, door lock/secure, flashlight, CALL_SOMEONE reaction, LATE phase fix, shift-result persistence, NavigationAgent3D movement, and JSON-backed localization. Remaining backlog lives in `TODO.md`.
On local `main` (6 commits not on origin): v0.2.1 packages, ADRs 0007–0010, asset pipeline wired to
`assets/textures/ai/game_ready/`, screenshots refreshed.
Uncommitted: `AccessLoadout`, control pass (cone, ring, radial, NPC face, named input actions),
and boot/spawn fixes. Headless 77/77; windowed smoke passes. Access-artifact and control-pass
sections moved to `DONE.md`.
Still in `TODO.md`: world follow-ups (guard float, step colliders, skyline asset, marquee size);
session shell; leftover PLAN verbs; crowd rubberneck; emergency aftermath; body-cam replay;
sit/photo/film poses; real crowd audio; Joke Shop/Studio extra events; PLAN.md says GUT/GitHub.
<!-- b64: RWFzdGVyIGVnZzogYSByYXJlIHJhZGlvIHRyYW5zbWlzc2lvbiAiwr9Ew7NuZGUgZXN0w6EgbGEgbW90YT8iIGZpcmVzIHZpYSBHYW1lTWFuYWdlci5fbWF5YmVfcmFkaW9fZWFzdGVyX2VnZyAoMi41JSBjaGFuY2UgYWZ0ZXIgYSBzdWNjZXNzZnVsIHJhZGlvIGNhbGwpLiBTdHJpbmcga2V5IHJhZGlvLmVhc3Rlcl9lZ2cgaW4gY29udGVudC9zdHJpbmdzLmVuLmpzb24uIERvIG5vdCByZW1vdmUu -->
+155 -4
View File
@@ -432,13 +432,15 @@ The venue-driven architecture maps onto Godot as follows:
## Testing
- Unit and scenario tests use the GUT (Godot Unit Test) framework.
- Unit and scenario tests use a lightweight custom headless runner
(`src/test/test_runner.gd`). GUT was evaluated but requires the editor plugin.
- Tests run in headless mode so rules, access verification, escalation, and scoring can be verified without rendering or user input.
## Version Control and CI
- Source is tracked with git.
- GitHub Actions runs headless Godot tests and, in later stages, exports desktop builds.
- Gitea Actions (`.gitea/workflows/build.yml`) runs headless Godot tests on push/PR and exports
Linux/Windows/macOS desktop builds plus the NSIS installer and AppImage on tags.
## Planned Commands
@@ -446,11 +448,160 @@ The following commands apply once the Godot project is scaffolded:
- Open the editor: `godot --editor --path .`
- Run the game: `godot --path .`
- Run tests headlessly via GUT.
- Run tests headlessly: `godot --headless --path . res://src/test/test_runner.tscn`
## Export Targets
Desktop is the first target: Windows, Linux, and macOS. Web export is a later option.
Desktop is the first target: Windows, Linux, and macOS. A Web export now ships as well
(non-threaded, static hosting); it is published with each release as a zip.
## Player Control, Focus, and Action Menu (next pass)
The prototype already walks and looks in first person, but the control scheme fights itself and targeting is a hairline. This pass replaces that with three things that belong together: solid locomotion, a visible focus on a person, and a round action menu for what to do with them.
Decisions: [ADR-0007](docs/adr/0007-locomotion-and-input-layers.md) locomotion and input layers, [ADR-0008](docs/adr/0008-cone-focus-and-head-ring.md) cone focus and focus circle, [ADR-0009](docs/adr/0009-radial-action-menu.md) radial action menu, [ADR-0010](docs/adr/0010-procedural-npc-presentation.md) procedural NPC presentation.
### What is wrong today
- `W` is walk-forward in the input map and wand-screening in `GameManager._handle_key`. Walking at someone with a wand venue fires the wand.
- `A` / `D` are strafe and also allow/deny during inspection.
- `Q` is polite direction and is also bound as `cancel`.
- Focus is an 8 m physics ray from the camera. Missing a capsule by a few pixels clears the target. There is no mark in the world that you have someone.
- NPC bodies are colored capsules with props. They read as markers, not people.
- Actions live on number keys `1`–`6` plus `Q` / `E` / `W` / `R`. That is a cheat-sheet, not a verb you pick while looking at a person.
- `Esc` only toggles mouse capture. There is no "I changed my mind" for an action in progress.
### Movement and look
Keep first-person WASD + mouse look. Fix the feel and stop stealing those keys.
- Body yaw from mouse X, head pitch from mouse Y, pitch clamped (already `-80` to `80`).
- Move relative to body yaw only (already). Add short acceleration / deceleration so starts and stops are not instant.
- Gravity only when `not is_on_floor()`; `floor_snap_length` so the steps work.
- Mouse look runs only while `MOUSE_MODE_CAPTURED` and no modal UI is open (radial, inspection, wand, radio, shift review).
- Walk speed stays around 3.2 m/s. No sprint, no crouch in this pass.
- While the radial is open: freeze look and WASD so `A` / `D` can cycle the menu. Recapture the mouse when the menu closes.
- `Esc` with a modal open closes that modal and does nothing else. `Esc` with nothing open keeps the current capture/release toggle.
Equipment stays on its own keys because it is not about a person: `G` flashlight, `L` door lock, `Tab` cycle nearby people, `V` venue (debug), `F` debug overlay. `R` stays the dedicated emergency radio (hold-to-confirm on the existing panel). Those do not go on the wheel.
Wand leaves `W`. It is a slice on the wheel (greyed when the venue has no wand) and an optional extra key that is not WASD, suggested `C`.
### Focus area
Replace the hairline ray as the primary picker.
- A **focus cone** in front of the camera: people within about 10 m whose head projects inside a screen-space circle around the crosshair (about 90 px / 10 degrees).
- If several heads sit in the circle, pick the one closest to the center, then the nearer one if tied.
- The person must be in front (dot with camera forward > 0) and not fully occluded by the building. A short ray to the head is enough for occlusion.
- `Tab` still cycles people inside 12 m as the aim-free path. Cycling sets the same focused person the cone would.
- Clearing: look away until the head leaves the circle for a few frames (small grace so a bob does not drop them), or they walk out of range.
### Focus circle
When a person is in focus, draw a blue, semi-transparent circle in front of their face.
- Attach to the avatar `Head` node, just in front of the face, billboarded to the camera so it stays a circle on screen.
- Slow spin is fine; no ping-pong scale spam.
- Idle focus: blue, semi-transparent, modest size. Radial open on that person: brighter, slightly larger.
- Optional small name under the circle (`display_name` only, not the archetype cheat `Violent Threat` in the world). The existing HUD target line can keep the archetype for the prototype.
- One circle at a time. Everyone else has none.
Implementation sketch: a thin unshaded `MeshInstance3D` circle (flat disc) in front of the face on the avatar, toggled from `GuardController` / `GameManager` when focus changes. Screen-space fallback (unproject head, draw a `Control` circle) is acceptable if the 3D circle z-fights on hats.
### Round action HUD
A doughnut / pie menu centered on the screen, opened on the focused person.
**Open**
- Left mouse button, or `Space`, while someone is in focus and no other modal is open.
- If nobody is in focus, log the existing "aim first" line and do not open.
- The click that opens the menu must not also pick a slice.
**While open**
- Mouse becomes visible. Look and walk freeze.
- Center of the doughnut shows the focused person's `display_name`.
- Slices, clockwise from the top, de-escalation first:
1. Raise hand (stop / wait)
2. Point away
3. Head shake
4. Polite direction (old `Q`)
5. Inspect access (old `E`)
6. Wand (grey if the venue has no wand)
7. Formal warning
8. Block
9. Radio assist
- Emergency is **not** on the wheel (`R` only).
- Highlight follows the mouse angle when the cursor is over the ring. `Left` / `Right` or `A` / `D` step the highlight. Wrap around.
**Confirm**
- Click a slice: that action runs, menu closes, mouse recaptured.
- `Space` while a slice is highlighted: same.
- Inspect or wand: close the wheel, then open the existing inspect / wand panel. Allow / deny on that panel stay `A` / `D` (walk is frozen only while the wheel is up; once the inspect panel is open, WASD can move again, so allow/deny on `A` / `D` remains a press handler and must ignore repeats / echo, which it already does). Safer: keep walk frozen for inspect/wand too, until the panel closes. Prefer freeze until the whole interaction ends.
- Radio assist: close the wheel, open the existing radio panel.
**Cancel**
- `Esc` or right mouse button: hide the menu, no action, no recorder event, recapture mouse.
- Focus and the focus circle stay as they were.
Number keys `1`–`6` may remain as shortcuts that skip the wheel so a smoke-test script still works. They must not fire while the wheel is open (the wheel owns `A` / `D` / `Space`). Document both in the README.
### NPC display
Stay on the procedural capsule rig (no Mixamo / GLB dependency this pass). Make them read as people at conversation distance.
- Neck between torso and head; shoes; simple hair cap (color from seed); two dark eye spheres on the face so a head has a front.
- Keep archetype props (camera, box, hat, sign, bag). Keep height / brightness variation from the visual seed.
- Collision capsule matches the scaled height so the focus ray / cone hits the body you see.
- `Head` stays a named node other systems can find (`get_node("Rig/Head")`) for the ring and the cone.
- Walk bob and the "look at the guard when noticed" cue stay.
- Do not put the internal type name (`Trespasser`, `Violent Threat`) on the body. The player should judge from clothes, props, and behavior. The debug overlay (`F`) can still print the type.
AI textures in `assets/textures/ai/` stay out of this pass unless a specific map (shirt, hair) is already the right size.
### Input map (target)
| Action | Bind |
| --- | --- |
| Walk | WASD / arrows (arrows **do not** walk while the wheel is open; they step the highlight) |
| Look | Mouse, captured |
| Open / confirm action menu | LMB, `Space` |
| Cancel menu | RMB, `Esc` |
| Step highlight | `A` / `D`, left / right arrows |
| Emergency radio | `R` |
| Flashlight | `G` |
| Door lock | `L` |
| Cycle nearby people | `Tab` |
| Wand shortcut | `C` (not `W`) |
| Inspect shortcut | `E` still allowed when the wheel is closed |
### Files this pass is expected to touch
- `src/game/guard_controller.gd` — cone focus, no look while modal, accel, floor snap.
- `src/game/npc_avatar.gd` — face / hair / neck, head marker, ring toggle.
- `src/game/ui/action_radial.gd` — new Control, `_draw` doughnut, slice pick.
- `src/game/ui/hud.gd` — host the radial; stop teaching number-key verbs as the only path.
- `src/game/game_manager.gd` — input routing, freeze flags, open/confirm/cancel.
- `project.godot` — input map.
- `src/core/strings.gd` — controls line, aim-first, no new tone-of-voice.
- `src/test/tests/test_focus_and_radial.gd` — cone pick, wrap, cancel emits nothing, `W` is not wand.
- README controls table and smoke checklist.
## Rendering, Models, and Selection (v0.3.0 pass)
Decisions: [ADR-0011](docs/adr/0011-aim-free-selection-and-precision-walk.md) aim-free selection and precision walk, [ADR-0012](docs/adr/0012-surface-materials-and-generated-props.md) surface materials and generated props.
The blockout look and the aim-first selection model were the two biggest gaps left by the control pass. This pass closes them without changing the game's rules.
**Look.** Surfaces get real profiles instead of stock materials: brick is rough and finely tiled, concrete is rougher still, asphalt is smoother and damp-looking, glass is smooth and slightly metallic. The environment gains ACES tonemapping, soft glow, aerial fog and a mild grade, all driven by the same per-phase configuration that already moves the sun and sky. The facade gains framed windows that light up after dark, a stone base course, door trim and handles. Street furniture becomes real geometry: bollards, benches, bins, planters, newspaper boxes and a hydrant, built as `.glb` props by `tools/model_pipeline.py` with a procedural fallback. NPCs get per-role clothing palettes, small role accessories (cap, hood, backpack, sunglasses, lanyard), hands, shoes, and an arm/leg walk swing. The rule stays: the player judges people from clothes, props and behaviour, never from an archetype label.
**Models.** Prop meshes are deterministic primitives, so the build works offline. An image model can supply the per-prop colour palette from a concept sheet; that is the only AI influence on geometry, and it is optional. Colours are converted from sRGB to linear before they reach glTF, because glTF factors are linear.
**Selection.** The focus cone ranks heads by angle from the crosshair then distance. `Tab` and the mouse wheel cycle nearby people nearest-first and pin the choice, so aim-free play works. Right click clears focus and briefly suppresses re-acquisition. Holding `Shift` walks at half speed for close work at the door. Escape keeps its single meaning (close the top panel, otherwise pause).
## Design Direction
+233 -22
View File
@@ -25,11 +25,15 @@ The central design goal is simple:
| **Linux — AppImage** | [`step-line-<ver>-x86_64.AppImage`](https://git.allucanget.biz/zwitschi/security-game/releases) · single file, `chmod +x` and run |
| **Linux — portable** | [`step-line-linux-<ver>.x86_64`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, `chmod +x` and run |
| **macOS** | [`step-line-macos-<ver>.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unsigned build, quarantine note below |
| **Web (browser)** | [`step-line-web-<ver>.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unzip and serve the folder from any static host (no special headers) |
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
> **Latest: v0.2.0** (2026-10-31) — built and published by Gitea Actions from the
> `v0.2.0` tag. Releases are built automatically when a `vX.Y.Z` tag is pushed
> Downloads are public (no sign-in required) since the host's instance-wide
> `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31.
>
> **Latest: v0.3.1** — props draw with a single atlas material instead of one material per part.
> Releases are built automatically when a `vX.Y.Z` tag is pushed
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
> (NSIS + appimagetool) and uploads with the portable binaries.
@@ -136,11 +140,143 @@ The architecture maps onto Godot 4 nodes, scenes, and custom resources as descri
- **Navigation:** `NavigationRegion3D` and `NavigationAgent3D`
- **NPC behavior:** Custom lightweight finite state machines driven by `NpcProfile` resources
- **UI and audio:** Godot `Control` nodes, themes, audio buses, and `AudioStreamPlayer` nodes
- **Art assets:** authored PNGs in `assets/textures/` (≤1024px, power-of-two) produced by `tools/generate_textures.py` and imported by Godot; structural geometry stays procedural (`WorldBuilder`). The same script can generate textures through the OpenRouter image API (`--ai`, requires `OPENROUTER_API_KEY`).
- **Art assets:** authored PNGs and the in-game generated set in `assets/textures/ai/game_ready/`; street-furniture models in `assets/models/generated/` build from `tools/model_pipeline.py`; structural geometry stays procedural (`WorldBuilder`). The legacy `tools/generate_textures.py --ai` batch remains available.
- **Asset tooling:** `tools/asset_pipeline.py` generates role-aware assets through OpenRouter and provides crop, resize, seamless-tile, and image-audit commands, including gameplay screenshot checks. `tools/model_pipeline.py` builds and audits the prop `.glb` models.
- **Testing:** GUT (Godot Unit Test) in headless mode
- **Source control:** git on a Gitea host with Gitea Actions (`.gitea/workflows/build.yml`) for headless tests and desktop release builds
- **Targets:** Windows, Linux, and macOS desktop first; web later
### Image Asset Tools
Install the Pillow dependency used by the existing texture generator and the asset tools:
```bash
python3 -m pip install -r requirements-tools.txt
```
Generate a role-sized image (generation requires `OPENROUTER_API_KEY`):
```bash
python3 tools/asset_pipeline.py generate --prompt "weathered red brick" --role tile --output assets/textures/brick_extra.png
```
Generated images are resized to the selected role profile. Non-square requests use
the validated `openai/gpt-5-image-mini` source ratio and crop to the final role dimensions.
Transparent glyphs can be recolored while preserving their alpha mask with
`--foreground '#ffffff'`.
Crop uses the image center by default; `--x` and `--y` select an explicit crop origin. Resize defaults to aspect-preserving `contain`; use `--fit cover` to crop or `--fit stretch` to distort to the exact dimensions.
```bash
python3 tools/asset_pipeline.py crop assets/textures/source.png assets/textures/crop.png --width 256 --height 128
python3 tools/asset_pipeline.py resize assets/textures/source.png assets/images/backgrounds/scene.png --width 1920 --height 1080 --fit cover
python3 tools/asset_pipeline.py seamless assets/textures/rough_tile.png assets/textures/tile.png
```
Audit accepts image files or directories. `--role auto` infers common roles from paths and filenames; explicit roles include `tile`, `texture`, `icon`, `card`, `banner`, `backdrop`, `background`, `overlay`, `app_icon`, and `screenshot`.
```bash
python3 tools/asset_pipeline.py audit assets/textures
python3 tools/asset_pipeline.py audit assets/images/backgrounds --role background --json
python3 -m unittest discover -s tools -p 'test_*.py'
```
The audit classifies measurable fit issues as `good` or `bad` based on role-specific dimensions, aspect ratio, alpha requirements, and tile-edge continuity. It lists perspective/projection as a visual review item because image semantics cannot be judged reliably by these deterministic checks.
### Prop models
Street furniture (bollards, benches, bins, planters, newspaper boxes, a hydrant)
is real 3D geometry generated by `tools/model_pipeline.py` and written to
`assets/models/generated/*.glb`. Godot imports each file as a `PackedScene`, and
`WorldBuilder` instantiates it with a procedural fallback when the model or its
import is missing, so the world still builds on a fresh checkout.
```bash
# Print the catalogue (parts, triangle count, whether a model/concept exists)
python3 tools/model_pipeline.py list
# Rebuild the models (deterministic, offline) and validate them
python3 tools/model_pipeline.py build
python3 tools/model_pipeline.py audit
# Optional: an image model supplies the colour palette per prop
# (requires OPENROUTER_API_KEY; the repo .env is read automatically)
python3 tools/model_pipeline.py concept
python3 tools/model_pipeline.py concept --only bench --force
```
Each prop is a small list of boxes and cylinders, so `build` needs no network and
the output is byte-identical between runs. `concept` asks an image model for a
studio-style reference; `dominant_colors` and `assign_roles` then map that image's
colours onto material roles (darkest → metal, warm saturated → wood, light grey →
stone, green → foliage). glTF stores colours in linear space, so the tool converts
the sRGB palettes before writing them. Re-run `build` and `godot --headless
--path . --import` after changing a prop. See
[ADR-0012](docs/adr/0012-surface-materials-and-generated-props.md).
Surface materials share one definition in `src/game/materials.gd`: named
roughness/metallic profiles per surface, plus the environment tune-up (ACES
tonemapping, glow, fog, colour grading) that the phase lighting applies.
### Screen design references
Screen mockups and screenshot comparison use `tools/screen_designs.py`. It turns the
screen descriptions in `docs/` (README captions, ADRs 0007–0010, `PLAN.md`, and
`architecture.md`) into reference images under `docs/screens/designs/`, then uses
those images as the expected state to check the game's captures.
Each screen is tagged `scene` (a realistic first-person 3D world view) or `ui`
(an in-game panel overlaid on a softened scene), and the generator appends a
shared art-direction suffix so the frames read as one coherent game. Requests use
a 3:2 landscape source and crop to the 16:9 capture dimensions.
```bash
# Export the built-in scenarios to editor-authorable .tres resources
godot --headless --path . --script res://tools/export_venues.gd
# List the catalogue (name, kind, whether a reference design exists)
python3 tools/screen_designs.py list
# Deterministic wireframes (offline, no API key); existing designs are kept
# unless --force is passed, so an offline run never replaces an AI mockup
python3 tools/screen_designs.py generate --draft
python3 tools/screen_designs.py generate --draft --only wand --force
# Realistic 3D scenes and in-game screen designs via OpenRouter
# (requires OPENROUTER_API_KEY)
python3 tools/screen_designs.py generate
# Compare docs/screenshots/*.png against the designs
python3 tools/screen_designs.py compare
python3 tools/screen_designs.py compare --json
# Ask a vision model whether each capture realises its design's screen and layout
# (requires OPENROUTER_API_KEY; the repo .env is read automatically)
python3 tools/screen_designs.py judge
python3 tools/screen_designs.py judge --model openai/gpt-4o-mini --json
python3 tools/screen_designs.py judge --only 03- # one capture
python3 tools/screen_designs.py judge --dry-run # build requests, no network
```
The catalogue covers 19 screens: the six README captures plus the start menu,
pause overlay, settings, shift review, radial menu, emergency radio, wand
screening, Joke Shop queue, TV Studio arrival, crowd panic, responder arrival,
body-camera timeline, and the escalation HUD.
`compare` flags gross drift: wrong aspect ratio, undersized frame, near-blank
frame, and large layout/palette/detail distance. It is a regression gate, not a
semantic judge — pixel and perceptual metrics cannot decide whether a frame's
composition is *right*.
`judge` covers that gap: it sends each capture together with its concept mockup to
a vision model and asks whether the capture realises the same screen and layout
intent. Art style, fidelity, fonts, and placeholder text are explicitly ignored.
Verdicts are classed as `match`, `review` (a mismatch below `--fail-confidence`,
default 0.6 — expected when a concept mockup is more stylised than the game), or
`diff` (a confident mismatch, which exits non-zero). Regenerate the designs after changing screen layout, then re-run
`compare` after regenerating the game screenshots with
`src/test/capture_screenshots.gd`.
### Setup
1. Download the Godot 4.3 editor (official build) and put `godot` on your PATH.
@@ -163,6 +299,30 @@ godot --headless --path . --script res://src/test/test_runner.gd
# Save a screenshot to user:// (windowed)
godot --path . --script res://src/test/take_screenshot.gd
# Windowed control smoke: focus circle, action wheel, wheel cycling + right-click clear; saves PNGs, exits 1 on failure
godot --path . --script res://src/test/smoke_focus_radial.gd
# Session-shell smoke: menu → shift → pause → settings → menu (headless or windowed)
godot --headless --path . --script res://src/test/smoke_session_shell.gd
# PLAN-verb smoke: recognize, request reason, body camera, warning, blocking
godot --headless --path . --script res://src/test/smoke_plan_verbs.gd
# Crowd smoke: rubbernecking on aggression, panic clearing the entrance
godot --headless --path . --script res://src/test/smoke_crowd.gd
# Emergency smoke: unjustified call, investigation note, evacuation aftermath
godot --headless --path . --script res://src/test/smoke_emergency.gd
# Body-camera timeline smoke: review → timeline → step through → back to review
godot --headless --path . --script res://src/test/smoke_timeline.gd
# Audio smoke: crowd bed follows mood, radio open/close crackle
godot --headless --path . --script res://src/test/smoke_audio.gd
# Scenario-content smoke: wand refusal, celebrity crowd spike
godot --headless --path . --script res://src/test/smoke_scenario_content.gd
```
The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor.
@@ -172,19 +332,27 @@ The unit test runner is the project's own lightweight harness (no editor plugin
| Input | Action |
| --- | --- |
| W/A/S/D + mouse | Move and look |
| Esc | Release / capture mouse |
| Space / left click | Action wheel for the focused person (Stop, Look, Point, No, Ask, Inspect, Wand, Warn, Block, Reason, Recognize, Radio) |
| A / D or ←/→ | Step through the action wheel (while it is open) |
| Space / left click | Confirm the highlighted action (while the wheel is open) |
| Esc / right click | Close the top panel; Esc with nothing open pauses the shift (Esc again resumes) |
| ← / → or ↑ / ↓ | Navigate the venue menu and the pause / settings panels |
| Enter | Start the highlighted venue from the menu, or confirm in a panel |
| 1 / 2 / 3 | Quick gestures without the wheel: raise hand, point, head shake (Level 1) |
| Q | Polite direction (Level 1) |
| 1 / 2 / 3 | Simple gestures: raise hand, point, head shake (Level 1) |
| 4 | Formal warning (Level 2) |
| 5 | Blocking and positioning (Level 3) |
| 6 | Security assistance radio (Level 4) |
| R | Emergency radio, hold on-screen button ~1.6s (Level 5) |
| E | Inspect access artifacts / close inspection |
| W | Handheld wand screening (wand venues only) |
| C | Handheld wand screening (wand venues only) |
| A / D | Allow / deny entry during inspection or wand check |
| V | Cycle venue: Hollywood Blvd → The Joke Shop → TV Studio (restarts shift) |
| Tab | Cycle target among nearby NPCs (aim-free alternative) |
| V | Cycle venue (debug shortcut; restart via V restarts the current shift) |
| Tab / mouse wheel ↓ / ↑ | Cycle the target among nearby NPCs, nearest first (aim-free; the target stays selected) |
| Right click (nothing open) | Clear the focused person and return to aim-based focus |
| Shift (hold) | Precision walk at roughly half speed for close work at the door |
| G | Toggle flashlight (widens NPC perception at night) |
| B | Toggle the body camera; a formal warning switches it on automatically |
| L | Lock / unlock the restricted door (thwarts forced entry) |
| F | Toggle debug overlay |
@@ -209,28 +377,71 @@ Implemented so far:
- 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`
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
- Headless unit tests: **52 passing** (access rules, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, navmesh, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings)
- Headless unit tests: **182 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, 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)
- 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
- 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`)
- Session shell: venue start menu (keyboard + mouse), Esc pause overlay (resume / restart / settings / quit to menu), settings panel with mouse-look sensitivity and HUD font scale persisted to `user://settings.json`, and "Run another shift" returning to the venue menu
- World presentation: skyline backdrop rendered with an alpha material, walkable step and landing colliders, larger marquee text, and larger focus circle / name tag
- Routine traffic despawns after a while at its waypoint so the crowd cap no longer fills with idle standers
- PLAN verbs: eye contact (distinct Level 1 cue), recognizing a person as a player decision recorded as access evidence, a body-camera flag that a formal warning arms (and `B` toggles), blocking that physically stations the guard between the person and the door, and a Level 3 request-reason ask answered from the NPC's `dialogue_lines`
- Incidents are now actually opened on focus and closed at shift end, so player actions, access evidence, escalation levels, and unresolved interactions reach the review (previously every recorded event was dropped)
- Crowd presentation: aggressive behavior and responder arrivals make idle bystanders stop and face the incident; panic sends people away from the door (and they leave the scene). A busy 16-NPC entrance measured ~1.2 ms/frame, so no LOD/culling was needed at the current cap
- Distant skyline ring: four alpha-scissored planes at 200 m read as a hazy city on the horizon (the baked backdrop used to show as a black slab), with a larger ground plane so the buildings meet the street
- Flashlight aims straight ahead and lights the person/door being checked instead of the ground in front of the guard
- Emergency aftermath: the `evacuation` outcome locks the door and clears the entrance, an emergency call that the situation did not justify opens an investigation and applies the false-report hit, and the shift review names the responder outcome and any investigations
- Body-camera timeline: the shift review opens a text timeline of every recorded incident event (time, person, action/outcome) that you step through with the arrow keys or buttons. No live 3D replay yet
- NPC theater: seated guests sit on the steps with bent legs, influencers and tourists raise a camera prop while photographing, performers sway their sign, and filming visitors pan slowly — all once they reach their intent's waypoint
- Audio: a looping procedural crowd bed (filtered murmur) whose volume follows `CrowdSystem.mood`, plus radio open/close crackle. All generated at runtime, no audio assets, and safe with a dummy audio server
- Accessibility: the escalation cue shows a shape meter plus the numeric level (`Escalation: Blocking [###--] L3/5`) so it reads in grayscale, and the HUD font scale from settings is applied at boot by resizing fonts (not Control.scale, which broke the right-aligned layout)
- Editor-ready venues: `tools/export_venues.gd` writes `content/venues/<id>.tres` from `ScenarioFactory`, and the `.tres` files load back with events, profiles, policy, phases, door lines, and locations intact (the factory stays the source of truth)
- Localization safety: `Strings.fmt` validates the placeholder count before formatting and fails safe (warns, returns the template) instead of raising an engine error, `Strings.placeholder_count` ignores `%%`, and `Strings.validate()` lints the table for trailing `%`, unsupported conversions, and mixed positional/sequential placeholders
- Authored scenario content: Joke Shop reservation-without-ID, borrowed/mismatched ID, a guest who refuses the wand screen, and a late arrival during a performance; Studio filming-boundary repeats and a celebrity arrival that draws a small crowd; Building events that point the player at the body camera and request-reason verbs
- Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets
- Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock)
- Rendering pass (v0.3.0 work, [ADR-0012](docs/adr/0012-surface-materials-and-generated-props.md)): one material module with roughness/metallic profiles per surface, ACES tonemapping, glow, aerial fog and colour grading driven by the shift phase, correctly scaled brick, framed facade windows that light up at night, door trim and handles, a curb and lane dashes, and the glowing marquee/lamp bulbs. NPCs gained per-role clothing palettes, caps/hoods/backpacks/sunglasses/lanyards, hands and shoes, and an arm/leg walk swing
- Prop atlas (v0.3.1): `tools/model_pipeline.py` packs each prop's palette into one power-of-two colour atlas plus a metallic/roughness atlas and emits a single material with per-vertex UVs, so a prop is one draw material rather than one per part; `audit` enforces the contract
- Generated prop models (v0.3.0 work): `tools/model_pipeline.py` builds bollards, benches, bins, planters, newspaper boxes and a hydrant as `.glb` props with per-part materials, loaded by `WorldBuilder` with a procedural fallback; the optional AI concept step (`model_pipeline.py concept`) supplies per-prop colour palettes
- Web build: `build/step-line-web-<version>.zip` (Godot's non-threaded web template, no COOP/COEP headers needed) built by `tools/build_releases.sh` and published with the desktop artifacts; serve the unzipped folder from any static host
- Aim-free selection pass (v0.3.0 work, [ADR-0011](docs/adr/0011-aim-free-selection-and-precision-walk.md)): the focus cone ranks candidates by angle then distance, `Tab` and the mouse wheel cycle nearby people nearest-first and pin the selection, right click clears focus with a short re-acquire grace, the HUD reports how many people are in the ring, and holding `Shift` walks at half speed for close work
Not yet implemented: body-camera replay timeline, venue selection startup menu, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`).
Not yet implemented: live 3D body-camera replay, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`).
### Smoke Test Checklist
1. `godot --path .` starts the game without script errors.
2. The guard moves and looks around; the building, steps, and sidewalk are visible.
3. NPCs spawn and approach the entrance during the first minutes.
4. Aiming at an NPC shows its archetype and intent in the HUD.
5. Pressing `Q` produces a reaction log entry and reputation feedback.
6. `E` opens access inspection showing artifacts and policy result; `A`/`D` record a decision.
7. `6` opens the assistance form; submitting shows dispatch feedback.
8. `R` opens the emergency form; the button must be held ~1.6s or the call cancels.
9. `G` toggles the flashlight (indicator appears in the HUD); `L` toggles the door lock (indicator turns red when locked).
10. `F` toggles the debug overlay with NPC states and shift stats.
11. NPCs route around the building using the navigation mesh (no stuck-at-wall behavior during a smoke run).
12. When the shift timer reaches 0, the shift review screen shows grade, incident summary, and persisted shift history.
1. `godot --path .` starts the game on the venue menu without script errors.
2. Choosing a venue (click or ↑/↓ + Enter) starts that venue's shift and hides the menu.
3. The guard moves and looks around; the building, steps, and sidewalk are visible.
4. The guard's camera sits at eye height and the guard stands on the landing, not inside the building.
5. Walking into the steps carries the guard up onto the landing (steps have colliders).
6. The skyline backdrop shows silhouettes against the sky, not an opaque black slab.
7. The `STEP LINE` marquee is readable from the sidewalk.
8. NPCs spawn and approach the entrance during the first minutes.
9. Looking at an NPC inside the focus cone shows a blue focus circle in front of the face, the person's name, and the HUD target line.
10. `Space` or left click opens the action wheel; `A`/`D` highlight a slice, `Space` confirms it, `Esc`/right click cancels without a log entry.
11. `E`/`C` open the inspection/wand panels from the wheel and freeze movement until they close; walking never fires the wand.
12. Pressing `Q` produces a reaction log entry and reputation feedback.
13. `E` opens access inspection showing venue-correct artifacts and policy result; `A`/`D` record a decision.
14. `6` opens the assistance form; submitting shows dispatch feedback.
15. `R` opens the emergency form; the button must be held ~1.6s or the call cancels.
16. `G` toggles the flashlight (indicator appears in the HUD); `L` toggles the door lock (indicator turns red when locked).
17. `F` toggles the debug overlay with NPC states and shift stats.
18. The wheel's `Look` slice is eye contact (Level 1) and logs as its own action.
19. `Reason` asks why the person is there and logs their `dialogue_lines` reply.
20. `Recognize` records the guard's recognition as access evidence; pressing it twice says so.
21. `Warn` turns the body camera on (REC overlay appears); `B` toggles it manually.
22. `Block` moves the guard between the person and the door.
23. The shift review reports the incident count, including interactions left unresolved.
24. Escalating on someone (or a responder arriving) makes idle bystanders stop and face the incident.
25. Repeated alarms panic the crowd and nearby people clear away from the door instead of standing around.
26. `Esc` with nothing open pauses the shift; resume / restart / settings / quit-to-menu all work, and settings changes persist across a restart.
27. NPCs route around the building using the navigation mesh (no stuck-at-wall behavior during a smoke run).
28. An unjustified emergency call (calm person, no weapon) logs an investigation and costs reputation.
29. After an evacuation outcome the door is locked and NPCs are leaving the entrance.
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.
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.
## Development Workflow
+13 -69
View File
@@ -1,74 +1,18 @@
# TODO
## 🎨 AI texture generation (OpenRouter)
Godot 4.3 prototype. Latest release **v0.3.1** (prop atlas). Headless suite **182/182** plus
**53** Python tooling tests; the session, PLAN-verb, crowd, emergency, timeline, audio, scenario-content, and
windowed focus/radial smokes pass. Anonymous downloads are enabled on the host.
- [x] Extend `tools/generate_textures.py` with an OpenRouter image-API mode
(`--ai`, `--prompt`, `--name`, `--model`, `--size`), decoding base64 PNG
responses and clamping output to ≤1024px.
- [x] Add prompts for all 15 game textures (world surfaces, awning, skyline,
vignette, 4 HUD icons, 5 access cards) and generate them under `assets/textures/ai/`.
- [ ] Make the `ai/` icons/vignette/skyline truly transparent: seedream-4.5 ignores
`background: transparent` (returns opaque images). Either switch to a
transparency-capable model or post-process (e.g. chroma-key the flat background).
All planned backlog sections are complete (see `DONE.md`). Optional future ideas live below.
## 🪟 Windows installer (self-contained, all game data)
## Future ideas (optional, not scheduled)
Goal: a real Windows installer that includes the entire game (executable + data),
Start-Menu/desktop shortcuts, and an uninstaller — instead of a bare .exe + .pck.
- [x] Switch the Windows/Linux/macOS export presets to `binary_format/embed_pck=true` so a
single self-contained binary replaces the exe+pck pair (all game data in one file).
- [x] Add a game icon: `assets/icon.png` (AppImage/desktop) + `assets/icon.ico` and
`packaging/windows/step-line.ico` generated by `tools/generate_textures.py`.
- [x] Author `packaging/windows/step-line.nsi` (NSIS): installer that installs the exe, writes
the uninstaller, shortcuts, and registry uninstall entry.
- [x] Add CI packaging steps to `.gitea/workflows/build.yml` (NSIS via apt; AppImage via
appimagetool) so tags/dispatch produce `step-line-setup-<ver>.exe` and
`step-line-<ver>-x86_64.AppImage` next to the portable binaries.
- [x] Extend `tools/publish_release.sh` artifact glob to upload the installer/AppImage too.
- [ ] Verify on CI: NSIS installer builds (cannot be run locally — no `makensis` on this box).
- [ ] Verify the Windows installer contents in CI (e.g. `7z l` shows the embedded data file).
## 🐧 Linux AppImage (self-contained, all game data)
Goal: a single executable `.AppImage` that runs the full game on any recent distro.
- [x] Author AppDir files: `packaging/linux/step-line.desktop`, `packaging/linux/AppRun`,
icon wiring.
- [x] Add `packaging/linux/build_appimage.sh` (appimagetool; `APPIMAGE_EXTRACT_AND_RUN=1` to
avoid FUSE dependency) producing `step-line-<ver>-x86_64.AppImage`.
- [x] Local verification: built the AppImage on this machine and ran it headless
(`--appimage-extract-and-run --headless --quit-after 90` → 0 script errors,
`[Step Line] game started`).
- [x] AppStream metainfo added (`packaging/linux/step-line.appdata.xml`); tool validates clean.
- [ ] CI verification: AppImage step runs on tags/dispatch and the artifact uploads.
- [x] README Downloads table documents installer + AppImage rows and naming.
## Distribution follow-up (host config, no code work)
- [ ] Asset downloads currently require sign-in on the host (`/releases/download/…` redirects to
login). Decide with the host admin: open repo visibility / `REQUIRE_SIGNIN_VIEW=false` or
accept sign-in-required downloads. Pages and the tag page are already public (HTTP 200).
## Future Systems (design-level backlog)
- [ ] Body-camera replay timeline in the shift review.
- [ ] Venue selection startup menu (keyboard cycling with `V` is implemented).
- [ ] IDE/editor-based GUT integration when a Godot editor workflow is introduced.
## Open Polish
- [ ] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting).
## Detailed Graphics — remaining performance items
The visual pass is done (textures, rigs, HUD feedback — see `DONE.md`). Remaining work is
performance hardening only.
- [ ] Profile the crowd: ensure textured materials and props hold target frame rate around a
busy entrance.
- [ ] Add LOD or culling for distant NPCs/props if the crowd grows.
## Screenshot Refresh
- [ ] Regenerate and verify the six README screenshots from the current build.
- [ ] Live 3D body-camera replay (the timeline is text-only today).
- [ ] LOD/culling for NPCs if `MAX_LIVE_NPCS` grows past the measured 16-avatar budget.
- [ ] Additional venues or seasonal events authored from the new `.tres` files.
- [ ] Real voice/dialogue audio (the crowd bed and radio crackle are procedural).
- [ ] Swap the primitive prop builder for an image-to-mesh model API behind the same
`assets/models/generated/<prop>.glb` contract.
- [ ] Regenerate the AI screen mockups for the seven new catalogue entries (only the
deterministic wireframes exist so far).
Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 927 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 921 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

+49 -4
View File
@@ -1,12 +1,24 @@
{
"hud.controls": "Q polite direction · 1-3 simple gestures · 4 warning · 5 block · 6 assist · R emergency · E inspect / close · W wand · A allow · D deny · V venue · G flashlight · L lock door · F debug",
"hud.controls": "LMB/Space wheel · A/D step · Space confirm · Esc cancel · R emergency · E inspect · C wand · Q polite · B camera · Tab/wheel cycle · Shift slow · G light · L lock · F debug",
"radial.raise_hand": "Stop",
"radial.eye_contact": "Look",
"radial.point": "Point",
"radial.head_shake": "No",
"radial.polite": "Ask",
"radial.inspect": "Inspect",
"radial.wand": "Wand",
"radial.warning": "Warn",
"radial.block": "Block",
"radial.reason": "Reason",
"radial.recognize": "Recognize",
"radial.radio": "Radio",
"hud.escalation_prefix": "Escalation: %s",
"hud.cooldown": "EMERGENCY RADIO COOLDOWN: %.0fs",
"hud.aim_first": "Aim at someone first.",
"hud.no_one_nearby": "No one nearby.",
"hud.targeting": "Targeting %s (Tab to cycle).",
"hud.targeting": "Targeting %s (Tab or mouse wheel cycles).",
"hud.no_wand": "No wand at this venue.",
"hud.screen_first": "Screen this guest with the wand (W) before admitting them.",
"hud.screen_first": "Screen this guest with the wand (C) before admitting them.",
"access.granted": "Entry granted: %s",
"access.denied": "Entry denied: %s",
"access.policy_satisfied": "policy satisfied",
@@ -23,6 +35,9 @@
"radio.emergency_dispatched": "Emergency responder dispatched.",
"radio.easter_egg": "¿Dónde está la mota?",
"responder.arrived": "Responder arrives and %ss the situation.",
"responder.evacuated": "The entrance is cleared and the door is locked while the block is secured.",
"investigation.unjustified": "Emergency call against %s reviewed: no weapon, low danger.",
"investigation.started": "Your emergency call is flagged for investigation.",
"react.comply": "%s complies and steps back.",
"react.leave": "%s turns and leaves.",
"react.argue": "%s argues with you.",
@@ -33,6 +48,24 @@
"react.return_later": "%s leaves but may come back.",
"react.call_someone": "%s calls someone for help.",
"react.none": "No reaction (yet).",
"bodycam.on": "Body camera on.",
"bodycam.off": "Body camera off.",
"bodycam.warning": "Formal warning recorded on the body camera.",
"recognition.declared": "You tell %s you recognize them. Logged as evidence.",
"recognition.already": "You have already recognized %s this shift.",
"reason.ask": "You ask %s why they are here.",
"reason.reply": "%s: \"%s\"",
"reason.silent": "%s says nothing and looks away.",
"hint.reservation_no_id": "On the reservation list but no ID — ask for ID, then check the list.",
"hint.borrowed_id": "The ID photo does not match the person. Verify before admitting.",
"hint.refuses_wand": "This guest refuses the wand screen. No screen, no entry.",
"hint.late_arrival": "Late arrival during the performance — check ID and ticket before opening the door.",
"hint.film_boundary": "Filming past the posted line. Ask their reason and point them back to the sidewalk.",
"hint.film_boundary_repeat": "Same crew again, still past the line. A formal warning is now reasonable.",
"hint.celebrity_arrival": "Celebrity arrival: the crowd is swelling. Keep the entrance clear.",
"hint.body_camera_reason": "They are filming the entrance. Ask their reason (Reason) and consider the body camera (B).",
"hint.ask_reason": "Unclear intent. Ask why they are here (Reason) before deciding anything else.",
"station.blocking": "You step between %s and the door.",
"perception.near": "%s notices you standing close.",
"perception.aware": "%s notices you watching.",
"door.locked": "The door is now locked.",
@@ -41,5 +74,17 @@
"flashlight.on": "Flashlight on.",
"flashlight.off": "Flashlight off.",
"hud.venue_switched": "Venue: %s",
"wand.recharge": "Wand scanner is recharging…"
"wand.recharge": "Wand scanner is recharging…",
"review.responder": "Responder outcomes: %s",
"review.investigations": "Investigations: %s",
"review.none": "none",
"timeline.title": "Body Camera Timeline",
"timeline.position": "Event %d of %d",
"timeline.empty": "No recorded events this shift.",
"timeline.prev": "◀ Previous",
"timeline.next": "Next ▶",
"timeline.close": "Close",
"timeline.hint": "Left/Right step through events · Esc closes · no live replay yet",
"review.timeline_button": "Body camera timeline",
"hud.nearby": "%d nearby · Tab or mouse wheel cycles targets"
}
+794
View File
@@ -0,0 +1,794 @@
[gd_resource type="Resource" script_class="VenueScenario" load_steps=17 format=3]
[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_2u28i"]
[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_acokf"]
[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_mqiqi"]
[sub_resource type="Resource" id="Resource_i8fga"]
script = ExtResource("1_2u28i")
required_artifacts = {
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."
[sub_resource type="Resource" id="Resource_c7lhe"]
script = ExtResource("2_acokf")
id = "tourist"
display_name = "Tourist"
npc_type = 0
temperament = 0.1
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."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_0t6ar"]
script = ExtResource("2_acokf")
id = "confused_visitor"
display_name = "Confused Visitor"
npc_type = 3
temperament = 0.2
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."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_wyv8p"]
script = ExtResource("2_acokf")
id = "delivery"
display_name = "Delivery Worker"
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?"])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_v2hc4"]
script = ExtResource("2_acokf")
id = "street_performer"
display_name = "Street Performer"
npc_type = 1
temperament = 0.45
base_compliance = 0.55
return_probability = 0.0
reactions_by_level = {
1: {
0: 55,
2: 15,
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"]
script = ExtResource("2_acokf")
id = "influencer"
display_name = "Influencer"
npc_type = 2
temperament = 0.5
base_compliance = 0.5
return_probability = 0.0
reactions_by_level = {
1: {
0: 40,
2: 10,
3: 30,
4: 20
},
2: {
0: 30,
2: 20,
4: 35,
8: 15
},
3: {
0: 10,
1: 15,
2: 20,
4: 30,
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."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_ijl2s"]
script = ExtResource("2_acokf")
id = "intoxicated"
display_name = "Intoxicated Pedestrian"
npc_type = 4
temperament = 0.6
base_compliance = 0.4
return_probability = 0.0
reactions_by_level = {
1: {
0: 30,
2: 20,
3: 50
},
2: {
0: 40,
2: 35,
3: 15,
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
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_eycmk"]
script = ExtResource("2_acokf")
id = "trespasser"
display_name = "Trespasser"
npc_type = 5
temperament = 0.55
base_compliance = 0.35
return_probability = 0.6
reactions_by_level = {
1: {
0: 30,
2: 25,
3: 45
},
2: {
0: 40,
2: 35,
6: 25
},
3: {
1: 35,
2: 25,
6: 15,
7: 10,
8: 15
},
4: {
1: 70,
7: 30
},
5: {
1: 30,
5: 50,
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."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_ggtpw"]
script = ExtResource("2_acokf")
id = "mental_crisis"
display_name = "Person in Crisis"
npc_type = 7
temperament = 0.7
base_compliance = 0.3
return_probability = 0.4
reactions_by_level = {
1: {
0: 25,
2: 20,
3: 55
},
2: {
0: 25,
2: 35,
3: 30,
5: 10
},
3: {
0: 25,
1: 15,
2: 30,
5: 30
},
4: {
1: 40,
5: 35,
7: 25
},
5: {
1: 25,
5: 60,
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
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_oa64f"]
script = ExtResource("2_acokf")
id = "violent_threat"
display_name = "Violent Threat"
npc_type = 6
temperament = 0.95
base_compliance = 0.05
return_probability = 0.0
reactions_by_level = {
1: {
2: 25,
3: 45,
5: 30
},
2: {
2: 30,
5: 45,
7: 25
},
3: {
1: 20,
5: 45,
7: 35
},
4: {
1: 15,
5: 30,
7: 55
},
5: {
1: 30,
5: 40,
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"]
script = ExtResource("2_acokf")
id = "protester"
display_name = "Protester"
npc_type = 12
temperament = 0.5
base_compliance = 0.4
return_probability = 0.25
reactions_by_level = {
1: {
0: 25,
2: 35,
3: 25,
4: 15
},
2: {
0: 15,
2: 40,
3: 15,
4: 25,
8: 5
},
3: {
0: 10,
1: 25,
2: 25,
4: 25,
8: 15
},
4: {
1: 60,
2: 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."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_8tj1k"]
script = ExtResource("2_acokf")
id = "authorized_staff"
display_name = "Staff Member"
npc_type = 8
temperament = 0.15
base_compliance = 1.0
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 100
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_tnpxm"]
script = ExtResource("2_acokf")
id = "authorized_guest"
display_name = "Authorized Guest"
npc_type = 9
temperament = 0.2
base_compliance = 0.95
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 92,
2: 8
},
2: {
0: 88,
2: 12
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[resource]
script = ExtResource("3_mqiqi")
id = "hollywood_building"
display_name = "Hollywood Boulevard Building"
description = "Steps and entrance of a private building off Hollywood Boulevard."
access_policy = SubResource("Resource_i8fga")
equipment = Array[String](["wristband_policy", "flashlight", "radio"])
locations = {
"entrance": Vector3(0, 0, 1),
"guard_post": Vector3(0, 0, -3),
"queue_area": Vector3(0, 0, 6),
"sidewalk": Vector3(0, 0, 9),
"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")])
event_pool = Array[Dictionary]([{
"base_weight": 1.0,
"id": "tourist_photo",
"intent": "photo",
"kind": "routine",
"npc_type": 0,
"phase": 0,
"probability": 0.42,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "protest_gather",
"intent": "protest",
"kind": "routine",
"npc_type": 12,
"phase": 0,
"probability": 0.18,
"time_range_s": Vector2(300, 900)
}, {
"base_weight": 1.0,
"id": "tourist_sit",
"intent": "sit",
"kind": "routine",
"npc_type": 0,
"phase": 0,
"probability": 0.26,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "confused_enter",
"intent": "enter",
"kind": "routine",
"npc_type": 3,
"phase": 0,
"probability": 0.25,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "delivery_drop",
"intent": "deliver",
"kind": "routine",
"npc_type": 10,
"phase": 0,
"probability": 0.3,
"time_range_s": Vector2(0, 600)
}, {
"base_weight": 1.0,
"id": "staff_entry",
"intent": "enter",
"kind": "routine",
"npc_type": 8,
"phase": 0,
"probability": 0.35,
"time_range_s": Vector2(0, 600)
}, {
"base_weight": 1.0,
"id": "guest_entry",
"intent": "enter",
"kind": "routine",
"npc_type": 9,
"phase": 0,
"probability": 0.25,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "performer_show",
"intent": "perform",
"kind": "routine",
"npc_type": 1,
"phase": 0,
"probability": 0.24,
"time_range_s": Vector2(300, 800)
}, {
"base_weight": 1.0,
"hint": "hint.body_camera_reason",
"id": "building_filming_boundary",
"intent": "film",
"kind": "scripted",
"npc_type": 2,
"phase": 1,
"probability": 0.4,
"time_range_s": Vector2(300, 600)
}, {
"base_weight": 1.0,
"hint": "hint.ask_reason",
"id": "building_unclear_intent",
"intent": "enter",
"kind": "scripted",
"npc_type": 3,
"phase": 2,
"probability": 0.4,
"time_range_s": Vector2(600, 780)
}, {
"base_weight": 1.0,
"id": "influencer_film",
"intent": "film",
"kind": "routine",
"npc_type": 2,
"phase": 0,
"probability": 0.26,
"time_range_s": Vector2(300, 900)
}, {
"base_weight": 1.0,
"id": "intoxicated_wander",
"intent": "wander",
"kind": "routine",
"npc_type": 4,
"phase": 1,
"probability": 0.15,
"time_range_s": Vector2(600, 900)
}, {
"base_weight": 1.0,
"id": "trespass_test",
"intent": "trespass",
"kind": "routine",
"npc_type": 5,
"phase": 0,
"probability": 0.16,
"time_range_s": Vector2(300, 900)
}, {
"base_weight": 1.0,
"id": "crisis_event",
"intent": "crisis",
"kind": "scripted",
"npc_type": 7,
"phase": 2,
"probability": 0.5,
"time_range_s": Vector2(640, 740)
}, {
"base_weight": 1.0,
"id": "violent_emergency_event",
"intent": "threat",
"kind": "scripted",
"npc_type": 6,
"phase": 3,
"probability": 0.28,
"requires_emergency": true,
"time_range_s": Vector2(760, 880)
}])
shift_phases = {
0: {
"start_s": 0.0
},
1: {
"start_s": 300.0
},
2: {
"start_s": 600.0
},
3: {
"start_s": 700.0
}
}
shift_duration_s = 900.0
policy_summary = ""
door_staff_lines = {}
+590
View File
@@ -0,0 +1,590 @@
[gd_resource type="Resource" script_class="VenueScenario" load_steps=13 format=3]
[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_lxsgk"]
[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_wfc6m"]
[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_8xm7a"]
[sub_resource type="Resource" id="Resource_xha77"]
script = ExtResource("1_lxsgk")
required_artifacts = {
2: true,
3: true
}
grant_if_any_required = false
minimum_age = 21
require_id = true
wand_screening_required = true
policy_summary = "Government ID and a valid ticket or reservation required. Carry items are screened."
[sub_resource type="Resource" id="Resource_8ksyk"]
script = ExtResource("2_wfc6m")
id = "tourist"
display_name = "Tourist"
npc_type = 0
temperament = 0.1
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."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_wt1op"]
script = ExtResource("2_wfc6m")
id = "confused_visitor"
display_name = "Confused Visitor"
npc_type = 3
temperament = 0.2
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."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_g1jr2"]
script = ExtResource("2_wfc6m")
id = "influencer"
display_name = "Influencer"
npc_type = 2
temperament = 0.5
base_compliance = 0.5
return_probability = 0.0
reactions_by_level = {
1: {
0: 40,
2: 10,
3: 30,
4: 20
},
2: {
0: 30,
2: 20,
4: 35,
8: 15
},
3: {
0: 10,
1: 15,
2: 20,
4: 30,
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."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_ys0b0"]
script = ExtResource("2_wfc6m")
id = "intoxicated"
display_name = "Intoxicated Pedestrian"
npc_type = 4
temperament = 0.6
base_compliance = 0.4
return_probability = 0.0
reactions_by_level = {
1: {
0: 30,
2: 20,
3: 50
},
2: {
0: 40,
2: 35,
3: 15,
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
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_bv0jw"]
script = ExtResource("2_wfc6m")
id = "trespasser"
display_name = "Trespasser"
npc_type = 5
temperament = 0.55
base_compliance = 0.35
return_probability = 0.6
reactions_by_level = {
1: {
0: 30,
2: 25,
3: 45
},
2: {
0: 40,
2: 35,
6: 25
},
3: {
1: 35,
2: 25,
6: 15,
7: 10,
8: 15
},
4: {
1: 70,
7: 30
},
5: {
1: 30,
5: 50,
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."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_7vgx3"]
script = ExtResource("2_wfc6m")
id = "violent_threat"
display_name = "Violent Threat"
npc_type = 6
temperament = 0.95
base_compliance = 0.05
return_probability = 0.0
reactions_by_level = {
1: {
2: 25,
3: 45,
5: 30
},
2: {
2: 30,
5: 45,
7: 25
},
3: {
1: 20,
5: 45,
7: 35
},
4: {
1: 15,
5: 30,
7: 55
},
5: {
1: 30,
5: 40,
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"]
script = ExtResource("2_wfc6m")
id = "authorized_staff"
display_name = "Staff Member"
npc_type = 8
temperament = 0.15
base_compliance = 1.0
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 100
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_07lsg"]
script = ExtResource("2_wfc6m")
id = "authorized_guest"
display_name = "Authorized Guest"
npc_type = 9
temperament = 0.2
base_compliance = 0.95
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 92,
2: 8
},
2: {
0: 88,
2: 12
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[resource]
script = ExtResource("3_8xm7a")
id = "joke_shop"
display_name = "The Joke Shop"
description = "Comedy club door: IDs, reservations, and wand screening."
access_policy = SubResource("Resource_xha77")
equipment = Array[String](["id_check", "guest_list", "wand", "radio"])
locations = {
"door_area": Vector3(0, 0, 3),
"entrance": Vector3(0, 0, 1),
"guard_post": Vector3(0, 0, -3),
"queue_area": Vector3(0, 0, 9),
"sidewalk": Vector3(0, 0, 9),
"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")])
event_pool = Array[Dictionary]([{
"base_weight": 1.0,
"id": "guest_valid",
"intent": "enter",
"kind": "routine",
"npc_type": 9,
"override_access": {
"has_id": true,
"has_ticket": true
},
"phase": 0,
"probability": 0.3,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "guest_no_ticket",
"intent": "enter",
"kind": "routine",
"npc_type": 9,
"override_access": {
"has_id": true,
"has_ticket": false
},
"phase": 0,
"probability": 0.18,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "guest_expired_id",
"intent": "enter",
"kind": "routine",
"npc_type": 9,
"override_access": {
"has_id": true,
"has_ticket": true,
"id_status": 1
},
"phase": 0,
"probability": 0.12,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "guest_no_id",
"intent": "enter",
"kind": "routine",
"npc_type": 9,
"override_access": {
"has_id": false,
"has_ticket": true
},
"phase": 0,
"probability": 0.12,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"hint": "hint.reservation_no_id",
"id": "joke_guest_reservation_no_id",
"intent": "enter",
"kind": "scripted",
"npc_type": 9,
"override_access": {
"guest_list_status": 0,
"has_guest_list": true,
"has_id": false,
"has_ticket": false
},
"phase": 2,
"probability": 0.45,
"time_range_s": Vector2(620, 860)
}, {
"base_weight": 1.0,
"hint": "hint.borrowed_id",
"id": "joke_borrowed_id",
"intent": "enter",
"kind": "scripted",
"npc_type": 9,
"override_access": {
"has_id": true,
"has_ticket": true,
"id_status": 3,
"ticket_status": 0
},
"phase": 1,
"probability": 0.4,
"time_range_s": Vector2(320, 700)
}, {
"base_weight": 1.0,
"hint": "hint.refuses_wand",
"id": "joke_refuses_wand",
"intent": "enter",
"kind": "scripted",
"npc_type": 9,
"override_access": {
"has_id": true,
"has_ticket": true,
"id_status": 0,
"ticket_status": 0
},
"phase": 2,
"probability": 0.4,
"refuses_screening": true,
"time_range_s": Vector2(650, 880)
}, {
"base_weight": 1.0,
"hint": "hint.late_arrival",
"id": "joke_late_arrival",
"intent": "arrive",
"kind": "scripted",
"npc_type": 9,
"override_access": {
"has_id": true,
"has_ticket": true,
"id_status": 0,
"ticket_status": 0
},
"phase": 2,
"probability": 0.35,
"time_range_s": Vector2(700, 860)
}, {
"base_weight": 1.0,
"id": "joke_staff",
"intent": "enter",
"kind": "routine",
"npc_type": 8,
"phase": 0,
"probability": 0.25,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "performer_show",
"intent": "perform",
"kind": "routine",
"npc_type": 1,
"phase": 0,
"probability": 0.15,
"time_range_s": Vector2(300, 800)
}, {
"base_weight": 1.0,
"id": "influencer_film",
"intent": "film",
"kind": "routine",
"npc_type": 2,
"phase": 0,
"probability": 0.15,
"time_range_s": Vector2(300, 900)
}, {
"base_weight": 1.0,
"id": "joke_disruptive",
"intent": "force_entry",
"kind": "scripted",
"npc_type": 4,
"phase": 2,
"probability": 0.5,
"time_range_s": Vector2(650, 830)
}])
shift_phases = {
0: {
"start_s": 0.0
},
1: {
"start_s": 300.0
},
2: {
"start_s": 600.0
},
3: {
"start_s": 700.0
}
}
shift_duration_s = 900.0
policy_summary = ""
door_staff_lines = {
"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."],
"refusal": ["I'm sorry, I can't let you in without that.", "Without a valid ID, I can't admit you tonight."],
"ticket_check": ["Your ticket or reservation, please.", "Do you have a ticket for tonight's show?"],
"wand_check": ["Please step forward for the wand check.", "Arms out, please — just a quick screening."]
}
+810
View File
@@ -0,0 +1,810 @@
[gd_resource type="Resource" script_class="VenueScenario" load_steps=17 format=3]
[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_xxhx3"]
[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_fiebn"]
[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_2dvuq"]
[sub_resource type="Resource" id="Resource_blmt0"]
script = ExtResource("1_xxhx3")
required_artifacts = {
1: true,
3: true,
4: true,
5: 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."
[sub_resource type="Resource" id="Resource_oddac"]
script = ExtResource("2_fiebn")
id = "tourist"
display_name = "Tourist"
npc_type = 0
temperament = 0.1
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."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_byir2"]
script = ExtResource("2_fiebn")
id = "confused_visitor"
display_name = "Confused Visitor"
npc_type = 3
temperament = 0.2
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."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_3tt0b"]
script = ExtResource("2_fiebn")
id = "delivery"
display_name = "Delivery Worker"
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?"])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_ihxib"]
script = ExtResource("2_fiebn")
id = "street_performer"
display_name = "Street Performer"
npc_type = 1
temperament = 0.45
base_compliance = 0.55
return_probability = 0.0
reactions_by_level = {
1: {
0: 55,
2: 15,
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"]
script = ExtResource("2_fiebn")
id = "influencer"
display_name = "Influencer"
npc_type = 2
temperament = 0.5
base_compliance = 0.5
return_probability = 0.0
reactions_by_level = {
1: {
0: 40,
2: 10,
3: 30,
4: 20
},
2: {
0: 30,
2: 20,
4: 35,
8: 15
},
3: {
0: 10,
1: 15,
2: 20,
4: 30,
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."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_bk410"]
script = ExtResource("2_fiebn")
id = "intoxicated"
display_name = "Intoxicated Pedestrian"
npc_type = 4
temperament = 0.6
base_compliance = 0.4
return_probability = 0.0
reactions_by_level = {
1: {
0: 30,
2: 20,
3: 50
},
2: {
0: 40,
2: 35,
3: 15,
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
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_jp52w"]
script = ExtResource("2_fiebn")
id = "trespasser"
display_name = "Trespasser"
npc_type = 5
temperament = 0.55
base_compliance = 0.35
return_probability = 0.6
reactions_by_level = {
1: {
0: 30,
2: 25,
3: 45
},
2: {
0: 40,
2: 35,
6: 25
},
3: {
1: 35,
2: 25,
6: 15,
7: 10,
8: 15
},
4: {
1: 70,
7: 30
},
5: {
1: 30,
5: 50,
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."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_5kplf"]
script = ExtResource("2_fiebn")
id = "mental_crisis"
display_name = "Person in Crisis"
npc_type = 7
temperament = 0.7
base_compliance = 0.3
return_probability = 0.4
reactions_by_level = {
1: {
0: 25,
2: 20,
3: 55
},
2: {
0: 25,
2: 35,
3: 30,
5: 10
},
3: {
0: 25,
1: 15,
2: 30,
5: 30
},
4: {
1: 40,
5: 35,
7: 25
},
5: {
1: 25,
5: 60,
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
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_6bscs"]
script = ExtResource("2_fiebn")
id = "violent_threat"
display_name = "Violent Threat"
npc_type = 6
temperament = 0.95
base_compliance = 0.05
return_probability = 0.0
reactions_by_level = {
1: {
2: 25,
3: 45,
5: 30
},
2: {
2: 30,
5: 45,
7: 25
},
3: {
1: 20,
5: 45,
7: 35
},
4: {
1: 15,
5: 30,
7: 55
},
5: {
1: 30,
5: 40,
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"]
script = ExtResource("2_fiebn")
id = "protester"
display_name = "Protester"
npc_type = 12
temperament = 0.5
base_compliance = 0.4
return_probability = 0.25
reactions_by_level = {
1: {
0: 25,
2: 35,
3: 25,
4: 15
},
2: {
0: 15,
2: 40,
3: 15,
4: 25,
8: 5
},
3: {
0: 10,
1: 25,
2: 25,
4: 25,
8: 15
},
4: {
1: 60,
2: 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."])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_6st6l"]
script = ExtResource("2_fiebn")
id = "authorized_staff"
display_name = "Staff Member"
npc_type = 8
temperament = 0.15
base_compliance = 1.0
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 100
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[sub_resource type="Resource" id="Resource_nf2oq"]
script = ExtResource("2_fiebn")
id = "authorized_guest"
display_name = "Authorized Guest"
npc_type = 9
temperament = 0.2
base_compliance = 0.95
return_probability = 0.0
reactions_by_level = {}
default_reactions = {
1: {
0: 92,
2: 8
},
2: {
0: 88,
2: 12
}
}
reaction_delay = 0.6
dialogue_lines = Array[String]([])
carried_items = Array[String]([])
[resource]
script = ExtResource("3_2dvuq")
id = "tv_studio"
display_name = "Hollywood Blvd Late Night TV Studio"
description = "Crowd, celebrities, and staff around a late-night studio."
access_policy = SubResource("Resource_blmt0")
equipment = Array[String](["wristband_policy", "guest_list", "radio", "flashlight"])
locations = {
"entrance": Vector3(0, 0, 1),
"guard_post": Vector3(0, 0, -3),
"queue_area": Vector3(0, 0, 9),
"sidewalk": Vector3(0, 0, 9),
"stage_door": Vector3(6, 0, -2),
"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")])
event_pool = Array[Dictionary]([{
"base_weight": 1.0,
"id": "audience_ticket",
"intent": "enter",
"kind": "routine",
"npc_type": 9,
"override_access": {
"has_ticket": true
},
"phase": 0,
"probability": 0.35,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "protest_outside",
"intent": "protest",
"kind": "routine",
"npc_type": 12,
"phase": 0,
"probability": 0.18,
"time_range_s": Vector2(300, 900)
}, {
"base_weight": 1.0,
"id": "celebrity_arrival",
"intent": "arrive",
"kind": "routine",
"npc_type": 9,
"override_access": {
"has_guest_list": true
},
"phase": 0,
"probability": 0.2,
"time_range_s": Vector2(300, 700)
}, {
"base_weight": 1.0,
"id": "crew_shift",
"intent": "enter",
"kind": "routine",
"npc_type": 8,
"phase": 0,
"probability": 0.3,
"time_range_s": Vector2(0, 700)
}, {
"base_weight": 1.0,
"id": "tourist_photo",
"intent": "photo",
"kind": "routine",
"npc_type": 0,
"phase": 0,
"probability": 0.35,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "confused_enter",
"intent": "enter",
"kind": "routine",
"npc_type": 3,
"phase": 0,
"probability": 0.25,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "influencer_film",
"intent": "film",
"kind": "routine",
"npc_type": 2,
"phase": 0,
"probability": 0.28,
"time_range_s": Vector2(0, 900)
}, {
"base_weight": 1.0,
"id": "trespass_blend",
"intent": "blend_in",
"kind": "routine",
"npc_type": 5,
"phase": 0,
"probability": 0.15,
"time_range_s": Vector2(300, 900)
}, {
"base_weight": 1.0,
"id": "studio_counterfeit",
"intent": "counterfeit_credential",
"kind": "scripted",
"npc_type": 5,
"override_access": {
"credential_status": 2,
"wristband_status": 2
},
"phase": 2,
"probability": 0.55,
"time_range_s": Vector2(620, 780)
}, {
"base_weight": 1.0,
"hint": "hint.film_boundary",
"id": "studio_film_boundary",
"intent": "film_boundary",
"kind": "scripted",
"npc_type": 2,
"phase": 1,
"probability": 0.5,
"time_range_s": Vector2(330, 560)
}, {
"base_weight": 1.0,
"hint": "hint.film_boundary_repeat",
"id": "studio_film_boundary_repeat",
"intent": "film_boundary",
"kind": "scripted",
"npc_type": 2,
"phase": 2,
"probability": 0.45,
"time_range_s": Vector2(640, 830)
}, {
"base_weight": 1.0,
"extra_spawns": [{
"intent": "photo",
"npc_type": 0
}, {
"intent": "photo",
"npc_type": 0
}, {
"intent": "film",
"npc_type": 2
}],
"hint": "hint.celebrity_arrival",
"id": "studio_celebrity_arrival",
"intent": "arrive",
"kind": "scripted",
"npc_type": 9,
"override_access": {
"guest_list_status": 0,
"has_guest_list": true
},
"phase": 2,
"probability": 0.5,
"time_range_s": Vector2(620, 760)
}, {
"base_weight": 1.0,
"id": "studio_follow_through",
"intent": "follow_authorized",
"kind": "scripted",
"npc_type": 5,
"override_access": {
"has_credential": false,
"has_guest_list": false,
"has_ticket": false,
"has_wristband": false
},
"phase": 2,
"probability": 0.45,
"time_range_s": Vector2(700, 860)
}])
shift_phases = {
0: {
"start_s": 0.0
},
1: {
"start_s": 300.0
},
2: {
"start_s": 600.0
},
3: {
"start_s": 700.0
}
}
shift_duration_s = 900.0
policy_summary = ""
door_staff_lines = {}
+38 -7
View File
@@ -37,8 +37,30 @@ The repository ships a Gitea Actions workflow (`.gitea/workflows/build.yml`):
Linux/Windows/macOS, artifact upload, and — on tags — release publication via
`tools/publish_release.sh`.
For tag builds, set a repo secret named **`GITEA_TOKEN`** (write:repository scope).
Without it the workflow still builds and tests, but the release publish step fails.
`tools/publish_release.sh` picks the first available credential, in order:
1. `GITEA_TOKEN` (repo secret, write:repository scope),
2. `GITEA_AUTH_HEADER` (a ready-made `Authorization: ...` header),
3. `~/.git-credentials` for the host,
4. the **local git extraheader** that `actions/checkout` configures on the CI job.
Option 4 is what makes tag builds publish without any repo secret: the runner
already injects basic-auth credentials for the checkout, and the script reuses that
header for the API (same server). `github.token` is *not* available in this Gitea
setup, so do not rely on it.
> **Disk space:** Gitea stores release assets under `/var/lib/gitea/data/attachments` on
> the host root filesystem (7.8 GB here). When it is full, asset uploads fail with
> `HTTP 500 … no space left on device` and even `git push` can fail with "unpacker error".
> `publish_release.sh` now verifies the asset count and exits non-zero, so this is visible.
>
> **Incident (2026-10-31):** `tools/publish_release.sh` never set `AUTH` in its
> `~/.git-credentials` branch, so every API call failed silently (`AUTH[@]` was
> unbound under `set -u`). Combined with no `GITEA_TOKEN` secret on the repo, tag
> builds from v0.2.2 to v0.2.8 produced installers but published nothing. The script
> is fixed (`AUTH=(-u "$CRED")`) and v0.2.9 was published locally with all five
> artifacts. If CI publishing fails again, run
> `./tools/publish_release.sh <version>` from a checkout with `~/.git-credentials`.
> **No `actions/cache` for now:** the runner has no local actions-cache service, so
> `actions/cache` times out against GitHub's cache API. We dropped it (Godot import
@@ -62,6 +84,8 @@ Artifacts are written to `build/` (all exports embed the PCK — single files):
- `build/step-line-linux-<version>.x86_64`
- `build/step-line-windows-<version>.exe`
- `build/step-line-macos-<version>.zip`
- `build/step-line-web-<version>.zip` — browser build (non-threaded Godot web
template; unzip and serve the folder, no COOP/COEP headers required)
When run in CI (tags/dispatch), the build job also packages:
@@ -80,15 +104,22 @@ the NSIS installer requires `makensis` (covered by CI).
The repository lives at `https://git.allucanget.biz/zwitschi/security-game`.
Preferred path — use the helper script with a token:
Preferred path — use the helper script. It needs either a token or the same
credentials used by `git push` (it falls back to `~/.git-credentials`):
```bash
GITEA_TOKEN=your_api_token ./tools/publish_release.sh 0.1.0
GITEA_TOKEN=your_api_token ./tools/publish_release.sh 0.2.1
# or simply: ./tools/publish_release.sh 0.2.1 (uses ~/.git-credentials)
```
The script creates the release from `project.godot`'s `config/version`, uploads
all three `build/` artifacts, and prints the release URL. The token needs only
`write:repository` scope on the Gitea host.
The script creates (or reuses) the release from `project.godot`'s
`config/version`, uploads the three portable artifacts **plus** the NSIS
installer and AppImage when present, and prints the release URL. If CI already
published the release, re-running the script only adds missing assets.
> **Host note**: the release is fully built by CI, but anonymous users can only
> browse and download when the instance sets `[service] REQUIRE_SIGNIN_VIEW=false`
> in `app.ini` (the repo itself is public). See the TODO for the exact steps.
Manual alternative:
@@ -0,0 +1,42 @@
# ADR-0007: Locomotion and input layers
- **Status:** Accepted
- **Date:** TBD
## Context
The prototype is first-person: WASD walks, mouse looks, `Esc` toggles capture. Action keys were then bound on the same letters. `W` is walk-forward in the input map and wand screening in `GameManager._handle_key`. `A` / `D` strafe and also allow or deny during inspection. `Q` is polite direction and is also bound as `cancel`.
A player who walks toward a guest on a wand venue therefore fires the wand. A player who strafes during an inspection can admit or refuse by accident. The mouse cannot click UI while it is captured, and `Esc` cannot mean both "release the mouse" and "I changed my mind."
## Decision
Treat input as layers, only one of which owns a key at a time.
1. **Locomotion** (default): WASD walk relative to body yaw, mouse look on the captured cursor, pitch clamped. Short acceleration and deceleration. Gravity only when airborne; snap to the floor so the steps work. Walk speed stays around 3.2 m/s. No sprint or crouch in this pass.
2. **Modal UI** (radial, inspect, wand, radio, shift review): freeze walk and look. Recapture the mouse when the modal closes. `A` / `D` and arrows belong to the modal while it is open.
3. **Equipment and global keys** stay off the person-verb set: `G` flashlight, `L` door lock, `Tab` cycle nearby people, `R` emergency radio, `F` debug, `V` venue (debug). Wand leaves `W`; the optional shortcut is `C`.
`Esc` closes the top modal first and does nothing else. With no modal open it keeps the capture/release toggle.
## Consequences
### Positive
- Walking cannot fire wand, inspect, or admission.
- The mouse can click the action menu because capture is released while it is open.
- Cancel has one meaning when a menu is up.
- Locomotion stays first-person, which matches the body-camera framing.
### Negative
- Freeze-while-modal means the player cannot sidestep during an inspect. That is accepted for this pass.
- Power users lose `W` as wand. The wheel and `C` replace it.
- The input map and `_unhandled_input` must agree, or the conflict returns.
## Alternatives considered
- **Keep letter-key verbs on WASD:** Rejected because walk and wand already collide in the prototype.
- **Third-person or click-to-move:** Rejected; the body-camera overlay and the "you are the guard" framing depend on first person.
- **Hold-right-mouse-to-look, free cursor otherwise:** Rejected for this pass. Constant look is the default security-sim stance; the menu is the exception.
- **Never freeze walk:** Rejected because `A` / `D` cannot both strafe and step a menu highlight.
+46
View File
@@ -0,0 +1,46 @@
# ADR-0008: Cone focus and focus circle
- **Status:** Accepted
- **Date:** TBD
## Context
The prototype picks a person with an 8 m physics ray from the camera. The collider is a capsule. A few pixels off the body, or a bob during a walk, clears the target. The HUD then prints a name and hint, but nothing in the world shows who is selected. `Tab` already cycles people within 12 m as an aim-free path.
The next pass needs the player to look at a person, know they have them, and only then open an action menu. A hairline ray is too brittle for that, and a HUD line is easy to miss in a crowd.
## Decision
Focus is a **cone**, not a ray.
- People within about 10 m whose **head** projects inside a screen-space circle around the crosshair (about 90 px / 10 degrees) are candidates.
- If several heads sit in the circle, pick the one closest to the center; if tied, the nearer one.
- The person must be in front of the camera. A short ray to the head skips people fully hidden by the building.
- A few frames of grace keep walk-bob from dropping focus.
- `Tab` still cycles people inside 12 m and sets the same focused person.
When someone is in focus, draw a **blue, semi-transparent circle in front of the face**, attached to the avatar `Head` node, billboarded so it stays a circle on screen. Blue while idle; brighter and slightly larger while the action menu is open on that person. Optional `display_name` under the circle. One circle at a time.
The world must not show the internal type name (`Trespasser`, `Violent Threat`). The debug overlay (`F`) can still print it.
## Consequences
### Positive
- Looking near a person is enough; the player does not have to pin a capsule.
- The circle is visible in the scene, so the HUD is not the only focus cue.
- `Tab` remains the accessibility path and shares the same focused person.
- The action menu (ADR-0009) has a clear subject.
### Negative
- Two heads in the same circle can steal focus; center-distance is a heuristic, not a lock-on.
- Hats and protester signs can z-fight a 3D circle. A screen-space circle unprojected from the head is an allowed fallback.
- Occlusion rays can fail on unusual geometry; grace frames hide some of that.
## Alternatives considered
- **Keep the hairline ray:** Rejected because it drops people too easily and has no world mark.
- **Sticky lock-on until cancelled:** Rejected because the job is to watch a crowd, not duel one person.
- **Screen reticle only, no world circle:** Rejected; in a crowd the player needs a mark on the face, not only a HUD line.
- **Click a person to select, then act:** Rejected for default play. The mouse is captured for look (ADR-0007). Click opens the action menu on whoever is already in the cone.
+60
View File
@@ -0,0 +1,60 @@
# ADR-0009: Radial action menu
- **Status:** Accepted
- **Date:** TBD
## Context
NPC verbs in the prototype live on `Q`, `E`, `W`, `1`–`6`, and `R`. That is a cheat sheet. It also collides with walk keys (ADR-0007). The design wants the player to look at a person (ADR-0008) and then choose the lowest useful action, not memorize a number row.
Inspection, wand screening, and radio already have their own panels. Emergency radio already has a hold-to-confirm on `R` because a misclick is costly. Those panels should stay. What is missing is a single, visible way to pick the verb.
## Decision
When someone is in focus, **left mouse button** or **Space** opens a round doughnut menu in the center of the screen. The click that opens it does not pick a slice. If nobody is in focus, do not open; keep the existing "aim first" log line.
Slices, clockwise from the top, lowest escalation first:
1. Raise hand (stop / wait)
2. Point away
3. Head shake
4. Polite direction
5. Inspect access
6. Wand (grey if the venue has no wand)
7. Formal warning
8. Block
9. Radio assist
Emergency is **not** on the wheel. It stays on `R` with the existing hold-to-confirm panel.
While the menu is open, walk and look freeze (ADR-0007) and the cursor is visible. Highlight follows the mouse angle over the ring, or steps with `A` / `D` or the left / right arrows, wrapping around. The center shows the focused person's `display_name`.
**Confirm:** click a slice, or `Space` on the highlighted slice. The menu closes, the mouse is recaptured, and that action runs. Inspect, wand, and radio assist close the wheel and then open the existing panels. Walk and look stay frozen until that panel closes.
**Cancel:** `Esc` or right mouse button. The menu hides. No action runs. No recorder event. Focus and the focus circle stay. The mouse is recaptured.
Number keys `1`–`6` may remain as shortcuts when the wheel is closed, so existing smoke tests still work. They must not fire while the wheel is open.
## Consequences
### Positive
- The player sees the verbs in de-escalation order instead of recalling a number row.
- Mouse and keyboard both work (click, or arrows / `A`/`D` then Space).
- Cancel is explicit and leaves no incident noise.
- Emergency cannot be picked by a bad slice click.
- Inspect, wand, and radio keep the panels already tested.
### Negative
- Nine slices is dense on a 1280×720 view. Labels must stay short.
- Freeze-until-panel-closes stops the player sidestepping during an ID check.
- Keeping `1`–`6` as hidden shortcuts can confuse if the README leads with them. Document the wheel first.
## Alternatives considered
- **Hold-to-open, release-to-confirm (GTA / Destiny):** Rejected. The requested input is click or Space to open, then click or Space to confirm, with Esc / RMB to abort. That is a toggle, not a hold.
- **Vertical list / command menu:** Rejected because a round layout around a focused person matches the focus circle and reads faster in a crowd.
- **One context-sensitive Use key:** Rejected; the game is about choosing among levels, not a single default verb.
- **Emergency on the wheel:** Rejected because a misclick would dispatch armed response. ADR-0002 already keeps that path rare and confirmed.
- **Drop number-key shortcuts immediately:** Deferred. They remain as a closed-menu fallback until the wheel is proven in play.
@@ -0,0 +1,53 @@
# ADR-0010: Procedural NPC presentation
- **Status:** Accepted
- **Date:** TBD
## Context
Avatars are `CharacterBody3D` capsules color-coded by `NpcType`, with a few props (camera, box, hat, sign, bag), height and brightness variation from a seed, a walk bob, and a head turn toward the guard when noticed. They work as markers. At conversation distance they do not read as people: no neck, no face direction, no shoes, no hair.
The player is supposed to judge from clothes, props, and behavior, not from a label. The HUD currently prints the internal type (`Violent Threat`, `Trespasser`) next to the name. Putting that name on the body would make the judgment for them.
A full Mixamo / GLB pipeline would also add animation imports, licenses, and a skeleton the rest of the sim does not need yet. AI textures in `assets/textures/ai/` are not sized or wired for clothing.
## Decision
Keep the procedural capsule rig for this pass. Do not add skinned meshes or Mixamo.
Make the body read as a person at conversation distance:
- Neck between torso and head.
- Shoes.
- Hair cap whose color comes from the visual seed.
- Two dark eye spheres so the head has a front.
- Named `Head` node (`Rig/Head`) as the attach point for the focus circle (ADR-0008) and the focus cone.
- Collision capsule scaled to the visible height so the cone hits the body the player sees.
Keep archetype props, height / brightness variation, walk bob, and the notice head-turn.
Do **not** put the internal type name on the body or as a world nametag. A focused `display_name` under the ring is allowed. The debug overlay (`F`) may still print type, intent, and FSM state. The HUD target line can keep the type during the prototype; removing it from the HUD is a later readability pass, not this one.
AI clothing textures stay out unless a map is already the right size and is wired on purpose.
## Consequences
### Positive
- People have a face direction the ring and the "noticed the guard" cue can use.
- No new asset toolchain, licenses, or animation retargeting.
- Head is a stable node other systems can find.
- The player is not told `Violent Threat` by a floating label.
### Negative
- Capsule people still look like a prototype at cinematic camera distance.
- Hair and eyes are spheres; they will not survive a close-up trailer.
- A later skinned-mesh pass will replace this rig and must keep the `Head` contract.
## Alternatives considered
- **Skinned Mixamo / GLB characters now:** Rejected. Cost and pipeline exceed this pass; the sim only needs a readable body and a head node.
- **Billboard sprites:** Rejected; first-person movement walks around people, and sprites fail at profile views.
- **Keep colored capsules as-is:** Rejected; the focus circle and cone need a real head, and capsules do not read as people at two metres.
- **World labels with archetype names:** Rejected because they leak sim truth and skip observation.
@@ -0,0 +1,48 @@
# ADR-0011: Aim-free target selection, precision walk, and focus clearing
- **Status:** Accepted
- **Date:** 2026-10-31
## Context
ADR-0007 and ADR-0008 split input into locomotion, look, and a focus cone. A pass over the resulting controls found four problems in play:
1. **The cone ignored what the player sees.** `NpcFocus.pick_index` returned the first head inside the cone in group order, not the one nearest the crosshair. Two people in the cone gave an arbitrary pick.
2. **`Tab` cycled in spawn order.** `GameManager._cycle_nearby_target` walked the `npcs` array, so "next" meant "next spawned", not "next nearest".
3. **A cycled target was dropped within a few frames.** The cone re-evaluated every physics frame and cleared a target that was not under the crosshair after the 8-frame miss grace. Aim-free selection therefore only worked while the player also aimed.
4. **There was no way to dismiss someone.** `Esc` pauses the shift and right click only cancelled modals.
The movement pass also left one gap at the door: the only walking speed is 3.2 m/s, which overshoots when the player is trying to stand at a register or read an artifact at conversation distance.
## Decision
**Rank the cone by angle, then distance.** `NpcFocus.rank_indices` scores every head by its angle from the camera forward vector and breaks ties by distance; `pick_index` is the first ranked entry. The cone therefore picks the person closest to the crosshair, which is what the player is looking at.
**Make aim-free selection authoritative.** `GuardController.cycle_target(direction)` walks `nearby_candidates()` — everyone within 12 m, nearest first — starting from the current target and wrapping in both directions. The chosen avatar is *pinned*: while a pinned target is alive the cone does not re-evaluate, so `Tab` and the mouse wheel produce a stable selection the player can then act on.
**Bind the mouse wheel to the same ring.** `cycle_target_next` (wheel down) and `cycle_target_prev` (wheel up) are new input actions handled in `GameManager._handle_mouse_button`. `Tab` remains the keyboard path. The HUD prints how many people are in the ring (`hud.nearby`), so the player knows the control exists before they need it.
**Right click clears focus, with a short grace.** With no modal open, right click clears the target and suppresses cone acquisition for `FOCUS_SUPPRESS_S = 0.75 s`. Without the grace the cone re-acquired the same person on the next frame, which made the control feel broken. Any explicit pick (cone or cycle) cancels the suppression.
**Add a precision walk.** Holding `Shift` (`walk_slow`) multiplies the walk speed by `SLOW_WALK_FACTOR = 0.45`. This is a fine-positioning aid, not a sprint: PLAN.md deliberately excludes sprint and crouch, and this does not add a faster move.
Escape keeps its current meaning (close the top modal, otherwise pause). Selection is not overloaded onto the same key.
## Consequences
### Positive
- The focused person is predictable: the one nearest the crosshair, or the one the player explicitly cycled to.
- Aim-free play works: cycle, act, cycle again, without fighting the cone.
- Right click has an unambiguous "never mind" meaning while remaining a modal cancel.
- Standing on a mark while reading an artifact is possible.
### Negative
- Two more input actions and one more piece of guard state (`target_pinned`, `focus_suppress_s`).
- A cycled target stays selected until the player clears it, acts, or the avatar is freed; it does not expire on its own.
- The 0.75 s suppression is a feel constant that may need tuning against play testing.
## Follow-up
- The action wheel (ADR-0009) and inspection panels still take `A`/`D`; the wheel owns those keys while it is open, and the guard is frozen then, so no conflict is observable.
@@ -0,0 +1,44 @@
# ADR-0012: Surface material profiles and generated prop models
- **Status:** Accepted
- **Date:** 2026-10-31
## Context
The procedural street was built from `BoxMesh` primitives with `StandardMaterial3D` defaults: albedo colour (or a texture) and everything else stock. Every surface therefore shared one roughness, street furniture was a set of scaled boxes, and the sky was a flat `ProceduralSkyMaterial` with no tonemapping, glow, or fog. Captures looked like a blockout: brick tiles were metres wide, the sidewalk blew out to white, the facade had no windows, and night mode only switched the lamp bulbs and the marquee.
Two constraints shaped the fix:
- The project renders with the **GL Compatibility** backend so it stays small and portable, which rules out features that only exist in Forward+ (`SSAO`, `SSIL`).
- The repo must build offline in CI, so any asset step has to work without a network or an API key.
## Decision
**One material module.** `src/game/materials.gd` (`SurfaceMaterials`) owns a named profile table (`brick`, `concrete`, `asphalt`, `awning`, `stone`, `metal`, `painted_metal`, `wood`, `cloth`, `skin`, `glass`, `rubber`) with roughness/metallic pairs, plus the recipes the world and avatars use (`brick()`, `concrete()`, `asphalt()`, `awning()`, `flat()`, `emissive()`, `glass()`, `window_pane()`). The world builder, the NPC avatars, and the prop fallbacks all call it, so a surface has one definition.
**One environment tune-up.** `SurfaceMaterials.configure_environment()` applies ACES tonemapping, soft glow (for the marquee, lamp bulbs, and lit windows), aerial fog, and a mild contrast/saturation lift. `GameManager.PHASE_LIGHTING` now carries the per-phase values (`exposure`, `glow`, `fog_color`, `fog_density`, `fog_energy`, `contrast`, `saturation`, `ambient_energy`) next to the existing sun/sky entries, so a phase change still happens in one place. Features unavailable in Compatibility are ignored by the driver rather than gated in code.
**Generated prop models.** `tools/model_pipeline.py` describes street furniture (bollard, bench, bin, planter, newspaper box, hydrant) as boxes and cylinders and writes binary glTF (`.glb`) into `assets/models/generated/`. Godot imports each file as a `PackedScene`, and `WorldBuilder._add_prop_model()` instantiates it with a procedural fallback when the file or its import is missing. The build is deterministic and needs no network.
**AI only supplies colour.** `model_pipeline.py concept` asks an image model (OpenRouter, same backend as the texture tools) for one studio-style concept per prop. `dominant_colors()` reads the centre of that image and ranks colours by frequency weighted by saturation (a grey backdrop otherwise wins), and `assign_roles()` maps the result onto material roles by hue and value: darkest tone → metal, warm saturated tone → wood, light desaturated tone → stone, green tone → foliage. Colours are converted from sRGB to **linear** before they are written into glTF `baseColorFactor`, because the factor is linear space and writing sRGB made every prop render pastel.
## Consequences
### Positive
- Surfaces read as materials: brick has fine, correctly scaled tiling, asphalt is smoother than brick, glass is smooth and slightly metallic, and the night sky carries warm lit windows.
- Street furniture is real geometry with per-part materials instead of scaled boxes, and it costs ~700 triangles for the whole set.
- The prop pipeline works with no key and no network; the concept step is optional and only changes colours.
- Tests can assert material profiles, environment values, and GLB structure without rendering.
### Negative
- The generated models are low-poly primitives, not sculpted assets; they are an improvement in silhouette, not photoreal props.
- Two colour spaces now exist in the tooling. `model_pipeline.srgb_to_linear` must be used for every colour written into glTF, which is easy to forget.
- Concept images are per-prop inputs that have to be regenerated (and reviewed) when the art direction changes; a poor concept now produces a poor palette.
- Compatibility rendering means no ambient occlusion, so contact shadows rely on the directional light only.
## Follow-up
- A 3D-model API (image-to-mesh) could replace the primitive builder behind the same `assets/models/generated/<prop>.glb` contract without touching `WorldBuilder`.
- Texture atlases for the props would let them share one material instead of one per part.
+9
View File
@@ -76,6 +76,9 @@ The player interacts with NPCs, venue entrances, access artifacts, equipment, st
- `ReputationEvaluator` converts incidents and shift outcomes into performance feedback.
- `CrowdSystem` models queues, attention, congestion, and public reaction.
- `ShiftReview` presents decisions, consequences, and improvement opportunities.
- `SurfaceMaterials` (`src/game/materials.gd`) holds the surface profiles, glass/emissive recipes, and the environment tune-up (tonemapping, glow, fog, grading) that the phase lighting applies.
- `WorldBuilder` assembles the procedural venue and instantiates the generated prop models from `assets/models/generated/` with a procedural fallback.
- `tools/model_pipeline.py` builds and audits those prop models; `tools/screen_designs.py` generates screen references and judges captures.
## 6. Runtime View
@@ -152,6 +155,12 @@ Architecture decisions are recorded as ADRs:
- [ADR-0004: Local single-player first](adr/0004-local-single-player-first.md)
- [ADR-0005: Event-based incident recording](adr/0005-event-based-incident-recording.md)
- [ADR-0006: Godot 4 with GDScript](adr/0006-godot-4-gdscript.md)
- [ADR-0007: Locomotion and input layers](adr/0007-locomotion-and-input-layers.md)
- [ADR-0008: Cone focus and focus circle](adr/0008-cone-focus-and-head-ring.md)
- [ADR-0009: Radial action menu](adr/0009-radial-action-menu.md)
- [ADR-0010: Procedural NPC presentation](adr/0010-procedural-npc-presentation.md)
- [ADR-0011: Aim-free selection and precision walk](adr/0011-aim-free-selection-and-precision-walk.md)
- [ADR-0012: Surface materials and generated props](adr/0012-surface-materials-and-generated-props.md)
## 10. Quality Requirements
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1004 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1000 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 910 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 971 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 838 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

+36
View File
@@ -103,3 +103,39 @@ application/export_angle=0
application/target_os_version=""
application/signature_flags=0
application/show_bundle_name=1
[preset.3]
name="Web"
platform="Web"
runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="build/web/index.html"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.3.options]
custom_template/debug=""
custom_template/release=""
variant/extensions_support=false
variant/thread_support=false
vram_texture_compression/for_desktop=true
vram_texture_compression/for_mobile=false
html/export_icon=true
html/custom_html_shell=""
html/head_include=""
html/canvas_resize_policy=2
html/focus_canvas_on_start=true
html/experimental_virtual_keyboard=false
progressive_web_app/enabled=false
+45 -15
View File
@@ -8,7 +8,7 @@ config_version=5
config/name="Step Line"
config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
config/version="0.2.1"
config/version="0.3.1"
run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.3", "GL Compatibility")
@@ -47,18 +47,6 @@ move_right={
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194321,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
interact={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null)
]
}
cancel={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":81,"key_label":0,"unicode":113,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
gesture_raise_hand={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":49,"key_label":0,"unicode":49,"location":0,"echo":false,"script":null)
@@ -91,8 +79,50 @@ radio_assist={
}
radio_emergency={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":87,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":114,"location":0,"echo":false,"script":null)
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":114,"location":0,"echo":false,"script":null)
]
}
action_menu={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null)
]
}
confirm={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194309,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
cancel={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":2,"canceled":false,"pressed":false,"double_click":false,"script":null)
]
}
inspect={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
]
}
wand={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":67,"key_label":0,"unicode":99,"location":0,"echo":false,"script":null)
]
}
walk_slow={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194325,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
cycle_target_next={
"deadzone": 0.5,
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":5,"canceled":false,"pressed":false,"double_click":false,"script":null)
]
}
cycle_target_prev={
"deadzone": 0.5,
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":4,"canceled":false,"pressed":false,"double_click":false,"script":null)
]
}
print_debug={
+1
View File
@@ -0,0 +1 @@
Pillow>=9.2
+26
View File
@@ -37,3 +37,29 @@ func save_shift_result(result: Dictionary) -> Error:
var results := load_shift_results()
results.append(result)
return _save_json(SHIFT_RESULTS_FILE, results)
## Defaults for player settings. Kept in code so a missing or partial
## settings.json still produces a usable config.
const DEFAULT_SETTINGS := {
"look_sensitivity": 0.0022,
"hud_font_scale": 1.0,
"ai_difficulty": "normal",
}
## Load player settings, merged over the defaults. Returns a Dictionary.
func load_settings() -> Dictionary:
var merged: Dictionary = DEFAULT_SETTINGS.duplicate(true)
var data = _load_json(SETTINGS_FILE)
if data is Dictionary:
for key in data.keys():
if merged.has(key):
merged[key] = data[key]
return merged
## Persist player settings. Returns Error.
func save_settings(settings: Dictionary) -> Error:
var merged: Dictionary = DEFAULT_SETTINGS.duplicate(true)
for key in settings.keys():
if merged.has(key):
merged[key] = settings[key]
return _save_json(SETTINGS_FILE, merged)
+148 -5
View File
@@ -343,6 +343,28 @@ static func hollywood_building() -> VenueScenario:
_building_routine("staff_entry", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 600), 0.35),
_building_routine("guest_entry", Rules.NpcType.AUTHORIZED_GUEST, "enter", Vector2(0, 900), 0.25),
_building_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.24),
{
"id": "building_filming_boundary",
"phase": Rules.Phase.MIDDAY,
"kind": "scripted",
"npc_type": Rules.NpcType.INFLUENCER,
"intent": "film",
"time_range_s": Vector2(300, 600),
"probability": 0.4,
"base_weight": 1.0,
"hint": "hint.body_camera_reason",
},
{
"id": "building_unclear_intent",
"phase": Rules.Phase.EVENING,
"kind": "scripted",
"npc_type": Rules.NpcType.CONFUSED_VISITOR,
"intent": "enter",
"time_range_s": Vector2(600, 780),
"probability": 0.4,
"base_weight": 1.0,
"hint": "hint.ask_reason",
},
_building_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.26),
_building_routine("intoxicated_wander", Rules.NpcType.INTOXICATED, "wander", Vector2(600, 900), 0.15),
_building_routine("trespass_test", Rules.NpcType.TRESPASSER, "trespass", Vector2(300, 900), 0.16),
@@ -393,6 +415,8 @@ static func joke_shop() -> VenueScenario:
"guard_post": Vector3(0, 0, -3),
"entrance": Vector3(0, 0, 1.0),
"door_area": Vector3(0, 0, 3),
"steps_end": Vector3(0, 0, 3),
"sidewalk": Vector3(0, 0, 9),
"queue_area": Vector3(0, 0, 9),
}
scenario.door_staff_lines = {
@@ -433,8 +457,69 @@ static func joke_shop() -> VenueScenario:
scenario.event_pool = [
_joke_guest("guest_valid", true, true, Vector2(0, 900), 0.30),
_joke_guest("guest_no_ticket", false, true, Vector2(0, 900), 0.18),
_joke_guest("guest_expired_id", true, true, Vector2(0, 900), 0.12),
_joke_guest("guest_expired_id", true, true, Vector2(0, 900), 0.12, {"id_status": Rules.ArtifactStatus.EXPIRED}),
_joke_guest("guest_no_id", true, false, Vector2(0, 900), 0.12),
{
"id": "joke_guest_reservation_no_id",
"phase": Rules.Phase.EVENING,
"kind": "scripted",
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
"intent": "enter",
"time_range_s": Vector2(620, 860),
"probability": 0.45,
"base_weight": 1.0,
"override_access": {
"has_guest_list": true, "guest_list_status": Rules.ArtifactStatus.VALID,
"has_id": false, "has_ticket": false,
},
"hint": "hint.reservation_no_id",
},
{
"id": "joke_borrowed_id",
"phase": Rules.Phase.MIDDAY,
"kind": "scripted",
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
"intent": "enter",
"time_range_s": Vector2(320, 700),
"probability": 0.4,
"base_weight": 1.0,
"override_access": {
"has_id": true, "id_status": Rules.ArtifactStatus.MISMATCHED,
"has_ticket": true, "ticket_status": Rules.ArtifactStatus.VALID,
},
"hint": "hint.borrowed_id",
},
{
"id": "joke_refuses_wand",
"phase": Rules.Phase.EVENING,
"kind": "scripted",
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
"intent": "enter",
"time_range_s": Vector2(650, 880),
"probability": 0.4,
"base_weight": 1.0,
"refuses_screening": true,
"override_access": {
"has_id": true, "id_status": Rules.ArtifactStatus.VALID,
"has_ticket": true, "ticket_status": Rules.ArtifactStatus.VALID,
},
"hint": "hint.refuses_wand",
},
{
"id": "joke_late_arrival",
"phase": Rules.Phase.EVENING,
"kind": "scripted",
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
"intent": "arrive",
"time_range_s": Vector2(700, 860),
"probability": 0.35,
"base_weight": 1.0,
"override_access": {
"has_id": true, "id_status": Rules.ArtifactStatus.VALID,
"has_ticket": true, "ticket_status": Rules.ArtifactStatus.VALID,
},
"hint": "hint.late_arrival",
},
_joke_staff_entry(Vector2(0, 900), 0.25),
_joke_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.15),
_joke_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.15),
@@ -475,6 +560,8 @@ static func tv_studio() -> VenueScenario:
"guard_post": Vector3(0, 0, -3),
"entrance": Vector3(0, 0, 1.0),
"stage_door": Vector3(6, 0, -2),
"steps_end": Vector3(0, 0, 3),
"sidewalk": Vector3(0, 0, 9),
"queue_area": Vector3(0, 0, 9),
}
@@ -492,9 +579,9 @@ static func tv_studio() -> VenueScenario:
])
scenario.event_pool = [
_studio_routine("audience_ticket", Rules.NpcType.AUTHORIZED_GUEST, "enter", Vector2(0, 900), 0.35),
_studio_guest("audience_ticket", {"has_ticket": true}, Vector2(0, 900), 0.35),
_studio_routine("protest_outside", Rules.NpcType.PROTESTER, "protest", Vector2(300, 900), 0.18),
_studio_routine("celebrity_arrival", Rules.NpcType.AUTHORIZED_GUEST, "arrive", Vector2(300, 700), 0.20),
_studio_guest("celebrity_arrival", {"has_guest_list": true}, Vector2(300, 700), 0.20, "arrive"),
_studio_routine("crew_shift", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 700), 0.30),
_studio_routine("tourist_photo", Rules.NpcType.TOURIST, "photo", Vector2(0, 900), 0.35),
_studio_routine("confused_enter", Rules.NpcType.CONFUSED_VISITOR, "enter", Vector2(0, 900), 0.25),
@@ -509,6 +596,46 @@ static func tv_studio() -> VenueScenario:
"time_range_s": Vector2(620, 780),
"probability": 0.55,
"base_weight": 1.0,
"override_access": {"wristband_status": Rules.ArtifactStatus.COUNTERFEIT, "credential_status": Rules.ArtifactStatus.COUNTERFEIT},
},
{
"id": "studio_film_boundary",
"phase": Rules.Phase.MIDDAY,
"kind": "scripted",
"npc_type": Rules.NpcType.INFLUENCER,
"intent": "film_boundary",
"time_range_s": Vector2(330, 560),
"probability": 0.5,
"base_weight": 1.0,
"hint": "hint.film_boundary",
},
{
"id": "studio_film_boundary_repeat",
"phase": Rules.Phase.EVENING,
"kind": "scripted",
"npc_type": Rules.NpcType.INFLUENCER,
"intent": "film_boundary",
"time_range_s": Vector2(640, 830),
"probability": 0.45,
"base_weight": 1.0,
"hint": "hint.film_boundary_repeat",
},
{
"id": "studio_celebrity_arrival",
"phase": Rules.Phase.EVENING,
"kind": "scripted",
"npc_type": Rules.NpcType.AUTHORIZED_GUEST,
"intent": "arrive",
"time_range_s": Vector2(620, 760),
"probability": 0.5,
"base_weight": 1.0,
"override_access": {"has_guest_list": true, "guest_list_status": Rules.ArtifactStatus.VALID},
"extra_spawns": [
{"npc_type": Rules.NpcType.TOURIST, "intent": "photo"},
{"npc_type": Rules.NpcType.TOURIST, "intent": "photo"},
{"npc_type": Rules.NpcType.INFLUENCER, "intent": "film"},
],
"hint": "hint.celebrity_arrival",
},
{
"id": "studio_follow_through",
@@ -516,6 +643,7 @@ static func tv_studio() -> VenueScenario:
"kind": "scripted",
"npc_type": Rules.NpcType.TRESPASSER,
"intent": "follow_authorized",
"override_access": {"has_ticket": false, "has_wristband": false, "has_credential": false, "has_guest_list": false},
"time_range_s": Vector2(700, 860),
"probability": 0.45,
"base_weight": 1.0,
@@ -549,12 +677,23 @@ static func _building_routine(id: String, npc_type: int, intent: String, time_ra
"time_range_s": time_range, "probability": prob, "base_weight": 1.0,
}
static func _joke_guest(id: String, has_ticket: bool, has_id: bool, time_range: Vector2, prob: float) -> Dictionary:
static func _joke_guest(id: String, has_ticket: bool, has_id: bool, time_range: Vector2, prob: float, extra: Dictionary = {}) -> Dictionary:
var override := {"has_ticket": has_ticket, "has_id": has_id}
for key in extra.keys():
override[key] = extra[key]
return {
"id": id, "phase": _phase_for_range(time_range, 900.0),
"kind": "routine", "npc_type": Rules.NpcType.AUTHORIZED_GUEST, "intent": "enter",
"time_range_s": time_range, "probability": prob, "base_weight": 1.0,
"override_access": {"has_ticket": has_ticket, "has_id": has_id},
"override_access": override,
}
static func _studio_guest(id: String, override_access: Dictionary, time_range: Vector2, prob: float, intent: String = "enter") -> Dictionary:
return {
"id": id, "phase": _phase_for_range(time_range, 900.0),
"kind": "routine", "npc_type": Rules.NpcType.AUTHORIZED_GUEST, "intent": intent,
"time_range_s": time_range, "probability": prob, "base_weight": 1.0,
"override_access": override_access,
}
static func _joke_staff_entry(time_range: Vector2, prob: float) -> Dictionary:
@@ -570,6 +709,10 @@ static func _joke_routine(id: String, npc_type: int, intent: String, time_range:
static func _studio_routine(id: String, npc_type: int, intent: String, time_range: Vector2, prob: float) -> Dictionary:
return _building_routine(id, npc_type, intent, time_range, prob)
## Scripted film-boundary intent: the influencer stands past the posted line.
static func _film_boundary_spot() -> String:
return "steps_end"
static func _phase_for_range(time_range: Vector2, duration: float) -> int:
var start := time_range.x / duration
if start < 0.34:
+134
View File
@@ -0,0 +1,134 @@
extends RefCounted
## Builds the access artifacts a person actually presents.
## Event `override_access` wins; otherwise a venue-appropriate default from
## type + policy. Recognition is never attached to every guest.
const Rules := preload("res://src/core/rules.gd")
const AccessArtifact := preload("res://src/core/access_artifact.gd")
const AccessPolicy := preload("res://src/core/access_policy.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd")
static func build(profile: NpcProfile, policy: AccessPolicy, override: Dictionary = {}) -> Array:
var npc_type: int = profile.npc_type if profile else Rules.NpcType.TOURIST
var out: Array = []
if override.get("recognized", false) or (override.is_empty() and npc_type == Rules.NpcType.AUTHORIZED_STAFF):
out.append(AccessArtifact.new(Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.VALID, {"known": true}))
if not override.is_empty():
_append_override(out, profile, override)
return out
if policy != null and (policy.require_id or policy.requires(Rules.AccessKind.ID)):
_append_id_ticket_defaults(out, profile, npc_type, policy)
return out
if policy != null and policy.grant_if_any_required:
_append_or_defaults(out, npc_type)
return out
_append_wristband_defaults(out, npc_type)
return out
static func roll_carried_items(seed_value: int, override: Dictionary = {}) -> Array:
if override.has("carried_items"):
return override["carried_items"]
var rng := RandomNumberGenerator.new()
rng.seed = seed_value if seed_value != 0 else 1
var roll := rng.randf()
if roll < 0.08:
return ["keys", "knife"]
if roll < 0.40:
return ["keys"]
if roll < 0.62:
return ["phone"]
if roll < 0.80:
return ["belt"]
return []
static func _append_override(out: Array, profile: NpcProfile, override: Dictionary) -> void:
if override.has("has_id") or override.has("id_status"):
var has_id := bool(override.get("has_id", true))
var status: int = int(override.get("id_status", Rules.ArtifactStatus.VALID if has_id else Rules.ArtifactStatus.MISSING))
if not has_id:
status = Rules.ArtifactStatus.MISSING
var attrs := {}
if status != Rules.ArtifactStatus.MISSING:
attrs["age"] = int(override.get("age", 24))
attrs["name"] = profile.display_name if profile else "Guest"
out.append(AccessArtifact.new(Rules.AccessKind.ID, status, attrs))
if override.has("has_ticket") or override.has("ticket_status"):
var has_ticket := bool(override.get("has_ticket", true))
var status: int = int(override.get("ticket_status", Rules.ArtifactStatus.VALID if has_ticket else Rules.ArtifactStatus.MISSING))
if not has_ticket:
status = Rules.ArtifactStatus.MISSING
out.append(AccessArtifact.new(Rules.AccessKind.TICKET, status, {}))
if override.has("has_wristband") or override.has("wristband_status"):
var status := _present_status(override, "has_wristband", "wristband_status")
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, status, _band_attrs(status)))
if override.has("has_credential") or override.has("credential_status"):
var status := _present_status(override, "has_credential", "credential_status")
out.append(AccessArtifact.new(Rules.AccessKind.CREDENTIAL, status, {"role": "staff"}))
if override.has("has_guest_list") or override.has("guest_list_status"):
var status := _present_status(override, "has_guest_list", "guest_list_status")
out.append(AccessArtifact.new(Rules.AccessKind.GUEST_LIST, status, {}))
static func _present_status(override: Dictionary, has_key: String, status_key: String) -> int:
if override.has(status_key):
return int(override[status_key])
return Rules.ArtifactStatus.VALID if bool(override.get(has_key, false)) else Rules.ArtifactStatus.MISSING
static func _append_id_ticket_defaults(out: Array, profile: NpcProfile, npc_type: int, policy: AccessPolicy) -> void:
if npc_type == Rules.NpcType.AUTHORIZED_STAFF:
return
if npc_type == Rules.NpcType.AUTHORIZED_GUEST:
out.append(AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.VALID, {
"age": 24, "name": profile.display_name if profile else "Guest",
}))
if policy.requires(Rules.AccessKind.TICKET):
out.append(AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.VALID, {}))
return
out.append(AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.MISSING, {}))
if policy.requires(Rules.AccessKind.TICKET):
out.append(AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.MISSING, {}))
static func _append_or_defaults(out: Array, npc_type: int) -> void:
match npc_type:
Rules.NpcType.AUTHORIZED_STAFF, Rules.NpcType.DELIVERY:
if not _has_kind(out, Rules.AccessKind.RECOGNITION):
out.append(AccessArtifact.new(Rules.AccessKind.CREDENTIAL, Rules.ArtifactStatus.VALID, {"role": "staff"}))
Rules.NpcType.AUTHORIZED_GUEST:
out.append(AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.VALID, {}))
Rules.NpcType.TRESPASSER:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, _band_attrs(Rules.ArtifactStatus.COUNTERFEIT)))
Rules.NpcType.CONFUSED_VISITOR:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.AMBIGUOUS, _band_attrs(Rules.ArtifactStatus.AMBIGUOUS)))
_:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {}))
static func _append_wristband_defaults(out: Array, npc_type: int) -> void:
match npc_type:
Rules.NpcType.AUTHORIZED_STAFF:
pass
Rules.NpcType.AUTHORIZED_GUEST, Rules.NpcType.DELIVERY:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.VALID, _band_attrs(Rules.ArtifactStatus.VALID)))
if npc_type == Rules.NpcType.DELIVERY:
out.append(AccessArtifact.new(Rules.AccessKind.CREDENTIAL, Rules.ArtifactStatus.VALID, {"role": "delivery"}))
Rules.NpcType.TRESPASSER:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, _band_attrs(Rules.ArtifactStatus.COUNTERFEIT)))
Rules.NpcType.CONFUSED_VISITOR:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.AMBIGUOUS, _band_attrs(Rules.ArtifactStatus.AMBIGUOUS)))
_:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {}))
static func _band_attrs(status: int) -> Dictionary:
match status:
Rules.ArtifactStatus.VALID:
return {"color": "red", "date": "2026-02-13", "event": "gala"}
Rules.ArtifactStatus.COUNTERFEIT:
return {"color": "red", "date": "2024-01-01"}
Rules.ArtifactStatus.AMBIGUOUS:
return {"color": "gold"}
_:
return {}
static func _has_kind(artifacts: Array, kind: int) -> bool:
for artifact in artifacts:
if artifact is AccessArtifact and artifact.kind == kind:
return true
return false
+2 -2
View File
@@ -15,7 +15,7 @@ func _init() -> void:
pass
## Verify a single person's set of artifacts (person_artifacts: Array[AccessArtifact]).
func verify(person_artifacts: Array, policy: AccessPolicy) -> Dictionary:
func verify(person_artifacts: Array, policy: AccessPolicy, already_screened: bool = false) -> Dictionary:
var reasons: Array[String] = []
var issues: Array[String] = []
var checked: Array[String] = []
@@ -48,7 +48,7 @@ func verify(person_artifacts: Array, policy: AccessPolicy) -> Dictionary:
# Wand screening is processed by the interaction layer (false alarms), but a
# policy can hard-require it; a refused screen blocks entry.
if policy.wand_screening_required and not person_screened(person_artifacts):
if policy.wand_screening_required and not already_screened and not person_screened(person_artifacts):
issues.append("Screening required")
return _result(false, reasons, issues, checked)
+14
View File
@@ -45,6 +45,20 @@ func apply_event(event_type: String) -> void:
"event": event_type,
})
## Presentation cue for the avatars after a crowd event (PLAN: Public Crowd
## Reaction). "watch" = rubberneck at the incident, "flee" = move away from the
## door, "" = no reaction. Pure so it is headless-testable.
static func reaction_for_event(event_type: String) -> String:
match event_type:
"aggressive_behavior", "visible_weapon", "emergency_responder":
return "watch"
_:
return ""
## True once the crowd is panicking hard enough that people should clear out.
func should_flee() -> bool:
return crowd_panic
func summary() -> Dictionary:
return {
"mood": mood,
+13
View File
@@ -78,6 +78,19 @@ func close_incident(npc_id: String, outcome: String) -> Dictionary:
func open_incidents_count() -> int:
return _open_incidents.size()
## True when an incident is already open for this NPC (avoids duplicating one
## incident every time the player re-focuses the same person).
func has_open_incident(npc_id: String) -> bool:
return _open_incidents.has(npc_id)
## Close every still-open incident at shift end so the review does not silently
## drop interactions the player never resolved. Returns how many were closed.
func close_all_open(outcome: String = "unresolved") -> int:
var ids: Array = _open_incidents.keys()
for npc_id in ids:
close_incident(str(npc_id), outcome)
return ids.size()
func to_dict() -> Dictionary:
return {
"incidents": incidents.duplicate(true),
+20
View File
@@ -34,6 +34,16 @@ var guard_noticed := false
## Whether this NPC has passed the handheld wand screening (The Joke Shop).
var screened := false
## Authored event flag: this person refuses the wand screen (Joke Shop).
var refuses_screening := false
## Whether the guard has declared they recognize this person (a player decision
## recorded as evidence, PLAN.md: Authorized Access).
var player_recognized := false
## Last reply the NPC gave when asked for their reason for entry.
var last_reason_reply := ""
## Event-level access payload (`has_id`, `has_ticket`, statuses). Empty means
## venue/type defaults from AccessLoadout.
var access_override: Dictionary = {}
func _init() -> void:
fsm = FSM.new(self)
@@ -147,6 +157,16 @@ func tick_return(delta: float) -> bool:
func decide_leave() -> bool:
return leave_requested
## Level 3 ask: request the person's reason for entry (PLAN.md). Pure and
## headless-testable; returns the line this NPC answers with ("" if silent).
func request_reason() -> String:
var lines: Array[String] = profile.dialogue_lines if profile else []
if lines.is_empty():
last_reason_reply = ""
return ""
last_reason_reply = lines[rng.randi_range(0, lines.size() - 1)]
return last_reason_reply
## Perception pass: given the guard's distance (meters), return a perception
## result describing how the NPC reacts to the guard's presence. Pure and
## headless-testable; the presentation layer feeds it the real distance.
+27 -3
View File
@@ -13,7 +13,7 @@ const EventScheduler := preload("res://src/core/event_scheduler.gd")
## Drives one shift: advances time, schedules events, owns the shared systems
## (recorder, reputation, radio, crowd), and produces the shift review.
signal npc_spawn_requested(profile: NpcProfile, intent: String, npc_id: String)
signal npc_spawn_requested(profile: NpcProfile, intent: String, npc_id: String, event: Dictionary)
signal shift_started(payload: Dictionary)
signal shift_ended(review: Dictionary)
signal phase_changed(phase: int)
@@ -21,6 +21,8 @@ signal phase_changed(phase: int)
var scenario: VenueScenario = null
var duration_s: float = 900.0
var elapsed_s: float = 0.0
## Wall-clock start, so the shift review can show event times as mm:ss.
var started_at_ms: int = 0
var current_phase: int = Rules.Phase.EARLY
var running := false
@@ -32,8 +34,16 @@ var crowd := CrowdSystem.new()
var scheduler := EventScheduler.new()
var spawned_count := 0
## Emergency aftermath (PLAN.md: Level 5). Responder resolutions and any
## investigation notes for calls that were not justified.
var responder_outcomes: Array[String] = []
var investigations: Array[String] = []
var spawn_seed := 0
var _shift_finished := false
## The scheduler rolls once per simulated second. `tick()` runs at physics rate,
## so accumulate time and only pull due events on whole-second boundaries.
const SPAWN_INTERVAL_S := 1.0
var _spawn_accum_s := 0.0
func setup(p_scenario: VenueScenario, p_duration_s: float = 900.0, p_seed: int = 0) -> void:
scenario = p_scenario
@@ -41,6 +51,8 @@ func setup(p_scenario: VenueScenario, p_duration_s: float = 900.0, p_seed: int =
spawn_seed = p_seed
scheduler.setup(scenario, p_seed)
event_bus = EventBus.new()
responder_outcomes.clear()
investigations.clear()
radio = RadioSystem.new()
reputation = ReputationEvaluator.new()
crowd = CrowdSystem.new()
@@ -55,7 +67,9 @@ func start() -> void:
return
running = true
_shift_finished = false
started_at_ms = Time.get_ticks_msec()
elapsed_s = 0.0
_spawn_accum_s = 0.0
current_phase = _phase_for_time(0.0)
event_bus.shift_state_changed.emit({"running": true, "elapsed_s": elapsed_s, "phase": current_phase})
shift_started.emit({"scenario_id": scenario.id, "duration_s": duration_s})
@@ -77,7 +91,12 @@ func tick(delta: float) -> void:
if elapsed_s >= duration_s:
_finish_shift()
return
_spawn_due(delta)
_spawn_accum_s += delta
if _spawn_accum_s >= SPAWN_INTERVAL_S:
_spawn_accum_s -= SPAWN_INTERVAL_S
if _spawn_accum_s > SPAWN_INTERVAL_S:
_spawn_accum_s = 0.0
_spawn_due(SPAWN_INTERVAL_S)
func _phase_for_time(t: float) -> int:
if scenario == null or scenario.shift_phases.is_empty():
@@ -102,13 +121,15 @@ func _spawn_due(delta: float) -> void:
var profile := scenario.profile_for(event.get("npc_type", Rules.NpcType.TOURIST))
if profile == null:
continue
npc_spawn_requested.emit(profile, event.get("intent", "roam"), npc_id)
npc_spawn_requested.emit(profile, event.get("intent", "roam"), npc_id, event)
func _finish_shift() -> void:
if _shift_finished:
return
_shift_finished = true
running = false
# Interactions the player never resolved still belong in this shift's review.
recorder.close_all_open("unresolved")
var review := build_review()
event_bus.shift_state_changed.emit({"running": false, "review": review})
shift_ended.emit(review)
@@ -119,6 +140,7 @@ func build_review() -> Dictionary:
"scenario_id": scenario.id if scenario else "",
"duration_s": duration_s,
"elapsed_s": elapsed_s,
"started_at_ms": started_at_ms,
"phase_at_end": current_phase,
"spawned_count": spawned_count,
"incidents": recorder.to_dict(),
@@ -128,6 +150,8 @@ func build_review() -> Dictionary:
"battery": radio.battery,
"emergency_cooldown_s": radio.emergency_cooldown_remaining,
},
"responder_outcomes": responder_outcomes.duplicate(),
"investigations": investigations.duplicate(),
"completed_at_ms": Time.get_ticks_msec(),
}
+57
View File
@@ -34,12 +34,69 @@ static func lookup(key: String) -> String:
_ensure_loaded()
return TABLE.get(key, key)
## Number of `%` conversion placeholders in a template, ignoring `%%` escapes.
static func placeholder_count(template: String) -> int:
var count := 0
var i := 0
while i < template.length():
if template[i] == "%":
if i + 1 < template.length() and template[i + 1] == "%":
i += 2
continue
count += 1
i += 1
return count
## Format a string by key with positional arguments.
##
## GDScript's `%` binds arguments by order, so a key whose placeholder count drifts
## from its call site would push an engine error. Validate first and fail safe: log
## a warning and return the raw template instead of crashing.
static func fmt(key: String, args: Array) -> String:
var template := lookup(key)
if template == key:
return template
var needed := placeholder_count(template)
if needed != args.size():
push_warning("Strings.fmt(%s): template needs %d args, got %d" % [key, needed, args.size()])
return template
return template % args
## Lint the loaded table for formatting hazards. Returns an array of problems.
## Checks: trailing `%`, invalid conversions, and mixed positional (`%1$s`) and
## sequential placeholders in one template.
static func validate() -> Array[String]:
_ensure_loaded()
var problems: Array[String] = []
for key in TABLE.keys():
var template := str(TABLE[key])
var positional := template.contains("$")
var sequential := 0
var i := 0
while i < template.length():
if template[i] != "%":
i += 1
continue
if i + 1 >= template.length():
problems.append("%s: trailing %% escape" % key)
break
var next := template[i + 1]
if next == "%":
i += 2
continue
var j := i + 1
while j < template.length() and template[j] in "0123456789.$":
if template[j] == "$":
positional = true
j += 1
if j >= template.length() or not (template[j] in "sdfxXv"):
problems.append("%s: unsupported conversion near '%%%s'" % [key, template.substr(i + 1, 3)])
elif not positional:
sequential += 1
i = j + 1
if positional and sequential > 0:
problems.append("%s: mixes positional (%%1$s) and sequential (%%s) placeholders" % key)
return problems
func _init() -> void:
push_error("Strings is a static utility class; use Strings.lookup / Strings.fmt.")
+3 -1
View File
@@ -63,7 +63,9 @@ func _spot_for_intent(intent: String) -> String:
return "entrance"
"photo", "film", "perform", "wander", "roam":
return "sidewalk"
"sit":
"sit", "film_boundary":
# film_boundary: an influencer ignoring the posted filming line, so they
# stand at the steps instead of the permitted sidewalk spot.
return "steps_end"
_:
return "sidewalk"
+99
View File
@@ -8,6 +8,11 @@ extends Node
const SAMPLE_RATE := 22050.0
var _players: Array[AudioStreamPlayer] = []
var _bed_player: AudioStreamPlayer = null
## Observable state so headless tests and the HUD can see what was requested.
var crowd_level: float = 0.0
var bed_playing := false
var radio_crackles := 0
func _ready() -> void:
# Pre-create a few players so overlapping cues don't cut each other off.
@@ -30,6 +35,43 @@ func play_escalation(level: int) -> void:
var freq := 440.0 - float(level) * 40.0
_play_tone(maxf(freq, 180.0), 0.18, 0.6)
## Start the looping crowd bed (low murmur). Called once per shift; safe to call
## again. Volume then follows `set_crowd_level`.
func start_crowd_bed() -> void:
if _bed_player == null:
_bed_player = AudioStreamPlayer.new()
_bed_player.name = "CrowdBed"
add_child(_bed_player)
_bed_player.stream = _build_crowd_stream()
_bed_player.volume_db = _level_to_db(crowd_level)
_bed_player.play()
bed_playing = true
func stop_crowd_bed() -> void:
if _bed_player:
_bed_player.stop()
bed_playing = false
## Crowd mood (0..1) drives the bed volume: quiet when calm, louder when the
## street is agitated or panicking.
func set_crowd_level(mood: float) -> void:
crowd_level = clampf(mood, 0.0, 1.0)
if _bed_player:
_bed_player.volume_db = _level_to_db(crowd_level)
static func _level_to_db(mood: float) -> float:
return lerpf(-34.0, -12.0, clampf(mood, 0.0, 1.0))
## Radio open cue: a short burst of filtered crackle.
func play_radio_open() -> void:
radio_crackles += 1
_play_noise(0.09, 0.35)
## Radio close cue: a lower, shorter click.
func play_radio_close() -> void:
radio_crackles += 1
_play_noise(0.06, 0.25, 1400.0)
## Play a denial/error cue (low buzz).
func play_deny() -> void:
_play_tone(220.0, 0.20, 0.5)
@@ -53,7 +95,64 @@ func _play_tone(freq: float, duration: float, volume: float, delay: float = 0.0)
var sample := sin(TAU * freq * t) * 0.6
playback.push_frame(Vector2(sample, sample))
## Deterministic low murmur for the crowd bed: filtered noise plus a low drone.
func _build_crowd_stream() -> AudioStreamWAV:
const SECONDS := 4.0
var frames := int(SAMPLE_RATE * SECONDS)
var data := PackedByteArray()
data.resize(frames * 2)
var rng := RandomNumberGenerator.new()
rng.seed = 20261031
var smooth := 0.0
for i in frames:
var t := float(i) / SAMPLE_RATE
# Low-passed noise (a simple one-pole filter) sounds like distant talking.
smooth = lerpf(smooth, rng.randf_range(-1.0, 1.0), 0.02)
var drone := sin(TAU * 68.0 * t) * 0.25 + sin(TAU * 103.0 * t) * 0.12
var sample := (smooth * 2.2 + drone) * 0.35
var v := int(clampf(sample, -1.0, 1.0) * 32767.0)
data[i * 2] = v & 0xFF
data[i * 2 + 1] = (v >> 8) & 0xFF
var stream := AudioStreamWAV.new()
stream.format = AudioStreamWAV.FORMAT_16_BITS
stream.mix_rate = int(SAMPLE_RATE)
stream.stereo = false
stream.data = data
stream.loop_mode = AudioStreamWAV.LOOP_FORWARD
stream.loop_begin = 0
stream.loop_end = frames
return stream
## Short filtered noise burst used for the radio crackle.
func _play_noise(duration: float, volume: float, cutoff_hz: float = 3200.0) -> void:
var player := _free_player()
if player == null:
return
var frames := int(SAMPLE_RATE * duration)
var data := PackedByteArray()
data.resize(frames * 2)
var rng := RandomNumberGenerator.new()
rng.seed = 7
var smooth := 0.0
var alpha := clampf(cutoff_hz / SAMPLE_RATE, 0.01, 0.9)
for i in frames:
smooth = lerpf(smooth, rng.randf_range(-1.0, 1.0), alpha)
var env := 1.0 - float(i) / float(maxi(frames, 1))
var v := int(clampf(smooth * env, -1.0, 1.0) * 32767.0)
data[i * 2] = v & 0xFF
data[i * 2 + 1] = (v >> 8) & 0xFF
var stream := AudioStreamWAV.new()
stream.format = AudioStreamWAV.FORMAT_16_BITS
stream.mix_rate = int(SAMPLE_RATE)
stream.stereo = false
stream.data = data
player.stream = stream
player.volume_db = linear_to_db(volume)
player.play()
func _free_player() -> AudioStreamPlayer:
if _players.is_empty():
return null
for player in _players:
if not player.playing:
return player
+803 -66
View File
File diff suppressed because it is too large. Load diff
+155 -33
View File
@@ -1,35 +1,62 @@
class_name GuardController
extends CharacterBody3D
## First-person guard controller: WASD movement, mouse look, interact raycast.
## First-person guard: WASD + mouse look. Focus is a cone on NPC heads (ADR-0007, ADR-0008).
signal picked_target(target: Node)
signal cleared_target()
const NpcFocus := preload("res://src/game/npc_focus.gd")
var move_speed: float = 3.2
var look_sensitivity: float = 0.0022
var accel: float = 12.0
var is_ready := false
var targeted_npc: Node3D = null
var input_locked := false
var _miss_frames := 0
const MISS_GRACE := 8
## Loop `Shift` halves the walk speed for close work (inspection, a guest at the
## door). It is a precision modifier, not a sprint (PLAN.md keeps movement plain).
const SLOW_WALK_FACTOR := 0.45
## Aim-free cycling (Tab / mouse wheel) has to survive the focus cone: without a
## pin the cone would drop a target the player is not looking at within a few
## frames.
var target_pinned := false
## After the player clears the focus, the cone stays quiet briefly so the person
## they were looking at is not re-acquired on the very next frame.
var focus_suppress_s := 0.0
const FOCUS_SUPPRESS_S := 0.75
## Radius used by aim-free cycling.
const NEARBY_RADIUS := 12.0
@onready var camera: Camera3D = $Head/Camera3D
@onready var head: Node3D = $Head
func _ready() -> void:
floor_snap_length = 0.3
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
is_ready = true
func _unhandled_input(event: InputEvent) -> void:
if input_locked:
return
if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
rotate_y(-event.relative.x * look_sensitivity)
head.rotate_x(-event.relative.y * look_sensitivity)
head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0))
elif event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
else:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
func _physics_process(_delta: float) -> void:
# Movement relative to the head's yaw only.
if focus_suppress_s > 0.0:
focus_suppress_s = maxf(0.0, focus_suppress_s - _delta)
if input_locked:
velocity.x = 0.0
velocity.z = 0.0
if not is_on_floor():
velocity.y -= 9.8 * _delta
else:
velocity.y = 0.0
move_and_slide()
return
var forward := -global_transform.basis.z
forward.y = 0.0
forward = forward.normalized()
@@ -41,28 +68,65 @@ func _physics_process(_delta: float) -> void:
Input.get_axis("move_left", "move_right"),
Input.get_axis("move_forward", "move_back")
)
var direction := (forward * input_dir.y + right * input_dir.x).normalized()
velocity.x = direction.x * move_speed
velocity.z = direction.z * move_speed
velocity.y -= 9.8 * _delta
if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
input_dir = Vector2.ZERO
var desired := (forward * input_dir.y + right * input_dir.x)
if desired.length() > 1.0:
desired = desired.normalized()
var speed := move_speed
if Input.is_action_pressed("walk_slow"):
speed *= SLOW_WALK_FACTOR
desired *= speed
velocity.x = move_toward(velocity.x, desired.x, accel * _delta * speed)
velocity.z = move_toward(velocity.z, desired.z, accel * _delta * speed)
if is_on_floor():
velocity.y = 0.0
else:
velocity.y -= 9.8 * _delta
move_and_slide()
_update_target()
func _update_target() -> void:
var space := get_world_3d().direct_space_state
var from := camera.global_position
var to := from + -camera.global_transform.basis.z * 8.0
var query := PhysicsRayQueryParameters3D.create(from, to)
query.collide_with_areas = true
query.collide_with_bodies = true
query.exclude = [get_rid()]
var hit := space.intersect_ray(query)
if camera == null:
return
# Right after "clear target" the cone stays quiet for a moment, otherwise it
# would re-acquire the person the player just dismissed.
if focus_suppress_s > 0.0:
if targeted_npc != null:
targeted_npc = null
cleared_target.emit()
return
# A pinned target (chosen with Tab or the mouse wheel) is not subject to the
# cone until it is freed or the player clears it.
if target_pinned:
if targeted_npc != null and is_instance_valid(targeted_npc):
return
target_pinned = false
var npcs := get_tree().get_nodes_in_group("npc") if get_tree() else []
var heads: Array = []
var avatars: Array = []
for node in npcs:
if not (node is Node3D) or not is_instance_valid(node):
continue
var avatar := node as Node3D
var head_node := avatar.get_node_or_null("Rig/Head") as Node3D
var pos: Vector3 = head_node.global_position if head_node else avatar.global_position + Vector3(0, 1.6, 0)
heads.append(pos)
avatars.append(avatar)
var cam_origin := camera.global_position
var cam_forward := -camera.global_transform.basis.z
var idx := NpcFocus.pick_index(cam_origin, cam_forward, heads)
var new_target: Node3D = null
if hit:
var obj: Object = hit.get("collider")
var parent_avatar: Node3D = _find_avatar(obj)
if parent_avatar != null:
new_target = parent_avatar
if idx >= 0:
var candidate: Node3D = avatars[idx]
if _head_visible(cam_origin, heads[idx]):
new_target = candidate
if new_target == null and targeted_npc != null:
_miss_frames += 1
if _miss_frames < MISS_GRACE:
return
else:
_miss_frames = 0
if new_target != targeted_npc:
targeted_npc = new_target
if new_target != null:
@@ -70,14 +134,72 @@ func _update_target() -> void:
else:
cleared_target.emit()
func _find_avatar(obj: Object) -> Node3D:
# Avatars are CharacterBody3D with a collision shape; guard against self and scene junk.
func _head_visible(from: Vector3, to: Vector3) -> bool:
var space := get_world_3d().direct_space_state
if space == null:
return true
var query := PhysicsRayQueryParameters3D.create(from, to)
query.collide_with_areas = false
query.collide_with_bodies = true
query.exclude = [get_rid()]
var hit := space.intersect_ray(query)
if hit.is_empty():
return true
var obj: Object = hit.get("collider")
var n := obj as Node
if n == null:
while n != null:
if n.is_in_group("npc"):
return true
n = n.get_parent()
return false
## Avatars within cycling range, nearest first. Aim-free selection (Tab, mouse
## wheel) walks this list so the order is predictable: closest person next.
func nearby_candidates(max_range: float = NEARBY_RADIUS) -> Array:
if get_tree() == null:
return []
var here := global_position
var out: Array = []
for node in get_tree().get_nodes_in_group("npc"):
if not (node is Node3D) or not is_instance_valid(node):
continue
var avatar := node as Node3D
if (avatar.global_position - here).length() <= max_range:
out.append(avatar)
out.sort_custom(func(a: Node3D, b: Node3D) -> bool:
return (a.global_position - here).length_squared() < (b.global_position - here).length_squared())
return out
## Next target in the aim-free ring. `direction` is +1 forward, -1 backward.
## Returns null when nobody is in range (the caller logs "no one nearby").
func cycle_target(direction: int = 1) -> Node3D:
var list := nearby_candidates()
if list.is_empty():
target_pinned = false
return null
var current: Node = n
while current != null:
if current.has_method("get_npc_controller"):
return current as Node3D
current = current.get_parent()
return null
var index := list.find(targeted_npc)
var next_index := 0
if index >= 0:
next_index = posmod(index + direction, list.size())
elif direction < 0:
next_index = list.size() - 1
target_pinned = true
return list[next_index]
## Pin a target chosen outside the cone and announce it like a cone pick.
func pin_target(avatar: Node3D) -> void:
target_pinned = avatar != null
# An explicit pick overrides the post-clear suppression window.
focus_suppress_s = 0.0
if targeted_npc == avatar:
return
targeted_npc = avatar
if avatar != null:
picked_target.emit(avatar)
else:
cleared_target.emit()
## Drop the target and hand focus back to the focus cone.
func clear_target() -> void:
target_pinned = false
focus_suppress_s = FOCUS_SUPPRESS_S
+137
View File
@@ -0,0 +1,137 @@
class_name SurfaceMaterials
extends RefCounted
## Shared surface recipes for the procedural world (ADR-0012).
##
## Godot's `StandardMaterial3D` defaults are flat: every surface comes out with
## the same roughness and a plain albedo, which is what made the street read as
## grey boxes. These helpers give each surface a physically plausible profile
## (brick is rough, glass is smooth, metal is metallic) and keep the textures,
## UV scaling, and texture filtering in one place so the world builder, the NPC
## avatars, and the props all look like one scene.
const T_BRICK := preload("res://assets/textures/ai/game_ready/brick_facade.png")
const T_CONCRETE := preload("res://assets/textures/ai/game_ready/concrete.png")
const T_ASPHALT := preload("res://assets/textures/ai/game_ready/asphalt.png")
const T_AWNING := preload("res://assets/textures/ai/game_ready/awning.png")
## Roughness / metallic pairs per surface role. Exposed so tests and tooling can
## assert the profiles instead of hard-coding numbers twice.
const PROFILES := {
"brick": {"roughness": 0.88, "metallic": 0.0},
"concrete": {"roughness": 0.94, "metallic": 0.0},
"asphalt": {"roughness": 0.72, "metallic": 0.0},
"awning": {"roughness": 0.80, "metallic": 0.0},
"stone": {"roughness": 0.88, "metallic": 0.0},
"metal": {"roughness": 0.32, "metallic": 0.75},
"painted_metal": {"roughness": 0.48, "metallic": 0.35},
"wood": {"roughness": 0.70, "metallic": 0.0},
"cloth": {"roughness": 0.85, "metallic": 0.0},
"skin": {"roughness": 0.62, "metallic": 0.0},
"glass": {"roughness": 0.12, "metallic": 0.10},
"rubber": {"roughness": 0.95, "metallic": 0.0},
}
static func profile(role: String) -> Dictionary:
return PROFILES.get(role, PROFILES["concrete"])
## Base textured surface with a named profile.
static func textured(texture: Texture2D, tint: Color, uv_scale: Vector2,
role: String = "concrete") -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_texture = texture
mat.albedo_color = tint
mat.uv1_scale = Vector3(uv_scale.x, uv_scale.y, 1.0)
mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC
mat.roughness = float(profile(role).get("roughness", 0.9))
mat.metallic = float(profile(role).get("metallic", 0.0))
return mat
static func brick(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(10, 5)) -> StandardMaterial3D:
return textured(T_BRICK, tint, uv_scale, "brick")
static func concrete(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(9, 2.4)) -> StandardMaterial3D:
return textured(T_CONCRETE, tint, uv_scale, "concrete")
static func asphalt(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(30, 7)) -> StandardMaterial3D:
var mat := textured(T_ASPHALT, tint, uv_scale, "asphalt")
# Damp asphalt at night picks up the street lamps and the marquee.
mat.metallic_specular = 0.6
return mat
static func awning(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(4, 1)) -> StandardMaterial3D:
return textured(T_AWNING, tint, uv_scale, "awning")
## Flat-coloured surface with a profile (props, trim, limbs, clothing).
static func flat(color: Color, role: String = "concrete") -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = color
mat.roughness = float(profile(role).get("roughness", 0.9))
mat.metallic = float(profile(role).get("metallic", 0.0))
return mat
## Self-lit surface (marquee bars, lamp bulbs, lock indicator).
static func emissive(color: Color, energy: float = 1.4) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = color
mat.emission_enabled = true
mat.emission = color
mat.emission_energy_multiplier = energy
mat.roughness = 0.4
return mat
## Dark glass for windows and the entrance door. The alpha keeps the interior
## visible, and the low roughness gives the facade a highlight instead of a hole.
static func glass(color: Color = Color(0.42, 0.56, 0.72, 0.75)) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = color
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.roughness = float(profile("glass").get("roughness", 0.12))
mat.metallic = 0.15
mat.metallic_specular = 0.8
return mat
## Window pane that can be lit from the inside at night. Solid dark glass reads
## as a window at midday; alpha glass just looked like white paper on the brick.
static func window_pane() -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(0.09, 0.12, 0.17)
mat.roughness = 0.18
mat.metallic = 0.35
mat.metallic_specular = 0.6
mat.emission_enabled = true
mat.emission = Color(1.0, 0.86, 0.62)
mat.emission_energy_multiplier = 0.0
return mat
## Environment look shared by every phase: ACES tonemapping, soft glow, aerial
## fog for depth, and a mild contrast/saturation lift. Phase values come from
## `GameManager.PHASE_LIGHTING`.
static func configure_environment(env: Environment, cfg: Dictionary) -> void:
if env == null:
return
env.background_mode = Environment.BG_SKY
env.tonemap_mode = Environment.TONE_MAPPER_ACES
env.tonemap_exposure = float(cfg.get("exposure", 1.0))
env.tonemap_white = 6.0
env.ambient_light_energy = float(cfg.get("ambient_energy", 0.5))
env.reflected_light_source = Environment.REFLECTION_SOURCE_SKY
# Glow makes the marquee, lamp bulbs, and windows read as light sources.
env.glow_enabled = true
env.glow_normalized = true
env.glow_intensity = float(cfg.get("glow", 0.55))
env.glow_strength = 1.0
env.glow_bloom = 0.08
env.glow_hdr_threshold = 1.0
env.glow_blend_mode = Environment.GLOW_BLEND_MODE_SOFTLIGHT
# Aerial perspective: the skyline ring sits ~200 m out, so light fog gives the
# distance its haze without hiding the street.
env.fog_enabled = true
env.fog_light_color = cfg.get("fog_color", Color(0.72, 0.78, 0.88))
env.fog_light_energy = float(cfg.get("fog_energy", 0.7))
env.fog_density = float(cfg.get("fog_density", 0.0035))
env.fog_aerial_perspective = 0.25
env.fog_sky_affect = 0.4
env.adjustment_enabled = true
env.adjustment_brightness = 1.0
env.adjustment_contrast = float(cfg.get("contrast", 1.06))
env.adjustment_saturation = float(cfg.get("saturation", 1.08))
+376 -41
View File
@@ -5,6 +5,7 @@ extends CharacterBody3D
const NPCController := preload("res://src/core/npc_controller.gd")
const Rules := preload("res://src/core/rules.gd")
const Materials := preload("res://src/game/materials.gd")
var npc: NPCController = null
var spawn_position: Vector3 = Vector3.ZERO
@@ -12,6 +13,19 @@ var target_position: Vector3 = Vector3.ZERO
var door_position: Vector3 = Vector3(0, 0.5, 1.2)
var awaiting_interaction := false
var arrived := false
## Seconds idle at an arrived waypoint. Ordinary traffic lives for a while, then
## despawns so the entrance does not fill with standers (TODO: NPC crowd cap).
var idle_s := 0.0
var lifetime_s := 90.0
var ephemeral := true
## Crowd presentation (PLAN: Public Crowd Reaction). "watch" rubbernecks at the
## incident, "flee" clears away from it, "" is normal navigation.
var reaction_mode := ""
var reaction_point := Vector3.ZERO
var _reaction_timer := 0.0
const RUBBERNECK_S := 12.0
const FLEE_S := 20.0
const FLEE_SPEED := 2.4
var agent: NavigationAgent3D = null
## Set once the nav agent proves it has a path; direct movement is the fallback.
var _nav_usable := false
@@ -39,14 +53,17 @@ func _init() -> void:
func get_npc_controller() -> NPCController:
return npc
func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3, p_door: Vector3 = Vector3(0, 0.5, 1.2)) -> void:
npc.setup("npc_%d" % seed_value, profile, intent, seed_value)
func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3, p_door: Vector3 = Vector3(0, 0.5, 1.2), p_npc_id: String = "", p_override: Dictionary = {}) -> void:
var id := p_npc_id if p_npc_id != "" else "npc_%d" % seed_value
npc.setup(id, profile, intent, seed_value)
npc.access_override = p_override
_visual_seed = seed_value
spawn_position = p_spawn
target_position = p_target
door_position = p_door
position = p_spawn
npc.fsm.state_changed.connect(_on_state_changed)
add_to_group("npc")
_build_body(profile.npc_type)
_nav_usable = false
_nav_try_frames = 0
@@ -62,9 +79,10 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector
var collider := CollisionShape3D.new()
var shape := CapsuleShape3D.new()
shape.radius = 0.35
shape.height = 1.6
var height_scale: float = _rig.scale.y if _rig else 1.0
shape.height = 1.6 * height_scale
collider.shape = shape
collider.position = Vector3(0, 0.8, 0)
collider.position = Vector3(0, 0.8 * height_scale, 0)
add_child(collider)
var _visual_seed := 0
@@ -72,39 +90,162 @@ var _rig: Node3D = null
var _head: Node3D = null
var _bob_phase := 0.0
var _guard_ref: Node3D = null
var _focus_circle: MeshInstance3D = null
var _name_tag: Label3D = null
## Theater props and limbs for the idle poses (ADR-0010 rig).
var _photo_prop: MeshInstance3D = null
var _sign_prop: MeshInstance3D = null
var _legs: Array[MeshInstance3D] = []
var _arms: Array[MeshInstance3D] = []
var _theater_t := 0.0
var _photo_prop_base_y := 0.0
var _sign_prop_base_y := 0.0
const SKIN_TONE := Color(0.88, 0.72, 0.60)
const PANTS_COLOR := Color(0.22, 0.22, 0.26)
const HAIR_COLORS := [Color(0.12, 0.1, 0.08), Color(0.35, 0.22, 0.12), Color(0.55, 0.42, 0.28), Color(0.15, 0.15, 0.18)]
## Which small accessory a role wears. This is look-only: the player must still
## read intent from behaviour, never from the archetype name (ADR-0010).
const ACCESSORIES := {
Rules.NpcType.AUTHORIZED_STAFF: "cap",
Rules.NpcType.RESPONDER: "cap",
Rules.NpcType.DELIVERY: "cap",
Rules.NpcType.TOURIST: "backpack",
Rules.NpcType.INFLUENCER: "sunglasses",
Rules.NpcType.TRESPASSER: "hood",
Rules.NpcType.VIOLENT_THREAT: "hood",
Rules.NpcType.MENTAL_CRISIS: "hood",
Rules.NpcType.AUTHORIZED_GUEST: "lanyard",
Rules.NpcType.PERFORMER: "none",
Rules.NpcType.PROTESTER: "none",
Rules.NpcType.INTOXICATED: "none",
Rules.NpcType.CONFUSED_VISITOR: "none",
}
## Clothing palette for one avatar. Pure and seed-driven so crowds read as
## individuals and the result is deterministic (tests rely on that).
static func outfit_for(npc_type: int, seed_value: int) -> Dictionary:
var base: Color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
var frac := float((seed_value * 1103515245 + 12345) % 1000) / 1000.0
var shirt := base.lerp(Color(0.92, 0.92, 0.94), frac * 0.22)
shirt = shirt.darkened(frac * 0.12)
# Jeans through khaki/chino, so not every avatar wears the same trousers.
var pants := Color(0.16, 0.18, 0.23).lerp(Color(0.46, 0.38, 0.28), frac)
var hair_index := (seed_value * 7) % HAIR_COLORS.size()
return {
"shirt": shirt,
"sleeves": shirt.darkened(0.16),
"pants": pants,
"hair": HAIR_COLORS[hair_index],
"skin": SKIN_TONE.darkened(frac * 0.18),
"accessory": ACCESSORIES.get(npc_type, "none"),
"height_scale": 0.92 + 0.16 * frac,
"blend": frac,
}
func _build_body(npc_type: int) -> void:
_rig = Node3D.new()
_rig.name = "Rig"
add_child(_rig)
var base_color: Color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
# Per-seed variation: slight height and brightness differences so crowds
# read as individuals (deterministic LCG, no RNG side effects).
var frac := float((_visual_seed * 1103515245 + 12345) % 1000) / 1000.0
var height_scale := 0.92 + 0.16 * frac
_rig.scale.y = height_scale
var shader_delta := 0.12 * frac
base_color = base_color.lightened(shader_delta) if frac < 0.5 else base_color.darkened(shader_delta)
var limbs_color := base_color.darkened(0.25)
# Torso
_add_capsule(_rig, 0.30, 0.95, Vector3(0, 1.05, 0), base_color)
# Legs
_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), PANTS_COLOR)
_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), PANTS_COLOR)
# Arms
_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0)
_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0)
# Head
var outfit := outfit_for(npc_type, _visual_seed)
var shirt: Color = outfit["shirt"]
var sleeves: Color = outfit["sleeves"]
var pants: Color = outfit["pants"]
var skin: Color = outfit["skin"]
var hair: Color = outfit["hair"]
_rig.scale.y = float(outfit["height_scale"])
var limbs_color := sleeves
# Torso, with a shoulder block and hips so the silhouette is not a tube.
_add_capsule(_rig, 0.30, 0.90, Vector3(0, 1.02, 0), shirt, 0.0, "cloth")
_add_box_mesh(_rig, Vector3(0.58, 0.14, 0.28), Vector3(0, 1.36, 0), shirt, "cloth")
_add_box_mesh(_rig, Vector3(0.44, 0.18, 0.26), Vector3(0, 0.62, 0), pants, "cloth")
# Legs and arms are tracked so the walk cycle can swing them.
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), pants, 0.0, "cloth"))
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), pants, 0.0, "cloth"))
_arms.append(_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0, "cloth"))
_arms.append(_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0, "cloth"))
# Hands and shoes give the limbs an end point at conversation distance.
_add_sphere(_rig, 0.065, Vector3(-0.47, 0.94, 0.03), skin, "skin")
_add_sphere(_rig, 0.065, Vector3(0.47, 0.94, 0.03), skin, "skin")
# Neck, head, face, hair, shoes so the body reads as a person (ADR-0010).
_add_capsule(_rig, 0.08, 0.16, Vector3(0, 1.48, 0), skin, 0.0, "skin")
_head = Node3D.new()
_head.name = "Head"
_head.position = Vector3(0, 1.62, 0)
_add_sphere(_head, 0.17, Vector3.ZERO, SKIN_TONE)
_add_sphere(_head, 0.17, Vector3.ZERO, skin, "skin")
_add_sphere(_head, 0.18, Vector3(0, 0.08, -0.02), hair, "cloth")
_add_sphere(_head, 0.035, Vector3(-0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1), "glass")
_add_sphere(_head, 0.035, Vector3(0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1), "glass")
_rig.add_child(_head)
_add_box_mesh(_rig, Vector3(0.16, 0.08, 0.28), Vector3(-0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14), "rubber")
_add_box_mesh(_rig, Vector3(0.16, 0.08, 0.28), Vector3(0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14), "rubber")
_add_accessory(str(outfit["accessory"]), shirt)
_build_focus_circle()
_add_props(npc_type)
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> void:
## Small role accessory: cap, hood, backpack, sunglasses or a staff lanyard.
func _add_accessory(kind: String, shirt: Color) -> void:
match kind:
"cap":
_add_sphere(_head, 0.175, Vector3(0, 0.12, 0), shirt.darkened(0.15), "cloth")
_add_box_mesh(_head, Vector3(0.30, 0.03, 0.16), Vector3(0, 0.10, 0.16), shirt.darkened(0.22), "cloth")
"hood":
_add_sphere(_head, 0.205, Vector3(0, 0.03, -0.03), Color(0.16, 0.16, 0.18), "cloth")
"backpack":
_add_box_mesh(_rig, Vector3(0.34, 0.42, 0.16), Vector3(0, 1.06, -0.24), Color(0.24, 0.28, 0.36), "cloth")
_add_box_mesh(_rig, Vector3(0.30, 0.06, 0.02), Vector3(0, 1.24, -0.33), Color(0.42, 0.44, 0.5), "cloth")
"sunglasses":
_add_box_mesh(_head, Vector3(0.24, 0.05, 0.03), Vector3(0, 0.04, 0.15), Color(0.06, 0.06, 0.08), "glass")
"lanyard":
_add_box_mesh(_rig, Vector3(0.05, 0.06, 0.02), Vector3(0, 1.20, 0.16), Color(0.85, 0.78, 0.30), "painted_metal")
_add_box_mesh(_rig, Vector3(0.02, 0.20, 0.02), Vector3(-0.08, 1.34, 0.14), Color(0.3, 0.32, 0.36), "cloth")
_add_box_mesh(_rig, Vector3(0.02, 0.20, 0.02), Vector3(0.08, 1.34, 0.14), Color(0.3, 0.32, 0.36), "cloth")
_:
pass
func _build_focus_circle() -> void:
_focus_circle = MeshInstance3D.new()
_focus_circle.name = "FocusCircle"
# Blue, semi-transparent disc floating in front of the face (ADR-0008).
# Billboarded so it always reads as a circle on screen.
var disc := CylinderMesh.new()
disc.top_radius = 0.15
disc.bottom_radius = 0.15
disc.height = 0.001
disc.radial_segments = 24
_focus_circle.mesh = disc
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.albedo_color = Color(0.25, 0.55, 1.0, 0.45)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
_focus_circle.material_override = mat
_focus_circle.position = Vector3(0, 0, 0.30)
_focus_circle.visible = false
_head.add_child(_focus_circle)
_name_tag = Label3D.new()
_name_tag.name = "NameTag"
_name_tag.text = npc.profile.display_name if npc and npc.profile else ""
_name_tag.font_size = 48
_name_tag.pixel_size = 0.004
_name_tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
_name_tag.position = Vector3(0, 0.58, 0)
_name_tag.modulate = Color(0.95, 0.95, 0.9, 0.9)
_name_tag.visible = false
_head.add_child(_name_tag)
func set_focus_visual(focused: bool, menu_open: bool = false) -> void:
if _focus_circle:
_focus_circle.visible = focused
var mat := _focus_circle.material_override as StandardMaterial3D
if mat:
mat.albedo_color = Color(0.35, 0.65, 1.0, 0.7) if menu_open else Color(0.25, 0.55, 1.0, 0.45)
_focus_circle.scale = Vector3.ONE * (1.15 if menu_open else 1.0)
if _name_tag:
_name_tag.visible = focused
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3,
color: Color, z_rot: float = 0.0, role: String = "cloth") -> MeshInstance3D:
var mi := MeshInstance3D.new()
var mesh := CapsuleMesh.new()
mesh.radius = radius
@@ -112,30 +253,42 @@ func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, co
mi.mesh = mesh
mi.position = pos
mi.rotation_degrees.z = z_rot
mi.material_override = _material(color)
mi.material_override = _material(color, role)
parent.add_child(mi)
return mi
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color) -> void:
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color,
role: String = "skin") -> void:
var mi := MeshInstance3D.new()
var mesh := SphereMesh.new()
mesh.radius = radius
mesh.height = radius * 2.0
mi.mesh = mesh
mi.position = pos
mi.material_override = _material(color)
mi.material_override = _material(color, role)
parent.add_child(mi)
func _material(color: Color) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = color
return mat
func _add_box_mesh(parent: Node3D, size: Vector3, pos: Vector3, color: Color,
role: String = "cloth") -> MeshInstance3D:
var mi := MeshInstance3D.new()
var mesh := BoxMesh.new()
mesh.size = size
mi.mesh = mesh
mi.position = pos
mi.material_override = _material(color, role)
parent.add_child(mi)
return mi
func _material(color: Color, role: String = "cloth") -> StandardMaterial3D:
return Materials.flat(color, role)
## Small per-archetype props so silhouettes differ (camera, box, hat, sign…).
func _add_props(npc_type: int) -> void:
var chest_y := 1.25
match npc_type:
Rules.NpcType.INFLUENCER:
_add_box_prop(_rig, Vector3(0.16, 0.1, 0.09), Vector3(0.3, chest_y, 0.22), Color(0.1, 0.1, 0.1))
_photo_prop = _add_box_prop(_rig, Vector3(0.16, 0.1, 0.09), Vector3(0.3, chest_y, 0.22), Color(0.1, 0.1, 0.1))
_photo_prop_base_y = _photo_prop.position.y
_add_box_prop(_rig, Vector3(0.05, 0.05, 0.06), Vector3(0.34, chest_y + 0.06, 0.30), Color(0.4, 0.7, 1.0))
Rules.NpcType.DELIVERY:
_add_box_prop(_rig, Vector3(0.34, 0.26, 0.28), Vector3(0.0, 1.02, 0.3), Color(0.55, 0.4, 0.25))
@@ -150,15 +303,22 @@ func _add_props(npc_type: int) -> void:
cone.material_override = hat
cone.position = Vector3(0, 0.26, 0)
_head.add_child(cone)
# Hat in one hand and a small board in the other: the performer plays
# to the sidewalk once at the waypoint.
_add_box_prop(_rig, Vector3(0.06, 0.5, 0.06), Vector3(-0.42, 1.3, 0.14), Color(0.45, 0.35, 0.25))
_sign_prop = _add_box_prop(_rig, Vector3(0.42, 0.32, 0.03), Vector3(-0.46, 1.6, 0.14), Color(0.95, 0.9, 0.75))
_sign_prop_base_y = _sign_prop.position.y
Rules.NpcType.PROTESTER:
_add_box_prop(_rig, Vector3(0.05, 1.2, 0.05), Vector3(0.28, 1.45, 0.18), Color(0.4, 0.36, 0.32))
_add_box_prop(_rig, Vector3(0.5, 0.42, 0.04), Vector3(0.48, 1.55, 0.18), Color(0.93, 0.93, 0.95))
_sign_prop = _add_box_prop(_rig, Vector3(0.5, 0.42, 0.04), Vector3(0.48, 1.55, 0.18), Color(0.93, 0.93, 0.95))
_sign_prop_base_y = _sign_prop.position.y
Rules.NpcType.TOURIST:
_add_box_prop(_rig, Vector3(0.3, 0.36, 0.14), Vector3(-0.34, 1.15, -0.18), Color(0.25, 0.3, 0.45))
_photo_prop = _add_box_prop(_rig, Vector3(0.3, 0.36, 0.14), Vector3(-0.34, 1.15, -0.18), Color(0.25, 0.3, 0.45))
_photo_prop_base_y = _photo_prop.position.y
Rules.NpcType.CONFUSED_VISITOR:
_add_box_prop(_rig, Vector3(0.3, 0.05, 0.34), Vector3(0, 0.92, 0.06), Color(0.9, 0.88, 0.8))
func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) -> void:
func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) -> MeshInstance3D:
var mi := MeshInstance3D.new()
var mesh := BoxMesh.new()
mesh.size = size
@@ -166,27 +326,168 @@ func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) ->
mi.position = pos
mi.material_override = _material(color)
parent.add_child(mi)
return mi
## Rubberneck: idle people stop and turn toward the incident. Returns false when
## this NPC is too engaged (walking, being talked to, already leaving) to watch.
func start_rubberneck(at: Vector3) -> bool:
if not arrived or npc.leave_requested or leave_requested_by_lifetime:
return false
# Someone actually waiting at the entrance is being dealt with, not a bystander.
# Avatars live under the identity SceneRoot, so `position` matches world space
# and also works before the node enters the tree (tests).
if awaiting_interaction and (position - door_position).length() < 3.0:
return false
if reaction_mode == "flee":
return false
reaction_mode = "watch"
reaction_point = at
_reaction_timer = RUBBERNECK_S
velocity = Vector3.ZERO
return true
## Panic: clear away from the incident (usually the door) in a straight line.
func start_flee(from: Vector3) -> void:
if npc.leave_requested or leave_requested_by_lifetime:
return
reaction_mode = "flee"
reaction_point = from
_reaction_timer = FLEE_S
var away := global_position - from
away.y = 0.0
if away.length() < 0.5:
away = Vector3(0.0, 0.0, 1.0)
target_position = global_position + away.normalized() * 25.0
target_position.y = 0.5
arrived = false
awaiting_interaction = false
func reaction_is_active() -> bool:
return reaction_mode != ""
func _tick_reaction(delta: float) -> void:
if reaction_mode == "":
return
_reaction_timer -= delta
if reaction_mode == "flee":
if _reaction_timer <= 0.0 or (global_position - target_position).length() < 1.5:
reaction_mode = ""
queue_free()
return
if _reaction_timer <= 0.0:
reaction_mode = ""
## Turn the body toward the reaction point without moving.
func _face_reaction_point(delta: float) -> void:
var to_point := reaction_point - global_position
to_point.y = 0.0
if to_point.length() < 0.1:
return
var target_yaw := atan2(-to_point.x, -to_point.z)
rotation.y = lerp_angle(rotation.y, target_yaw, clampf(delta * 4.0, 0.0, 1.0))
func _physics_process(delta: float) -> void:
if npc == null or npc.fsm == null:
return
_advance_fsm(delta)
_tick_reaction(delta)
if reaction_mode == "watch":
velocity = Vector3.ZERO
_face_reaction_point(delta)
_animate(delta)
return
if reaction_mode == "flee":
_move_toward_target(delta)
_animate(delta)
return
_move_toward_target(delta)
_animate(delta)
## Procedural walk bob + head-toward-guard cue when the NPC is aware.
## Theater pose for the current intent once the NPC has stopped walking.
## "sit" at the steps, "photo" for camera props, "perform" for the hat/sign,
## "film" for a slow pan, otherwise "idle".
func theater_pose() -> String:
if not arrived:
return "idle"
match npc.intent:
"sit":
return "sit"
"photo":
return "photo"
"film":
return "film"
"perform":
return "perform"
_:
return "idle"
## How far the rig drops for a seated pose. Pure so it is headless-testable.
static func crouch_for_pose(pose: String) -> float:
return -0.34 if pose == "sit" else 0.0
## Vertical lift of a held camera prop. `phase` is seconds; the raise is brief so
## it reads as taking a photo rather than a permanently raised arm.
static func prop_lift_for_pose(pose: String, phase: float) -> float:
if pose != "photo" and pose != "film":
return 0.0
return maxf(0.0, sin(phase * 1.7)) * 0.16
## Opposite arm/leg swing for the walk cycle. `side` is 0 or 1; pure so the gait
## can be checked without a scene tree.
static func limb_swing(phase: float, side: int, amplitude: float) -> float:
return sin(phase + float(side) * PI) * amplitude
## Sway amount for a performer playing to the sidewalk.
static func sway_for_pose(pose: String, phase: float) -> float:
if pose != "perform":
return 0.0
return sin(phase * 2.2) * 0.09
func _animate(delta: float) -> void:
if _rig == null:
return
var speed := Vector2(velocity.x, velocity.z).length()
var pose := theater_pose()
var crouch := crouch_for_pose(pose)
if speed > 0.3:
_bob_phase += delta * (6.0 + speed)
_rig.position.y = sin(_bob_phase) * 0.035
_rig.rotation.x = sin(_bob_phase * 0.5) * 0.04
for i in _legs.size():
_legs[i].rotation.x = limb_swing(_bob_phase, i, 0.5)
for i in _arms.size():
_arms[i].rotation.x = limb_swing(_bob_phase, i, -0.38)
elif pose != "idle" or not is_zero_approx(_rig.position.y):
# Idle theater: seated, photographing, filming, or performing.
_theater_t += delta
_bob_phase = 0.0
_rig.position.y = lerpf(_rig.position.y, crouch, 0.12)
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
var lift := prop_lift_for_pose(pose, _theater_t)
if _photo_prop:
_photo_prop.position.y = _photo_prop_base_y + lift
_photo_prop.rotation.x = -lift * 1.4
var sway := sway_for_pose(pose, _theater_t)
if _sign_prop:
_sign_prop.rotation.z = sway
_sign_prop.position.y = _sign_prop_base_y + absf(sway) * 0.25
if _legs.size() == 2:
# Negative pitch swings the legs forward (toward the way the body faces).
var leg_angle := deg_to_rad(-78.0) if pose == "sit" else 0.0
_legs[0].rotation.x = lerpf(_legs[0].rotation.x, leg_angle, 0.12)
_legs[1].rotation.x = lerpf(_legs[1].rotation.x, leg_angle, 0.12)
for arm in _arms:
arm.rotation.x = lerpf(arm.rotation.x, 0.0, 0.14)
else:
_bob_phase = 0.0
_rig.position.y = lerpf(_rig.position.y, 0.0, 0.2)
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
for i in _legs.size():
_legs[i].rotation.x = lerpf(_legs[i].rotation.x, 0.0, 0.2)
for arm in _arms:
arm.rotation.x = lerpf(arm.rotation.x, 0.0, 0.2)
# The focus circle billboards via its material; no per-frame look_at needed.
# Attention cue: head turns toward the guard when awareness was raised.
if _head != null and npc.guard_noticed:
if _guard_ref == null or not is_instance_valid(_guard_ref):
@@ -200,25 +501,40 @@ func _animate(delta: float) -> void:
_head.rotation.y = lerp_angle(_head.rotation.y, world_yaw - parent_yaw, 0.1)
func _advance_fsm(delta: float) -> void:
# Routine traffic despawns after a while once it has finished its walk, so
# the crowd cap is not permanently occupied by idle standers.
if ephemeral and arrived and npc.fsm.current_state != "LEAVING":
idle_s += delta
if idle_s >= lifetime_s:
leave_requested_by_lifetime = true
return
# Returning-trespasser: an NPC directed away may circle back to the door.
if npc.tick_return(delta):
arrived = false
awaiting_interaction = false
target_position = door_position
return
if npc.leave_requested:
if npc.leave_requested or leave_requested_by_lifetime:
var away := global_position + (global_position - spawn_position).normalized() * 40.0
away.y = 0.5
target_position = away
if (global_position - away).length() < 2.0:
queue_free()
return
if arrived and not awaiting_interaction:
awaiting_interaction = true
var leave_requested_by_lifetime := false
func _move_toward_target(delta: float) -> void:
if agent == null:
_move_direct(delta)
return
agent.target_position = target_position
# Re-assigning the target every physics frame invalidates the path, so
# get_next_path_position() keeps returning the current position and the
# avatar never moves. Only update the agent when the target changes.
if agent.target_position != target_position:
agent.target_position = target_position
if agent.is_navigation_finished():
if (global_position - target_position).length() < 3.0:
_arrive()
@@ -235,14 +551,32 @@ func _move_toward_target(delta: float) -> void:
_move_direct(delta)
return
var next := agent.get_next_path_position()
# Godot reports the current position as the first path point (on the navmesh
# plane, so below the body) and only advances past it once the agent is within
# path_desired_distance. Steering at the point we are standing on gives a
# zero-length direction, and steering at any point merely "far enough" away can
# pick the old start behind us. Follow the first path point that is genuinely
# closer to the destination than we are.
var path := agent.get_current_navigation_path()
if path.size() >= 2:
var goal: Vector3 = path[path.size() - 1]
var here_to_goal := Vector2(goal.x - global_position.x, goal.z - global_position.z).length()
for i in range(1, path.size()):
var candidate: Vector3 = path[i]
candidate.y = global_position.y
var candidate_to_goal := Vector2(goal.x - candidate.x, goal.z - candidate.z).length()
if candidate_to_goal < here_to_goal - 0.05:
next = candidate
break
var dir := (next - global_position)
dir.y = 0.0
if dir.length() < 0.2:
velocity = Vector3.ZERO
return
dir = dir.normalized()
velocity.x = dir.x * 1.3
velocity.z = dir.z * 1.3
var speed := FLEE_SPEED if reaction_mode == "flee" else 1.3
velocity.x = dir.x * speed
velocity.z = dir.z * speed
velocity.y -= 9.8 * delta
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
move_and_slide()
@@ -256,8 +590,9 @@ func _move_direct(delta: float) -> void:
_arrive()
return
var dir := to_target.normalized()
velocity.x = dir.x * 1.3
velocity.z = dir.z * 1.3
var speed := FLEE_SPEED if reaction_mode == "flee" else 1.3
velocity.x = dir.x * speed
velocity.z = dir.z * speed
velocity.y -= 9.8 * delta
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
move_and_slide()
+45
View File
@@ -0,0 +1,45 @@
extends RefCounted
## World-space focus cone (ADR-0008). Picks the head closest to the camera
## forward vector inside range and angle. No viewport needed, so tests run headless.
const MAX_RANGE := 10.0
const MAX_ANGLE_RAD := 0.175 # ~10 degrees
static func score_head(cam_origin: Vector3, cam_forward: Vector3, head_pos: Vector3, max_range: float = MAX_RANGE, max_angle: float = MAX_ANGLE_RAD) -> float:
var to := head_pos - cam_origin
var dist := to.length()
if dist < 0.05 or dist > max_range:
return -1.0
var dir := to / dist
var dot := cam_forward.dot(dir)
if dot <= 0.0:
return -1.0
var ang := acos(clampf(dot, -1.0, 1.0))
if ang > max_angle:
return -1.0
return ang
static func pick_index(cam_origin: Vector3, cam_forward: Vector3, head_positions: Array, max_range: float = MAX_RANGE, max_angle: float = MAX_ANGLE_RAD) -> int:
var ranked := rank_indices(cam_origin, cam_forward, head_positions, max_range, max_angle)
return ranked[0] if not ranked.is_empty() else -1
## Every candidate inside the cone, best first: smallest angle to the crosshair,
## then nearest. The cone used to return position in the group, which made the
## pick order depend on spawn order; ranking by angle matches what the player
## sees and gives `Tab` / the mouse wheel a stable order too.
static func rank_indices(cam_origin: Vector3, cam_forward: Vector3, head_positions: Array, max_range: float = MAX_RANGE, max_angle: float = MAX_ANGLE_RAD) -> Array:
var scored: Array = []
for i in head_positions.size():
var pos: Vector3 = head_positions[i]
var score := score_head(cam_origin, cam_forward, pos, max_range, max_angle)
if score < 0.0:
continue
scored.append([score, (pos - cam_origin).length(), i])
scored.sort_custom(func(a: Array, b: Array) -> bool:
if not is_equal_approx(float(a[0]), float(b[0])):
return float(a[0]) < float(b[0])
return float(a[1]) < float(b[1]))
var ranked: Array = []
for entry in scored:
ranked.append(int(entry[2]))
return ranked
+182
View File
@@ -0,0 +1,182 @@
extends CanvasLayer
## Doughnut action menu (ADR-0009). Open on LMB/Space, confirm click/Space,
## cancel Esc/RMB. Emergency is not a slice.
signal confirmed(slice_id: String)
signal cancelled()
const Rules := preload("res://src/core/rules.gd")
const Strings := preload("res://src/core/strings.gd")
const SLICE_DEFS := [
{"id": "raise_hand", "label_key": "radial.raise_hand", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "raise hand"},
{"id": "eye_contact", "label_key": "radial.eye_contact", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "eye contact"},
{"id": "point", "label_key": "radial.point", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "point"},
{"id": "head_shake", "label_key": "radial.head_shake", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "head shake"},
{"id": "polite", "label_key": "radial.polite", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "polite direction"},
{"id": "inspect", "label_key": "radial.inspect", "level": -1, "action": "inspect"},
{"id": "wand", "label_key": "radial.wand", "level": -1, "action": "wand"},
{"id": "warning", "label_key": "radial.warning", "level": Rules.Level.FORMAL_WARNING, "action": "formal warning"},
{"id": "block", "label_key": "radial.block", "level": Rules.Level.BLOCKING, "action": "blocking"},
{"id": "reason", "label_key": "radial.reason", "level": Rules.Level.BLOCKING, "action": "request reason"},
{"id": "recognize", "label_key": "radial.recognize", "level": -1, "action": "recognize"},
{"id": "radio", "label_key": "radial.radio", "level": -1, "action": "radio assist"},
]
var selected := 0
var wand_available := false
var person_name := ""
var is_open := false
var _opened_ms := 0
var _wheel: Control = null
var _center_label: Label = null
func _init() -> void:
name = "ActionRadial"
layer = 25
var wheel := Control.new()
wheel.name = "Wheel"
wheel.set_anchors_preset(Control.PRESET_FULL_RECT)
wheel.mouse_filter = Control.MOUSE_FILTER_STOP
wheel.draw.connect(_on_wheel_draw)
wheel.gui_input.connect(_on_wheel_input)
add_child(wheel)
_wheel = wheel
_center_label = Label.new()
_center_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_center_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_center_label.set_anchors_preset(Control.PRESET_CENTER)
_center_label.offset_left = -120
_center_label.offset_right = 120
_center_label.offset_top = -24
_center_label.offset_bottom = 24
_center_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_center_label)
hide()
func slice_count() -> int:
return SLICE_DEFS.size()
func slice_id(index: int) -> String:
if index < 0 or index >= SLICE_DEFS.size():
return ""
return str(SLICE_DEFS[index]["id"])
func slice_def(index: int) -> Dictionary:
if index < 0 or index >= SLICE_DEFS.size():
return {}
return SLICE_DEFS[index]
func is_slice_enabled(index: int) -> bool:
if slice_id(index) == "wand":
return wand_available
return true
func open_menu(p_name: String, p_wand: bool) -> void:
person_name = p_name
wand_available = p_wand
selected = 0
is_open = true
_opened_ms = Time.get_ticks_msec()
_center_label.text = person_name
show()
_wheel.queue_redraw()
func close_menu() -> void:
is_open = false
hide()
func step(direction: int) -> void:
if not is_open or SLICE_DEFS.is_empty():
return
var n := SLICE_DEFS.size()
var next := selected
for i in n:
next = (next + direction) % n
if next < 0:
next += n
if is_slice_enabled(next):
selected = next
_wheel.queue_redraw()
return
func confirm_selected() -> String:
if not is_open:
return ""
if not is_slice_enabled(selected):
return ""
return slice_id(selected)
func angle_to_index(angle: float) -> int:
# angle from atan2, 0 = +x, we want 0 = top (-PI/2).
var n := float(SLICE_DEFS.size())
var shifted := angle + PI / 2.0
while shifted < 0.0:
shifted += TAU
while shifted >= TAU:
shifted -= TAU
var idx := int(floor(shifted / TAU * n)) % SLICE_DEFS.size()
return idx
func _on_wheel_input(event: InputEvent) -> void:
if not is_open:
return
if event is InputEventMouseMotion:
var idx := _index_at_mouse(event.position)
if idx >= 0 and is_slice_enabled(idx):
selected = idx
_wheel.queue_redraw()
elif event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_LEFT:
if Time.get_ticks_msec() - _opened_ms < 120:
return
var idx := _index_at_mouse(event.position)
if idx >= 0 and is_slice_enabled(idx):
selected = idx
confirmed.emit(slice_id(selected))
elif event.button_index == MOUSE_BUTTON_RIGHT:
cancelled.emit()
func _index_at_mouse(local_pos: Vector2) -> int:
var center := _wheel.size * 0.5
var delta := local_pos - center
if delta.length() < 36.0:
return -1
return angle_to_index(atan2(delta.y, delta.x))
func _on_wheel_draw() -> void:
if not is_open:
return
var center := _wheel.size * 0.5
var n := SLICE_DEFS.size()
var outer := 174.0
var inner := 64.0
for i in n:
var a0 := -PI / 2.0 + TAU * float(i) / float(n)
var a1 := -PI / 2.0 + TAU * float(i + 1) / float(n)
var col := Color(0.12, 0.14, 0.18, 0.82)
if i == selected:
col = Color(0.92, 0.78, 0.28, 0.9)
if not is_slice_enabled(i):
col = Color(0.2, 0.2, 0.22, 0.45)
_draw_slice(center, inner, outer, a0, a1, col)
var mid := (a0 + a1) * 0.5
var label_pos := center + Vector2(cos(mid), sin(mid)) * ((inner + outer) * 0.5)
var text := Strings.lookup(str(SLICE_DEFS[i]["label_key"]))
# With 12 slices the label box is tight: center it on the slice midpoint.
var font := ThemeDB.fallback_font
var text_size := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, 12)
_wheel.draw_string(font, label_pos - Vector2(text_size.x * 0.5, -4), text, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, Color.WHITE)
func _draw_slice(center: Vector2, inner: float, outer: float, a0: float, a1: float, color: Color) -> void:
var pts := PackedVector2Array()
var steps := 8
for s in steps + 1:
var t := float(s) / float(steps)
var ang := lerpf(a0, a1, t)
pts.append(center + Vector2(cos(ang), sin(ang)) * outer)
for s in steps + 1:
var t := float(steps - s) / float(steps)
var ang := lerpf(a0, a1, t)
pts.append(center + Vector2(cos(ang), sin(ang)) * inner)
_wheel.draw_colored_polygon(pts, color)
+211
View File
@@ -0,0 +1,211 @@
class_name EventTimeline
extends CanvasLayer
## Body-camera timeline for the shift review (PLAN.md: Evidence and Body Camera).
## First version is a text timeline of the structured incident events ADR-0005
## already stores: step through them with the arrow keys or the buttons. No live
## 3D replay.
signal closed()
const Strings := preload("res://src/core/strings.gd")
var events: Array[Dictionary] = []
var index := 0
var _list_label: Label = null
var _detail_label: Label = null
var _counter_label: Label = null
var _root: Control = null
func _init() -> void:
name = "EventTimeline"
layer = 32
_build()
hide()
func _build() -> void:
_root = Control.new()
_root.name = "TimelineRoot"
_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.9)
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(760, 0)
column.add_theme_constant_override("separation", 8)
center.add_child(column)
var title := Label.new()
title.text = Strings.lookup("timeline.title")
title.add_theme_font_size_override("font_size", 26)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
column.add_child(title)
_counter_label = Label.new()
_counter_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_counter_label.add_theme_color_override("font_color", Color(0.72, 0.78, 0.9))
column.add_child(_counter_label)
_detail_label = Label.new()
_detail_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_detail_label.custom_minimum_size = Vector2(760, 48)
_detail_label.add_theme_font_size_override("font_size", 18)
column.add_child(_detail_label)
_list_label = Label.new()
_list_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
_list_label.autowrap_mode = TextServer.AUTOWRAP_OFF
_list_label.custom_minimum_size = Vector2(760, 0)
_list_label.add_theme_font_size_override("font_size", 13)
_list_label.add_theme_color_override("font_color", Color(0.8, 0.84, 0.9))
column.add_child(_list_label)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 10)
column.add_child(row)
var prev := Button.new()
prev.name = "PrevButton"
prev.text = Strings.lookup("timeline.prev")
prev.pressed.connect(func(): step(-1))
row.add_child(prev)
var next := Button.new()
next.name = "NextButton"
next.text = Strings.lookup("timeline.next")
next.pressed.connect(func(): step(1))
row.add_child(next)
var close := Button.new()
close.name = "CloseButton"
close.text = Strings.lookup("timeline.close")
close.pressed.connect(_on_close)
row.add_child(close)
var hint := Label.new()
hint.text = Strings.lookup("timeline.hint")
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
hint.add_theme_color_override("font_color", Color(0.55, 0.58, 0.65))
column.add_child(hint)
## Flatten every incident's event stream into one time-ordered list.
func flatten(review: Dictionary) -> Array[Dictionary]:
var out: Array[Dictionary] = []
var incidents: Dictionary = review.get("incidents", {})
var list: Array = incidents.get("incidents", [])
for incident in list:
var incident_id := str(incident.get("id", ""))
var npc_id := str(incident.get("npc_id", ""))
for event in incident.get("events", []):
var row: Dictionary = (event as Dictionary).duplicate()
row["incident_id"] = incident_id
row["npc_id"] = npc_id
out.append(row)
out.sort_custom(func(a, b): return int(a.get("at_ms", 0)) < int(b.get("at_ms", 0)))
return out
func show_timeline(review: Dictionary) -> void:
events = flatten(review)
index = 0
visible = true
_root.visible = true
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
_refresh()
func step(direction: int) -> void:
if events.is_empty():
return
index = clampi(index + direction, 0, events.size() - 1)
_refresh()
func close() -> void:
visible = false
_root.visible = false
closed.emit()
func _on_close() -> void:
close()
func _refresh() -> void:
if events.is_empty():
_counter_label.text = Strings.lookup("timeline.empty")
_detail_label.text = ""
_list_label.text = ""
return
var event: Dictionary = events[index]
_counter_label.text = Strings.fmt("timeline.position", [index + 1, events.size()])
_detail_label.text = describe(event)
var lines: Array[String] = []
var from := maxi(0, index - 6)
var to := mini(events.size(), from + 13)
for i in range(from, to):
var marker := ">" if i == index else " "
lines.append("%s %s" % [marker, describe(events[i])])
_list_label.text = "\n".join(lines)
## One-line summary of a recorded event. Public so it is headless-testable.
func describe(event: Dictionary) -> String:
var type := str(event.get("type", "event"))
var npc := str(event.get("npc_id", ""))
var stamp := _stamp(int(event.get("at_ms", 0)))
var detail := ""
match type:
"player_action":
detail = "%s (L%d)" % [str(event.get("action", "")), int(event.get("level", 0))]
"access_check":
var evidence: Dictionary = event.get("evidence", {})
detail = "decided %s (verified %s)" % [
"allow" if bool(evidence.get("decided", false)) else "deny",
"yes" if bool(evidence.get("verified_granted", false)) else "no",
]
"recognition_decision":
detail = "recognized"
"reason_requested":
var reply := str(event.get("reply", ""))
detail = "reason: \"%s\"" % (reply if reply != "" else "no answer")
"body_camera":
detail = "body camera %s (%s)" % [
"on" if bool(event.get("on", false)) else "off",
str(event.get("reason", "")),
]
"responder_action":
detail = "%s response: %s" % [str(event.get("call_type", "assist")), str(event.get("outcome", ""))]
"evacuation":
detail = "evacuated"
"investigation_opened":
detail = "investigation: %s" % str(event.get("reason", ""))
_:
detail = type
return "%s %s %s" % [stamp, npc, detail]
func _stamp(at_ms: int) -> String:
if at_ms <= 0:
return "--:--"
var total := at_ms / 1000
return "%02d:%02d" % [total / 60, total % 60]
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
if event is InputEventKey and event.pressed and not event.echo:
match event.keycode:
KEY_LEFT, KEY_A:
step(-1)
get_viewport().set_input_as_handled()
KEY_RIGHT, KEY_D:
step(1)
get_viewport().set_input_as_handled()
KEY_ESCAPE:
close()
get_viewport().set_input_as_handled()
+78 -14
View File
@@ -5,16 +5,16 @@ extends CanvasLayer
const Rules := preload("res://src/core/rules.gd")
const Strings := preload("res://src/core/strings.gd")
const T_VIGNETTE := preload("res://assets/textures/vignette.png")
const T_ICON_FLASH := preload("res://assets/textures/icons/flashlight_on.png")
const T_ICON_DOOR := preload("res://assets/textures/icons/door_locked.png")
const T_ICON_WAND := preload("res://assets/textures/icons/wand.png")
const T_ICON_REC := preload("res://assets/textures/icons/rec_dot.png")
const T_CARD_ID := preload("res://assets/textures/cards/id_card.png")
const T_CARD_WRIST := preload("res://assets/textures/cards/wristband.png")
const T_CARD_TICKET := preload("res://assets/textures/cards/ticket.png")
const T_CARD_CRED := preload("res://assets/textures/cards/credential.png")
const T_CARD_GUEST := preload("res://assets/textures/cards/guest_list.png")
const T_VIGNETTE := preload("res://assets/textures/ai/game_ready/vignette.png")
const T_ICON_FLASH := preload("res://assets/textures/ai/game_ready/icons/flashlight_on.png")
const T_ICON_DOOR := preload("res://assets/textures/ai/game_ready/icons/door_locked.png")
const T_ICON_WAND := preload("res://assets/textures/ai/game_ready/icons/wand.png")
const T_ICON_REC := preload("res://assets/textures/ai/game_ready/icons/rec_dot.png")
const T_CARD_ID := preload("res://assets/textures/ai/game_ready/cards/id_card.png")
const T_CARD_WRIST := preload("res://assets/textures/ai/game_ready/cards/wristband.png")
const T_CARD_TICKET := preload("res://assets/textures/ai/game_ready/cards/ticket.png")
const T_CARD_CRED := preload("res://assets/textures/ai/game_ready/cards/credential.png")
const T_CARD_GUEST := preload("res://assets/textures/ai/game_ready/cards/guest_list.png")
const CARD_TEXTURES := {
Rules.AccessKind.ID: T_CARD_ID,
Rules.AccessKind.WRISTBAND: T_CARD_WRIST,
@@ -26,6 +26,7 @@ const CARD_TEXTURES := {
var top_label: Label
var time_label: Label
var target_label: Label
var nearby_label: Label
var hint_label: Label
var log_label: Label
var debug_label: Label
@@ -47,6 +48,10 @@ var escalation_label: Label
var _debug_enabled := false
var _log_lines: Array[String] = []
var _cam_overlay: Control = null
var _body_camera_on := false
var _font_scale := 1.0
var _base_font_sizes: Dictionary = {}
func _init() -> void:
name = "HUD"
@@ -81,6 +86,15 @@ func _init() -> void:
target_label.add_theme_font_size_override("font_size", 16)
vbox.add_child(target_label)
nearby_label = Label.new()
nearby_label.add_theme_font_size_override("font_size", 12)
nearby_label.modulate = Color(0.92, 0.95, 1.0)
# A shadow keeps the hint readable over the bright sidewalk and the sky.
nearby_label.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.7))
nearby_label.add_theme_constant_override("shadow_offset_x", 1)
nearby_label.add_theme_constant_override("shadow_offset_y", 1)
vbox.add_child(nearby_label)
hint_label = Label.new()
hint_label.modulate = Color(1, 0.9, 0.6)
hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
@@ -138,11 +152,19 @@ func _init() -> void:
add_child(vignette_rect)
# --- body-camera overlay (REC + shift clock + corner brackets, top right) ---
# Toggled by the body-camera flag (PLAN Level 2); off until the guard activates
# it or issues a formal warning.
_cam_overlay = Control.new()
_cam_overlay.name = "BodyCameraOverlay"
_cam_overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
_cam_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
_cam_overlay.visible = false
add_child(_cam_overlay)
var cam_box := VBoxContainer.new()
cam_box.set_anchors_preset(Control.PRESET_TOP_RIGHT)
cam_box.position = Vector2(-180, 50)
cam_box.alignment = BoxContainer.ALIGNMENT_END
add_child(cam_box)
_cam_overlay.add_child(cam_box)
var rec_row := HBoxContainer.new()
rec_row.alignment = BoxContainer.ALIGNMENT_END
cam_box.add_child(rec_row)
@@ -258,6 +280,12 @@ func _update_radio_cooldown(coordinator: Object) -> void:
func set_target_label(text: String) -> void:
target_label.text = text
## How many people the aim-free ring (Tab / mouse wheel) can cycle through.
func set_nearby_hint(count: int) -> void:
if nearby_label == null:
return
nearby_label.text = Strings.fmt("hud.nearby", [count]) if count > 0 else ""
var _flashlight := false
var _door_locked := false
@@ -279,6 +307,32 @@ func set_wand_icon(visible: bool) -> void:
if wand_icon:
wand_icon.visible = visible
## Show or hide the body-camera overlay (REC + clock + brackets). The flag is a
## record of the interaction (PLAN Level 2), not a permanent HUD fixture.
func set_body_camera(on: bool) -> void:
_body_camera_on = on
if _cam_overlay:
_cam_overlay.visible = on
func is_body_camera_on() -> bool:
return _body_camera_on
## Scale the whole HUD text block (accessibility settings).
## Resize HUD text by changing font sizes (not Control.scale, which overflows
## anchored labels and breaks the right-aligned layout).
func set_font_scale(scale: float) -> void:
var clamped := clampf(scale, 0.75, 1.75)
_font_scale = clamped
var scaled: Array[Label] = [top_label, log_label, target_label, hint_label,
nearby_label, time_label, mode_label, escalation_label, radio_cooldown_label, debug_label]
for label in scaled:
if label == null:
continue
if not _base_font_sizes.has(label):
_base_font_sizes[label] = label.get_theme_font_size("font_size")
var base: int = int(_base_font_sizes[label])
label.add_theme_font_size_override("font_size", maxi(8, int(round(float(base) * clamped))))
func _make_icon(texture: Texture2D) -> TextureRect:
var tr := TextureRect.new()
tr.texture = texture
@@ -296,6 +350,7 @@ func _add_corner_brackets(color: Color) -> void:
var corners := [
Vector2(OFF, OFF), Vector2(vp.x - OFF, OFF), Vector2(OFF, vp.y - OFF), Vector2(vp.x - OFF, vp.y - OFF),
]
var parent: Node = _cam_overlay if _cam_overlay != null else self
for i in 4:
var c: Vector2 = corners[i]
var flip_x: bool = (i % 2) == 1
@@ -305,13 +360,13 @@ func _add_corner_brackets(color: Color) -> void:
h.size = Vector2(LEN, TH)
h.position = Vector2(c.x - (LEN if flip_x else 0.0), c.y - (TH if flip_y else 0.0))
h.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(h)
parent.add_child(h)
var v := ColorRect.new()
v.color = color
v.size = Vector2(TH, LEN)
v.position = Vector2(c.x - (TH if flip_x else 0.0), c.y - (LEN if flip_y else 0.0))
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(v)
parent.add_child(v)
func set_action_hint(text: String) -> void:
hint_label.text = text
@@ -320,9 +375,18 @@ func set_action_hint(text: String) -> void:
## Color-coded escalation feedback (PLAN.md: Polish and Accessibility).
## Green → yellow → orange → red as the escalation level rises.
## Grayscale-safe escalation meter: filled/empty blocks plus the level number, so
## the cue does not rely on hue alone (accessibility).
static func escalation_meter(level: int) -> String:
var clamped := clampi(level, 0, 5)
var filled := ""
for i in 5:
filled += "#" if i < clamped else "-"
return "[%s] L%d/5" % [filled, clamped]
func set_escalation(level: int) -> void:
var name: String = Rules.LEVEL_NAMES.get(level, "Observation")
escalation_label.text = Strings.fmt("hud.escalation_prefix", [name])
escalation_label.text = "%s %s" % [Strings.fmt("hud.escalation_prefix", [name]), escalation_meter(level)]
var tint := Color(0.6, 0.9, 0.6)
match level:
Rules.Level.OBSERVATION, Rules.Level.SIMPLE_DETERRENCE:
+84
View File
@@ -0,0 +1,84 @@
class_name PauseOverlay
extends CanvasLayer
## Shift pause overlay: resume, restart the shift, open settings, or quit to the
## venue menu. Opened with Esc from the world (or a dedicated pause action) and
## while the overlay is open the guard is frozen.
signal resume_requested()
signal restart_requested()
signal settings_requested()
signal quit_to_menu_requested()
var _root: Control = null
var _resume_button: Button = null
func _ready() -> void:
layer = 18
_build()
hide()
func _build() -> void:
_root = Control.new()
_root.name = "PauseRoot"
_root.set_anchors_preset(Control.PRESET_FULL_RECT)
_root.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_root)
var bg := ColorRect.new()
bg.color = Color(0.02, 0.03, 0.05, 0.82)
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
_root.add_child(bg)
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(320, 0)
column.add_theme_constant_override("separation", 10)
center.add_child(column)
var title := Label.new()
title.text = "Paused"
title.add_theme_font_size_override("font_size", 36)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
column.add_child(title)
_resume_button = _add_button(column, "Resume", "ResumeButton", func(): resume_requested.emit())
_add_button(column, "Restart shift", "RestartButton", func(): restart_requested.emit())
_add_button(column, "Settings", "SettingsButton", func(): settings_requested.emit())
_add_button(column, "Quit to menu", "MenuButton", func(): quit_to_menu_requested.emit())
var hint := Label.new()
hint.text = "Esc resumes"
hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
hint.add_theme_color_override("font_color", Color(0.55, 0.58, 0.65))
column.add_child(hint)
func _add_button(parent: Control, text: String, node_name: String, cb: Callable) -> Button:
var button := Button.new()
button.name = node_name
button.text = text
button.custom_minimum_size = Vector2(320, 44)
button.pressed.connect(cb)
parent.add_child(button)
return button
func open() -> void:
visible = true
_root.visible = true
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
if _resume_button:
_resume_button.grab_focus()
func close() -> void:
visible = false
_root.visible = false
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:
resume_requested.emit()
get_viewport().set_input_as_handled()
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff. Show more