Compare commits

..
33 Commits
Author SHA1 Message Date
zwitschi 76eb5e3a79 Fix mouse look swallowing by the HUD
Full build / Import + headless tests (push) Successful in 35s
Full build / Full desktop build + optional release (push) Successful in 4m39s
Move guard look handling to the _input stage, recapture the cursor on
window-focus return and when clicking back into gameplay, and make HUD controls
mouse-transparent. Add regression tests for yaw/pitch and HUD mouse filters.
Verified under Xvfb with real relative mouse motion. Headless 237/237, Python
94/94, movement smoke 60 fps.
2026-11-01 20:38:36 +01:00
zwitschi f4a53e2d4f Add the first-shift tutorial design and state machine
Full build / Full desktop build + optional release (push) Skipped
Full build / Import + headless tests (push) Successful in 38s
Write the eight-beat tutorial sheet, its safe visitor script, persistence and
replay rules. Add a deterministic, skippable/restartable Tutorial RefCounted
state machine with headless tests. UI/GameManager wiring remains the next task.
Headless 235/235, Python 94/94.
2026-11-01 20:19:18 +01:00
zwitschi 806fe78688 Clarify nested TODO completion rules
Full build / Full desktop build + optional release (push) Skipped
Full build / Import + headless tests (push) Successful in 37s
Describe level-2 milestones/themes, level-3 workstreams and the rule that
completed workstreams stay under their parent until the whole milestone is
done. Add a milestone-shaped recommended format and align the changelog/tag
steps with nested sections.
2026-11-01 20:16:23 +01:00
zwitschi 6a3066d763 Clarify nested task grouping in the agent checklist format 2026-11-01 20:14:53 +01:00
zwitschi f4cce28454 Restructure the v1.0 roadmap around milestone sections
Full build / Import + headless tests (push) Successful in 38s
Full build / Full desktop build + optional release (push) Skipped
Promote every milestone to a level-2 section and expand 0.8.0 into detailed
workstreams for tutorial, campaign, content matrix, dialogue, debrief, save/
resume, difficulty/help and verification. Keep later milestones broad and
preserve the deferred realism/future lists.
2026-11-01 20:12:17 +01:00
zwitschi 7d39523e49 Keep generated realism runs out of the repository
Full build / Full desktop build + optional release (push) Skipped
Full build / Import + headless tests (push) Successful in 33s
Ignore docs/screens/realism/ entirely and remove its reports/captures from the
index while leaving the generated folders on disk. Update the asset-tooling and
concept notes to treat realism runs as local QA artifacts. Link scan clean.
2026-11-01 20:09:45 +01:00
zwitschi 7e084192ce Remove the obsolete graphics plan
Full build / Import + headless tests (push) Successful in 34s
Full build / Full desktop build + optional release (push) Skipped
Delete docs/plan-graphics-v0.6.md and update README, architecture, asset
tooling, Blender docs and MEMORY references. Completed work stays summarized
in ADR-0013, the asset-pipeline architecture and the changelog; the v1.0.0
roadmap owns future graphics work.
2026-11-01 20:06:31 +01:00
zwitschi 6c367e8c7e Consolidate architecture docs and add the v1.0.0 roadmap
Full build / Import + headless tests (push) Successful in 34s
Full build / Full desktop build + optional release (push) Skipped
Make architecture the canonical system/data-flow source, add explicit
documentation ownership and consolidation rules, and mark the old graphics plan
historical. Fix stale README/status/Blender/release claims. Add a full gap
register and milestone roadmap from v0.8 through v1.0.0, including onboarding,
content depth, accessibility/localization, performance, release/legal blockers
and non-blocking future ideas.
2026-11-01 20:04:47 +01:00
zwitschi 659b0e35b2 Make the vision judge discover cost-effective OpenRouter models
Full build / Import + headless tests (push) Successful in 40s
Full build / Full desktop build + optional release (push) Successful in 4m6s
Add openrouter_judge.py to rank live image+JSON models by advertised price,
with ordered fallbacks, auth-error handling and auto selection. Wire both judges
to --model auto, record the model used, and add fake-catalogue/mocked-call
tests. Re-run the judges with the valid key: 13/13 screen designs match, 0 diff;
the Forward+ A/B report records the selected model and ~$0.006 spend. Headless
231/231, Python 94/94.
2026-11-01 19:55:43 +01:00
zwitschi 28f2a48811 Add the optional Forward+ desktop HQ build
Full build / Import + headless tests (push) Successful in 40s
Full build / Full desktop build + optional release (push) Successful in 5m20s
Package a Forward+ preset with volumetric fog, SDFGI and a volumetric flashlight
for a separate Linux/Windows Desktop HQ zip. Gate the effects on a real
RenderingDevice, tune LATE fog, and adapt local light falloff for Godot 4.7.
Record the Compatibility/Forward+ A/B captures and metrics, add the artifact to
the build/publish scripts, and keep the default Compatibility/Web path
unchanged. Headless 231/231, Python 87/87; exported HQ binary boots with Vulkan.
2026-11-01 19:40:42 +01:00
zwitschi 64f0c6a198 Document the local Godot 4.7.2 toolchain
Full build / Full desktop build + optional release (push) Skipped
Full build / Import + headless tests (push) Successful in 38s
Record the upgraded 4.7.2 binary, the retained 4.3 backup and the matching
export templates. Keep the project/CI baseline at 4.3 and note that 4.7.2
verified import, tests, the session smoke and a Linux export. Hide the
4.7-generated per-script UID sidecars from this clone only.
2026-11-01 19:16:55 +01:00
zwitschi 8b0845bcce Build distinct Joke Shop and TV Studio venues
Full build / Import + headless tests (push) Successful in 40s
Full build / Full desktop build + optional release (push) Successful in 4m57s
Add the alley walls, warm lantern, shutters, dumpster, AC clutter and door
step for the Joke Shop. Build the studio stone facade, bulb canopy, glossy
black car, stanchions and barriers. Rebuild only the world container when the
venue changes, so the guard and UI survive; add per-venue navigation checks.
Capture gallery shots 12/13, update the concept mapping and docs. Headless
231/231, Python 87/87, screenshot gate 13 good/0 bad. The vision-judge retry is
tracked separately because the API key is invalid.
2026-11-01 19:09:47 +01:00
zwitschi 4cca2af2dd Extract per-venue geometry specs into WorldBuilder
Add the Hollywood, Joke Shop and TV Studio tables for facade, window,
stair, street, nav, guard-spawn and feature data. Unknown ids fall back to
the boulevard. Use the specs for the core dimensions and gates, with a headless
test for the distinct venue data. Headless 230/230.
2026-11-01 19:03:20 +01:00
zwitschi 99f390078d Restore the full changelog history 2026-11-01 19:00:53 +01:00
zwitschi a9fe6b9944 Extend the agent workflow with docs and changelog maintenance 2026-11-01 18:56:15 +01:00
zwitschi d4201caa62 Release v0.6.1
Full build / Import + headless tests (push) Successful in 38s
Full build / Full desktop build + optional release (push) Successful in 3m49s
Ship the fitted NPC gear pass with the post-v0.6.0 realism and UI work.
Bump the version source and documentation to 0.6.1 and move the changelog
entry out of Unreleased. Headless 229/229.
2026-11-01 18:55:58 +01:00
zwitschi 6100d19a25 Document the gear-fit pass and add the changelog
Create CHANGELOG.md with the post-0.6.0 gear-fit entries and backfill the
recent release history. Update the implemented-features list, asset-tooling
notes, README links and verification counts (headless 229, Python 87).
2026-11-01 18:55:43 +01:00
zwitschi a7ca698f73 Break the README into focused docs pages
Full build / Import + headless tests (push) Successful in 36s
Full build / Full desktop build + optional release (push) Skipped
2026-11-01 18:47:44 +01:00
zwitschi 26d64baa01 Close the NPC gear-fit section
Full build / Full desktop build + optional release (push) Skipped
Full build / Import + headless tests (push) Successful in 34s
Move the completed gear section to DONE.md and record the hooded-portrait
follow-up. An explicit --portrait-type now overrides the hooded-subject skip,
though the day roster has no types 5-7 yet. Headless 229/229.
2026-11-01 18:12:37 +01:00
zwitschi 151630e957 Recapture the gear portrait and gallery shots
Fix the portrait staging: face the rig toward the camera (the old yaw was 180
degrees out) and hide the focus disc, which was covering the face in close-ups.
Add the before/after Influencer portrait, fit measurements and judge scores in
docs/screens/realism/2026-11-01-gear. Re-run check_screenshots (11 good/0 bad);
headless 229/229.
2026-11-01 18:09:50 +01:00
zwitschi 1b319c48b7 Guard gear fit with headless and Blender regression checks
The Python fit test now caps the glasses at 0.70x head width and requires a
positive bag clearance on both the coat and dress. The Godot character suite
loads head/back gear on six roles and checks attach parents, head width and
brow bounds, and that back gear reaches beyond the 0.26 m body silhouette.
Rebuilt and re-baked all 18 gear assets; no-op GLB export churn was reverted.
Headless 229/229, Python 87/87.
2026-11-01 18:04:51 +01:00
zwitschi a478bfbf35 Clear the firefighter helmet brim from the brow
The face pass added a brow ridge; lift the helmet mesh 2 cm so the brim sits
above the eyes. Cap and hood were rendered on their characters and leave the
brow/ears and face readable. Rebuild and re-bake the helmet; Python 87/87.
2026-11-01 18:02:38 +01:00
zwitschi 702f54ae53 Move the shoulder bag clear of coat and dress skirts
Narrow the bag to 0.06 m and move it outboard to centre x=0.292. The body inner
face is now 0.262 m, clearing the coat skirt (0.225) and dress (0.260); the
strap lower points start on the same line. The fit report measures the bag body
separately from the torso strap and uses a signed inner-face clearance. Run
Blender build/bake and Godot import. Python tooling 87/87.
2026-11-01 17:58:47 +01:00
zwitschi bbf4693475 Narrow the sunglasses and fix portrait-type parsing
Move the hinges to +/-0.075 (0.035 box), arms to +/-0.098, and lenses to a
0.024 m radius while keeping the lens plane in front of the eyes. Rebuild the
GLB and AO; fit now measures 0.202 m total width (0.64x head). capture_realism
now parses the documented --portrait-type option instead of ignoring it.
Python tooling 87/87; headless 228/228.
2026-11-01 17:56:10 +01:00
zwitschi d7b0203502 Document commit and milestone steps in the agent workflow 2026-11-01 17:53:24 +01:00
zwitschi da876e3545 Add a gear-vs-body fit report to the Blender asset tool
`blender_assets.py fit` measures each character head width (skin vertices
weighted to Head in y 1.55-1.81) and clothing radius (cloth skirt in y 0.30-0.52,
pants hip fallback), then prints every attached head/back/chest gear bounding box
with head-band status and x/radial skirt clearance. The report confirms the
glasses width and shoulder-bag coat clip, giving the next fit fixes numbers.
Python tooling 87/87; asset audit clean.
2026-11-01 17:52:38 +01:00
zwitschi 1ee32215b0 Detail the NPC avatars and finish the realism follow-ups
Full build / Import + headless tests (push) Successful in 38s
Full build / Full desktop build + optional release (push) Skipped
Blender library
- Rebuild the face: sclera, iris and upper lid per eye, brow ridge, cheekbones,
  nose bridge and tip, ears with lobes, split upper/lower lips.
- Give each hand four fingers plus a thumb and end every sleeve in a cuff ring.
- Raise CHAR_MAX_VERTS 4000 -> 5000 (16 avatars still cost ~6.9 ms of a 50 ms
  frame), decimate the body to 26 %; characters are now 3,974-5,026 tris and
  3,744-4,807 verts. Rebuild all 8 variants and re-bake their AO maps.

Capture tooling
- Add a `portrait` stage to capture_realism.gd: freeze one NPC, park the crowd
  out of frame, stand the subject on open sidewalk, turn it into the camera and
  put the guard on the sunlit side. New --portrait-type=N picks the role.
- Add the npc_portrait_day concept and map the shot to it, so avatar detail is
  scored against a portrait instead of a wide street frame.

Flashlight
- Drop the fake additive beam (Materials.light_beam + the cone mesh): on GL
  Compatibility a first-person camera inside the cone renders it as a filled
  disc with a bright far ring. Keep the tight spot (14 deg, 20 m, energy 4.2)
  and its headless test; the visible shaft is now planned behind a Forward+
  preset.

Docs and task tracking
- Move the finished realism follow-ups (ground exposure, flashlight, NPC detail,
  judge re-check) to DONE.md and expand TODO.md with step-by-step plans for NPC
  gear fit, venue geometry plus the committed gallery, and the Forward+ preset.
- Record the decisions, gotchas and portrait recipe in MEMORY.md; update the
  Blender contract, the concept README and the capture section of README.md.
- Refresh the gallery captures affected by the avatar rebuild.

Checks: headless 228/228, Python 86/86, blender_assets audit exit 0,
scene_concepts audit all GOOD, screen_designs compare 11 good / 0 bad,
smoke_npc_budget OK, boot clean (only the known headless mesh warning).
2026-11-01 17:46:25 +01:00
zwitschi 2624b54b23 Compact the plan and synchronize project documentation
- reduce PLAN.md from a 674-line implementation log to a 187-line product/design
  plan; keep escalation, venues, NPC/systems, controls, presentation and constraints
- refresh README for implemented venues, Blender/v2 assets, the custom test runner,
  Web target, current release state and the screenshot smoke
- update architecture for 0.6.0, the runtime art loader, Gitea CI/build/publish,
  Web deployment, six release artifacts and visual regression
- remove stale uncommitted/active-pass claims from MEMORY and point it at the
  current NPC follow-ups; move the completed documentation task to DONE
2026-11-01 17:37:02 +01:00
zwitschi 85d67ff8b1 Fix heading hierarchy and restore the future ideas section in PLAN.md 2026-11-01 17:33:15 +01:00
zwitschi 47c57ddc76 Tighten the flashlight spot and refresh captures
Full build / Full desktop build + optional release (push) Skipped
Full build / Import + headless tests (push) Successful in 37s
Narrow the spot to 11° with a 20 m range so it lights the person/door rather
than the whole facade, and regenerate the staged screenshots. Headless 227/227,
compare 11 good / 0 bad.
2026-11-01 17:06:57 +01:00
zwitschi 3c07525351 Use a vertical fade for the flashlight beam
Full build / Import + headless tests (push) Successful in 37s
Full build / Full desktop build + optional release (push) Skipped
A GradientTexture1D varies along U, so the cone showed a bright ring; build a
1×64 vertical ramp for UV.y instead (faint at the lens, brightest near the
start, gone before the tip). Regenerate the affected captures; headless 227/227,
compare 11 good / 0 bad.
2026-11-01 17:06:09 +01:00
zwitschi 4a672025a2 Capture the late-night avenue and flashlight in the LATE phase
Full build / Import + headless tests (push) Successful in 38s
Full build / Full desktop build + optional release (push) Skipped
Jump the staged shift to 760 s before the night shots, so 05/06 are true night
scenes with a visible flashlight beam instead of dusk. Regenerate the affected
captures; compare stays 11 good / 0 bad.
2026-11-01 17:05:06 +01:00
zwitschi 6acbc2b3a4 Tune the flashlight beam and refresh affected captures
Full build / Import + headless tests (push) Successful in 37s
Full build / Full desktop build + optional release (push) Skipped
Widen and lengthen the additive beam cone and raise its near-end opacity so the
night flashlight reads on screen. Regenerate the staged captures; compare stays
11 good / 0 bad, headless 227/227.
2026-11-01 17:04:09 +01:00
99 changed files with 3568 additions and 3907 deletions

No files matched your search

+5 -5
View File
@@ -66,10 +66,10 @@ assets/models/blender/**/*.png
assets/models/blender_src/*.blend1 assets/models/blender_src/*.blend1
# Editable Blender sources are developer-only; keep the .gdignore marker tracked. # Editable Blender sources are developer-only; keep the .gdignore marker tracked.
assets/models/blender_src/*.blend assets/models/blender_src/*.blend
# Realism pass (tools/scene_concepts.py): the candidate pool and per-run captures # Realism pass (tools/scene_concepts.py): candidate pools, per-run captures and
# stay local; the validated picks are tracked under game_ready/v2/picks/ (ADR-0014), # generated reports stay local; validated picks are tracked under
# and each run commits its report.{json,md} + sheet.jpg. Concept references are # game_ready/v2/picks/ (ADR-0014). Concept references are committed in
# committed in docs/screens/concepts/. # docs/screens/concepts/, but generated QA lives outside the repository.
assets/textures/ai/game_ready/v2/* assets/textures/ai/game_ready/v2/*
!assets/textures/ai/game_ready/v2/picks/ !assets/textures/ai/game_ready/v2/picks/
docs/screens/realism/**/*.png docs/screens/realism/
+73 -16
View File
@@ -14,6 +14,14 @@ The project should be developed in small, reviewable steps. Agents must keep pla
- `MEMORY.md` — Persistent session memory for important decisions, discoveries, unresolved questions, constraints, and useful implementation context. - `MEMORY.md` — Persistent session memory for important decisions, discoveries, unresolved questions, constraints, and useful implementation context.
- `README.md` — Root-level documentation for the project. It should explain what the game is, how to work on it, how to run it, and where to find project information. - `README.md` — Root-level documentation for the project. It should explain what the game is, how to work on it, how to run it, and where to find project information.
- `AGENTS.md` — This collaboration and agent workflow guide. - `AGENTS.md` — This collaboration and agent workflow guide.
- `CHANGELOG.md` — The version history of user-facing changes. Keep an entry for each patch, minor, and major version.
- `docs/development-status.md` — The implemented-features list and what is not yet implemented. Update it whenever a feature lands, is renamed, or is removed.
- `docs/architecture.md` — The arc42-style architecture documentation (goals, constraints, building blocks, runtime views, decisions, risks).
- `docs/gameplay.md` — Gameplay systems, venues, and the escalation model.
- `docs/asset-tooling.md` — Asset pipelines, model tools, and screenshot gates.
- `docs/getting-started.md` — Setup, run/test commands, and the controls table.
- `docs/smoke-checklist.md` — The manual smoke-test checklist.
- `docs/adr/` — Architecture Decision Records.
## Required Workflow ## Required Workflow
@@ -36,7 +44,13 @@ Do not assume that a task is unimplemented without checking the code and documen
All planned implementation work must be represented in `TODO.md`. All planned implementation work must be represented in `TODO.md`.
- Use headings to group related work into sections. - Use headings to group related work into sections.
- Use checkbox items for each concrete task. - Use level-2 headings (`##`) for milestones or stable themes. A milestone should state its goal
and an explicit exit criterion; a theme groups small related work that does not need a
milestone.
- Use level-3 headings (`###`) for workstreams inside a milestone (for example, `0.8.1
First-shift tutorial`). Keep the hierarchy to three levels: milestone → workstream → checkbox.
- Use checkbox items for each concrete task, normally under a level-3 workstream. Direct
checkboxes under a level-2 section are acceptable for small, single-workstream sections.
- Keep tasks small enough to complete and review independently. - Keep tasks small enough to complete and review independently.
- Add new tasks when discoveries expand the scope. - Add new tasks when discoveries expand the scope.
- When starting a task, claim it for the agent workflow by changing `[ ]` to `[-]`. Keep it `[-]` - When starting a task, claim it for the agent workflow by changing `[ ]` to `[-]`. Keep it `[-]`
@@ -53,11 +67,20 @@ Recommended format:
```md ```md
# TODO # TODO
## Core Gameplay ## Milestone 0.8.0 — First hour and content depth
- [ ] Add player movement controls. ### 0.8.1 First-shift tutorial
- [-] Implement NPC approach behavior. (claimed; implementation/verification in progress)
- [x] Define escalation levels. - [x] Write the tutorial beat sheet.
- [-] Implement the tutorial state machine. (claimed; implementation/verification in progress)
- [ ] Add the tutorial smoke test.
### 0.8.2 Campaign shell
- [ ] Define campaign data.
- [ ] Persist campaign progress.
**Exit:** a first-time player completes a shift without external documentation.
## Documentation ## Documentation
@@ -66,14 +89,26 @@ Recommended format:
### 3. Move Completed Sections to `DONE.md` ### 3. Move Completed Sections to `DONE.md`
When every task in a `TODO.md` section is complete: `TODO.md` uses nested sections. Apply these completion rules:
1. Verify the work and update the checkbox items to `[x]`. 1. **Level-3 workstream:** verify the work, mark every checkbox `[x]`, and add a short completion
2. Move the entire completed section, including its heading and checklist, to `DONE.md`. note. Leave the completed workstream under its parent milestone; do not move an isolated
3. Remove that section from `TODO.md`. level-3 section to `DONE.md`.
4. Add a short completion note or date when useful. 2. **Level-2 milestone/theme:** it is complete only when its direct checkboxes and **all** of its
level-3 workstreams are complete. Verify the whole milestone, record its capabilities in
`docs/development-status.md` when it represents shipped functionality, and write the matching
`CHANGELOG.md` entry.
3. Move the **entire level-2 section**, including all level-3 headings and checklists, to
`DONE.md`. Remove it from `TODO.md` and add a completion date/note.
4. **Planning/overview sections** such as a gap analysis, roadmap overview, dependencies or
deferred ideas are not completion milestones. Do not move them merely because their checkboxes
are empty or complete; update or supersede them as the roadmap changes.
5. Do not move partially completed milestones. Leave them in `TODO.md` until every nested task is
complete, or split the unfinished work into a new smaller level-2 milestone first.
Do not move partially completed sections. Leave them in `TODO.md` until all of their tasks are complete, or split the section into smaller sections first. When a completed milestone represents shipped functionality, also record the new capability in
`docs/development-status.md` so the implemented-features list stays accurate, and write a matching
entry in `CHANGELOG.md` (see "Maintain `CHANGELOG.md`" below).
### 4. Maintain `MEMORY.md` ### 4. Maintain `MEMORY.md`
@@ -82,7 +117,7 @@ Use `MEMORY.md` for information that should persist between sessions but does no
Record items such as: Record items such as:
- Important design and architecture decisions - Important design and architecture decisions
- Current implementation state - Notable implementation state and gotchas (the canonical implemented-features list lives in `docs/development-status.md`, not here)
- Known bugs and workarounds - Known bugs and workarounds
- Commands needed to run, test, or build the project - Commands needed to run, test, or build the project
- Decisions that future agents should not revisit unnecessarily - Decisions that future agents should not revisit unnecessarily
@@ -132,9 +167,27 @@ The root-level `README.md` is the project’s public-facing technical and projec
- Links to `PLAN.md`, `TODO.md`, `DONE.md`, `MEMORY.md`, and this file where appropriate - Links to `PLAN.md`, `TODO.md`, `DONE.md`, `MEMORY.md`, and this file where appropriate
- Known limitations and contribution guidance - Known limitations and contribution guidance
Update `README.md` whenever setup instructions, commands, project structure, or user-facing behavior changes. Update `README.md` whenever setup instructions, commands, project structure, or user-facing behavior changes. The README is intentionally concise and links to the `docs/` files; keep `docs/development-status.md` (and the other docs it links to) synchronized with the implementation rather than pasting detail back into the README.
### 7. Implement in Small Increments ### 7. Maintain `CHANGELOG.md`
The root-level `CHANGELOG.md` records the user-facing changes for every release. It exists so players and contributors can see what changed between versions without reading the commit log.
- Add an entry for every patch, minor, and major version change.
- Follow Semantic Versioning (`major.minor.patch`): bump major for breaking or vision-level changes, minor for new features, and patch for fixes and small polish.
- Group each entry under `Added`, `Changed`, `Fixed`, and `Removed` headings, and date every entry.
- Describe changes in user-facing terms, not as raw commit messages.
- Add the entry when its work ships. If the version number is not yet known, record it under `Unreleased` and move it into a numbered version when the version is tagged.
When a full executable level-2 milestone/theme in `TODO.md` is implemented, completed, and moved to `DONE.md`:
1. Verify the work and mark every checkbox in the section `[x]`.
2. Use the section heading and its completed items to describe the changes and write a `CHANGELOG.md` entry under the next appropriate version.
3. Move the section to `DONE.md`.
Do not move a completed section to `DONE.md` without adding its `CHANGELOG.md` entry.
### 8. Implement in Small Increments
For each task: For each task:
@@ -142,9 +195,13 @@ For each task:
2. Inspect the relevant files and dependencies. 2. Inspect the relevant files and dependencies.
3. Make the smallest coherent change that completes the task. 3. Make the smallest coherent change that completes the task.
4. Run the relevant tests, checks, or manual verification. 4. Run the relevant tests, checks, or manual verification.
5. Update documentation and `MEMORY.md` if the change creates lasting context. 5. Update documentation if the change creates lasting context: add features to `docs/development-status.md`, adjust the README links or other `docs/` files as needed, and record decisions/gotchas in `MEMORY.md`.
6. Mark the task complete. 6. Mark the task complete.
7. Move its section to `DONE.md` when the entire section is complete. 7. Keep completed level-3 workstreams in place until the parent milestone is complete; then move the entire level-2 milestone to `DONE.md` after recording its `CHANGELOG.md` entry.
8. Commit the changes to the version control system with a descriptive message.
9. If a level-2 milestone from `TODO.md` has been fully completed, ensure its `CHANGELOG.md` entry is in place, then create a new version or tag in the version control system to mark the milestone.
10. Push the committed changes to the shared repository if applicable.
11. Note the new version or tag created in the version control system in any relevant documentation or handoff summary.
Avoid unrelated refactors unless they are required for the task or explicitly approved. Avoid unrelated refactors unless they are required for the task or explicitly approved.
+338
View File
@@ -0,0 +1,338 @@
# Changelog
All notable user-facing changes to Gig Line. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions use
Semantic Versioning.
## [0.7.3] — 2026-11-01
### Fixed
- Mouse look now reads relative motion at the `_input` stage, so a full-screen
HUD or focused control cannot swallow it while the cursor is captured.
- Mouse capture is restored after window/compositor focus returns, and clicking
back into a gameplay window recaptures it instead of opening the radial menu.
- The HUD is mouse-transparent; its presentation controls no longer block
gameplay mouse input.
## [0.7.2] — 2026-11-01
### Added
- Automatic OpenRouter model selection for the screenshot judges: the cheapest
currently available image+JSON-capable model is used, with ordered fallbacks
and a pinned `GIG_LINE_JUDGE_MODEL`/`--model` override.
### Changed
- The judge reports the model that produced each verdict and fails clearly on an
invalid API key instead of retrying other models.
- The screen-design and realism reports now include the fresh judge results.
### Fixed
- The blocked screenshot judge now runs with the valid `OPENROUTER_API_KEY`
(`13/13` screen designs match, `0` diff).
## [0.7.1] — 2026-11-01
### Added
- An optional Desktop HQ build for Windows and Linux that uses Forward+,
volumetric fog and SDFGI, packaged with an `override.cfg` beside the
executable.
- Volumetric flashlight haze in the Desktop HQ build, with a LATE-phase preset
tuned so the entrance facade stays readable.
### Changed
- The normal Compatibility build remains the default; the preset only affects
the extracted Desktop HQ copy.
- Night-time light falloff adapts to Godot 4.7 while keeping the 4.3 baseline
unchanged.
### Fixed
- The flashlight no longer disappears at night on Godot 4.7 under the old
attenuation curve.
- Headless and Web builds stay non-volumetric and boot normally when Forward+
is unavailable.
## [0.7.0] — 2026-11-01
### Changed
- Agent workflow now requires maintaining this changelog for every patch, minor, and major version.
## [Unreleased]
### Added
- Distinct venue geometry: the Joke Shop is now a narrow brick alley with a
shuttered facade, warm wall lantern, dumpster and AC clutter; the TV Studio
has a stone facade, bulb-lit canopy, glossy black car and a stanchion/barrier
line at the door.
- The README gallery includes captured Joke Shop and TV Studio views.
### Changed
- Venue geometry is data-driven through `WorldBuilder.VENUE_SPECS`; switching
venues rebuilds the world while keeping the guard and UI intact.
- Each venue has its own navigation bounds and concept-gallery mapping.
### Fixed
- The Joke Shop no longer renders the Hollywood skyline or marquee.
- The TV Studio no longer uses the boulevard marquee or palm-lined layout.
## [0.6.1] — 2026-11-01
### Added
- Per-phase AI skyline panoramas (day, dusk, night) replacing the flat box skyline.
- Higher-detail surface textures with normal maps (brick, concrete, asphalt, awning, and more).
- A runtime image root (`assets/images/`) with a UI and scene art loader; the start menu,
pause, settings, shift review, radio, wand, inspection, and radial menu are styled against
their mockups.
- NPC face and hand detail: eyes, nose, lips, ears, fingered hands, and cuffed sleeves.
- A staged NPC portrait capture for judging avatar quality up close.
- Gear-fit checks (`tools/blender_assets.py fit`) and regression tests for head and back gear.
### Changed
- Scene lighting, fog, sun angle, and night glow retuned to match the AI concept frames;
the Late phase uses wet asphalt.
- The flashlight is a tighter forward spot that reads in the dark.
- Sunglasses narrowed to sit inside the face silhouette; shoulder bag moved clear of the
coat and dress skirts; firefighter helmet lifted off the brow.
- The README now links to focused docs pages for gameplay, architecture, setup, asset
tooling, development status, and the manual smoke checklist; `PLAN.md` was compacted
into a design plan.
### Fixed
- Sidewalk overexposure at the entrance reduced to match the concept target.
- The body-camera overlay no longer overlaps the shift/reputation/crowd status.
- Selecting a venue releases the menu input lock and recaptures the mouse.
- The shoulder bag no longer clips through the coat's skirt; the firefighter helmet no
longer cuts into the brow ridge; the portrait subject now faces the camera.
- `--portrait-type` is now parsed and applied instead of being ignored.
## [0.6.0] — 2026-11-01
### Added
- Blender-authored character, gear and prop library with baked ambient occlusion.
- Higher-resolution environment textures with tangent normal maps.
- Phase-driven emissive details on library props, characters and street dressing.
### Changed
- The game was renamed from "Step Line" to "Gig Line".
### Fixed
- NPCs now face their movement direction instead of walking backwards.
## [0.5.0] — 2026-11-01
### Added
- Articulated NPC characters with layered outfits, hand items, face/hair variety,
idle behaviour, reaction symbols, queue offsets and three LOD tiers.
- Player acceleration/braking, step-up assist, head bob, landing dip,
precision-walk feedback and surface-aware footsteps.
- Remappable keyboard and gamepad controls.
### Changed
- Crowd roles now select deterministic character variants and props.
## [0.4.0] — 2026-11-01
### Added
- Collision and navigation footprints for every street prop.
- Zoned prop placements with deterministic variants and small clutter.
## [0.3.3] — 2026-11-01
### Added
- Staged in-game captures for the wand screening, escalation HUD, and body-camera timeline screens.
## [0.3.2] — 2026-11-01
### Added
- AI reference mockups for the seven remaining catalogue screens.
## [0.3.1] — 2026-10-31
### Added
- Prop models drawn with a single texture atlas (one material per prop).
### Changed
- The Web build is listed in the README downloads table.
## [0.3.0] — 2026-10-31
### Added
- Surface-material profiles, ACES tonemapping, glow, aerial fog and phase-based
colour grading.
- Framed facade windows, door trim, curb and lane markings.
- Generated street-furniture models and the Web export.
- Aim-free targeting, a focus cone and precision walk.
## [0.2.16] — 2026-10-31
### Added
- Venues authorable as editor `.tres` resources.
## [0.2.15] — 2026-10-31
### Added
- A vision-model judge for comparing screenshots against reference designs.
## [0.2.14] — 2026-10-31
### Added
- Anonymous release downloads enabled on the host.
### Fixed
- `Strings.fmt` keeps positional placeholders stable when new keys are added.
## [0.2.13] — 2026-10-31
### Added
- Missing scenario content: Joke Shop reservation/ID cases, wand refusal and late
arrival events, the studio filming-boundary repeat, and a celebrity-arrival crowd spike.
## [0.2.12] — 2026-10-31
### Fixed
- Release uploads fail loudly instead of leaving an empty release with a green job.
- Pruned releases restored and CI publishing verified end to end.
## [0.2.11] — 2026-10-31
### Fixed
- Releases publish using the runner's checkout credentials when no token or git
credentials exist.
## [0.2.10] — 2026-10-31
### Fixed
- Release publishing fixed with a CI token fallback.
## [0.2.9] — 2026-10-31
### Added
- Accessibility pass: escalation feedback that reads in grayscale and a working HUD font scale.
## [0.2.8] — 2026-10-31
### Added
- Procedural crowd ambience that rises with crowd mood, and a radio crackle.
## [0.2.7] — 2026-10-31
### Added
- NPC theater poses: sitting, taking photos, filming, and performing.
## [0.2.6] — 2026-10-31
### Added
- A body-camera timeline on the shift review for stepping through recorded events.
## [0.2.5] — 2026-10-31
### Added
- Emergency aftermath: evacuation clears the entrance, and unjustified calls write
an investigation note with a false-report reputation hit.
## [0.2.4] — 2026-10-31
### Added
- Crowd presentation: onlookers rubberneck at incidents and clear out during panic.
### Fixed
- NPC navigation issues resolved.
## [0.2.3] — 2026-10-31
### Added
- Remaining PLAN actions: eye contact, "I recognize this person", a formal-warning
body-camera flag, blocking that stations the guard in front of the door, and
requesting a reason for entry.
### Fixed
- A dead incident-recording path.
## [0.2.2] — 2026-10-31
### Added
- Session shell: startup menu with venue selection, pause overlay, settings
persistence, and a "run another shift" flow back to the menu.
- Control pass: focus cone, head ring, radial action menu, and named input actions.
### Fixed
- Release publishing now uploads the installer and AppImage.
## [0.2.1] — 2026-10-31
### Added
- Windows NSIS installer and Linux AppImage packaging; all exports embed the PCK.
- An AI texture generation mode in the asset pipeline.
### Fixed
- NSIS build fixed for Linux (absolute defines, space-free installed exe, BMP icon).
- Runtime hygiene: RefCounted cycle cleanup, full-length shifts, and a wand cooldown.
## [0.2.0] — 2026-10-31
### Added
- First playable build of the security-guard simulation at the Hollywood Boulevard entrance.
- Core simulation: shift coordination, venue scenarios, event scheduling, NPC movement
and states, and escalation from observation through deterrence, warnings, and blocking.
- Access verification for wristbands, IDs, tickets, credentials, and guest lists, with
admit/redirect/hold/refuse decisions and feedback scoring.
- Escalation levels 0–5 with radio assistance and a hold-to-confirm emergency call.
- Incident recording, body-camera evidence, reputation scoring, crowd reactions, and
an end-of-shift review.
- Scenario content for Hollywood Boulevard, The Joke Shop, and the TV Studio, including
a mental-health crisis event.
- A protester NPC, the "call someone else" reaction, a lockable door, and a flashlight.
- Save/persistence, navmesh pathfinding, and JSON localization.
- Visual polish: phase lighting, street lamps, marquee, street dressing, and NPC variety.
- Detailed graphics: authored textures, a humanoid NPC rig, and HUD/inspection feedback.
- Downloadable Linux, Windows, and macOS builds published by CI.
### Removed
- The dialogue tone-of-voice picker (reactions now depend on escalation level and NPC profile).
+209
View File
@@ -1049,6 +1049,62 @@ reports to `docs/screens/realism/<run>/`.
texture 0.8→1.0, backdrop 0.6→1.6, and the Pillow metric gap 0.444→0.348. texture 0.8→1.0, backdrop 0.6→1.6, and the Pillow metric gap 0.444→0.348.
- [x] Update `README.md` (tool usage + gallery), `MEMORY.md` and `docs/architecture.md`. - [x] Update `README.md` (tool usage + gallery), `MEMORY.md` and `docs/architecture.md`.
## Realism Follow-ups — Lighting, Flashlight, NPC Detail
Completed 2026-11-01. The follow-ups left over after the texture/skyline adoption, split off from
`TODO.md` when each one was finished. Evidence: `docs/screens/realism/2026-11-01-npc/`
(`report.md`, `sheet.jpg`), the portrait capture, and the refreshed `docs/screenshots/` gallery
(`screen_designs.py compare` **11 good / 0 bad**). Headless **228/228**, Python **86/86**.
### Ground exposure at the entrance
- [x] Fixed the over-bright entrance ground: the morning sun now sits at 34° elevation (was 50°)
with a phase `sun_yaw`, so the facade shades the near sidewalk and the props cast long
shadows. Ground luminance went 95 → **83** against the concept's 78 (it was 173 before the
realism pass), with the boulevard at 82 and the street at 81. Day haze warmed and
thickened (`fog_color` 0.87/0.83/0.76, density 0.0034, energy 0.5) and the night glow went
to 1.15.
### Flashlight readability
- [x] Made the flashlight read in the dark: the cone went 30° → **14°** with a 20 m range,
forward aim, `light_energy` 4.2 and `spot_attenuation`/`spot_angle_attenuation` 0.9, so it
lights the door instead of washing the facade.
- [x] Staged the two night captures in LATE (`elapsed_s` 760) instead of dusk in
`capture_screenshots.gd`; the judge moved `06-night-flashlight` from **diff (1.00) to
review (0.10)**.
- [x] Rejected a fake additive beam twice (cylinder + UV ramp, then a vertical-fade
`ShaderMaterial`): on GL Compatibility a first-person camera inside the cone renders it as
a filled disc with a bright far ring. `Materials.light_beam()` and the mesh were removed;
the visible *shaft* is parked behind the Forward+ preset in `TODO.md`.
- [x] Guarded the aim with `test_flashlight_aims_a_tight_forward_spot` (lamp on the head, straight
ahead, cone ≤ 20°, starts off).
### NPC faces, hands and sleeves
- [x] Rebuilt the avatar detail so skin and clothing stop reading as low-poly at conversation
distance: each eye has a sclera, iris and upper lid; added a brow ridge, cheekbones, a nose
bridge and tip, ears with lobes, and split upper/lower lips. Hands have four fingers plus a
thumb, and every sleeve ends in a cuff ring.
- [x] Raised `CHAR_MAX_VERTS` 4000 → **5000** (documented in the code and
`docs/blender-assets.md`) after measuring 16 avatars at ~6.9 ms of a 50 ms frame on a single
skinned mesh, so `npc_coat` fits at 4807 vertices; the character range is now 3,974–5,026
triangles and 3,744–4,807 vertices. Body decimation 0.36 → 0.26.
- [x] Rebuilt all 8 variants and re-baked their AO maps (`assets/models/blender/npc/*_ao.png`),
re-imported, and kept `blender_assets.py audit` at exit 0.
- [x] Added a staged portrait capture: `capture_realism.gd` gained a `portrait` stage that
freezes one NPC, parks the crowd out of frame, stands the subject on open sidewalk, turns
it into the camera and puts the guard on the sunlit side; `--portrait-type=N` picks a role.
It is the only capture where a face fills the frame, and it feeds the new
`npc_portrait_day` concept (`tools/scene_concepts.py` `SHOTS`).
- [x] Regenerated the gallery shots and re-ran the judge: like-for-like overall 2.33 → 2.44,
lighting 3.0 → 3.22, materials 2.22 → 2.44. The `characters` sub-score stayed flat
(~2.2–2.3) because the target is a photoreal face, so avatar quality is judged from the
portrait rather than the aggregate.
- [x] Re-checked the `screen_designs.py judge` default model (`google/gemini-2.5-flash-lite`):
it works but intermittently answers HTTP 400 "API key not valid" (one shot in ten on
2026-11-01); `--model google/gemini-2.5-flash` remains the fallback.
## Visual Alignment — UI Paths and Art Loader ## Visual Alignment — UI Paths and Art Loader
Completed 2026-11-01. Completed 2026-11-01.
@@ -1127,3 +1183,156 @@ marquee text, hand-authored tree shapes) are content, not grading.
- [x] Align the body-camera timeline to its mockup. - [x] Align the body-camera timeline to its mockup.
- [x] Align scene lighting, fog, and post-processing for `venue_overview` and `late_night_avenue`: lowered the morning sun to 34° (`sun_yaw` is now a phase value), warmed and thickened the day haze, raised the night glow, and brightened the street albedo. `judge` moved `01-venue-overview` to **match** (0.90) and `05-late-night-avenue` from diff to **review** (0.10); `compare` stays 11 good / 0 bad. - [x] Align scene lighting, fog, and post-processing for `venue_overview` and `late_night_avenue`: lowered the morning sun to 34° (`sun_yaw` is now a phase value), warmed and thickened the day haze, raised the night glow, and brightened the street albedo. `judge` moved `01-venue-overview` to **match** (0.90) and `05-late-night-avenue` from diff to **review** (0.10); `compare` stays 11 good / 0 bad.
- [x] Fix the body-camera REC/clock overlay overlapping the shift/rep/crowd status in the top-right HUD. - [x] Fix the body-camera REC/clock overlay overlapping the shift/rep/crowd status in the top-right HUD.
## Documentation Consistency
Completed 2026-11-01. `PLAN.md` was compacted from the historical implementation log into a
product/design plan (674 → 187 lines): escalation, NPC/venue content and design constraints stay,
while the completed control/rendering pass specs were folded into a short Presentation and
Controls section. `README.md` now reflects the implemented venues, Blender/v2 asset pipeline, the
custom test runner, the Web target, the current release state, and the capture smoke. The
architecture's version, runtime-art loader, asset/tooling blocks, Gitea CI/build/publish pipeline,
Web deployment and release artifact list were brought up to date. `MEMORY.md` was cleaned of
obsolete uncommitted/active-pass claims and now points at the current follow-ups.
- [x] Re-check `PLAN.md`/`MEMORY.md` against the current build, update `README.md` and `docs/architecture.md`, and compact the historical PLAN sections.
## NPC gear fit (v0.6.1)
Completed 2026-11-01. Eyes, bag and helmet now fit the measured body silhouette; portrait staging and gallery shots are refreshed.
The 2026-11-01 detail pass fixed the bodies but not the props worn on them. In the portrait
capture (`--stages=day,portrait`, see the report in `docs/screens/realism/2026-11-01-npc/`) the
sunglasses span the whole upper face and the shoulder bag cuts through `npc_coat`'s skirt. Blender sources for gear
live in `tools/blender_assets.py` (`_build_gear()`), the contract in `docs/blender-assets.md`
(gear budget 1500 tris, `GEAR_ATTACH` points in `NpcAvatar`).
- [x] Measure the fit in Blender, not by eye: `python3 tools/blender_assets.py fit` prints each
character's measured head width and hip/skirt radius, then every head/back/chest gear
bounding box with head-band status or skirt clearance. Current measurements: head width
0.315 m; coat skirt 0.225 m, dress 0.260 m; sunglasses 0.282 m (0.90x head); shoulder-bag
x-clearance -0.025 m on the coat (radial -0.031 m on the dress).
- [x] Narrow `sunglasses` from 0.28 m to ~0.20 m. The hinges moved to ±0.075 (0.035 box), the
arms to ±0.098, and the lenses are 0.024 m radius at the same `y=-0.152`, `z=0.035` plane.
Rebuilt GLB/AO: measured width **0.202 m** (0.64x head), still inside the head band.
Also fixed `capture_realism.gd` so `--portrait-type=N` is actually parsed (it was documented
but ignored).
- [x] Fix `shoulder_bag` clipping through `npc_coat`/`npc_dress`: narrowed the bag to 0.06 m and
moved it outboard (centre x=0.292); the body's inner face is now 0.262 m, clearing the coat
(0.225) by 0.037 m and the dress (0.260) by 0.002 m. The strap's lower attach points sit on
the same outboard line. `fit` now measures the bag body separately from the torso strap.
- [x] Same pass for `cap`/`beanie`/`hood`/`firefighter_helmet`: rendered the cap (vest), hood
(hoodie), and firefighter helmet on their characters. Cap and hood clear the brow/ears and
the hood leaves the face readable; the helmet brim sat on the brow, so the whole helmet was
lifted 2 cm and rebuilt/re-baked. Beanie uses the same capped-shell sizing as the cap and is
currently unused; it is covered by the upcoming headgear regression check.
- [x] Rebuild the gear (`build --only <18 gear names>`) and re-bake AO (`tools/bake_ao.py --only
<same>`), then `godot --headless --path . --import` (clean). Blender's GLB export is
byte-nondeterministic for unchanged assets (padding/index order changes), so the no-op churn
was reverted; only the intended sunglasses, bag, and helmet assets are committed.
- [x] Add a regression test: `tools/test_blender_assets.py` limits the glasses to 0.70x head
width and requires positive bag clearance on both coat and dress;
`test_npc_character.test_head_and_back_gear_clear_the_body_silhouette` loads the gear on
six roles and checks head-parent/width/brow bounds plus back-parent/0.26 m outer radius.
- [x] Re-capture the portrait (`--stages=day,portrait`) and the gallery shots; record the
before/after in `docs/screens/realism/2026-11-01-gear/`. The portrait staging now turns the
rig toward the camera (the old yaw was 180° out) and hides the blue focus disc, which was
covering the face in close-ups. `check_screenshots.sh` is 11 good / 0 bad; the judge went
from overall 2 / characters 1 to overall 3 / characters 2.
- [x] Consider a second character close-up for a hoodie/winter NPC so headgear is covered too.
`--portrait-type=N` now overrides the hooded-subject skip, but the day roster does not spawn
types 5-7, so no hooded subject was available; deferred as a scenario-spawn follow-up.
## Venue geometry and the committed gallery (v0.6.1)
Completed 2026-11-01. All three venues now build distinct geometry from `WorldBuilder.VENUE_SPECS`; the gallery has Joke Shop and TV Studio views (13 good / 0 bad). The vision-judge retry is tracked separately because the API key was invalid.
Today all three venues share the Hollywood `WorldBuilder` geometry, so the Joke Shop and studio
concepts are only compared on lighting (noted in `tools/scene_concepts.py` `SHOTS`). They also
have no stage in the committed `docs/screenshots/` gallery, only in `capture_realism.gd`.
- [x] Extract a geometry spec per venue instead of branching inline: `WorldBuilder.VENUE_SPECS`
now holds facade/stair/street/nav dimensions, window/pilaster rows, guard spawn, feature
flags and prop placements for Hollywood, Joke Shop and TV Studio. Unknown ids fall back to
Hollywood; `test_venue_geometry_specs_are_distinct_and_defaulted` checks the narrow Joke Shop
facade, disabled skyline/marquee, studio bulb-canopy flag and fallback.
- [x] Joke Shop: narrow brick alley (10–12 m wide), one warm wall lantern as the key light,
recessed door with a step, shuttered window, dumpster/AC clutter, no skyline ring — the
alley concept has depth, not a horizon. `_build_alley_dressing()` adds the side walls,
shutters, step, dumpster collider and AC boxes; the lantern registers a warm night light.
- [x] TV Studio: granite/stone facade, bulb canopy instead of the marquee, glossy black car on
the curb, stanchions and barrier line at the door, no palm trees. The stone facade uses the
concrete/stone texture, `_build_bulb_canopy()` adds the warm bulb row and lights, and the
studio-specific prop placements place stanchions and barriers without the boulevard palms.
- [x] Keep navigation working: `WorldBuilder` reads nav min/max from each spec, the new
`test_navigation_mesh_matches_each_venue_bounds` checks all three meshes, and
`GameManager._rebuild_world_for_venue()` replaces the world on venue selection/cycling
without touching the guard/UI. The Joke Shop/Studio scenario-content and session-shell
smokes still pass and NPCs reach the door.
- [x] Split the concept mapping so each venue is judged on its own frame: `SHOTS` now maps the
gallery shots to `joke_shop_alley_evening` / `studio_stage_door_night`, and the
"shared geometry" caveat is gone. `screen_designs` maps `12-joke-shop` and
`13-studio-arrival` to their existing concept references.
- [x] Add both venues to the committed gallery: two new stages in
`src/test/capture_screenshots.gd` (`12-joke-shop.png`, `13-studio-arrival.png`) reuse the
existing `joke_shop_queue` / `studio_arrival` designs. `check_screenshots.sh` is **13 good /
0 bad**; the new screenshots are 18.2 / 32.6 MAE against their references.
- [-] Update `docs/architecture.md` (venue geometry), `README.md` (gallery) and the screenshot
table when the stages land.
## Forward+ desktop preset and a volumetric flashlight (v0.7.1)
Completed 2026-11-01. The optional Desktop HQ build uses Forward+ with volumetric fog, SDFGI and a volumetric flashlight; Compatibility/Web/headless remain the default. The entrance A/B improved the metric gap 1.568 → 1.187. The vision-judge retry remains tracked separately because the API key was invalid.
The Compatibility renderer has no volumetrics, so the flashlight is a tight pool of light with
no visible shaft. A second desktop build can render it properly without touching the CI/default
path.
- [x] Write the preset as an `override.cfg` (Godot reads it beside the binary/project) that sets
`rendering/renderer/rendering_method="forward_plus"`, enables `volumetric_fog_*` and SDFGI,
and keeps the Compatibility build as the default (`project.godot` unchanged). The preset
lives in `packaging/desktop_hq/`; `local lights adapted for Godot 4.7 falloff` too.
- [x] Gate the effect in code: only give the flashlight a volumetric component when the renderer
is Forward+ (`RenderingServer.get_rendering_device() != null`), so Web/CI builds are
untouched and the existing headless tests keep passing. Environment volumetric fog/SDFGI
use the same gate; tests assert the headless lamp is non-volumetric.
- [x] Tune `light_volumetric_fog_energy`/`fog_density` for the LATE phase and re-check the
entrance so the shaft does not wash the facade again. LATE density is 0.020 and the lamp
volumetric energy is 3.0; the entrance read improves without bleaching the brick.
- [x] A/B the two renderers with `capture_realism.gd` (`--stages=late --flashlight`, two `--out`
dirs) and record the diff in `docs/screens/realism/2026-11-01-forward-plus/`: entrance
metric gap 1.568 → 1.187, two-shot mean 0.907 → 0.723. Judge scores are blocked by the
invalid API key and tracked with the other judge retry.
- [x] Add the preset to `tools/build_releases.sh` and `docs/RELEASING.md` as an optional
"desktop HQ" artifact, and note the runtime requirement (Vulkan driver) in `README.md`.
`gig-line-desktop-hq-<version>.zip` packages the Linux/Windows binaries, override.cfg and
README; `publish_release.sh` uploads it when present.
- [x] Test: a headless run must still boot when Forward+ is unavailable (the preset must degrade
to Compatibility instead of failing to start). Verified with the preset in the project
root: `godot --headless --path . --quit-after 5` exits 0 and starts the game; an exported
HQ binary also boots with Vulkan Forward+ under Xvfb.
## Vision judge reliability (v0.7.2)
Completed 2026-11-01. The judge now discovers the cheapest current OpenRouter image+JSON model (falling back through known candidates), reports the model used, and uses the valid key. Full screen-design judge: 13/13 match, 0 diff.
The judge failed with an invalid-key HTTP 400 even though the `.env` key was later
valid, and a hard-coded model can disappear or be unavailable. Make the judge discover
current OpenRouter vision models, prefer cheap/structured-output-capable ones, and fall
back when a provider rejects a request.
- [x] Add `tools/openrouter_judge.py`: query `/api/v1/models`, filter image-input models
with `response_format` support, rank by estimated token/image cost, and keep a
small known-good fallback list.
- [x] Let both `screen_designs.py judge` and `scene_concepts.py realism --judge` use
`--model auto` (the new default), retry a transient provider error once, and move
to the next model on model/provider failures. An invalid key fails with exit 2.
- [x] Print/report the selected model(s) and fallback order; the judge JSON records the
selected model and the selected-parameter cost is inferred from the public catalogue.
- [x] Add `tools/test_openrouter_judge.py` with a fake catalogue and mocked calls for ranking,
explicit/env override, invalid-key stop, transient retry and provider fallback.
- [x] Re-run the full screen-design judge and the Forward+ A/B judge with the valid
`OPENROUTER_API_KEY`: screen designs are **13/13 match, 0 diff**; the Forward+ report
records the selected model and ~$0.006 spend.
- [x] Update `docs/asset-tooling.md`, `MEMORY.md`, `README.md` and `CHANGELOG.md`; the
public catalogue picks the cheapest image+JSON model (`z-ai/glm-5.3-flash` at run time).
+112 -56
View File
@@ -4,15 +4,30 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
## Decisions ## Decisions
- The visual-alignment path/loader/verification work is complete and recorded in `DONE.md`; - The visual-alignment path/loader/panel/HUD work and the realism lighting pass are complete and
the only open visual task is scene lighting/fog, held until the current realism pass lands. recorded in `DONE.md`; remaining visual items are the realism follow-ups in `TODO.md`.
- `PLAN.md` is a compact product/design document, not an implementation log. Historical control
and v0.3.0 specs were folded into its Presentation and Controls section; put new step-by-step
work in `TODO.md`, not the plan.
- Editable Blender sources under `assets/models/blender_src/` are gitignored and untracked - Editable Blender sources under `assets/models/blender_src/` are gitignored and untracked
(46 `.blend` files removed from the index on 2026-11-01); the `.gdignore` marker stays tracked. (46 `.blend` files removed from the index on 2026-11-01); the `.gdignore` marker stays tracked.
- `screen_designs.py judge` default `google/gemini-2.5-flash-lite` worked on 2026-11-01 - `screen_designs.py judge` default `google/gemini-2.5-flash-lite` worked on 2026-11-01
(7 match / 4 review / 0 diff); `--model` remains available. (7 match / 4 review / 0 diff); `--model` remains available.
- `capture_realism.gd` parses `--portrait-type=N`; it was documented but ignored before the gear
pass. Portrait captures need the requested NPC type to have spawned, otherwise the stage can
finish with an empty frame; use the Blender character previews (`render --only npc_coat`) for
deterministic gear checks.
- Headgear: the firefighter helmet was lifted 2 cm after the brow/face pass; cap and hood were
visually checked on `npc_vest`/`npc_hoodie`. The beanie is unused and shares the cap shell.
- Gear fit is measured with `python3 tools/blender_assets.py fit` (stdlib, reads the GLBs):
head width uses skin vertices weighted to Head in y 1.55–1.81; clothing radius is the cloth
skirt in y 0.30–0.52 with a pants hip fallback. The command reports per-character gear boxes,
head-band status and x/radial skirt clearance; `test_blender_assets` covers its shape.
- Post-realism work is split by theme, not by date: finished items are archived in `DONE.md`, and
`TODO.md` carries the v1.0.0 gap register and milestone roadmap. New work belongs there, not in
a revived historical plan document.
- Graphics v0.6.0 plan (2026-11-01): NPCs move to Blender-authored skinned meshes - Graphics v0.6.0 plan (completed 2026-11-01; ADR-0013): NPCs move to Blender-authored skinned meshes. `tools/npc_pipeline.py` builds
(`docs/plan-graphics-v0.6.md`, ADR-0013). `tools/npc_pipeline.py` builds
`assets/models/generated/npc_base.glb`; `NpcAvatar` keeps the procedural `_build_body()` `assets/models/generated/npc_base.glb`; `NpcAvatar` keeps the procedural `_build_body()`
fallback. Preserve `Rig/Head`, the limb node names, and the seed-deterministic fallback. Preserve `Rig/Head`, the limb node names, and the seed-deterministic
`outfit_for`/`layer_for`/`hand_item_for` helpers. Blender = flatpak 5.2.0 LTS, sandbox cwd `outfit_for`/`layer_for`/`hand_item_for` helpers. Blender = flatpak 5.2.0 LTS, sandbox cwd
@@ -27,6 +42,26 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
- 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. - 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. - RNG: NPC reactions and event scheduling are seedable for deterministic tests.
- **Mouse look (v0.7.3):** guard look is handled in `GuardController._input()` (before GUI), not `_unhandled_input`; focus/click-back recaptures the cursor and the HUD is made mouse-transparent recursively. Regression tests cover yaw/pitch and HUD filters.
- **Tutorial foundation (0.8.0 work):** `docs/tutorial.md` is the eight-beat design; `src/game/tutorial.gd` is a pure `RefCounted` state machine with skip/restart and tests. It is not yet wired into `GameManager`; when wiring it, add an explicit QA bypass because captures/smokes would otherwise see prompts.
- **Documentation ownership (2026-11-01):** `docs/architecture.md` §12 defines the source-of-truth map and consolidation rules. Obsolete/historical plan documents are removed rather than kept; current work belongs in `TODO.md`. README/development-status/Blender AO/Forward+ stale claims were corrected. The v1.0.0 gap register and 0.8/0.9/0.9.5/1.0 roadmap live in `TODO.md`.
- **Vision judge (v0.7.2):** `tools/openrouter_judge.py` reads `/api/v1/models`, filters image-input models with `response_format`, and ranks by advertised prompt/image/completion prices. `--model auto` (default) uses the cheapest (`z-ai/glm-5.3-flash` on 2026-11-01) with fallbacks; explicit `--model`/`GIG_LINE_JUDGE_MODEL` wins. Auth errors abort, provider/model errors fall through. Full screen judge 13/13 match, 0 diff; A/B judge ~$0.006.
- **Desktop HQ preset (v0.7.1):** `packaging/desktop_hq/override.cfg` switches a
copied executable to Forward+; `Materials.forward_plus_active()` gates
volumetric fog/SDFGI and `light_volumetric_fog_energy`, and
`Materials.modern_light_falloff()` compensates for Godot 4.7's much harsher
local-light falloff. LATE volumetric density is 0.020 with flashlight
volumetric energy 3.0. `tools/build_releases.sh` packages the Linux/Windows
binaries + override into `gig-line-desktop-hq-<version>.zip`;
`publish_release.sh` uploads it when present. A/B report:
`docs/screens/realism/2026-11-01-forward-plus/` (entrance gap 1.568→1.187).
- **Local Godot toolchain (2026-11-01):** `~/.local/bin/godot` is 4.7.2 stable;
4.3 stable is kept as `~/.local/bin/godot-4.3.stable`. Export templates for
4.7.2 are installed under `~/.local/share/godot/export_templates/4.7.2.stable`
(4.3 remains). The project and CI still pin 4.3 in `project.godot`/the Gitea
workflow; 4.7.2 imports, runs the 231-test suite, session-shell smoke and a
Linux export successfully. Godot 4.7 writes `*.gd.uid` files; they are kept
out of this clone's status via `.git/info/exclude` until the project upgrades.
- **Blender invocation (v0.6.0):** `flatpak run org.blender.Blender --background --python - **Blender invocation (v0.6.0):** `flatpak run org.blender.Blender --background --python
/run/media/system/DATA/Documents/06-games/security-game/tools/npc_pipeline.py -- build`. /run/media/system/DATA/Documents/06-games/security-game/tools/npc_pipeline.py -- build`.
The flatpak cannot read `/tmp`; use absolute repo paths. `assets/models/npc/.gdignore` is The flatpak cannot read `/tmp`; use absolute repo paths. `assets/models/npc/.gdignore` is
@@ -55,27 +90,14 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
## Handoff (read this first) ## Handoff (read this first)
- **Status + active passes (2026-11-01, moved from `TODO.md`):** v0.6.0 shipped — the Blender - **Status (2026-11-01, after the venue-geometry pass):** v0.7.0 is released. Post-v0.6.0 the
character/gear/prop library is integrated, AO maps are baked and wired, and the environment realism pass, the visual-alignment pass and the NPC face/hand pass all landed. Headless
textures are lifted to 1024² with normal maps (see `DONE.md`). Headless **227/227**, Python **229/229**, Python tooling **87/87**, `tools/check_screenshots.sh` **11 good / 0 bad**,
**86**; the scheduled presentation pass is complete and the realism pass is adopted `screen_designs.py compare` 11 good / 0 bad, `scene_concepts.py audit` all GOOD,
(per-phase skyline panoramas, v2 surface tiles, retuned lighting). Active in `TODO.md`: `blender_assets.py audit` exit 0, `smoke_npc_budget` OK (16 avatars, 6.94 ms of 50 ms).
- **Visual alignment with rendered mockups:** the AI references in `docs/screens/designs/` `PLAN.md` is compact; README/architecture track the current controls, asset pipeline, CI and
define the intended look of every screen. The pass reorganizes generated-image storage, release artifacts. The historical notes below remain useful for implementation gotchas; for
loads the proposed UI/scene art at runtime (`src/game/ui/art.gd`, `assets/images/`), and current tasks use `TODO.md`.
aligns the selection screens, HUD, and scene visuals with those mockups — verified with
`screen_designs.py compare` + `judge`.
- **Realism pass** (plan: `PLAN.md` → Visual Quality Direction → Realism pass): replace the
flat skyline backdrop and the flat surface tiles with AI-generated realistic backgrounds
and textures, add example 3D scene frames as art direction, and measure with the
screenshot tools. Rule: never overwrite an existing image — new outputs get new paths
(`next_free_path` -vN suffix). Raw → `assets/textures/ai/v2/raw/` (gitignored); candidates
→ `assets/textures/ai/game_ready/v2/` (gitignored pool, only validated picks tracked,
ADR-0014); scene refs → `docs/screens/concepts/`; trials → `docs/screens/realism/<run>/`.
Realism report 2026-11-01-v2 mean judge scores: backdrop 2.6 → 3.6 (v2 day + night
skyline) → **4.0** (v2 day + dusk skyline for the Evening phase); texture detail 1.2 →
1.6–1.75; overall 1.6 → 2.0. Scores swing ±1–2 between runs (trend only); the
`characters` score moved 3.0 → 1.0 with identical NPCs, showing the noise.
- **Phase 2 — AI texturing (this agent, DONE 2026-11-01).** Phase 1 assets were - **Phase 2 — AI texturing (this agent, DONE 2026-11-01).** Phase 1 assets were
already in the tree (untracked) and are preserved. Generated six seamless AI already in the tree (untracked) and are preserved. Generated six seamless AI
@@ -89,8 +111,8 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
(writes `assets/textures/npc/palette.json` via `model_pipeline.dominant_colors`). (writes `assets/textures/npc/palette.json` via `model_pipeline.dominant_colors`).
`write_slot_textures()` now skips the albedo for slots with an AI override `write_slot_textures()` now skips the albedo for slots with an AI override
(normal/ORM stay procedural). Blender `build` re-run: GLB audit ok, 1204 tris / (normal/ORM stay procedural). Blender `build` re-run: GLB audit ok, 1204 tris /
2488 verts, AI albedo embedded for cloth+pants. **Do not commit yet** — Phase 1 2488 verts, AI albedo embedded for cloth+pants. Phase 2 completed and shipped with
files are uncommitted; coordinate the commit with the Phase 1/3 agents. the v0.6.0 asset library.
**Import concurrency:** a parallel agent is running **Import concurrency:** a parallel agent is running
`timeout 300 godot --headless --editor --quit --path .` and then checks `timeout 300 godot --headless --editor --quit --path .` and then checks
`assets/models/generated/npc_base.glb.import` — treat that as the canonical `assets/models/generated/npc_base.glb.import` — treat that as the canonical
@@ -118,8 +140,6 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
`game_ready` textures, generated models, NPC maps), and refreshed `game_ready` textures, generated models, NPC maps), and refreshed
dev-status / not-yet lines; `docs/architecture.md` Level 2 now lists dev-status / not-yet lines; `docs/architecture.md` Level 2 now lists
`npc_pipeline.py` + `asset_pipeline.py` and the confirmed-asset locations. `npc_pipeline.py` + `asset_pipeline.py` and the confirmed-asset locations.
**Foreign, left uncommitted:** a TODO section "Bug Fix NPCs walking backwards"
from another agent — do not touch or commit it.
- Repo: `/run/media/system/DATA/Documents/06-games/security-game` (also reachable at - 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). `/var/home/zwitschi/Documents/06-games/security-game`). Godot: `~/.local/bin/godot` (4.3.stable).
@@ -183,11 +203,38 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
autocorrelation) + `align_stripes()` (resample to whole periods) replace the edge crossfade autocorrelation) + `align_stripes()` (resample to whole periods) replace the edge crossfade
for `awning_canvas`; `audit_striped_tile()` compares the wrap step with the harshest interior for `awning_canvas`; `audit_striped_tile()` compares the wrap step with the harshest interior
step. step.
**Judge:** `google/gemini-2.5-flash-lite` currently answers HTTP 400 "API key not valid" for **Judge:** the default `google/gemini-2.5-flash-lite` mostly works but intermittently answers
this key, so runs use `--model google/gemini-2.5-flash`; scores are noisy (±1–2), so HTTP 400 "API key not valid" (one shot of ten on 2026-11-01); re-run or pass
`--model google/gemini-2.5-flash` when it does. Scores are noisy (±1–2), so
`realism --repeats 3` averages and `summarize()` reports a like-for-like mean over the shots `realism --repeats 3` averages and `summarize()` reports a like-for-like mean over the shots
every set captured. Final: overall 0.8→1.0, backdrop 0.6→1.6, metric gap 0.444→0.348. every set captured. Final: overall 0.8→1.0, backdrop 0.6→1.6, metric gap 0.444→0.348.
`screen_designs.py judge` gained one retry for the same transient 400. `screen_designs.py judge` gained one retry for the same transient 400.
**Character portrait capture (2026-11-01):** `capture_realism.gd` has a `portrait` stage
(`npc_portrait` -> concept `npc_portrait_day`) because crowd shots cannot judge avatars. It
freezes one NPC, parks the rest of the crowd 140 m away (a passer-by otherwise blocks the
frame), moves the subject to an open sidewalk patch, turns it into the camera and stands the
guard on the sunlit side. Needed because the NPC FSM re-writes `rotation.y` every physics tick
(so the yaw must be set after `set_physics_process(false)`), the venue's morning sun leaves a
face in shadow from most angles, and `visible = false` does not keep the crowd out of frame.
`--portrait-type=N` forces a role; hooded roles (trespasser/violent/crisis) are skipped.
**Flashlight (2026-11-01, final):** a *tight* `SpotLight3D` on `Head`: `spot_range` 20,
`spot_angle` 14, `light_energy` 4.2, `spot_attenuation` 0.9, forward `rotation_degrees = 0`,
starts invisible. A fake additive light shaft was tried twice (cylinder with a UV ramp, then a
vertical-fade `ShaderMaterial`) and **rejected both times**: on GL Compatibility a first-person
camera inside the cone renders it as a filled disc and the far ring shows, so
`Materials.light_beam()` and the beam mesh are gone. Regression guard:
`test_flashlight_aims_a_tight_forward_spot` in `src/test/tests/test_door_and_flashlight.gd`
(lamp on the head, aims straight, cone ≤ 20°, starts off). Night captures are staged in LATE
(`elapsed_s` 760), not dusk.
**NPC detail pass (2026-11-01):** `_face()` now builds sclera + iris + upper lid per eye, brow
ridge, cheekbones, nose bridge/tip, ears with lobes and split upper/lower lips; the new
`eye_white` material is a *fixed-colour* slot, so `_apply_outfit_materials()` leaves it white.
`_hands_and_shoes()` has four fingers plus a thumb and `_clothing()` adds `Cuff` tori.
Budget work: `CHAR_MAX_VERTS` 5000 (see the library note), body decimation 0.26, upper lids are
boxes rather than spheres, and knuckle spheres/nostrils were cut to stay inside it.
**Orientation debugging:** to check a GLB's front axis, compare the mean Z of the `eye_white`
surface (face) and of the `rubber` sole verts (toes) — all 8 avatars are +Z, so any
"they walk backwards" symptom is a yaw bug, not an export flip.
- **Blender asset library (v0.6.0, integrated 2026-11-01):** - **Blender asset library (v0.6.0, integrated 2026-11-01):**
`tools/blender_assets.py` (`build`/`render` in Blender; `audit`/`sheet`/`list` stdlib) writes `tools/blender_assets.py` (`build`/`render` in Blender; `audit`/`sheet`/`list` stdlib) writes
`assets/models/blender/{npc,npc_gear,props}/*.glb`, `manifest.json` (integration source of `assets/models/blender/{npc,npc_gear,props}/*.glb`, `manifest.json` (integration source of
@@ -195,8 +242,10 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
`docs/blender_previews/sheet_*.jpg` (per-asset PNG renders are gitignored). 8 characters, 18 `docs/blender_previews/sheet_*.jpg` (per-asset PNG renders are gitignored). 8 characters, 18
gear, 20 props; notes in `docs/blender-assets.md`. Characters are drop-in for `npc_base` gear, 20 props; notes in `docs/blender-assets.md`. Characters are drop-in for `npc_base`
(same mesh node name, bones at the same rest positions, five tint slots) and stay under the (same mesh node name, bones at the same rest positions, five tint slots) and stay under the
6000/4000 budget only because of body decimation (0.36) + `smart_project` at 80°: glTF vertex 6000/4000 budget only because of body decimation (0.26) + `smart_project` at 80°: glTF vertex
count includes UV-seam splits, so extra beveled detail quickly breaks 4000. count includes UV-seam splits, so extra beveled detail quickly breaks the limit. The 2026-11-01
face/hand pass raised `CHAR_MAX_VERTS` to 5000 (tris 3,974–5,026) after measuring 16 avatars at
~6.9 ms of a 50 ms frame; `npc_pipeline.MAX_VERTICES`/`MAX_TRIANGLES` (base mesh) are unchanged.
**Gotchas:** `bisect_plane` and thin bevels leave zero-area triangles (Godot warns "non-finite **Gotchas:** `bisect_plane` and thin bevels leave zero-area triangles (Godot warns "non-finite
normal in LOD generation"); `_clean()` + a weld after bisect fix it, and the audit rejects them. normal in LOD generation"); `_clean()` + a weld after bisect fix it, and the audit rejects them.
Fixed-colour detail materials carry a 4×4 texture because `_apply_outfit_materials()` sets Fixed-colour detail materials carry a 4×4 texture because `_apply_outfit_materials()` sets
@@ -210,6 +259,17 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
same colliders/obstacles, and collects library emissives for `set_night_mode()`. same colliders/obstacles, and collects library emissives for `set_night_mode()`.
`LIBRARY_TRIANGLE_BUDGET` = 60k (current ~27,986). Library props are excluded from `LIBRARY_TRIANGLE_BUDGET` = 60k (current ~27,986). Library props are excluded from
`PROP_PLACEMENTS` only when they need dedicated slots (door/marquee/sedan/handrail/lantern). `PROP_PLACEMENTS` only when they need dedicated slots (door/marquee/sedan/handrail/lantern).
**Gear fit (v0.6.1):** `fit` measures head width (0.315 m), skirt/hip radius and gear bounds;
sunglasses are 0.202 m, the bag's body inner face is 0.262 m (coat 0.225, dress 0.260), and the
helmet is lifted 2 cm above the brow. `test_npc_character` guards head/back gear in Godot.
Blender's GLB export is byte-nondeterministic for unchanged assets (padding/index order), so do
not commit a full no-op rebuild; rebuild only the assets whose geometry changed.
- **Venue geometry (v0.6.1+):** `WorldBuilder.VENUE_SPECS` is the per-venue table; `WorldBuilder.new(root, id)` / `set_venue()` select it, unknown ids fall back to `hollywood_building`. Dimensions, window/pilaster rows, stairs, street, nav bounds, guard spawn, feature flags and prop placements are read from the spec. `GameManager.world_root` holds only the world; `_rebuild_world_for_venue()` frees/recreates it on venue selection/cycling so guard/UI survive. Joke Shop uses procedural alley walls/shutters/dumpster/AC; TV Studio uses a stone facade, bulb canopy, car paint and barrier placements. Gallery shots 12/13 use the existing `joke_shop_queue`/`studio_arrival` references.
- **Screen-design judge (2026-11-01):** `screen_designs.py judge` could not run because `OPENROUTER_API_KEY` returned HTTP 400 "API key not valid". `check_screenshots.sh` still passed 13/13; the judge retry is a TODO follow-up.
- **Portrait capture:** `capture_realism.gd` faces the **Rig** toward the camera (using the
inverse look direction) and hides the blue `FocusCircle`; the old code used the wrong yaw and
left the focus disc over the face. An explicit `--portrait-type=N` now overrides the hooded
skip, but the day roster spawns none of types 5-7, so a hooded close-up is deferred.
- **v0.6.0 environment pass:** `tools/bake_ao.py` bakes 256² AO maps per library asset - **v0.6.0 environment pass:** `tools/bake_ao.py` bakes 256² AO maps per library asset
(`<name>_ao.png`, red channel) and `Materials.apply_ao()`/`SurfaceMaterials` wire them in; (`<name>_ao.png`, red channel) and `Materials.apply_ao()`/`SurfaceMaterials` wire them in;
`tools/make_normal_maps.py` derives tangent normals for the 1024² brick/concrete/asphalt `tools/make_normal_maps.py` derives tangent normals for the 1024² brick/concrete/asphalt
@@ -239,19 +299,19 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
adopted (custom runner covers it without an editor plugin). 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. - Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg.
## Decisions ## UI / Presentation Decisions
- Runtime UI/scene art is loaded through `src/game/ui/art.gd` (`assets/images/ui/` then `scenes/`); missing files return null so panels keep procedural fallbacks. - Runtime UI/scene art is loaded through `src/game/ui/art.gd` (`assets/images/ui/` then `scenes/`); missing files return null so panels keep procedural fallbacks.
- Screen mockups with baked-in controls are used as backdrops plus an opaque `ArtPanel` cover, not as clickable full-screen skins. - Screen mockups with baked-in controls are used as backdrops plus an opaque `ArtPanel` cover, not as clickable full-screen skins.
- Radio, wand, inspection, and body-camera timeline panels use their reference art as backdrops; forms sit on transparent, padded panels over the covered mockup controls. - Radio, wand, inspection, and body-camera timeline panels use their reference art as backdrops; forms sit on transparent, padded panels over the covered mockup controls.
- HUD target names use a boxed chip under the crosshair; escalation has a five-segment bar below the text label. The body-camera REC/clock block starts at y=92 on the right so it clears the shift/rep/crowd text. - HUD target names use a boxed chip under the crosshair; escalation has a five-segment bar below the text label. The body-camera REC/clock block starts at y=92 on the right so it clears the shift/rep/crowd text.
- Staged captures were regenerated from HEAD in a clean worktree; the main working tree's `docs/screenshots` is being rewritten by the realism pass, so do not overwrite it from that run. - Staged captures in `docs/screenshots/` are committed and were last regenerated after the realism and NPC passes; re-run `tools/check_screenshots.sh` (not a partial capture) before replacing them.
- `tools/check_screenshots.sh` is the repeatable capture+compare smoke; both capture scripts accept `--out=res://docs/screenshots`. Judge verdicts on the staged set were 7 match / 4 review / 0 diff. - `tools/check_screenshots.sh` is the repeatable capture+compare smoke; both capture scripts accept `--out=res://docs/screenshots`. Judge verdicts on the staged set were 7 match / 4 review / 0 diff.
- Venue selection used to leave `guard.input_locked` true and the mouse visible; `_start_shift()` must call `_refresh_freeze()` after starting the clock. - Venue selection used to leave `guard.input_locked` true and the mouse visible; `_start_shift()` must call `_refresh_freeze()` after starting the clock.
## Current State ## Current State
- **v0.3.0 pass (2026-10-31, uncommitted):** rendering, generated prop models, screen - **v0.3.0 pass (2026-10-31, historical; shipped):** rendering, generated prop models, screen
catalogue, aim-free selection. See ADR-0011 (selection) and ADR-0012 (materials/props). 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 - `src/game/materials.gd` (`SurfaceMaterials`) is now the single source of surface
recipes + `configure_environment()`. `GameManager.PHASE_LIGHTING` gained recipes + `configure_environment()`. `GameManager.PHASE_LIGHTING` gained
@@ -301,7 +361,7 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
`carried_items` per spawn (seeded). Tests in `src/test/tests/test_access_loadout.gd`. `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. - Wand shortcut is **`C`**, not `W` (`W` is walk). `radio_emergency` input map no longer includes W.
`hud.controls` / `hud.screen_first` updated. `hud.controls` / `hud.screen_first` updated.
- Control pass (in tree, uncommitted, smoke-tested): cone focus via `src/game/npc_focus.gd`, - Control pass (historical; smoke-tested): cone focus via `src/game/npc_focus.gd`,
accel/decel, gravity only airborne, `input_locked` freeze, Esc no longer on the guard. 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`. `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 `src/game/ui/action_radial.gd` doughnut (LMB/Space open, A/D/arrows, Space/click confirm, Esc/RMB
@@ -313,7 +373,7 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
awning, and marquee are visible; step treads rise toward the door; `ShiftCoordinator` pulls the 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); event scheduler once per simulated second (was every physics frame → >1200 NPCs in seconds);
`GameManager.MAX_LIVE_NPCS = 16` caps live avatars. `GameManager.MAX_LIVE_NPCS = 16` caps live avatars.
- **Session shell (2026-10-31, uncommitted):** the game now boots into `StartMenu` - **Session shell (2026-10-31, historical):** the game boots into `StartMenu`
(`src/game/ui/start_menu.gd`, layer 20) instead of a live shift. `GameManager._on_venue_selected()` (`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()`. 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 / Esc with nothing open now pauses (`PauseOverlay`, layer 18) → resume / restart / settings /
@@ -328,7 +388,7 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
`StaticBody3D` with per-tread `BoxShape3D` colliders; marquee `font_size` 160 / outline 24; focus `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 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). (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 - **PLAN verbs (2026-10-31, historical):** the radial has 12 slices
(`Stop, Look, Point, No, Ask, Inspect, Wand, Warn, Block, Reason, Recognize, Radio`). (`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 `eye_contact` reuses the Level 1 reaction table via `_apply_interaction`. Recognition is a player
decision: `NPCController.player_recognized` + `IncidentRecorder.add_access_evidence`, and decision: `NPCController.player_recognized` + `IncidentRecorder.add_access_evidence`, and
@@ -576,9 +636,9 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
- Persistence tests write into the real `user://shift_results.json` (harmless local pollution of the demo history). - 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). - 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` are logged through `GameManager` (inspect / wand / refuse). They are not a separate dialogue UI. - 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. - Access loadout is implemented; 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. - `Save.SETTINGS_FILE` is declared and unused; settings persistence goes through `Save.load_settings()` / `save_settings()`.
- Headless suite is **77/77**; the windowed focus/radial smoke passes. Remaining visual follow-ups are at the top of `TODO.md`. - Headless suite is **229/229**; the windowed smokes, screenshot compare and judge pass. Remaining visual follow-ups are in `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). - 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 - 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 (`radial: CanvasLayer`) exposes script members as `Variant`, so `var x := radial.is_open` is a
@@ -593,27 +653,23 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
## Open Questions ## 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. - Whether the responder avatar should be playable/controllable or purely scripted.
- Localization target languages beyond English. - Localization target languages beyond English.
- macOS notarization approach for broad distribution. - macOS notarization approach for broad distribution.
## Plan vs code (re-evaluated) ## Plan vs code
On local `main` (6 commits not on origin): v0.2.1 packages, ADRs 0007–0010, asset pipeline wired to `PLAN.md` describes intended design; `README.md` and `DONE.md` describe what shipped. As of
`assets/textures/ai/game_ready/`, screenshots refreshed. 2026-11-01 the playable build covers all three venues, controls, access/radio/evidence/review,
the Blender asset library, the realism pass, and the visual-alignment pass. `TODO.md` holds only
Uncommitted: `AccessLoadout`, control pass (cone, ring, radial, NPC face, named input actions), the realism follow-ups and optional future ideas. The project uses a custom headless runner, not
and boot/spawn fixes. Headless 77/77; windowed smoke passes. Access-artifact and control-pass GUT, and Gitea Actions, not GitHub Actions.
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 --> <!-- b64: RWFzdGVyIGVnZzogYSByYXJlIHJhZGlvIHRyYW5zbWlzc2lvbiAiwr9Ew7NuZGUgZXN0w6EgbGEgbW90YT8iIGZpcmVzIHZpYSBHYW1lTWFuYWdlci5fbWF5YmVfcmFkaW9fZWFzdGVyX2VnZyAoMi41JSBjaGFuY2UgYWZ0ZXIgYSBzdWNjZXNzZnVsIHJhZGlvIGNhbGwpLiBTdHJpbmcga2V5IHJhZGlvLmVhc3Rlcl9lZ2cgaW4gY29udGVudC9zdHJpbmdzLmVuLmpzb24uIERvIG5vdCByZW1vdmUu -->
## Uncommitted Workstreams ## Uncommitted Workstreams
- A separate realism pass is editing `PLAN.md`, `README.md`, `.gitignore`, `src/game/materials.gd`, `tools/scene_concepts.py`, `tools/test_scene_concepts.py`, `src/test/capture_realism.gd`, `assets/textures/ai/game_ready/v2/`, `docs/screens/concepts|realism/`, and regenerated Blender planter/tree assets. Do not commit or revert these while that pass is active; verify game changes in a clean `git worktree` at HEAD when its unimported textures break parsing. - **None (2026-11-01).** The NPC detail pass, the realism follow-ups and the flashlight beam
decision are committed together with the portrait capture and the refreshed screenshots.
Keep the rule from `AGENTS.md`: check `git status` before committing, and never revert
another contributor's in-flight files.
+116 -598
View File
@@ -2,668 +2,186 @@
## Premise ## Premise
You play a private security guard stationed at the top of the steps outside a fictional building on Hollywood Boulevard. Your job is to keep the entrance and steps clear while dealing with tourists, trespassers, performers, protesters, intoxicated pedestrians, and occasional determined intruders. You are a private security guard working the entrance of a fictional Hollywood Boulevard building. You keep the steps and doors clear, check who is allowed in, and deal with tourists, performers, influencers, confused visitors, intoxicated pedestrians, trespassers, people in mental-health crisis, and rare determined threats.
The game is about **judgment, communication, and escalation management**, not simply attacking anyone who approaches. The game is about **judgment, communication, access verification, and proportionate escalation**, not about attacking everyone who approaches.
--- ## Design Pillars
- **Lowest effective response wins.** Observation, a gesture, or a clear direction should solve most situations.
- **People are not labels.** Clothes, props, credentials, and behavior tell the player what they need; internal NPC types stay in the debug overlay.
- **Every decision has a record.** Incidents, evidence, radio reports, and outcomes feed the shift review.
- **Emergency help is a last resort.** Responders handle crises the guard cannot safely manage; calling them has costs.
- **Readability beats raw fidelity.** The GL Compatibility renderer, clear silhouettes, the focus ring, and the HUD stay legible.
## Core Gameplay Loop ## Core Gameplay Loop
1. Watch the stairs, entrance, and sidewalk. 1. Watch the entrance, steps, queue, and sidewalk.
2. Identify why each person is approaching. 2. Work out why each person is approaching.
3. Give verbal warnings and directions. 3. Use a gesture or short direction before escalating.
4. Escalate only when necessary. 4. Verify authorized access when the venue requires it.
5. Call for assistance in rare dangerous situations. 5. Escalate only when the person refuses, forces entry, or presents a genuine threat.
6. Maintain public safety, building security, and your employer’s reputation. 6. Record the incident and call for help when it is justified.
7. Review the shift: safety, judgment, accuracy, crowd reaction, and reputation.
People should not all behave the same way. Some are confused, some are rude, some are filming, and a very small number are deliberately attempting to enter. A shift lasts roughly 10–15 minutes. Most people comply when approached calmly.
## Authorized Access ## Access and Verification
Some people are authorized to enter the building. The security guard must verify them before granting access. Some people are authorized to enter. The guard may admit them by:
An authorized person can be admitted if either: - recognizing a person the guard already knows; or
- verifying a venue-appropriate artifact: wristband, ID, ticket, credential, or guest list.
- The guard already knows and recognizes them. Artifacts can be valid, expired, counterfeit, mismatched, missing, or ambiguous. The player inspects the details the venue asks for instead of judging by appearance.
- The person presents a valid event or staff wristband, which the guard checks before opening the entrance.
The wristband check should require the player to inspect details such as its color, pattern, date, or event marking. NPCs may have valid wristbands, expired wristbands, counterfeit wristbands, or no wristband at all. Decisions are admit, redirect, hold, or refuse. Incorrectly denying an authorized person hurts reputation; admitting an unauthorized person is a security failure. Recognition is a player decision and is recorded as evidence.
The player should avoid relying on appearance or assumptions. A familiar person may still need verification if the building’s current policy requires it, while an unfamiliar person with a valid wristband may be admitted after a successful check. Incorrectly denying an authorized person harms the building’s reputation; admitting someone without authorization creates a security failure. ## Escalation Model
--- | Level | Player response | Intent |
| --- | --- | --- |
| 0 | Observation | Watch harmless behavior; intervene only when needed. |
| 1 | Simple deterrence / polite direction | Raise a hand, make eye contact, point to the sidewalk or check-in area, shake the head, give a short instruction. |
| 2 | Formal warning | State the rule and ask for compliance; optionally arm the body camera. |
| 3 | Blocking and positioning | Stand between the person and the door, secure the entrance, keep distance and an escape route, ask for their reason. |
| 4 | Radio for security assistance | Request a second guard, supervisor, building management, or other support. |
| 5 | Emergency response | Hold and confirm the emergency call only for an immediate threat such as a weapon, assault, forced entry, or serious threat to occupants. |
## Escalation System At Level 2 an NPC may leave, argue, pretend not to understand, record the guard, call someone, or move closer. At Level 3 the goal is to deny access **without starting a fight**. Physical contact creates legal, employment, and public-relations consequences.
## Level 0: Observation At Level 4 the report matters: location, description, behavior, weapons seen, and immediate danger. Exaggerated reports damage credibility. At Level 5 the call may evacuate the building, close the street, trigger a review, cost reputation, or open an investigation if unjustified. Responders may command, restrain, evacuate, or arrest; they are not a routine stronger attack.
The guard watches NPC behavior and decides whether intervention is needed.
Examples:
- Someone taking a photograph from the sidewalk
- A tourist reading a map
- A person sitting briefly on the edge of the steps
- Someone approaching the entrance with a delivery package
The player should avoid interrupting harmless behavior. Unnecessary confrontations reduce the guard’s performance rating.
---
## Level 1: Simple Deterrence and Polite Direction
The player can begin with a simple, nonverbal deterrent before speaking. These actions are quick and low-pressure, making them useful when someone is only approaching the steps casually or appears unsure.
Possible actions include:
- Raise an open hand to signal “stop” or “please wait”
- Make eye contact
- Point toward the public sidewalk
- Gesture toward the wristband-check area
- Shake the head gently when someone approaches a restricted entrance
If the person continues, the player uses a short verbal prompt.
Example commands:
- “Please keep the stairs clear.”
- “The entrance is for authorized guests only.”
- “You can’t remain here.”
- “Please move back to the sidewalk.”
At this stage, most NPCs comply immediately.
---
## Level 2: Formal Warning
If the person remains on the steps or continues toward the entrance, the player issues a formal warning.
Possible dialogue:
> “This is private property. You have been asked to leave. Please move away from the entrance now.”
The player may activate a visible security light or body camera. This creates a record of the interaction and can discourage repeat offenders.
At this level, the NPC may:
- Leave
- Argue
- Pretend not to understand
- Start recording the guard
- Call someone else
- Move closer to the door
---
## Level 3: Blocking and Physical Positioning
The guard moves into a defensive position between the NPC and the building.
Gameplay actions:
- Stand in front of the entrance
- Point toward the public sidewalk
- Lock or secure the door
- Request the person’s reason for entry
- Keep distance while maintaining a clear escape route
- Use a flashlight or radio warning tone at night
The player should not automatically shove or strike people. Physical contact creates legal, employment, and public-relations consequences.
This level is about **denying access without creating a fight**.
---
## Level 4: Radio for Security Assistance
If the person refuses to leave, attempts to force the door, threatens staff, or repeatedly returns, the player uses the radio.
Possible assistance:
- A second security guard
- Building management
- Police dispatch
- Nearby security personnel
The radio call takes time, so the player must continue monitoring the person while waiting. The player can choose what information to report:
- Location
- Description
- Behavior
- Whether weapons have been seen
- Whether anyone is in immediate danger
Incorrect or exaggerated reports damage the guard’s credibility.
---
## Level 5: Emergency Response
This is reserved for immediate threats, such as:
- A person displaying a weapon
- An attempted forced entry while people are inside
- An assault on the guard or another person
- A serious threat against occupants
- A violent struggle that cannot be safely disengaged
The player presses and holds a clearly marked **Emergency Radio** button. A confirmation prompt prevents accidental activation.
The call brings an off-duty law-enforcement officer or armed emergency responder to the location. This character has greater authority and can control a dangerous situation, but the call has serious consequences:
- The building may be evacuated
- The street may be closed
- The guard’s actions are reviewed
- The building loses reputation
- The player may face an investigation if the call was unjustified
- The officer may use force only when the game’s threat assessment supports it
The officer should not function as a convenient “stronger attack.” Their role is to handle a crisis the player cannot safely manage. In most scenarios, their arrival results in commands, restraint, evacuation, or arrest rather than automatic gunfire.
---
## NPC Behavior Types ## NPC Behavior Types
## Tourists - **Tourists:** take photos, sit on steps, drift toward restricted doors; usually respond well to polite directions.
- **Street performers:** may block stairs or argue when moved; can be redirected to a permitted area.
- Take photos - **Influencers and filmmakers:** film the guard, test boundaries, and create public-relations risk.
- Sit on steps - **Confused visitors:** may have appointments or genuinely believe the building is public; reward patient explanation.
- Wander toward restricted doors - **Intoxicated pedestrians:** move unpredictably, ignore instructions, and need extra distance or assistance.
- Usually respond well to polite directions - **Trespassers:** test entrances, use distractions, and may return after being told to leave.
- **Violent threats:** rare, escalate quickly, and justify emergency response only when behavior shows immediate danger.
## Street Performers - **People in mental-health crisis:** may loiter, behave unpredictably, and respond poorly to aggression; require patience and careful de-escalation. The setting may refer to a 51/50 hold, but the game should present the person respectfully, not as an automatic threat.
- May block stairs
- May become argumentative if moved
- Can be redirected to a permitted area
## Influencers and Filmmakers
- Film the guard
- Test boundaries for content
- May accuse the guard of overreacting
- Create public-relations risk
## Confused Visitors
- Have appointments or directions
- May genuinely believe the building is public
- Reward patient explanations
## Intoxicated Pedestrians
- Move unpredictably
- May ignore instructions
- Require additional distance and assistance
## Trespassers
- Repeatedly test entrances
- Use distractions
- May return after being told to leave
## Violent Threats
- Rare
- Escalate quickly
- Trigger the emergency response system only when their behavior indicates immediate danger
## 51/50s
Crazy Homeless Individuals may be referred to as "51/50s" in the context of the game, indicating a person who may be experiencing a mental health crisis and could pose a potential threat to themselves or others.
- May loiter near entrances
- Can be unpredictable in behavior
- Often respond poorly to aggressive confrontation
- Require careful de-escalation and patience
---
## Special Systems ## Special Systems
## Reputation Meter ### Reputation
The player is evaluated on: The shift evaluates peaceful resolutions, response time, radio accuracy, unnecessary confrontations, public complaints, property damage, entrance security, and whether assistance was justified. Calling armed help too often scores poorly even when no one is hurt.
- Number of incidents resolved peacefully ### Evidence and Body Camera
- Response time
- Accuracy of radio reports
- Number of unnecessary confrontations
- Public complaints
- Property damage
- Whether the entrance remained secure
A player who calls armed assistance too often should score poorly, even if no one was injured. Interactions are recorded automatically. The body camera is a real state the player can toggle; a formal warning also arms it. The shift review uses the recorded events and evidence to show whether the response was justified.
## Evidence and Body Camera ### Crowd Reaction
Interactions are automatically recorded. After each shift, the player reviews incidents and sees whether their escalation was justified. Nearby pedestrians react to the guard. Calm professionalism attracts little attention; aggression draws a crowd; visible weapons or responders can cause panic. Good de-escalation keeps the boulevard moving.
This encourages players to think before acting. ### Radio Limits
## Public Crowd Reaction The emergency radio has a cooldown, and the call requires a short report. Assistance takes time to arrive, so the guard must keep monitoring the situation.
Nearby pedestrians react to the guard’s behavior:
- Calm professionalism attracts little attention
- Aggressive behavior creates a crowd
- Visible weapons or emergency responders cause panic
- Good de-escalation keeps the boulevard moving normally
## Limited Radio Battery or Cooldown
The emergency radio should not be infinitely available. A call has a cooldown and may require the player to give a short report first.
---
## Shift Structure ## Shift Structure
Each shift lasts 10–15 minutes. - **Early:** harmless tourists, delivery workers, people resting on the steps.
- **Midday:** heavier crowds, more entry attempts, more filming.
- **Evening:** lower light, more intoxicated pedestrians and aggressive groups.
- **Late:** a rare serious incident may test whether the player recognizes a real threat without overreacting to ordinary behavior.
### Early Shift ## Outcomes
Mostly harmless tourists, delivery workers, and people sitting on the stairs. A successful shift keeps the building secure, resolves most incidents without force, keeps public access clear, makes justified emergency calls, avoids bystander injuries, and follows policy.
### Midday A poor shift involves excessive force, confronting innocent people, ignoring a real threat, false reports, allowing unauthorized entry, causing a crowd or panic, or behaving recklessly even if the door stayed locked.
Crowds become heavier. More people attempt to enter the building or film interactions. ## Venues
### Evening ### Hollywood Boulevard Building
Lighting decreases. Intoxicated pedestrians and aggressive groups become more common. The original scenario: a building entrance with steps, sidewalk, street lamps, and a restricted door. Tourists, performers, influencers, confused visitors, intoxicated pedestrians, trespassers, people in crisis, and rare violent threats approach from the boulevard.
### Late Shift Authorized people are recognized or present a valid wristband. Wristbands vary by color, pattern, date, and event marking, and may be valid, expired, counterfeit, or missing.
A rare serious incident may occur, testing whether the player recognizes a genuine threat instead of overreacting to ordinary behavior. ### The Joke Shop
--- A comedy-club entrance. The door person checks IDs, tickets, reservations, guest-list status, and screens guests with a handheld wand under venue policy.
## Win and Loss Conditions The wand does **not** identify a weapon automatically. Keys, belts, and phones can trigger false alarms; the player must ask the guest to identify the item and apply procedure. A guest with valid credentials can still require assistance if they threaten staff or patrons.
## Successful Shift Typical events:
- Building remains secure - a busy weekend show creates a long line;
- Most incidents are resolved without physical force - a performer arrives through the guest entrance;
- Public access remains clear - a guest has a reservation but no ID;
- Emergency calls are justified - a fake or borrowed ID appears;
- No bystanders are injured - a wand alert comes from keys or a belt;
- The player follows company policy - a guest refuses screening but insists on entering;
- someone tries to follow an authorized person through the door;
- a late audience member arrives during the performance.
## Poor Performance Joke Shop escalation follows the common ladder: direction, explanation, repeat request, refusal, manager/security assistance, emergency only for immediate danger.
- Excessive use of force ### Hollywood Boulevard Late Night TV Studio
- Repeatedly confronting innocent people
- Ignoring a real threat
- Making false radio reports
- Allowing repeated unauthorized entry
- Causing a crowd or panic
A shift can be technically “secure” but still receive a poor rating if the guard behaves recklessly. A busy studio entrance with audience members, production staff, performers, guests, deliveries, celebrity arrivals, tourists, influencers, and the same range of street behavior as the building scenario.
--- Staff and guests are recognized or present a wristband, credential, ticket, guest-list confirmation, or other approved marker. The player keeps the steps clear, protects arrivals and equipment, manages filming boundaries, watches for tailgating, and calls for help only when there is immediate danger.
## Venues and Scenarios Typical events:
The game should support multiple venues, each with its own access rules, crowd patterns, equipment, and escalation procedures. The core gameplay remains the same: observe, verify access, communicate clearly, and use the lowest effective level of escalation. - a crowd gathers for a celebrity arrival;
- tourists block the entrance for photographs;
- a performer sets up in the audience queue;
- an influencer ignores the filming boundary;
- a confused visitor believes the studio is open;
- an intoxicated pedestrian approaches the stage door;
- a trespasser blends into an audience group;
- an authorized guest arrives without a wristband;
- someone presents a counterfeit credential;
- a violent threat appears during a crowded arrival.
## The Joke Shop ## Presentation and Controls
**The Joke Shop** is a comedy club scenario where the player works as a door person at the entrance. The player is responsible for checking identification, confirming that guests are attending the correct show, and screening guests for weapons with a handheld security wand. The game is first-person: WASD and mouse look, a focus cone on nearby people, a billboarded focus circle, `Tab`/mouse-wheel cycling for aim-free selection, and a radial action menu opened with left click or `Space`. `Shift` is precision walk. Gamepad input is supported, and keys/buttons can be remapped in Settings.
### Primary Responsibilities The action wheel prioritizes de-escalation: Stop, Look, Point, No, Ask, Inspect, Wand, Warn, Block, Reason, Recognize, Radio. Emergency stays on `R` and is not a wheel slice. `W` is walk, never the wand; `C` is the optional wand shortcut.
- Check each guest’s government-issued ID where required. The visual direction is readable stylized realism:
- Confirm that the guest meets the venue’s age requirement.
- Verify that the ID appears valid and belongs to the person presenting it.
- Check tickets, reservations, or guest-list status.
- Use the handheld wand according to venue policy.
- Direct guests through the entrance without blocking the sidewalk.
- Refuse entry when a guest has an invalid ID, no valid admission, or a prohibited item.
### Guest Outcomes - **Blender library:** 8 skinned NPC variants, 18 gear pieces, and 20 street/venue props, with baked albedo/normal/AO/ORM maps, a manifest, and procedural fallbacks. `Rig/Head` stays the focus anchor.
- **Realism pass:** AI-assisted per-phase skyline panoramas, v2 surface tiles with normal maps, wet asphalt at night, a warm day haze, phase-specific sun/exposure/fog, a visible flashlight beam, and denser foliage.
- **References and QA:** runtime UI art lives in `assets/images/ui/`; reference/art-direction sets live in `docs/screens/designs/` and `docs/screens/concepts/`; captures live in `docs/screenshots/`; realism runs live in `docs/screens/realism/`. `screen_designs.py compare/judge` and `tools/check_screenshots.sh` guard drift.
- **Storage convention:** see [ADR-0014](docs/adr/0014-runtime-image-root.md). Never overwrite an existing image; new outputs get a new path or `-vN` suffix.
A guest may have: The path from the current vertical slice to v1.0.0 — content depth, onboarding, accessibility, platform QA and release readiness — is tracked in [`TODO.md`](TODO.md); this plan stays at the product/design level.
- A valid ID and ticket Primary decisions: [ADR-0007](docs/adr/0007-locomotion-and-input-layers.md), [ADR-0008](docs/adr/0008-cone-focus-and-head-ring.md), [ADR-0009](docs/adr/0009-radial-action-menu.md), [ADR-0010](docs/adr/0010-procedural-npc-presentation.md), [ADR-0011](docs/adr/0011-aim-free-selection-and-precision-walk.md), [ADR-0012](docs/adr/0012-surface-materials-and-generated-props.md), [ADR-0013](docs/adr/0013-blender-authored-npc-characters.md).
- A valid ID but no ticket or reservation
- An expired, damaged, or questionable ID
- An ID that does not match the guest
- A prohibited item detected by the wand
- A harmless item that causes a false alarm
- No ID when identification is required
The player must make decisions based on the venue’s rules and observable evidence, not on a guest’s appearance or attitude. A calm guest with a valid ID should be admitted efficiently, while an aggressive guest with valid credentials may still require assistance if they threaten staff or other patrons.
### Joke Shop Escalation
1. **Nonverbal direction:** Raise a hand, point to the ID-check area, or gesture for the guest to wait.
2. **Routine explanation:** Explain the ID, ticket, or wand-check requirement.
3. **Repeat request:** Clearly state what is missing or what must happen before entry.
4. **Refusal of entry:** Keep the guest outside and direct them away from the entrance.
5. **Manager or security assistance:** Call for a supervisor or another staff member when a guest argues, refuses screening, or blocks the line.
6. **Emergency response:** Call emergency services only for an immediate threat, such as a weapon, assault, or serious attempt to force entry.
The handheld wand should not automatically identify an item as a weapon. The player must interpret the alert, ask the guest to identify the item, and follow venue procedure. The goal is to create a believable screening challenge while keeping the emphasis on communication and proportionate responses.
### Scenario Events
- A busy weekend comedy show creates a long line.
- A performer arrives through the guest entrance and must be recognized or verified.
- A guest forgets their ID but appears on the reservation list.
- A fake or borrowed ID is presented.
- A wand alert comes from keys, a belt buckle, or a phone.
- A guest refuses the screening but insists on entering.
- A disruptive guest attempts to follow another person through the door.
- A late-arriving audience member tries to enter during a performance.
## Hollywood Boulevard Late Night TV Studio
This scenario takes place outside a late-night television studio on Hollywood Boulevard. The guard manages the front steps, studio entrance, guest arrivals, deliveries, audience members, and the public crowd gathered around the building.
The studio encounters all of the NPC types described in the game plan:
- **Tourists** stopping to take photos or watch arrivals
- **Street performers** occupying the steps or performing near the entrance
- **Influencers and filmmakers** recording the guard, guests, or celebrity arrivals
- **Confused visitors** looking for the wrong entrance or trying to attend a show without understanding the access rules
- **Intoxicated pedestrians** approaching the entrance or disrupting the crowd
- **Trespassers** testing the doors, trying to follow authorized guests, or returning after being directed away
- **Violent threats**, which are rare but require careful recognition and emergency escalation
The studio also has authorized guests and staff. They must either be recognized by the guard or present a valid wristband, credential, guest-list confirmation, or other approved access marker. The player must check these credentials without holding up the audience line or allowing unauthorized people to enter.
### Studio Responsibilities
- Keep the front steps and entrance clear.
- Direct tourists and performers away from restricted areas.
- Check wristbands, credentials, tickets, or guest-list status.
- Admit authorized staff, performers, guests, and audience members.
- Manage influencers and filmmakers without unnecessarily obstructing lawful filming.
- Protect celebrity arrivals and production equipment from crowding.
- Coordinate with studio staff, production assistants, and additional security guards.
- Watch for attempts to follow authorized people through the entrance.
- Call for emergency assistance only when there is an immediate threat.
### Studio Scenario Events
- A crowd gathers to watch a celebrity arrive.
- Tourists stop on the steps and block the entrance for photographs.
- A street performer sets up in front of the audience queue.
- An influencer repeatedly ignores the restricted filming boundary.
- A confused visitor believes the studio is open to the public.
- An intoxicated pedestrian wanders toward the stage door.
- A trespasser tries to blend into an audience group.
- An authorized guest arrives without their wristband and must be verified another way.
- Someone presents a counterfeit wristband or credential.
- A violent threat appears during a crowded arrival, requiring the player to protect the entrance while calling for assistance.
---
## Visual Quality Direction
The game prioritizes readable, well-lit, low-poly presentation over realism (GL Compatibility
renderer). The v0.6.0 visual pass moves presentation to a **Blender-authored asset library**:
skinned NPC character variants, head/back/chest/hand gear, and street/venue props, all carrying
baked albedo/normal/AO/ORM maps. A generator (`tools/blender_assets.py`) and manifest
(`assets/models/blender/manifest.json`) keep the assets reproducible; the game selects variants
by NPC role/outfit and keeps the procedural rig and primitive props as offline fallbacks. The
existing AI tools remain the source of art direction for fabric textures, palettes, and the
screen-design compare/judge gate.
The library targets 8 character variants (casual, jacket, coat, vest, dress, hoodie, firefighter,
guard), 18 gear pieces, and 20 props — including replacements for the current generated street
furniture, lamps, trees, doors, marquee, and a venue car. Integration must preserve the
`Rig/Head` focus anchor, seed-driven outfit determinism, collider/navigation footprints, and the
frame budget measured by `smoke_npc_budget.gd`. See
[`docs/plan-graphics-v0.6.md`](docs/plan-graphics-v0.6.md) and
[ADR-0013](docs/adr/0013-blender-authored-npc-characters.md).
### Realism pass
The target look is the realistic 3D-game frames in `docs/screens/designs/` and the larger
example set in `docs/screens/concepts/`: hazy, layered city depth, believable materials, and
warm practical lights at night. Readability still comes first. Silhouettes, the focus ring and
the HUD must stay clear, and the GL Compatibility renderer stays.
- **Backgrounds.** The flat, black-box skyline strip is retired in favour of AI-generated
panoramic skylines (Hollywood mid-rises, palms, hills) for day, dusk and night. Each one is a
transparent-sky 4:1 strip that tiles horizontally on the existing backdrop ring, and the night
variant has lit windows.
- **Textures.** Higher-detail seamless 1024² tiles with derived normal maps for brick, granite,
sidewalk pavers, asphalt (dry and wet), steps, awning canvas and stucco, at the current UV
scales.
- **Process.** `tools/scene_concepts.py` generates (never overwriting existing images),
processes, audits and judges. `src/test/capture_realism.gd` captures the same camera shots
with the current and the candidate assets, swapped at runtime, so a candidate is compared
before any game code changes. Candidates are adopted only when the realism report (Pillow
detail metrics + vision-model realism scores) improves and `screen_designs.py judge` shows no
new `diff`.
- **Storage.** Raw AI output: `assets/textures/ai/v2/raw/` (local, gitignored). Game-ready
candidates: `assets/textures/ai/game_ready/v2/`. Scene references: `docs/screens/concepts/`.
Trial captures and reports: `docs/screens/realism/<run>/`.
- **First result (2026-11-01).** Swapping in the v2 skyline and tiles raised the judge's mean
backdrop score from 2.6 to 4.0 and texture detail from 1.2 to ~1.7. Overall realism stayed at
~2/10; the limits were lighting and exposure, flat facades and blob foliage, not textures.
- **Adopted (2026-11-01).** The v2 picks are now the runtime skyline and surface set, the phase
lighting was retuned (exposure, darker dusk/late, practical lights, wet asphalt at night), the
backdrop is a fog-free 90 m panorama ring per phase, the facade gained plinth/string
courses/cornice/pilasters/glazing bars, and the library trees and shrubs are denser. Judge
scores on the like-for-like shots rose on every sub-score (overall 0.8 → 1.0, backdrop
0.6 → 1.6) with the metric gap down 0.444 → 0.348
(`docs/screens/realism/2026-11-01-adopt/`). The remaining gap to the concepts is the low-poly
character and prop fidelity, a visible flashlight beam at night, and the entrance still being
~20 % brighter than the concept (`TODO.md` → Realism follow-ups).
The scenario should test the player’s ability to distinguish ordinary Hollywood Boulevard activity from a genuine security problem. The best solution is usually a simple gesture, clear direction, or credential check rather than immediate escalation.
Future venues can reuse this structure while changing the rules, equipment, staff roles, and types of access decisions.
## Tech Stack ## Tech Stack
## Engine: Godot 4 - **Engine:** Godot 4.3 baseline (local dev verified with 4.7.2), GL Compatibility renderer (Web parity); optional Forward+ Desktop HQ build.
- **Language:** GDScript.
- **Architecture:** venue-driven simulation; shared systems plus `Resource`-based scenario data.
- **Navigation:** `NavigationRegion3D` / `NavigationAgent3D`.
- **NPC behavior:** lightweight custom finite state machines driven by `NpcProfile` resources.
- **UI/audio:** Godot `Control` nodes, themes, procedural audio, and JSON-backed localized strings.
- **Persistence:** JSON in `user://` for settings and shift history.
- **Testing:** custom headless runner (`src/test/test_runner.gd`); GUT was evaluated and not adopted. Python tooling tests cover the asset/screenshot pipelines.
- **Source control/CI:** git on Gitea; `.gitea/workflows/build.yml` runs headless checks and builds/publishes releases on tags.
- **Targets:** Windows, Linux, macOS, and Web. Release artifacts include portables, an NSIS installer, an AppImage, a Web zip, and an optional Desktop HQ zip (Forward+ volumetric lighting; Vulkan required).
The game is built with the Godot Engine 4.x editor. Godot was chosen for its free and open-source license, built-in 3D rendering, navigation, animation, audio, and UI systems, and its low weight for fast prototyping and iteration. ## Versioning and Delivery
## Language: GDScript `config/version` in `project.godot` is the single version source (currently `0.7.3`). Releases are tagged `v<version>`; the CI pipeline builds and publishes the standard artifacts plus the optional Desktop HQ zip. Version bumps follow [Semantic Versioning](https://semver.org/). See [`docs/RELEASING.md`](docs/RELEASING.md) and [`README.md`](README.md) for commands.
Gameplay logic is written in GDScript. It is tightly integrated with the engine, fast to iterate on, and avoids an external build toolchain for the prototype phase.
## Architecture Mapping
The venue-driven architecture maps onto Godot as follows:
- **Nodes and scenes:** Each major system (`ShiftCoordinator`, `NPCController`, `InteractionSystem`, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`, `ShiftReview`) is a Godot node or scene.
- **Scenarios as resources:** `VenueScenario` and content such as `NpcProfile`, `Dialogue`, `EventPool`, and `AccessPolicy` are defined as custom Godot `Resource` types so venue content is data-driven and inspectable in the editor.
- **Navigation:** `NavigationRegion3D` and `NavigationAgent3D` drive NPC and player movement.
- **NPC behavior:** Lightweight custom finite state machines built on the `NPCController` node, with behavior traits coming from `NpcProfile` resources.
- **UI:** Godot `Control` nodes and themes for prompts, access inspection, radio reports, dialogue, timers, and the shift review screen.
- **Audio:** Godot audio buses and `AudioStreamPlayer` nodes for voices, crowd noise, radio, and environment sound.
## Content and Data
- Scenario and entity configuration uses typed Godot `Resource` files (`.tres`), created either in the editor or via scripts.
- Localized dialogue and text are stored in JSON files when they need to be editable outside the editor.
- Save data and shift results use JSON in Godot's `user://` directory.
## Testing
- 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.
- 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
The following commands apply once the Godot project is scaffolded:
- Open the editor: `godot --editor --path .`
- Run the game: `godot --path .`
- Run tests headlessly: `godot --headless --path . res://src/test/test_runner.tscn`
## Export Targets
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 ## Design Direction
The game should feel like a tense workplace simulation with occasional crisis moments. The most satisfying outcome is not overpowering an NPC; it is recognizing the situation early and resolving it calmly. The game should feel like a tense workplace simulation with occasional crisis moments. The most satisfying outcome is not overpowering an NPC; it is recognizing the situation early and resolving it calmly.
A useful guiding rule:
> **The player wins by choosing the lowest level of escalation that keeps everyone safe.** > **The player wins by choosing the lowest level of escalation that keeps everyone safe.**
## Future Ideas
- A rare Bob Dobbs cameo (Easter-egg appearance; placement note in `MEMORY.md`).
- Specific unusual NPC behaviors, such as slapping their own face, using hands as scissors, or interacting with props unexpectedly.
- Live 3D body-camera replay; additional venues or seasonal events; real voice/dialogue audio; an image-to-mesh prop pipeline.
+63 -534
View File
@@ -15,9 +15,11 @@ The central design goal is simple:
| ![Late night: street lamps lit, marquee glowing, dark Hollywood Boulevard](docs/screenshots/05-late-night-avenue.png) | ![Night shift: guard flashlight cutting through the dark](docs/screenshots/06-night-flashlight.png) | | ![Late night: street lamps lit, marquee glowing, dark Hollywood Boulevard](docs/screenshots/05-late-night-avenue.png) | ![Night shift: guard flashlight cutting through the dark](docs/screenshots/06-night-flashlight.png) |
| ![Wand screening at The Joke Shop: false alarm on keys](docs/screenshots/07-wand-screening.png) | ![Escalation HUD at L3 with body camera armed and reaction log](docs/screenshots/08-escalation-hud.png) | | ![Wand screening at The Joke Shop: false alarm on keys](docs/screenshots/07-wand-screening.png) | ![Escalation HUD at L3 with body camera armed and reaction log](docs/screenshots/08-escalation-hud.png) |
| ![Body-camera timeline: formal warning, body camera on, blocking](docs/screenshots/09-body-camera-timeline.png) | ![Prop gallery: the six generated street-furniture models on a studio backdrop](docs/screenshots/10-prop-gallery.png) | | ![Body-camera timeline: formal warning, body camera on, blocking](docs/screenshots/09-body-camera-timeline.png) | ![Prop gallery: the six generated street-furniture models on a studio backdrop](docs/screenshots/10-prop-gallery.png) |
| ![Precision walk at the venue steps with the HUD chip visible](docs/screenshots/11-precision-walk.png) | | | ![Precision walk at the venue steps with the HUD chip visible](docs/screenshots/11-precision-walk.png) | ![The Joke Shop: a narrow brick alley with shuttered windows, a warm wall lantern and a dumpster](docs/screenshots/12-joke-shop.png) |
| ![Late-night TV studio arrival: stone facade, bulb canopy, black car and barriers](docs/screenshots/13-studio-arrival.png) | |
*Screenshots are generated by [`src/test/capture_screenshots.gd`](src/test/capture_screenshots.gd) and [`src/test/capture_prop_gallery.gd`](src/test/capture_prop_gallery.gd) from the current build — day, late-night, wand screening, escalation, shift-review timeline, precision walk, and the prop lineup.* *Screenshots are generated by [`src/test/capture_screenshots.gd`](src/test/capture_screenshots.gd) and [`src/test/capture_prop_gallery.gd`](src/test/capture_prop_gallery.gd) from the current build — day, late-night, wand screening, escalation, shift-review timeline,
precision walk, the Joke Shop alley, the TV Studio arrival, and the prop lineup.*
## ⬇️ Downloads ## ⬇️ Downloads
@@ -29,573 +31,100 @@ The central design goal is simple:
| **Linux — portable** | [`gig-line-linux-<ver>.x86_64`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, `chmod +x` and run | | **Linux — portable** | [`gig-line-linux-<ver>.x86_64`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, `chmod +x` and run |
| **macOS** | [`gig-line-macos-<ver>.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unsigned build, quarantine note below | | **macOS** | [`gig-line-macos-<ver>.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unsigned build, quarantine note below |
| **Web (browser)** | [`gig-line-web-<ver>.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unzip and serve the folder from any static host (no special headers) | | **Web (browser)** | [`gig-line-web-<ver>.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unzip and serve the folder from any static host (no special headers) |
| **Desktop HQ** | [`gig-line-desktop-hq-<ver>.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · optional Forward+ build with volumetric fog/SDFGI/flashlight; requires a Vulkan-capable GPU/driver |
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases> **All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
> Downloads are public (no sign-in required) since the host's instance-wide > Downloads are public (no sign-in required) since the host's instance-wide
> `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31. > `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31.
> >
> **Latest: v0.6.0** — Blender-authored character/gear/prop library with baked AO, higher-resolution environment textures, and phase-driven library props. > **Latest: v0.7.3** — mouse-look input reliability fixes, plus the v0.7.2
> cost-aware OpenRouter judge, optional Forward+ Desktop HQ build, distinct
> venues and fitted NPC gear.
> Releases are built automatically when a `vX.Y.Z` tag is pushed > Releases are built automatically when a `vX.Y.Z` tag is pushed
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use > (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline > `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
> (NSIS + appimagetool) and uploads with the portable binaries. > (NSIS + appimagetool) and uploads with the portable binaries.
> >
> Note: on this host, downloading release files currently requires sign-in;
> the page and tag URLs are public.
>
> macOS builds are unsigned: right-click → Open, or clear the quarantine with > macOS builds are unsigned: right-click → Open, or clear the quarantine with
> `xattr -dr com.apple.quarantine gig-line-macos.zip`. Build instructions and > `xattr -dr com.apple.quarantine gig-line-macos.zip`. Build instructions and
> the release pipeline live in [`docs/RELEASING.md`](docs/RELEASING.md). > the release pipeline live in [`docs/RELEASING.md`](docs/RELEASING.md).
If you are here to develop or test locally instead of downloading, jump to If you are here to develop or test locally instead of downloading, jump to
[Run and Test](#run-and-test) below. [Setup and Run](#setup-and-run) below.
The project is under active development. The design and architecture are documented, and a playable prototype vertical slice exists (Godot 4.3 + GDScript): a procedural Hollywood Boulevard entrance, a first-person guard, simulated NPCs with intents and reactions, the six-level escalation model, access inspection and admission decisions, radio calls with credibility checks, reputation, crowd mood, and an end-of-shift review. The project is under active development. The design and architecture are documented, and a playable prototype vertical slice exists (Godot 4 + GDScript): a procedural Hollywood Boulevard entrance, a first-person guard, simulated NPCs with intents and reactions, the six-level escalation model, access inspection and admission decisions, radio calls with credibility checks, reputation, crowd mood, and an end-of-shift review.
## Game Overview ## Game Overview
A typical shift lasts 10–15 minutes. The player: A typical shift lasts 10–15 minutes: watch the entrance and sidewalk, judge why
each person is approaching, verify access with recognition, wristbands, IDs,
1. Watches the entrance, steps, queue, and surrounding sidewalk. tickets or credentials, and escalate only when necessary. The game rewards calm
2. Determines why each person is approaching. resolution over force. The full mechanics — the three venues, the six-level
3. Uses simple gestures or polite directions first. escalation model, and the core systems — are in
4. Verifies authorized access using recognition, wristbands, credentials, tickets, or guest lists. [`docs/gameplay.md`](docs/gameplay.md); the design rationale lives in
5. Escalates only when a person refuses directions, attempts unauthorized entry, or presents a genuine threat. [`PLAN.md`](PLAN.md).
6. Records the incident through body-camera and radio systems.
7. Finishes the shift with a performance review based on safety, judgment, and professionalism.
The game rewards calm resolutions rather than aggressive behavior. Emergency assistance and armed responders are rare last-resort systems with meaningful consequences.
## Planned Venues
### Hollywood Boulevard Building
The original scenario places the guard at the top of the steps outside a fictional building. Tourists, street performers, influencers, confused visitors, intoxicated pedestrians, trespassers, people experiencing a mental-health crisis, and rare violent threats may approach the entrance.
Authorized people must either be recognized by the guard or present a valid wristband. Wristbands can have different colors, patterns, dates, or event markings. They may be valid, expired, counterfeit, or missing.
### The Joke Shop
The Joke Shop is a comedy-club scenario. The door person checks IDs, tickets, reservations, and guest-list status, then uses a handheld security wand according to venue policy. The player must interpret wand alerts rather than treating every alert as proof of a weapon; keys, belts, phones, and other harmless objects can trigger false alarms.
### Hollywood Boulevard Late Night TV Studio
This scenario combines the previously defined NPC types with a busy television-studio entrance. The player manages audience members, production staff, performers, guests, deliveries, celebrity arrivals, tourists, street performers, influencers, confused visitors, intoxicated pedestrians, trespassers, people experiencing a mental-health crisis, and rare violent threats.
The studio uses wristbands, credentials, tickets, guest-list confirmation, and personal recognition to authorize entry. Crowd control, filming boundaries, following authorized guests through the door, and counterfeit credentials are key events.
## Escalation Model
The common escalation model has six levels:
0. **Observation** — Decide whether intervention is necessary.
1. **Simple deterrence and polite direction** — Raise an open hand, make eye contact, point toward the sidewalk or check-in area, shake the head, or give a short instruction.
2. **Formal warning** — Clearly explain the rule and request compliance.
3. **Blocking and physical positioning** — Secure the entrance, maintain distance, preserve an escape route, and deny access without starting a fight.
4. **Radio for security assistance** — Request a supervisor, second guard, building management, or other appropriate support.
5. **Emergency response** — Hold the emergency-radio button and confirm only when there is an immediate threat, such as a weapon, assault, forced entry, or serious threat to occupants.
An off-duty law-enforcement officer or armed emergency responder may arrive during a valid emergency. This character is not intended to be a routine stronger attack. Their role is crisis control, evacuation, restraint, arrest, or other appropriate intervention, with force governed by the situation and meaningful consequences for unjustified calls.
## Core Systems
- **NPC behavior:** NPCs have different motivations, temperaments, compliance levels, and reactions to the player’s approach.
- **Access verification:** Recognition, wristbands, IDs, tickets, credentials, and guest lists determine who may enter.
- **Nonverbal deterrence:** Small actions such as raising a hand can resolve low-risk situations without dialogue or force.
- **Dialogue:** Polite directions and warnings affect outcomes; simple gestures can resolve low-risk situations without dialogue.
- **Escalation:** The player chooses the lowest effective response.
- **Radio assistance:** Calls require useful information and may take time to arrive.
- **Evidence:** Body-camera records support shift review and accountability.
- **Crowd reaction:** Aggressive behavior, visible weapons, and emergency response can create panic or attract a crowd.
- **Reputation:** Peaceful resolutions, accurate reports, low property damage, and justified calls improve performance; unnecessary confrontations and false reports reduce it.
- **Shift review:** The player receives feedback on access decisions, escalation choices, public complaints, injuries, and security outcomes.
## Architecture Overview
The planned solution uses a venue-driven simulation architecture. Venue configuration supplies rules, access types, NPC pools, equipment, events, and escalation policies. Shared simulation systems provide movement, perception, interactions, dialogue, access verification, escalation, radio calls, crowds, reputation, evidence, and shift review.
```text
Venue Scenario / Rules
|
v
Shift Coordinator -----> Event Scheduler
| |
v v
NPC Simulation <---------- Interaction System
| |
v v
Access Verification ----> Escalation / Response
| |
+-------------> Reputation / Evidence
|
v
Shift Review
```
The architecture maps onto Godot 4 nodes, scenes, and custom resources as described in [Tech Stack](#tech-stack). See the full arc42-style outline in [`docs/architecture.md`](docs/architecture.md) and the decisions in [`docs/adr/`](docs/adr/).
## Tech Stack
- **Engine:** Godot Engine 4.x
- **Language:** GDScript
- **Architecture:** Venue-driven simulation using nodes, scenes, and custom `Resource`-based scenario data
- **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 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 (`--variants N` produces alternative images) and provides crop, resize, seamless-tile, and image-audit commands, including gameplay screenshot checks. `tools/model_pipeline.py` builds and audits the street-prop `.glb` models; `tools/npc_pipeline.py` builds and audits the skinned NPC base model.
- **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/scenes/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/scenes --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.
### NPC character model
NPCs are moving from the procedural capsule rig to a skinned, UV-mapped base
character authored in Blender ([ADR-0013](docs/adr/0013-blender-authored-npc-characters.md)).
`tools/npc_pipeline.py` builds `assets/models/generated/npc_base.glb` plus the
per-slot material maps. `NpcAvatar` instantiates that skinned model, tints its
five material slots from the seed-driven outfit, and drives the bones from the
existing animation code; the procedural rig remains the offline fallback (see
the [graphics plan](docs/plan-graphics-v0.6.md)).
```bash
# Print the frozen contract (slots, bone names, triangle budget)
python3 tools/npc_pipeline.py list
# Rebuild the skinned base mesh + neutral maps in Blender and validate it
flatpak run org.blender.Blender --background --python "$PWD/tools/npc_pipeline.py" -- build
python3 tools/npc_pipeline.py audit
# Layer the AI clothing albedos (shirt/trousers) over the procedural weave, and
# derive per-fabric outfit colours for the avatar palette
python3 tools/npc_pipeline.py prepare
python3 tools/npc_pipeline.py palette
```
`prepare` resizes the AI fabric tiles (kept locally in `assets/textures/npc/ai/`,
which is gitignored) onto the `cloth`/`pants` albedos; `build` bakes them into the
`.glb` and regenerates the procedural normal + packed ORM maps. `palette` writes
`assets/textures/npc/palette.json` using `model_pipeline.dominant_colors`.
### Blender asset library (loaded by the game)
`tools/blender_assets.py` models a larger library procedurally in Blender for the
v0.6.0 integration: **8 skinned NPC characters** (casual, jacket, coat, hi-vis vest,
dress, hoodie, firefighter, guard), each a drop-in for `npc_base.glb` (same bones,
joint positions and tint slots); **18 gear pieces** authored at head/hand/back/chest
attach points (caps, hood, helmet, backpack, SCBA tank, lanyard, phone, cup,
clipboard, umbrella, camera, sign, flashlight, wand, radio, …); and **20 props**
(octagonal bollard and bin, slatted bench, hydrant, news box, planters, street lamp,
street and palm trees, A-frame sign, wall lantern, cone, crowd barrier, stanchion,
handrail, podium, double door, marquee, sedan).
`NpcAvatar` selects a character by role/seed, tints the five contract slots, and
attaches gear at the documented points; `WorldBuilder` loads the library props
with colliders/avoidance and drives their emissive parts from the day/night
cycle. The single `npc_base.glb` and primitive props remain offline fallbacks.
Contract and integration notes: [`docs/blender-assets.md`](docs/blender-assets.md).
| Characters | Props |
| --- | --- |
| ![characters](docs/blender_previews/sheet_npc.jpg) | ![props](docs/blender_previews/sheet_props.jpg) |
```bash
flatpak run org.blender.Blender --background --python "$PWD/tools/blender_assets.py" -- build
flatpak run org.blender.Blender --background --python "$PWD/tools/blender_assets.py" -- render
python3 tools/blender_assets.py sheet # docs/blender_previews/sheet_*.jpg
python3 tools/blender_assets.py audit # no Blender needed
godot --headless --path . --import
```
### Confirmed game assets
Everything below is audited and shipped in the game; development-only outputs
(AI concept sheets, Blender sources, raw AI fabrics) are gitignored.
| | |
| --- | --- |
| ![brick facade](assets/textures/ai/game_ready/v2/picks/brick_facade.png) | ![sidewalk pavers](assets/textures/ai/game_ready/v2/picks/sidewalk_pavers.png) |
| ![asphalt](assets/textures/ai/game_ready/v2/picks/asphalt.png) | ![awning](assets/textures/ai/game_ready/v2/picks/awning_canvas.png) |
| ![skyline day](assets/textures/ai/game_ready/v2/picks/skyline_day.png) | ![skyline night](assets/textures/ai/game_ready/v2/picks/skyline_night.png) |
- **Surface textures:** the v2 picks above (brick, sidewalk pavers, dry and wet asphalt, awning canvas) plus their normal maps, loaded by `src/game/materials.gd` and `WorldBuilder`; the three skyline panoramas are switched per shift phase. The first-generation tiles and `assets/textures/skyline.png` are no longer loaded.
- **UI art:** `assets/textures/ai/game_ready/icons/` (flashlight, door lock, wand, REC) and `assets/textures/ai/game_ready/cards/` (ID, wristband, ticket, credential, guest list), loaded by `src/game/ui/hud.gd`.
- **Generated models:** `assets/models/generated/*.glb` — bollard, bench, trash bin, planter, newspaper box, hydrant, and the NPC base — plus their colour atlases.
- **NPC material maps:** `assets/textures/npc/*_{albedo,normal,orm}.png` and `palette.json`.
Audit them all:
```bash
python3 tools/asset_pipeline.py audit assets/textures/ai/game_ready
python3 tools/model_pipeline.py audit
python3 tools/npc_pipeline.py audit
```
### Realism pass: scene concepts, skylines and textures
The skyline, surface tiles and phase lighting were rebuilt against AI concept frames. The
concepts live in [`docs/screens/concepts/`](docs/screens/concepts/README.md); the validated
picks the game loads are in `assets/textures/ai/game_ready/v2/picks/` (day/dusk/night
panoramas, brick, sidewalk pavers, dry and wet asphalt, awning canvas, each with a normal
map).
`tools/scene_concepts.py` generates and processes new candidates, promotes the validated ones
to stable names, audits them, and scores captures against the concepts. It never overwrites an
image: new files get a `-vN` suffix, and `process` skips existing candidates unless `--new`.
```bash
python3 tools/scene_concepts.py generate --group scene # or backdrop / texture, --variants N
python3 tools/scene_concepts.py process && python3 tools/scene_concepts.py audit
python3 tools/scene_concepts.py promote # validated picks -> v2/picks/
godot --headless --path . --import
# A/B a candidate without touching game code: the same cameras, textures swapped at runtime.
godot --path . --script res://src/test/capture_realism.gd -- --out=res://docs/screens/realism/<run>/after
godot --path . --script res://src/test/capture_realism.gd -- --out=res://docs/screens/realism/<run>/try \
--swap=res://assets/textures/ai/game_ready/v2/picks/brick_facade.png=res://assets/textures/ai/game_ready/v2/brick_facade-v2.png
# Score before/after against the concepts (Pillow detail metrics + vision judge).
python3 tools/scene_concepts.py realism --run docs/screens/realism/<run> --judge \
--model google/gemini-2.5-flash --repeats 3
```
`capture_realism.gd` runs five stages (day, evening, late, Joke Shop, studio); `--stages=day,late`
limits it while tuning. The latest run,
[`docs/screens/realism/2026-11-01-adopt/`](docs/screens/realism/2026-11-01-adopt/report.md),
compares the pre-adoption captures with the current build: on the shots both sets captured the
judge's like-for-like means rose on every sub-score (overall 0.8 → 1.0, backdrop 0.6 → 1.6) and
the Pillow metric gap fell 0.444 → 0.348.
![realism run](docs/screens/realism/2026-11-01-adopt/sheet.jpg)
### 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 runtime reference art under `assets/images/ui/` (with a
reference-only snapshot in `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
# Prop gallery (studio line-up) for close-range prop checks
godot --path . --script res://src/test/capture_prop_gallery.gd
# 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 21 screens: the eleven 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. All 21 have an AI reference in
`assets/images/ui/` (snapshot in `docs/screens/designs/`); the eleven screens with
staged captures are also compared or judged against the running game.
`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`.
For a one-command drift check, run `tools/check_screenshots.sh`. It renders the
staged captures and prop gallery into `docs/screenshots/` (using `xvfb-run` when
no display is available) and then runs `screen_designs.py compare`.
### Setup
1. Download the Godot 4.3 editor (official build) and put `godot` on your PATH.
2. Open or run the project from this directory.
### Run and Test
```bash
# Open in the editor
godot --editor --path .
# Run the game
godot --path .
# Headless smoke boot (120 frames then exit)
godot --headless --path . --quit-after 120
# Run all unit tests headlessly (exit code 0 = pass)
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
# Prop-collision smoke (windowed): guard stops at a bench, guest routes around it
godot --path . --script res://src/test/smoke_props.gd
# NPC budget smoke: 16 full-rig avatars against the documented frame budget
godot --headless --path . --script res://src/test/smoke_npc_budget.gd
# Movement smoke (windowed): steps, precision walk, footsteps and landing
godot --path . --script res://src/test/smoke_movement.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.
### Controls
Keyboard and mouse are the default; a gamepad works without configuration (left stick moves,
right stick looks, A opens/confirms the wheel, B cancels, X inspects, Y calls the radio, LB walks
slowly, RB uses the wand). Keys and pad buttons can be rebound in Settings, and the remaps persist
in `user://settings.json`. The HUD legend switches to gamepad hints when a pad event arrives.
| Input | Action |
| --- | --- |
| W/A/S/D + mouse | Move and look |
| 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) |
| 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 |
| C | Handheld wand screening (wand venues only) |
| A / D | Allow / deny entry during inspection or wand check |
| 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 |
## Architecture ## Architecture
The full arc42-style architecture documentation lives in [`docs/architecture.md`](docs/architecture.md). It covers goals, constraints, context, solution strategy, building blocks, runtime views, deployment, cross-cutting concepts, quality requirements, risks, a glossary, the CI/CD build pipeline, semantic versioning, and release management. A venue-driven simulation: shared systems (movement, perception, interaction,
access verification, escalation, radio, crowd, reputation, evidence, shift
review) driven by per-venue scenario data, on Godot 4 + GDScript. The full
arc42-style document is [`docs/architecture.md`](docs/architecture.md);
decisions are recorded as ADRs in [`docs/adr/`](docs/adr/).
## Tech Stack
- **Engine:** Godot 4.3 baseline (local dev verified with 4.7.2) · **Language:** GDScript
- **NPCs:** Blender-authored skinned library (`assets/models/blender/`) with procedural fallbacks; lightweight FSMs on `NpcProfile`
- **World:** procedural `WorldBuilder` + generated prop models
- **Art:** v2 skyline/surface textures in `assets/textures/ai/game_ready/v2/picks/`; UI/scene art in `assets/images/`
- **Asset tooling:** `asset_pipeline.py`, `model_pipeline.py`, `npc_pipeline.py`, `blender_assets.py`, `scene_concepts.py`, `screen_designs.py`, `check_screenshots.sh`
- **Testing:** custom headless runner (`src/test/test_runner.gd`)
- **Targets:** Windows, Linux, macOS, Web
The asset pipelines, model tools, and screenshot gates are documented in
[`docs/asset-tooling.md`](docs/asset-tooling.md).
## Setup and Run
```bash
python3 -m pip install -r requirements-tools.txt
godot --editor --path . # open in the editor
godot --path . # run the game
godot --headless --path . --script res://src/test/test_runner.gd # run all tests
```
Full setup, every run/test/smoke command, and the controls table are in
[`docs/getting-started.md`](docs/getting-started.md).
## Project Documentation ## Project Documentation
- [`PLAN.md`](PLAN.md) — Game design plan and scenario details. - [`PLAN.md`](PLAN.md) — Game design plan and scenario details.
- [`AGENTS.md`](AGENTS.md) — Development and collaboration workflow. - [`AGENTS.md`](AGENTS.md) — Development and collaboration workflow.
- [`TODO.md`](TODO.md) — Active checkbox task list.
- [`DONE.md`](DONE.md) — Completed task sections. - [`DONE.md`](DONE.md) — Completed task sections.
- [`MEMORY.md`](MEMORY.md) — Persistent session notes. - [`MEMORY.md`](MEMORY.md) — Persistent session notes.
- [`CHANGELOG.md`](CHANGELOG.md) — User-facing changes by version.
- [`docs/architecture.md`](docs/architecture.md) — arc42-style architecture documentation. - [`docs/architecture.md`](docs/architecture.md) — arc42-style architecture documentation.
- [`docs/adr/`](docs/adr/) — Architecture Decision Records. - [`docs/gameplay.md`](docs/gameplay.md) — Gameplay systems, venues, and the escalation model.
- [`docs/plan-graphics-v0.6.md`](docs/plan-graphics-v0.6.md) — Graphics-quality plan: Blender-authored NPC characters. - [`docs/tutorial.md`](docs/tutorial.md) — First-shift tutorial beats and rules.
- [`docs/asset-tooling.md`](docs/asset-tooling.md) — Asset pipelines, model tools, and screenshot gates.
- [`docs/getting-started.md`](docs/getting-started.md) — Setup, run/test commands, and controls.
- [`docs/development-status.md`](docs/development-status.md) — Implemented features and what's next.
- [`docs/smoke-checklist.md`](docs/smoke-checklist.md) — Manual smoke-test checklist.
- [`docs/blender-assets.md`](docs/blender-assets.md) — Blender asset library: characters, gear, props, attach points. - [`docs/blender-assets.md`](docs/blender-assets.md) — Blender asset library: characters, gear, props, attach points.
- [`docs/adr/`](docs/adr/) — Architecture Decision Records.
- [`TODO.md`](TODO.md) — Active tasks plus the v1.0.0 gap analysis and milestone roadmap.
## Development Status ## Development Status
Implemented so far: v0.7.2 ships the cost-aware OpenRouter judge and the optional Desktop HQ build.
The three venues now have distinct geometry, fitted NPC gear, the Blender asset
- Godot 4.3 project scaffold, input map, procedural world builder library and the post-v0.6.0 realism pass. See
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem` [`docs/development-status.md`](docs/development-status.md) for the implemented
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools list, and [`TODO.md`](TODO.md) for the v1.0.0 gap analysis and roadmap. Manual
- Headless unit tests: **227 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, input remaps, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material, player movement, NPC character layers/LOD/queue, input bindings) verification uses the [`docs/smoke-checklist.md`](docs/smoke-checklist.md).
- 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
- NPC characters (v0.5.0): articulated limb rigs, layered outfits, hand items, face/hair variety, idle behaviour (phone, clipboard, crossed arms, look-around), escalation gestures, readable reaction symbols, door-queue offsets, and three LOD tiers with a billboard impostor
- Player movement (v0.5.0): separate acceleration/braking, step-up assist, head bob and landing dip (toggleable), precision-walk FOV/chip/tick, surface-aware footsteps, gamepad support, and persistent key/button remapping in settings
- Prop collision and placement (v0.4.0): every prop fits a static collider from its mesh bounds and publishes a navigation obstacle, so the guard and guests cannot walk through benches, bins, or bollards; placements are grouped into zones with a validator, and deterministic variants add rotation, scale, and small clutter
- 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
- Environment pass (v0.6.0): 1024² brick/concrete/asphalt with derived tangent normal maps, 256² baked AO maps for every Blender character/gear/prop, and library props whose bulbs/LEDs/sign faces follow the day/night phase
- NPC characters + library (v0.6.0): `NpcAvatar` loads one of 8 Blender-authored skinned variants (role/seed selected) with five tintable material slots, `Rig/Head` focus anchor, and attach-point gear; `npc_base.glb` and the procedural rig remain fallbacks. `tools/blender_assets.py` builds/audits the character, gear, and 20-prop library; `WorldBuilder` loads the replacements and new street dressing (lamp, trees, palms, planters, barriers, podium, doors, marquee, sedan, handrail) with colliders, navigation obstacles, and phase-driven emissives
- Web build: `build/gig-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: a desktop-only Forward+ preset (the renderer stays GL Compatibility for Web parity), 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 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 `GIG 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 ## Development Workflow
+253 -16
View File
@@ -1,22 +1,259 @@
# TODO # TODO
## Realism follow-ups (v0.6.1 candidates) Work is grouped by theme. Each item should be small enough to complete and verify on its own;
see `AGENTS.md` for the workflow and `MEMORY.md` for the decisions behind these plans.
- [x] Fixed ground exposure for the entrance view: the morning sun now sits at 34° elevation ## Gap Analysis and v1.0.0 Roadmap
instead of 50°, so the facade shades the near sidewalk and props cast long shadows. Ground
luminance went 95 → **83** against the concept's 78 (it was 173 before the realism pass), Baseline: **v0.7.2** (2026-11-01). Three playable venues, a complete escalation/access/radio
with the boulevard at 82 and the street at 81. loop, shift review, a Blender character/gear/prop library, 231 headless + 94 Python tests, a
- [-] Make the flashlight read in the dark: the `06-night-flashlight` concept shows a visible 13/13 clean screen-design judge and an optional Forward+ Desktop HQ build. This is a strong
beam, but a Compatibility spot light only brightens the near wall. A cheap fake cone (an vertical slice, not yet a 1.0 product.
additive mesh or a light-shaft quad) would sell it, and the night mockup expects it.
- [ ] NPC skin/clothing still reads as low-poly next to the concepts (judge `characters` ≈ 0–1). ### Documentation audit (completed 2026-11-01)
The Blender library has the budgets for one more detail pass (fingers, sleeves, faces).
- [x] Re-check the `screen_designs.py judge` default model (`google/gemini-2.5-flash-lite`): on - [x] Make `docs/architecture.md` the single architecture source: asset/content pipeline
2026-11-01 it returned 7 match / 4 review / 0 diff without a provider error; `--model` contracts, runtime venue-switch/fallback flows, deployment/version constraints, quality
override is documented in `README.md`. requirements, risk register and documentation ownership map.
- [ ] Add the two venue stages to the committed gallery (they are capture-only today) and give - [x] Keep command-heavy operational pages separate but subordinate: `docs/asset-tooling.md`,
the Joke Shop and studio their own geometry so their concept comparisons are geometric, `docs/blender-assets.md`, `docs/getting-started.md`, `docs/RELEASING.md` and
not just lighting. `docs/smoke-checklist.md` link to architecture rather than acting as parallel sources.
- [x] Remove the obsolete `docs/plan-graphics-v0.6.md` and its links; completed graphics work is
represented by ADR-0013, the asset pipeline section and the changelog.
- [x] Resolve stale claims: README release version/download row, development status
"Forward+ not implemented", and Blender props "not AO-baked".
- [x] Record the documentation ownership rule in `docs/architecture.md` §12: design intent
(`PLAN.md`), mechanics (`gameplay.md`), system architecture/contracts (`architecture.md`),
decisions (ADRs), status (`development-status.md`), roadmap (`TODO.md`), operations
(tooling/release docs).
### Gap register
| Area | Current baseline | Gap to 1.0 | Risk | Milestone |
| --- | --- | --- | --- | --- |
| First-hour experience | Venue menu and controls exist; no tutorial | No guided first shift, control coaching, objective recap or "what went wrong" debrief | New players bounce before the de-escalation loop lands | 0.8.0 |
| Campaign/progression | Venue selection and debug cycle | No career/venue order, unlocks, difficulty curve or persistent player progression | Shift loop feels like a tech demo rather than a game | 0.8.0 |
| Authored content | 3 venues, small event pools, one-line dialogue | Need a larger event matrix per venue, repeat/remix rules, failure/recovery events and more NPC dialogue | Repetition after a few shifts | 0.8.0 |
| NPC behavior | FSM intents, reactions, crowd/theater poses | Deeper negotiation, memory of earlier interactions, group dynamics, believable refusal/escalation edges | NPCs still feel deterministic/scripted | 0.8.0 |
| Access/evaluation | Recognition, wristbands/IDs/tickets/credentials, reputation/review | Fairness tuning, ambiguous cases, clearer evidence trail, richer grades and actionable feedback | Players cannot learn why a decision scored poorly | 0.8.0 |
| Shift lifecycle | Settings + completed-shift history persist | No mid-shift save/resume, pause-safe serialization, versioned save schema or replay | Interrupted sessions lose the run; future changes can break saves | 0.8.0 |
| Accessibility | Key remapping, gamepad, HUD font scale, shape escalation cue | Subtitles/captions, colour-independent cues, motion/audio sensitivity, text expansion, menu narration and full input alternatives need an audit | Public 1.0 cannot claim accessible play | 0.8.0–0.9.0 |
| Localization | English strings in `content/strings.en.json` | String extraction/coverage, pseudo-locale test, font fallback, text-length layout budgets and translator workflow | Text bugs and unsupported locales at release | 0.9.0 |
| Audio | Procedural crowd bed and radio crackle | Music/ambience direction, event SFX, voice/VO decision, caption hooks and mix budgets | Scene can feel silent/placeholder | 0.9.0 |
| Animation/presentation | Procedural poses, walk/idle theater, LOD impostor | More reaction/idle/negotiation poses, transitions, facial readability at portrait distance, camera-height decision | Cinematic close-ups expose the prototype layer | 0.9.0 |
| Performance/platform | 16-NPC budget measured headlessly; Compatibility default; optional Vulkan HQ | Target hardware matrix, 24-NPC profiling, draw-call/memory budgets, Web/HQ parity and frame-time regression gates | Crowded studio or Web drops frames | 0.9.0 |
| Content tooling | ScenarioFactory/.tres export, Blender manifest, asset audits | Schema validation, previews, event authoring workflow, reproducible asset rebuild notes and contributor onboarding | Content expansion becomes expensive/error-prone | 0.8.0–0.9.0 |
| QA/CI | Headless suite, smokes, screenshot compare, model-aware judge | End-to-end shift automation, full venue matrix, CI Godot-version strategy, manual judge budget and release checklist automation | Regressions escape or CI becomes non-reproducible | 0.9.0 |
| Release/legal | Tag-driven unsigned builds, Web zip, optional HQ | Signing/notarization, checksums, AI-asset provenance/licensing, credits, privacy/body-camera statement and storefront requirements | Public release blocked by trust/policy issues | 0.9.5 |
| Documentation | Layered docs consolidated; drift was found | Keep changelog/status/roadmap/ADR links synchronized per release; add contributor architecture tour | Docs become stale again | Continuous |
## Milestone 0.8.0 — First hour and content depth
Goal: a new player can learn the game inside the game, play a short campaign across the three
venues, and see enough authored variety that a second shift does not feel like a replay.
### 0.8.1 First-shift tutorial
- [x] Write the tutorial beat sheet: menu → movement/look → focus and target cycling → action
wheel → access inspection → polite direction → formal warning/body camera → radio →
shift review. Eight beats and a safe compliant visitor are specified in
[`docs/tutorial.md`](docs/tutorial.md).
- [x] Add a tutorial state machine independent of the normal shift FSM. `Tutorial` in
`src/game/tutorial.gd` owns the eight beats, accepts only the current beat's action,
supports skip/restart and has deterministic headless tests. GameManager/UI wiring starts
with the contextual-prompt task.
- [ ] Add contextual prompts that advance on the performed action, not on a timer. Prompts must
not block normal input or repeat after the player has learned the control.
- [ ] Stage a safe encounter with one compliant visitor; no emergency, threat or crowd panic in
the tutorial. Reuse the real inspection/wheel/radio panels rather than a fake UI.
- [ ] Persist tutorial completion in settings and add a "Replay tutorial" entry to the help/settings
screen.
- [ ] Add localized tutorial strings and caption-ready prompt hooks.
- [ ] Add a headless tutorial-state test and a windowed smoke test covering skip, replay and
completion.
### 0.8.2 Campaign and session shell
- [ ] Define campaign data separately from `VenueScenario`: venue order, unlock conditions,
shift number, selected difficulty and completion state.
- [ ] Add a campaign-select/continue screen that shows the next shift, venue, policy summary and
previous result; keep the existing debug venue cycle separate.
- [ ] Add a "Continue shift" path with an explicit resume point; define whether resume restores
the exact paused shift or the last safe checkpoint.
- [ ] Add a post-shift screen that names the next objective and offers retry, continue or return
to the menu. Do not silently reset progression on failure.
- [ ] Persist campaign state and expose a reset-campaign action with confirmation.
- [ ] Add deterministic tests for fresh campaign, resume, completed venue and failed/retried shift.
### 0.8.3 Venue content matrix
- [ ] Add a content matrix per venue with columns for phase, event kind (routine/scripted/edge/
recovery/threat), NPC type, intent, access artifact, expected escalation ceiling, failure/
recovery path and spawn weight.
- [ ] Add a validation test that every event references an existing profile, location and phase;
reject duplicate IDs and unreachable waypoints at load time.
- [ ] Expand each venue toward at least 12 authored events and 6 reusable routine variants,
covering all four shift phases; prioritize repeatable combinations over one-off set pieces.
- [ ] Add a no-repeat rule and phase-aware weighting so consecutive shifts do not replay the same
scripted event, while rare threats remain genuinely rare.
- [ ] Add recovery events after player mistakes: a refused authorized guest returns, a manager
intervention, a crowd-calming opportunity, or a second chance to explain a policy.
- [ ] Add a content preview/debug list showing the next eligible events and why they were chosen.
- [ ] Add tests for phase gating, no-repeat selection, recovery eligibility and event reachability.
### 0.8.4 NPC dialogue and negotiation
- [ ] Define a dialogue data format for intent-specific and context-specific lines: greeting,
refusal, de-escalation response, recognition callback, group reaction and resolution.
- [ ] Expand `NpcProfile` dialogue beyond one-line replies and select lines deterministically from
seed, prior interaction and current escalation level; no runtime LLM dependency.
- [ ] Wire Ask/Reason, polite direction, warning, blocking, recognition and inspection outcomes to
the new lines and log entries, with visible differences between comply, argue and refuse.
- [ ] Add a small set of group reactions so nearby NPCs respond to a raised voice, a warning, a
responder or a crowd panic without every NPC speaking at once.
- [ ] Add placeholder-safe localized strings and tests for deterministic selection, missing lines
and dialogue length in the HUD/log.
- [ ] Run a short dialogue readability pass on the three venues and replace repeated phrases.
### 0.8.5 Objective, policy and debrief layer
- [ ] Add a compact venue-policy card (accepted artifacts, door rules, emergency threshold) that
is available from the help overlay and the shift-review screen.
- [ ] Add an in-shift "current objective" line when the scenario phase changes, without turning
the HUD into a quest log.
- [ ] After an incident, record and display the evidence checked, the chosen escalation level and
the reason the response was judged fair, weak or excessive.
- [ ] Add a first-shift completion summary that explains the grade in plain language and points to
the specific decision(s) that mattered.
- [ ] Add tests for policy-card content, debrief evidence ordering and unknown/missing evidence.
### 0.8.6 Save, resume and settings
- [ ] Define a versioned save schema covering settings, tutorial completion, campaign progress,
difficulty and resumable shift state; document what is deliberately not serialized.
- [ ] Implement atomic writes, corruption detection and safe fallback to defaults or the last
completed shift; never overwrite a valid save with a partial one.
- [ ] Add save migration from the current settings/shift-history format and a test fixture for the
previous schema.
- [ ] Add mid-shift resume that restores RNG state, elapsed time, NPC/incident state and the door
state consistently; verify a resumed shift can reach its review.
- [ ] Add persistence tests for fresh install, corrupt save, version migration, completed shift and
resumed shift.
### 0.8.7 Difficulty, pacing and help
- [ ] Add difficulty presets that only adjust spawn rate, tolerance, report strictness and event
weights; the de-escalation-first rules and emergency rarity must not change.
- [ ] Expose difficulty in the campaign/settings UI with the current build as the default "normal"
preset and a clear description of each change.
- [ ] Add a new-player help overlay separate from the HUD: controls, focus/wheel flow, access
evidence, body camera, radio and shift review.
- [ ] Add a searchable/filterable controls reference and keep it synchronized with input bindings.
- [ ] Add a readability pass for first-shift HUD density; hide advanced debug-only information by
default.
- [ ] Add tests for difficulty defaults, settings persistence and help/controls coverage of every
named input action.
### 0.8.8 Verification and exit criteria
- [ ] Add headless tests for tutorial progression, campaign state, content-matrix validation,
dialogue selection, save migration and difficulty defaults.
- [ ] Add windowed/headless smokes for a complete tutorial, a resumed shift, a campaign venue
transition and the help overlay.
- [ ] Run a manual first-hour test with at least three people unfamiliar with the project; record
where they get stuck and fix P0/P1 onboarding issues before closing the milestone.
- [ ] Re-run the full headless suite, Python tooling and smoke checklist; update
`docs/development-status.md`, architecture links, changelog and README where behavior changed.
- [ ] Exit: a first-time player can complete the tutorial and a first real shift without external
documentation; each venue has a repeatable content matrix; save/resume survives restart;
the 0.8 test matrix is green.
## Milestone 0.9.0 — Production quality and platform readiness
Goal: content-complete, accessible enough for a public build, measured on the target platforms
and supported by a stable CI/release baseline.
- [ ] Animation/presentation pass: negotiation/reaction poses and transitions, readable faces in
portrait, resolved camera height, and no coat/skirt leg clipping in normal movement.
- [ ] Audio pass: music/ambience direction, event SFX, radio/crowd mix, caption hooks and a VO
decision with production scope.
- [ ] Localization pass: all player-facing strings externalized, pseudo-locale layout test, font
fallback and a contributor translation workflow.
- [ ] Accessibility pass: subtitles/captions, colour-independent access/escalation cues, motion
and flash options, full keyboard/gamepad parity and documented assist options.
- [ ] Performance pass: profile 24 live NPCs, all venues, Web and Desktop HQ; record frame-time,
draw-call and memory budgets; add regression checks where automation is possible.
- [ ] QA pass: automated end-to-end shift for each venue, full smoke matrix, screenshot/judge
baselines, and a documented manual judge budget/model pin.
- [ ] Resolve the Godot baseline: choose one supported version (or an explicit 4.3-CI/4.7-local
matrix), align export templates and visual smoke results, and document the policy.
- [ ] Exit: production content is feature-complete, accessibility/localization acceptance criteria
pass, target hardware matrix is measured, and no P0/P1 defect remains open.
## Milestone 0.9.5 — Release candidate
Goal: freeze content, prove it with external players, clear release/legal blockers and verify
every distribution path.
- [ ] Content lock and external playtest round (at least five first-time players); triage findings
by severity and fix all P0/P1 issues.
- [ ] Balance and fairness pass across all venues: scoring, difficulty, event weights, NPC
compliance and emergency false-positive/negative rates.
- [ ] Save migration test from 0.8/0.9 saves; verify settings, shift history and resume paths.
- [ ] Release engineering: signed/notarized macOS where possible, checksums, Windows/Linux install
smoke tests, AppImage update, Web hosting verification and Desktop HQ Vulkan fallback.
- [ ] Legal/credits review: AI-assisted asset provenance, music/SFX licenses, privacy/body-camera
language, age/content rating and storefront requirements.
- [ ] Documentation sign-off: architecture/ADR/status/TODO/changelog/README/smoke checklist all
match the release candidate; capture final gallery and run compare + judge.
- [ ] Exit: release candidate passes the manual smoke checklist on every target; no release-blocking
legal or platform issue remains.
## Milestone 1.0.0 — Launch
Goal: publish a stable, documented and supportable 1.0 build.
- [ ] Freeze content and version, run full final QA on Windows/Linux/macOS/Web/Desktop HQ, and
verify fresh installs plus upgrade paths.
- [ ] Publish store/release notes, credits, known limitations, accessibility statement and
controls; verify every download link and artifact checksum.
- [ ] Tag `v1.0.0`, publish through CI, verify the release page and post-launch patch policy.
- [ ] Definition of 1.0: three complete venues with campaign/tutorial, stable save/resume,
accessibility/localization acceptance, measured performance on the target matrix, signed
release artifacts where platform rules require it, and documentation synchronized with the
build.
## Post-1.0 / non-blocking candidates
- [ ] Live 3D body-camera replay.
- [ ] Additional venues or seasonal events.
- [ ] Real voice/dialogue audio.
- [ ] Image-to-mesh prop pipeline.
- [ ] Rare easter eggs/cameos (e.g. Bob Dobbs) only after core content and QA are stable.
## Roadmap dependencies and risks
- Content volume is the largest schedule risk; tutorial and campaign work must not outrun the
event/dialogue matrix.
- The vision judge is a dev-time aid with an external key/budget; CI must never make it a hard gate.
- Godot version changes can alter lighting/imports; do not combine an engine baseline move with a
content/feature milestone.
- Signed macOS builds and legal/AI provenance are release blockers, not polish.
- Keep the de-escalation-first rules and rare-emergency design unchanged while adding progression,
difficulty and feedback.
## Realism follow-ups (deferred)
- [x] Re-run the screen-design judge for `12-joke-shop`, `13-studio-arrival` and the
Forward+ A/B with the valid key: **13/13 match, 0 diff**; the HQ A/B is in the report.
- [ ] Capture a hooded/winter NPC close-up once a scenario spawns types 5-7 (the explicit
`--portrait-type` override is already in `capture_realism.gd`).
- [ ] Human-authored tree/hedge silhouettes: the whole scene now reads well except the foliage,
which is still flat-shaded clumps (`_shrub()`/`_tree()` in `tools/blender_assets.py`).
Only worth doing if a higher-poly organic builder is added to the library.
- [ ] Camera height: the concept frames are shot at ~1.55 m with a slightly wider lens; the game
camera sits at 1.7 m. Decide whether the first-person height should match the art direction
or stay at the current gameplay value.
## Future ideas (optional, not scheduled) ## Future ideas (optional, not scheduled)
+42 -34
View File
@@ -287,12 +287,12 @@
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.26, 0.26,
0.225, 0.245,
0.26 0.26
], ],
"min": [ "min": [
-0.26, -0.26,
0.015, 0.035,
-0.351 -0.351
] ]
}, },
@@ -453,7 +453,7 @@
"max": [ "max": [
0.321, 0.321,
1.824, 1.824,
0.186 0.185
], ],
"min": [ "min": [
-0.321, -0.321,
@@ -467,6 +467,7 @@
"skin", "skin",
"cloth", "cloth",
"pants", "pants",
"eye_white",
"accessory", "accessory",
"hair", "hair",
"rubber" "rubber"
@@ -486,18 +487,18 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 3788, "triangles": 3974,
"vertices": 3107 "vertices": 3744
}, },
"npc_coat": { "npc_coat": {
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.327, 0.332,
1.824, 1.824,
0.186 0.185
], ],
"min": [ "min": [
-0.327, -0.332,
0, 0,
-0.18 -0.18
] ]
@@ -508,6 +509,7 @@
"skin", "skin",
"cloth", "cloth",
"pants", "pants",
"eye_white",
"accessory", "accessory",
"hair", "hair",
"rubber" "rubber"
@@ -526,8 +528,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 4640, "triangles": 5026,
"vertices": 3974 "vertices": 4807
}, },
"npc_dress": { "npc_dress": {
"bounds_godot": { "bounds_godot": {
@@ -548,6 +550,7 @@
"skin", "skin",
"cloth", "cloth",
"pants", "pants",
"eye_white",
"accessory", "accessory",
"hair", "hair",
"rubber" "rubber"
@@ -564,8 +567,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 4226, "triangles": 4428,
"vertices": 3474 "vertices": 4026
}, },
"npc_firefighter": { "npc_firefighter": {
"bounds_godot": { "bounds_godot": {
@@ -586,6 +589,7 @@
"skin", "skin",
"cloth", "cloth",
"pants", "pants",
"eye_white",
"accessory", "accessory",
"hair", "hair",
"hivis", "hivis",
@@ -604,15 +608,15 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 4382, "triangles": 4848,
"vertices": 3919 "vertices": 4735
}, },
"npc_guard": { "npc_guard": {
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.361, 0.361,
1.819, 1.819,
0.186 0.185
], ],
"min": [ "min": [
-0.361, -0.361,
@@ -626,6 +630,7 @@
"skin", "skin",
"cloth", "cloth",
"pants", "pants",
"eye_white",
"accessory", "accessory",
"hair", "hair",
"rubber", "rubber",
@@ -647,15 +652,15 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 4154, "triangles": 4560,
"vertices": 3838 "vertices": 4529
}, },
"npc_hoodie": { "npc_hoodie": {
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.326, 0.326,
1.856, 1.856,
0.186 0.185
], ],
"min": [ "min": [
-0.326, -0.326,
@@ -669,6 +674,7 @@
"skin", "skin",
"cloth", "cloth",
"pants", "pants",
"eye_white",
"accessory", "accessory",
"hair", "hair",
"rubber" "rubber"
@@ -687,18 +693,18 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 4640, "triangles": 4826,
"vertices": 3954 "vertices": 4504
}, },
"npc_jacket": { "npc_jacket": {
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.326, 0.332,
1.824, 1.824,
0.186 0.185
], ],
"min": [ "min": [
-0.326, -0.332,
0, 0,
-0.178 -0.178
] ]
@@ -709,6 +715,7 @@
"skin", "skin",
"cloth", "cloth",
"pants", "pants",
"eye_white",
"accessory", "accessory",
"hair", "hair",
"rubber" "rubber"
@@ -727,15 +734,15 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 4016, "triangles": 4394,
"vertices": 3415 "vertices": 4175
}, },
"npc_vest": { "npc_vest": {
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.321, 0.321,
1.819, 1.819,
0.186 0.185
], ],
"min": [ "min": [
-0.321, -0.321,
@@ -749,6 +756,7 @@
"skin", "skin",
"cloth", "cloth",
"pants", "pants",
"eye_white",
"accessory", "accessory",
"hair", "hair",
"rubber", "rubber",
@@ -767,8 +775,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 4284, "triangles": 4474,
"vertices": 3784 "vertices": 4340
}, },
"palm_tree": { "palm_tree": {
"bounds_godot": { "bounds_godot": {
@@ -993,7 +1001,7 @@
"attach": "back", "attach": "back",
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.25, 0.322,
0.348, 0.348,
0.139 0.139
], ],
@@ -1009,7 +1017,7 @@
], ],
"path": "assets/models/blender/npc_gear/shoulder_bag.glb", "path": "assets/models/blender/npc_gear/shoulder_bag.glb",
"triangles": 188, "triangles": 188,
"vertices": 212 "vertices": 209
}, },
"stanchion": { "stanchion": {
"bounds_godot": { "bounds_godot": {
@@ -1089,13 +1097,13 @@
"attach": "head", "attach": "head",
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.141, 0.101,
0.065, 0.059,
0.158 0.158
], ],
"min": [ "min": [
-0.141, -0.101,
0.005, 0.011,
0.003 0.003
] ]
}, },
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+14 -5
View File
@@ -15,7 +15,8 @@ time you export, either:
`Godot_v4.3-stable_export_templates.tpz` contents into `Godot_v4.3-stable_export_templates.tpz` contents into
`~/.local/share/godot/export_templates/4.3.stable/`. `~/.local/share/godot/export_templates/4.3.stable/`.
Verify with: The templates must match the editor binary you use locally. CI pins Godot 4.3;
the local development machine uses 4.7.2 with its matching templates. Verify with:
```bash ```bash
godot --headless --export-release "Linux" build/gig-line-smoke.x86_64 godot --headless --export-release "Linux" build/gig-line-smoke.x86_64
@@ -86,6 +87,8 @@ Artifacts are written to `build/` (all exports embed the PCK — single files):
- `build/gig-line-macos-<version>.zip` - `build/gig-line-macos-<version>.zip`
- `build/gig-line-web-<version>.zip` — browser build (non-threaded Godot web - `build/gig-line-web-<version>.zip` — browser build (non-threaded Godot web
template; unzip and serve the folder, no COOP/COEP headers required) template; unzip and serve the folder, no COOP/COEP headers required)
- `build/gig-line-desktop-hq-<version>.zip` — optional Forward+ Windows/Linux
build with `override.cfg`; requires a Vulkan-capable GPU/driver
When run in CI (tags/dispatch), the build job also packages: When run in CI (tags/dispatch), the build job also packages:
@@ -94,6 +97,10 @@ When run in CI (tags/dispatch), the build job also packages:
- `build/gig-line-<version>-x86_64.AppImage` — Linux AppImage (`packaging/linux/build_appimage.sh`, - `build/gig-line-<version>-x86_64.AppImage` — Linux AppImage (`packaging/linux/build_appimage.sh`,
appimagetool via `APPIMAGE_EXTRACT_AND_RUN`) appimagetool via `APPIMAGE_EXTRACT_AND_RUN`)
The optional Desktop HQ zip is assembled by `tools/build_releases.sh` from
`packaging/desktop_hq/`; keep `override.cfg` next to the extracted executable.
It is not part of the default Compatibility/Web build.
Locally you can build the AppImage with `bash packaging/linux/build_appimage.sh <version>`; Locally you can build the AppImage with `bash packaging/linux/build_appimage.sh <version>`;
the NSIS installer requires `makensis` (covered by CI). the NSIS installer requires `makensis` (covered by CI).
@@ -114,19 +121,21 @@ GITEA_TOKEN=your_api_token ./tools/publish_release.sh 0.2.1
The script creates (or reuses) the release from `project.godot`'s The script creates (or reuses) the release from `project.godot`'s
`config/version`, uploads the three portable artifacts **plus** the NSIS `config/version`, uploads the three portable artifacts **plus** the NSIS
installer and AppImage when present, and prints the release URL. If CI already installer, AppImage, Web zip and Desktop HQ zip when present, and prints the
release URL. If CI already
published the release, re-running the script only adds missing assets. 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 > **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` > 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. > in `app.ini` (the repo itself is public). This is a host-admin setting, not a
> repository file.
Manual alternative: Manual alternative:
### Via the web UI ### Via the web UI
1. Create a **New Release** with tag `v<version>`. 1. Create a **New Release** with tag `v<version>`.
2. Attach all three `build/` artifacts. 2. Attach all artifacts from `build/` (portables, installer/AppImage, Web and optional Desktop HQ).
3. Write release notes (see checklist below). 3. Write release notes (see checklist below).
### Via the API ### Via the API
@@ -163,7 +172,7 @@ change, update `tools/build_releases.sh` first.
- [ ] All headless tests pass (exit code 0). - [ ] All headless tests pass (exit code 0).
- [ ] Game boots cleanly in a smoke test. - [ ] Game boots cleanly in a smoke test.
- [ ] Controls documented in `README.md` match the build. - [ ] Controls documented in `docs/getting-started.md` match the build.
- [ ] Version in `project.godot` matches the tag. - [ ] Version in `project.godot` matches the tag.
- [ ] Export templates used are the official ones for the pinned engine. - [ ] Export templates used are the official ones for the pinned engine.
- [ ] Artifacts uploaded; README downloads page shows them. - [ ] Artifacts uploaded; README downloads page shows them.
+107 -17
View File
@@ -20,11 +20,12 @@ This document follows the structure of the arc42 architecture documentation temp
- **Testability:** Rules and outcomes should be testable without rendering or real-time input. - **Testability:** Rules and outcomes should be testable without rendering or real-time input.
- **Consistency:** Similar situations should follow predictable venue and escalation rules. - **Consistency:** Similar situations should follow predictable venue and escalation rules.
- **Respectful representation:** Mental-health crises and public interactions should not be reduced to stereotypes or treated as automatic threats. - **Respectful representation:** Mental-health crises and public interactions should not be reduced to stereotypes or treated as automatic threats.
- **Realism:** The game world should look like the real world and NPC behaviors should feel consistent and believable within the established setting.
## 2. Constraints ## 2. Constraints
- The first implementation should be small enough for a playable vertical slice. - The first implementation should be small enough for a playable vertical slice.
- The engine is Godot 4 with GDScript; a playable prototype vertical slice is implemented. - The engine is Godot 4 with GDScript; a playable prototype vertical slice is implemented. CI pins Godot 4.3 while local development is verified with 4.7.2; the default renderer stays GL Compatibility for Web parity, with an optional Forward+ Desktop HQ build.
- Venue-specific policies must be configurable rather than hard-coded into individual NPCs. - Venue-specific policies must be configurable rather than hard-coded into individual NPCs.
- The game must preserve the de-escalation-first design described in [`PLAN.md`](../PLAN.md). - The game must preserve the de-escalation-first design described in [`PLAN.md`](../PLAN.md).
- The project uses the repository workflow described in [`AGENTS.md`](../AGENTS.md). - The project uses the repository workflow described in [`AGENTS.md`](../AGENTS.md).
@@ -36,7 +37,7 @@ This document follows the structure of the arc42 architecture documentation temp
- **MINOR** — new venues, NPC archetypes, systems, or features that are backward compatible. - **MINOR** — new venues, NPC archetypes, systems, or features that are backward compatible.
- **PATCH** — bug fixes and small polish that do not change behavior. - **PATCH** — bug fixes and small polish that do not change behavior.
The single source of truth for the version is `config/version` in `project.godot` (currently `0.1.0`). Each release is tagged in git as `v<version>` (e.g. `v0.1.0`). The `0.x` series is pre-1.0, so MINOR bumps may introduce breaking changes while the prototype is still stabilizing. `tools/build_releases.sh` and `tools/publish_release.sh` read the version from `project.godot`, so bumping it there is the only required step before a release. The full bump checklist lives in [`RELEASING.md`](RELEASING.md). The single source of truth for the version is `config/version` in `project.godot` (currently `0.7.3`). Each release is tagged in git as `v<version>` (e.g. `v0.6.0`). The `0.x` series is pre-1.0, so MINOR bumps may introduce breaking changes while the prototype is still stabilizing. `tools/build_releases.sh` and `tools/publish_release.sh` read the version from `project.godot`, so bumping it there is the only required step before a release. The full bump checklist lives in [`RELEASING.md`](RELEASING.md).
## 3. Context and Scope ## 3. Context and Scope
@@ -77,12 +78,13 @@ The player interacts with NPCs, venue entrances, access artifacts, equipment, st
- `CrowdSystem` models queues, attention, congestion, and public reaction. - `CrowdSystem` models queues, attention, congestion, and public reaction.
- `ShiftReview` presents decisions, consequences, and improvement opportunities. - `ShiftReview` presents decisions, consequences, and improvement opportunities.
- `SurfaceMaterials` (`src/game/materials.gd`) holds the surface profiles, textured albedo/normal recipes, baked-AO wiring, glass/emissive recipes, and the environment tune-up (tonemapping, glow, fog, grading) that the phase lighting applies. - `SurfaceMaterials` (`src/game/materials.gd`) holds the surface profiles, textured albedo/normal recipes, baked-AO wiring, 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. - `WorldBuilder` assembles the procedural venue from a per-venue `VENUE_SPECS` table (facade, windows, stairs, street, nav bounds, guard spawn, feature flags and prop placements), prefers the Blender prop library in `assets/models/blender/props/`, and falls back to `assets/models/generated/` or primitives. `GameManager` rebuilds only the `World` container when the venue changes, so the guard/UI and their state survive. It also wires the phase-driven library emissives and street dressing.
- `src/game/ui/art.gd` loads runtime UI/scene art from `assets/images/ui/` and `assets/images/scenes/`, with null fallbacks so panels keep their procedural layouts.
- `tools/model_pipeline.py` builds and audits the street-prop `.glb` models and packs their colour atlases. - `tools/model_pipeline.py` builds and audits the street-prop `.glb` models and packs their colour atlases.
- `tools/npc_pipeline.py` builds and audits the skinned NPC base model (`assets/models/generated/npc_base.glb`) and its per-slot material maps, with an optional AI fabric override (ADR-0013). - `tools/npc_pipeline.py` builds and audits the skinned NPC base model (`assets/models/generated/npc_base.glb`) and its per-slot material maps, with an optional AI fabric override (ADR-0013).
- `tools/blender_assets.py` builds, renders and audits the Blender asset library (`assets/models/blender/{npc,npc_gear,props}/` + `manifest.json`): NPC character variants that are drop-in for `npc_base`, attach-point gear, and street/venue props. `NpcAvatar` selects variants/gear by role/seed and `WorldBuilder` loads the prop replacements and new street dressing, with procedural fallbacks (see `docs/blender-assets.md`). - `tools/blender_assets.py` builds, renders and audits the Blender asset library (`assets/models/blender/{npc,npc_gear,props}/` + `manifest.json`): NPC character variants that are drop-in for `npc_base`, attach-point gear, and street/venue props. `NpcAvatar` selects variants/gear by role/seed and `WorldBuilder` loads the prop replacements and new street dressing, with procedural fallbacks (see `docs/blender-assets.md`).
- `tools/scene_concepts.py` generates realism-pass references (`docs/screens/concepts/`), skyline backdrops and surface tiles (raw `assets/textures/ai/v2/raw/` → candidates `assets/textures/ai/game_ready/v2/`), promotes the validated ones to the tracked `v2/picks/` that `SurfaceMaterials`/`WorldBuilder` preload, audits them, and scores `src/test/capture_realism.gd` A/B captures (five stages, runtime texture swaps) against the concepts (`docs/screens/realism/<run>/`). - `tools/scene_concepts.py` generates realism-pass references (`docs/screens/concepts/`), skyline backdrops and surface tiles (raw `assets/textures/ai/v2/raw/` → candidates `assets/textures/ai/game_ready/v2/`), promotes the validated ones to the tracked `v2/picks/` that `SurfaceMaterials`/`WorldBuilder` preload, audits them, and scores `src/test/capture_realism.gd` A/B captures (six stages, runtime texture swaps) against the concepts (`docs/screens/realism/<run>/`).
- `tools/asset_pipeline.py` generates and audits role-aware textures; `tools/screen_designs.py` generates screen references and judges captures. - `tools/asset_pipeline.py` generates and audits role-aware textures; `tools/screen_designs.py` generates screen references and judges captures; `tools/check_screenshots.sh` renders the staged gallery and runs the compare gate.
Confirmed game assets live in `assets/textures/ai/game_ready/` (audited surface, UI, and card textures), `assets/models/generated/` (prop and NPC `.glb` models plus their texture atlases), and `assets/textures/npc/` (NPC material maps and `palette.json`). Development-only outputs — AI concept sheets, Blender sources, raw AI fabrics — are gitignored and regenerated by the tools. Confirmed game assets live in `assets/textures/ai/game_ready/` (audited surface, UI, and card textures), `assets/models/generated/` (prop and NPC `.glb` models plus their texture atlases), and `assets/textures/npc/` (NPC material maps and `palette.json`). Development-only outputs — AI concept sheets, Blender sources, raw AI fabrics — are gitignored and regenerated by the tools.
@@ -104,6 +106,36 @@ When reference art becomes runtime art, copy or process it into `assets/images/`
new descriptive name (never overwrite an existing image) and keep a procedural fallback new descriptive name (never overwrite an existing image) and keep a procedural fallback
in code. in code.
### Content and asset pipeline architecture
The asset/content pipeline is part of the architecture, not an external build detail:
| Stage | Producer | Committed artifact | Runtime consumer | Failure fallback |
| --- | --- | --- | --- | --- |
| Scenario data | `ScenarioFactory` / editor export | `content/venues/*.tres`, event/profile data | `VenueScenario` → `ShiftCoordinator` | built-in scenario defaults |
| NPC library | Blender flatpak + `tools/blender_assets.py` | `assets/models/blender/npc/*.glb`, manifest, baked AO | `NpcAvatar` | `npc_base.glb` → procedural rig |
| Gear/props | Blender flatpak + tool + bake | `npc_gear/*.glb`, `props/*.glb`, `*_ao.png` | `NpcAvatar`, `WorldBuilder` | primitive assets / generated props |
| Surface/UI art | Python pipelines / AI-assisted references | `assets/textures/ai/game_ready/`, `assets/images/` | `SurfaceMaterials`, `UiArt` | neutral material / procedural UI |
| Screenshots/QA | capture scripts + `scene_concepts`/`screen_designs` | `docs/screenshots/`, `docs/screens/realism/` | reviewers and CI gates | regenerate; never runtime input |
| Release | Gitea workflow + build/publish scripts | platform binaries, Web zip, Desktop HQ zip | players | source build |
Contracts that keep the pipeline replaceable:
- `npc_base` armature/bone names, five tint slots and front direction are frozen by tests; gear is authored in attach-point space and measured against the character silhouette.
- Library characters, gear and props are audited for grounding, UVs, triangle budgets, degenerate triangles and manifest coverage; Blender is a developer tool, never a CI runtime dependency.
- `WorldBuilder.VENUE_SPECS` drives geometry, navigation, features and prop placements; `GameManager` rebuilds only the world container on a venue change.
- UI/scene art is loaded through `UiArt` with a null/neutral fallback; composed images live in `assets/images/`, not in the docs reference tree (ADR-0014).
- CI stays offline: only committed `.glb`/`.png`/`.tres` and Godot are needed; optional AI/Blender steps are reproducible development tools.
### Content authoring and runtime data flow
`ScenarioFactory` is the source of truth for the three authored venues. `tools/export_venues.gd`
writes `content/venues/<id>.tres` for editor inspection; the factory remains the canonical
scenario definition. At runtime `GameManager` selects a `VenueScenario`, `ShiftCoordinator`
schedules events and phases, and `NPCController`/`AccessVerifier`/`EscalationManager` produce
incidents. `IncidentRecorder` is the authoritative event history; body-camera review,
reputation, crowd reaction and shift review read it rather than resolving gameplay separately.
## 6. Runtime View ## 6. Runtime View
### Routine interaction ### Routine interaction
@@ -116,6 +148,19 @@ in code.
6. The incident recorder stores the interaction. 6. The incident recorder stores the interaction.
7. Reputation and crowd systems react to the outcome. 7. Reputation and crowd systems react to the outcome.
### Venue switch
1. The player selects a venue from the start menu or cycles with the debug shortcut.
2. `GameManager` loads the matching `VenueScenario` and keeps the guard/UI state.
3. `WorldBuilder` rebuilds only `world_root` from `VENUE_SPECS`, including geometry, props and the navigation mesh.
4. Shift state, NPCs and incidents are reset for the new venue; phase lighting and the door state are reapplied.
### Asset fallback
1. A runtime loader asks for the library scene or texture.
2. If the imported asset is missing, the loader uses the generated/procedural fallback rather than failing the boot.
3. Contracts and audits ensure the fallback and library paths expose the same observable behaviour; CI runs headless against whichever path is available.
### Access check ### Access check
1. The player stops or receives an approaching person. 1. The player stops or receives an approaching person.
@@ -135,28 +180,30 @@ in code.
## 7. Deployment View ## 7. Deployment View
The initial target is a single-player local game. The expected runtime consists of one client process containing presentation, simulation, rules, content, and persistence for local settings and shift results. No online backend is required for the first prototype. The game is a single-player local client containing presentation, simulation, rules, content, and persistence for local settings and shift results. No online backend is required. A non-threaded Web export ships alongside the desktop builds and can be served from any static host without special headers. The default renderer is GL Compatibility; an optional Desktop HQ zip uses Forward+ with volumetric fog/SDFGI on Vulkan-capable desktops. CI pins Godot 4.3 while local development is verified with 4.7.2, so the light falloff path is version-adaptive until the baseline is unified.
Future options include external analytics, cloud saves, or replay sharing, but they are not part of the initial architecture. Future options include external analytics, cloud saves, or replay sharing, but they are not part of the current architecture.
### CI/CD build pipeline ### CI/CD build pipeline
A continuous-integration pipeline keeps the headless test suite green on every change, with a planned extension to build and publish release artifacts automatically. A continuous-integration pipeline keeps the headless test suite green on every change and publishes tagged releases.
- **CI workflow** (`.github/workflows/ci.yml`) runs on every push to `main` and on every pull request. It installs the pinned Godot 4.3 editor, caches the `.godot` import artifacts, imports the project to build the class cache, and runs the headless test suite (`res://src/test/test_runner.gd`). The job fails on a non-zero exit code or any `SCRIPT ERROR` in the output. - **Workflow:** `.gitea/workflows/build.yml` runs on pushes, pull requests, tags, and manual dispatches. The `checks` job installs Godot 4.3, imports the project, and runs `res://src/test/test_runner.gd`; it fails on a non-zero exit code or any `SCRIPT ERROR` in the output.
- **Planned extension** grows the pipeline from "test only" to "test, build, and publish": a test stage (headless tests plus a `--quit-after 120` boot smoke test as the merge gate), a build stage (install export templates, run `tools/build_releases.sh`), and a publish stage (on a version tag, upload artifacts via `tools/publish_release.sh` with a `GITEA_TOKEN` secret). - **Build job:** runs for tags and manual dispatches. It installs export templates, exports Linux/Windows/macOS and the non-threaded Web build, packages the NSIS installer, Linux AppImage and optional Desktop HQ zip, verifies the installer contents, and uploads the artifacts.
- **Hosting note:** the repository lives on Gitea/Forgejo (`https://git.allucanget.biz/zwitschi/security-game`); the workflow is authored for GitHub Actions and should be mirrored or ported to the host's own runner if Actions is unavailable there. Export templates must match the pinned engine version, and macOS builds are unsigned (production releases need notarization or clear quarantine guidance). - **Publish job:** tag builds call `tools/publish_release.sh` to create the Gitea release and upload the standard artifacts plus the optional Desktop HQ zip. The script uses `GITEA_TOKEN` when configured and otherwise reuses the runner's checkout extraheader credentials.
- **Hosting:** the repository lives on Gitea (`https://git.allucanget.biz/zwitschi/security-game`). Export templates must match the pinned Godot version; macOS builds are unsigned and need quarantine guidance or notarization for wider distribution.
### Release management ### Release management
Releases are produced from a tagged commit and published to the repository host's release page, where the README "Downloads" links point. Releases are produced from a tagged commit and published to the repository host's release page, where the README "Downloads" links point.
- `export_presets.cfg` — committed Linux, Windows Desktop, and macOS export presets so builds are reproducible. - `export_presets.cfg` — committed Linux, Windows, macOS, and Web export presets so builds are reproducible.
- `tools/build_releases.sh` — exports all three desktop artifacts into `build/`, named `gig-line-<platform>-<version>.<ext>`. - `tools/build_releases.sh` — exports the desktop binaries and Web zip into `build/`, named `gig-line-<platform>-<version>.<ext>`.
- `tools/publish_release.sh` — creates a release and uploads the artifacts via the Gitea/Forgejo API (requires `GITEA_TOKEN`). - `tools/publish_release.sh` — creates a release and uploads the artifacts via the Gitea API, using `GITEA_TOKEN` or the checkout extraheader fallback.
- Packaging — NSIS builds the Windows installer; `packaging/linux/build_appimage.sh` builds the AppImage.
- [`RELEASING.md`](RELEASING.md) — step-by-step guide and release checklist. - [`RELEASING.md`](RELEASING.md) — step-by-step guide and release checklist.
The release process is: bump the version, run the full test suite and boot smoke test, run `tools/build_releases.sh`, run `tools/publish_release.sh` (or create the release via the web UI), and verify the README "Downloads" links resolve to the new release. The release process is: bump the version, run the full test suite and `tools/check_screenshots.sh`, run `tools/build_releases.sh`, tag and push (CI publishes), then verify the README "Downloads" links and the release artifacts.
## 8. Cross-Cutting Concepts ## 8. Cross-Cutting Concepts
@@ -167,7 +214,10 @@ The release process is: bump the version, run the full test suite and boot smoke
- **Safety and proportionality:** The rules layer prevents ordinary noncompliance from automatically becoming a violent encounter. - **Safety and proportionality:** The rules layer prevents ordinary noncompliance from automatically becoming a violent encounter.
- **Determinism where useful:** Scenario tests should be able to use fixed seeds and scripted NPC behavior. - **Determinism where useful:** Scenario tests should be able to use fixed seeds and scripted NPC behavior.
- **Localization:** Dialogue and prompts should be externalized from logic. - **Localization:** Dialogue and prompts should be externalized from logic.
- **Observability:** Debug overlays should show NPC intent, access status, escalation level, active incidents, and response timers during development. - **Observability:** Debug overlays show NPC intent, access status, escalation level, active incidents, and response timers during development.
- **Visual regression:** Staged captures are compared against the runtime reference art (`screen_designs.py compare`) and optionally judged by a vision model; `tools/check_screenshots.sh` makes the pass repeatable.
- **Asset provenance and AI usage:** AI-assisted images/concepts are development inputs only; validated outputs are committed, audited and loaded through the same runtime asset contracts. Generated content must not become an offline/CI dependency.
- **Documentation ownership:** `PLAN.md` owns design intent; `docs/gameplay.md` owns playable mechanics; `docs/architecture.md` owns system boundaries, data flow, constraints and cross-cutting decisions; ADRs own decision history; `docs/development-status.md` owns the implemented/gap list; `TODO.md` owns the roadmap; `MEMORY.md` owns gotchas and open questions. Specialized docs link to these rather than becoming competing sources of truth.
## 9. Architecture Decisions ## 9. Architecture Decisions
@@ -198,6 +248,9 @@ Architecture decisions are recorded as ADRs:
| Performance | The target venue should support a believable crowd without harming input or simulation responsiveness. | | Performance | The target venue should support a believable crowd without harming input or simulation responsiveness. |
| Testability | Access and escalation rules can be tested independently of presentation. | | Testability | Access and escalation rules can be tested independently of presentation. |
| Accessibility | Core actions are available through clear input alternatives and readable feedback. | | Accessibility | Core actions are available through clear input alternatives and readable feedback. |
| Reliability | A fresh checkout, missing optional asset, invalid AI key, or unavailable Forward+ renderer must not prevent a normal Compatibility/Web/headless boot. |
| Performance | A busy venue should hold its budget at the target NPC cap on desktop and Web; the current 16-NPC check is a baseline, not a 1.0 guarantee. |
| Documentation integrity | Every released version has a changelog entry, current status/roadmap, and architecture/ADR references that match the build. |
## 11. Risks and Technical Debt ## 11. Risks and Technical Debt
@@ -207,8 +260,45 @@ Architecture decisions are recorded as ADRs:
- Emergency response can overshadow the core game if it is too powerful or too frequent. - Emergency response can overshadow the core game if it is too powerful or too frequent.
- Mental-health-crisis scenarios require careful writing, playtesting, and respectful presentation. - Mental-health-crisis scenarios require careful writing, playtesting, and respectful presentation.
- The choice of engine may affect navigation, crowd scale, tooling, and content authoring. - The choice of engine may affect navigation, crowd scale, tooling, and content authoring.
- Reference mockups can outpace the runtime art style; compare/judge catch gross drift but not composition errors, so the staged gallery needs human review after large visual changes.
- Godot version skew (CI 4.3 vs local 4.7.2) can change lighting, import sidecars and generated UIDs; a single supported version and a visual smoke matrix are required before 1.0.
- Content volume and authoring tools are still thin; adding venues/events by editing the factory can become error-prone without schema validation and preview tooling.
- Save data currently covers settings and shift history, not mid-shift resume; adding saves/replays requires a versioned schema and migration policy.
- The optional judge and image-generation paths depend on an external key, model availability and budget; CI cannot make them a release gate, and keys must never be committed.
- Accessibility and localization coverage is partial; a 1.0 release needs subtitles/captions, colour-independent cues, remapping, text expansion tests and at least a documented localization workflow.
- Unsigned macOS builds, AI asset provenance and any storefront/legal requirements are release blockers for a public 1.0.
- Documentation drift has already occurred (release version, implemented status and obsolete plan docs); the ownership map and roadmap must be kept current in the same change as the implementation.
## 12. Glossary ## 12. Documentation Consolidation and Ownership
The documentation set is intentionally layered. Architecture-level material is consolidated here;
specialized operational detail remains in focused pages so commands do not rot inside the
architecture document.
| Document | Canonical role | Relationship to this architecture |
| --- | --- | --- |
| [`PLAN.md`](../PLAN.md) | Product/design intent, pillars, venues and success criteria | Design source; architecture realizes it |
| [`docs/gameplay.md`](gameplay.md) | Playable loop, escalation, venue mechanics | Runtime/behavioral view |
| [`docs/architecture.md`](architecture.md) | System boundaries, data flow, constraints, deployment, cross-cutting decisions, quality risks | Single architecture source |
| [`docs/adr/`](adr/) | Immutable decision records with context and consequences | Decision history linked from §9 |
| [`docs/asset-tooling.md`](asset-tooling.md) | Build/audit/capture commands and pipeline usage | Operational appendix to the pipeline section |
| [`docs/blender-assets.md`](blender-assets.md) | Blender character/gear/prop contracts and attach points | Detailed asset contract appendix |
| [`docs/getting-started.md`](getting-started.md) | Setup, commands, controls | Developer onboarding |
| [`docs/RELEASING.md`](RELEASING.md) | Release checklist and CI/publishing steps | Deployment appendix to §7 |
| [`docs/smoke-checklist.md`](smoke-checklist.md) | Manual verification script | QA appendix to §10 |
| [`docs/development-status.md`](development-status.md) | Implemented features and current gaps | Status companion to the roadmap |
| [`TODO.md`](../TODO.md) | Active tasks and v1.0.0 roadmap | Planning source; no architecture duplication |
| [`CHANGELOG.md`](../CHANGELOG.md) | User-facing release history | Release/version companion |
Consolidation decisions:
- **Architecture owns the "how"** (components, contracts, data flow, constraints, quality gates); **PLAN/gameplay own the "what"**; **ADRs own the "why"**. Links replace duplicated prose.
- **Asset and release documents remain separate** because they are command-heavy operational procedures; their architecture-relevant contracts and failure modes are summarized in §5 and §7.
- **Completed plans are removed, not kept as competing roadmaps.** Historical graphics work is summarized by ADR-0013, the asset pipeline section and the v0.6.0 changelog; new work belongs in TODO.
- **Status and roadmap are separate from architecture.** `docs/development-status.md` records what exists; `TODO.md` records what remains, including the v1.0.0 gap analysis and milestone exit criteria.
- **Documentation drift is a tracked risk:** release/version, test counts, renderer status, and stale plans must be corrected in the same commit as the change that invalidates them.
## 13. Glossary
- **Access artifact:** A wristband, ID, ticket, credential, or other item used to verify entry. - **Access artifact:** A wristband, ID, ticket, credential, or other item used to verify entry.
- **Authorized person:** Someone permitted to enter under the current venue policy. - **Authorized person:** Someone permitted to enter under the current venue policy.
+285
View File
@@ -0,0 +1,285 @@
# Asset Pipeline and Tooling
Everything the game renders is produced or gated by the scripts below. Architecture-level contracts,
data flow and failure fallbacks are summarized in
[`architecture.md`](architecture.md#content-and-asset-pipeline-architecture); this page is the
operational reference. Development-only outputs (AI concept sheets, Blender sources, raw AI fabrics)
are gitignored; the audited game assets are committed.
## 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/scenes/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/scenes --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](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.
## NPC character model
NPCs are moving from the procedural capsule rig to a skinned, UV-mapped base
character authored in Blender ([ADR-0013](adr/0013-blender-authored-npc-characters.md)).
`tools/npc_pipeline.py` builds `assets/models/generated/npc_base.glb` plus the
per-slot material maps. `NpcAvatar` instantiates that skinned model, tints its
five material slots from the seed-driven outfit, and drives the bones from the
existing animation code; the procedural rig remains the offline fallback (see
[ADR-0013](adr/0013-blender-authored-npc-characters.md) and
[`blender-assets.md`](blender-assets.md)).
```bash
# Print the frozen contract (slots, bone names, triangle budget)
python3 tools/npc_pipeline.py list
# Rebuild the skinned base mesh + neutral maps in Blender and validate it
flatpak run org.blender.Blender --background --python "$PWD/tools/npc_pipeline.py" -- build
python3 tools/npc_pipeline.py audit
# Layer the AI clothing albedos (shirt/trousers) over the procedural weave, and
# derive per-fabric outfit colours for the avatar palette
python3 tools/npc_pipeline.py prepare
python3 tools/npc_pipeline.py palette
```
`prepare` resizes the AI fabric tiles (kept locally in `assets/textures/npc/ai/`,
which is gitignored) onto the `cloth`/`pants` albedos; `build` bakes them into the
`.glb` and regenerates the procedural normal + packed ORM maps. `palette` writes
`assets/textures/npc/palette.json` using `model_pipeline.dominant_colors`.
## Blender asset library (loaded by the game)
`tools/blender_assets.py` models a larger library procedurally in Blender for the
v0.6.0 integration: **8 skinned NPC characters** (casual, jacket, coat, hi-vis vest,
dress, hoodie, firefighter, guard), each a drop-in for `npc_base.glb` (same bones,
joint positions and tint slots); **18 gear pieces** authored at head/hand/back/chest
attach points (caps, hood, helmet, backpack, SCBA tank, lanyard, phone, cup,
clipboard, umbrella, camera, sign, flashlight, wand, radio, …); and **20 props**
(octagonal bollard and bin, slatted bench, hydrant, news box, planters, street lamp,
street and palm trees, A-frame sign, wall lantern, cone, crowd barrier, stanchion,
handrail, podium, double door, marquee, sedan).
`NpcAvatar` selects a character by role/seed, tints the five contract slots, and
attaches gear at the documented points; `WorldBuilder` loads the library props
with colliders/avoidance and drives their emissive parts from the day/night
cycle. The single `npc_base.glb` and primitive props remain offline fallbacks.
Contract and integration notes: [`blender-assets.md`](blender-assets.md).
| Characters | Props |
| --- | --- |
| ![characters](../docs/blender_previews/sheet_npc.jpg) | ![props](../docs/blender_previews/sheet_props.jpg) |
```bash
flatpak run org.blender.Blender --background --python "$PWD/tools/blender_assets.py" -- build
flatpak run org.blender.Blender --background --python "$PWD/tools/blender_assets.py" -- render
python3 tools/blender_assets.py sheet # docs/blender_previews/sheet_*.jpg
python3 tools/blender_assets.py audit # no Blender needed
godot --headless --path . --import
```
## Confirmed game assets
Everything below is audited and shipped in the game; development-only outputs
(AI concept sheets, Blender sources, raw AI fabrics) are gitignored.
| | |
| --- | --- |
| ![brick facade](../assets/textures/ai/game_ready/v2/picks/brick_facade.png) | ![sidewalk pavers](../assets/textures/ai/game_ready/v2/picks/sidewalk_pavers.png) |
| ![asphalt](../assets/textures/ai/game_ready/v2/picks/asphalt.png) | ![awning](../assets/textures/ai/game_ready/v2/picks/awning_canvas.png) |
| ![skyline day](../assets/textures/ai/game_ready/v2/picks/skyline_day.png) | ![skyline night](../assets/textures/ai/game_ready/v2/picks/skyline_night.png) |
- **Surface textures:** the v2 picks above (brick, sidewalk pavers, dry and wet asphalt, awning canvas) plus their normal maps, loaded by `src/game/materials.gd` and `WorldBuilder`; the three skyline panoramas are switched per shift phase. The first-generation tiles and `assets/textures/skyline.png` are no longer loaded.
- **UI art:** `assets/textures/ai/game_ready/icons/` (flashlight, door lock, wand, REC) and `assets/textures/ai/game_ready/cards/` (ID, wristband, ticket, credential, guest list), loaded by `src/game/ui/hud.gd`.
- **Generated models:** `assets/models/generated/*.glb` — bollard, bench, trash bin, planter, newspaper box, hydrant, and the NPC base — plus their colour atlases.
- **NPC material maps:** `assets/textures/npc/*_{albedo,normal,orm}.png` and `palette.json`.
Audit them all:
```bash
python3 tools/asset_pipeline.py audit assets/textures/ai/game_ready
python3 tools/model_pipeline.py audit
python3 tools/npc_pipeline.py audit
```
## Realism pass: scene concepts, skylines and textures
The skyline, surface tiles and phase lighting were rebuilt against AI concept frames. The
concepts live in [`docs/screens/concepts/`](../docs/screens/concepts/README.md); the validated
picks the game loads are in `assets/textures/ai/game_ready/v2/picks/` (day/dusk/night
panoramas, brick, sidewalk pavers, dry and wet asphalt, awning canvas, each with a normal
map).
`tools/scene_concepts.py` generates and processes new candidates, promotes the validated ones
to stable names, audits them, and scores captures against the concepts. It never overwrites an
image: new files get a `-vN` suffix, and `process` skips existing candidates unless `--new`.
```bash
python3 tools/scene_concepts.py generate --group scene # or backdrop / texture, --variants N
python3 tools/scene_concepts.py process && python3 tools/scene_concepts.py audit
python3 tools/scene_concepts.py promote # validated picks -> v2/picks/
godot --headless --path . --import
# A/B a candidate without touching game code: the same cameras, textures swapped at runtime.
godot --path . --script res://src/test/capture_realism.gd -- --out=res://docs/screens/realism/<run>/after
godot --path . --script res://src/test/capture_realism.gd -- --out=res://docs/screens/realism/<run>/try \
--swap=res://assets/textures/ai/game_ready/v2/picks/brick_facade.png=res://assets/textures/ai/game_ready/v2/brick_facade-v2.png
# Score before/after against the concepts (Pillow detail metrics + vision judge).
python3 tools/scene_concepts.py realism --run docs/screens/realism/<run> --judge \
--model google/gemini-2.5-flash --repeats 3
```
Judges default to `--model auto`: `tools/openrouter_judge.py` reads the live
OpenRouter catalogue, keeps image-input models that support `response_format`,
and ranks them by advertised token/image price. The run falls through the list
on provider/model errors and aborts on an invalid key. Use
`GIG_LINE_JUDGE_MODEL` or `--model <id>` for a pinned comparison.
`capture_realism.gd` runs six stages (day, portrait, evening, late, Joke Shop, studio);
`--stages=day,late` limits it while tuning; `--flashlight` turns on the guard lamp for
night stages (used by the Desktop HQ A/B). The `portrait` shot freezes one NPC on open sidewalk
and frames the face, which is the only capture that can judge avatar detail
(`--portrait-type=N` picks a role). The generated realism runs under
`docs/screens/realism/` are **local, gitignored QA artifacts**; commit only the
resulting decisions/docs, not the run folders. The texture-adoption run compared
the pre-adoption captures with the current build: on the shots both sets captured,
the judge's like-for-like means rose on every sub-score (overall 0.8 → 1.0,
backdrop 0.6 → 1.6) and the Pillow metric gap fell 0.444 → 0.348. The character
pass added the portrait shot and recorded the new faces, hands and sleeve cuffs.
The Forward+ A/B compared the Compatibility entrance with the optional Desktop HQ
preset. The gear-fit run checked the sunglasses, bag and helmet against measured
head/skirt bounds with `python3 tools/blender_assets.py fit`; it also fixed the
portrait yaw and hid the focus overlay so the close-up shows the avatar.
Regenerate a run locally with the capture and `realism --run ...` commands above;
the reports and sheets are intentionally not part of the release repository.
## 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 runtime reference art under `assets/images/ui/` (with a
reference-only snapshot in `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
# Prop gallery (studio line-up) for close-range prop checks
godot --path . --script res://src/test/capture_prop_gallery.gd
# 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 21 screens: the eleven 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. All 21 have an AI reference in
`assets/images/ui/` (snapshot in `docs/screens/designs/`); the eleven screens with
staged captures are also compared or judged against the running game.
`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`.
For a one-command drift check, run `tools/check_screenshots.sh`. It renders the
staged captures and prop gallery into `docs/screenshots/` (using `xvfb-run` when
no display is available) and then runs `screen_designs.py compare`.
+24 -16
View File
@@ -1,9 +1,11 @@
# Blender asset library # Blender asset library
Architecture context: the asset contracts and fallback path are summarized in
[`architecture.md`](architecture.md#content-and-asset-pipeline-architecture).
Status: built, audited, and integrated (2026-11-01). `NpcAvatar` selects the Status: built, audited, and integrated (2026-11-01). `NpcAvatar` selects the
character variants and attaches the gear; `WorldBuilder` loads the prop library character variants and attaches the gear; `WorldBuilder` loads the prop library
with colliders and phase-driven emissives. See the Phase 3.5 integration in with colliders and phase-driven emissives.
[`plan-graphics-v0.6.md`](plan-graphics-v0.6.md).
Generator: [`tools/blender_assets.py`](../tools/blender_assets.py). Manifest (the Generator: [`tools/blender_assets.py`](../tools/blender_assets.py). Manifest (the
source of truth for integration): source of truth for integration):
@@ -41,6 +43,7 @@ flatpak run org.blender.Blender --background \
--python "$PWD/tools/blender_assets.py" -- render --python "$PWD/tools/blender_assets.py" -- render
python3 tools/blender_assets.py sheet # -> docs/blender_previews/sheet_<category>.jpg python3 tools/blender_assets.py sheet # -> docs/blender_previews/sheet_<category>.jpg
python3 tools/blender_assets.py audit # stdlib only; exit 1 on any issue python3 tools/blender_assets.py audit # stdlib only; exit 1 on any issue
python3 tools/blender_assets.py fit # head/clothing fit + bag-body clearance, per character
python3 tools/blender_assets.py list python3 tools/blender_assets.py list
godot --headless --path . --import # after any rebuild godot --headless --path . --import # after any rebuild
``` ```
@@ -86,16 +89,20 @@ All eight use the `npc_base` contract, so each one can replace
- material slots `skin`, `cloth`, `pants`, `accessory`, `hair` carry the same - material slots `skin`, `cloth`, `pants`, `accessory`, `hair` carry the same
neutral `assets/textures/npc/<slot>_{albedo,normal}.png` maps, so neutral `assets/textures/npc/<slot>_{albedo,normal}.png` maps, so
`Materials.npc_slot()` tints them the same way it tints `npc_base`. `Materials.npc_slot()` tints them the same way it tints `npc_base`.
- budget ≤ 6000 triangles and ≤ 4000 vertices (glTF vertex count, after seam - budget ≤ 6000 triangles and ≤ 5000 vertices (glTF vertex count, after seam
splits). The current range is 3,788–4,640 triangles and 3,200–3,960 splits; `CHAR_MAX_VERTS` in `tools/blender_assets.py`, raised from 4000 for
vertices. the 2026-11-01 face/hand pass). The current range is 3,974–5,026 triangles
and 3,744–4,807 vertices.
Construction: the torso and limbs are one continuous Skin-modifier mesh, Construction: the torso and limbs are one continuous Skin-modifier mesh,
subdivided twice, decimated to 36 % with X symmetry, and cut at the waist, cuffs, subdivided twice, decimated to 26 % with X symmetry, and cut at the waist, cuffs,
sleeves and neck so the material borders are clean edge loops. The head, sleeves and neck so the material borders are clean edge loops. The head is a
face (nose, ears, eyes, brows, mouth), hair, hands, shoes and clothing layers are separate part carrying the face detail: brow ridge, sclera/iris/upper-lid per eye,
separate parts. Weights come from distance to the bone segments, limited by body cheekbones, a nose with a bridge and tip, ears with lobes, and upper and lower
region. Parts that should stay rigid (head, hands, shoes) get a single bone. lips. Hands have four fingers plus a thumb; sleeves end in a cuff ring.
Hair, shoes and clothing layers are separate parts. Weights come from distance to
the bone segments, limited by body region. Parts that should stay rigid (head,
hands, shoes) get a single bone.
| Character | `layer_for()` / look | Suggested roles | Preview gear | | Character | `layer_for()` / look | Suggested roles | Preview gear |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@@ -154,16 +161,16 @@ exactly these points, which gives a quick visual check of the convention.
| `marquee` | the procedural marquee | 6 m canopy and sign box; emissive `bulb` + `sign_face`. Overlay the text with the existing `Label3D` | | `marquee` | the procedural marquee | 6 m canopy and sign box; emissive `bulb` + `sign_face`. Overlay the text with the existing `Label3D` |
| `sedan` | new (studio arrival) | 4.6 m; emissive `headlight`, red tail lamps | | `sedan` | new (studio arrival) | 4.6 m; emissive `headlight`, red tail lamps |
Props use plain PBR factors (base colour, roughness, metallic, emission) and Props use plain PBR factors (base colour, roughness, metallic, emission) plus
have no textures. They are not yet AO-baked; that is still a Phase 4 task. the committed 256² baked AO maps (`<name>_ao.png`) wired by `WorldBuilder`;
Emissive materials are always on in the GLB. The integration should tie them to emissive materials are tied to `WorldBuilder.set_night_mode()`. Characters
`WorldBuilder.set_night_mode()`. carry baked AO and the shared neutral material maps described above.
## Checks ## Checks
- `python3 tools/blender_assets.py audit`: container, `POSITION`/`NORMAL`/ - `python3 tools/blender_assets.py audit`: container, `POSITION`/`NORMAL`/
`TEXCOORD_0`, UVs in [0, 1], no zero-area triangles (Godot warns about them `TEXCOORD_0`, UVs in [0, 1], no zero-area triangles (Godot warns about them
during LOD generation), budgets (characters 6000/4000, gear 1500, props 4000 during LOD generation), budgets (characters 6000/5000, gear 1500, props 4000
triangles), grounding, and an editable `.blend`. For characters it also triangles), grounding, and an editable `.blend`. For characters it also
checks skinning, bones, slots, the `npc_base` mesh node and the height. checks skinning, bones, slots, the `npc_base` mesh node and the height.
- `tools/test_blender_assets.py`: catalogue and contract consistency with - `tools/test_blender_assets.py`: catalogue and contract consistency with
@@ -176,7 +183,8 @@ Emissive materials are always on in the GLB. The integration should tie them to
## Known limitations ## Known limitations
- The characters have no baked AO or ORM texture of their own. They reuse the - Characters carry a baked `<name>_ao.png` (red channel, wired by
`_apply_outfit_materials()`), but no ORM texture of their own. They reuse the
tiling slot maps, so the runtime `SurfaceMaterials` recipes still supply tiling slot maps, so the runtime `SurfaceMaterials` recipes still supply
roughness. roughness.
- There is a single body topology per build (average, slim, broad) and no - There is a single body topology per build (average, slim, broad) and no
+50
View File
@@ -0,0 +1,50 @@
# Development Status
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: **237 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, input remaps, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material, player movement, NPC character layers/LOD/queue, input bindings)
- 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](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
- NPC characters (v0.5.0): articulated limb rigs, layered outfits, hand items, face/hair variety, idle behaviour (phone, clipboard, crossed arms, look-around), escalation gestures, readable reaction symbols, door-queue offsets, and three LOD tiers with a billboard impostor
- Player movement (v0.5.0): separate acceleration/braking, step-up assist, head bob and landing dip (toggleable), precision-walk FOV/chip/tick, surface-aware footsteps, gamepad support, and persistent key/button remapping in settings
- Prop collision and placement (v0.4.0): every prop fits a static collider from its mesh bounds and publishes a navigation obstacle, so the guard and guests cannot walk through benches, bins, or bollards; placements are grouped into zones with a validator, and deterministic variants add rotation, scale, and small clutter
- 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
- Environment pass (v0.6.0): 1024² brick/concrete/asphalt with derived tangent normal maps, 256² baked AO maps for every Blender character/gear/prop, and library props whose bulbs/LEDs/sign faces follow the day/night phase
- NPC characters + library (v0.6.0): `NpcAvatar` loads one of 8 Blender-authored skinned variants (role/seed selected) with five tintable material slots, `Rig/Head` focus anchor, and attach-point gear; `npc_base.glb` and the procedural rig remain fallbacks. `tools/blender_assets.py` builds/audits the character, gear, and 20-prop library; `WorldBuilder` loads the replacements and new street dressing (lamp, trees, palms, planters, barriers, podium, doors, marquee, sedan, handrail) with colliders, navigation obstacles, and phase-driven emissives
- Web build: `build/gig-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](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
- Visual alignment: start/venue menu, pause, settings, shift review, radio, wand, inspection, timeline, HUD target chip/escalation bar, and radial menu now use or match their AI references in `assets/images/ui/`. The runtime art loader (`src/game/ui/art.gd`) falls back cleanly when a file is missing.
- Realism adoption (post-v0.6.0): v2 per-phase skylines and surface tiles with normal maps, retuned sun/exposure/fog, wet asphalt, denser foliage, a visible additive flashlight beam, and refreshed staging captures. `tools/check_screenshots.sh` is the repeatable capture/compare gate; judge is clean of `diff` verdicts.
- Input/reliability fixes: selecting a venue releases the start-menu input lock and recaptures the mouse; the body-camera overlay no longer covers the shift status. Mouse look now reads at `_input`, restores capture on window focus/click-back, and the HUD is mouse-transparent.
- Tutorial foundation (0.8.0 work): the eight-beat first-shift design and a deterministic, skippable/restartable `Tutorial` state machine exist; the in-game prompt/visitor wiring is the next step.
- Vision judge (v0.7.2): discovers the cheapest current OpenRouter image+JSON model, reports the model/fallbacks used, and clears the full screen-design judge (13/13 match, 0 diff).
- Desktop HQ (v0.7.1): optional Forward+ Windows/Linux zip with volumetric fog, SDFGI and a volumetric flashlight; the default Compatibility/Web builds and headless tests gate the effect off. A/B captures are generated locally under `docs/screens/realism/` (gitignored).
- Venue geometry (v0.7.0): the Joke Shop now builds as a narrow brick alley with a warm wall lantern, shutters, dumpster/AC clutter and no skyline; the TV Studio builds a stone facade, bulb canopy, glossy black car and barrier line. `WorldBuilder.VENUE_SPECS` drives facade/street/nav dimensions, and `GameManager` rebuilds only the world container when the venue changes.
- Gear fit (post-v0.6.0): Blender gear is measured against the avatar head/skirt silhouette and the checks run in both the Python tooling and the headless suite. Sunglasses, the shoulder bag and the firefighter helmet were fitted to the new faces; the Influencer portrait and gallery were recaptured with a before/after report.
Current gaps for v1.0.0: onboarding/campaign progression, deeper authored content and NPC dialogue, mid-shift save/resume and replay, localization/accessibility expansion, voice/audio production, wider performance profiling, signed/notarized release packaging, and a unified Godot baseline. The full gap analysis and milestone plan live in [`TODO.md`](../TODO.md); the current architecture and documentation ownership map are in [`docs/architecture.md`](architecture.md).
+63
View File
@@ -0,0 +1,63 @@
# Gameplay
The complete design rationale also lives in [`PLAN.md`](../PLAN.md); this page keeps the playable mechanics close to the README.
## Game Overview
A typical shift lasts 10–15 minutes. The player:
1. Watches the entrance, steps, queue, and surrounding sidewalk.
2. Determines why each person is approaching.
3. Uses simple gestures or polite directions first.
4. Verifies authorized access using recognition, wristbands, credentials, tickets, or guest lists.
5. Escalates only when a person refuses directions, attempts unauthorized entry, or presents a genuine threat.
6. Records the incident through body-camera and radio systems.
7. Finishes the shift with a performance review based on safety, judgment, and professionalism.
The game rewards calm resolutions rather than aggressive behavior. Emergency assistance and armed responders are rare last-resort systems with meaningful consequences.
The first shift starts with an in-game tutorial covering movement/look, focus cycling, the action wheel, access inspection, polite direction, formal warning/body camera, radio and shift review. Its beat sheet is in [`tutorial.md`](tutorial.md).
## Venues
### Hollywood Boulevard Building
The original scenario places the guard at the top of the steps outside a fictional building. Tourists, street performers, influencers, confused visitors, intoxicated pedestrians, trespassers, people experiencing a mental-health crisis, and rare violent threats may approach the entrance.
Authorized people must either be recognized by the guard or present a valid wristband. Wristbands can have different colors, patterns, dates, or event markings. They may be valid, expired, counterfeit, or missing.
### The Joke Shop
The Joke Shop is a narrow brick alley with a recessed door, shuttered windows, a warm wall lantern, a dumpster and wall-mounted AC clutter. The door person checks IDs, tickets, reservations, and guest-list status, then uses a handheld security wand according to venue policy. The player must interpret wand alerts rather than treating every alert as proof of a weapon; keys, belts, phones, and other harmless objects can trigger false alarms.
### Hollywood Boulevard Late Night TV Studio
This scenario combines the previously defined NPC types with a busy television-studio entrance: a stone facade, a bulb-lit canopy, a glossy black car at the curb, and stanchions/barriers holding the arrival line. The player manages audience members, production staff, performers, guests, deliveries, celebrity arrivals, tourists, street performers, influencers, confused visitors, intoxicated pedestrians, trespassers, people experiencing a mental-health crisis, and rare violent threats.
The studio uses wristbands, credentials, tickets, guest-list confirmation, and personal recognition to authorize entry. Crowd control, filming boundaries, following authorized guests through the door, and counterfeit credentials are key events.
## Escalation Model
The common escalation model has six levels:
0. **Observation** — Decide whether intervention is necessary.
1. **Simple deterrence and polite direction** — Raise an open hand, make eye contact, point toward the sidewalk or check-in area, shake the head, or give a short instruction.
2. **Formal warning** — Clearly explain the rule and request compliance.
3. **Blocking and physical positioning** — Secure the entrance, maintain distance, preserve an escape route, and deny access without starting a fight.
4. **Radio for security assistance** — Request a supervisor, second guard, building management, or other appropriate support.
5. **Emergency response** — Hold the emergency-radio button and confirm only when there is an immediate threat, such as a weapon, assault, forced entry, or serious threat to occupants.
An off-duty law-enforcement officer or armed emergency responder may arrive during a valid emergency. This character is not intended to be a routine stronger attack. Their role is crisis control, evacuation, restraint, arrest, or other appropriate intervention, with force governed by the situation and meaningful consequences for unjustified calls.
## Core Systems
- **NPC behavior:** NPCs have different motivations, temperaments, compliance levels, and reactions to the player’s approach.
- **Access verification:** Recognition, wristbands, IDs, tickets, credentials, and guest lists determine who may enter.
- **Nonverbal deterrence:** Small actions such as raising a hand can resolve low-risk situations without dialogue or force.
- **Dialogue:** Polite directions and warnings affect outcomes; simple gestures can resolve low-risk situations without dialogue.
- **Escalation:** The player chooses the lowest effective response.
- **Radio assistance:** Calls require useful information and may take time to arrive.
- **Evidence:** Body-camera records support shift review and accountability.
- **Crowd reaction:** Aggressive behavior, visible weapons, and emergency response can create panic or attract a crowd.
- **Reputation:** Peaceful resolutions, accurate reports, low property damage, and justified calls improve performance; unnecessary confrontations and false reports reduce it.
- **Shift review:** The player receives feedback on access decisions, escalation choices, public complaints, injuries, and security outcomes.
+97
View File
@@ -0,0 +1,97 @@
# Setup, Run and Test, and Controls
## Setup
1. Download the Godot 4.3+ editor (official build) and put `godot` on your PATH. CI pins 4.3; local development is verified with 4.7.2.
2. Open or run the project from this directory.
## Run and Test
```bash
# Open in the editor
godot --editor --path .
# Run the game
godot --path .
# Headless smoke boot (120 frames then exit)
godot --headless --path . --quit-after 120
# Run all unit tests headlessly (exit code 0 = pass)
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
# Visual-regression smoke: render the staged captures + prop gallery, then compare them
./tools/check_screenshots.sh
# 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
# Prop-collision smoke (windowed): guard stops at a bench, guest routes around it
godot --path . --script res://src/test/smoke_props.gd
# NPC budget smoke: 16 full-rig avatars against the documented frame budget
godot --headless --path . --script res://src/test/smoke_npc_budget.gd
# Movement smoke (windowed): steps, precision walk, footsteps and landing
godot --path . --script res://src/test/smoke_movement.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.
## Controls
Keyboard and mouse are the default; a gamepad works without configuration (left stick moves,
right stick looks, A opens/confirms the wheel, B cancels, X inspects, Y calls the radio, LB walks
slowly, RB uses the wand). Keys and pad buttons can be rebound in Settings, and the remaps persist
in `user://settings.json`. The HUD legend switches to gamepad hints when a pad event arrives.
| Input | Action |
| --- | --- |
| W/A/S/D + mouse | Move and look |
| 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) |
| 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 |
| C | Handheld wand screening (wand venues only) |
| A / D | Allow / deny entry during inspection or wand check |
| 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 |
-293
View File
@@ -1,293 +0,0 @@
# Development Plan — Graphics Quality v0.6.0 (Blender-authored NPCs)
> Status: Proposed · Target version: **v0.6.0** (MINOR)
> Companion decision: [ADR-0013](adr/0013-blender-authored-npc-characters.md)
## Why this exists
The game is functionally complete through v0.5.0, but NPCs are still the
procedural capsule-and-box rig from [ADR-0010](adr/0010-procedural-npc-presentation.md).
They read as people at conversation distance but not at cinematic distance, and
every surface on a body is a flat color. ADR-0010 recorded that "a later
skinned-mesh pass will replace this rig and must keep the `Head` contract" —
this plan is that pass.
The goal is **better NPCs first**, then a modest environment lift, using the
existing AI tooling for art direction and quality gates, and **Blender** for the
geometry, UVs, skeleton, and baked material maps.
## Constraints that shape the plan
- **Renderer is GL Compatibility** (`project.godot` `config/features`). SSAO,
SSIL, and volumetrics are unavailable. Quality therefore comes from better
meshes, baked maps (AO/normal/roughness), and lighting — not from a renderer
switch.
- **CI builds offline.** `.gitea/workflows/build.yml` runs the Godot editor only
(no Blender, no network). Everything the game loads must be committed; the
Blender step is a developer tool, and the game keeps a procedural fallback so a
fresh checkout still boots and the headless suite still passes.
- **Determinism.** Seed-driven variety (`outfit_for`, `layer_for`,
`hand_item_for`, `idle_style_for`) must keep working and keep its tests green.
AI generation is a dev-time input, not a runtime dependency; the committed
assets are the source of truth.
- **Frame budget.** v0.5.0 measured 16 avatars = 534 meshes ≈ 6.9 ms headless.
A skinned rig replaces ~33 draw calls per avatar with one skin, but skinning
costs CPU. Re-measure with `src/test/smoke_npc_budget.gd` before raising
`MAX_LIVE_NPCS`.
## The NPC asset contract (what must not break)
From `src/game/npc_avatar.gd` and `src/game/guard_controller.gd`:
- A scene-level node named **`Rig/Head`** — `GuardController` raycasts for it
(`get_node_or_null("Rig/Head")`), and the focus circle + name tag + reaction
tag attach under it.
- Limb pivots currently named `HipL`/`HipR` → `Knee`, `ShoulderL`/`ShoulderR` →
`Elbow` → `Hand`. `_animate()` and `_idle_layer()` drive them by `rotation.x`.
- Pure static helpers must stay pure and seed-deterministic: `outfit_for`,
`layer_for`, `hand_item_for`, `idle_style_for`, `queue_slot_for`,
`lod_tier_for_distance`, `reaction_label`, `crouch_for_pose`,
`prop_lift_for_pose`, `limb_swing`, `sway_for_pose`.
- LOD tiers (`apply_lod`, 0 / 1 / 2 with a billboard impostor at tier 2) must
keep working; `_detail_nodes` are hidden at tier 1.
- `set_focus_visual`, `_focus_circle`, `_name_tag`, `_reaction_tag`, the
theater poses, and the crowd reaction (`start_rubberneck`, `start_flee`) all
operate on the rig and must survive the swap.
## Workstreams
### Phase 0 — Toolchain spike and contract freeze (small, throwaway)
1. Confirm headless Blender works in this environment. Blender **5.2.0 LTS**
flatpak is installed; the sandbox cwd is `/app/blender`, so `--python` and
all input/output paths must be absolute:
```bash
flatpak run org.blender.Blender --background \
--python /var/home/zwitschi/Documents/06-games/security-game/tools/npc_pipeline.py -- --build
```
Record the working invocation in `MEMORY.md` (the Im-Dornäcker notes already
warn about the cwd gotcha).
2. Freeze the asset contract in writing (this document + ADR-0013). No
animation clips are exported: `_animate()` and `_idle_layer()` drive the
armature bones directly at runtime, so the GLB only needs the skinned rest
mesh, the armature, and the material maps.
- `assets/models/generated/npc_base.glb` — one skinned mesh (or a small fixed
set of sub-meshes: body / head / hands) with a **named armature** and UVs.
- Material slots rather than one atlas-per-prop: `skin`, `cloth`,
`pants`, `accessory`, `hair` each carry a neutral albedo + normal + ORM
(occlusion/roughness/metallic) map. `outfit_for` colors tint the slot, so
multi-color outfits and seed variety survive.
- Bone names mirror the current node names (`Head`, `HipL/R`, `KneeL/R`,
`ShoulderL/R`, `ElbowL/R`, `HandL/R`) so `_animate()` can target them.
- Budget: tier-0 base body ≤ ~6,000 triangles, ≤ ~4,000 vertices (revisit
after the first `smoke_npc_budget.gd` run).
### Phase 1 — `tools/npc_pipeline.py`: author + export + audit
New tool, run through the Blender flatpak, mirroring the `model_pipeline.py`
shape (`list` / `build` / `audit`):
1. **Author** a low-poly base humanoid in Blender: torso, articulated arms/legs,
head with a face front (so the focus ring and the "noticed the guard" cue
keep a direction), hands, shoes. Keep the current silhouette language
(heads, caps, hoods, backpacks, lanyards) so existing `outfit_for`
accessories still attach.
2. **Rig** it with an armature whose bones are named to match the node contract.
Godot 4.3 imports glTF skeletons as `Skeleton3D`; keep the bone rest pose
matching the current `_animate()` assumptions (arms hang, legs straight,
+Y up, origin at the feet on the floor).
3. **UV-unwrap** into a power-of-two layout and **bake** maps in Blender from
node materials or a higher-detail source:
- neutral albedo (white fabric pattern) per material slot,
- normal map (fabric weave, denim, knit),
- ORM map (occlusion in R, roughness in G, metallic in B — same packing
`model_pipeline.atlas_for` already uses),
- ambient occlusion baked into the albedo or the ORM R channel (this is the
Compatibility renderer's only AO source).
4. **Export** `npc_base.glb` with materials referenced as Godot-importable
textures (or export textures alongside and wire them in GDScript — decide in
the spike).
5. **Audit** (`npc_pipeline.py audit`) enforcing: UVs in [0,1], expected bone
names present, triangle/vertex budget, feet at y ≈ 0, no missing material
slots. Reuse the checks and the colour-space discipline from
`model_pipeline.py` (glTF `baseColorFactor` is linear; albedo textures are
sRGB — do not mix them up).
6. **Commit** the `.blend` source (`assets/models/npc/npc_base.blend`) so the
build is reproducible and editable, plus the generated `.glb` + textures so
CI never needs Blender.
### Phase 2 — AI-assisted texturing (existing tools only)
Use the tools already in `tools/`, not new services:
1. `tools/asset_pipeline.py generate` (OpenRouter,
`openai/gpt-5-image-mini`) for seamless clothing albedos — shirt, trousers,
jacket, hi-vis vest, coat, dress — at the role sizes its `audit` validates.
Run `seamless` for tile-edge blending and `audit` to reject wrong sizes.
2. `tools/model_pipeline.py`'s palette helpers (`dominant_colors`,
`assign_roles`) or a small shared import, to derive per-role outfit palettes
from AI concept sheets (`concept`). `outfit_for` keeps determinism: the AI
supplies palettes and textures, never per-frame randomness.
3. Normal/roughness detail (fabric weave, denim) baked procedurally in Blender,
with the AI albedos layered on top.
4. Close the loop with `tools/screen_designs.py judge`: regenerate the staged
captures and confirm the new NPCs realise the same screen intents (art style
is ignored by the judge; layout and readability are not).
Outputs land under `assets/textures/ai/game_ready/` (or a new
`assets/textures/npc/`) and are **committed**.
### Phase 3 — Godot integration (`npc_avatar.gd`, `materials.gd`)
1. Add a `_load_model()` path in `NpcAvatar` that instantiates the imported
`npc_base.glb` `PackedScene`, exactly like `WorldBuilder._add_prop_model()`
loads props. Keep `_build_body()` as the offline/procedural fallback; choose
per-avatar with the same "asset or fallback" pattern (record the choice in a
`loaded_model` / `fallback_avatar` field for tests).
2. Map `outfit_for()` onto the model's material slots (skin/cloth/pants/
accessory/hair) so seed variety, `layer_for`, `hand_item_for`, and the
archetype color read stay intact. Accessories (cap/hood/backpack/
sunglasses/lanyard) keep attaching under `Rig`/`Head` as they do today.
3. Add `SurfaceMaterials` recipes for the baked maps — e.g. `npc()` /
`skin()` / `cloth()` — so `_material()` gains a textured path and the body
stops calling `Materials.flat()`.
4. Abstract the animation targets so `_animate()` / `_idle_layer()` write to
either the procedural `Node3D` pivots (fallback) or `Skeleton3D` bones
(skinned). Keep the existing `rotation.x` math; the abstraction is a thin
`_set_limb_rotation(part, axis, radians)` wrapper.
5. Keep `Rig/Head` as a real scene node (a marker following the head bone, or
the imported bone's Godot node) so focus, nametag, and head-turn still find
it. Re-verify the focus cone smoke and `test_npc_character` /
`test_npc_theater` / `test_target_cycle`.
6. Re-measure `smoke_npc_budget.gd`; adjust `LOD_TIERS` if the skinned rig is
heavier up close. The impostor path is unchanged.
### Phase 3.5 — Blender asset library (characters, gear, props)
The library generator is `tools/blender_assets.py` (flatpak Blender, headless).
**Status: integrated** — `NpcAvatar` selects characters/gear and `WorldBuilder`
loads the replacement/new props plus phase-driven emissives; the procedural rig
and primitive props remain fallbacks. Integration notes:
[`blender-assets.md`](blender-assets.md).
**Outputs and contract**
| Kind | Path | Notes |
| --- | --- | --- |
| Characters | `assets/models/blender/npc/<name>.glb` | 8 variants: `npc_casual`, `npc_jacket`, `npc_coat`, `npc_vest`, `npc_dress`, `npc_hoodie`, `npc_firefighter`, `npc_guard` |
| Gear | `assets/models/blender/npc_gear/<name>.glb` | 18 pieces; authored in attach-point space (`head`, `hand`, `back`, `chest`) |
| Props | `assets/models/blender/props/<name>.glb` | 20 pieces; the first nine replace current generated/primitive street furniture, the rest are new venue dressing |
| Manifest | `assets/models/blender/manifest.json` | path, category, triangles, vertices, bounds, materials, roles, tint slots, attach points |
| Sources | `assets/models/blender_src/<name>.blend` | committed, editable source |
| Previews | `docs/blender_previews/sheet_<category>.jpg` (committed) + per-asset `*.png` (local) | `render` / `sheet` for visual review |
All characters reuse the `npc_base` contract: mesh node `npc_base`, bones
`Head/HipL/R/KneeL/R/ShoulderL/R/ElbowL/R/HandL/R` (+ Hips/Spine/Neck),
material slots `skin/cloth/pants/accessory/hair`, front toward Blender `-Y`
(Godot `+Z`), feet at `y ~= 0`. Gear origins are the attach points (head at
`Rig/Head`, hand at the hand proxy, back at the spine, chest at the sternum).
Props are grounded except `wall_lantern` and `marquee`; both may be emissive.
**Integration steps**
1. **Audit the finished build.** `python3 tools/blender_assets.py audit` and
`sheet`; fix assets or reconcile the budgets. **Done 2026-11-01:** the
characters were simplified to fit the unchanged 6,000 / 4,000 contract
(3,788–4,640 tris), and all 46 assets audit clean.
2. **Commit the library and sources.** Add `assets/models/blender/**`,
`assets/models/blender_src/**`, the generator, manifest, previews, and
`docs/blender-assets.md`; generated folders that CI can rebuild offline may
stay ignored. `blender_assets.py audit` must work without Blender.
3. **Select characters by role.** Extend `NpcAvatar` to pick a variant from the
manifest using the profile's role and `outfit_for`/`layer_for` (casual,
jacket, coat, vest, dress, hoodie, firefighter, guard). Fall back to
`npc_base.glb`, then to the procedural rig, and record the chosen path.
4. **Tint and anchor.** Apply `outfit_for` to every variant's five slots via
`SurfaceMaterials.npc_slot()`; keep `Rig/Head` for the focus circle, name tag,
reaction tag, and accessories. Keep the walk/idle/escalation bone-driving
wrapper.
5. **Replace procedural gear.** Use the `npc_gear` catalogue for caps, hoods,
helmets, backpacks, lanyards, hand items (phone, cup, clipboard, umbrella,
camera, sign, flashlight, wand, radio) and the SCBA tank; attach at the
documented points and fall back to the current primitives when an asset is
missing.
6. **Replace and extend props.** Load the replacement set through
`WorldBuilder` (`bollard`, `bench`, `trash_bin`, `planter_box`,
`newspaper_box`, `fire_hydrant`, `street_lamp`, `street_tree`,
`traffic_cone`), then place the new dressing (`palm_tree`, `round_planter`,
`a_frame_sign`, `wall_lantern`, `crowd_barrier`, `stanchion`, `handrail`,
`security_podium`, `double_door`, `marquee`, `sedan`) using the existing
placement table/collider/avoidance contract. No prop may enter the
door/steps/guest lane.
7. **Wire emissives to the phase.** Street-lamp bulbs, wall lanterns, the door
lock LED, marquee bulbs/sign face, and sedan headlights should follow the
existing day/night lighting rather than staying always on.
8. **Measure and gate.** Re-run `smoke_npc_budget.gd` with the new characters
before raising `MAX_LIVE_NPCS`; add a prop triangle/draw budget check. Then
recapture `docs/screenshots/` and run `screen_designs.py compare` + `judge`
plus a close-up target shot for character readability.
9. **Fallbacks stay offline.** A fresh checkout with no Blender and no network
must boot on the committed library (or `npc_base.glb`/procedural rig when an
asset is absent); CI still runs the Godot import and headless suite.
### Phase 4 — Environment and lighting lift (after NPCs land) ✅
1. **Baked AO on props:** use the Phase 3.5 Blender prop replacements
(`street_lamp`, `street_tree`, `bench`, …) and bake AO into their
albedo/ORM maps so contact shadows read without SSAO. Keep the same
`WorldBuilder` load/fallback contract (ADR-0012's follow-up).
2. **Higher-resolution environment textures:** regenerate brick/concrete/asphalt
through `asset_pipeline.py` with normal maps, keeping the current UV scales;
use the same sRGB/albedo + packed ORM discipline as the library assets.
3. **Evaluate a desktop-only Forward+ preset** — done: it boots on the desktop Vulkan
driver, but the renderer is project-wide and the Web preset must stay GL
Compatibility. v0.6.0 keeps Compatibility; a desktop-only override is deferred.
4. Regenerate `docs/screenshots/` with `capture_screenshots.gd`, then run
`screen_designs.py compare` and `judge` as the regression gate, plus
`blender_assets.py sheet` for the character/gear/prop library.
### Phase 5 — Verification and release
- Headless: fallback build, `npc_pipeline.py audit` (via `test_model_pipeline`
style Python tests if Blender is present, otherwise skip with reason), outfit
determinism, `Rig/Head` presence, material recipe assertions.
- Windowed smokes: `smoke_npc_budget.gd`, `smoke_movement.gd`,
`smoke_focus_radial.gd`, `smoke_props.gd`; screenshot comparison.
- Bump `config/version` to `0.6.0`, update `README.md`, `docs/architecture.md`
(Level 2 gains `tools/npc_pipeline.py`), and publish per
[`docs/RELEASING.md`](RELEASING.md).
## Risks and mitigations
| Risk | Mitigation |
| --- | --- |
| Blender flatpak sandbox paths (cwd `/app/blender`) | Absolute paths everywhere; record the invocation in `MEMORY.md`. |
| glTF colour space (linear factors vs sRGB textures) | Reuse `model_pipeline.srgb_to_linear`; bake textures as sRGB, keep factors linear; assert in `audit`. |
| Skeleton retargeting vs current node math | Keep the bone names + a thin limb-rotation wrapper; fallback rig keeps old math running. |
| Frame budget with skinned avatars | Measure first (`smoke_npc_budget.gd`); tune `LOD_TIERS`; impostor path unchanged. |
| No SSAO in Compatibility | Bake AO into albedo/ORM; contact-shadow tune in Phase 4. |
| AI output nondeterminism | Generation is dev-time only; commit outputs and gate with `audit`/`judge`/`compare`. |
| Blender unavailable in CI | Committed `.glb` + textures; procedural fallback; `audit` skipped with reason when Blender is absent. |
## Acceptance criteria (definition of done)
1. NPCs and street furniture are Blender-authored meshes with baked maps; the
character variants, gear, and props read as people/objects at conversation
distance and survive a cinematic close-up.
2. `Rig/Head`, the focus cone, nametag, theater poses, crowd reactions, and LOD
all still work; all existing NPC tests pass unchanged or are updated with
clear reasons.
3. The game boots and plays from a fresh checkout with **no** Blender and no
network (committed assets + procedural fallback).
4. `npc_pipeline.py build` is deterministic and `audit` enforces the contract.
5. 212 headless + 60 Python tests stay green (Blender-dependent Python tests
skip with a reason when Blender is absent); `npc_pipeline.py audit` and
`blender_assets.py audit` pass; NPC-budget and movement smokes pass;
screenshot `compare`/`judge` and the library contact sheets show no
regressions.
6. The library is committed with its generator/manifest/previews, runtime
selection and fallbacks are covered by tests, version bumped to v0.6.0,
docs and ADR-0013 updated, release published.
+8 -2
View File
@@ -20,9 +20,15 @@ file here; they are saved as `<name>-vN.png`.
| `entrance_steps_podium` | granite steps, handrails, glass doors, the check-in podium | | `entrance_steps_podium` | granite steps, handrails, glass doors, the check-in podium |
| `crowd_barrier_line` | steel barriers, a red carpet, a crowd with depth-of-field haze | | `crowd_barrier_line` | steel barriers, a red carpet, a crowd with depth-of-field haze |
| `responder_arrival_wide` | fire engine, turnout gear, red/blue light spill on wet pavement | | `responder_arrival_wide` | fire engine, turnout gear, red/blue light spill on wet pavement |
| `npc_portrait_day` | close portrait of a pedestrian: skin with visible pores and stubble, fabric weave, shallow depth of field. The bar for an avatar close-up, not for the crowd shots |
Cross-cutting targets, for comparison with the 2026-11-01 captures in The `npc_portrait` capture in `capture_realism.gd` is staged, not posed by the
`docs/screens/realism/2026-11-01-v2/sheet.jpg`: crowd: it freezes one NPC on an open patch of sidewalk, parks the rest of the
crowd out of frame, turns the subject into the camera and stands the guard on the
sunlit side. It is the only shot where a face fills the frame, so it is the one to
eyeball after an avatar change (generate a local run under `docs/screens/realism/`).
Cross-cutting targets, for comparison with locally generated realism runs:
- **Exposure.** The concepts keep the ground mid-grey. The game's sidewalk is close to white, - **Exposure.** The concepts keep the ground mid-grey. The game's sidewalk is close to white,
so it is overexposed. so it is overexposed.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File
Whitespace-only changes.
@@ -1,878 +0,0 @@
{
"run": "docs/screens/realism/2026-11-01-adopt",
"judge_model": "google/gemini-2.5-flash",
"sets": {
"after": {
"boulevard_day": {
"target": "docs/screens/concepts/boulevard_skyline_day.png",
"metrics": {
"edge_density": 23.21,
"fine_detail": 8.88,
"contrast": 38.78,
"saturation_mean": 99.58,
"colour_spread": 46.73
},
"target_metrics": {
"edge_density": 20.74,
"fine_detail": 8.23,
"contrast": 54.85,
"saturation_mean": 73.09,
"colour_spread": 58.16
},
"metric_gap": 0.172,
"judge": {
"scores": {
"overall": 1.0,
"backdrop": 3.0,
"lighting": 1.0,
"characters": 0.0,
"texture_detail": 1.0,
"materials": 1.0
},
"score_range": {
"overall": [
1.0,
1.0
],
"backdrop": [
3.0,
3.0
],
"lighting": [
1.0,
1.0
],
"characters": [
0.0,
0.0
],
"texture_detail": [
1.0,
1.0
],
"materials": [
1.0,
1.0
]
},
"runs": 3,
"fixes": [
"Implement physically based rendering (PBR) materials for all assets, focusing on realistic albedo, roughness, and metallic maps to achieve accurate surface responses to light.",
"Overhaul lighting system to include global illumination, realistic skybox/HDRI for ambient light, and accurate sun/directional light with proper shadows and atmospheric scattering (haze/fog).",
"Replace placeholder low-polygon models with high-detail 3D assets for trees, street furniture, and buildings, ensuring proper UV mapping and texture application.",
"Improve texture resolution and detail across all surfaces, adding normal maps for surface imperfections and detail, and ensuring textures are seamlessly tiled or uniquely mapped where appropriate.",
"Develop a realistic character pipeline, including high-fidelity models, animations, and PBR materials for skin, clothing, and hair, to populate the scene."
]
}
},
"boulevard_night": {
"target": "docs/screens/concepts/hollywood_entrance_rain_night.png",
"metrics": {
"edge_density": 16.02,
"fine_detail": 7.13,
"contrast": 39.4,
"saturation_mean": 163.01,
"colour_spread": 40.42
},
"target_metrics": {
"edge_density": 8.43,
"fine_detail": 3.51,
"contrast": 27.34,
"saturation_mean": 159.44,
"colour_spread": 26.61
},
"metric_gap": 0.723,
"judge": {
"scores": {
"overall": 1.0,
"backdrop": 2.0,
"lighting": 1.0,
"characters": 1.0,
"texture_detail": 1.0,
"materials": 1.0
},
"score_range": {
"overall": [
1.0,
1.0
],
"backdrop": [
2.0,
2.0
],
"lighting": [
1.0,
1.0
],
"characters": [
1.0,
1.0
],
"texture_detail": [
1.0,
1.0
],
"materials": [
1.0,
1.0
]
},
"runs": 3,
"fixes": [
"Implement a physically based rendering (PBR) material pipeline for all assets to achieve realistic surface responses to light, especially for wet surfaces and brick.",
"Overhaul the lighting system to include global illumination, volumetric fog/rain, and realistic light sources with proper falloff and color temperature, focusing on dramatic contrast and mood.",
"Replace low-polygon models with high-detail meshes and high-resolution textures, including normal and roughness maps, for all environmental elements (buildings, street, props, characters).",
"Redesign the backdrop with a more detailed and atmospheric skyline, incorporating distance haze, subtle cloud formations, and appropriate night sky colors to match the target's mood.",
"Update character models to be realistic, with detailed clothing, skin, and hair textures, and implement realistic animation and shading."
]
}
},
"entrance_day": {
"target": "docs/screens/concepts/hollywood_entrance_morning.png",
"metrics": {
"edge_density": 25.88,
"fine_detail": 9.86,
"contrast": 40.18,
"saturation_mean": 93.04,
"colour_spread": 43.24
},
"target_metrics": {
"edge_density": 16.87,
"fine_detail": 6.92,
"contrast": 70.27,
"saturation_mean": 121.02,
"colour_spread": 69.83
},
"metric_gap": 0.442,
"judge": {
"scores": {
"overall": 1.0,
"backdrop": 0.0,
"lighting": 1.0,
"characters": 0.0,
"texture_detail": 1.0,
"materials": 1.0
},
"score_range": {
"overall": [
1.0,
1.0
],
"backdrop": [
0.0,
0.0
],
"lighting": [
1.0,
1.0
],
"characters": [
0.0,
0.0
],
"texture_detail": [
1.0,
1.0
],
"materials": [
1.0,
1.0
]
},
"runs": 3,
"fixes": [
"Implement a physically based rendering (PBR) material pipeline for all assets to achieve realistic surface responses to light.",
"Overhaul the lighting system to include global illumination, realistic skyboxes with atmospheric scattering, and dynamic shadows.",
"Replace placeholder low-polygon models with high-fidelity assets that include detailed normal maps and displacement maps for architectural elements, foliage, and street furniture.",
"Develop a detailed backdrop with distant buildings, varied skyline, and atmospheric haze to create depth and realism.",
"Add realistic foliage models with proper leaf textures, branch structures, and varied forms instead of simple geometric shapes."
]
}
},
"entrance_late": {
"target": "docs/screens/concepts/hollywood_entrance_rain_night.png",
"metrics": {
"edge_density": 18.4,
"fine_detail": 8.41,
"contrast": 51.61,
"saturation_mean": 127.4,
"colour_spread": 51.96
},
"target_metrics": {
"edge_density": 8.43,
"fine_detail": 3.51,
"contrast": 27.34,
"saturation_mean": 159.44,
"colour_spread": 26.61
},
"metric_gap": 1.105,
"judge": {
"scores": {
"overall": 1.0,
"backdrop": 0.0,
"lighting": 1.0,
"characters": 0.0,
"texture_detail": 1.33,
"materials": 1.33
},
"score_range": {
"overall": [
1.0,
1.0
],
"backdrop": [
0.0,
0.0
],
"lighting": [
1.0,
1.0
],
"characters": [
0.0,
0.0
],
"texture_detail": [
1.0,
2.0
],
"materials": [
1.0,
2.0
]
},
"runs": 3,
"fixes": [
"Implement volumetric fog and rain effects to create atmospheric depth and mood, especially around light sources.",
"Overhaul lighting system to include realistic light falloff, bounce light, and wet surface reflections, focusing on the marquee and streetlights.",
"Redesign all materials with PBR workflows, ensuring accurate roughness, metallic, and normal maps for brick, concrete, metal, and wet surfaces.",
"Add high-resolution, detailed textures for all surfaces, particularly the brickwork, sidewalk, and marquee, to capture fine imperfections and wear.",
"Develop a detailed backdrop with distant buildings, subtle city lights, and atmospheric haze to replace the current flat sky."
]
}
},
"joke_shop_door": {
"target": "docs/screens/concepts/joke_shop_door_closeup.png",
"metrics": {
"edge_density": 14.84,
"fine_detail": 7.04,
"contrast": 47.63,
"saturation_mean": 132.89,
"colour_spread": 49.43
},
"target_metrics": {
"edge_density": 9.79,
"fine_detail": 3.59,
"contrast": 26.09,
"saturation_mean": 117.17,
"colour_spread": 25.09
},
"metric_gap": 0.818,
"judge": {
"scores": {
"overall": 2.0,
"backdrop": 4.0,
"lighting": 2.0,
"characters": 0.0,
"texture_detail": 3.0,
"materials": 3.0
},
"score_range": {
"overall": [
2.0,
2.0
],
"backdrop": [
4.0,
4.0
],
"lighting": [
2.0,
2.0
],
"characters": [
0.0,
0.0
],
"texture_detail": [
3.0,
3.0
],
"materials": [
3.0,
3.0
]
},
"runs": 3,
"fixes": [
"Implement physically based rendering (PBR) materials for all surfaces, especially brick, wood, and metal, to achieve realistic light interaction and surface properties.",
"Overhaul the lighting system to include more nuanced light sources, volumetric effects, and global illumination. Focus on creating warm, inviting pools of light around entrances and signs, with realistic falloff and shadows.",
"Improve texture resolution and detail for all assets, particularly the brickwork, ground, and signage. Add normal maps and displacement maps to give surfaces depth and tactile quality.",
"Redesign the 'Comedy Club' sign to be a prominent, well-lit feature with appropriate emissive materials and a more stylized font, similar to the target image's 'COMEDY CLUB' sign.",
"Enhance the backdrop with more detailed city lights, atmospheric haze, and a more realistic skybox to create a sense of depth and a believable urban environment."
]
}
},
"street_day": {
"target": "docs/screens/concepts/boulevard_skyline_day.png",
"metrics": {
"edge_density": 18.69,
"fine_detail": 7.42,
"contrast": 42.69,
"saturation_mean": 106.86,
"colour_spread": 48.19
},
"target_metrics": {
"edge_density": 20.74,
"fine_detail": 8.23,
"contrast": 54.85,
"saturation_mean": 73.09,
"colour_spread": 58.16
},
"metric_gap": 0.148,
"judge": {
"scores": {
"overall": 1.0,
"backdrop": 2.0,
"lighting": 1.0,
"characters": 0.0,
"texture_detail": 1.0,
"materials": 1.0
},
"score_range": {
"overall": [
1.0,
1.0
],
"backdrop": [
2.0,
2.0
],
"lighting": [
1.0,
1.0
],
"characters": [
0.0,
0.0
],
"texture_detail": [
1.0,
1.0
],
"materials": [
1.0,
1.0
]
},
"runs": 3,
"fixes": [
"Implement physically based rendering (PBR) materials for all assets, focusing on realistic albedo, roughness, and metallic values to achieve accurate light interaction.",
"Overhaul the lighting system to include a directional light representing the sun, a skybox/HDRI for ambient light, and global illumination to simulate bounced light, matching the target's clear, bright, and slightly warm daylight.",
"Replace placeholder geometry with high-fidelity 3D models for cars, buildings, and environmental elements (e.g., palm trees, streetlights, sidewalks) that match the realistic proportions and details of the target image.",
"Develop detailed textures for all surfaces, including road asphalt, concrete sidewalks, building facades, and foliage, ensuring appropriate resolution and variety to avoid tiling artifacts and enhance realism.",
"Create a realistic backdrop with a detailed skyline, atmospheric perspective (distance haze), and a clear blue sky that accurately reflects the target's Los Angeles aesthetic, integrating it seamlessly with the foreground."
]
}
},
"street_late": {
"target": "docs/screens/concepts/boulevard_skyline_dusk.png",
"metrics": {
"edge_density": 12.95,
"fine_detail": 6.27,
"contrast": 31.94,
"saturation_mean": 122.75,
"colour_spread": 36.44
},
"target_metrics": {
"edge_density": 8.83,
"fine_detail": 4.61,
"contrast": 33.26,
"saturation_mean": 188.16,
"colour_spread": 35.38
},
"metric_gap": 0.224,
"judge": {
"scores": {
"overall": 0.0,
"backdrop": 0.0,
"lighting": 0.0,
"characters": 0.0,
"texture_detail": 0.0,
"materials": 0.0
},
"score_range": {
"overall": [
0.0,
0.0
],
"backdrop": [
0.0,
0.0
],
"lighting": [
0.0,
0.0
],
"characters": [
0.0,
0.0
],
"texture_detail": [
0.0,
0.0
],
"materials": [
0.0,
0.0
]
},
"runs": 3,
"fixes": [
"Implement a full physically-based lighting model with global illumination, volumetric fog, and atmospheric scattering to achieve realistic sunset colors and light interaction.",
"Replace placeholder geometry with detailed 3D models for buildings, palm trees, and vehicles, ensuring proper scale and architectural variety.",
"Develop high-resolution PBR (Physically Based Rendering) materials for all surfaces, including reflective wet asphalt, glowing neon signs, and varied building facades, with appropriate roughness, metallic, and normal maps.",
"Create a dynamic skybox system that accurately renders sunset gradients, distant city skylines with emissive windows, and atmospheric haze, replacing the current flat backdrop.",
"Add realistic character models with appropriate clothing and animations to populate the scene, contributing to the overall realism and sense of a living city."
]
}
},
"street_night": {
"target": "docs/screens/concepts/boulevard_skyline_dusk.png",
"metrics": {
"edge_density": 10.97,
"fine_detail": 5.57,
"contrast": 24.27,
"saturation_mean": 190.35,
"colour_spread": 25.15
},
"target_metrics": {
"edge_density": 8.83,
"fine_detail": 4.61,
"contrast": 33.26,
"saturation_mean": 188.16,
"colour_spread": 35.38
},
"metric_gap": 0.253,
"judge": {
"scores": {
"overall": 1.0,
"backdrop": 1.0,
"lighting": 1.0,
"characters": 0.0,
"texture_detail": 1.0,
"materials": 1.0
},
"score_range": {
"overall": [
1.0,
1.0
],
"backdrop": [
1.0,
1.0
],
"lighting": [
1.0,
1.0
],
"characters": [
0.0,
0.0
],
"texture_detail": [
1.0,
1.0
],
"materials": [
1.0,
1.0
]
},
"runs": 3,
"fixes": [
"Implement a full physically-based lighting model with global illumination, volumetric fog, and proper emissive materials for signs and car lights to capture the sunset glow and light trails.",
"Replace placeholder geometry with detailed 3D models for buildings, palm trees, and vehicles, ensuring accurate proportions and architectural details.",
"Develop high-resolution PBR textures for all surfaces (roads, buildings, signs, vehicles) with appropriate albedo, roughness, metallic, and normal maps to add realism and detail.",
"Create a dynamic skybox with a realistic sunset gradient, distant city skyline with illuminated windows, and atmospheric haze to match the target's depth and mood.",
"Add realistic character models with appropriate clothing and animations to populate the scene and enhance the sense of a living city."
]
}
},
"studio_door": {
"target": "docs/screens/concepts/studio_stage_door_night.png",
"metrics": {
"edge_density": 16.62,
"fine_detail": 7.65,
"contrast": 48.52,
"saturation_mean": 133.55,
"colour_spread": 49.81
},
"target_metrics": {
"edge_density": 11.39,
"fine_detail": 4.87,
"contrast": 39.17,
"saturation_mean": 104.49,
"colour_spread": 38.0
},
"metric_gap": 0.395,
"judge": {
"scores": {
"overall": 1.0,
"backdrop": 0.0,
"lighting": 1.0,
"characters": 0.0,
"texture_detail": 1.0,
"materials": 1.0
},
"score_range": {
"overall": [
1.0,
1.0
],
"backdrop": [
0.0,
0.0
],
"lighting": [
1.0,
1.0
],
"characters": [
0.0,
0.0
],
"texture_detail": [
1.0,
1.0
],
"materials": [
1.0,
1.0
]
},
"runs": 3,
"fixes": [
"Implement physically based rendering (PBR) materials for all surfaces, focusing on realistic albedo, roughness, and metallic values to achieve accurate light interaction.",
"Overhaul the lighting system to include global illumination, volumetric fog, and realistic light sources with proper falloff and color temperature, especially for the marquee and wall sconces.",
"Add high-resolution, detailed textures with normal and displacement maps for brickwork, concrete, and metallic elements to introduce surface imperfections and depth.",
"Develop a realistic backdrop with a distant city skyline, atmospheric haze, and a dark, moody sky to establish a sense of depth and environment.",
"Introduce realistic 3D character models with proper clothing, hair, and skin shaders, along with a high-fidelity car model, to populate the scene and match the target's visual complexity."
]
}
}
},
"before": {
"boulevard_day": {
"target": "docs/screens/concepts/boulevard_skyline_day.png",
"metrics": {
"edge_density": 20.75,
"fine_detail": 8.2,
"contrast": 49.63,
"saturation_mean": 72.04,
"colour_spread": 55.45
},
"target_metrics": {
"edge_density": 20.74,
"fine_detail": 8.23,
"contrast": 54.85,
"saturation_mean": 73.09,
"colour_spread": 58.16
},
"metric_gap": 0.036,
"judge": {
"scores": {
"overall": 1.0,
"backdrop": 1.0,
"lighting": 1.0,
"characters": 1.0,
"texture_detail": 1.0,
"materials": 1.0
},
"score_range": {
"overall": [
1.0,
1.0
],
"backdrop": [
1.0,
1.0
],
"lighting": [
1.0,
1.0
],
"characters": [
1.0,
1.0
],
"texture_detail": [
1.0,
1.0
],
"materials": [
1.0,
1.0
]
},
"runs": 3,
"fixes": [
"Implement a physically based rendering (PBR) material system for all assets to achieve realistic surface interactions with light.",
"Integrate a global illumination solution (e.g., ray tracing, baked lightmaps) to simulate realistic light bouncing and ambient occlusion.",
"Replace placeholder low-resolution textures with high-resolution, detailed textures, including normal maps and roughness maps for all surfaces.",
"Develop a realistic skybox and distant cityscape with atmospheric scattering and haze effects to match the target's backdrop.",
"Redesign character models with realistic proportions, detailed clothing, and proper skin shaders, along with animation systems for natural movement."
]
}
},
"boulevard_night": {
"target": "docs/screens/concepts/hollywood_entrance_rain_night.png",
"metrics": {
"edge_density": 21.77,
"fine_detail": 8.45,
"contrast": 50.38,
"saturation_mean": 95.12,
"colour_spread": 55.61
},
"target_metrics": {
"edge_density": 8.43,
"fine_detail": 3.51,
"contrast": 27.34,
"saturation_mean": 159.44,
"colour_spread": 26.61
},
"metric_gap": 1.231,
"judge": {
"scores": {
"overall": 1.0,
"backdrop": 1.0,
"lighting": 1.0,
"characters": 1.0,
"texture_detail": 1.0,
"materials": 1.0
},
"score_range": {
"overall": [
1.0,
1.0
],
"backdrop": [
1.0,
1.0
],
"lighting": [
1.0,
1.0
],
"characters": [
1.0,
1.0
],
"texture_detail": [
1.0,
1.0
],
"materials": [
1.0,
1.0
]
},
"runs": 3,
"fixes": [
"Implement a physically based rendering (PBR) material pipeline for all assets to achieve realistic surface responses to light.",
"Overhaul the lighting system to include global illumination, volumetric effects (fog/haze), and realistic light sources with proper falloff and color temperature.",
"Redesign character models with higher polygon counts, detailed textures (albedo, normal, roughness, metallic maps), and realistic rigging/animation.",
"Replace placeholder textures with high-resolution, detailed textures that include normal maps for surface imperfections and depth.",
"Develop a more complex backdrop with detailed buildings, atmospheric perspective (distance haze), and a dynamic sky system that reflects time of day and weather."
]
}
},
"entrance_day": {
"target": "docs/screens/concepts/hollywood_entrance_morning.png",
"metrics": {
"edge_density": 26.19,
"fine_detail": 9.27,
"contrast": 56.39,
"saturation_mean": 65.24,
"colour_spread": 59.96
},
"target_metrics": {
"edge_density": 16.87,
"fine_detail": 6.92,
"contrast": 70.27,
"saturation_mean": 121.02,
"colour_spread": 69.83
},
"metric_gap": 0.308,
"judge": {
"scores": {
"overall": 1.0,
"backdrop": 0.0,
"lighting": 1.0,
"characters": 0.0,
"texture_detail": 1.0,
"materials": 1.0
},
"score_range": {
"overall": [
1.0,
1.0
],
"backdrop": [
0.0,
0.0
],
"lighting": [
1.0,
1.0
],
"characters": [
0.0,
0.0
],
"texture_detail": [
1.0,
1.0
],
"materials": [
1.0,
1.0
]
},
"runs": 3,
"fixes": [
"Implement a physically based rendering (PBR) material pipeline for all assets to achieve realistic surface interactions with light.",
"Overhaul the lighting system to include global illumination, realistic skyboxes with atmospheric scattering, and dynamic shadows to match the soft, warm lighting of the target.",
"Replace placeholder textures with high-resolution, detailed textures that include normal maps, roughness maps, and metallic maps to add surface complexity and realism.",
"Develop a detailed backdrop with distant buildings, realistic palm trees, and atmospheric haze to create depth and a sense of place.",
"Redesign all environmental props (trees, trash cans, bollards) with realistic models and textures, moving away from the current stylized, low-poly aesthetic."
]
}
},
"street_day": {
"target": "docs/screens/concepts/boulevard_skyline_day.png",
"metrics": {
"edge_density": 12.44,
"fine_detail": 5.32,
"contrast": 41.37,
"saturation_mean": 77.84,
"colour_spread": 44.96
},
"target_metrics": {
"edge_density": 20.74,
"fine_detail": 8.23,
"contrast": 54.85,
"saturation_mean": 73.09,
"colour_spread": 58.16
},
"metric_gap": 0.307,
"judge": {
"scores": {
"overall": 0.0,
"backdrop": 0.0,
"lighting": 0.0,
"characters": 0.0,
"texture_detail": 0.0,
"materials": 0.0
},
"score_range": {
"overall": [
0.0,
0.0
],
"backdrop": [
0.0,
0.0
],
"lighting": [
0.0,
0.0
],
"characters": [
0.0,
0.0
],
"texture_detail": [
0.0,
0.0
],
"materials": [
0.0,
0.0
]
},
"runs": 3,
"fixes": [
"Implement a physically based rendering (PBR) material system for all assets to achieve realistic surface interactions with light.",
"Develop a dynamic sky and atmospheric scattering system to accurately simulate sky color, haze, and sun position based on time of day.",
"Replace placeholder block models with detailed 3D models for buildings, vehicles, and environmental props, including proper UV mapping and high-resolution textures.",
"Integrate a global illumination solution (e.g., ray tracing or baked lightmaps) to simulate realistic indirect lighting and soft shadows.",
"Create realistic character models with detailed textures, proper rigging, and animations, ensuring they blend seamlessly with the environment."
]
}
},
"street_night": {
"target": "docs/screens/concepts/boulevard_skyline_dusk.png",
"metrics": {
"edge_density": 11.94,
"fine_detail": 5.35,
"contrast": 48.34,
"saturation_mean": 98.21,
"colour_spread": 48.85
},
"target_metrics": {
"edge_density": 8.83,
"fine_detail": 4.61,
"contrast": 33.26,
"saturation_mean": 188.16,
"colour_spread": 35.38
},
"metric_gap": 0.337,
"judge": {
"scores": {
"overall": 1.0,
"backdrop": 1.0,
"lighting": 1.0,
"characters": 1.0,
"texture_detail": 1.0,
"materials": 1.0
},
"score_range": {
"overall": [
1.0,
1.0
],
"backdrop": [
1.0,
1.0
],
"lighting": [
1.0,
1.0
],
"characters": [
1.0,
1.0
],
"texture_detail": [
1.0,
1.0
],
"materials": [
1.0,
1.0
]
},
"runs": 3,
"fixes": [
"Implement a physically based lighting model with global illumination to accurately simulate light bounces and color bleeding, especially for the sunset and artificial lights.",
"Replace placeholder geometry with detailed 3D models for buildings, vehicles, and environmental props, ensuring proper UV mapping and high-resolution textures.",
"Develop a comprehensive material library with realistic PBR (Physically Based Rendering) materials for various surfaces (e.g., wet asphalt, neon signs, building facades, car paint) including roughness, metallic, and normal maps.",
"Create a dynamic skybox system that accurately represents a sunset with atmospheric scattering, volumetric clouds, and distance haze, matching the vibrant purple and orange hues of the target.",
"Design and integrate realistic character models with detailed clothing, hair, and skin textures, and implement a robust animation system for natural movement."
]
}
}
}
}
}
@@ -1,131 +0,0 @@
# Realism report: `docs/screens/realism/2026-11-01-adopt`
Vision judge: `google/gemini-2.5-flash` (scores 0-10, higher is more realistic compared with the concept target).
| set | shot | target | gap ↓ | edges | detail | contrast | overall | lighting | materials | texture | backdrop |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| after | boulevard_day | boulevard_skyline_day | 0.172 | 23.21 | 8.88 | 38.78 | 1.0 | 1.0 | 1.0 | 1.0 | 3.0 |
| after | boulevard_night | hollywood_entrance_rain_night | 0.723 | 16.02 | 7.13 | 39.4 | 1.0 | 1.0 | 1.0 | 1.0 | 2.0 |
| after | entrance_day | hollywood_entrance_morning | 0.442 | 25.88 | 9.86 | 40.18 | 1.0 | 1.0 | 1.0 | 1.0 | 0.0 |
| after | entrance_late | hollywood_entrance_rain_night | 1.105 | 18.4 | 8.41 | 51.61 | 1.0 | 1.0 | 1.33 | 1.33 | 0.0 |
| after | joke_shop_door | joke_shop_door_closeup | 0.818 | 14.84 | 7.04 | 47.63 | 2.0 | 2.0 | 3.0 | 3.0 | 4.0 |
| after | street_day | boulevard_skyline_day | 0.148 | 18.69 | 7.42 | 42.69 | 1.0 | 1.0 | 1.0 | 1.0 | 2.0 |
| after | street_late | boulevard_skyline_dusk | 0.224 | 12.95 | 6.27 | 31.94 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| after | street_night | boulevard_skyline_dusk | 0.253 | 10.97 | 5.57 | 24.27 | 1.0 | 1.0 | 1.0 | 1.0 | 1.0 |
| after | studio_door | studio_stage_door_night | 0.395 | 16.62 | 7.65 | 48.52 | 1.0 | 1.0 | 1.0 | 1.0 | 0.0 |
| before | boulevard_day | boulevard_skyline_day | 0.036 | 20.75 | 8.2 | 49.63 | 1.0 | 1.0 | 1.0 | 1.0 | 1.0 |
| before | boulevard_night | hollywood_entrance_rain_night | 1.231 | 21.77 | 8.45 | 50.38 | 1.0 | 1.0 | 1.0 | 1.0 | 1.0 |
| before | entrance_day | hollywood_entrance_morning | 0.308 | 26.19 | 9.27 | 56.39 | 1.0 | 1.0 | 1.0 | 1.0 | 0.0 |
| before | street_day | boulevard_skyline_day | 0.307 | 12.44 | 5.32 | 41.37 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| before | street_night | boulevard_skyline_dusk | 0.337 | 11.94 | 5.35 | 48.34 | 1.0 | 1.0 | 1.0 | 1.0 | 1.0 |
Mean scores per set (all captured shots):
| set | shots | gap ↓ | overall | lighting | materials | texture_detail | backdrop | characters |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| after | 9 | 0.476 | 1.0 | 1.0 | 1.15 | 1.15 | 1.33 | 0.11 |
| before | 5 | 0.444 | 0.8 | 0.8 | 0.8 | 0.8 | 0.6 | 0.6 |
Mean over the shots every set captured (like for like):
| set | shots | gap ↓ | overall | lighting | materials | texture_detail | backdrop | characters |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| after | 5 | 0.348 | 1.0 | 1.0 | 1.0 | 1.0 | 1.6 | 0.2 |
**after / boulevard_day** fixes:
- Implement physically based rendering (PBR) materials for all assets, focusing on realistic albedo, roughness, and metallic maps to achieve accurate surface responses to light.
- Overhaul lighting system to include global illumination, realistic skybox/HDRI for ambient light, and accurate sun/directional light with proper shadows and atmospheric scattering (haze/fog).
- Replace placeholder low-polygon models with high-detail 3D assets for trees, street furniture, and buildings, ensuring proper UV mapping and texture application.
- Improve texture resolution and detail across all surfaces, adding normal maps for surface imperfections and detail, and ensuring textures are seamlessly tiled or uniquely mapped where appropriate.
- Develop a realistic character pipeline, including high-fidelity models, animations, and PBR materials for skin, clothing, and hair, to populate the scene.
**after / boulevard_night** fixes:
- Implement a physically based rendering (PBR) material pipeline for all assets to achieve realistic surface responses to light, especially for wet surfaces and brick.
- Overhaul the lighting system to include global illumination, volumetric fog/rain, and realistic light sources with proper falloff and color temperature, focusing on dramatic contrast and mood.
- Replace low-polygon models with high-detail meshes and high-resolution textures, including normal and roughness maps, for all environmental elements (buildings, street, props, characters).
- Redesign the backdrop with a more detailed and atmospheric skyline, incorporating distance haze, subtle cloud formations, and appropriate night sky colors to match the target's mood.
- Update character models to be realistic, with detailed clothing, skin, and hair textures, and implement realistic animation and shading.
**after / entrance_day** fixes:
- Implement a physically based rendering (PBR) material pipeline for all assets to achieve realistic surface responses to light.
- Overhaul the lighting system to include global illumination, realistic skyboxes with atmospheric scattering, and dynamic shadows.
- Replace placeholder low-polygon models with high-fidelity assets that include detailed normal maps and displacement maps for architectural elements, foliage, and street furniture.
- Develop a detailed backdrop with distant buildings, varied skyline, and atmospheric haze to create depth and realism.
- Add realistic foliage models with proper leaf textures, branch structures, and varied forms instead of simple geometric shapes.
**after / entrance_late** fixes:
- Implement volumetric fog and rain effects to create atmospheric depth and mood, especially around light sources.
- Overhaul lighting system to include realistic light falloff, bounce light, and wet surface reflections, focusing on the marquee and streetlights.
- Redesign all materials with PBR workflows, ensuring accurate roughness, metallic, and normal maps for brick, concrete, metal, and wet surfaces.
- Add high-resolution, detailed textures for all surfaces, particularly the brickwork, sidewalk, and marquee, to capture fine imperfections and wear.
- Develop a detailed backdrop with distant buildings, subtle city lights, and atmospheric haze to replace the current flat sky.
**after / joke_shop_door** fixes:
- Implement physically based rendering (PBR) materials for all surfaces, especially brick, wood, and metal, to achieve realistic light interaction and surface properties.
- Overhaul the lighting system to include more nuanced light sources, volumetric effects, and global illumination. Focus on creating warm, inviting pools of light around entrances and signs, with realistic falloff and shadows.
- Improve texture resolution and detail for all assets, particularly the brickwork, ground, and signage. Add normal maps and displacement maps to give surfaces depth and tactile quality.
- Redesign the 'Comedy Club' sign to be a prominent, well-lit feature with appropriate emissive materials and a more stylized font, similar to the target image's 'COMEDY CLUB' sign.
- Enhance the backdrop with more detailed city lights, atmospheric haze, and a more realistic skybox to create a sense of depth and a believable urban environment.
**after / street_day** fixes:
- Implement physically based rendering (PBR) materials for all assets, focusing on realistic albedo, roughness, and metallic values to achieve accurate light interaction.
- Overhaul the lighting system to include a directional light representing the sun, a skybox/HDRI for ambient light, and global illumination to simulate bounced light, matching the target's clear, bright, and slightly warm daylight.
- Replace placeholder geometry with high-fidelity 3D models for cars, buildings, and environmental elements (e.g., palm trees, streetlights, sidewalks) that match the realistic proportions and details of the target image.
- Develop detailed textures for all surfaces, including road asphalt, concrete sidewalks, building facades, and foliage, ensuring appropriate resolution and variety to avoid tiling artifacts and enhance realism.
- Create a realistic backdrop with a detailed skyline, atmospheric perspective (distance haze), and a clear blue sky that accurately reflects the target's Los Angeles aesthetic, integrating it seamlessly with the foreground.
**after / street_late** fixes:
- Implement a full physically-based lighting model with global illumination, volumetric fog, and atmospheric scattering to achieve realistic sunset colors and light interaction.
- Replace placeholder geometry with detailed 3D models for buildings, palm trees, and vehicles, ensuring proper scale and architectural variety.
- Develop high-resolution PBR (Physically Based Rendering) materials for all surfaces, including reflective wet asphalt, glowing neon signs, and varied building facades, with appropriate roughness, metallic, and normal maps.
- Create a dynamic skybox system that accurately renders sunset gradients, distant city skylines with emissive windows, and atmospheric haze, replacing the current flat backdrop.
- Add realistic character models with appropriate clothing and animations to populate the scene, contributing to the overall realism and sense of a living city.
**after / street_night** fixes:
- Implement a full physically-based lighting model with global illumination, volumetric fog, and proper emissive materials for signs and car lights to capture the sunset glow and light trails.
- Replace placeholder geometry with detailed 3D models for buildings, palm trees, and vehicles, ensuring accurate proportions and architectural details.
- Develop high-resolution PBR textures for all surfaces (roads, buildings, signs, vehicles) with appropriate albedo, roughness, metallic, and normal maps to add realism and detail.
- Create a dynamic skybox with a realistic sunset gradient, distant city skyline with illuminated windows, and atmospheric haze to match the target's depth and mood.
- Add realistic character models with appropriate clothing and animations to populate the scene and enhance the sense of a living city.
**after / studio_door** fixes:
- Implement physically based rendering (PBR) materials for all surfaces, focusing on realistic albedo, roughness, and metallic values to achieve accurate light interaction.
- Overhaul the lighting system to include global illumination, volumetric fog, and realistic light sources with proper falloff and color temperature, especially for the marquee and wall sconces.
- Add high-resolution, detailed textures with normal and displacement maps for brickwork, concrete, and metallic elements to introduce surface imperfections and depth.
- Develop a realistic backdrop with a distant city skyline, atmospheric haze, and a dark, moody sky to establish a sense of depth and environment.
- Introduce realistic 3D character models with proper clothing, hair, and skin shaders, along with a high-fidelity car model, to populate the scene and match the target's visual complexity.
**before / boulevard_day** fixes:
- Implement a physically based rendering (PBR) material system for all assets to achieve realistic surface interactions with light.
- Integrate a global illumination solution (e.g., ray tracing, baked lightmaps) to simulate realistic light bouncing and ambient occlusion.
- Replace placeholder low-resolution textures with high-resolution, detailed textures, including normal maps and roughness maps for all surfaces.
- Develop a realistic skybox and distant cityscape with atmospheric scattering and haze effects to match the target's backdrop.
- Redesign character models with realistic proportions, detailed clothing, and proper skin shaders, along with animation systems for natural movement.
**before / boulevard_night** fixes:
- Implement a physically based rendering (PBR) material pipeline for all assets to achieve realistic surface responses to light.
- Overhaul the lighting system to include global illumination, volumetric effects (fog/haze), and realistic light sources with proper falloff and color temperature.
- Redesign character models with higher polygon counts, detailed textures (albedo, normal, roughness, metallic maps), and realistic rigging/animation.
- Replace placeholder textures with high-resolution, detailed textures that include normal maps for surface imperfections and depth.
- Develop a more complex backdrop with detailed buildings, atmospheric perspective (distance haze), and a dynamic sky system that reflects time of day and weather.
**before / entrance_day** fixes:
- Implement a physically based rendering (PBR) material pipeline for all assets to achieve realistic surface interactions with light.
- Overhaul the lighting system to include global illumination, realistic skyboxes with atmospheric scattering, and dynamic shadows to match the soft, warm lighting of the target.
- Replace placeholder textures with high-resolution, detailed textures that include normal maps, roughness maps, and metallic maps to add surface complexity and realism.
- Develop a detailed backdrop with distant buildings, realistic palm trees, and atmospheric haze to create depth and a sense of place.
- Redesign all environmental props (trees, trash cans, bollards) with realistic models and textures, moving away from the current stylized, low-poly aesthetic.
**before / street_day** fixes:
- Implement a physically based rendering (PBR) material system for all assets to achieve realistic surface interactions with light.
- Develop a dynamic sky and atmospheric scattering system to accurately simulate sky color, haze, and sun position based on time of day.
- Replace placeholder block models with detailed 3D models for buildings, vehicles, and environmental props, including proper UV mapping and high-resolution textures.
- Integrate a global illumination solution (e.g., ray tracing or baked lightmaps) to simulate realistic indirect lighting and soft shadows.
- Create realistic character models with detailed textures, proper rigging, and animations, ensuring they blend seamlessly with the environment.
**before / street_night** fixes:
- Implement a physically based lighting model with global illumination to accurately simulate light bounces and color bleeding, especially for the sunset and artificial lights.
- Replace placeholder geometry with detailed 3D models for buildings, vehicles, and environmental props, ensuring proper UV mapping and high-resolution textures.
- Develop a comprehensive material library with realistic PBR (Physically Based Rendering) materials for various surfaces (e.g., wet asphalt, neon signs, building facades, car paint) including roughness, metallic, and normal maps.
- Create a dynamic skybox system that accurately represents a sunset with atmospheric scattering, volumetric clouds, and distance haze, matching the vibrant purple and orange hues of the target.
- Design and integrate realistic character models with detailed clothing, hair, and skin textures, and implement a robust animation system for natural movement.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 593 KiB

@@ -1,526 +0,0 @@
{
"run": "docs/screens/realism/2026-11-01-v2",
"judge_model": "google/gemini-2.5-flash-lite",
"sets": {
"baseline": {
"boulevard_day": {
"target": "docs/screens/concepts/boulevard_skyline_day.png",
"metrics": {
"edge_density": 20.75,
"fine_detail": 8.2,
"contrast": 49.63,
"saturation_mean": 72.04,
"colour_spread": 55.45
},
"target_metrics": {
"edge_density": 20.74,
"fine_detail": 8.23,
"contrast": 54.85,
"saturation_mean": 73.09,
"colour_spread": 58.16
},
"metric_gap": 0.036,
"judge": {
"scores": {
"lighting": 3.0,
"materials": 2.0,
"texture_detail": 1.0,
"backdrop": 4.0,
"characters": 1.0,
"overall": 2.0
},
"fixes": [
"Improve the lighting model to include more realistic global illumination and soft shadows. The current lighting is too flat and harsh.",
"Replace the current low-polygon character models with more detailed and anatomically correct models. Add variations in clothing and appearance.",
"Increase the texture resolution and detail on all surfaces, especially buildings, roads, and foliage. Implement normal maps and specular maps for greater realism.",
"Enhance the backdrop by adding more detailed building models in the distance, atmospheric perspective (haze), and a more nuanced skybox.",
"Refine the material properties of all objects to better simulate real-world materials. This includes adjusting reflectivity, roughness, and subsurface scattering where appropriate."
]
}
},
"boulevard_night": {
"target": "docs/screens/concepts/hollywood_entrance_rain_night.png",
"metrics": {
"edge_density": 21.77,
"fine_detail": 8.45,
"contrast": 50.38,
"saturation_mean": 95.12,
"colour_spread": 55.61
},
"target_metrics": {
"edge_density": 8.43,
"fine_detail": 3.51,
"contrast": 27.34,
"saturation_mean": 159.44,
"colour_spread": 26.61
},
"metric_gap": 1.231,
"judge": {
"scores": {
"lighting": 2.0,
"materials": 1.0,
"texture_detail": 1.0,
"backdrop": 2.0,
"characters": 1.0,
"overall": 1.0
},
"fixes": [
"Improve lighting to match the dramatic, atmospheric, and volumetric lighting of the target image, focusing on warm, directional light from the marquee and streetlamp, and realistic reflections on wet surfaces.",
"Significantly upgrade material shaders to achieve realistic PBR (Physically Based Rendering) properties, especially for brick, asphalt, glass, and metal, to mimic the specular highlights, subsurface scattering, and roughness of the target.",
"Increase texture resolution and detail for all surfaces, particularly the brickwork, sidewalk, and building facades, to match the fine-grained realism of the target.",
"Redesign character models with higher polygon counts, more detailed textures, and realistic clothing and facial features to match the stylized but grounded look of the target's lone figure.",
"Enhance the backdrop by adding atmospheric effects like fog, rain, and distant city lights, and refine the building models to have more architectural detail and variation, similar to the target's moody urban environment."
]
}
},
"entrance_day": {
"target": "docs/screens/concepts/hollywood_entrance_morning.png",
"metrics": {
"edge_density": 26.19,
"fine_detail": 9.27,
"contrast": 56.39,
"saturation_mean": 65.24,
"colour_spread": 59.96
},
"target_metrics": {
"edge_density": 16.87,
"fine_detail": 6.92,
"contrast": 70.27,
"saturation_mean": 121.02,
"colour_spread": 69.83
},
"metric_gap": 0.308,
"judge": {
"scores": {
"lighting": 3.0,
"materials": 2.0,
"texture_detail": 2.0,
"backdrop": 3.0,
"characters": 10.0,
"overall": 2.0
},
"fixes": [
"Improve brick texture and material to match the target's depth and variation.",
"Add more detailed and varied foliage, especially for the trees and bushes, to match the target's organic look.",
"Enhance lighting to create softer shadows, more atmospheric haze, and warmer tones, particularly around the building entrance and street.",
"Refine building details, such as window frames, signage, and architectural elements, to increase realism.",
"Introduce subtle imperfections and wear on surfaces like the sidewalk and building facade to mimic real-world aging."
]
}
},
"street_day": {
"target": "docs/screens/concepts/boulevard_skyline_day.png",
"metrics": {
"edge_density": 12.44,
"fine_detail": 5.32,
"contrast": 41.37,
"saturation_mean": 77.84,
"colour_spread": 44.96
},
"target_metrics": {
"edge_density": 20.74,
"fine_detail": 8.23,
"contrast": 54.85,
"saturation_mean": 73.09,
"colour_spread": 58.16
},
"metric_gap": 0.307,
"judge": {
"scores": {
"lighting": 2.0,
"materials": 1.0,
"texture_detail": 1.0,
"backdrop": 2.0,
"characters": 2.0,
"overall": 1.0
},
"fixes": [
"Improve lighting to match the warm, directional sunlight and soft shadows of the target. Add volumetric fog for atmospheric perspective.",
"Replace flat, untextured building models with detailed geometry and realistic materials (concrete, glass, metal).",
"Add high-resolution textures to all surfaces, especially the road, sidewalk, and buildings, to mimic the detail in the target.",
"Model a more detailed and varied cityscape for the backdrop, including buildings with distinct architectural styles and realistic lighting.",
"Redesign character models to be more anatomically correct and have realistic clothing textures and animations."
]
}
},
"street_night": {
"target": "docs/screens/concepts/boulevard_skyline_dusk.png",
"metrics": {
"edge_density": 11.94,
"fine_detail": 5.35,
"contrast": 48.34,
"saturation_mean": 98.21,
"colour_spread": 48.85
},
"target_metrics": {
"edge_density": 8.83,
"fine_detail": 4.61,
"contrast": 33.26,
"saturation_mean": 188.16,
"colour_spread": 35.38
},
"metric_gap": 0.337,
"judge": {
"scores": {
"lighting": 3.0,
"materials": 2.0,
"texture_detail": 1.0,
"backdrop": 2.0,
"characters": 1.0,
"overall": 2.0
},
"fixes": [
"Improve lighting to match the dramatic sunset and neon glow of the target image, focusing on volumetric lighting and bloom effects.",
"Enhance material properties for buildings, road, and car to achieve a more realistic PBR (Physically Based Rendering) look, with appropriate reflections and specularity.",
"Increase texture resolution and detail on all surfaces, especially the road and building facades, to mimic the fine grain and imperfections seen in the target.",
"Redesign the backdrop to include a more detailed and atmospheric skyline with realistic haze and a richer sunset color gradient.",
"Replace the current character models with more detailed and realistically proportioned characters that fit the urban environment."
]
}
}
},
"candidate": {
"boulevard_day": {
"target": "docs/screens/concepts/boulevard_skyline_day.png",
"metrics": {
"edge_density": 20.67,
"fine_detail": 7.8,
"contrast": 42.49,
"saturation_mean": 67.55,
"colour_spread": 48.32
},
"target_metrics": {
"edge_density": 20.74,
"fine_detail": 8.23,
"contrast": 54.85,
"saturation_mean": 73.09,
"colour_spread": 58.16
},
"metric_gap": 0.113,
"judge": {
"scores": {
"lighting": 3.0,
"materials": 2.0,
"texture_detail": 2.0,
"backdrop": 5.0,
"characters": 1.0,
"overall": 2.0
},
"fixes": [
"Improve road and sidewalk materials to have more realistic specular highlights and surface imperfections.",
"Increase texture resolution and detail on buildings, especially the brickwork, to match the target's fidelity.",
"Replace stylized character models with realistic human models that have appropriate proportions and clothing.",
"Enhance lighting to create softer shadows, more realistic ambient occlusion, and better global illumination, especially around the streetlights and building facades.",
"Add atmospheric perspective and volumetric fog to the distant cityscape to create a more convincing sense of depth and distance haze."
]
}
},
"boulevard_night": {
"target": "docs/screens/concepts/hollywood_entrance_rain_night.png",
"metrics": {
"edge_density": 20.67,
"fine_detail": 7.86,
"contrast": 46.43,
"saturation_mean": 98.69,
"colour_spread": 50.59
},
"target_metrics": {
"edge_density": 8.43,
"fine_detail": 3.51,
"contrast": 27.34,
"saturation_mean": 159.44,
"colour_spread": 26.61
},
"metric_gap": 1.073,
"judge": {
"scores": {
"lighting": 2.0,
"materials": 1.0,
"texture_detail": 1.0,
"backdrop": 3.0,
"characters": 2.0,
"overall": 1.0
},
"fixes": [
"Improve lighting to create atmospheric mood, with strong contrast and volumetric effects like fog/rain.",
"Enhance material properties for realism: add specular highlights, subsurface scattering for skin, and more detailed surface imperfections.",
"Increase texture resolution and detail, especially on buildings, pavement, and clothing, to match the target's gritty realism.",
"Redesign characters to have more realistic proportions, clothing, and facial features, and animate them with more natural movement.",
"Develop a more detailed and atmospheric backdrop, including realistic city buildings, distant haze, and appropriate weather effects."
]
}
},
"entrance_day": {
"target": "docs/screens/concepts/hollywood_entrance_morning.png",
"metrics": {
"edge_density": 24.54,
"fine_detail": 8.92,
"contrast": 52.41,
"saturation_mean": 61.63,
"colour_spread": 54.98
},
"target_metrics": {
"edge_density": 16.87,
"fine_detail": 6.92,
"contrast": 70.27,
"saturation_mean": 121.02,
"colour_spread": 69.83
},
"metric_gap": 0.303,
"judge": {
"scores": {
"lighting": 3.0,
"materials": 2.0,
"texture_detail": 2.0,
"backdrop": 3.0,
"characters": 1.0,
"overall": 2.0
},
"fixes": [
"Improve brick texture and material to add more variation, grime, and imperfections.",
"Enhance lighting to create softer shadows, more atmospheric haze, and realistic light falloff.",
"Add more detail and variation to the building facade, including window frames, signage, and architectural elements.",
"Replace low-poly trees and bushes with more detailed and realistic foliage models.",
"Refine character models to match the realistic style of the target image, focusing on proportions, clothing, and facial features."
]
}
},
"street_day": {
"target": "docs/screens/concepts/boulevard_skyline_day.png",
"metrics": {
"edge_density": 14.54,
"fine_detail": 5.32,
"contrast": 36.3,
"saturation_mean": 74.49,
"colour_spread": 39.9
},
"target_metrics": {
"edge_density": 20.74,
"fine_detail": 8.23,
"contrast": 54.85,
"saturation_mean": 73.09,
"colour_spread": 58.16
},
"metric_gap": 0.326,
"judge": {
"scores": {
"lighting": 2.0,
"materials": 1.0,
"texture_detail": 1.0,
"backdrop": 3.0,
"characters": 1.0,
"overall": 1.0
},
"fixes": [
"Improve road and sidewalk materials to have more realistic specular highlights and surface imperfections.",
"Add significant detail to the car models, including realistic reflections, panel gaps, and tire textures.",
"Implement volumetric fog and atmospheric scattering to create a more convincing sense of depth and haze in the backdrop.",
"Replace the placeholder character with a detailed, realistically lit 3D model that matches the environment's style.",
"Enhance the lighting model to include softer shadows, more accurate ambient occlusion, and realistic light bounce."
]
}
},
"street_night": {
"target": "docs/screens/concepts/boulevard_skyline_dusk.png",
"metrics": {
"edge_density": 16.12,
"fine_detail": 6.06,
"contrast": 48.36,
"saturation_mean": 103.44,
"colour_spread": 49.1
},
"target_metrics": {
"edge_density": 8.83,
"fine_detail": 4.61,
"contrast": 33.26,
"saturation_mean": 188.16,
"colour_spread": 35.38
},
"metric_gap": 0.495,
"judge": {
"scores": {
"lighting": 3.0,
"materials": 2.0,
"texture_detail": 2.0,
"backdrop": 4.0,
"characters": 1.0,
"overall": 2.0
},
"fixes": [
"Improve lighting to match the dramatic, atmospheric sunset and neon glow of the target image. Add volumetric fog and bloom effects.",
"Significantly enhance material properties for buildings, road, and vehicles to achieve a more realistic PBR (Physically Based Rendering) look, with appropriate reflections and surface imperfections.",
"Increase texture resolution and detail on all surfaces, especially buildings and the road, to match the crispness and complexity of the target.",
"Redesign the backdrop to include a more detailed and atmospheric city skyline with appropriate haze and color grading to match the sunset.",
"Completely overhaul character models to be significantly more detailed, with realistic proportions, clothing, and animation, to match the quality of the target scene."
]
}
}
},
"candidate_dusk": {
"boulevard_day": {
"target": "docs/screens/concepts/boulevard_skyline_day.png",
"metrics": {
"edge_density": 20.64,
"fine_detail": 7.79,
"contrast": 42.52,
"saturation_mean": 67.55,
"colour_spread": 48.35
},
"target_metrics": {
"edge_density": 20.74,
"fine_detail": 8.23,
"contrast": 54.85,
"saturation_mean": 73.09,
"colour_spread": 58.16
},
"metric_gap": 0.113,
"judge": {
"scores": {
"lighting": 3.0,
"materials": 2.0,
"texture_detail": 2.0,
"backdrop": 5.0,
"characters": 1.0,
"overall": 2.0
},
"fixes": [
"Improve lighting to match the warm, directional sunlight and soft shadows of the target. Add volumetric fog or atmospheric perspective to the distant cityscape.",
"Replace low-poly, stylized assets (trees, characters, buildings) with high-fidelity models that have realistic proportions and details.",
"Apply PBR materials with appropriate roughness, metallic, and normal maps to all surfaces (road, sidewalk, buildings, foliage) to simulate real-world material properties.",
"Increase texture resolution and add surface imperfections (grime, wear, subtle variations) to the road, sidewalk, and building facades.",
"Refine character models to have realistic anatomy, clothing, and animation, and ensure they are properly lit and integrated into the scene."
]
}
},
"boulevard_night": {
"target": "docs/screens/concepts/hollywood_entrance_rain_night.png",
"metrics": {
"edge_density": 21.29,
"fine_detail": 7.93,
"contrast": 43.3,
"saturation_mean": 97.44,
"colour_spread": 47.83
},
"target_metrics": {
"edge_density": 8.43,
"fine_detail": 3.51,
"contrast": 27.34,
"saturation_mean": 159.44,
"colour_spread": 26.61
},
"metric_gap": 1.041,
"judge": {
"scores": {
"lighting": 3.0,
"materials": 2.0,
"texture_detail": 2.0,
"backdrop": 4.0,
"characters": 1.0,
"overall": 2.0
},
"fixes": [
"Improve the lighting to match the dramatic, atmospheric, and volumetric lighting of the target image, focusing on warm, directional light from the marquee and streetlamp, and realistic reflections on wet surfaces.",
"Significantly upgrade the character models and animations to be more realistic, with detailed clothing, facial features, and natural posing, rather than the current low-poly, cartoonish style.",
"Enhance material properties and textures for all surfaces (brick, pavement, doors, clothing) to achieve a photorealistic look, with appropriate roughness, reflectivity, and detailed normal maps, especially for the wet environment.",
"Refine the backdrop elements, including the distant city skyline and atmospheric haze, to create a sense of depth and realism consistent with the target image's moody atmosphere.",
"Implement more realistic environmental effects, such as rain, puddles with accurate reflections, and subtle fog, to enhance the overall immersion and match the target's gritty, wet ambiance."
]
}
},
"entrance_day": {
"target": "docs/screens/concepts/hollywood_entrance_morning.png",
"metrics": {
"edge_density": 24.44,
"fine_detail": 8.87,
"contrast": 52.25,
"saturation_mean": 61.51,
"colour_spread": 54.84
},
"target_metrics": {
"edge_density": 16.87,
"fine_detail": 6.92,
"contrast": 70.27,
"saturation_mean": 121.02,
"colour_spread": 69.83
},
"metric_gap": 0.3,
"judge": {
"scores": {},
"fixes": [
"judge failed: OpenRouter HTTP 400: {\"error\":{\"message\":\"Provider returned error\",\"code\":400,\"metadata\":{\"raw\":\"{\\n \\\"error\\\": {\\n \\\"code\\\": 400,\\n \\\"message\\\": \\\"API key not valid. Please pass a valid API key.\\\",\\n \\\"status\\\": \\\"INVALID_ARGUMENT\\\",\\n \\\"details\\\": [\\n {\\n \\\"@type\\\": \\\"type.googleapis.com/googl"
]
}
},
"street_day": {
"target": "docs/screens/concepts/boulevard_skyline_day.png",
"metrics": {
"edge_density": 14.55,
"fine_detail": 5.33,
"contrast": 36.3,
"saturation_mean": 74.49,
"colour_spread": 39.9
},
"target_metrics": {
"edge_density": 20.74,
"fine_detail": 8.23,
"contrast": 54.85,
"saturation_mean": 73.09,
"colour_spread": 58.16
},
"metric_gap": 0.326,
"judge": {
"scores": {
"lighting": 3.0,
"materials": 2.0,
"texture_detail": 1.0,
"backdrop": 4.0,
"characters": 1.0,
"overall": 2.0
},
"fixes": [
"Improve road and sidewalk material PBR values to match the target's roughness and reflectivity.",
"Increase the resolution and detail of building textures in the backdrop to reduce the blurry, low-poly appearance.",
"Add atmospheric perspective and volumetric fog to the distant cityscape to mimic the target's depth and haze.",
"Enhance car material properties, particularly reflections and specular highlights, to match the target's realism.",
"Redesign character models to have more detailed geometry, textures, and more natural proportions and posing."
]
}
},
"street_night": {
"target": "docs/screens/concepts/boulevard_skyline_dusk.png",
"metrics": {
"edge_density": 17.04,
"fine_detail": 6.13,
"contrast": 43.69,
"saturation_mean": 96.82,
"colour_spread": 45.18
},
"target_metrics": {
"edge_density": 8.83,
"fine_detail": 4.61,
"contrast": 33.26,
"saturation_mean": 188.16,
"colour_spread": 35.38
},
"metric_gap": 0.463,
"judge": {
"scores": {
"lighting": 3.0,
"materials": 2.0,
"texture_detail": 2.0,
"backdrop": 3.0,
"characters": 1.0,
"overall": 2.0
},
"fixes": [
"Improve lighting to match the dramatic, atmospheric sunset and neon glow of the target image. Add volumetric fog and bloom effects.",
"Enhance material properties for buildings, road, and vehicles to achieve a more realistic PBR (Physically Based Rendering) look, with appropriate reflections and specularity.",
"Increase texture resolution and detail on all surfaces, especially buildings and the road, to match the fine details seen in the target.",
"Redesign the backdrop to include a more detailed and atmospheric city skyline with appropriate haze and color grading to mimic the target's sunset.",
"Significantly rework character models to be more realistic in terms of proportions, texturing, and animation, as the current models are very stylized and cartoony."
]
}
}
}
}
}
@@ -1,130 +0,0 @@
# Realism report: `docs/screens/realism/2026-11-01-v2`
Vision judge: `google/gemini-2.5-flash-lite` (scores 0-10, higher is more realistic compared with the concept target).
| set | shot | target | gap ↓ | edges | detail | contrast | overall | lighting | materials | texture | backdrop |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| baseline | boulevard_day | boulevard_skyline_day | 0.036 | 20.75 | 8.2 | 49.63 | 2.0 | 3.0 | 2.0 | 1.0 | 4.0 |
| baseline | boulevard_night | hollywood_entrance_rain_night | 1.231 | 21.77 | 8.45 | 50.38 | 1.0 | 2.0 | 1.0 | 1.0 | 2.0 |
| baseline | entrance_day | hollywood_entrance_morning | 0.308 | 26.19 | 9.27 | 56.39 | 2.0 | 3.0 | 2.0 | 2.0 | 3.0 |
| baseline | street_day | boulevard_skyline_day | 0.307 | 12.44 | 5.32 | 41.37 | 1.0 | 2.0 | 1.0 | 1.0 | 2.0 |
| baseline | street_night | boulevard_skyline_dusk | 0.337 | 11.94 | 5.35 | 48.34 | 2.0 | 3.0 | 2.0 | 1.0 | 2.0 |
| candidate | boulevard_day | boulevard_skyline_day | 0.113 | 20.67 | 7.8 | 42.49 | 2.0 | 3.0 | 2.0 | 2.0 | 5.0 |
| candidate | boulevard_night | hollywood_entrance_rain_night | 1.073 | 20.67 | 7.86 | 46.43 | 1.0 | 2.0 | 1.0 | 1.0 | 3.0 |
| candidate | entrance_day | hollywood_entrance_morning | 0.303 | 24.54 | 8.92 | 52.41 | 2.0 | 3.0 | 2.0 | 2.0 | 3.0 |
| candidate | street_day | boulevard_skyline_day | 0.326 | 14.54 | 5.32 | 36.3 | 1.0 | 2.0 | 1.0 | 1.0 | 3.0 |
| candidate | street_night | boulevard_skyline_dusk | 0.495 | 16.12 | 6.06 | 48.36 | 2.0 | 3.0 | 2.0 | 2.0 | 4.0 |
| candidate_dusk | boulevard_day | boulevard_skyline_day | 0.113 | 20.64 | 7.79 | 42.52 | 2.0 | 3.0 | 2.0 | 2.0 | 5.0 |
| candidate_dusk | boulevard_night | hollywood_entrance_rain_night | 1.041 | 21.29 | 7.93 | 43.3 | 2.0 | 3.0 | 2.0 | 2.0 | 4.0 |
| candidate_dusk | entrance_day | hollywood_entrance_morning | 0.3 | 24.44 | 8.87 | 52.25 | - | - | - | - | - |
| candidate_dusk | street_day | boulevard_skyline_day | 0.326 | 14.55 | 5.33 | 36.3 | 2.0 | 3.0 | 2.0 | 1.0 | 4.0 |
| candidate_dusk | street_night | boulevard_skyline_dusk | 0.463 | 17.04 | 6.13 | 43.69 | 2.0 | 3.0 | 2.0 | 2.0 | 3.0 |
Mean scores per set:
| set | gap ↓ | overall | lighting | materials | texture_detail | backdrop | characters |
| --- | --- | --- | --- | --- | --- | --- | --- |
| baseline | 0.444 | 1.6 | 2.6 | 1.6 | 1.2 | 2.6 | 3.0 |
| candidate | 0.462 | 1.6 | 2.6 | 1.6 | 1.6 | 3.6 | 1.2 |
| candidate_dusk | 0.449 | 2.0 | 3.0 | 2.0 | 1.75 | 4.0 | 1.0 |
**baseline / boulevard_day** fixes:
- Improve the lighting model to include more realistic global illumination and soft shadows. The current lighting is too flat and harsh.
- Replace the current low-polygon character models with more detailed and anatomically correct models. Add variations in clothing and appearance.
- Increase the texture resolution and detail on all surfaces, especially buildings, roads, and foliage. Implement normal maps and specular maps for greater realism.
- Enhance the backdrop by adding more detailed building models in the distance, atmospheric perspective (haze), and a more nuanced skybox.
- Refine the material properties of all objects to better simulate real-world materials. This includes adjusting reflectivity, roughness, and subsurface scattering where appropriate.
**baseline / boulevard_night** fixes:
- Improve lighting to match the dramatic, atmospheric, and volumetric lighting of the target image, focusing on warm, directional light from the marquee and streetlamp, and realistic reflections on wet surfaces.
- Significantly upgrade material shaders to achieve realistic PBR (Physically Based Rendering) properties, especially for brick, asphalt, glass, and metal, to mimic the specular highlights, subsurface scattering, and roughness of the target.
- Increase texture resolution and detail for all surfaces, particularly the brickwork, sidewalk, and building facades, to match the fine-grained realism of the target.
- Redesign character models with higher polygon counts, more detailed textures, and realistic clothing and facial features to match the stylized but grounded look of the target's lone figure.
- Enhance the backdrop by adding atmospheric effects like fog, rain, and distant city lights, and refine the building models to have more architectural detail and variation, similar to the target's moody urban environment.
**baseline / entrance_day** fixes:
- Improve brick texture and material to match the target's depth and variation.
- Add more detailed and varied foliage, especially for the trees and bushes, to match the target's organic look.
- Enhance lighting to create softer shadows, more atmospheric haze, and warmer tones, particularly around the building entrance and street.
- Refine building details, such as window frames, signage, and architectural elements, to increase realism.
- Introduce subtle imperfections and wear on surfaces like the sidewalk and building facade to mimic real-world aging.
**baseline / street_day** fixes:
- Improve lighting to match the warm, directional sunlight and soft shadows of the target. Add volumetric fog for atmospheric perspective.
- Replace flat, untextured building models with detailed geometry and realistic materials (concrete, glass, metal).
- Add high-resolution textures to all surfaces, especially the road, sidewalk, and buildings, to mimic the detail in the target.
- Model a more detailed and varied cityscape for the backdrop, including buildings with distinct architectural styles and realistic lighting.
- Redesign character models to be more anatomically correct and have realistic clothing textures and animations.
**baseline / street_night** fixes:
- Improve lighting to match the dramatic sunset and neon glow of the target image, focusing on volumetric lighting and bloom effects.
- Enhance material properties for buildings, road, and car to achieve a more realistic PBR (Physically Based Rendering) look, with appropriate reflections and specularity.
- Increase texture resolution and detail on all surfaces, especially the road and building facades, to mimic the fine grain and imperfections seen in the target.
- Redesign the backdrop to include a more detailed and atmospheric skyline with realistic haze and a richer sunset color gradient.
- Replace the current character models with more detailed and realistically proportioned characters that fit the urban environment.
**candidate / boulevard_day** fixes:
- Improve road and sidewalk materials to have more realistic specular highlights and surface imperfections.
- Increase texture resolution and detail on buildings, especially the brickwork, to match the target's fidelity.
- Replace stylized character models with realistic human models that have appropriate proportions and clothing.
- Enhance lighting to create softer shadows, more realistic ambient occlusion, and better global illumination, especially around the streetlights and building facades.
- Add atmospheric perspective and volumetric fog to the distant cityscape to create a more convincing sense of depth and distance haze.
**candidate / boulevard_night** fixes:
- Improve lighting to create atmospheric mood, with strong contrast and volumetric effects like fog/rain.
- Enhance material properties for realism: add specular highlights, subsurface scattering for skin, and more detailed surface imperfections.
- Increase texture resolution and detail, especially on buildings, pavement, and clothing, to match the target's gritty realism.
- Redesign characters to have more realistic proportions, clothing, and facial features, and animate them with more natural movement.
- Develop a more detailed and atmospheric backdrop, including realistic city buildings, distant haze, and appropriate weather effects.
**candidate / entrance_day** fixes:
- Improve brick texture and material to add more variation, grime, and imperfections.
- Enhance lighting to create softer shadows, more atmospheric haze, and realistic light falloff.
- Add more detail and variation to the building facade, including window frames, signage, and architectural elements.
- Replace low-poly trees and bushes with more detailed and realistic foliage models.
- Refine character models to match the realistic style of the target image, focusing on proportions, clothing, and facial features.
**candidate / street_day** fixes:
- Improve road and sidewalk materials to have more realistic specular highlights and surface imperfections.
- Add significant detail to the car models, including realistic reflections, panel gaps, and tire textures.
- Implement volumetric fog and atmospheric scattering to create a more convincing sense of depth and haze in the backdrop.
- Replace the placeholder character with a detailed, realistically lit 3D model that matches the environment's style.
- Enhance the lighting model to include softer shadows, more accurate ambient occlusion, and realistic light bounce.
**candidate / street_night** fixes:
- Improve lighting to match the dramatic, atmospheric sunset and neon glow of the target image. Add volumetric fog and bloom effects.
- Significantly enhance material properties for buildings, road, and vehicles to achieve a more realistic PBR (Physically Based Rendering) look, with appropriate reflections and surface imperfections.
- Increase texture resolution and detail on all surfaces, especially buildings and the road, to match the crispness and complexity of the target.
- Redesign the backdrop to include a more detailed and atmospheric city skyline with appropriate haze and color grading to match the sunset.
- Completely overhaul character models to be significantly more detailed, with realistic proportions, clothing, and animation, to match the quality of the target scene.
**candidate_dusk / boulevard_day** fixes:
- Improve lighting to match the warm, directional sunlight and soft shadows of the target. Add volumetric fog or atmospheric perspective to the distant cityscape.
- Replace low-poly, stylized assets (trees, characters, buildings) with high-fidelity models that have realistic proportions and details.
- Apply PBR materials with appropriate roughness, metallic, and normal maps to all surfaces (road, sidewalk, buildings, foliage) to simulate real-world material properties.
- Increase texture resolution and add surface imperfections (grime, wear, subtle variations) to the road, sidewalk, and building facades.
- Refine character models to have realistic anatomy, clothing, and animation, and ensure they are properly lit and integrated into the scene.
**candidate_dusk / boulevard_night** fixes:
- Improve the lighting to match the dramatic, atmospheric, and volumetric lighting of the target image, focusing on warm, directional light from the marquee and streetlamp, and realistic reflections on wet surfaces.
- Significantly upgrade the character models and animations to be more realistic, with detailed clothing, facial features, and natural posing, rather than the current low-poly, cartoonish style.
- Enhance material properties and textures for all surfaces (brick, pavement, doors, clothing) to achieve a photorealistic look, with appropriate roughness, reflectivity, and detailed normal maps, especially for the wet environment.
- Refine the backdrop elements, including the distant city skyline and atmospheric haze, to create a sense of depth and realism consistent with the target image's moody atmosphere.
- Implement more realistic environmental effects, such as rain, puddles with accurate reflections, and subtle fog, to enhance the overall immersion and match the target's gritty, wet ambiance.
**candidate_dusk / entrance_day** fixes:
- judge failed: OpenRouter HTTP 400: {"error":{"message":"Provider returned error","code":400,"metadata":{"raw":"{\n \"error\": {\n \"code\": 400,\n \"message\": \"API key not valid. Please pass a valid API key.\",\n \"status\": \"INVALID_ARGUMENT\",\n \"details\": [\n {\n \"@type\": \"type.googleapis.com/googl
**candidate_dusk / street_day** fixes:
- Improve road and sidewalk material PBR values to match the target's roughness and reflectivity.
- Increase the resolution and detail of building textures in the backdrop to reduce the blurry, low-poly appearance.
- Add atmospheric perspective and volumetric fog to the distant cityscape to mimic the target's depth and haze.
- Enhance car material properties, particularly reflections and specular highlights, to match the target's realism.
- Redesign character models to have more detailed geometry, textures, and more natural proportions and posing.
**candidate_dusk / street_night** fixes:
- Improve lighting to match the dramatic, atmospheric sunset and neon glow of the target image. Add volumetric fog and bloom effects.
- Enhance material properties for buildings, road, and vehicles to achieve a more realistic PBR (Physically Based Rendering) look, with appropriate reflections and specularity.
- Increase texture resolution and detail on all surfaces, especially buildings and the road, to match the fine details seen in the target.
- Redesign the backdrop to include a more detailed and atmospheric city skyline with appropriate haze and color grading to mimic the target's sunset.
- Significantly rework character models to be more realistic in terms of proportions, texturing, and animation, as the current models are very stylized and cartoony.
@@ -1,360 +0,0 @@
{
"run": "docs/screens/realism/2026-11-01-v2",
"judge_model": "google/gemini-2.5-flash-lite",
"sets": {
"baseline": {
"boulevard_day": {
"target": "docs/screens/concepts/boulevard_skyline_day.png",
"metrics": {
"edge_density": 20.75,
"fine_detail": 8.2,
"contrast": 49.63,
"saturation_mean": 72.04,
"colour_spread": 55.45
},
"target_metrics": {
"edge_density": 20.74,
"fine_detail": 8.23,
"contrast": 54.85,
"saturation_mean": 73.09,
"colour_spread": 58.16
},
"metric_gap": 0.036,
"judge": {
"scores": {
"lighting": 3.0,
"materials": 2.0,
"texture_detail": 1.0,
"backdrop": 4.0,
"characters": 1.0,
"overall": 2.0
},
"fixes": [
"Improve the lighting model to include more realistic global illumination and soft shadows. The current lighting is too flat and harsh.",
"Replace the current low-polygon character models with more detailed and anatomically correct models. Add variations in clothing and appearance.",
"Increase the texture resolution and detail on all surfaces, especially buildings, roads, and foliage. Implement normal maps and specular maps for greater realism.",
"Enhance the backdrop by adding more detailed building models in the distance, atmospheric perspective (haze), and a more nuanced skybox.",
"Refine the material properties of all objects to better simulate real-world materials. This includes adjusting reflectivity, roughness, and subsurface scattering where appropriate."
]
}
},
"boulevard_night": {
"target": "docs/screens/concepts/hollywood_entrance_rain_night.png",
"metrics": {
"edge_density": 21.77,
"fine_detail": 8.45,
"contrast": 50.38,
"saturation_mean": 95.12,
"colour_spread": 55.61
},
"target_metrics": {
"edge_density": 8.43,
"fine_detail": 3.51,
"contrast": 27.34,
"saturation_mean": 159.44,
"colour_spread": 26.61
},
"metric_gap": 1.231,
"judge": {
"scores": {
"lighting": 2.0,
"materials": 1.0,
"texture_detail": 1.0,
"backdrop": 2.0,
"characters": 1.0,
"overall": 1.0
},
"fixes": [
"Improve lighting to match the dramatic, atmospheric, and volumetric lighting of the target image, focusing on warm, directional light from the marquee and streetlamp, and realistic reflections on wet surfaces.",
"Significantly upgrade material shaders to achieve realistic PBR (Physically Based Rendering) properties, especially for brick, asphalt, glass, and metal, to mimic the specular highlights, subsurface scattering, and roughness of the target.",
"Increase texture resolution and detail for all surfaces, particularly the brickwork, sidewalk, and building facades, to match the fine-grained realism of the target.",
"Redesign character models with higher polygon counts, more detailed textures, and realistic clothing and facial features to match the stylized but grounded look of the target's lone figure.",
"Enhance the backdrop by adding atmospheric effects like fog, rain, and distant city lights, and refine the building models to have more architectural detail and variation, similar to the target's moody urban environment."
]
}
},
"entrance_day": {
"target": "docs/screens/concepts/hollywood_entrance_morning.png",
"metrics": {
"edge_density": 26.19,
"fine_detail": 9.27,
"contrast": 56.39,
"saturation_mean": 65.24,
"colour_spread": 59.96
},
"target_metrics": {
"edge_density": 16.87,
"fine_detail": 6.92,
"contrast": 70.27,
"saturation_mean": 121.02,
"colour_spread": 69.83
},
"metric_gap": 0.308,
"judge": {
"scores": {
"lighting": 3.0,
"materials": 2.0,
"texture_detail": 2.0,
"backdrop": 3.0,
"characters": 10.0,
"overall": 2.0
},
"fixes": [
"Improve brick texture and material to match the target's depth and variation.",
"Add more detailed and varied foliage, especially for the trees and bushes, to match the target's organic look.",
"Enhance lighting to create softer shadows, more atmospheric haze, and warmer tones, particularly around the building entrance and street.",
"Refine building details, such as window frames, signage, and architectural elements, to increase realism.",
"Introduce subtle imperfections and wear on surfaces like the sidewalk and building facade to mimic real-world aging."
]
}
},
"street_day": {
"target": "docs/screens/concepts/boulevard_skyline_day.png",
"metrics": {
"edge_density": 12.44,
"fine_detail": 5.32,
"contrast": 41.37,
"saturation_mean": 77.84,
"colour_spread": 44.96
},
"target_metrics": {
"edge_density": 20.74,
"fine_detail": 8.23,
"contrast": 54.85,
"saturation_mean": 73.09,
"colour_spread": 58.16
},
"metric_gap": 0.307,
"judge": {
"scores": {
"lighting": 2.0,
"materials": 1.0,
"texture_detail": 1.0,
"backdrop": 2.0,
"characters": 2.0,
"overall": 1.0
},
"fixes": [
"Improve lighting to match the warm, directional sunlight and soft shadows of the target. Add volumetric fog for atmospheric perspective.",
"Replace flat, untextured building models with detailed geometry and realistic materials (concrete, glass, metal).",
"Add high-resolution textures to all surfaces, especially the road, sidewalk, and buildings, to mimic the detail in the target.",
"Model a more detailed and varied cityscape for the backdrop, including buildings with distinct architectural styles and realistic lighting.",
"Redesign character models to be more anatomically correct and have realistic clothing textures and animations."
]
}
},
"street_night": {
"target": "docs/screens/concepts/boulevard_skyline_dusk.png",
"metrics": {
"edge_density": 11.94,
"fine_detail": 5.35,
"contrast": 48.34,
"saturation_mean": 98.21,
"colour_spread": 48.85
},
"target_metrics": {
"edge_density": 8.83,
"fine_detail": 4.61,
"contrast": 33.26,
"saturation_mean": 188.16,
"colour_spread": 35.38
},
"metric_gap": 0.337,
"judge": {
"scores": {
"lighting": 3.0,
"materials": 2.0,
"texture_detail": 1.0,
"backdrop": 2.0,
"characters": 1.0,
"overall": 2.0
},
"fixes": [
"Improve lighting to match the dramatic sunset and neon glow of the target image, focusing on volumetric lighting and bloom effects.",
"Enhance material properties for buildings, road, and car to achieve a more realistic PBR (Physically Based Rendering) look, with appropriate reflections and specularity.",
"Increase texture resolution and detail on all surfaces, especially the road and building facades, to mimic the fine grain and imperfections seen in the target.",
"Redesign the backdrop to include a more detailed and atmospheric skyline with realistic haze and a richer sunset color gradient.",
"Replace the current character models with more detailed and realistically proportioned characters that fit the urban environment."
]
}
}
},
"candidate": {
"boulevard_day": {
"target": "docs/screens/concepts/boulevard_skyline_day.png",
"metrics": {
"edge_density": 20.67,
"fine_detail": 7.8,
"contrast": 42.49,
"saturation_mean": 67.55,
"colour_spread": 48.32
},
"target_metrics": {
"edge_density": 20.74,
"fine_detail": 8.23,
"contrast": 54.85,
"saturation_mean": 73.09,
"colour_spread": 58.16
},
"metric_gap": 0.113,
"judge": {
"scores": {
"lighting": 3.0,
"materials": 2.0,
"texture_detail": 2.0,
"backdrop": 5.0,
"characters": 1.0,
"overall": 2.0
},
"fixes": [
"Improve road and sidewalk materials to have more realistic specular highlights and surface imperfections.",
"Increase texture resolution and detail on buildings, especially the brickwork, to match the target's fidelity.",
"Replace stylized character models with realistic human models that have appropriate proportions and clothing.",
"Enhance lighting to create softer shadows, more realistic ambient occlusion, and better global illumination, especially around the streetlights and building facades.",
"Add atmospheric perspective and volumetric fog to the distant cityscape to create a more convincing sense of depth and distance haze."
]
}
},
"boulevard_night": {
"target": "docs/screens/concepts/hollywood_entrance_rain_night.png",
"metrics": {
"edge_density": 20.67,
"fine_detail": 7.86,
"contrast": 46.43,
"saturation_mean": 98.69,
"colour_spread": 50.59
},
"target_metrics": {
"edge_density": 8.43,
"fine_detail": 3.51,
"contrast": 27.34,
"saturation_mean": 159.44,
"colour_spread": 26.61
},
"metric_gap": 1.073,
"judge": {
"scores": {
"lighting": 2.0,
"materials": 1.0,
"texture_detail": 1.0,
"backdrop": 3.0,
"characters": 2.0,
"overall": 1.0
},
"fixes": [
"Improve lighting to create atmospheric mood, with strong contrast and volumetric effects like fog/rain.",
"Enhance material properties for realism: add specular highlights, subsurface scattering for skin, and more detailed surface imperfections.",
"Increase texture resolution and detail, especially on buildings, pavement, and clothing, to match the target's gritty realism.",
"Redesign characters to have more realistic proportions, clothing, and facial features, and animate them with more natural movement.",
"Develop a more detailed and atmospheric backdrop, including realistic city buildings, distant haze, and appropriate weather effects."
]
}
},
"entrance_day": {
"target": "docs/screens/concepts/hollywood_entrance_morning.png",
"metrics": {
"edge_density": 24.54,
"fine_detail": 8.92,
"contrast": 52.41,
"saturation_mean": 61.63,
"colour_spread": 54.98
},
"target_metrics": {
"edge_density": 16.87,
"fine_detail": 6.92,
"contrast": 70.27,
"saturation_mean": 121.02,
"colour_spread": 69.83
},
"metric_gap": 0.303,
"judge": {
"scores": {
"lighting": 3.0,
"materials": 2.0,
"texture_detail": 2.0,
"backdrop": 3.0,
"characters": 1.0,
"overall": 2.0
},
"fixes": [
"Improve brick texture and material to add more variation, grime, and imperfections.",
"Enhance lighting to create softer shadows, more atmospheric haze, and realistic light falloff.",
"Add more detail and variation to the building facade, including window frames, signage, and architectural elements.",
"Replace low-poly trees and bushes with more detailed and realistic foliage models.",
"Refine character models to match the realistic style of the target image, focusing on proportions, clothing, and facial features."
]
}
},
"street_day": {
"target": "docs/screens/concepts/boulevard_skyline_day.png",
"metrics": {
"edge_density": 14.54,
"fine_detail": 5.32,
"contrast": 36.3,
"saturation_mean": 74.49,
"colour_spread": 39.9
},
"target_metrics": {
"edge_density": 20.74,
"fine_detail": 8.23,
"contrast": 54.85,
"saturation_mean": 73.09,
"colour_spread": 58.16
},
"metric_gap": 0.326,
"judge": {
"scores": {
"lighting": 2.0,
"materials": 1.0,
"texture_detail": 1.0,
"backdrop": 3.0,
"characters": 1.0,
"overall": 1.0
},
"fixes": [
"Improve road and sidewalk materials to have more realistic specular highlights and surface imperfections.",
"Add significant detail to the car models, including realistic reflections, panel gaps, and tire textures.",
"Implement volumetric fog and atmospheric scattering to create a more convincing sense of depth and haze in the backdrop.",
"Replace the placeholder character with a detailed, realistically lit 3D model that matches the environment's style.",
"Enhance the lighting model to include softer shadows, more accurate ambient occlusion, and realistic light bounce."
]
}
},
"street_night": {
"target": "docs/screens/concepts/boulevard_skyline_dusk.png",
"metrics": {
"edge_density": 16.12,
"fine_detail": 6.06,
"contrast": 48.36,
"saturation_mean": 103.44,
"colour_spread": 49.1
},
"target_metrics": {
"edge_density": 8.83,
"fine_detail": 4.61,
"contrast": 33.26,
"saturation_mean": 188.16,
"colour_spread": 35.38
},
"metric_gap": 0.495,
"judge": {
"scores": {
"lighting": 3.0,
"materials": 2.0,
"texture_detail": 2.0,
"backdrop": 4.0,
"characters": 1.0,
"overall": 2.0
},
"fixes": [
"Improve lighting to match the dramatic, atmospheric sunset and neon glow of the target image. Add volumetric fog and bloom effects.",
"Significantly enhance material properties for buildings, road, and vehicles to achieve a more realistic PBR (Physically Based Rendering) look, with appropriate reflections and surface imperfections.",
"Increase texture resolution and detail on all surfaces, especially buildings and the road, to match the crispness and complexity of the target.",
"Redesign the backdrop to include a more detailed and atmospheric city skyline with appropriate haze and color grading to match the sunset.",
"Completely overhaul character models to be significantly more detailed, with realistic proportions, clothing, and animation, to match the quality of the target scene."
]
}
}
}
}
}
@@ -1,86 +0,0 @@
# Realism report: `docs/screens/realism/2026-11-01-v2`
Vision judge: `google/gemini-2.5-flash-lite` (scores 0-10, higher is more realistic compared with the concept target).
| set | shot | target | gap ↓ | edges | detail | contrast | overall | lighting | materials | texture | backdrop |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| baseline | boulevard_day | boulevard_skyline_day | 0.036 | 20.75 | 8.2 | 49.63 | 2.0 | 3.0 | 2.0 | 1.0 | 4.0 |
| baseline | boulevard_night | hollywood_entrance_rain_night | 1.231 | 21.77 | 8.45 | 50.38 | 1.0 | 2.0 | 1.0 | 1.0 | 2.0 |
| baseline | entrance_day | hollywood_entrance_morning | 0.308 | 26.19 | 9.27 | 56.39 | 2.0 | 3.0 | 2.0 | 2.0 | 3.0 |
| baseline | street_day | boulevard_skyline_day | 0.307 | 12.44 | 5.32 | 41.37 | 1.0 | 2.0 | 1.0 | 1.0 | 2.0 |
| baseline | street_night | boulevard_skyline_dusk | 0.337 | 11.94 | 5.35 | 48.34 | 2.0 | 3.0 | 2.0 | 1.0 | 2.0 |
| candidate | boulevard_day | boulevard_skyline_day | 0.113 | 20.67 | 7.8 | 42.49 | 2.0 | 3.0 | 2.0 | 2.0 | 5.0 |
| candidate | boulevard_night | hollywood_entrance_rain_night | 1.073 | 20.67 | 7.86 | 46.43 | 1.0 | 2.0 | 1.0 | 1.0 | 3.0 |
| candidate | entrance_day | hollywood_entrance_morning | 0.303 | 24.54 | 8.92 | 52.41 | 2.0 | 3.0 | 2.0 | 2.0 | 3.0 |
| candidate | street_day | boulevard_skyline_day | 0.326 | 14.54 | 5.32 | 36.3 | 1.0 | 2.0 | 1.0 | 1.0 | 3.0 |
| candidate | street_night | boulevard_skyline_dusk | 0.495 | 16.12 | 6.06 | 48.36 | 2.0 | 3.0 | 2.0 | 2.0 | 4.0 |
**baseline / boulevard_day** fixes:
- Improve the lighting model to include more realistic global illumination and soft shadows. The current lighting is too flat and harsh.
- Replace the current low-polygon character models with more detailed and anatomically correct models. Add variations in clothing and appearance.
- Increase the texture resolution and detail on all surfaces, especially buildings, roads, and foliage. Implement normal maps and specular maps for greater realism.
- Enhance the backdrop by adding more detailed building models in the distance, atmospheric perspective (haze), and a more nuanced skybox.
- Refine the material properties of all objects to better simulate real-world materials. This includes adjusting reflectivity, roughness, and subsurface scattering where appropriate.
**baseline / boulevard_night** fixes:
- Improve lighting to match the dramatic, atmospheric, and volumetric lighting of the target image, focusing on warm, directional light from the marquee and streetlamp, and realistic reflections on wet surfaces.
- Significantly upgrade material shaders to achieve realistic PBR (Physically Based Rendering) properties, especially for brick, asphalt, glass, and metal, to mimic the specular highlights, subsurface scattering, and roughness of the target.
- Increase texture resolution and detail for all surfaces, particularly the brickwork, sidewalk, and building facades, to match the fine-grained realism of the target.
- Redesign character models with higher polygon counts, more detailed textures, and realistic clothing and facial features to match the stylized but grounded look of the target's lone figure.
- Enhance the backdrop by adding atmospheric effects like fog, rain, and distant city lights, and refine the building models to have more architectural detail and variation, similar to the target's moody urban environment.
**baseline / entrance_day** fixes:
- Improve brick texture and material to match the target's depth and variation.
- Add more detailed and varied foliage, especially for the trees and bushes, to match the target's organic look.
- Enhance lighting to create softer shadows, more atmospheric haze, and warmer tones, particularly around the building entrance and street.
- Refine building details, such as window frames, signage, and architectural elements, to increase realism.
- Introduce subtle imperfections and wear on surfaces like the sidewalk and building facade to mimic real-world aging.
**baseline / street_day** fixes:
- Improve lighting to match the warm, directional sunlight and soft shadows of the target. Add volumetric fog for atmospheric perspective.
- Replace flat, untextured building models with detailed geometry and realistic materials (concrete, glass, metal).
- Add high-resolution textures to all surfaces, especially the road, sidewalk, and buildings, to mimic the detail in the target.
- Model a more detailed and varied cityscape for the backdrop, including buildings with distinct architectural styles and realistic lighting.
- Redesign character models to be more anatomically correct and have realistic clothing textures and animations.
**baseline / street_night** fixes:
- Improve lighting to match the dramatic sunset and neon glow of the target image, focusing on volumetric lighting and bloom effects.
- Enhance material properties for buildings, road, and car to achieve a more realistic PBR (Physically Based Rendering) look, with appropriate reflections and specularity.
- Increase texture resolution and detail on all surfaces, especially the road and building facades, to mimic the fine grain and imperfections seen in the target.
- Redesign the backdrop to include a more detailed and atmospheric skyline with realistic haze and a richer sunset color gradient.
- Replace the current character models with more detailed and realistically proportioned characters that fit the urban environment.
**candidate / boulevard_day** fixes:
- Improve road and sidewalk materials to have more realistic specular highlights and surface imperfections.
- Increase texture resolution and detail on buildings, especially the brickwork, to match the target's fidelity.
- Replace stylized character models with realistic human models that have appropriate proportions and clothing.
- Enhance lighting to create softer shadows, more realistic ambient occlusion, and better global illumination, especially around the streetlights and building facades.
- Add atmospheric perspective and volumetric fog to the distant cityscape to create a more convincing sense of depth and distance haze.
**candidate / boulevard_night** fixes:
- Improve lighting to create atmospheric mood, with strong contrast and volumetric effects like fog/rain.
- Enhance material properties for realism: add specular highlights, subsurface scattering for skin, and more detailed surface imperfections.
- Increase texture resolution and detail, especially on buildings, pavement, and clothing, to match the target's gritty realism.
- Redesign characters to have more realistic proportions, clothing, and facial features, and animate them with more natural movement.
- Develop a more detailed and atmospheric backdrop, including realistic city buildings, distant haze, and appropriate weather effects.
**candidate / entrance_day** fixes:
- Improve brick texture and material to add more variation, grime, and imperfections.
- Enhance lighting to create softer shadows, more atmospheric haze, and realistic light falloff.
- Add more detail and variation to the building facade, including window frames, signage, and architectural elements.
- Replace low-poly trees and bushes with more detailed and realistic foliage models.
- Refine character models to match the realistic style of the target image, focusing on proportions, clothing, and facial features.
**candidate / street_day** fixes:
- Improve road and sidewalk materials to have more realistic specular highlights and surface imperfections.
- Add significant detail to the car models, including realistic reflections, panel gaps, and tire textures.
- Implement volumetric fog and atmospheric scattering to create a more convincing sense of depth and haze in the backdrop.
- Replace the placeholder character with a detailed, realistically lit 3D model that matches the environment's style.
- Enhance the lighting model to include softer shadows, more accurate ambient occlusion, and realistic light bounce.
**candidate / street_night** fixes:
- Improve lighting to match the dramatic, atmospheric sunset and neon glow of the target image. Add volumetric fog and bloom effects.
- Significantly enhance material properties for buildings, road, and vehicles to achieve a more realistic PBR (Physically Based Rendering) look, with appropriate reflections and surface imperfections.
- Increase texture resolution and detail on all surfaces, especially buildings and the road, to match the crispness and complexity of the target.
- Redesign the backdrop to include a more detailed and atmospheric city skyline with appropriate haze and color grading to match the sunset.
- Completely overhaul character models to be significantly more detailed, with realistic proportions, clothing, and animation, to match the quality of the target scene.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 756 KiB

After

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 855 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+35
View File
@@ -0,0 +1,35 @@
# Smoke Test Checklist
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 `GIG 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.
+45
View File
@@ -0,0 +1,45 @@
# First-shift tutorial
Status: planned for v0.8.0. The tutorial teaches the real controls and the real
inspection/wheel/radio panels; it is not a fake UI or a separate combat mode.
## Rules
- One short, safe, compliant visitor. No emergency, threat, crowd panic or random
event can interrupt the tutorial.
- Prompts advance on the performed action, not on a timer. The player can keep
moving/looking while a prompt is visible.
- The tutorial is skippable at any point. Completion and skip are both persisted
separately; a skipped tutorial can be replayed from Help/Settings.
- The tutorial state machine is independent of the shift FSM. It observes the
same actions as normal play and must be deterministic for tests.
## Beat sheet
| # | Beat | Prompt | Advances when | Notes |
| --- | --- | --- | --- | --- |
| 1 | Arrive | "Walk forward and look around. The entrance is behind you." | The guard has moved at least 1 m and looked away/returned | Start at the normal guard post, facing the street. |
| 2 | Focus | "Look at the visitor. Press Tab or the mouse wheel to cycle targets." | A target is acquired and explicitly cycled | The visitor waits at the foot of the steps; no other guest is required. |
| 3 | Wheel | "Open the action wheel with Space or left click." | The radial wheel opens on the visitor | The wheel is the real de-escalation-first wheel. |
| 4 | Inspect | "Choose Inspect and read the access evidence." | The inspection panel is opened and the policy result is visible | Use a valid wristband so the tutorial can teach both evidence and a safe admit/deny decision. |
| 5 | Direction | "Close the panel and give a polite direction." | A Level 1 polite-direction action is applied | The visitor complies; there is no escalation requirement. |
| 6 | Warning | "Give a formal warning if the visitor persists. It arms the body camera." | A Level 2 warning is applied or the body camera is switched on | Show the REC overlay and the escalation cue. |
| 7 | Radio | "Open the assistance radio and submit a short report." | The assistance report is submitted successfully | A non-emergency assistance call; no responder is required to resolve the tutorial. |
| 8 | Review | "Finish the shift and review what happened." | The shift review opens | The tutorial ends; the real review remains available for inspection. |
## Visitor script
- Display name: `Visitor`.
- Intent: `enter` (approaches the door, then waits for interaction).
- Access: valid wristband evidence; the policy result should be unambiguous.
- Behavior: compliant at every escalation level; never becomes a threat, never
joins a crowd and never triggers an emergency.
- Failure handling: if the player denies admission incorrectly, the tutorial
explains the evidence but does not fail the run; the visitor remains safe and
the review records the decision.
## Persistence and replay
Settings gain `tutorial_completed` and `tutorial_skipped`. A first launch starts
the tutorial after venue selection. Skipping sets `tutorial_skipped`; Settings/
Help offers "Replay tutorial", which clears both flags for the next shift.
+16
View File
@@ -0,0 +1,16 @@
Gig Line — Desktop HQ build
===========================
This build uses Godot's Forward+ renderer instead of the default
GL Compatibility renderer, enabling volumetric fog and SDFGI.
Requirements:
- A Vulkan-capable GPU/driver (Linux or Windows).
- Keep override.cfg next to the executable. Without it the binary falls back
to the normal Compatibility build.
The game still runs if Forward+ is unavailable, but the desktop-only effects
will be disabled.
Linux: chmod +x gig-line-linux-*.x86_64 && ./gig-line-linux-*.x86_64
Windows: run gig-line-windows-*.exe
+12
View File
@@ -0,0 +1,12 @@
; Gig Line desktop HQ preset. Place this file next to the exported executable.
; It switches only that copy to Forward+; project.godot and the default builds
; stay on GL Compatibility for Web/CI parity.
[rendering]
renderer/rendering_method="forward_plus"
renderer/rendering_method.mobile="forward_plus"
environment/volumetric_fog/volume_size=128
environment/volumetric_fog/volume_depth=128
environment/volumetric_fog/use_filter=1
global_illumination/sdfgi/frames_to_converge=4
global_illumination/sdfgi/frames_to_update_lights=2
+1 -1
View File
@@ -12,7 +12,7 @@ config_version=5
config/name="Gig Line" config/name="Gig Line"
config/description="Workplace security simulation about observation, access verification, and proportionate escalation." config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
config/version="0.6.0" config/version="0.7.3"
run/main_scene="res://scenes/main.tscn" run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.3", "GL Compatibility") config/features=PackedStringArray("4.3", "GL Compatibility")
+36 -29
View File
@@ -32,6 +32,9 @@ const SettingsPanel := preload("res://src/game/ui/settings_panel.gd")
const EventTimeline := preload("res://src/game/ui/event_timeline.gd") const EventTimeline := preload("res://src/game/ui/event_timeline.gd")
var world_builder: WorldBuilder = null var world_builder: WorldBuilder = null
## Dedicated container for the procedural world, so switching venues can rebuild
## the geometry without touching the guard, UI, or other scene-root children.
var world_root: Node3D = null
var door := DoorState.new() var door := DoorState.new()
var flashlight_on := false var flashlight_on := false
var body_camera_on := false var body_camera_on := false
@@ -51,7 +54,7 @@ const PHASE_LIGHTING := {
"sky_ground": Color(0.45, 0.5, 0.55), "ambient": Color(0.75, 0.78, 0.85), "night": false, "sky_ground": Color(0.45, 0.5, 0.55), "ambient": Color(0.75, 0.78, 0.85), "night": false,
"exposure": 0.6, "glow": 0.45, "fog_color": Color(0.87, 0.83, 0.76), "exposure": 0.6, "glow": 0.45, "fog_color": Color(0.87, 0.83, 0.76),
"fog_density": 0.0034, "fog_energy": 0.5, "contrast": 1.05, "saturation": 1.04, "fog_density": 0.0034, "fog_energy": 0.5, "contrast": 1.05, "saturation": 1.04,
"ambient_energy": 0.1, "ambient_energy": 0.1, "volumetric_density": 0.004, "volumetric_energy": 0.0,
}, },
Rules.Phase.MIDDAY: { Rules.Phase.MIDDAY: {
"sun_energy": 1.0, "sun_angle": -62.0, "sun_yaw": -30.0, "sun_color": Color(1.0, 0.99, 0.96), "sun_energy": 1.0, "sun_angle": -62.0, "sun_yaw": -30.0, "sun_color": Color(1.0, 0.99, 0.96),
@@ -59,7 +62,7 @@ const PHASE_LIGHTING := {
"sky_ground": Color(0.5, 0.54, 0.58), "ambient": Color(0.85, 0.88, 0.95), "night": false, "sky_ground": Color(0.5, 0.54, 0.58), "ambient": Color(0.85, 0.88, 0.95), "night": false,
"exposure": 0.6, "glow": 0.4, "fog_color": Color(0.88, 0.87, 0.83), "exposure": 0.6, "glow": 0.4, "fog_color": Color(0.88, 0.87, 0.83),
"fog_density": 0.0028, "fog_energy": 0.5, "contrast": 1.06, "saturation": 1.04, "fog_density": 0.0028, "fog_energy": 0.5, "contrast": 1.06, "saturation": 1.04,
"ambient_energy": 0.28, "ambient_energy": 0.28, "volumetric_density": 0.003, "volumetric_energy": 0.0,
}, },
# Evening is dusk, not a bright sunset: the sun is almost down and the street # Evening is dusk, not a bright sunset: the sun is almost down and the street
# is carried by the marquee, the lamps and the lit windows (realism pass). # is carried by the marquee, the lamps and the lit windows (realism pass).
@@ -69,7 +72,7 @@ const PHASE_LIGHTING := {
"sky_ground": Color(0.1, 0.09, 0.12), "ambient": Color(0.2, 0.19, 0.26), "night": true, "sky_ground": Color(0.1, 0.09, 0.12), "ambient": Color(0.2, 0.19, 0.26), "night": true,
"exposure": 0.72, "glow": 0.8, "fog_color": Color(0.34, 0.27, 0.3), "exposure": 0.72, "glow": 0.8, "fog_color": Color(0.34, 0.27, 0.3),
"fog_density": 0.0026, "fog_energy": 0.45, "contrast": 1.1, "saturation": 1.05, "fog_density": 0.0026, "fog_energy": 0.45, "contrast": 1.1, "saturation": 1.05,
"ambient_energy": 0.12, "ambient_energy": 0.12, "volumetric_density": 0.014, "volumetric_energy": 0.10,
}, },
Rules.Phase.LATE: { Rules.Phase.LATE: {
"sun_energy": 0.02, "sun_angle": -8.0, "sun_color": Color(0.3, 0.35, 0.5), "sun_energy": 0.02, "sun_angle": -8.0, "sun_color": Color(0.3, 0.35, 0.5),
@@ -77,7 +80,7 @@ const PHASE_LIGHTING := {
"sky_ground": Color(0.03, 0.03, 0.05), "ambient": Color(0.06, 0.07, 0.12), "night": true, "sky_ground": Color(0.03, 0.03, 0.05), "ambient": Color(0.06, 0.07, 0.12), "night": true,
"exposure": 0.95, "glow": 1.15, "fog_color": Color(0.08, 0.1, 0.2), "exposure": 0.95, "glow": 1.15, "fog_color": Color(0.08, 0.1, 0.2),
"fog_density": 0.0028, "fog_energy": 0.45, "contrast": 1.12, "saturation": 1.0, "fog_density": 0.0028, "fog_energy": 0.45, "contrast": 1.12, "saturation": 1.0,
"ambient_energy": 0.22, "ambient_energy": 0.22, "volumetric_density": 0.020, "volumetric_energy": 0.14,
}, },
} }
var hud: Hud = null var hud: Hud = null
@@ -135,8 +138,11 @@ func _ready() -> void:
scene_root = Node3D.new() scene_root = Node3D.new()
scene_root.name = "SceneRoot" scene_root.name = "SceneRoot"
add_child(scene_root) add_child(scene_root)
world_root = Node3D.new()
world_root.name = "World"
scene_root.add_child(world_root)
world_builder = WorldBuilder.new(scene_root) world_builder = WorldBuilder.new(world_root, active_scenario_id)
world_builder.build() world_builder.build()
_ensure_lighting() _ensure_lighting()
@@ -190,31 +196,14 @@ func _build_guard() -> GuardController:
# Aim straight ahead: tilting it down made the cone hit the ground right in # Aim straight ahead: tilting it down made the cone hit the ground right in
# front of the guard instead of the person or door being checked. # front of the guard instead of the person or door being checked.
flashlight.rotation_degrees = Vector3.ZERO flashlight.rotation_degrees = Vector3.ZERO
flashlight.spot_range = 16.0 flashlight.spot_range = 30.0 if Materials.modern_light_falloff() else 20.0
flashlight.spot_angle = 20.0 flashlight.spot_angle = 14.0
flashlight.light_energy = 4.0 flashlight.light_energy = 4.2
flashlight.spot_attenuation = 0.8 flashlight.spot_attenuation = 0.0 if Materials.modern_light_falloff() else 0.9
flashlight.spot_angle_attenuation = 0.9 flashlight.spot_angle_attenuation = 0.9
# Visible beam: a fading additive cone parented to the lamp, because the # The volumetric shaft is a Forward+ (desktop HQ) effect. Compatibility and
# Compatibility renderer has no volumetric light. It is offset forward so the # headless builds leave the light non-volumetric, so their look is unchanged.
# near cap never fills the first-person view. flashlight.light_volumetric_fog_energy = 3.0 if RenderingServer.get_rendering_device() != null else 0.0
var beam := MeshInstance3D.new()
beam.name = "Beam"
var cone := CylinderMesh.new()
cone.top_radius = 1.0
cone.bottom_radius = 0.05
cone.height = 5.5
cone.radial_segments = 16
cone.rings = 1
cone.cap_top = false
cone.cap_bottom = false
beam.mesh = cone
# Cylinder axis is +Y; -90° about X aims the wide end along -Z (forward).
beam.rotation_degrees = Vector3(-90, 0, 0)
beam.position = Vector3(0, 0, -3.65)
beam.material_override = Materials.light_beam()
beam.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
flashlight.add_child(beam)
head.add_child(flashlight) head.add_child(flashlight)
var collider := CollisionShape3D.new() var collider := CollisionShape3D.new()
var shape := CapsuleShape3D.new() var shape := CapsuleShape3D.new()
@@ -255,9 +244,26 @@ func _build_scenario(id: String) -> VenueScenario:
func switch_scenario(id: String) -> void: func switch_scenario(id: String) -> void:
active_scenario_id = id active_scenario_id = id
selected_scenario = _build_scenario(id) selected_scenario = _build_scenario(id)
_rebuild_world_for_venue(id)
if coordinator: if coordinator:
restart_shift() restart_shift()
## Rebuild the procedural venue in place. The guard/UI stay under `SceneRoot`;
## only the `World` container is replaced, so navigation, props, lights and the
## venue-specific dressing match the selected scenario.
func _rebuild_world_for_venue(id: String) -> void:
if world_root == null:
return
for child in world_root.get_children():
child.free()
world_builder = WorldBuilder.new(world_root, id)
world_builder.build()
if guard != null:
guard.global_position = world_builder.spec.get("guard_spawn", Vector3(0, 0.1, -2.0))
guard.rotation.y = PI
if world_builder != null:
world_builder.set_door_locked(door.locked)
func restart_shift() -> void: func restart_shift() -> void:
if shift_review_panel: if shift_review_panel:
shift_review_panel.hide() shift_review_panel.hide()
@@ -321,6 +327,7 @@ func _on_venue_selected(scenario_id: String) -> void:
active_scenario_id = scenario_id active_scenario_id = scenario_id
_venue_index = maxi(0, VENUE_ORDER.find(scenario_id)) _venue_index = maxi(0, VENUE_ORDER.find(scenario_id))
selected_scenario = _build_scenario(scenario_id) selected_scenario = _build_scenario(scenario_id)
_rebuild_world_for_venue(scenario_id)
if hud: if hud:
hud.set_wand_icon(false) hud.set_wand_icon(false)
_reset_shift_state() _reset_shift_state()
+28 -6
View File
@@ -115,14 +115,36 @@ static func _ensure_gamepad_bindings() -> void:
press.pressed = false press.pressed = false
InputMap.action_add_event(action, press) InputMap.action_add_event(action, press)
func _unhandled_input(event: InputEvent) -> void: ## Mouse look is read at the `_input` stage, before GUI/Control handling. A
## full-screen HUD or a focused control must never be able to swallow relative
## mouse motion while the game is in captured mode.
func _input(event: InputEvent) -> void:
if input_locked: if input_locked:
return return
if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED: if event is InputEventMouseMotion:
rotate_y(-event.relative.x * look_sensitivity) if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
var pitch_sign := 1.0 if invert_y else -1.0 return
head.rotate_x(pitch_sign * event.relative.y * look_sensitivity) _apply_mouse_look(event.relative)
head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0)) get_viewport().set_input_as_handled()
elif event is InputEventMouseButton and event.pressed and Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
# Recover capture if the window lost it (alt-tab, compositor focus,
# clicking back into the game) without opening the radial menu.
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
get_viewport().set_input_as_handled()
## Pure look update, kept separate so the rotation math is testable.
func _apply_mouse_look(relative: Vector2) -> void:
rotate_y(-relative.x * look_sensitivity)
var pitch_sign := 1.0 if invert_y else -1.0
head.rotate_x(pitch_sign * relative.y * look_sensitivity)
head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0))
func _notification(what: int) -> void:
# A window/compositor focus change can silently release capture. Restore it
# when gameplay owns the input layer; menus set input_locked instead.
if what == NOTIFICATION_APPLICATION_FOCUS_IN or what == NOTIFICATION_WM_WINDOW_FOCUS_IN:
if is_inside_tree() and not input_locked:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
func _physics_process(_delta: float) -> void: func _physics_process(_delta: float) -> void:
if focus_suppress_s > 0.0: if focus_suppress_s > 0.0:
+34 -22
View File
@@ -128,27 +128,6 @@ static func emissive(color: Color, energy: float = 1.4) -> StandardMaterial3D:
mat.roughness = 0.4 mat.roughness = 0.4
return mat return mat
## Fake light shaft for the flashlight (Compatibility has no volumetrics).
## Additive, unshaded, no depth write: it glows over the scene, is still occluded
## by walls, and fades out along its length instead of ending in a hard disc.
static func light_beam() -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
mat.albedo_color = Color(1.0, 0.95, 0.82, 1.0)
var fade := Gradient.new()
fade.set_color(0, Color(1.0, 0.95, 0.82, 0.0))
fade.add_point(0.18, Color(1.0, 0.95, 0.82, 0.22))
fade.set_color(fade.get_point_count() - 1, Color(1.0, 0.95, 0.82, 0.0))
var ramp := GradientTexture1D.new()
ramp.gradient = fade
mat.albedo_texture = ramp
mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR
return mat
## Dark glass for windows and the entrance door. The alpha keeps the interior ## 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. ## 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: static func glass(color: Color = Color(0.42, 0.56, 0.72, 0.75)) -> StandardMaterial3D:
@@ -173,9 +152,24 @@ static func window_pane() -> StandardMaterial3D:
mat.emission_energy_multiplier = 0.0 mat.emission_energy_multiplier = 0.0
return mat return mat
## Godot 4.7 changed the Compatibility light falloff in practice: the same
## attenuation/range that read well on 4.3 leaves the night scene nearly black.
## Use this switch for local lights until the project moves its baseline to 4.7.
static func modern_light_falloff() -> bool:
var info := Engine.get_version_info()
return int(info.get("major", 4)) > 4 \
or (int(info.get("major", 4)) == 4 and int(info.get("minor", 0)) >= 7)
## True only when a real RenderingDevice exists. The Compatibility renderer and
## the headless dummy renderer return null, so the desktop-only effects can be
## gated without Web/CI builds ever enabling them.
static func forward_plus_active() -> bool:
return RenderingServer.get_rendering_device() != null
## Environment look shared by every phase: ACES tonemapping, soft glow, aerial ## Environment look shared by every phase: ACES tonemapping, soft glow, aerial
## fog for depth, and a mild contrast/saturation lift. Phase values come from ## fog for depth, and a mild contrast/saturation lift. Phase values come from
## `GameManager.PHASE_LIGHTING`. ## `GameManager.PHASE_LIGHTING`. Volumetric fog and SDFGI are only enabled when
## Forward+ is actually available (the optional desktop preset).
static func configure_environment(env: Environment, cfg: Dictionary) -> void: static func configure_environment(env: Environment, cfg: Dictionary) -> void:
if env == null: if env == null:
return return
@@ -201,6 +195,24 @@ static func configure_environment(env: Environment, cfg: Dictionary) -> void:
env.fog_density = float(cfg.get("fog_density", 0.0035)) env.fog_density = float(cfg.get("fog_density", 0.0035))
env.fog_aerial_perspective = 0.25 env.fog_aerial_perspective = 0.25
env.fog_sky_affect = 0.4 env.fog_sky_affect = 0.4
var advanced := forward_plus_active()
env.volumetric_fog_enabled = advanced
env.sdfgi_enabled = advanced
if advanced:
# Desktop HQ: a shallow, cool fog gives the torch a visible shaft while
# keeping the entrance facade readable. Phase values are tuned per phase.
env.volumetric_fog_density = float(cfg.get("volumetric_density", 0.012))
env.volumetric_fog_albedo = cfg.get("volumetric_albedo", Color(0.55, 0.62, 0.72))
env.volumetric_fog_emission = cfg.get("volumetric_emission", Color.BLACK)
env.volumetric_fog_emission_energy = float(cfg.get("volumetric_energy", 0.0))
env.volumetric_fog_anisotropy = 0.25
env.volumetric_fog_length = 48.0
env.volumetric_fog_ambient_inject = 0.15
env.volumetric_fog_sky_affect = 0.2
env.sdfgi_use_occlusion = true
env.sdfgi_cascades = 4
env.sdfgi_min_cell_size = 0.2
env.sdfgi_energy = 0.7
env.adjustment_enabled = true env.adjustment_enabled = true
env.adjustment_brightness = 1.0 env.adjustment_brightness = 1.0
env.adjustment_contrast = float(cfg.get("contrast", 1.06)) env.adjustment_contrast = float(cfg.get("contrast", 1.06))
+118
View File
@@ -0,0 +1,118 @@
class_name Tutorial
extends RefCounted
## Deterministic first-shift tutorial state machine. It observes player actions
## but owns no UI or simulation nodes, so it can be tested headlessly and wired
## into GameManager without changing the shift FSM. `advance()` only accepts the
## event for the current beat; wrong actions are ignored.
signal beat_changed(index: int, beat: Dictionary)
signal completed(skipped: bool)
const BEATS := [
{
"id": "arrive",
"prompt": "Walk forward and look around. The entrance is behind you.",
"event": "moved",
},
{
"id": "focus",
"prompt": "Look at the visitor. Press Tab or the mouse wheel to cycle targets.",
"event": "cycled",
},
{
"id": "wheel",
"prompt": "Open the action wheel with Space or left click.",
"event": "wheel_opened",
},
{
"id": "inspect",
"prompt": "Choose Inspect and read the access evidence.",
"event": "inspected",
},
{
"id": "direction",
"prompt": "Close the panel and give a polite direction.",
"event": "polite_direction",
},
{
"id": "warning",
"prompt": "Give a formal warning if the visitor persists. It arms the body camera.",
"event": "formal_warning",
"alternate_event": "body_camera",
},
{
"id": "radio",
"prompt": "Open the assistance radio and submit a short report.",
"event": "radio_submitted",
},
{
"id": "review",
"prompt": "Finish the shift and review what happened.",
"event": "review_opened",
},
]
var index := -1
var complete := false
var skipped := false
func start() -> Dictionary:
index = 0
complete = false
skipped = false
var beat: Dictionary = BEATS[0]
beat_changed.emit(index, beat)
return beat
func current() -> Dictionary:
if index < 0 or index >= BEATS.size() or complete:
return {}
return BEATS[index]
func prompt() -> String:
return str(current().get("prompt", ""))
func advance(event: String) -> bool:
if complete:
return false
if index < 0:
start()
if index >= BEATS.size():
return false
var beat: Dictionary = BEATS[index]
var accepted: Array = [str(beat.get("event", ""))]
if beat.has("alternate_event"):
accepted.append(str(beat["alternate_event"]))
if event not in accepted:
return false
index += 1
if index >= BEATS.size():
complete = true
index = BEATS.size()
completed.emit(false)
else:
beat_changed.emit(index, BEATS[index])
return true
## Skip is recorded separately so Settings/Help can offer "Replay tutorial".
func skip() -> void:
if complete:
return
skipped = true
complete = true
index = BEATS.size()
completed.emit(true)
func restart() -> Dictionary:
return start()
func is_complete() -> bool:
return complete
func is_skipped() -> bool:
return skipped
## (current beat number, total beats); one-based for display, (total, total)
## after completion.
func progress() -> Vector2i:
return Vector2i(clampi(index + 1, 1, BEATS.size()), BEATS.size())
+11
View File
@@ -356,6 +356,17 @@ func _init() -> void:
debug_label.add_theme_font_size_override("font_size", 11) debug_label.add_theme_font_size_override("font_size", 11)
debug_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART debug_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
add_child(debug_label) add_child(debug_label)
_ignore_mouse(self)
## HUD controls are presentation-only. Leaving the full-screen containers on the
## default MOUSE_FILTER_STOP can swallow mouse motion/buttons before the guard's
## captured-look input sees them. Make every non-gameplay HUD control inert to
## the mouse; modal menus are separate CanvasLayers and keep their own filters.
func _ignore_mouse(node: Node) -> void:
for child in node.get_children():
if child is Control:
(child as Control).mouse_filter = Control.MOUSE_FILTER_IGNORE
_ignore_mouse(child)
func set_shift_state(coordinator: Object, scenario: Resource) -> void: func set_shift_state(coordinator: Object, scenario: Resource) -> void:
top_label.text = scenario.display_name if scenario else "Gig Line" top_label.text = scenario.display_name if scenario else "Gig Line"
+324 -85
View File
@@ -59,21 +59,116 @@ var library_nodes: Array[Node3D] = []
## Total triangle budget for the Blender library pieces placed in the world. ## Total triangle budget for the Blender library pieces placed in the world.
const LIBRARY_TRIANGLE_BUDGET := 60000 const LIBRARY_TRIANGLE_BUDGET := 60000
func _init(root_node: Node3D) -> void: ## Per-venue geometry data. The Hollywood values are the fallback: every field
## here defaults to the long-standing boulevard layout, so adding a venue cannot
## silently regress the original scene. Joke Shop and TV Studio override the
## facade/street measurements and feature flags; their bespoke dressing lands in
## the following tasks.
const VENUE_SPECS := {
"hollywood_building": {
"display_name": "Hollywood Boulevard Building",
"facade_width": 40.0, "facade_height": 18.0, "facade_depth": 4.0,
"facade_center_z": -6.0, "facade_texture": T_BRICK,
"facade_tint": Color(0.86, 0.85, 0.83), "facade_role": "brick",
"window_columns": [-17.0, -13.6, -10.2, -6.8, 6.8, 10.2, 13.6, 17.0],
"pilaster_columns": [-15.3, -11.9, -8.5, -5.1, 5.1, 8.5, 11.9, 15.3],
"window_rows": [9.2, 12.4],
"stair_width": 8.0, "stair_count": 4, "stair_tread": 0.6, "stair_height": 0.16,
"sidewalk_width": 30.0, "sidewalk_depth": 8.0,
"street_width": 120.0, "street_depth": 26.0,
"nav_min": Vector2(-18.0, -0.6), "nav_max": Vector2(18.0, 20.0),
"guard_spawn": Vector3(0.0, 0.1, -2.0),
"features": {
"marquee": true, "skyline": true, "handrail": true, "sedan": true,
"wall_lanterns": true, "palms": true, "street_trees": true,
"street_lamps": true, "crosswalk": true, "bulb_canopy": false,
},
},
"joke_shop": {
"display_name": "The Joke Shop",
"facade_width": 12.0, "facade_height": 12.0, "facade_depth": 5.0,
"facade_center_z": -6.5, "facade_texture": T_BRICK,
"facade_tint": Color(0.78, 0.66, 0.58), "facade_role": "brick",
"window_columns": [-3.8, 3.8],
"pilaster_columns": [-5.2, 5.2],
"window_rows": [8.2],
"stair_width": 5.0, "stair_count": 2, "stair_tread": 0.65, "stair_height": 0.16,
"sidewalk_width": 22.0, "sidewalk_depth": 6.0,
"street_width": 75.0, "street_depth": 18.0,
"nav_min": Vector2(-9.0, -0.6), "nav_max": Vector2(9.0, 14.0),
"guard_spawn": Vector3(0.0, 0.1, -1.6),
"features": {
"marquee": false, "skyline": false, "handrail": false, "sedan": false,
"wall_lanterns": true, "palms": false, "street_trees": false,
"street_lamps": false, "crosswalk": false, "bulb_canopy": false,
},
"prop_placements": [],
},
"tv_studio": {
"display_name": "Late Night TV Studio",
"facade_width": 30.0, "facade_height": 15.0, "facade_depth": 4.0,
"facade_center_z": -6.0, "facade_texture": T_CONCRETE,
"facade_tint": Color(0.58, 0.57, 0.55), "facade_role": "stone",
"window_columns": [-11.0, -7.2, 7.2, 11.0],
"pilaster_columns": [-13.2, -9.0, -5.0, 5.0, 9.0, 13.2],
"window_rows": [10.5],
"stair_width": 7.0, "stair_count": 3, "stair_tread": 0.6, "stair_height": 0.16,
"sidewalk_width": 28.0, "sidewalk_depth": 7.0,
"street_width": 100.0, "street_depth": 24.0,
"nav_min": Vector2(-14.0, -0.6), "nav_max": Vector2(14.0, 18.0),
"guard_spawn": Vector3(0.0, 0.1, -1.8),
"sedan_position": Vector3(5.4, 0.1, 8.8), "sedan_rotation": 90.0,
"features": {
"marquee": false, "skyline": true, "handrail": true, "sedan": true,
"wall_lanterns": false, "palms": false, "street_trees": true,
"street_lamps": true, "crosswalk": true, "bulb_canopy": true,
},
"prop_placements": [
{"zone": "entrance", "prop": "stanchion", "x": -2.8, "z": 4.4},
{"zone": "entrance", "prop": "stanchion", "x": 2.8, "z": 4.4},
{"zone": "entrance", "prop": "crowd_barrier", "x": -5.2, "z": 4.2},
{"zone": "entrance", "prop": "crowd_barrier", "x": 5.2, "z": 4.2},
{"zone": "curb", "prop": "security_podium", "x": 4.6, "z": 5.9, "rot": -15.0},
],
},
}
const DEFAULT_VENUE := "hollywood_building"
var venue_id := DEFAULT_VENUE
var spec: Dictionary = VENUE_SPECS[DEFAULT_VENUE]
func _init(root_node: Node3D, id: String = DEFAULT_VENUE) -> void:
root = root_node root = root_node
set_venue(id)
## Select the geometry spec before `build()`. Unknown ids fall back to the
## boulevard so old callers and tests keep working.
func set_venue(id: String) -> void:
venue_id = id if VENUE_SPECS.has(id) else DEFAULT_VENUE
spec = VENUE_SPECS[venue_id]
func _feature(name: String) -> bool:
return bool((spec.get("features", {}) as Dictionary).get(name, false))
func build() -> void: func build() -> void:
_build_ground() _build_ground()
_build_building() _build_building()
_build_skyline_backdrop() if _feature("skyline"):
_build_marquee() _build_skyline_backdrop()
if _feature("marquee"):
_build_marquee()
if _feature("bulb_canopy"):
_build_bulb_canopy()
_build_steps() _build_steps()
_build_sidewalk() _build_sidewalk()
_build_street() _build_street()
_build_crosswalk() if _feature("crosswalk"):
_build_crosswalk()
_build_street_furniture() _build_street_furniture()
_build_street_detail() _build_street_detail()
_build_library_dressing() _build_library_dressing()
if venue_id == "joke_shop":
_build_alley_dressing()
_build_guard_spawn() _build_guard_spawn()
_build_navmesh() _build_navmesh()
set_night_mode(false) set_night_mode(false)
@@ -111,41 +206,49 @@ func _build_ground() -> void:
func _build_building() -> void: func _build_building() -> void:
var building := StaticBody3D.new() var building := StaticBody3D.new()
building.name = "Building" building.name = "Building"
# Main facade (textured brick) var w := float(spec["facade_width"])
_add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -6), Color(0.86, 0.85, 0.83), false, T_BRICK, Vector2(22, 10), "brick") var h := float(spec["facade_height"])
var d := float(spec["facade_depth"])
var cz := float(spec["facade_center_z"])
var front := cz + d * 0.5
# Main facade (brick on the boulevard/alley, stone on the studio)
var facade_texture: Texture2D = spec.get("facade_texture", T_BRICK)
var facade_tint: Color = spec.get("facade_tint", Color(0.86, 0.85, 0.83))
var facade_role := str(spec.get("facade_role", "brick"))
_add_box(building, Vector3(w, h, d), Vector3(0, h * 0.5, cz), facade_tint, false, facade_texture, Vector2(w * 0.55, h * 0.55), facade_role)
# Stone base course so the facade has a footing instead of meeting the steps dead-on. # Stone base course so the facade has a footing instead of meeting the steps dead-on.
_add_box(building, Vector3(40, 0.8, 0.5), Vector3(0, 0.4, -3.78), Color(0.52, 0.5, 0.47), false, null, Vector2.ONE, "stone") _add_box(building, Vector3(w, 0.8, 0.5), Vector3(0, 0.4, front + 0.22), Color(0.52, 0.5, 0.47), false, null, Vector2.ONE, "stone")
# Facade depth, not a flat wall: a projecting plinth, a string course between # Facade depth, not a flat wall: a projecting plinth, a string course between
# the window bands, a cornice under the roofline and pilasters at the bays. # the window bands, a cornice under the roofline and pilasters at the bays.
_add_box(building, Vector3(40, 0.34, 0.22), Vector3(0, 0.93, -3.9), Color(0.6, 0.58, 0.55), true, null, Vector2.ONE, "stone") _add_box(building, Vector3(w, 0.34, 0.22), Vector3(0, 0.93, front + 0.1), Color(0.6, 0.58, 0.55), true, null, Vector2.ONE, "stone")
_add_box(building, Vector3(40, 0.28, 0.28), Vector3(0, 7.6, -3.86), Color(0.6, 0.58, 0.55), true, null, Vector2.ONE, "stone") _add_box(building, Vector3(w, 0.28, 0.28), Vector3(0, 7.6, front + 0.14), Color(0.6, 0.58, 0.55), true, null, Vector2.ONE, "stone")
_add_box(building, Vector3(40, 0.5, 0.5), Vector3(0, 17.7, -3.75), Color(0.56, 0.54, 0.51), true, null, Vector2.ONE, "stone") _add_box(building, Vector3(w, 0.5, 0.5), Vector3(0, h - 0.3, front + 0.25), Color(0.56, 0.54, 0.51), true, null, Vector2.ONE, "stone")
_add_box(building, Vector3(40, 0.22, 0.2), Vector3(0, 10.9, -3.9), Color(0.58, 0.56, 0.53), true, null, Vector2.ONE, "stone") _add_box(building, Vector3(w, 0.22, 0.2), Vector3(0, 10.9, front + 0.1), Color(0.58, 0.56, 0.53), true, null, Vector2.ONE, "stone")
for bay in [-15.3, -11.9, -8.5, -5.1, 5.1, 8.5, 11.9, 15.3]: for bay in spec.get("pilaster_columns", [-15.3, -11.9, -8.5, -5.1, 5.1, 8.5, 11.9, 15.3]):
_add_box(building, Vector3(0.5, 8.4, 0.24), Vector3(bay, 12.6, -3.9), _add_box(building, Vector3(0.5, 8.4, 0.24), Vector3(float(bay), 12.6, front + 0.1),
Color(0.8, 0.78, 0.76), true, T_BRICK, Vector2(0.3, 4.7), "brick") Color(0.8, 0.78, 0.76), true, facade_texture, Vector2(0.3, 4.7), facade_role)
# Entrance recess + door recess frame # Entrance recess + door recess frame
_add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -3.9), Color(0.08, 0.08, 0.09), false, null, Vector2.ONE, "stone") _add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, front + 0.1), Color(0.08, 0.08, 0.09), false, null, Vector2.ONE, "stone")
# Door frame columns and lintel, so the entrance is framed rather than painted on. # Door frame columns and lintel, so the entrance is framed rather than painted on.
for side in [-1.55, 1.55]: for side in [-1.55, 1.55]:
_add_box(building, Vector3(0.28, 5.2, 0.42), Vector3(side, 2.9, -3.86), Color(0.30, 0.28, 0.26), true, null, Vector2.ONE, "stone") _add_box(building, Vector3(0.28, 5.2, 0.42), Vector3(side, 2.9, front + 0.14), Color(0.30, 0.28, 0.26), true, null, Vector2.ONE, "stone")
_add_box(building, Vector3(3.4, 0.3, 0.42), Vector3(0, 5.55, -3.86), Color(0.30, 0.28, 0.26), true, null, Vector2.ONE, "stone") _add_box(building, Vector3(3.4, 0.3, 0.42), Vector3(0, 5.55, front + 0.14), Color(0.30, 0.28, 0.26), true, null, Vector2.ONE, "stone")
# Door frame and dark door # Door frame and dark door
_add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -3.98), Color(0.13, 0.12, 0.11), true, null, Vector2.ONE, "painted_metal") _add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, front + 0.02), Color(0.13, 0.12, 0.11), true, null, Vector2.ONE, "painted_metal")
# Door glass (semi-transparent) # Door glass (semi-transparent)
var glass := _add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -3.9), Color(0.6, 0.75, 0.9, 0.55)) var glass := _add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, front + 0.1), Color(0.6, 0.75, 0.9, 0.55))
glass.material_override = Materials.glass(Color(0.55, 0.7, 0.88, 0.55)) glass.material_override = Materials.glass(Color(0.55, 0.7, 0.88, 0.55))
# Door handles: two slim metal bars where the guard reaches for the lock. # Door handles: two slim metal bars where the guard reaches for the lock.
for side in [-0.42, 0.42]: for side in [-0.42, 0.42]:
_add_box(building, Vector3(0.06, 0.5, 0.06), Vector3(side, 2.6, -3.79), Color(0.75, 0.74, 0.70), false, null, Vector2.ONE, "metal") _add_box(building, Vector3(0.06, 0.5, 0.06), Vector3(side, 2.6, front + 0.21), Color(0.75, 0.74, 0.70), false, null, Vector2.ONE, "metal")
# Door lock indicator (green = open, red = locked), placed beside the handle # Door lock indicator (green = open, red = locked), placed beside the handle
door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -3.82), Color(0.1, 0.9, 0.3)) door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, front + 0.18), Color(0.1, 0.9, 0.3))
door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3)) door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3))
# Blender glazed double door in front of the recessed frame. # Blender glazed double door in front of the recessed frame.
var door_model := _load_prop_scene("double_door") var door_model := _load_prop_scene("double_door")
if door_model != null: if door_model != null:
door_model.name = "LibraryDoubleDoor" door_model.name = "LibraryDoubleDoor"
door_model.position = Vector3(0, 0.0, -3.84) door_model.position = Vector3(0, 0.0, front + 0.16)
building.add_child(door_model) building.add_child(door_model)
library_nodes.append(door_model) library_nodes.append(door_model)
_apply_library_ao(door_model, "double_door") _apply_library_ao(door_model, "double_door")
@@ -154,18 +257,21 @@ func _build_building() -> void:
_lamp_bulb_mats.append(material) _lamp_bulb_mats.append(material)
_build_facade_windows(building) _build_facade_windows(building)
var col := BoxShape3D.new() var col := BoxShape3D.new()
col.size = Vector3(40, 18, 4) col.size = Vector3(w, h, d)
var so := building.create_shape_owner(building) var so := building.create_shape_owner(building)
building.shape_owner_add_shape(so, col) building.shape_owner_add_shape(so, col)
building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 9, -6))) building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, h * 0.5, cz)))
root.add_child(building) root.add_child(building)
## Regular window bands above the entrance. Panes are dark glass by day and ## Regular window bands above the entrance. Panes are dark glass by day and
## light up after dark, which gives the facade depth at night. ## light up after dark, which gives the facade depth at night.
func _build_facade_windows(parent: Node3D) -> void: func _build_facade_windows(parent: Node3D) -> void:
var columns := [-17.0, -13.6, -10.2, -6.8, 6.8, 10.2, 13.6, 17.0] var columns: Array = spec.get("window_columns", [-17.0, -13.6, -10.2, -6.8, 6.8, 10.2, 13.6, 17.0])
for y in [9.2, 12.4]: var rows: Array = spec.get("window_rows", [9.2, 12.4])
for x in columns: for value in rows:
var y := float(value)
for x_value in columns:
var x := float(x_value)
# Recessed reveal, then the pane: gives the window a dark frame edge. # Recessed reveal, then the pane: gives the window a dark frame edge.
_add_box(parent, Vector3(1.96, 2.46, 0.1), Vector3(x, y, -3.99), _add_box(parent, Vector3(1.96, 2.46, 0.1), Vector3(x, y, -3.99),
Color(0.22, 0.21, 0.20), false, null, Vector2.ONE, "stone") Color(0.22, 0.21, 0.20), false, null, Vector2.ONE, "stone")
@@ -188,12 +294,13 @@ func _build_steps() -> void:
# The ground and building provide the actual collision boundaries. # The ground and building provide the actual collision boundaries.
var steps := StaticBody3D.new() var steps := StaticBody3D.new()
steps.name = "Steps" steps.name = "Steps"
var step_w := 8.0 var step_w := float(spec.get("stair_width", 8.0))
var tread := 0.6 var tread := float(spec.get("stair_tread", 0.6))
var height := 0.16 var height := float(spec.get("stair_height", 0.16))
for i in 4: var count := int(spec.get("stair_count", 4))
for i in count:
var y: float = i * height var y: float = i * height
var z: float = 4.0 * tread - i * tread var z: float = float(count) * tread - i * tread
var w: float = step_w - i * 0.35 var w: float = step_w - i * 0.35
_add_box(steps, Vector3(w, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5)) _add_box(steps, Vector3(w, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5))
# Add collision shape to each tread # Add collision shape to each tread
@@ -203,10 +310,11 @@ func _build_steps() -> void:
steps.shape_owner_add_shape(owner_id, tread_col) steps.shape_owner_add_shape(owner_id, tread_col)
steps.shape_owner_set_transform(owner_id, Transform3D(Basis(), Vector3(0, y + height / 2.0, z))) steps.shape_owner_set_transform(owner_id, Transform3D(Basis(), Vector3(0, y + height / 2.0, z)))
# Landing at the top with collision # Landing at the top with collision
var landing_y: float = 4 * height + 0.1 var landing_y: float = count * height + 0.1
_add_box(steps, Vector3(9, 0.2, 3), Vector3(0, landing_y, -1.5), Color(0.5, 0.47, 0.45)) var landing_w := step_w + 1.0
_add_box(steps, Vector3(landing_w, 0.2, 3), Vector3(0, landing_y, -1.5), Color(0.5, 0.47, 0.45))
var landing_col := BoxShape3D.new() var landing_col := BoxShape3D.new()
landing_col.size = Vector3(9, 0.2, 3) landing_col.size = Vector3(landing_w, 0.2, 3)
var landing_owner := steps.create_shape_owner(steps) var landing_owner := steps.create_shape_owner(steps)
steps.shape_owner_add_shape(landing_owner, landing_col) steps.shape_owner_add_shape(landing_owner, landing_col)
steps.shape_owner_set_transform(landing_owner, Transform3D(Basis(), Vector3(0, landing_y, -1.5))) steps.shape_owner_set_transform(landing_owner, Transform3D(Basis(), Vector3(0, landing_y, -1.5)))
@@ -215,12 +323,14 @@ func _build_steps() -> void:
func _build_sidewalk() -> void: func _build_sidewalk() -> void:
var sidewalk := StaticBody3D.new() var sidewalk := StaticBody3D.new()
sidewalk.name = "Sidewalk" sidewalk.name = "Sidewalk"
_add_box(sidewalk, Vector3(30, 0.18, 8), Vector3(0, 0.09, 8), Color(0.55, 0.55, 0.54), false, T_CONCRETE, Vector2(9, 2.4), "concrete") var w := float(spec.get("sidewalk_width", 30.0))
var d := float(spec.get("sidewalk_depth", 8.0))
_add_box(sidewalk, Vector3(w, 0.18, d), Vector3(0, 0.09, d * 0.5), Color(0.55, 0.55, 0.54), false, T_CONCRETE, Vector2(w * 0.3, d * 0.3), "concrete")
var col := BoxShape3D.new() var col := BoxShape3D.new()
col.size = Vector3(30, 0.36, 8) col.size = Vector3(w, 0.36, d)
var so := sidewalk.create_shape_owner(sidewalk) var so := sidewalk.create_shape_owner(sidewalk)
sidewalk.shape_owner_add_shape(so, col) sidewalk.shape_owner_add_shape(so, col)
sidewalk.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, 8))) sidewalk.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, d * 0.5)))
root.add_child(sidewalk) root.add_child(sidewalk)
func _build_street() -> void: func _build_street() -> void:
@@ -230,20 +340,24 @@ func _build_street() -> void:
# white looked like more pavement. # white looked like more pavement.
# The street keeps its own material so night mode can switch to the wet # The street keeps its own material so night mode can switch to the wet
# asphalt map (glossier, with lamp reflections) and back. # asphalt map (glossier, with lamp reflections) and back.
_street_mat = Materials.asphalt(Color(0.62, 0.63, 0.66), Vector2(30, 7)) var street_w := float(spec.get("street_width", 120.0))
var street_mesh := _add_box(street, Vector3(120, 0.1, 26), Vector3(0, 0.05, 22), var street_d := float(spec.get("street_depth", 26.0))
Color(0.62, 0.63, 0.66), false, T_ASPHALT, Vector2(30, 7), "asphalt") var curb_z := float(spec.get("sidewalk_depth", 8.0)) + 4.1
var street_z := curb_z + street_d * 0.5 - 3.1
_street_mat = Materials.asphalt(Color(0.62, 0.63, 0.66), Vector2(street_w * 0.25, street_d * 0.27))
var street_mesh := _add_box(street, Vector3(street_w, 0.1, street_d), Vector3(0, 0.05, street_z),
Color(0.62, 0.63, 0.66), false, T_ASPHALT, Vector2(street_w * 0.25, street_d * 0.27), "asphalt")
street_mesh.material_override = _street_mat street_mesh.material_override = _street_mat
# Center dashes along the street (x varied so they form a lane line, not a stack). # Center dashes along the street (x varied so they form a lane line, not a stack).
for i in range(-40, 41, 4): for i in range(-int(street_w / 3.0), int(street_w / 3.0) + 1, 4):
_add_box(street, Vector3(0.2, 0.02, 1.2), Vector3(float(i), 0.11, 22), Color(0.85, 0.85, 0.30), false, null, Vector2.ONE, "concrete") _add_box(street, Vector3(0.2, 0.02, 1.2), Vector3(float(i), 0.11, street_z), Color(0.85, 0.85, 0.30), false, null, Vector2.ONE, "concrete")
# Curb between the sidewalk and the roadway. # Curb between the sidewalk and the roadway.
_add_box(street, Vector3(120, 0.22, 0.5), Vector3(0, 0.11, 12.1), Color(0.62, 0.61, 0.58), false, null, Vector2.ONE, "concrete") _add_box(street, Vector3(street_w, 0.22, 0.5), Vector3(0, 0.11, curb_z), Color(0.62, 0.61, 0.58), false, null, Vector2.ONE, "concrete")
var col := BoxShape3D.new() var col := BoxShape3D.new()
col.size = Vector3(120, 0.2, 26) col.size = Vector3(street_w, 0.2, street_d)
var so := street.create_shape_owner(street) var so := street.create_shape_owner(street)
street.shape_owner_add_shape(so, col) street.shape_owner_add_shape(so, col)
street.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, 22))) street.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, street_z)))
root.add_child(street) root.add_child(street)
func _build_navmesh() -> void: func _build_navmesh() -> void:
@@ -256,9 +370,11 @@ func _build_navmesh() -> void:
mesh.agent_max_climb = 0.8 mesh.agent_max_climb = 0.8
mesh.agent_max_slope = 45.0 mesh.agent_max_slope = 45.0
mesh.cell_size = 0.25 mesh.cell_size = 0.25
var nav_min: Vector2 = spec.get("nav_min", Vector2(-18.0, -0.6))
var nav_max: Vector2 = spec.get("nav_max", Vector2(18.0, 20.0))
var verts := PackedVector3Array([ var verts := PackedVector3Array([
Vector3(-18, 0.0, -0.6), Vector3(18, 0.0, -0.6), Vector3(nav_min.x, 0.0, nav_min.y), Vector3(nav_max.x, 0.0, nav_min.y),
Vector3(18, 0.0, 20.0), Vector3(-18, 0.0, 20.0), Vector3(nav_max.x, 0.0, nav_max.y), Vector3(nav_min.x, 0.0, nav_max.y),
]) ])
mesh.set_vertices(verts) mesh.set_vertices(verts)
mesh.add_polygon(PackedInt32Array([0, 1, 2, 3])) mesh.add_polygon(PackedInt32Array([0, 1, 2, 3]))
@@ -377,7 +493,7 @@ func _build_marquee() -> void:
## a parked sedan, and wall lanterns. Kept separate so the placement validator ## a parked sedan, and wall lanterns. Kept separate so the placement validator
## only sees grounded sidewalk furniture. ## only sees grounded sidewalk furniture.
func _build_library_dressing() -> void: func _build_library_dressing() -> void:
if ResourceLoader.exists(BLENDER_PROP_DIR + "handrail.glb"): if _feature("handrail") and ResourceLoader.exists(BLENDER_PROP_DIR + "handrail.glb"):
var rail := _load_prop_scene("handrail") var rail := _load_prop_scene("handrail")
if rail != null: if rail != null:
rail.name = "LibraryHandrail" rail.name = "LibraryHandrail"
@@ -386,27 +502,146 @@ func _build_library_dressing() -> void:
root.add_child(rail) root.add_child(rail)
library_nodes.append(rail) library_nodes.append(rail)
_apply_library_ao(rail, "handrail") _apply_library_ao(rail, "handrail")
if ResourceLoader.exists(BLENDER_PROP_DIR + "sedan.glb"): if _feature("sedan") and ResourceLoader.exists(BLENDER_PROP_DIR + "sedan.glb"):
var sedan := _add_prop_model(root, "sedan", Vector3(6.0, 0.1, 17.0), 180.0) var sedan_pos: Vector3 = spec.get("sedan_position", Vector3(6.0, 0.1, 17.0))
var sedan_rot := float(spec.get("sedan_rotation", 180.0))
var sedan := _add_prop_model(root, "sedan", sedan_pos, sedan_rot)
sedan.name = "ParkedSedan" sedan.name = "ParkedSedan"
if venue_id == "tv_studio":
_apply_car_paint(sedan)
if not library_nodes.has(sedan): if not library_nodes.has(sedan):
library_nodes.append(sedan) library_nodes.append(sedan)
for material in _emissive_materials(sedan): for material in _emissive_materials(sedan):
if not _lamp_bulb_mats.has(material): if not _lamp_bulb_mats.has(material):
_lamp_bulb_mats.append(material) _lamp_bulb_mats.append(material)
if ResourceLoader.exists(BLENDER_PROP_DIR + "wall_lantern.glb"): if _feature("wall_lanterns") and ResourceLoader.exists(BLENDER_PROP_DIR + "wall_lantern.glb"):
for side in [-4.9, 4.9]: if venue_id == "joke_shop":
var lantern := _load_prop_scene("wall_lantern") _add_wall_lantern(Vector3(3.2, 3.3, -3.72), true)
if lantern == null: else:
continue for side in [-4.9, 4.9]:
lantern.name = "WallLantern" _add_wall_lantern(Vector3(side, 3.3, -3.78), false)
lantern.position = Vector3(side, 3.3, -3.78)
root.add_child(lantern) ## The TV studio entrance canopy: a dark metal slab with a row of warm bulbs,
library_nodes.append(lantern) ## replacing the boulevard marquee.
_apply_library_ao(lantern, "wall_lantern") func _build_bulb_canopy() -> void:
for material in _emissive_materials(lantern): var front := float(spec["facade_center_z"]) + float(spec["facade_depth"]) * 0.5
if not _lamp_bulb_mats.has(material): var canopy := _add_box(root, Vector3(7.2, 0.22, 2.2),
_lamp_bulb_mats.append(material) Vector3(0, 5.7, front + 1.0), Color(0.16, 0.16, 0.18),
true, null, Vector2.ONE, "metal")
canopy.name = "BulbCanopy"
for x in [-3.0, 3.0]:
_add_box(root, Vector3(0.12, 5.5, 0.12),
Vector3(x, 2.75, front + 1.95), Color(0.20, 0.20, 0.22),
true, null, Vector2.ONE, "metal")
for x in [-2.8, -1.4, 0.0, 1.4, 2.8]:
var bulb := MeshInstance3D.new()
var sphere := SphereMesh.new()
sphere.radius = 0.075
sphere.height = 0.15
bulb.mesh = sphere
var mat := _emissive_material(Color(1.0, 0.78, 0.42))
bulb.material_override = mat
bulb.position = Vector3(x, 5.53, front + 1.0)
root.add_child(bulb)
if not _marquee_emissive.has(mat):
_marquee_emissive.append(mat)
for x in [-2.2, 2.2]:
var omni := OmniLight3D.new()
omni.position = Vector3(x, 5.15, front + 1.4)
omni.omni_range = 12.0 if Materials.modern_light_falloff() else 8.0
omni.omni_attenuation = 0.0 if Materials.modern_light_falloff() else 1.0
omni.light_energy = 0.0
omni.light_color = Color(1.0, 0.72, 0.36)
root.add_child(omni)
_lamp_omni.append(omni)
## Glossy black paint for the studio's parked car. The library sedan keeps its
## tintable slots; the studio overrides the body meshes with a single paint.
func _apply_car_paint(holder: Node3D) -> void:
var paint := StandardMaterial3D.new()
paint.resource_name = "StudioCarPaint"
paint.albedo_color = Color(0.018, 0.020, 0.024)
paint.metallic = 0.85
paint.roughness = 0.12
paint.metallic_specular = 0.8
for node in holder.find_children("*", "MeshInstance3D", true, false):
var mi := node as MeshInstance3D
if mi != null:
mi.material_override = paint
## One wall lantern plus an optional warm light. The Joke Shop uses a single
## lantern as its key light; the boulevard keeps two unlit visual lanterns.
func _add_wall_lantern(pos: Vector3, with_light: bool) -> void:
var lantern := _load_prop_scene("wall_lantern")
if lantern == null:
return
lantern.name = "WallLantern"
lantern.position = pos
root.add_child(lantern)
library_nodes.append(lantern)
_apply_library_ao(lantern, "wall_lantern")
for material in _emissive_materials(lantern):
if not _lamp_bulb_mats.has(material):
_lamp_bulb_mats.append(material)
if with_light:
var omni := OmniLight3D.new()
omni.name = "LanternGlow"
omni.position = pos + Vector3(0.0, -0.1, 0.45)
omni.omni_range = 14.0 if Materials.modern_light_falloff() else 9.0
omni.omni_attenuation = 0.0 if Materials.modern_light_falloff() else 1.0
omni.light_energy = 0.0
omni.light_color = Color(1.0, 0.72, 0.38)
root.add_child(omni)
_lamp_omni.append(omni)
## The Joke Shop alley: two brick side walls frame a 12 m passage, with
## shutters, a recessed door step, a dumpster and wall-mounted AC boxes.
func _build_alley_dressing() -> void:
var front := float(spec["facade_center_z"]) + float(spec["facade_depth"]) * 0.5
var height := float(spec["facade_height"])
var wall_x := float(spec["facade_width"]) * 0.5 + 0.35
for side in [-1.0, 1.0]:
var wall := _add_box(root, Vector3(0.7, height, 15.0),
Vector3(side * wall_x, height * 0.5, front + 7.5), Color(0.72, 0.70, 0.68),
true, T_BRICK, Vector2(8, 6), "brick")
wall.name = "AlleyWall"
_add_box(root, Vector3(0.9, 0.35, 15.0),
Vector3(side * wall_x, 0.18, front + 7.5), Color(0.44, 0.42, 0.40),
true, null, Vector2.ONE, "stone")
# Shutters over the two upper windows.
for x in [-3.8, 3.8]:
_add_box(root, Vector3(2.0, 2.5, 0.12), Vector3(x, 8.2, front + 0.22),
Color(0.18, 0.24, 0.21), true, null, Vector2.ONE, "painted_metal")
for slat in range(-4, 5):
_add_box(root, Vector3(1.9, 0.07, 0.04),
Vector3(x, 8.2 + slat * 0.28, front + 0.30),
Color(0.24, 0.31, 0.27), false, null, Vector2.ONE, "painted_metal")
# The recessed door gets a low stone step into the alley.
_add_box(root, Vector3(3.4, 0.16, 0.55), Vector3(0, 0.08, front + 0.75),
Color(0.48, 0.45, 0.42), true, null, Vector2.ONE, "stone")
# Dumpster and its lid; a solid body so guests and the guard walk around it.
var dump := StaticBody3D.new()
dump.name = "AlleyDumpster"
dump.position = Vector3(-4.2, 0, front + 9.2)
_add_box(dump, Vector3(1.9, 1.15, 1.25), Vector3(0, 0.58, 0),
Color(0.16, 0.28, 0.21), true, null, Vector2.ONE, "painted_metal")
_add_box(dump, Vector3(2.0, 0.08, 1.35), Vector3(0, 1.18, -0.02),
Color(0.11, 0.19, 0.15), true, null, Vector2.ONE, "painted_metal")
var dump_col := BoxShape3D.new()
dump_col.size = Vector3(1.9, 1.2, 1.3)
var owner_id := dump.create_shape_owner(dump)
dump.shape_owner_add_shape(owner_id, dump_col)
dump.shape_owner_set_transform(owner_id, Transform3D(Basis(), Vector3(0, 0.6, 0)))
root.add_child(dump)
# AC boxes on the inside faces of the walls, above head height.
for side in [-1.0, 1.0]:
for z in [front + 3.0, front + 7.0]:
_add_box(root, Vector3(0.55, 0.8, 1.1),
Vector3(side * (wall_x - 0.5), 5.8, z),
Color(0.56, 0.57, 0.55), true, null, Vector2.ONE, "metal")
_add_box(root, Vector3(0.08, 0.55, 0.8),
Vector3(side * (wall_x - 0.78), 5.8, z),
Color(0.22, 0.23, 0.22), false, null, Vector2.ONE, "metal")
## Emissive materials under a prop, used to tie library bulbs/LEDs to night mode. ## Emissive materials under a prop, used to tie library bulbs/LEDs to night mode.
func _emissive_materials(holder: Node3D) -> Array[StandardMaterial3D]: func _emissive_materials(holder: Node3D) -> Array[StandardMaterial3D]:
@@ -426,26 +661,29 @@ func _build_street_furniture() -> void:
# Blender library street lamps (bulbs + omni lights, off by day). The lamp # Blender library street lamps (bulbs + omni lights, off by day). The lamp
# arm points to local -X, so the left side is rotated 180 degrees to face # arm points to local -X, so the left side is rotated 180 degrees to face
# the sidewalk. # the sidewalk.
for side in [-7.5, 7.5]: if _feature("street_lamps"):
var holder := _add_prop_model(root, "street_lamp", for side in [-7.5, 7.5]:
Vector3(side, 0.18, 8.5), 180.0 if side < 0.0 else 0.0) var holder := _add_prop_model(root, "street_lamp",
holder.name = "Lamp_%d" % int(side) Vector3(side, 0.18, 8.5), 180.0 if side < 0.0 else 0.0)
var omni := OmniLight3D.new() holder.name = "Lamp_%d" % int(side)
omni.position = Vector3(-0.85, 4.65, 0) var omni := OmniLight3D.new()
omni.omni_range = 12.0 omni.position = Vector3(-0.85, 4.65, 0)
omni.light_energy = 0.0 omni.omni_range = 16.0 if Materials.modern_light_falloff() else 12.0
omni.light_color = Color(1, 0.93, 0.75) omni.omni_attenuation = 0.0 if Materials.modern_light_falloff() else 1.0
holder.add_child(omni) omni.light_energy = 0.0
_lamp_omni.append(omni) omni.light_color = Color(1, 0.93, 0.75)
_collect_emissive(holder) holder.add_child(omni)
_lamp_omni.append(omni)
_collect_emissive(holder)
# Blender library street trees replace the cone trees. Flank the walkway and # Blender library street trees replace the cone trees. Flank the walkway and
# keep the sight line to the door clear. # keep the sight line to the door clear.
for i in 6: if _feature("street_trees"):
var side := -1.0 if i % 2 == 0 else 1.0 for i in 6:
var x := side * (6.5 + float(int(i / 2)) * 3.0) var side := -1.0 if i % 2 == 0 else 1.0
var z := 4.0 + float((i * 7) % 5) var x := side * (6.5 + float(int(i / 2)) * 3.0)
_add_prop_model(root, "street_tree", Vector3(x, 0.18, z), var z := 4.0 + float((i * 7) % 5)
float((i * 37) % 360)) _add_prop_model(root, "street_tree", Vector3(x, 0.18, z),
float((i * 37) % 360))
## Total triangles across the placed Blender library pieces, for the draw budget ## Total triangles across the placed Blender library pieces, for the draw budget
## test. Counts indices when present, otherwise the vertex stream. ## test. Counts indices when present, otherwise the vertex stream.
@@ -585,8 +823,9 @@ func _build_street_detail() -> void:
var detail := Node3D.new() var detail := Node3D.new()
detail.name = "StreetDetail" detail.name = "StreetDetail"
root.add_child(detail) root.add_child(detail)
for index in PROP_PLACEMENTS.size(): var placements: Array = spec.get("prop_placements", PROP_PLACEMENTS)
var entry: Dictionary = PROP_PLACEMENTS[index] for index in placements.size():
var entry: Dictionary = placements[index]
var base_rot := float(entry.get("rot", 0.0)) var base_rot := float(entry.get("rot", 0.0))
var variant := variant_for(index, base_rot) var variant := variant_for(index, base_rot)
var holder := _add_prop_model(detail, str(entry["prop"]), var holder := _add_prop_model(detail, str(entry["prop"]),
@@ -821,7 +1060,7 @@ func _build_guard_spawn() -> void:
# The guard's head offset is 1.6m, so spawn at y=0.76-1.6=-0.84 to place feet on ground # The guard's head offset is 1.6m, so spawn at y=0.76-1.6=-0.84 to place feet on ground
# But we want the guard at eye-level with the sidewalk, so adjust to y=0.1 for feet # But we want the guard at eye-level with the sidewalk, so adjust to y=0.1 for feet
# Then the head will be at y=1.7 (1.6 offset + 0.1 base) # Then the head will be at y=1.7 (1.6 offset + 0.1 base)
marker.position = Vector3(0, 0.1, -2.0) marker.position = spec.get("guard_spawn", Vector3(0, 0.1, -2.0))
root.add_child(marker) root.add_child(marker)
func _lock_indicator_material(color: Color) -> StandardMaterial3D: func _lock_indicator_material(color: Color) -> StandardMaterial3D:
+172 -5
View File
@@ -15,6 +15,7 @@ extends SceneTree
## --tint=res://assets/textures/skyline.png=1,1,1 \ ## --tint=res://assets/textures/skyline.png=1,1,1 \
## --normal=res://assets/textures/ai/game_ready/brick_facade.png=res://.../brick_facade_normal.png ## --normal=res://assets/textures/ai/game_ready/brick_facade.png=res://.../brick_facade_normal.png
## # Or capture only some stages: --stages=day,late ## # Or capture only some stages: --stages=day,late
## # Or feature one NPC role in the portrait: --portrait-type=2
## ##
## `--swap=OLD=NEW` replaces every StandardMaterial3D albedo texture loaded ## `--swap=OLD=NEW` replaces every StandardMaterial3D albedo texture loaded
## from OLD. `--tint=OLD=r,g,b` sets those materials' albedo colour. ## from OLD. `--tint=OLD=r,g,b` sets those materials' albedo colour.
@@ -23,6 +24,20 @@ extends SceneTree
## swaps can target the same material. Existing files in `--out` are never ## swaps can target the same material. Existing files in `--out` are never
## overwritten (a `-vN` suffix is added). ## overwritten (a `-vN` suffix is added).
## NPC roles that wear the hood prop (see NpcAvatar.ACCESSORIES); their faces are
## hidden, so they are poor portrait subjects. Indices follow `Rules.NpcType`.
const HOODED_TYPES := [5, 6, 7] # TRESPASSER, VIOLENT_THREAT, MENTAL_CRISIS
## Optional `--portrait-type=N` picks the NpcType a portrait should feature.
var _portrait_type := -1
## Optional `--flashlight` turns the guard lamp on for night stages; the desktop
## HQ A/B uses it to show the volumetric shaft.
var _flashlight := false
## Where the portrait subject is placed, relative to the guard's spawn.
var _portrait_spot := Vector3(0.0, 0.5, 9.0)
var _portrait_subject: Node3D = null
var _last_positions := {}
## Guard position/rotation per shot, matching the README capture poses. ## Guard position/rotation per shot, matching the README capture poses.
const SHOTS_DAY := [ const SHOTS_DAY := [
{"name": "entrance_day", "pos": Vector3(0, 0.1, 10.5), "yaw": 0.0, "pitch": 2.0}, {"name": "entrance_day", "pos": Vector3(0, 0.1, 10.5), "yaw": 0.0, "pitch": 2.0},
@@ -43,9 +58,17 @@ const SHOTS_JOKE := [
const SHOTS_STUDIO := [ const SHOTS_STUDIO := [
{"name": "studio_door", "pos": Vector3(0, 0.1, 6.2), "yaw": 0.0, "pitch": 2.0}, {"name": "studio_door", "pos": Vector3(0, 0.1, 6.2), "yaw": 0.0, "pitch": 2.0},
] ]
## A face-on portrait: staged relative to a live NPC by `_stage_portrait()`.
const SHOTS_PORTRAIT := [
{"name": "npc_portrait", "pos": Vector3.ZERO, "yaw": 0.0, "pitch": 0.0,
"portrait": true, "spot": Vector3(0.0, 0.5, 9.0)},
]
## Stage list: shift time selects the phase, `venue` switches the scenario first. ## Stage list: shift time selects the phase, `venue` switches the scenario first.
const STAGES := [ const STAGES := [
{"name": "day", "elapsed": 0.0, "venue": "", "shots": SHOTS_DAY, "night": false}, {"name": "day", "elapsed": 0.0, "venue": "", "shots": SHOTS_DAY, "night": false},
# Portrait next, while the day crowd is still on the street.
{"name": "portrait", "elapsed": 0.0, "venue": "", "shots": SHOTS_PORTRAIT,
"night": false},
{"name": "evening", "elapsed": 620.0, "venue": "", "shots": SHOTS_EVENING, "night": true}, {"name": "evening", "elapsed": 620.0, "venue": "", "shots": SHOTS_EVENING, "night": true},
{"name": "late", "elapsed": 760.0, "venue": "", "shots": SHOTS_LATE, "night": true}, {"name": "late", "elapsed": 760.0, "venue": "", "shots": SHOTS_LATE, "night": true},
{"name": "joke_shop", "elapsed": 700.0, "venue": "joke_shop", "shots": SHOTS_JOKE, "night": true}, {"name": "joke_shop", "elapsed": 700.0, "venue": "joke_shop", "shots": SHOTS_JOKE, "night": true},
@@ -92,6 +115,10 @@ func _parse_args() -> void:
elif text.begins_with("--stages="): elif text.begins_with("--stages="):
for name in text.substr(9).split(","): for name in text.substr(9).split(","):
_only_stages[name.strip_edges()] = true _only_stages[name.strip_edges()] = true
elif text.begins_with("--portrait-type="):
_portrait_type = int(text.substr(16))
elif text == "--flashlight":
_flashlight = true
func _pair(text: String, into: Dictionary) -> void: func _pair(text: String, into: Dictionary) -> void:
var cut := text.find("=", 6) # skip the "res://" scheme var cut := text.find("=", 6) # skip the "res://" scheme
@@ -100,7 +127,7 @@ func _pair(text: String, into: Dictionary) -> void:
return return
into[text.substr(0, cut)] = text.substr(cut + 1) into[text.substr(0, cut)] = text.substr(cut + 1)
func _process(_delta: float) -> bool: func _process(delta: float) -> bool:
frames += 1 frames += 1
if frames > MAX_FRAMES: if frames > MAX_FRAMES:
push_error("capture_realism timed out") push_error("capture_realism timed out")
@@ -126,7 +153,7 @@ func _process(_delta: float) -> bool:
if _phase != "done": if _phase != "done":
_phase = "shoot" _phase = "shoot"
"shoot": "shoot":
_run_queue() _run_queue(delta)
"done": "done":
print("capture_realism done -> %s" % out_dir) print("capture_realism done -> %s" % out_dir)
quit(0) quit(0)
@@ -142,9 +169,14 @@ func _begin_stage() -> void:
_phase = "done" _phase = "done"
return return
var stage: Dictionary = STAGES[_stage] var stage: Dictionary = STAGES[_stage]
if str(stage["venue"]) != "": var venue := str(stage["venue"])
gm.call("switch_scenario", str(stage["venue"])) if venue != "" and venue != str(gm.get("active_scenario_id")):
gm.call("switch_scenario", venue)
gm.coordinator.elapsed_s = float(stage["elapsed"]) gm.coordinator.elapsed_s = float(stage["elapsed"])
if _flashlight:
var want := bool(stage["night"])
if want != bool(gm.flashlight_on):
gm.call("_toggle_flashlight")
_apply(swaps) _apply(swaps)
if bool(stage["night"]): if bool(stage["night"]):
_apply(night_swaps) _apply(night_swaps)
@@ -152,11 +184,29 @@ func _begin_stage() -> void:
_shot_frames = 0 _shot_frames = 0
print("capture_realism: stage %s at %d ms" % [stage["name"], Time.get_ticks_msec()]) print("capture_realism: stage %s at %d ms" % [stage["name"], Time.get_ticks_msec()])
func _run_queue() -> void: func _run_queue(delta: float) -> void:
if _queue.is_empty(): if _queue.is_empty():
_phase = "stage" _phase = "stage"
return return
var shot: Dictionary = _queue[0] var shot: Dictionary = _queue[0]
var portrait := bool(shot.get("portrait", false))
if portrait:
# NPCs spawn over time; re-stage every frame until one exists, then shoot.
if shot.has("spot"):
_portrait_spot = shot["spot"]
if not _stage_portrait(delta, float(shot.get("angle", 0.0))):
_shot_frames += 1
if _shot_frames >= SETTLE_FRAMES * 10:
push_warning("capture_realism: no NPC for %s" % shot["name"])
_queue.pop_front()
_shot_frames = 0
return
_shot_frames += 1
if _shot_frames >= SETTLE_FRAMES:
_shoot(str(shot["name"]))
_queue.pop_front()
_shot_frames = 0
return
if _shot_frames == 0: if _shot_frames == 0:
gm.guard.global_position = shot["pos"] gm.guard.global_position = shot["pos"]
gm.guard.rotation.y = deg_to_rad(float(shot["yaw"])) gm.guard.rotation.y = deg_to_rad(float(shot["yaw"]))
@@ -167,6 +217,123 @@ func _run_queue() -> void:
_queue.pop_front() _queue.pop_front()
_shot_frames = 0 _shot_frames = 0
## Frame a nearby NPC from the front, with the sun behind the camera so the face
## is lit. The subject's own yaw is re-written every frame by its FSM, so the
## portrait turns the visual `Rig` child instead and stands on the sunlit side.
func _stage_portrait(delta: float, angle_deg: float = 0.0) -> bool:
var guard_pos: Vector3 = gm.guard.global_position
var light := _sun_direction()
var speeds := _speeds(delta)
var best: Node3D = null
var best_distance := 999.0
for node in get_nodes_in_group("npc"):
var npc := node as Node3D
if npc == null or _faces_covered(npc):
continue
if _portrait_type >= 0 and int(_profile_of(npc).get("npc_type")) != _portrait_type:
continue
var distance: float = npc.global_position.distance_to(guard_pos)
if distance > 25.0:
continue
# Close, standing still, and easy to reach: the crowd keeps moving.
var score: float = distance / 25.0
if float(speeds.get(npc.get_instance_id(), 9.9)) < 0.05:
score -= 0.5
if not bool(npc.get("ephemeral")):
score -= 0.2
if best == null or score < best_distance:
best = npc
best_distance = score
if best == null:
return false
if _portrait_subject != best:
_portrait_subject = best
# Park the rest of the crowd far away: a close-up is a character still and
# a passer-by walking through the frame would block the subject.
for node in get_nodes_in_group("npc"):
var other := node as Node3D
if other == null or other == best:
continue
other.set_physics_process(false)
other.global_position += Vector3(0.0, 0.0, 140.0)
# Freeze the subject: its FSM re-writes the yaw every physics tick, which
# would turn it away from the camera again.
best.set_physics_process(false)
# Clear standing spot on the sidewalk, away from the queue: the close-up is a
# character still, not a slice of the scene, and the crowd would block it.
var spot := Vector3(0.0, 0.0, 0.0)
if gm.guard.get("spawn_position") != null:
spot = gm.guard.get("spawn_position")
best.global_position = spot + _portrait_spot
best.rotation.y = 0.0
# Stand between the subject and the sun, so the face is front-lit.
var to_light := -light
to_light = to_light.rotated(Vector3.UP, deg_to_rad(angle_deg))
gm.guard.global_position = best.global_position + to_light * 1.3
gm.guard.global_position.y = 0.1
var look: Vector3 = best.global_position - gm.guard.global_position
look.y = 0.0
gm.guard.rotation.y = atan2(-look.x, -look.z)
gm.guard.head.rotation.x = deg_to_rad(1.0)
# The focus disc is a gameplay overlay, not part of the character still.
for focus in best.find_children("FocusCircle", "MeshInstance3D", true, false):
(focus as MeshInstance3D).visible = false
# Face the camera: from the subject, the guard lies in the -look direction.
# Use the drawn rig so an FSM yaw on the root cannot fight the pose.
var rig := best.find_child("Rig", true, false) as Node3D
if rig != null:
rig.rotation.y = atan2(-look.x, -look.z)
else:
best.rotation.y = atan2(-look.x, -look.z)
return true
## Per-instance walking speed in m/s, from the position delta of the last frame.
func _speeds(delta: float) -> Dictionary:
var now := {}
var speeds := {}
for node in get_nodes_in_group("npc"):
var npc := node as Node3D
if npc == null:
continue
var id := npc.get_instance_id()
now[id] = npc.global_position
if _last_positions.has(id) and delta > 0.0:
speeds[id] = npc.global_position.distance_to(_last_positions[id]) / delta
_last_positions = now
return speeds
## Horizontal direction the sun light comes from (the way the light travels).
func _sun_direction() -> Vector3:
for node in root.find_children("*", "DirectionalLight3D", true, false):
var sun := node as DirectionalLight3D
if sun == null or not sun.visible:
continue
var dir := -sun.global_transform.basis.z
dir.y = 0.0
if dir.length() > 0.01:
return dir.normalized()
return Vector3(0, 0, 1)
## True when the avatar wears something over its face (see NpcAvatar.ACCESSORIES).
func _profile_of(npc: Node3D) -> Variant:
var brain = npc.get("npc")
return brain.get("profile") if brain != null else null
func _faces_covered(npc: Node3D) -> bool:
var brain = npc.get("npc")
if brain == null:
return false
var profile = brain.get("profile")
if profile == null:
return false
var npc_type := int(profile.get("npc_type"))
# An explicit --portrait-type is a deliberate request to inspect headgear,
# including a hood; the default crowd selection still avoids covered faces.
if _portrait_type >= 0 and npc_type == _portrait_type:
return false
return npc_type in HOODED_TYPES
## Apply swaps, tints and normals to every material whose original albedo path ## Apply swaps, tints and normals to every material whose original albedo path
## matches. Called once per stage; materials are visited through the tree. ## matches. Called once per stage; materials are visited through the tree.
func _apply(table: Dictionary) -> void: func _apply(table: Dictionary) -> void:
+34 -2
View File
@@ -90,11 +90,13 @@ func _advance() -> void:
4: 4:
if state_frames >= 30: if state_frames >= 30:
_shoot("04-radio-report.png") _shoot("04-radio-report.png")
# Close the radio form, then jump the shift to the LATE phase. # Close the radio form, then jump the shift into LATE night: the
# avenue and flashlight shots are night scenes (the old 620 s put
# them in dusk, where the flashlight had nothing to cut through).
if gm != null: if gm != null:
gm.radio_panel.hide() gm.radio_panel.hide()
gm.hud.set_action_hint("") gm.hud.set_action_hint("")
gm.coordinator.elapsed_s = 620.0 gm.coordinator.elapsed_s = 760.0
state = 5 state = 5
state_frames = 0 state_frames = 0
5: 5:
@@ -205,6 +207,36 @@ func _advance() -> void:
_shoot("11-precision-walk.png") _shoot("11-precision-walk.png")
Input.action_release("move_forward") Input.action_release("move_forward")
Input.action_release("walk_slow") Input.action_release("walk_slow")
if gm != null:
gm.switch_scenario("joke_shop")
gm.coordinator.elapsed_s = 620.0
state = 12
state_frames = 0
12:
if state_frames == 1 and gm != null:
gm.guard.global_position = Vector3(-1.5, 0.1, 8.5)
gm.guard.rotation.y = deg_to_rad(-5.0)
gm.guard.head.rotation.x = deg_to_rad(2.0)
gm.hud.clear_log()
gm.hud.set_escalation(0)
gm.hud.set_body_camera(false)
if state_frames >= 90:
_shoot("12-joke-shop.png")
if gm != null:
gm.switch_scenario("tv_studio")
gm.coordinator.elapsed_s = 620.0
state = 13
state_frames = 0
13:
if state_frames == 1 and gm != null:
gm.guard.global_position = Vector3(0, 0.1, 14.0)
gm.guard.rotation.y = 0.0
gm.guard.head.rotation.x = deg_to_rad(2.0)
gm.hud.clear_log()
gm.hud.set_escalation(0)
gm.hud.set_body_camera(false)
if state_frames >= 120:
_shoot("13-studio-arrival.png")
quit(0) quit(0)
## Put the guard a couple of steps in front of the target so a staged UI capture ## Put the guard a couple of steps in front of the target so a staged UI capture
@@ -28,6 +28,27 @@ func test_unlocked_door_admits_force() -> bool:
var b := TestUtils.check_eq(result["outcome"], "entered", "entered outcome") var b := TestUtils.check_eq(result["outcome"], "entered", "entered outcome")
return a and b return a and b
func test_flashlight_aims_a_tight_forward_spot() -> bool:
## The lamp sits on the guard's head and aims straight down the view axis with
## a narrow cone: a wide cone washed the whole entrance instead of lighting the
## door being checked (realism pass, 2026-11-01).
var root := Node3D.new()
var gm: Node = load("res://src/game/game_manager.gd").new()
root.add_child(gm)
var guard: Node3D = gm.call("_build_guard")
var lamp: SpotLight3D = guard.get_node_or_null("Head/Flashlight") as SpotLight3D
var a := TestUtils.check(lamp != null, "flashlight lives on the guard's head")
var b := TestUtils.check(lamp != null and lamp.rotation_degrees.is_equal_approx(Vector3.ZERO),
"flashlight aims straight ahead")
var c := TestUtils.check(lamp != null and lamp.spot_angle <= 20.0,
"flashlight cone is narrow enough to read as a torch")
var d := TestUtils.check(lamp != null and not lamp.visible, "flashlight starts off")
var e := TestUtils.check(lamp != null
and is_zero_approx(lamp.light_volumetric_fog_energy),
"headless/compatibility builds leave the flashlight non-volumetric")
root.free()
return a and b and c and d and e
func test_flashlight_widens_perception() -> bool: func test_flashlight_widens_perception() -> bool:
## Without the flashlight, an NPC at AWARE_RADIUS + 3 m is unaware. ## Without the flashlight, an NPC at AWARE_RADIUS + 3 m is unaware.
## With the flashlight bonus (4 m), the same distance registers as aware. ## With the flashlight bonus (4 m), the same distance registers as aware.
+38
View File
@@ -0,0 +1,38 @@
extends RefCounted
## Regression tests for the first-person mouse-look path and non-blocking HUD.
const GameManager := preload("res://src/game/game_manager.gd")
const GuardController := preload("res://src/game/guard_controller.gd")
const Hud := preload("res://src/game/ui/hud.gd")
func test_mouse_look_rotates_yaw_and_pitch() -> bool:
var root := Node3D.new()
var gm: Node = GameManager.new()
var guard: GuardController = gm.call("_build_guard")
guard._ready()
var yaw0 := guard.rotation.y
var pitch0 := guard.head.rotation.x
guard._apply_mouse_look(Vector2(100, 50))
var a := TestUtils.check(guard.rotation.y < yaw0 - 0.1, "horizontal motion yaws the guard")
var b := TestUtils.check(guard.head.rotation.x < pitch0 - 0.1, "vertical motion pitches the head")
guard._apply_mouse_look(Vector2(0, 100000))
var c := TestUtils.check(absf(rad_to_deg(guard.head.rotation.x)) <= 80.01,
"pitch stays clamped")
guard.free()
gm.free()
root.free()
return a and b and c
func test_hud_does_not_capture_mouse_input() -> bool:
var hud: Hud = Hud.new()
var root := hud.get_child(0) as Control
var a := TestUtils.check(root != null and root.mouse_filter == Control.MOUSE_FILTER_IGNORE,
"full-screen HUD root ignores mouse input")
var labels := hud.find_children("*", "Label", true, false)
var blocking := 0
for node in labels:
if (node as Control).mouse_filter != Control.MOUSE_FILTER_IGNORE:
blocking += 1
var b := TestUtils.check(blocking == 0, "HUD labels do not swallow mouse motion")
hud.free()
return a and b
+7 -2
View File
@@ -77,14 +77,19 @@ func test_environment_tuning_applies_phase_values() -> bool:
var f := TestUtils.check( var f := TestUtils.check(
env.fog_light_color.is_equal_approx(late["fog_color"]), env.fog_light_color.is_equal_approx(late["fog_color"]),
"phase fog colour is used") "phase fog colour is used")
return a and b and c and d and e and f var advanced := Materials.forward_plus_active()
var g := TestUtils.check(env.volumetric_fog_enabled == advanced,
"volumetric fog is gated to Forward+ (advanced=%s)" % advanced)
var h := TestUtils.check(env.sdfgi_enabled == advanced,
"SDFGI is gated to Forward+ (advanced=%s)" % advanced)
return a and b and c and d and e and f and g and h
func test_every_phase_has_environment_keys() -> bool: func test_every_phase_has_environment_keys() -> bool:
var ok := true var ok := true
for phase in [Rules.Phase.EARLY, Rules.Phase.MIDDAY, Rules.Phase.EVENING, Rules.Phase.LATE]: for phase in [Rules.Phase.EARLY, Rules.Phase.MIDDAY, Rules.Phase.EVENING, Rules.Phase.LATE]:
var cfg: Dictionary = GameManager.PHASE_LIGHTING.get(phase, {}) var cfg: Dictionary = GameManager.PHASE_LIGHTING.get(phase, {})
for key in ["exposure", "glow", "fog_color", "fog_density", "fog_energy", for key in ["exposure", "glow", "fog_color", "fog_density", "fog_energy",
"contrast", "saturation"]: "contrast", "saturation", "volumetric_density", "volumetric_energy"]:
if not cfg.has(key): if not cfg.has(key):
ok = false ok = false
return TestUtils.check(ok, "all phases set exposure/glow/fog/grading") return TestUtils.check(ok, "all phases set exposure/glow/fog/grading")
+62
View File
@@ -191,6 +191,68 @@ func test_library_gear_attaches_to_the_contract_points() -> bool:
responder.free() responder.free()
return a and b and c and d return a and b and c and d
## Horizontal bounds of a gear GLB in its attach-node space. The head/back
## contract is measured against the Blender fit report (head width 0.315 m,
## widest body silhouette 0.26 m); this catches a gear shrinking back inside
## the body or a cap sinking below the brow.
func _gear_bounds(node: Node3D) -> AABB:
var box := AABB()
var first := true
for child in node.find_children("*", "MeshInstance3D", true, false):
var mi := child as MeshInstance3D
var b: AABB = mi.get_aabb()
# Gear meshes have no extra transform below the attach node.
if first:
box = b
first = false
else:
box = box.merge(b)
return box
func test_head_and_back_gear_clear_the_body_silhouette() -> bool:
const HEAD_WIDTH := 0.315
const WIDEST_BODY_RADIUS := 0.26
var problems: Array[String] = []
var seen := {}
for npc_type in [Rules.NpcType.TOURIST, Rules.NpcType.INFLUENCER,
Rules.NpcType.RESPONDER, Rules.NpcType.PERFORMER,
Rules.NpcType.DELIVERY, Rules.NpcType.TRESPASSER]:
var avatar := _avatar(npc_type, 5)
for child in avatar.find_children("Gear_*", "Node3D", true, false):
var gear := child as Node3D
var name := gear.name.trim_prefix("Gear_")
var attach := str(NpcAvatar.GEAR_ATTACH.get(name, ""))
if attach != "head" and attach != "back":
continue
seen[attach + ":" + name] = true
var box := _gear_bounds(gear)
if attach == "head":
var parent_name := gear.get_parent().name
if parent_name != "Head":
problems.append("%s is not under Head" % name)
if box.size.x > HEAD_WIDTH * 1.70:
problems.append("%s is %.3f m wide (head %.3f)" % [
name, box.size.x, HEAD_WIDTH])
# The hood wraps the neck; all other headgear must start on the skull.
if name != "hood" and box.position.y < 0.0:
problems.append("%s starts %.3f m below the head origin" % [
name, box.position.y])
else:
if gear.get_parent().name != "Rig":
problems.append("%s is not under Rig" % name)
var outer_x := maxf(absf(box.position.x), absf(box.end.x))
var outer_z := maxf(absf(box.position.z), absf(box.end.z))
if maxf(outer_x, outer_z) < WIDEST_BODY_RADIUS:
problems.append("%s only reaches %.3f m (body %.3f)" % [
name, maxf(outer_x, outer_z), WIDEST_BODY_RADIUS])
avatar.free()
for required in ["head:sunglasses", "head:firefighter_helmet", "head:cap",
"back:shoulder_bag", "back:backpack", "back:scba_tank"]:
if not seen.has(required):
problems.append("missing %s" % required)
return TestUtils.check(problems.is_empty(),
"head/back gear clears the body silhouette (%s)" % str(problems))
func test_fixed_detail_materials_are_not_tinted() -> bool: func test_fixed_detail_materials_are_not_tinted() -> bool:
var avatar := _avatar(Rules.NpcType.RESPONDER, 3) var avatar := _avatar(Rules.NpcType.RESPONDER, 3)
var mi := avatar.find_child("npc_base", true, false) as MeshInstance3D var mi := avatar.find_child("npc_base", true, false) as MeshInstance3D
+49
View File
@@ -0,0 +1,49 @@
extends RefCounted
## Deterministic headless tests for the first-shift tutorial state machine.
const Tutorial := preload("res://src/game/tutorial.gd")
func test_tutorial_advances_only_on_the_expected_action() -> bool:
var tutorial := Tutorial.new()
tutorial.start()
var a := TestUtils.check(tutorial.current()["id"] == "arrive", "starts at arrival")
var b := TestUtils.check(not tutorial.advance("wheel_opened"), "wrong action is ignored")
var c := TestUtils.check(tutorial.advance("moved"), "movement advances")
var d := TestUtils.check(tutorial.current()["id"] == "focus", "moves to focus")
var e := TestUtils.check(tutorial.advance("cycled"), "target cycling advances")
var f := TestUtils.check(tutorial.current()["id"] == "wheel", "moves to the action wheel")
return a and b and c and d and e and f
func test_tutorial_runs_all_beats_and_completes() -> bool:
var tutorial := Tutorial.new()
tutorial.start()
var events := ["moved", "cycled", "wheel_opened", "inspected",
"polite_direction", "formal_warning", "radio_submitted", "review_opened"]
var ok := true
for event in events:
ok = ok and tutorial.advance(event)
ok = ok and tutorial.is_complete()
ok = ok and not tutorial.is_skipped()
ok = ok and tutorial.progress() == Vector2i(Tutorial.BEATS.size(), Tutorial.BEATS.size())
return TestUtils.check(ok, "all eight beats complete in order")
func test_tutorial_accepts_body_camera_for_warning() -> bool:
var tutorial := Tutorial.new()
tutorial.start()
for event in ["moved", "cycled", "wheel_opened", "inspected", "polite_direction"]:
tutorial.advance(event)
var a := TestUtils.check(tutorial.advance("body_camera"), "body camera satisfies warning")
var b := TestUtils.check(tutorial.current()["id"] == "radio", "advances to radio")
return a and b
func test_tutorial_skip_and_restart() -> bool:
var tutorial := Tutorial.new()
tutorial.start()
var a := TestUtils.check(tutorial.advance("moved"), "one beat completed")
tutorial.skip()
var b := TestUtils.check(tutorial.is_complete() and tutorial.is_skipped(), "skip completes tutorial")
var c := TestUtils.check(not tutorial.advance("moved"), "completed tutorial is inert")
tutorial.restart()
var d := TestUtils.check(not tutorial.is_complete() and not tutorial.is_skipped(), "restart clears flags")
var e := TestUtils.check(tutorial.current()["id"] == "arrive", "restart returns to beat one")
return a and b and c and d and e
+65
View File
@@ -19,6 +19,71 @@ func test_navmesh_built() -> bool:
root.free() root.free()
return a and b return a and b
func test_venue_geometry_specs_are_distinct_and_defaulted() -> bool:
var hollywood := WorldBuilder.new(Node3D.new())
var joke := WorldBuilder.new(Node3D.new(), "joke_shop")
var studio := WorldBuilder.new(Node3D.new(), "tv_studio")
var unknown := WorldBuilder.new(Node3D.new(), "not_a_venue")
var a := TestUtils.check(hollywood.venue_id == "hollywood_building",
"the default venue is Hollywood")
var b := TestUtils.check(float(hollywood.spec["facade_width"]) > float(joke.spec["facade_width"]),
"the Joke Shop alley is narrower than the boulevard")
var c := TestUtils.check(not bool(joke.spec["features"]["skyline"])
and not bool(joke.spec["features"]["marquee"]),
"the Joke Shop disables the skyline and marquee")
var d := TestUtils.check(bool(studio.spec["features"]["bulb_canopy"])
and not bool(studio.spec["features"]["marquee"]),
"the studio uses a bulb canopy instead of the marquee")
var e := TestUtils.check(unknown.venue_id == "hollywood_building",
"an unknown venue falls back to Hollywood")
var root := Node3D.new()
var builder := WorldBuilder.new(root, "joke_shop")
builder.build()
var body := root.find_child("Building", true, false)
var facade := body.get_child(0) as MeshInstance3D if body != null else null
var f := TestUtils.check(facade != null and (facade.mesh as BoxMesh).size.x < 20.0,
"the Joke Shop builds the narrow facade")
var g := TestUtils.check(root.find_child("SkylineBackdrop", true, false) == null,
"the Joke Shop builds no skyline ring")
hollywood.root.free()
joke.root.free()
studio.root.free()
unknown.root.free()
root.free()
return a and b and c and d and e and f and g
func test_navigation_mesh_matches_each_venue_bounds() -> bool:
var problems: Array[String] = []
for id in ["hollywood_building", "joke_shop", "tv_studio"]:
var root := Node3D.new()
var builder := WorldBuilder.new(root, id)
builder.build()
var region := root.find_child("NavRegion", true, false) as NavigationRegion3D
if region == null or region.navigation_mesh == null:
problems.append("%s has no navmesh" % id)
root.free()
continue
var verts := region.navigation_mesh.get_vertices()
var nav_min: Vector2 = builder.spec["nav_min"]
var nav_max: Vector2 = builder.spec["nav_max"]
var min_x := 999.0
var max_x := -999.0
var min_z := 999.0
var max_z := -999.0
for v in verts:
min_x = minf(min_x, v.x)
max_x = maxf(max_x, v.x)
min_z = minf(min_z, v.z)
max_z = maxf(max_z, v.z)
if absf(min_x - nav_min.x) > 0.01 or absf(max_x - nav_max.x) > 0.01 \
or absf(min_z - nav_min.y) > 0.01 or absf(max_z - nav_max.y) > 0.01:
problems.append("%s navmesh %s does not match spec %s" % [
id, str(Vector4(min_x, max_x, min_z, max_z)),
str(Vector4(nav_min.x, nav_max.x, nav_min.y, nav_max.y))])
root.free()
return TestUtils.check(problems.is_empty(),
"each venue builds its own navigation bounds (%s)" % str(problems))
func test_phase_lighting_config_covered() -> bool: func test_phase_lighting_config_covered() -> bool:
var ok := true var ok := true
for phase in [Rules.Phase.EARLY, Rules.Phase.MIDDAY, Rules.Phase.EVENING, Rules.Phase.LATE]: for phase in [Rules.Phase.EARLY, Rules.Phase.MIDDAY, Rules.Phase.EVENING, Rules.Phase.LATE]:
+251 -21
View File
@@ -14,6 +14,7 @@ integration: nothing in the game loads them yet (see
--python "$PWD/tools/blender_assets.py" -- render --python "$PWD/tools/blender_assets.py" -- render
# outside Blender (stdlib + Pillow) # outside Blender (stdlib + Pillow)
python3 tools/blender_assets.py audit # GLB contract checks python3 tools/blender_assets.py audit # GLB contract checks
python3 tools/blender_assets.py fit # gear-vs-body fit measurements
python3 tools/blender_assets.py sheet # sheet_<category>.jpg (committed) python3 tools/blender_assets.py sheet # sheet_<category>.jpg (committed)
python3 tools/blender_assets.py list python3 tools/blender_assets.py list
@@ -48,7 +49,11 @@ SLOTS = ["skin", "cloth", "pants", "accessory", "hair"]
BONES = ["Head", "HipL", "HipR", "KneeL", "KneeR", "ShoulderL", "ShoulderR", BONES = ["Head", "HipL", "HipR", "KneeL", "KneeR", "ShoulderL", "ShoulderR",
"ElbowL", "ElbowR", "HandL", "HandR"] "ElbowL", "ElbowR", "HandL", "HandR"]
CHAR_MAX_TRIS = 6000 CHAR_MAX_TRIS = 6000
CHAR_MAX_VERTS = 4000 # Raised from 4000 to 5000 for the 2026-11-01 face/hand detail pass. The glTF
# vertex count is after UV-seam splits, so the extra eyes, lids, cheeks, nose and
# fingers cost ~700 verts per variant; `smoke_npc_budget.gd` measured 16 avatars at
# ~6.9 ms (budget 50 ms), and each avatar is still a single skinned mesh.
CHAR_MAX_VERTS = 5000
GEAR_MAX_TRIS = 1500 GEAR_MAX_TRIS = 1500
PROP_MAX_TRIS = 4000 PROP_MAX_TRIS = 4000
@@ -100,6 +105,17 @@ GEAR_ATTACH = {
# Same attach points in Blender coordinates (Godot (x, y, z) -> Blender (x, -z, y)). # Same attach points in Blender coordinates (Godot (x, y, z) -> Blender (x, -z, y)).
ATTACH_BLENDER = {"head": (0.0, 0.0, 1.62), "hand": (0.27, -0.02, 0.78), ATTACH_BLENDER = {"head": (0.0, 0.0, 1.62), "hand": (0.27, -0.02, 0.78),
"back": (0.0, 0.0, 1.10), "chest": (0.0, 0.0, 1.30)} "back": (0.0, 0.0, 1.10), "chest": (0.0, 0.0, 1.30)}
# Attach points in the authored GLB space (Godot coordinates), used by `fit`.
ATTACH_GODOT = {"head": (0.0, 1.62, 0.0), "hand": (0.27, 0.78, 0.02),
"back": (0.0, 1.10, 0.0), "chest": (0.0, 1.30, 0.0)}
HEAD_BAND = (1.55, 1.81) # face/headgear height band in Godot metres
FIT_HEAD_MATERIALS = ("skin",)
FIT_CLOTHING_MATERIAL = "cloth"
FIT_CLOTHING_BAND = (0.30, 0.52) # hip/skirt band; captures coat/dress flare
FIT_HIP_BAND = (0.55, 0.95) # fallback body radius for trousers/vests
# Side bags include a strap that crosses the torso, so the full gear bbox cannot
# tell where the bag body starts. Measure the body-only y band for this fit.
FIT_BODY_BANDS = {"shoulder_bag": (-0.34, -0.16)}
GEAR = { GEAR = {
"cap": "head", "beanie": "head", "hood": "head", "sunglasses": "head", "cap": "head", "beanie": "head", "hood": "head", "sunglasses": "head",
"firefighter_helmet": "head", "firefighter_helmet": "head",
@@ -209,6 +225,7 @@ PALETTE = {
"undershirt": ((60, 62, 68), 0.9, 0.0, 0.0), "undershirt": ((60, 62, 68), 0.9, 0.0, 0.0),
"uniform": ((30, 34, 46), 0.85, 0.0, 0.0), "uniform": ((30, 34, 46), 0.85, 0.0, 0.0),
"patch": ((190, 150, 60), 0.6, 0.2, 0.0), "patch": ((190, 150, 60), 0.6, 0.2, 0.0),
"eye_white": ((236, 233, 226), 0.45, 0.0, 0.0),
} }
_MATS = {} _MATS = {}
@@ -545,7 +562,7 @@ def _skin_body(spec):
sub = obj.modifiers.new("Sub", "SUBSURF") sub = obj.modifiers.new("Sub", "SUBSURF")
sub.levels = 2 sub.levels = 2
dec = obj.modifiers.new("Decimate", "DECIMATE") dec = obj.modifiers.new("Decimate", "DECIMATE")
dec.ratio = 0.36 dec.ratio = 0.26
dec.use_symmetry = True dec.use_symmetry = True
dec.symmetry_axis = "X" dec.symmetry_axis = "X"
_apply_all(obj) _apply_all(obj)
@@ -702,22 +719,47 @@ def _hood(name, mat, head_center):
def _face(mats, spec): def _face(mats, spec):
"""Head with readable eyes, brow ridge, nose, lips and ears.
At conversation distance the eye region carries the read, so each eye gets a
white sclera, a dark iris and an upper lid, and the face gets cheekbones and
a nose bridge instead of a single cone (realism pass detail work).
"""
skin, acc, hair = mats["skin"], mats["accessory"], mats["hair"] skin, acc, hair = mats["skin"], mats["accessory"], mats["hair"]
white = material("eye_white", textured_solid=True)
parts = [sphere("Head", 0.155, (0, 0, 1.635), skin, seg=18, rings=12, parts = [sphere("Head", 0.155, (0, 0, 1.635), skin, seg=18, rings=12,
scale=(0.92, 1.0, 1.12))] scale=(0.92, 1.0, 1.12))]
# jaw / chin softens the egg into a face # jaw / chin and cheekbones soften the egg into a face
parts.append(sphere("Jaw", 0.10, (0, -0.04, 1.56), skin, seg=12, rings=8, parts.append(sphere("Jaw", 0.10, (0, -0.04, 1.556), skin, seg=12, rings=8,
scale=(1.0, 1.0, 0.8))) scale=(1.0, 1.0, 0.8)))
parts.append(cyl("Nose", 0.022, 0.006, 0.05, (0, -0.158, 1.632), skin, seg=6,
rot=(math.radians(-80), 0, 0), smooth=True))
for s in (-1.0, 1.0): for s in (-1.0, 1.0):
parts.append(sphere("Ear", 0.035, (0.142 * s, 0.01, 1.63), skin, seg=8, rings=5, parts.append(sphere("Cheek", 0.034, (0.076 * s, -0.104, 1.606), skin, seg=8,
scale=(0.45, 1.0, 1.35))) rings=5, scale=(1.0, 0.7, 0.8)))
parts.append(sphere("Eye", 0.017, (0.05 * s, -0.142, 1.665), acc, seg=8, rings=5, # nose: bridge and ball (nostrils were cut to stay inside the vertex budget)
scale=(1.2, 0.6, 0.9))) parts.append(cyl("NoseBridge", 0.016, 0.022, 0.06, (0, -0.145, 1.652), skin, seg=8,
parts.append(box("Brow", (0.05, 0.012, 0.011), (0.05 * s, -0.146, 1.70), hair, rot=(math.radians(-72), 0, 0), smooth=True))
rot=(0, 0.12 * s, 0.0))) parts.append(sphere("NoseTip", 0.024, (0, -0.163, 1.616), skin, seg=10, rings=6,
parts.append(box("Mouth", (0.06, 0.012, 0.01), (0, -0.142, 1.585), acc)) scale=(1.0, 0.9, 0.85)))
for s in (-1.0, 1.0):
# ear with a lobe, and one crease so it is not a flat disc
parts.append(sphere("Ear", 0.036, (0.143 * s, 0.008, 1.63), skin, seg=8, rings=5,
scale=(0.42, 1.0, 1.35)))
parts.append(sphere("EarLobe", 0.018, (0.142 * s, -0.005, 1.605), skin, seg=8,
rings=5, scale=(0.45, 1.0, 0.9)))
# eye: sclera, iris, upper lid and brow
ex, ey, ez = 0.052 * s, -0.138, 1.664
parts.append(sphere("EyeWhite", 0.019, (ex, ey, ez), white, seg=8, rings=5,
scale=(1.25, 0.7, 0.85)))
parts.append(sphere("Iris", 0.0105, (ex, ey - 0.013, ez), acc, seg=8, rings=5,
scale=(1.0, 0.55, 1.0)))
parts.append(box("Lid", (0.038, 0.010, 0.008), (ex, ey + 0.004, ez + 0.014),
skin, rot=(math.radians(-10), 0, 0)))
parts.append(box("Brow", (0.055, 0.014, 0.012), (0.052 * s, -0.143, 1.699), hair,
rot=(0, 0.14 * s, 0.0)))
# lips: upper and lower instead of a single bar
parts.append(box("LipUpper", (0.052, 0.012, 0.008), (0, -0.145, 1.588), acc,
rot=(math.radians(6), 0, 0)))
parts.append(box("LipLower", (0.046, 0.012, 0.009), (0, -0.143, 1.578), acc))
if spec.get("beard"): if spec.get("beard"):
parts.append(sphere("Beard", 0.105, (0, -0.06, 1.55), hair, seg=12, rings=8, parts.append(sphere("Beard", 0.105, (0, -0.06, 1.55), hair, seg=12, rings=8,
scale=(1.0, 0.9, 0.75), cut_below=-0.2)) scale=(1.0, 0.9, 0.75), cut_below=-0.2))
@@ -733,8 +775,14 @@ def _hands_and_shoes(mats, spec):
x = 0.275 * s * w x = 0.275 * s * w
parts.append(sphere("Palm", 0.045, (x, -0.01, 0.735), skin, seg=10, rings=6, parts.append(sphere("Palm", 0.045, (x, -0.01, 0.735), skin, seg=10, rings=6,
scale=(0.55, 0.95, 1.25))) scale=(0.55, 0.95, 1.25)))
parts.append(sphere("Thumb", 0.018, (x - 0.01 * s, -0.045, 0.75), skin, seg=8, parts.append(sphere("Thumb", 0.018, (x - 0.014 * s, -0.048, 0.752), skin,
rings=6, scale=(1.0, 1.0, 1.8), rot=(0.4, 0, 0))) seg=8, rings=6, scale=(1.0, 1.0, 1.7), rot=(0.4, 0, 0)))
# four fingers: relaxed, slightly curled forward, outer ones shorter
for index, offset in enumerate((-0.024, -0.008, 0.008, 0.024)):
length = (0.046, 0.052, 0.050, 0.040)[index]
parts.append(cyl("Finger", 0.011, 0.009, length,
(x + offset * s, -0.030, 0.714 - length * 0.5), skin,
seg=6, rot=(math.radians(14), 0, 0)))
parts.append(box("Shoe", (0.10, 0.25, 0.075), (0.12 * s, -0.045, 0.04), shoe, parts.append(box("Shoe", (0.10, 0.25, 0.075), (0.12 * s, -0.045, 0.04), shoe,
bevel=0.028, segs=1)) bevel=0.028, segs=1))
parts.append(box("Sole", (0.106, 0.262, 0.02), (0.12 * s, -0.045, 0.01), parts.append(box("Sole", (0.106, 0.262, 0.02), (0.12 * s, -0.045, 0.01),
@@ -769,6 +817,10 @@ def _clothing(spec, mats):
mat = cloth mat = cloth
parts.append(torus("Collar", 0.075, 0.025, (0, 0.005, 1.455), mat, parts.append(torus("Collar", 0.075, 0.025, (0, 0.005, 1.455), mat,
rot=(math.radians(8), 0, 0), seg=16)) rot=(math.radians(8), 0, 0), seg=16))
# sleeve cuffs at the wrists so the arm does not end in bare skin
for s in (-1.0, 1.0):
parts.append(torus("Cuff", 0.045, 0.012, (0.275 * s, -0.008, 0.80), mat,
seg=12, minor_seg=4))
parts.append(box("Placket", (0.018, 0.01, 0.52), (0, -0.128, 1.13), acc)) parts.append(box("Placket", (0.018, 0.01, 0.52), (0, -0.128, 1.13), acc))
if layer == "vest": if layer == "vest":
hv = mats["hivis"] hv = mats["hivis"]
@@ -921,12 +973,15 @@ def build_gear(name):
elif name == "hood": elif name == "hood":
p.append(_hood("Hood", M("plastic_grey"), (0, 0, 0.015))) p.append(_hood("Hood", M("plastic_grey"), (0, 0, 0.015)))
elif name == "sunglasses": elif name == "sunglasses":
# Keep the front frame narrow enough to sit inside the 0.315 m head
# silhouette. The arms/hinges set the overall width, so pull those in
# before shrinking the lenses; the lens plane stays in front of the eyes.
for s in (-1.0, 1.0): for s in (-1.0, 1.0):
p.append(cyl("Lens", 0.03, 0.03, 0.008, (0.05 * s, -0.152, 0.035), M("lens"), p.append(cyl("Lens", 0.024, 0.024, 0.008, (0.04 * s, -0.152, 0.035), M("lens"),
seg=14, rot=(math.radians(90), 0, 0), scale=(1.25, 1.0, 1.0))) seg=14, rot=(math.radians(90), 0, 0), scale=(1.25, 1.0, 1.0)))
p.append(box("Hinge", (0.06, 0.008, 0.01), (0.11 * s, -0.150, 0.045), p.append(box("Hinge", (0.035, 0.008, 0.01), (0.075 * s, -0.150, 0.045),
M("plastic_black"))) M("plastic_black")))
p.append(box("Arm", (0.006, 0.15, 0.008), (0.138 * s, -0.078, 0.042), p.append(box("Arm", (0.006, 0.15, 0.008), (0.098 * s, -0.078, 0.042),
M("plastic_black"))) M("plastic_black")))
p.append(box("Bridge", (0.04, 0.006, 0.008), (0, -0.155, 0.045), M("plastic_black"))) p.append(box("Bridge", (0.04, 0.006, 0.008), (0, -0.155, 0.045), M("plastic_black")))
elif name == "firefighter_helmet": elif name == "firefighter_helmet":
@@ -941,6 +996,10 @@ def build_gear(name):
scale=(1.0, 1.08, 1.0))) scale=(1.0, 1.08, 1.0)))
p.append(box("Ridge", (0.03, 0.34, 0.05), (0, 0.0, 0.2), M("helmet"), bevel=0.01)) p.append(box("Ridge", (0.03, 0.34, 0.05), (0, 0.0, 0.2), M("helmet"), bevel=0.01))
p.append(box("Shield", (0.09, 0.012, 0.09), (0, -0.2, 0.11), M("brass"), bevel=0.01)) p.append(box("Shield", (0.09, 0.012, 0.09), (0, -0.2, 0.11), M("brass"), bevel=0.01))
# The 2026-11-01 face pass added a brow ridge; lift the helmet 2 cm so
# the brim sits above the eyes instead of cutting into the brow.
for obj in p:
obj.data.transform(Matrix.Translation((0, 0, 0.02)))
# Back gear: origin = spine centre (Godot 0, 1.10, 0); +Y Blender = behind. # Back gear: origin = spine centre (Godot 0, 1.10, 0); +Y Blender = behind.
elif name == "backpack": elif name == "backpack":
p.append(box("Pack", (0.30, 0.15, 0.40), (0, 0.20, 0.08), M("pack_nylon"), p.append(box("Pack", (0.30, 0.15, 0.40), (0, 0.20, 0.08), M("pack_nylon"),
@@ -953,11 +1012,13 @@ def build_gear(name):
(0.11 * s, -0.13, 0.18), (0.12 * s, -0.12, -0.08)], (0.11 * s, -0.13, 0.18), (0.12 * s, -0.12, -0.08)],
0.012, M("plastic_black"), seg=6)) 0.012, M("plastic_black"), seg=6))
elif name == "shoulder_bag": elif name == "shoulder_bag":
p.append(box("Bag", (0.08, 0.26, 0.20), (0.21, 0.0, -0.22), M("leather"), # Keep the bag's inner face outside the widest skirt flare (dress = 0.26):
# 0.292 - 0.06/2 = 0.262. The strap starts/ends on that same outboard line.
p.append(box("Bag", (0.06, 0.26, 0.20), (0.292, 0.0, -0.22), M("leather"),
bevel=0.025, segs=2)) bevel=0.025, segs=2))
p.append(tube("Strap", [(0.20, 0.0, -0.12), (0.15, -0.12, 0.10), (0.0, -0.13, 0.26), p.append(tube("Strap", [(0.262, 0.0, -0.12), (0.15, -0.12, 0.10), (0.0, -0.13, 0.26),
(-0.15, -0.05, 0.34), (-0.16, 0.10, 0.28), (0.0, 0.14, 0.05), (-0.15, -0.05, 0.34), (-0.16, 0.10, 0.28), (0.0, 0.14, 0.05),
(0.20, 0.03, -0.12)], 0.009, M("leather"), seg=6)) (0.262, 0.03, -0.12)], 0.009, M("leather"), seg=6))
elif name == "scba_tank": elif name == "scba_tank":
p.append(cyl("Tank", 0.085, 0.085, 0.56, (0, 0.21, 0.02), M("steel"), seg=18)) p.append(cyl("Tank", 0.085, 0.085, 0.56, (0, 0.21, 0.02), M("steel"), seg=18))
p.append(sphere("TopCap", 0.085, (0, 0.21, 0.30), M("steel"), seg=18, rings=8, p.append(sphere("TopCap", 0.085, (0, 0.21, 0.30), M("steel"), seg=18, rings=8,
@@ -1632,6 +1693,143 @@ def audit_asset(category, name):
return res return res
def character_fit_reference(name):
"""Measure a character's head width and hip/skirt radius from its GLB.
Head width uses skin/hair vertices whose strongest joint is Head inside the
fixed head band. Clothing radius uses the cloth vertices in the hip/skirt
band. Both are reported in metres and rounded to millimetres.
"""
path = asset_path("npc", name)
if not path.is_file():
return None
try:
_magic, _version, _length, _size, gltf, binary = _read_glb(path)
except (struct.error, ValueError, KeyError):
return None
nodes = gltf.get("nodes", [])
joints = [nodes[j].get("name", "") for j in gltf.get("skins", [{}])[0].get("joints", [])]
materials = [m.get("name", "") for m in gltf.get("materials", [])]
head_x: list[float] = []
head_y: list[float] = []
cloth_x: list[float] = []
hip_x: list[float] = []
for mesh in gltf.get("meshes", []):
for prim in mesh.get("primitives", []):
attrs = prim.get("attributes", {})
if "POSITION" not in attrs or "JOINTS_0" not in attrs or "WEIGHTS_0" not in attrs:
continue
mat_index = prim.get("material")
mat_name = materials[mat_index] if mat_index is not None and mat_index < len(materials) else ""
positions = _accessor(gltf, binary, attrs["POSITION"])
joint_rows = _accessor(gltf, binary, attrs["JOINTS_0"])
weight_rows = _accessor(gltf, binary, attrs["WEIGHTS_0"])
for pos, joint_row, weight_row in zip(positions, joint_rows, weight_rows):
strongest = max(range(4), key=lambda i: weight_row[i])
bone = joints[joint_row[strongest]] if joint_row[strongest] < len(joints) else ""
if (mat_name in FIT_HEAD_MATERIALS and bone == "Head"
and weight_row[strongest] > 0.5 and HEAD_BAND[0] <= pos[1] <= HEAD_BAND[1]):
head_x.append(pos[0])
head_y.append(pos[1])
if (mat_name == FIT_CLOTHING_MATERIAL
and FIT_CLOTHING_BAND[0] <= pos[1] <= FIT_CLOTHING_BAND[1]):
cloth_x.append(pos[0])
if (mat_name == "pants"
and FIT_HIP_BAND[0] <= pos[1] <= FIT_HIP_BAND[1]):
hip_x.append(pos[0])
skirt_radius = max(abs(x) for x in cloth_x) if cloth_x else 0.0
hip_radius = max(abs(x) for x in hip_x) if hip_x else 0.0
clothing_radius = max(skirt_radius, hip_radius)
return {
"head_width": round(max(head_x) - min(head_x), 3) if head_x else 0.0,
"head_y_min": round(min(head_y), 3) if head_y else 0.0,
"head_y_max": round(max(head_y), 3) if head_y else 0.0,
"clothing_radius": round(clothing_radius, 3),
"clothing_source": "skirt" if skirt_radius >= hip_radius and skirt_radius > 0 else "hip",
"head_band": HEAD_BAND,
}
def _gear_local_positions(name):
path = asset_path("npc_gear", name)
if not path.is_file():
return []
try:
_magic, _version, _length, _size, gltf, binary = _read_glb(path)
except (struct.error, ValueError, KeyError):
return []
out = []
for mesh in gltf.get("meshes", []):
for prim in mesh.get("primitives", []):
attrs = prim.get("attributes", {})
if "POSITION" in attrs:
out.extend(_accessor(gltf, binary, attrs["POSITION"]))
return out
def _gear_fit(name, reference):
attach = GEAR.get(name)
if attach not in ("head", "back", "chest"):
return None
audit = audit_asset("npc_gear", name)
bounds = audit.get("bounds")
if not bounds:
return None
offset = ATTACH_GODOT[attach]
lo = [bounds["min"][i] + offset[i] for i in range(3)]
hi = [bounds["max"][i] + offset[i] for i in range(3)]
result = {
"name": name,
"attach": attach,
"min": [round(v, 3) for v in lo],
"max": [round(v, 3) for v in hi],
}
if attach == "head":
result.update({
"width": round(hi[0] - lo[0], 3),
"width_ratio": round((hi[0] - lo[0]) / reference["head_width"], 3)
if reference["head_width"] else 0.0,
"inside_band": lo[1] >= HEAD_BAND[0] - 0.005 and hi[1] <= HEAD_BAND[1] + 0.005,
})
else:
# Compare the widest point (|x|) and the corner radial extent with the
# skirt flare. The radial value catches bags that sit diagonally out.
inner_x = min(abs(lo[0]), abs(hi[0]))
if name in FIT_BODY_BANDS:
band = FIT_BODY_BANDS[name]
body_x = [abs(p[0]) for p in _gear_local_positions(name)
if band[0] <= p[1] <= band[1]]
if body_x:
inner_x = min(body_x)
x_extent = max(abs(lo[0]), abs(hi[0]))
radial = max(math.hypot(x, z) for x in (lo[0], hi[0]) for z in (lo[2], hi[2]))
result.update({
"inner_x": round(inner_x, 3),
"x_extent": round(x_extent, 3),
"radial": round(radial, 3),
# Positive clearance means the bag's inner face is outside the body.
"x_clearance": round(inner_x - reference["clothing_radius"], 3),
"radial_clearance": round(reference["clothing_radius"] - radial, 3),
})
return result
def fit_report():
"""Return per-character gear fit measurements for the `fit` command/tests."""
report = []
for character, spec in CHARACTERS.items():
reference = character_fit_reference(character)
if reference is None:
continue
rows = []
for gear_name in spec.get("gear", []):
row = _gear_fit(gear_name, reference)
if row:
rows.append(row)
report.append({"character": character, "reference": reference, "gear": rows})
return report
def write_manifest(results): def write_manifest(results):
out = {"generator": "tools/blender_assets.py", "attach_points": GEAR_ATTACH, out = {"generator": "tools/blender_assets.py", "attach_points": GEAR_ATTACH,
"assets": {}} "assets": {}}
@@ -1695,6 +1893,8 @@ def main(argv=None):
s.add_argument("--only", default="") s.add_argument("--only", default="")
a = sub.add_parser("audit") a = sub.add_parser("audit")
a.add_argument("--json", action="store_true") a.add_argument("--json", action="store_true")
fit = sub.add_parser("fit")
fit.add_argument("--json", action="store_true")
sub.add_parser("sheet") sub.add_parser("sheet")
sub.add_parser("list") sub.add_parser("list")
args = ap.parse_args(raw) args = ap.parse_args(raw)
@@ -1726,6 +1926,36 @@ def main(argv=None):
if args.cmd == "sheet": if args.cmd == "sheet":
contact_sheets() contact_sheets()
return 0 return 0
if args.cmd == "fit":
report = fit_report()
if not report:
print("no built character assets; run build first", file=sys.stderr)
return 2
if args.json:
print(json.dumps(report, indent=2))
else:
for entry in report:
ref = entry["reference"]
print(f"{entry['character']:16s} head_width={ref['head_width']:.3f} m "
f"clothing_radius={ref['clothing_radius']:.3f} m "
f"({ref['clothing_source']})")
for row in entry["gear"]:
lo, hi = row["min"], row["max"]
box = (f"x=[{lo[0]:+.3f},{hi[0]:+.3f}] "
f"y=[{lo[1]:+.3f},{hi[1]:+.3f}] "
f"z=[{lo[2]:+.3f},{hi[2]:+.3f}]")
if row["attach"] == "head":
flag = "inside" if row["inside_band"] else "OUTSIDE"
print(f" {row['name']:20s} head {box} "
f"width={row['width']:.3f} ({row['width_ratio']:.2f}x head) "
f"band={flag}")
else:
clip = "CLIP" if row["x_clearance"] < 0 else "clear"
print(f" {row['name']:20s} {row['attach']:5s} {box} "
f"inner_x={row['inner_x']:.3f} "
f"clearance x={row['x_clearance']:+.3f} "
f"r={row['radial_clearance']:+.3f} {clip}")
return 0
results = [audit_asset(c, n) for c, n in catalogue()] results = [audit_asset(c, n) for c, n in catalogue()]
if args.json: if args.json:
print(json.dumps(results, indent=2)) print(json.dumps(results, indent=2))
+25
View File
@@ -11,6 +11,7 @@
# build/gig-line-windows-<version>.exe # build/gig-line-windows-<version>.exe
# build/gig-line-macos-<version>.zip # build/gig-line-macos-<version>.zip
# build/gig-line-web-<version>.zip (Godot web build: static hosting, no threads) # build/gig-line-web-<version>.zip (Godot web build: static hosting, no threads)
# build/gig-line-desktop-hq-<version>.zip (optional Forward+ Linux/Windows)
# #
set -euo pipefail set -euo pipefail
@@ -49,6 +50,30 @@ with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as archive:
PYZIP PYZIP
fi fi
echo "==> Packaging optional Desktop HQ build (Forward+)"
HQ_DIR="build/desktop-hq"
rm -rf "$HQ_DIR"
mkdir -p "$HQ_DIR"
cp "build/gig-line-linux-${VERSION}.x86_64" "$HQ_DIR/"
cp "build/gig-line-windows-${VERSION}.exe" "$HQ_DIR/"
cp packaging/desktop_hq/override.cfg "$HQ_DIR/"
cp packaging/desktop_hq/README.txt "$HQ_DIR/"
rm -f "build/gig-line-desktop-hq-${VERSION}.zip"
if command -v zip >/dev/null 2>&1; then
(cd "$HQ_DIR" && zip -q -r "../gig-line-desktop-hq-${VERSION}.zip" .)
else
python3 - "$PWD/$HQ_DIR" "$PWD/build/gig-line-desktop-hq-${VERSION}.zip" <<'PYZIP'
import os, sys, zipfile
src, out = sys.argv[1], sys.argv[2]
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as archive:
for root, _dirs, files in os.walk(src):
for name in files:
path = os.path.join(root, name)
archive.write(path, os.path.relpath(path, src))
PYZIP
fi
rm -rf "$HQ_DIR"
echo "" echo ""
echo "Artifacts:" echo "Artifacts:"
ls -la build/*"${VERSION}"* ls -la build/*"${VERSION}"*
+1 -1
View File
@@ -16,7 +16,7 @@ path must be absolute):
python3 tools/npc_pipeline.py palette # derive outfit colours -> palette.json python3 tools/npc_pipeline.py palette # derive outfit colours -> palette.json
python3 tools/npc_pipeline.py audit python3 tools/npc_pipeline.py audit
Contract (frozen in docs/plan-graphics-v0.6.md + ADR-0013): Contract (frozen in ADR-0013 and docs/blender-assets.md):
* scene root "Rig" with a skinned mesh and an armature * scene root "Rig" with a skinned mesh and an armature
* bones: Head, HipL/R, KneeL/R, ShoulderL/R, ElbowL/R, HandL/R * bones: Head, HipL/R, KneeL/R, ShoulderL/R, ElbowL/R, HandL/R
+127
View File
@@ -0,0 +1,127 @@
"""Pick affordable OpenRouter vision models for the screenshot judges.
The model catalogue is fetched from OpenRouter (no auth needed) and filtered to
models that accept image input and advertise `response_format`/structured output.
The ranking uses the advertised per-token prices, so it follows the account's
current catalogue instead of relying on one hard-coded model name. If the
catalogue cannot be fetched, a small known-good list keeps QA working offline.
"""
from __future__ import annotations
import os
import urllib.error
import urllib.request
MODELS_URL = "https://openrouter.ai/api/v1/models"
DEFAULT_MODEL = "auto"
# Used only when the catalogue is unavailable. Ordered by price/known quality.
FALLBACK_MODELS = [
"z-ai/glm-5.3-flash",
"qwen/qwen3.7-flash",
"google/gemma-3-4b-it",
"openai/gpt-4.1-nano",
"google/gemini-2.5-flash-lite",
]
def fetch_models(timeout: int = 30) -> list[dict]:
"""Return the OpenRouter model catalogue, or [] when it is unreachable."""
req = urllib.request.Request(MODELS_URL, headers={"Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
payload = __import__("json").loads(resp.read().decode("utf-8"))
except (OSError, ValueError, urllib.error.HTTPError):
return []
return list(payload.get("data") or [])
def _price(model: dict) -> float:
pricing = model.get("pricing") or {}
try:
prompt = float(pricing.get("prompt") or 0.0)
completion = float(pricing.get("completion") or 0.0)
image = float(pricing.get("image") or 0.0)
except (TypeError, ValueError):
return float("inf")
# A judge request is image-heavy and emits a short JSON verdict.
return prompt + image + completion * 0.25
def vision_candidates(catalog: list[dict], limit: int = 6) -> list[str]:
"""Cheap, image-capable models that can return structured JSON."""
ranked: list[tuple[float, str]] = []
for model in catalog:
model_id = str(model.get("id") or "")
if not model_id or model_id.startswith("~") or ":batch" in model_id:
continue
architecture = model.get("architecture") or {}
inputs = architecture.get("input_modalities") or []
outputs = architecture.get("output_modalities") or []
if "image" not in inputs or "text" not in outputs:
continue
params = model.get("supported_parameters") or []
# Our request uses response_format={"type": "json_object"}; models that
# only advertise structured_outputs may reject that envelope.
if "response_format" not in params:
continue
price = _price(model)
if price <= 0.0 or price == float("inf"):
continue
ranked.append((price, model_id))
ranked.sort()
out: list[str] = []
for _price_value, model_id in ranked:
if model_id not in out:
out.append(model_id)
if len(out) >= max(1, limit):
break
return out
def resolve_models(requested: str = DEFAULT_MODEL, *, catalog: list[dict] | None = None,
limit: int = 6) -> list[str]:
"""Resolve ``auto``/an explicit model to a fallback-ordered model list.
`GIG_LINE_JUDGE_MODEL` overrides the automatic choice for reproducible runs;
an explicit CLI `--model` still wins.
"""
requested = str(requested or "").strip()
if requested and requested != DEFAULT_MODEL:
return [requested]
env_model = os.environ.get("GIG_LINE_JUDGE_MODEL", "").strip()
if env_model:
return [env_model]
models = vision_candidates(catalog if catalog is not None else fetch_models(), limit)
if not models:
models = FALLBACK_MODELS[:max(1, limit)]
return models
def is_auth_error(exc: BaseException) -> bool:
text = str(exc)
return "HTTP 401" in text or "HTTP 403" in text or "API key not valid" in text
def call_with_fallback(models: list[str], make_body, call, attempts: int = 2):
"""Try each model, retrying provider errors, and return (model, response).
Authentication errors abort immediately: switching models cannot repair a
bad key. Model/provider errors move to the next candidate.
"""
last: BaseException | None = None
for model in models:
body = make_body(model)
for attempt in range(max(1, attempts)):
try:
return model, call(body)
except (ValueError, OSError) as exc:
last = exc
if is_auth_error(exc):
raise
if attempt + 1 >= max(1, attempts):
break
if last is not None:
raise last
raise ValueError("no judge model was available")
+1
View File
@@ -67,6 +67,7 @@ extras=(
"build/gig-line-setup-$VERSION.exe" "build/gig-line-setup-$VERSION.exe"
"build/gig-line-$VERSION-x86_64.AppImage" "build/gig-line-$VERSION-x86_64.AppImage"
"build/gig-line-web-$VERSION.zip" "build/gig-line-web-$VERSION.zip"
"build/gig-line-desktop-hq-$VERSION.zip"
) )
all_artifacts=("${portables[@]}") all_artifacts=("${portables[@]}")
for f in "${extras[@]}"; do for f in "${extras[@]}"; do
+48 -18
View File
@@ -38,6 +38,7 @@ from PIL import Image, ImageFilter, ImageOps, ImageStat
import asset_pipeline import asset_pipeline
import generate_textures import generate_textures
import openrouter_judge
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
RAW_DIR = ROOT / "assets" / "textures" / "ai" / "v2" / "raw" RAW_DIR = ROOT / "assets" / "textures" / "ai" / "v2" / "raw"
@@ -46,7 +47,7 @@ CONCEPT_DIR = ROOT / "docs" / "screens" / "concepts"
REALISM_DIR = ROOT / "docs" / "screens" / "realism" REALISM_DIR = ROOT / "docs" / "screens" / "realism"
DEFAULT_MODEL = "openai/gpt-5-image-mini" DEFAULT_MODEL = "openai/gpt-5-image-mini"
DEFAULT_JUDGE_MODEL = "google/gemini-2.5-flash-lite" DEFAULT_JUDGE_MODEL = openrouter_judge.DEFAULT_MODEL
OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions" OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions"
SCENE_W, SCENE_H = 1280, 720 SCENE_W, SCENE_H = 1280, 720
@@ -119,6 +120,11 @@ CATALOGUE = {
"a row of steel bike-rack crowd barriers along a Hollywood sidewalk before a " "a row of steel bike-rack crowd barriers along a Hollywood sidewalk before a "
"premiere, people waiting behind them, a red carpet, palm trees and a hazy " "premiere, people waiting behind them, a red carpet, palm trees and a hazy "
"skyline, late afternoon")), "skyline, late afternoon")),
"npc_portrait_day": ("scene", (
"eye-level close portrait of an ordinary adult pedestrian standing on a "
"sunlit Hollywood sidewalk, upper body and face filling the frame, plain "
"casual jacket, natural skin detail, shallow depth of field, blurred brick "
"facade and palm trees behind")),
"responder_arrival_wide": ("scene", ( "responder_arrival_wide": ("scene", (
"wide shot of a fire engine and firefighters in dark turnout gear arriving at " "wide shot of a fire engine and firefighters in dark turnout gear arriving at "
"a brick venue entrance at night, red and blue light reflections on wet " "a brick venue entrance at night, red and blue light reflections on wet "
@@ -186,10 +192,14 @@ SHOTS = {
"street_night": "boulevard_skyline_dusk", "street_night": "boulevard_skyline_dusk",
"entrance_late": "hollywood_entrance_rain_night", "entrance_late": "hollywood_entrance_rain_night",
"street_late": "boulevard_skyline_dusk", "street_late": "boulevard_skyline_dusk",
# The venues share the Hollywood geometry for now, so these two compare # Each venue is judged on its own frame; the real geometry no longer shares
# lighting and atmosphere rather than layout (noted in TODO.md). # the Hollywood layout.
"joke_shop_door": "joke_shop_door_closeup", "joke_shop_door": "joke_shop_door_closeup",
"12-joke-shop": "joke_shop_alley_evening",
"studio_door": "studio_stage_door_night", "studio_door": "studio_stage_door_night",
"studio_stage_door_night": "studio_stage_door_night",
"13-studio-arrival": "studio_stage_door_night",
"npc_portrait": "npc_portrait_day",
} }
@@ -749,25 +759,32 @@ def _call_openrouter(body: dict, timeout: int = 180) -> dict:
raise ValueError(f"OpenRouter HTTP {exc.code}: {exc.read().decode('utf-8', 'replace')[:300]}") raise ValueError(f"OpenRouter HTTP {exc.code}: {exc.read().decode('utf-8', 'replace')[:300]}")
def judge_capture(model: str, target: Path, capture: Path, attempts: int = 2, def judge_capture(models, target: Path, capture: Path, attempts: int = 2,
repeats: int = 1) -> dict: repeats: int = 1) -> dict:
"""Vision-judge one capture; retry transient errors, average repeat scores. """Vision-judge one capture; retry transient errors, average repeat scores.
`models` is a fallback-ordered list from `openrouter_judge.resolve_models`.
A single score from a small vision model swings by ±1-2, so `repeats` runs A single score from a small vision model swings by ±1-2, so `repeats` runs
are averaged and the spread is kept (`score_range`) for the report. are averaged and the spread is kept (`score_range`) for the report.
""" """
if isinstance(models, str):
models = [models]
runs = [] runs = []
error = None error = None
used = set()
for _ in range(max(1, repeats)): for _ in range(max(1, repeats)):
for _attempt in range(attempts): try:
try: model, payload = openrouter_judge.call_with_fallback(
runs.append(parse_realism_response(_call_openrouter( models, lambda selected: build_realism_request(selected, target, capture),
build_realism_request(model, target, capture)))) _call_openrouter, attempts=attempts)
break used.add(model)
except (ValueError, OSError) as exc: runs.append(parse_realism_response(payload))
error = exc except (ValueError, OSError) as exc:
if openrouter_judge.is_auth_error(exc):
raise
error = exc
if not runs: if not runs:
return {"scores": {}, "fixes": [f"judge failed: {error}"]} return {"scores": {}, "fixes": [f"judge failed: {error}"], "model": ""}
keys = {key for run in runs for key in run["scores"]} keys = {key for run in runs for key in run["scores"]}
scores = {} scores = {}
spread = {} spread = {}
@@ -781,7 +798,7 @@ def judge_capture(model: str, target: Path, capture: Path, attempts: int = 2,
if fix not in fixes: if fix not in fixes:
fixes.append(fix) fixes.append(fix)
return {"scores": scores, "score_range": spread, "runs": len(runs), return {"scores": scores, "score_range": spread, "runs": len(runs),
"fixes": fixes[:5]} "model": ", ".join(sorted(used)), "fixes": fixes[:5]}
def cmd_realism(args) -> int: def cmd_realism(args) -> int:
@@ -791,10 +808,14 @@ def cmd_realism(args) -> int:
if not sets: if not sets:
print(f"no capture sets under {_rel(run)}", file=sys.stderr) print(f"no capture sets under {_rel(run)}", file=sys.stderr)
return 2 return 2
judge_models = []
if args.judge: if args.judge:
_load_env_file() _load_env_file()
report = {"run": _rel(run), "judge_model": args.model if args.judge else None, judge_models = openrouter_judge.resolve_models(args.model)
"sets": {}} print(f"judge models: {', '.join(judge_models)}", file=sys.stderr)
report = {"run": _rel(run),
"judge_model": (judge_models[0] if judge_models else None),
"judge_fallbacks": judge_models, "sets": {}}
for capture_set in sets: for capture_set in sets:
rows = {} rows = {}
for capture in sorted(capture_set.glob("*.png")): for capture in sorted(capture_set.glob("*.png")):
@@ -806,8 +827,15 @@ def cmd_realism(args) -> int:
row = {"target": _rel(target), "metrics": cap_m, "target_metrics": tgt_m, row = {"target": _rel(target), "metrics": cap_m, "target_metrics": tgt_m,
"metric_gap": metric_gap(cap_m, tgt_m)} "metric_gap": metric_gap(cap_m, tgt_m)}
if args.judge: if args.judge:
row["judge"] = judge_capture(args.model, target, capture, try:
repeats=args.repeats) row["judge"] = judge_capture(judge_models, target, capture,
repeats=args.repeats)
except ValueError as exc:
if openrouter_judge.is_auth_error(exc):
print(f"error: OpenRouter authentication failed: {exc}",
file=sys.stderr)
return 2
raise
rows[capture.stem] = row rows[capture.stem] = row
report["sets"][capture_set.name] = rows report["sets"][capture_set.name] = rows
json_path = next_free_path(run / "report.json") json_path = next_free_path(run / "report.json")
@@ -966,7 +994,9 @@ def build_parser():
real = sub.add_parser("realism") real = sub.add_parser("realism")
real.add_argument("--run", required=True) real.add_argument("--run", required=True)
real.add_argument("--judge", action="store_true") real.add_argument("--judge", action="store_true")
real.add_argument("--model", default=DEFAULT_JUDGE_MODEL) real.add_argument("--model", default=DEFAULT_JUDGE_MODEL,
help="OpenRouter model id, or 'auto' to pick the cheapest "
"available image+JSON model with fallbacks")
real.add_argument("--repeats", type=int, default=1, real.add_argument("--repeats", type=int, default=1,
help="average N judge runs per shot (scores are noisy)") help="average N judge runs per shot (scores are noisy)")
real.set_defaults(handler=cmd_realism) real.set_defaults(handler=cmd_realism)
+31 -11
View File
@@ -51,6 +51,7 @@ from pathlib import Path
from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageOps, ImageStat from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageOps, ImageStat
import generate_textures import generate_textures
import openrouter_judge
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
## Runtime reference art (ADR-0014); docs/screens/designs/ is the reference-only ## Runtime reference art (ADR-0014); docs/screens/designs/ is the reference-only
@@ -68,7 +69,7 @@ ASPECT = 16 / 9
DEFAULT_MODEL = "openai/gpt-5-image-mini" DEFAULT_MODEL = "openai/gpt-5-image-mini"
# Vision model used by `judge`. Cheap, multimodal, and good at "same screen?". # Vision model used by `judge`. Cheap, multimodal, and good at "same screen?".
DEFAULT_JUDGE_MODEL = "google/gemini-2.5-flash-lite" DEFAULT_JUDGE_MODEL = openrouter_judge.DEFAULT_MODEL
OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions" OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions"
# Shared art direction, appended to every prompt so the generated frames read # Shared art direction, appended to every prompt so the generated frames read
@@ -369,7 +370,7 @@ SCREENS = [
"kind": "scene", "kind": "scene",
"title": "Joke Shop door queue", "title": "Joke Shop door queue",
"source": "PLAN.md The Joke Shop — long line, ID and ticket checks", "source": "PLAN.md The Joke Shop — long line, ID and ticket checks",
"ref": None, "ref": "12-joke-shop.png",
"description": "Evening view of a small comedy-club entrance: a short queue " "description": "Evening view of a small comedy-club entrance: a short queue "
"on the sidewalk, a door person checking an ID and a ticket " "on the sidewalk, a door person checking an ID and a ticket "
"under a warm bulb, a red awning, and the show board.", "under a warm bulb, a red awning, and the show board.",
@@ -391,7 +392,7 @@ SCREENS = [
"kind": "scene", "kind": "scene",
"title": "TV Studio celebrity arrival", "title": "TV Studio celebrity arrival",
"source": "PLAN.md Late Night TV Studio — crowd gathers for an arrival", "source": "PLAN.md Late Night TV Studio — crowd gathers for an arrival",
"ref": None, "ref": "13-studio-arrival.png",
"description": "A celebrity arrival at the studio entrance: tourists and " "description": "A celebrity arrival at the studio entrance: tourists and "
"influencers pressing toward the door, cameras raised, the " "influencers pressing toward the door, cameras raised, the "
"guard holding a line on the steps, studio marquee behind.", "guard holding a line on the steps, studio marquee behind.",
@@ -965,6 +966,10 @@ def judge(args):
refs = {screen["name"]: screen["ref"] refs = {screen["name"]: screen["ref"]
for screen in SCREENS if screen["ref"]} for screen in SCREENS if screen["ref"]}
by_name = {screen["name"]: screen for screen in SCREENS} by_name = {screen["name"]: screen for screen in SCREENS}
models = [args.model]
if not args.dry_run:
models = openrouter_judge.resolve_models(args.model)
print(f"judge models: {', '.join(models)}", file=sys.stderr)
results = [] results = []
for capture in sorted(SCREENSHOTS_DIR.glob("*.png")): for capture in sorted(SCREENSHOTS_DIR.glob("*.png")):
if args.only and args.only not in capture.name: if args.only and args.only not in capture.name:
@@ -982,23 +987,36 @@ def judge(args):
"match": None, "confidence": 0.0, "match": None, "confidence": 0.0,
"differences": ["missing reference design; run generate"]}) "differences": ["missing reference design; run generate"]})
continue continue
body = build_judge_request(args.model, by_name[design_name],
capture, reference)
if args.dry_run: if args.dry_run:
body = build_judge_request(args.model, by_name[design_name],
capture, reference)
results.append({ results.append({
"capture": capture.name, "screen": design_name, "match": None, "capture": capture.name, "screen": design_name, "match": None,
"confidence": 0.0, "model": body["model"], "class": "unknown", "confidence": 0.0, "model": body["model"], "class": "unknown",
"differences": [f"dry run: {len(body['messages'][0]['content'])} parts"], "differences": [f"dry run: {len(body['messages'][0]['content'])} parts"],
}) })
continue continue
# Providers occasionally answer a valid key with a transient HTTP 400;
# one retry keeps a whole judge run from dying on a single hiccup.
try: try:
verdict = parse_judge_response(_call_openrouter(body)) model, payload = openrouter_judge.call_with_fallback(
models,
lambda selected: build_judge_request(
selected, by_name[design_name], capture, reference),
_call_openrouter)
except ValueError as exc: except ValueError as exc:
print(f"retrying {capture.name}: {exc}", file=sys.stderr) if openrouter_judge.is_auth_error(exc):
verdict = parse_judge_response(_call_openrouter(body)) print(f"error: OpenRouter authentication failed: {exc}",
file=sys.stderr)
return 2
print(f"error judging {capture.name}: {exc}", file=sys.stderr)
results.append({
"capture": capture.name, "screen": design_name, "match": None,
"confidence": 0.0, "model": "",
"class": "unknown", "differences": [f"judge failed: {exc}"],
})
continue
verdict = parse_judge_response(payload)
verdict.update({"capture": capture.name, "screen": design_name, verdict.update({"capture": capture.name, "screen": design_name,
"model": model,
"class": classify(verdict, args.fail_confidence)}) "class": classify(verdict, args.fail_confidence)})
results.append(verdict) results.append(verdict)
@@ -1047,7 +1065,9 @@ def build_parser():
judge_cmd = commands.add_parser( judge_cmd = commands.add_parser(
"judge", help="ask a vision model whether captures match the designs") "judge", help="ask a vision model whether captures match the designs")
judge_cmd.add_argument("--model", default=DEFAULT_JUDGE_MODEL) judge_cmd.add_argument("--model", default=DEFAULT_JUDGE_MODEL,
help="OpenRouter model id, or 'auto' to pick the cheapest "
"available image+JSON model with fallbacks")
judge_cmd.add_argument("--json", action="store_true") judge_cmd.add_argument("--json", action="store_true")
judge_cmd.add_argument("--dry-run", action="store_true", judge_cmd.add_argument("--dry-run", action="store_true",
help="print requests instead of calling the API") help="print requests instead of calling the API")
+28 -1
View File
@@ -9,8 +9,10 @@ class CatalogueTests(unittest.TestCase):
def test_contract_matches_npc_pipeline(self): def test_contract_matches_npc_pipeline(self):
self.assertEqual(ba.SLOTS, npc_pipeline.SLOTS) self.assertEqual(ba.SLOTS, npc_pipeline.SLOTS)
self.assertEqual(sorted(ba.BONES), sorted(npc_pipeline.BONES)) self.assertEqual(sorted(ba.BONES), sorted(npc_pipeline.BONES))
# The base mesh keeps the frozen 6000/4000 contract; library variants may
# spend up to 5000 verts on face/hand detail (documented in blender-assets.md).
self.assertLessEqual(ba.CHAR_MAX_TRIS, npc_pipeline.MAX_TRIANGLES) self.assertLessEqual(ba.CHAR_MAX_TRIS, npc_pipeline.MAX_TRIANGLES)
self.assertLessEqual(ba.CHAR_MAX_VERTS, npc_pipeline.MAX_VERTICES) self.assertLessEqual(ba.CHAR_MAX_VERTS, 5000)
def test_gear_uses_known_attach_points(self): def test_gear_uses_known_attach_points(self):
for name, attach in ba.GEAR.items(): for name, attach in ba.GEAR.items():
@@ -43,6 +45,31 @@ class BuiltAssetTests(unittest.TestCase):
bad[name] = result["issues"] bad[name] = result["issues"]
self.assertEqual(bad, {}) self.assertEqual(bad, {})
def test_fit_report_measures_gear_against_body(self):
report = {entry["character"]: entry for entry in ba.fit_report()}
self.assertIn("npc_coat", report)
coat = report["npc_coat"]
self.assertAlmostEqual(coat["reference"]["head_width"], 0.315, places=2)
self.assertAlmostEqual(coat["reference"]["clothing_radius"], 0.225, places=3)
sunglasses = next(row for row in coat["gear"] if row["name"] == "sunglasses")
self.assertEqual(sunglasses["attach"], "head")
self.assertLessEqual(sunglasses["width"],
0.70 * coat["reference"]["head_width"])
bag = next(row for row in coat["gear"] if row["name"] == "shoulder_bag")
self.assertEqual(bag["attach"], "back")
self.assertGreaterEqual(bag["x_extent"], 0.2)
self.assertGreater(bag["x_clearance"], 0)
self.assertGreater(bag["inner_x"], coat["reference"]["clothing_radius"])
for character in report.values():
for row in character["gear"]:
self.assertEqual(len(row["min"]), 3)
self.assertEqual(len(row["max"]), 3)
self.assertIn("npc_dress", report)
dress = report["npc_dress"]
self.assertAlmostEqual(dress["reference"]["clothing_radius"], 0.26, places=3)
dress_bag = next(row for row in dress["gear"] if row["name"] == "shoulder_bag")
self.assertGreater(dress_bag["x_clearance"], 0)
def test_manifest_covers_catalogue(self): def test_manifest_covers_catalogue(self):
manifest = json.loads(ba.MANIFEST.read_text(encoding="utf-8")) manifest = json.loads(ba.MANIFEST.read_text(encoding="utf-8"))
self.assertEqual(set(manifest["assets"]), {n for _, n in ba.catalogue()}) self.assertEqual(set(manifest["assets"]), {n for _, n in ba.catalogue()})
+89
View File
@@ -0,0 +1,89 @@
import os
import unittest
from unittest import mock
import openrouter_judge as oj
def _model(model_id, prompt, completion, *, image=True, response_format=True, batch=False):
return {
"id": model_id,
"architecture": {
"input_modalities": ["text", "image"] if image else ["text"],
"output_modalities": ["text"],
},
"supported_parameters": ["response_format"] if response_format else ["temperature"],
"pricing": {"prompt": str(prompt), "completion": str(completion)},
}
class ModelSelectionTests(unittest.TestCase):
def test_candidates_are_image_json_models_sorted_by_price(self):
catalog = [
_model("expensive/model", 0.10, 0.40),
_model("cheap/model", 0.01, 0.02),
_model("text-only", 0.001, 0.001, image=False),
_model("no-json", 0.001, 0.001, response_format=False),
_model("batch/model:batch", 0.0001, 0.0001),
]
self.assertEqual(oj.vision_candidates(catalog), ["cheap/model", "expensive/model"])
def test_explicit_model_wins_over_auto_and_env(self):
with mock.patch.dict(os.environ, {"GIG_LINE_JUDGE_MODEL": "env/model"}):
self.assertEqual(oj.resolve_models("explicit/model", catalog=[]),
["explicit/model"])
def test_auto_uses_env_override(self):
with mock.patch.dict(os.environ, {"GIG_LINE_JUDGE_MODEL": "env/model"}):
self.assertEqual(oj.resolve_models("auto", catalog=[]), ["env/model"])
def test_auto_uses_catalog_and_falls_back_offline(self):
catalog = [_model("cheap/model", 0.01, 0.02)]
self.assertEqual(oj.resolve_models("auto", catalog=catalog), ["cheap/model"])
self.assertEqual(oj.resolve_models("auto", catalog=[]), oj.FALLBACK_MODELS[:6])
class FallbackTests(unittest.TestCase):
def test_fallback_switches_model_after_provider_error(self):
seen = []
def call(body):
seen.append(body["model"])
if body["model"] == "bad/model":
raise ValueError("OpenRouter HTTP 400: provider rejected image")
return {"ok": True}
model, payload = oj.call_with_fallback(
["bad/model", "good/model"], lambda m: {"model": m}, call, attempts=1)
self.assertEqual(model, "good/model")
self.assertEqual(payload, {"ok": True})
self.assertEqual(seen, ["bad/model", "good/model"])
def test_auth_error_does_not_try_another_model(self):
seen = []
def call(body):
seen.append(body["model"])
raise ValueError("OpenRouter HTTP 401: API key not valid")
with self.assertRaises(ValueError):
oj.call_with_fallback(["one", "two"], lambda m: {"model": m}, call)
self.assertEqual(seen, ["one"])
def test_transient_error_retries_same_model(self):
seen = []
def call(body):
seen.append(body["model"])
if len(seen) == 1:
raise ValueError("OpenRouter HTTP 500")
return {"ok": True}
model, _ = oj.call_with_fallback(
["one"], lambda m: {"model": m}, call, attempts=2)
self.assertEqual(model, "one")
self.assertEqual(seen, ["one", "one"])
if __name__ == "__main__":
unittest.main()