Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b0845bcce | ||
|
|
4cca2af2dd | ||
|
|
99f390078d | ||
|
|
a9fe6b9944 | ||
|
|
d4201caa62 | ||
|
|
6100d19a25 | ||
|
|
a7ca698f73 | ||
|
|
26d64baa01 | ||
|
|
151630e957 | ||
|
|
1b319c48b7 | ||
|
|
a478bfbf35 | ||
|
|
702f54ae53 | ||
|
|
bbf4693475 | ||
|
|
d7b0203502 | ||
|
|
da876e3545 |
No files matched your search
@@ -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
|
||||||
|
|
||||||
@@ -75,6 +83,8 @@ When every task in a `TODO.md` section is complete:
|
|||||||
|
|
||||||
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.
|
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 section 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`
|
||||||
|
|
||||||
Use `MEMORY.md` for information that should persist between sessions but does not belong in the general plan or task list.
|
Use `MEMORY.md` for information that should persist between sessions but does not belong in the general plan or task list.
|
||||||
@@ -82,7 +92,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 +142,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 level-2 section 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 +170,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. Move its section to `DONE.md` when the entire section is complete (after recording its `CHANGELOG.md` entry).
|
||||||
|
8. Commit the changes to the version control system with a descriptive message.
|
||||||
|
9. If a section 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.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
# 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.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).
|
||||||
@@ -1196,3 +1196,86 @@ Web deployment and release artifact list were brought up to date. `MEMORY.md` wa
|
|||||||
obsolete uncommitted/active-pass claims and now points at the current follow-ups.
|
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.
|
- [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.
|
||||||
@@ -13,6 +13,16 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
(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: the finished lighting/flashlight/NPC items
|
- Post-realism work is split by theme, not by date: the finished lighting/flashlight/NPC items
|
||||||
are archived in `DONE.md`, and `TODO.md` carries three planned workstreams (NPC gear fit,
|
are archived in `DONE.md`, and `TODO.md` carries three planned workstreams (NPC gear fit,
|
||||||
venue geometry + committed gallery, Forward+ desktop preset). New visual work should be added
|
venue geometry + committed gallery, Forward+ desktop preset). New visual work should be added
|
||||||
@@ -62,9 +72,9 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
|
|
||||||
## Handoff (read this first)
|
## Handoff (read this first)
|
||||||
|
|
||||||
- **Status (2026-11-01, after the NPC detail pass):** v0.6.0 is released. Post-v0.6.0 the
|
- **Status (2026-11-01, after the venue-geometry pass):** v0.7.0 is released. Post-v0.6.0 the
|
||||||
realism pass, the visual-alignment pass and the NPC face/hand pass all landed. Headless
|
realism pass, the visual-alignment pass and the NPC face/hand pass all landed. Headless
|
||||||
**228/228**, Python tooling **86/86**, `tools/check_screenshots.sh` **11 good / 0 bad**,
|
**229/229**, Python tooling **87/87**, `tools/check_screenshots.sh` **11 good / 0 bad**,
|
||||||
`screen_designs.py compare` 11 good / 0 bad, `scene_concepts.py audit` all GOOD,
|
`screen_designs.py compare` 11 good / 0 bad, `scene_concepts.py audit` all GOOD,
|
||||||
`blender_assets.py audit` exit 0, `smoke_npc_budget` OK (16 avatars, 6.94 ms of 50 ms).
|
`blender_assets.py audit` exit 0, `smoke_npc_budget` OK (16 avatars, 6.94 ms of 50 ms).
|
||||||
`PLAN.md` is compact; README/architecture track the current controls, asset pipeline, CI and
|
`PLAN.md` is compact; README/architecture track the current controls, asset pipeline, CI and
|
||||||
@@ -231,6 +241,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
|
||||||
@@ -599,7 +620,7 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
- 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; 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; settings persistence goes through `Save.load_settings()` / `save_settings()`.
|
- `Save.SETTINGS_FILE` is declared and unused; settings persistence goes through `Save.load_settings()` / `save_settings()`.
|
||||||
- Headless suite is **228/228**; the windowed smokes, screenshot compare and judge pass. Remaining visual follow-ups are in `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
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ Primary decisions: [ADR-0007](docs/adr/0007-locomotion-and-input-layers.md), [AD
|
|||||||
|
|
||||||
## Versioning and Delivery
|
## Versioning and Delivery
|
||||||
|
|
||||||
`config/version` in `project.godot` is the single version source (currently `0.6.0`). Releases are tagged `v<version>`; the CI pipeline builds and publishes the six artifacts. Version bumps follow [Semantic Versioning](https://semver.org/). See [`docs/RELEASING.md`](docs/RELEASING.md) and [`README.md`](README.md) for commands.
|
`config/version` in `project.godot` is the single version source (currently `0.7.0`). Releases are tagged `v<version>`; the CI pipeline builds and publishes the six artifacts. Version bumps follow [Semantic Versioning](https://semver.org/). See [`docs/RELEASING.md`](docs/RELEASING.md) and [`README.md`](README.md) for commands.
|
||||||
|
|
||||||
## Design Direction
|
## Design Direction
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ The central design goal is simple:
|
|||||||
|  |  |
|
|  |  |
|
||||||
|  |  |
|
|  |  |
|
||||||
|  |  |
|
|  |  |
|
||||||
|  | |
|
|  |  |
|
||||||
|
|  | |
|
||||||
|
|
||||||
*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
|
||||||
|
|
||||||
@@ -35,9 +37,9 @@ The central design goal is simple:
|
|||||||
> 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.0** — distinct Joke Shop and TV Studio geometry, fitted NPC gear,
|
||||||
> `main` also contains the post-v0.6.0 realism and visual-alignment pass (v2 skylines/textures,
|
> and the post-v0.6.0 realism/visual-alignment work (v2 skylines/textures, UI mockup
|
||||||
> UI mockup art, flashlight beam, HUD fixes); the next release will package it.
|
> art, flashlight beam, HUD fixes).
|
||||||
> 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
|
||||||
@@ -48,470 +50,53 @@ The central design goal is simple:
|
|||||||
> 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.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.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## 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 implemented 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:** the Blender-authored character/gear/prop library in `assets/models/blender/` is the preferred runtime set, with `assets/models/generated/` and procedural geometry as fallbacks; the adopted v2 skyline/surface textures live in `assets/textures/ai/game_ready/v2/picks/`, and UI/scene art in `assets/images/`. Structural geometry stays procedural (`WorldBuilder`). `tools/scene_concepts.py` and `tools/screen_designs.py` document the art-direction/gate pipeline.
|
|
||||||
- **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`, `tools/npc_pipeline.py`, and `tools/blender_assets.py` build and audit the generated props, NPC base, and Blender library. `tools/check_screenshots.sh` renders and compares the staged gallery.
|
|
||||||
- **Testing:** custom headless runner (`src/test/test_runner.gd`); GUT was evaluated but not adopted because it requires the editor plugin.
|
|
||||||
- **Source control:** git on a Gitea host with Gitea Actions (`.gitea/workflows/build.yml`) for headless tests, desktop/Web exports, installers, and release publishing
|
|
||||||
- **Targets:** Windows, Linux, macOS, and Web
|
|
||||||
|
|
||||||
### 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 |
|
|
||||||
| --- | --- |
|
|
||||||
|  |  |
|
|
||||||
|
|
||||||
```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.
|
|
||||||
|
|
||||||
| | |
|
|
||||||
| --- | --- |
|
|
||||||
|  |  |
|
|
||||||
|  |  |
|
|
||||||
|  |  |
|
|
||||||
|
|
||||||
- **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 six stages (day, portrait, evening, late, Joke Shop, studio);
|
|
||||||
`--stages=day,late` limits it while tuning. 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 texture-adoption 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. The character pass
|
|
||||||
([`2026-11-01-npc/`](docs/screens/realism/2026-11-01-npc/report.md)) adds the portrait shot and
|
|
||||||
records the new faces, hands and sleeve cuffs.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
# 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 |
|
|
||||||
|
|
||||||
## 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.x · **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
|
||||||
|
|
||||||
@@ -520,92 +105,25 @@ The full arc42-style architecture documentation lives in [`docs/architecture.md`
|
|||||||
- [`TODO.md`](TODO.md) — Active checkbox task list.
|
- [`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/gameplay.md`](docs/gameplay.md) — Gameplay systems, venues, and the escalation model.
|
||||||
|
- [`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/adr/`](docs/adr/) — Architecture Decision Records.
|
- [`docs/adr/`](docs/adr/) — Architecture Decision Records.
|
||||||
- [`docs/plan-graphics-v0.6.md`](docs/plan-graphics-v0.6.md) — Graphics-quality plan: Blender-authored NPC characters.
|
- [`docs/plan-graphics-v0.6.md`](docs/plan-graphics-v0.6.md) — Graphics-quality plan: Blender-authored NPC characters.
|
||||||
- [`docs/blender-assets.md`](docs/blender-assets.md) — Blender asset library: characters, gear, props, attach points.
|
|
||||||
|
|
||||||
## Development Status
|
## Development Status
|
||||||
|
|
||||||
Implemented so far:
|
v0.7.0 shipped distinct venue geometry: the Joke Shop alley and TV Studio
|
||||||
|
entrance no longer share the Hollywood layout. It builds on v0.6.1's fitted NPC
|
||||||
- Godot 4.3 project scaffold, input map, procedural world builder
|
gear and v0.6.0's Blender asset library, baked AO and environment lift. 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 full
|
||||||
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
|
implemented list and [`TODO.md`](TODO.md) for what's next. Manual verification
|
||||||
- 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)
|
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
|
|
||||||
|
|
||||||
- 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 now releases the start-menu input lock and recaptures the mouse; the body-camera overlay no longer covers the shift status.
|
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -3,67 +3,7 @@
|
|||||||
Work is grouped by theme. Each item should be small enough to complete and verify on its own;
|
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.
|
see `AGENTS.md` for the workflow and `MEMORY.md` for the decisions behind these plans.
|
||||||
|
|
||||||
## NPC gear fit (v0.6.1)
|
## Forward+ desktop preset and a volumetric flashlight (v0.7.1)
|
||||||
|
|
||||||
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`).
|
|
||||||
|
|
||||||
- [ ] Measure the fit in Blender, not by eye: extend `blender_assets.py audit` (or add a `fit`
|
|
||||||
subcommand) to print, per head/back/chest gear piece, its bounding box against the avatar
|
|
||||||
head width (measured 0.314 m on `npc_coat`), the head height band (y 1.55–1.81) and the
|
|
||||||
clothing radius (coat/dress skirt flare 0.225–0.26 m), for every character variant.
|
|
||||||
- [ ] Narrow `sunglasses` from 0.28 m to ~0.20 m. The width is driven by the arms/hinges
|
|
||||||
(`Hinge` at `x = ±0.11` with a 0.06 box, `Arm` at `±0.138`), not by the 0.03 m lenses, so
|
|
||||||
pull those in first and shrink the lenses to ~0.024 m radius; keep the lens plane at
|
|
||||||
`y = -0.152` (in front of the sclera) and `z = 0.035` (eye line) so it does not ride up
|
|
||||||
over the new brow ridge.
|
|
||||||
- [ ] Fix `shoulder_bag` clipping through `npc_coat`/`npc_dress`: the bag body spans `|x|`
|
|
||||||
0.17–0.25 at the hip while the skirt flares to 0.225–0.26, so its inner half is inside the
|
|
||||||
cloth. Move it outboard (or up onto the belt line) until the inner face clears the flare,
|
|
||||||
and keep the strap tube outside the same radius.
|
|
||||||
- [ ] Same pass for `cap`/`beanie`/`hood`/`firefighter_helmet`: they must not clip the new
|
|
||||||
brow/ear geometry, and `hood` must still leave the face readable for hoodie avatars.
|
|
||||||
- [ ] Rebuild the gear (`build`) and re-bake AO (`tools/bake_ao.py`), then
|
|
||||||
`godot --headless --path . --import`.
|
|
||||||
- [ ] Add a regression test: `tools/test_blender_assets.py` checks the glasses width against the
|
|
||||||
head width, and `src/test/tests/test_npc_character.gd` checks the head/back attach nodes
|
|
||||||
stay outside the body silhouette radius.
|
|
||||||
- [ ] Re-capture the portrait (`--stages=day,portrait`) and the gallery shots; record the
|
|
||||||
before/after in `docs/screens/realism/<date>-gear/`.
|
|
||||||
- [ ] Consider a second character close-up for a hoodie/winter NPC so headgear is covered too
|
|
||||||
(`--portrait-type=5` already forces a hooded role for comparison).
|
|
||||||
|
|
||||||
## Venue geometry and the committed gallery (v0.6.1)
|
|
||||||
|
|
||||||
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`.
|
|
||||||
|
|
||||||
- [ ] Extract a geometry spec per venue instead of branching inline: a `scenario.` (or
|
|
||||||
`WorldBuilder`) table of facade width/height, door style, window rows, awning/marquee
|
|
||||||
flags, street furniture set, palette and props, defaulting to the Hollywood values so
|
|
||||||
nothing regresses.
|
|
||||||
- [ ] 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.
|
|
||||||
- [ ] 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.
|
|
||||||
- [ ] Keep navigation working: regenerate/verify the `NavigationRegion3D` bake per venue,
|
|
||||||
update sidewalk/steps/door waypoints (Joke Shop and Studio already have `sidewalk` and
|
|
||||||
`steps_end`), and confirm NPCs still reach the door (smoke + `test_venue_*`).
|
|
||||||
- [ ] Split the concept mapping so each venue is judged on its own frame (`joke_shop_*`,
|
|
||||||
`studio_*`) and drop the "shared geometry" caveat in `tools/scene_concepts.py`.
|
|
||||||
- [ ] Add both venues to the committed gallery: two new stages in
|
|
||||||
`src/test/capture_screenshots.gd` (`12-joke-shop.png`, `13-studio-arrival.png`) reusing the
|
|
||||||
existing `joke_shop_queue` / `studio_arrival` designs, then `check_screenshots.sh` and a
|
|
||||||
fresh judge run.
|
|
||||||
- [ ] 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.0)
|
|
||||||
|
|
||||||
The Compatibility renderer has no volumetrics, so the flashlight is a tight pool of light with
|
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
|
no visible shaft. A second desktop build can render it properly without touching the CI/default
|
||||||
@@ -86,6 +26,10 @@ path.
|
|||||||
|
|
||||||
## Realism follow-ups (deferred)
|
## Realism follow-ups (deferred)
|
||||||
|
|
||||||
|
- [ ] Re-run the screen-design judge for `12-joke-shop` / `13-studio-arrival` when
|
||||||
|
`OPENROUTER_API_KEY` is valid; the 2026-11-01 attempt failed with an invalid-key HTTP 400.
|
||||||
|
- [ ] 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,
|
- [ ] 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`).
|
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.
|
Only worth doing if a higher-poly organic builder is added to the library.
|
||||||
|
|||||||
@@ -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
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1001,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
|
||||||
],
|
],
|
||||||
@@ -1017,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": {
|
||||||
@@ -1097,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
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 35 KiB |
@@ -37,7 +37,7 @@ This document follows the structure of the arc42 architecture documentation temp
|
|||||||
- **MINOR** — new venues, NPC archetypes, systems, or features that are backward compatible.
|
- **MINOR** — new venues, NPC archetypes, systems, or features that are backward compatible.
|
||||||
- **PATCH** — bug fixes and small polish that do not change behavior.
|
- **PATCH** — bug fixes and small polish that do not change behavior.
|
||||||
|
|
||||||
The single source of truth for the version is `config/version` in `project.godot` (currently `0.6.0`). Each release is tagged in git as `v<version>` (e.g. `v0.6.0`). The `0.x` series is pre-1.0, so MINOR bumps may introduce breaking changes while the prototype is still stabilizing. `tools/build_releases.sh` and `tools/publish_release.sh` read the version from `project.godot`, so bumping it there is the only required step before a release. The full bump checklist lives in [`RELEASING.md`](RELEASING.md).
|
The single source of truth for the version is `config/version` in `project.godot` (currently `0.7.0`). Each release is tagged in git as `v<version>` (e.g. `v0.6.0`). The `0.x` series is pre-1.0, so MINOR bumps may introduce breaking changes while the prototype is still stabilizing. `tools/build_releases.sh` and `tools/publish_release.sh` read the version from `project.godot`, so bumping it there is the only required step before a release. The full bump checklist lives in [`RELEASING.md`](RELEASING.md).
|
||||||
|
|
||||||
## 3. Context and Scope
|
## 3. Context and Scope
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ 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, prefers the Blender prop library in `assets/models/blender/props/`, and falls back to `assets/models/generated/` or primitives. It also wires the phase-driven library emissives and street dressing.
|
- `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.
|
- `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).
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
# Asset Pipeline and Tooling
|
||||||
|
|
||||||
|
Everything the game renders is produced or gated by the scripts below. 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
|
||||||
|
the [graphics plan](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: [`blender-assets.md`](blender-assets.md).
|
||||||
|
|
||||||
|
| Characters | Props |
|
||||||
|
| --- | --- |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
```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.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
|  |  |
|
||||||
|
|  |  |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
- **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 six stages (day, portrait, evening, late, Joke Shop, studio);
|
||||||
|
`--stages=day,late` limits it while tuning. 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 texture-adoption 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. The character pass
|
||||||
|
([`2026-11-01-npc/`](../docs/screens/realism/2026-11-01-npc/report.md)) adds the portrait shot and
|
||||||
|
records the new faces, hands and sleeve cuffs. The gear-fit run
|
||||||
|
([`2026-11-01-gear/`](../docs/screens/realism/2026-11-01-gear/report.md)) checks
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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`.
|
||||||
@@ -41,6 +41,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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 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: **231 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 now releases the start-menu input lock and recaptures the mouse; the body-camera overlay no longer covers the shift status.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
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`](../TODO.md)).
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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.
|
||||||
|
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 |
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
{
|
||||||
|
"run": "docs/screens/realism/2026-11-01-gear-judge",
|
||||||
|
"judge_model": "google/gemini-2.5-flash-lite",
|
||||||
|
"sets": {
|
||||||
|
"baseline": {
|
||||||
|
"npc_portrait": {
|
||||||
|
"target": "docs/screens/concepts/npc_portrait_day.png",
|
||||||
|
"metrics": {
|
||||||
|
"edge_density": 24.42,
|
||||||
|
"fine_detail": 9.88,
|
||||||
|
"contrast": 43.77,
|
||||||
|
"saturation_mean": 90.21,
|
||||||
|
"colour_spread": 50.11
|
||||||
|
},
|
||||||
|
"target_metrics": {
|
||||||
|
"edge_density": 7.47,
|
||||||
|
"fine_detail": 3.47,
|
||||||
|
"contrast": 52.66,
|
||||||
|
"saturation_mean": 93.44,
|
||||||
|
"colour_spread": 54.88
|
||||||
|
},
|
||||||
|
"metric_gap": 1.093,
|
||||||
|
"judge": {
|
||||||
|
"scores": {
|
||||||
|
"backdrop": 4.0,
|
||||||
|
"lighting": 3.0,
|
||||||
|
"overall": 2.0,
|
||||||
|
"materials": 2.0,
|
||||||
|
"texture_detail": 1.0,
|
||||||
|
"characters": 1.0
|
||||||
|
},
|
||||||
|
"score_range": {
|
||||||
|
"backdrop": [
|
||||||
|
4.0,
|
||||||
|
4.0
|
||||||
|
],
|
||||||
|
"lighting": [
|
||||||
|
3.0,
|
||||||
|
3.0
|
||||||
|
],
|
||||||
|
"overall": [
|
||||||
|
2.0,
|
||||||
|
2.0
|
||||||
|
],
|
||||||
|
"materials": [
|
||||||
|
2.0,
|
||||||
|
2.0
|
||||||
|
],
|
||||||
|
"texture_detail": [
|
||||||
|
1.0,
|
||||||
|
1.0
|
||||||
|
],
|
||||||
|
"characters": [
|
||||||
|
1.0,
|
||||||
|
1.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"runs": 1,
|
||||||
|
"fixes": [
|
||||||
|
"Improve character model realism with more detailed facial features, realistic skin shaders, and better clothing textures.",
|
||||||
|
"Enhance material properties for all objects, especially the brick building and pavement, to better simulate real-world reflectivity, roughness, and subsurface scattering.",
|
||||||
|
"Increase texture resolution and detail for all assets, particularly for the brickwork, pavement, and character clothing, to reduce the appearance of tiling and blurriness.",
|
||||||
|
"Refine lighting to match the warm, directional sunlight and soft shadows of the target image, paying attention to ambient occlusion and global illumination.",
|
||||||
|
"Add atmospheric perspective and volumetric fog to the backdrop to create a sense of depth and distance, mimicking the haze seen in the target image."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"candidate": {
|
||||||
|
"npc_portrait": {
|
||||||
|
"target": "docs/screens/concepts/npc_portrait_day.png",
|
||||||
|
"metrics": {
|
||||||
|
"edge_density": 24.43,
|
||||||
|
"fine_detail": 9.89,
|
||||||
|
"contrast": 43.74,
|
||||||
|
"saturation_mean": 90.17,
|
||||||
|
"colour_spread": 50.08
|
||||||
|
},
|
||||||
|
"target_metrics": {
|
||||||
|
"edge_density": 7.47,
|
||||||
|
"fine_detail": 3.47,
|
||||||
|
"contrast": 52.66,
|
||||||
|
"saturation_mean": 93.44,
|
||||||
|
"colour_spread": 54.88
|
||||||
|
},
|
||||||
|
"metric_gap": 1.094,
|
||||||
|
"judge": {
|
||||||
|
"scores": {
|
||||||
|
"backdrop": 5.0,
|
||||||
|
"lighting": 4.0,
|
||||||
|
"overall": 3.0,
|
||||||
|
"materials": 3.0,
|
||||||
|
"texture_detail": 2.0,
|
||||||
|
"characters": 2.0
|
||||||
|
},
|
||||||
|
"score_range": {
|
||||||
|
"backdrop": [
|
||||||
|
5.0,
|
||||||
|
5.0
|
||||||
|
],
|
||||||
|
"lighting": [
|
||||||
|
4.0,
|
||||||
|
4.0
|
||||||
|
],
|
||||||
|
"overall": [
|
||||||
|
3.0,
|
||||||
|
3.0
|
||||||
|
],
|
||||||
|
"materials": [
|
||||||
|
3.0,
|
||||||
|
3.0
|
||||||
|
],
|
||||||
|
"texture_detail": [
|
||||||
|
2.0,
|
||||||
|
2.0
|
||||||
|
],
|
||||||
|
"characters": [
|
||||||
|
2.0,
|
||||||
|
2.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"runs": 1,
|
||||||
|
"fixes": [
|
||||||
|
"Improve character model realism with more detailed facial features, realistic skin shaders, and better hair simulation.",
|
||||||
|
"Enhance material properties for clothing, pavement, and buildings to include subsurface scattering, anisotropic reflections, and more varied roughness maps.",
|
||||||
|
"Increase texture resolution and add micro-detail (e.g., pores, fabric weave, concrete imperfections) to all surfaces.",
|
||||||
|
"Implement more sophisticated global illumination and atmospheric scattering for a softer, more realistic lighting and haze effect.",
|
||||||
|
"Refine the backdrop by adding more complex building geometry, varied foliage, and realistic distant city details with appropriate atmospheric perspective."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Gear fit report: `docs/screens/realism/2026-11-01-gear`
|
||||||
|
|
||||||
|
Influencer (`npc_coat`) portrait, forced with `--portrait-type=2`, before the gear-fit
|
||||||
|
changes (`da876e3`, with the capture yaw/focus fixes backported) and after them (`a478bfb`).
|
||||||
|
Both frames use the same camera, sun and portrait staging. The focus disc is hidden because
|
||||||
|
it is a gameplay overlay, not part of the character still.
|
||||||
|
|
||||||
|
| measurement | before | after |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| sunglasses width | 0.282 m (0.90× head) | **0.202 m** (0.64× head) |
|
||||||
|
| bag body inner face | 0.168 m (coat skirt 0.225, dress 0.260) | **0.262 m** (clears both) |
|
||||||
|
| firefighter helmet brim | sat on the brow ridge | **raised 2 cm** |
|
||||||
|
|
||||||
|
Capture commands:
|
||||||
|
|
||||||
|
```
|
||||||
|
xvfb-run -a godot --path . --script res://src/test/capture_realism.gd -- \
|
||||||
|
--stages=portrait --portrait-type=2 \
|
||||||
|
--out=res://docs/screens/realism/2026-11-01-gear/candidate-v2
|
||||||
|
tools/check_screenshots.sh # 11 good / 0 bad
|
||||||
|
python3 tools/scene_concepts.py realism \
|
||||||
|
--run docs/screens/realism/2026-11-01-gear --judge
|
||||||
|
```
|
||||||
|
|
||||||
|
The judge improved the portrait from **overall 2 / characters 1** (baseline) to
|
||||||
|
**overall 3 / characters 2** (candidate); the remaining fixes are the broader realism
|
||||||
|
targets (skin/cloth shaders, GI, backdrop haze), not gear fit.
|
||||||
|
|
||||||
|
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 | npc_portrait | npc_portrait_day | 1.093 | 24.42 | 9.88 | 43.77 | 2.0 | 3.0 | 2.0 | 1.0 | 4.0 |
|
||||||
|
| candidate | npc_portrait | npc_portrait_day | 1.094 | 24.43 | 9.89 | 43.74 | 3.0 | 4.0 | 3.0 | 2.0 | 5.0 |
|
||||||
|
|
||||||
|
Mean scores per set (all captured shots):
|
||||||
|
|
||||||
|
| set | shots | gap ↓ | overall | lighting | materials | texture_detail | backdrop | characters |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| baseline | 1 | 1.093 | 2.0 | 3.0 | 2.0 | 1.0 | 4.0 | 1.0 |
|
||||||
|
| candidate | 1 | 1.094 | 3.0 | 4.0 | 3.0 | 2.0 | 5.0 | 2.0 |
|
||||||
|
|
||||||
|
**baseline / npc_portrait** fixes:
|
||||||
|
- Improve character model realism with more detailed facial features, realistic skin shaders, and better clothing textures.
|
||||||
|
- Enhance material properties for all objects, especially the brick building and pavement, to better simulate real-world reflectivity, roughness, and subsurface scattering.
|
||||||
|
- Increase texture resolution and detail for all assets, particularly for the brickwork, pavement, and character clothing, to reduce the appearance of tiling and blurriness.
|
||||||
|
- Refine lighting to match the warm, directional sunlight and soft shadows of the target image, paying attention to ambient occlusion and global illumination.
|
||||||
|
- Add atmospheric perspective and volumetric fog to the backdrop to create a sense of depth and distance, mimicking the haze seen in the target image.
|
||||||
|
|
||||||
|
**candidate / npc_portrait** fixes:
|
||||||
|
- Improve character model realism with more detailed facial features, realistic skin shaders, and better hair simulation.
|
||||||
|
- Enhance material properties for clothing, pavement, and buildings to include subsurface scattering, anisotropic reflections, and more varied roughness maps.
|
||||||
|
- Increase texture resolution and add micro-detail (e.g., pores, fabric weave, concrete imperfections) to all surfaces.
|
||||||
|
- Implement more sophisticated global illumination and atmospheric scattering for a softer, more realistic lighting and haze effect.
|
||||||
|
- Refine the backdrop by adding more complex building geometry, varied foliage, and realistic distant city details with appropriate atmospheric perspective.
|
||||||
|
After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 756 KiB After Width: | Height: | Size: 756 KiB |
|
Before Width: | Height: | Size: 856 KiB After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
@@ -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.
|
||||||
@@ -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.0"
|
||||||
run/main_scene="res://scenes/main.tscn"
|
run/main_scene="res://scenes/main.tscn"
|
||||||
config/features=PackedStringArray("4.3", "GL Compatibility")
|
config/features=PackedStringArray("4.3", "GL Compatibility")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -235,9 +241,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()
|
||||||
@@ -301,6 +324,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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
if _feature("skyline"):
|
||||||
_build_skyline_backdrop()
|
_build_skyline_backdrop()
|
||||||
|
if _feature("marquee"):
|
||||||
_build_marquee()
|
_build_marquee()
|
||||||
|
if _feature("bulb_canopy"):
|
||||||
|
_build_bulb_canopy()
|
||||||
_build_steps()
|
_build_steps()
|
||||||
_build_sidewalk()
|
_build_sidewalk()
|
||||||
_build_street()
|
_build_street()
|
||||||
|
if _feature("crosswalk"):
|
||||||
_build_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,144 @@ 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"):
|
||||||
|
if venue_id == "joke_shop":
|
||||||
|
_add_wall_lantern(Vector3(3.2, 3.3, -3.72), true)
|
||||||
|
else:
|
||||||
for side in [-4.9, 4.9]:
|
for side in [-4.9, 4.9]:
|
||||||
|
_add_wall_lantern(Vector3(side, 3.3, -3.78), false)
|
||||||
|
|
||||||
|
## The TV studio entrance canopy: a dark metal slab with a row of warm bulbs,
|
||||||
|
## replacing the boulevard marquee.
|
||||||
|
func _build_bulb_canopy() -> void:
|
||||||
|
var front := float(spec["facade_center_z"]) + float(spec["facade_depth"]) * 0.5
|
||||||
|
var canopy := _add_box(root, Vector3(7.2, 0.22, 2.2),
|
||||||
|
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 = 8.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")
|
var lantern := _load_prop_scene("wall_lantern")
|
||||||
if lantern == null:
|
if lantern == null:
|
||||||
continue
|
return
|
||||||
lantern.name = "WallLantern"
|
lantern.name = "WallLantern"
|
||||||
lantern.position = Vector3(side, 3.3, -3.78)
|
lantern.position = pos
|
||||||
root.add_child(lantern)
|
root.add_child(lantern)
|
||||||
library_nodes.append(lantern)
|
library_nodes.append(lantern)
|
||||||
_apply_library_ao(lantern, "wall_lantern")
|
_apply_library_ao(lantern, "wall_lantern")
|
||||||
for material in _emissive_materials(lantern):
|
for material in _emissive_materials(lantern):
|
||||||
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 with_light:
|
||||||
|
var omni := OmniLight3D.new()
|
||||||
|
omni.name = "LanternGlow"
|
||||||
|
omni.position = pos + Vector3(0.0, -0.1, 0.45)
|
||||||
|
omni.omni_range = 9.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,6 +659,7 @@ 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.
|
||||||
|
if _feature("street_lamps"):
|
||||||
for side in [-7.5, 7.5]:
|
for side in [-7.5, 7.5]:
|
||||||
var holder := _add_prop_model(root, "street_lamp",
|
var holder := _add_prop_model(root, "street_lamp",
|
||||||
Vector3(side, 0.18, 8.5), 180.0 if side < 0.0 else 0.0)
|
Vector3(side, 0.18, 8.5), 180.0 if side < 0.0 else 0.0)
|
||||||
@@ -440,6 +674,7 @@ func _build_street_furniture() -> void:
|
|||||||
_collect_emissive(holder)
|
_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.
|
||||||
|
if _feature("street_trees"):
|
||||||
for i in 6:
|
for i in 6:
|
||||||
var side := -1.0 if i % 2 == 0 else 1.0
|
var side := -1.0 if i % 2 == 0 else 1.0
|
||||||
var x := side * (6.5 + float(int(i / 2)) * 3.0)
|
var x := side * (6.5 + float(int(i / 2)) * 3.0)
|
||||||
@@ -585,8 +820,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 +1057,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:
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ 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))
|
||||||
|
|
||||||
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
|
||||||
@@ -264,8 +266,16 @@ func _stage_portrait(delta: float, angle_deg: float = 0.0) -> bool:
|
|||||||
look.y = 0.0
|
look.y = 0.0
|
||||||
gm.guard.rotation.y = atan2(-look.x, -look.z)
|
gm.guard.rotation.y = atan2(-look.x, -look.z)
|
||||||
gm.guard.head.rotation.x = deg_to_rad(1.0)
|
gm.guard.head.rotation.x = deg_to_rad(1.0)
|
||||||
# Turn the drawn rig (not the root) so the subject looks at the camera.
|
# The focus disc is a gameplay overlay, not part of the character still.
|
||||||
best.rotation.y = atan2(look.x, look.z)
|
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
|
return true
|
||||||
|
|
||||||
@@ -308,7 +318,12 @@ func _faces_covered(npc: Node3D) -> bool:
|
|||||||
var profile = brain.get("profile")
|
var profile = brain.get("profile")
|
||||||
if profile == null:
|
if profile == null:
|
||||||
return false
|
return false
|
||||||
return int(profile.get("npc_type")) in HOODED_TYPES
|
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.
|
||||||
|
|||||||
@@ -207,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -104,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",
|
||||||
@@ -961,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":
|
||||||
@@ -981,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"),
|
||||||
@@ -993,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,
|
||||||
@@ -1672,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": {}}
|
||||||
@@ -1735,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)
|
||||||
@@ -1766,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))
|
||||||
|
|||||||
@@ -191,10 +191,13 @@ 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",
|
"npc_portrait": "npc_portrait_day",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -369,7 +369,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 +391,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.",
|
||||||
|
|||||||
@@ -45,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()})
|
||||||
|
|||||||