Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
659b0e35b2 | ||
|
|
28f2a48811 | ||
|
|
64f0c6a198 | ||
|
|
8b0845bcce | ||
|
|
4cca2af2dd | ||
|
|
99f390078d | ||
|
|
a9fe6b9944 |
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,11 +170,11 @@ 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.
|
8. Commit the changes to the version control system with a descriptive message.
|
||||||
9. If a section from `TODO.md` has been fully completed, create a new version or tag in the version control system to mark the milestone.
|
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.
|
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.
|
11. Note the new version or tag created in the version control system in any relevant documentation or handoff summary.
|
||||||
|
|
||||||
|
|||||||
@@ -4,33 +4,107 @@ All notable user-facing changes to Gig Line. The format follows
|
|||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions use
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions use
|
||||||
Semantic Versioning.
|
Semantic Versioning.
|
||||||
|
|
||||||
|
## [0.7.2] — 2026-11-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Automatic OpenRouter model selection for the screenshot judges: the cheapest
|
||||||
|
currently available image+JSON-capable model is used, with ordered fallbacks
|
||||||
|
and a pinned `GIG_LINE_JUDGE_MODEL`/`--model` override.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The judge reports the model that produced each verdict and fails clearly on an
|
||||||
|
invalid API key instead of retrying other models.
|
||||||
|
- The screen-design and realism reports now include the fresh judge results.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The blocked screenshot judge now runs with the valid `OPENROUTER_API_KEY`
|
||||||
|
(`13/13` screen designs match, `0` diff).
|
||||||
|
|
||||||
|
## [0.7.1] — 2026-11-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- An optional Desktop HQ build for Windows and Linux that uses Forward+,
|
||||||
|
volumetric fog and SDFGI, packaged with an `override.cfg` beside the
|
||||||
|
executable.
|
||||||
|
- Volumetric flashlight haze in the Desktop HQ build, with a LATE-phase preset
|
||||||
|
tuned so the entrance facade stays readable.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The normal Compatibility build remains the default; the preset only affects
|
||||||
|
the extracted Desktop HQ copy.
|
||||||
|
- Night-time light falloff adapts to Godot 4.7 while keeping the 4.3 baseline
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The flashlight no longer disappears at night on Godot 4.7 under the old
|
||||||
|
attenuation curve.
|
||||||
|
- Headless and Web builds stay non-volumetric and boot normally when Forward+
|
||||||
|
is unavailable.
|
||||||
|
|
||||||
|
## [0.7.0] — 2026-11-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Agent workflow now requires maintaining this changelog for every patch, minor, and major version.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Distinct venue geometry: the Joke Shop is now a narrow brick alley with a
|
||||||
|
shuttered facade, warm wall lantern, dumpster and AC clutter; the TV Studio
|
||||||
|
has a stone facade, bulb-lit canopy, glossy black car and a stanchion/barrier
|
||||||
|
line at the door.
|
||||||
|
- The README gallery includes captured Joke Shop and TV Studio views.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Venue geometry is data-driven through `WorldBuilder.VENUE_SPECS`; switching
|
||||||
|
venues rebuilds the world while keeping the guard and UI intact.
|
||||||
|
- Each venue has its own navigation bounds and concept-gallery mapping.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The Joke Shop no longer renders the Hollywood skyline or marquee.
|
||||||
|
- The TV Studio no longer uses the boulevard marquee or palm-lined layout.
|
||||||
|
|
||||||
## [0.6.1] — 2026-11-01
|
## [0.6.1] — 2026-11-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Gear-fit checks: `tools/blender_assets.py fit` reports head/skirt clearance,
|
- Per-phase AI skyline panoramas (day, dusk, night) replacing the flat box skyline.
|
||||||
and the headless suite verifies that head and back gear clears the body
|
- Higher-detail surface textures with normal maps (brick, concrete, asphalt, awning, and more).
|
||||||
silhouette.
|
- A runtime image root (`assets/images/`) with a UI and scene art loader; the start menu,
|
||||||
- A refreshed Influencer portrait and gallery captures with a before/after
|
pause, settings, shift review, radio, wand, inspection, and radial menu are styled against
|
||||||
report in `docs/screens/realism/2026-11-01-gear/`.
|
their mockups.
|
||||||
- An explicit `--portrait-type` override for capturing hooded roles when a
|
- NPC face and hand detail: eyes, nose, lips, ears, fingered hands, and cuffed sleeves.
|
||||||
scenario spawns one.
|
- 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
|
### Changed
|
||||||
|
|
||||||
- Narrowed the sunglasses so they sit inside the face silhouette.
|
- Scene lighting, fog, sun angle, and night glow retuned to match the AI concept frames;
|
||||||
- Moved the shoulder bag outboard so it clears both the coat and dress skirts.
|
the Late phase uses wet asphalt.
|
||||||
- Raised the firefighter helmet so the brim no longer sits on the brow.
|
- The flashlight is a tighter forward spot that reads in the dark.
|
||||||
- Portrait captures now turn the subject toward the camera and hide the blue
|
- Sunglasses narrowed to sit inside the face silhouette; shoulder bag moved clear of the
|
||||||
focus overlay, so close-ups show the character rather than the UI.
|
coat and dress skirts; firefighter helmet lifted off the brow.
|
||||||
- The README now links to focused docs pages for gameplay, architecture, setup,
|
- The README now links to focused docs pages for gameplay, architecture, setup, asset
|
||||||
asset tooling, development status and the manual smoke checklist.
|
tooling, development status, and the manual smoke checklist; `PLAN.md` was compacted
|
||||||
|
into a design plan.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- The shoulder bag no longer clips through the coat's skirt.
|
- Sidewalk overexposure at the entrance reduced to match the concept target.
|
||||||
- The firefighter helmet no longer cuts into the brow ridge.
|
- The body-camera overlay no longer overlaps the shift/reputation/crowd status.
|
||||||
- The portrait subject no longer faces away from the camera.
|
- 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.
|
- `--portrait-type` is now parsed and applied instead of being ignored.
|
||||||
|
|
||||||
## [0.6.0] — 2026-11-01
|
## [0.6.0] — 2026-11-01
|
||||||
@@ -40,20 +114,14 @@ Semantic Versioning.
|
|||||||
- Blender-authored character, gear and prop library with baked ambient occlusion.
|
- Blender-authored character, gear and prop library with baked ambient occlusion.
|
||||||
- Higher-resolution environment textures with tangent normal maps.
|
- Higher-resolution environment textures with tangent normal maps.
|
||||||
- Phase-driven emissive details on library props, characters and street dressing.
|
- Phase-driven emissive details on library props, characters and street dressing.
|
||||||
- Dedicated README docs for gameplay, architecture, setup, asset tooling,
|
|
||||||
development status and manual smoke testing.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Environment lighting, fog, sun angle and night glow were retuned for the new
|
- The game was renamed from "Step Line" to "Gig Line".
|
||||||
skyline and surface assets.
|
|
||||||
- The flashlight beam is visible at night and the NPC detail pass adds faces,
|
|
||||||
hands and cuffs.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- The body-camera overlay no longer covers the shift status.
|
- NPCs now face their movement direction instead of walking backwards.
|
||||||
- Selecting a venue releases the menu input lock and recaptures the mouse.
|
|
||||||
|
|
||||||
## [0.5.0] — 2026-11-01
|
## [0.5.0] — 2026-11-01
|
||||||
|
|
||||||
@@ -76,6 +144,28 @@ Semantic Versioning.
|
|||||||
- Collision and navigation footprints for every street prop.
|
- Collision and navigation footprints for every street prop.
|
||||||
- Zoned prop placements with deterministic variants and small clutter.
|
- 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
|
## [0.3.0] — 2026-10-31
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -85,3 +175,153 @@ Semantic Versioning.
|
|||||||
- Framed facade windows, door trim, curb and lane markings.
|
- Framed facade windows, door trim, curb and lane markings.
|
||||||
- Generated street-furniture models and the Web export.
|
- Generated street-furniture models and the Web export.
|
||||||
- Aim-free targeting, a focus cone and precision walk.
|
- 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).
|
||||||
@@ -1242,3 +1242,97 @@ live in `tools/blender_assets.py` (`_build_gear()`), the contract in `docs/blend
|
|||||||
- [x] Consider a second character close-up for a hoodie/winter NPC so headgear is covered too.
|
- [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
|
`--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.
|
types 5-7, so no hooded subject was available; deferred as a scenario-spawn follow-up.
|
||||||
|
|
||||||
|
## Venue geometry and the committed gallery (v0.6.1)
|
||||||
|
|
||||||
|
Completed 2026-11-01. All three venues now build distinct geometry from `WorldBuilder.VENUE_SPECS`; the gallery has Joke Shop and TV Studio views (13 good / 0 bad). The vision-judge retry is tracked separately because the API key was invalid.
|
||||||
|
|
||||||
|
Today all three venues share the Hollywood `WorldBuilder` geometry, so the Joke Shop and studio
|
||||||
|
concepts are only compared on lighting (noted in `tools/scene_concepts.py` `SHOTS`). They also
|
||||||
|
have no stage in the committed `docs/screenshots/` gallery, only in `capture_realism.gd`.
|
||||||
|
|
||||||
|
- [x] Extract a geometry spec per venue instead of branching inline: `WorldBuilder.VENUE_SPECS`
|
||||||
|
now holds facade/stair/street/nav dimensions, window/pilaster rows, guard spawn, feature
|
||||||
|
flags and prop placements for Hollywood, Joke Shop and TV Studio. Unknown ids fall back to
|
||||||
|
Hollywood; `test_venue_geometry_specs_are_distinct_and_defaulted` checks the narrow Joke Shop
|
||||||
|
facade, disabled skyline/marquee, studio bulb-canopy flag and fallback.
|
||||||
|
- [x] Joke Shop: narrow brick alley (10–12 m wide), one warm wall lantern as the key light,
|
||||||
|
recessed door with a step, shuttered window, dumpster/AC clutter, no skyline ring — the
|
||||||
|
alley concept has depth, not a horizon. `_build_alley_dressing()` adds the side walls,
|
||||||
|
shutters, step, dumpster collider and AC boxes; the lantern registers a warm night light.
|
||||||
|
- [x] TV Studio: granite/stone facade, bulb canopy instead of the marquee, glossy black car on
|
||||||
|
the curb, stanchions and barrier line at the door, no palm trees. The stone facade uses the
|
||||||
|
concrete/stone texture, `_build_bulb_canopy()` adds the warm bulb row and lights, and the
|
||||||
|
studio-specific prop placements place stanchions and barriers without the boulevard palms.
|
||||||
|
- [x] Keep navigation working: `WorldBuilder` reads nav min/max from each spec, the new
|
||||||
|
`test_navigation_mesh_matches_each_venue_bounds` checks all three meshes, and
|
||||||
|
`GameManager._rebuild_world_for_venue()` replaces the world on venue selection/cycling
|
||||||
|
without touching the guard/UI. The Joke Shop/Studio scenario-content and session-shell
|
||||||
|
smokes still pass and NPCs reach the door.
|
||||||
|
- [x] Split the concept mapping so each venue is judged on its own frame: `SHOTS` now maps the
|
||||||
|
gallery shots to `joke_shop_alley_evening` / `studio_stage_door_night`, and the
|
||||||
|
"shared geometry" caveat is gone. `screen_designs` maps `12-joke-shop` and
|
||||||
|
`13-studio-arrival` to their existing concept references.
|
||||||
|
- [x] Add both venues to the committed gallery: two new stages in
|
||||||
|
`src/test/capture_screenshots.gd` (`12-joke-shop.png`, `13-studio-arrival.png`) reuse the
|
||||||
|
existing `joke_shop_queue` / `studio_arrival` designs. `check_screenshots.sh` is **13 good /
|
||||||
|
0 bad**; the new screenshots are 18.2 / 32.6 MAE against their references.
|
||||||
|
- [-] Update `docs/architecture.md` (venue geometry), `README.md` (gallery) and the screenshot
|
||||||
|
table when the stages land.
|
||||||
|
|
||||||
|
## Forward+ desktop preset and a volumetric flashlight (v0.7.1)
|
||||||
|
|
||||||
|
Completed 2026-11-01. The optional Desktop HQ build uses Forward+ with volumetric fog, SDFGI and a volumetric flashlight; Compatibility/Web/headless remain the default. The entrance A/B improved the metric gap 1.568 → 1.187. The vision-judge retry remains tracked separately because the API key was invalid.
|
||||||
|
|
||||||
|
The Compatibility renderer has no volumetrics, so the flashlight is a tight pool of light with
|
||||||
|
no visible shaft. A second desktop build can render it properly without touching the CI/default
|
||||||
|
path.
|
||||||
|
|
||||||
|
- [x] Write the preset as an `override.cfg` (Godot reads it beside the binary/project) that sets
|
||||||
|
`rendering/renderer/rendering_method="forward_plus"`, enables `volumetric_fog_*` and SDFGI,
|
||||||
|
and keeps the Compatibility build as the default (`project.godot` unchanged). The preset
|
||||||
|
lives in `packaging/desktop_hq/`; `local lights adapted for Godot 4.7 falloff` too.
|
||||||
|
- [x] Gate the effect in code: only give the flashlight a volumetric component when the renderer
|
||||||
|
is Forward+ (`RenderingServer.get_rendering_device() != null`), so Web/CI builds are
|
||||||
|
untouched and the existing headless tests keep passing. Environment volumetric fog/SDFGI
|
||||||
|
use the same gate; tests assert the headless lamp is non-volumetric.
|
||||||
|
- [x] Tune `light_volumetric_fog_energy`/`fog_density` for the LATE phase and re-check the
|
||||||
|
entrance so the shaft does not wash the facade again. LATE density is 0.020 and the lamp
|
||||||
|
volumetric energy is 3.0; the entrance read improves without bleaching the brick.
|
||||||
|
- [x] A/B the two renderers with `capture_realism.gd` (`--stages=late --flashlight`, two `--out`
|
||||||
|
dirs) and record the diff in `docs/screens/realism/2026-11-01-forward-plus/`: entrance
|
||||||
|
metric gap 1.568 → 1.187, two-shot mean 0.907 → 0.723. Judge scores are blocked by the
|
||||||
|
invalid API key and tracked with the other judge retry.
|
||||||
|
- [x] Add the preset to `tools/build_releases.sh` and `docs/RELEASING.md` as an optional
|
||||||
|
"desktop HQ" artifact, and note the runtime requirement (Vulkan driver) in `README.md`.
|
||||||
|
`gig-line-desktop-hq-<version>.zip` packages the Linux/Windows binaries, override.cfg and
|
||||||
|
README; `publish_release.sh` uploads it when present.
|
||||||
|
- [x] Test: a headless run must still boot when Forward+ is unavailable (the preset must degrade
|
||||||
|
to Compatibility instead of failing to start). Verified with the preset in the project
|
||||||
|
root: `godot --headless --path . --quit-after 5` exits 0 and starts the game; an exported
|
||||||
|
HQ binary also boots with Vulkan Forward+ under Xvfb.
|
||||||
|
|
||||||
|
## Vision judge reliability (v0.7.2)
|
||||||
|
|
||||||
|
Completed 2026-11-01. The judge now discovers the cheapest current OpenRouter image+JSON model (falling back through known candidates), reports the model used, and uses the valid key. Full screen-design judge: 13/13 match, 0 diff.
|
||||||
|
|
||||||
|
The judge failed with an invalid-key HTTP 400 even though the `.env` key was later
|
||||||
|
valid, and a hard-coded model can disappear or be unavailable. Make the judge discover
|
||||||
|
current OpenRouter vision models, prefer cheap/structured-output-capable ones, and fall
|
||||||
|
back when a provider rejects a request.
|
||||||
|
|
||||||
|
- [x] Add `tools/openrouter_judge.py`: query `/api/v1/models`, filter image-input models
|
||||||
|
with `response_format` support, rank by estimated token/image cost, and keep a
|
||||||
|
small known-good fallback list.
|
||||||
|
- [x] Let both `screen_designs.py judge` and `scene_concepts.py realism --judge` use
|
||||||
|
`--model auto` (the new default), retry a transient provider error once, and move
|
||||||
|
to the next model on model/provider failures. An invalid key fails with exit 2.
|
||||||
|
- [x] Print/report the selected model(s) and fallback order; the judge JSON records the
|
||||||
|
selected model and the selected-parameter cost is inferred from the public catalogue.
|
||||||
|
- [x] Add `tools/test_openrouter_judge.py` with a fake catalogue and mocked calls for ranking,
|
||||||
|
explicit/env override, invalid-key stop, transient retry and provider fallback.
|
||||||
|
- [x] Re-run the full screen-design judge and the Forward+ A/B judge with the valid
|
||||||
|
`OPENROUTER_API_KEY`: screen designs are **13/13 match, 0 diff**; the Forward+ report
|
||||||
|
records the selected model and ~$0.006 spend.
|
||||||
|
- [x] Update `docs/asset-tooling.md`, `MEMORY.md`, `README.md` and `CHANGELOG.md`; the
|
||||||
|
public catalogue picks the cheapest image+JSON model (`z-ai/glm-5.3-flash` at run time).
|
||||||
@@ -44,6 +44,23 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
- Shift length is the scenario's `shift_duration_s` (900 s in all three venues). Tests and the screenshot tool override elapsed time; they do not change the default.
|
- Shift length is the scenario's `shift_duration_s` (900 s in all three venues). Tests and the screenshot tool override elapsed time; they do not change the default.
|
||||||
- RNG: NPC reactions and event scheduling are seedable for deterministic tests.
|
- RNG: NPC reactions and event scheduling are seedable for deterministic tests.
|
||||||
|
|
||||||
|
- **Vision judge (v0.7.2):** `tools/openrouter_judge.py` reads `/api/v1/models`, filters image-input models with `response_format`, and ranks by advertised prompt/image/completion prices. `--model auto` (default) uses the cheapest (`z-ai/glm-5.3-flash` on 2026-11-01) with fallbacks; explicit `--model`/`GIG_LINE_JUDGE_MODEL` wins. Auth errors abort, provider/model errors fall through. Full screen judge 13/13 match, 0 diff; A/B judge ~$0.006.
|
||||||
|
- **Desktop HQ preset (v0.7.1):** `packaging/desktop_hq/override.cfg` switches a
|
||||||
|
copied executable to Forward+; `Materials.forward_plus_active()` gates
|
||||||
|
volumetric fog/SDFGI and `light_volumetric_fog_energy`, and
|
||||||
|
`Materials.modern_light_falloff()` compensates for Godot 4.7's much harsher
|
||||||
|
local-light falloff. LATE volumetric density is 0.020 with flashlight
|
||||||
|
volumetric energy 3.0. `tools/build_releases.sh` packages the Linux/Windows
|
||||||
|
binaries + override into `gig-line-desktop-hq-<version>.zip`;
|
||||||
|
`publish_release.sh` uploads it when present. A/B report:
|
||||||
|
`docs/screens/realism/2026-11-01-forward-plus/` (entrance gap 1.568→1.187).
|
||||||
|
- **Local Godot toolchain (2026-11-01):** `~/.local/bin/godot` is 4.7.2 stable;
|
||||||
|
4.3 stable is kept as `~/.local/bin/godot-4.3.stable`. Export templates for
|
||||||
|
4.7.2 are installed under `~/.local/share/godot/export_templates/4.7.2.stable`
|
||||||
|
(4.3 remains). The project and CI still pin 4.3 in `project.godot`/the Gitea
|
||||||
|
workflow; 4.7.2 imports, runs the 231-test suite, session-shell smoke and a
|
||||||
|
Linux export successfully. Godot 4.7 writes `*.gd.uid` files; they are kept
|
||||||
|
out of this clone's status via `.git/info/exclude` until the project upgrades.
|
||||||
- **Blender invocation (v0.6.0):** `flatpak run org.blender.Blender --background --python
|
- **Blender invocation (v0.6.0):** `flatpak run org.blender.Blender --background --python
|
||||||
/run/media/system/DATA/Documents/06-games/security-game/tools/npc_pipeline.py -- build`.
|
/run/media/system/DATA/Documents/06-games/security-game/tools/npc_pipeline.py -- build`.
|
||||||
The flatpak cannot read `/tmp`; use absolute repo paths. `assets/models/npc/.gdignore` is
|
The flatpak cannot read `/tmp`; use absolute repo paths. `assets/models/npc/.gdignore` is
|
||||||
@@ -72,7 +89,7 @@ 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 gear-fit pass):** v0.6.1 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
|
||||||
**229/229**, Python tooling **87/87**, `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,
|
||||||
@@ -246,6 +263,8 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
helmet is lifted 2 cm above the brow. `test_npc_character` guards head/back gear in Godot.
|
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
|
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.
|
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
|
- **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
|
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
|
left the focus disc over the face. An explicit `--portrait-type=N` now overrides the hooded
|
||||||
|
|||||||
@@ -168,11 +168,11 @@ Primary decisions: [ADR-0007](docs/adr/0007-locomotion-and-input-layers.md), [AD
|
|||||||
- **Persistence:** JSON in `user://` for settings and shift history.
|
- **Persistence:** JSON in `user://` for settings and shift history.
|
||||||
- **Testing:** custom headless runner (`src/test/test_runner.gd`); GUT was evaluated and not adopted. Python tooling tests cover the asset/screenshot pipelines.
|
- **Testing:** custom headless runner (`src/test/test_runner.gd`); GUT was evaluated and not adopted. Python tooling tests cover the asset/screenshot pipelines.
|
||||||
- **Source control/CI:** git on Gitea; `.gitea/workflows/build.yml` runs headless checks and builds/publishes releases on tags.
|
- **Source control/CI:** git on Gitea; `.gitea/workflows/build.yml` runs headless checks and builds/publishes releases on tags.
|
||||||
- **Targets:** Windows, Linux, macOS, and Web. Release artifacts include portables, an NSIS installer, an AppImage, and a Web zip.
|
- **Targets:** Windows, Linux, macOS, and Web. Release artifacts include portables, an NSIS installer, an AppImage, a Web zip, and an optional Desktop HQ zip (Forward+ volumetric lighting; Vulkan required).
|
||||||
|
|
||||||
## Versioning and Delivery
|
## Versioning and Delivery
|
||||||
|
|
||||||
`config/version` in `project.godot` is the single version source (currently `0.6.1`). 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.2`). Releases are tagged `v<version>`; the CI pipeline builds and publishes the standard artifacts plus the optional Desktop HQ zip. Version bumps follow [Semantic Versioning](https://semver.org/). See [`docs/RELEASING.md`](docs/RELEASING.md) and [`README.md`](README.md) for commands.
|
||||||
|
|
||||||
## Design Direction
|
## Design Direction
|
||||||
|
|
||||||
|
|||||||
@@ -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.1** — fitted NPC gear, a refreshed portrait/gallery capture and the
|
> **Latest: v0.7.0** — distinct Joke Shop and TV Studio geometry, fitted NPC gear,
|
||||||
> post-v0.6.0 realism/visual-alignment work (v2 skylines/textures, UI mockup art,
|
> and the post-v0.6.0 realism/visual-alignment work (v2 skylines/textures, UI mockup
|
||||||
> flashlight beam, HUD fixes).
|
> 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
|
||||||
@@ -50,7 +52,7 @@ The central design goal is simple:
|
|||||||
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
|
||||||
[Setup and Run](#setup-and-run) below.
|
[Setup and Run](#setup-and-run) below.
|
||||||
|
|
||||||
The project is under active development. The design and architecture are documented, and a playable prototype vertical slice exists (Godot 4.3 + GDScript): a procedural Hollywood Boulevard entrance, a first-person guard, simulated NPCs with intents and reactions, the six-level escalation model, access inspection and admission decisions, radio calls with credibility checks, reputation, crowd mood, and an end-of-shift review.
|
The project is under active development. The design and architecture are documented, and a playable prototype vertical slice exists (Godot 4 + GDScript): a procedural Hollywood Boulevard entrance, a first-person guard, simulated NPCs with intents and reactions, the six-level escalation model, access inspection and admission decisions, radio calls with credibility checks, reputation, crowd mood, and an end-of-shift review.
|
||||||
|
|
||||||
## Game Overview
|
## Game Overview
|
||||||
|
|
||||||
@@ -116,9 +118,9 @@ Full setup, every run/test/smoke command, and the controls table are in
|
|||||||
|
|
||||||
## Development Status
|
## Development Status
|
||||||
|
|
||||||
v0.6.1 shipped (fitted NPC gear and refreshed captures) on top of v0.6.0's
|
v0.7.0 shipped distinct venue geometry: the Joke Shop alley and TV Studio
|
||||||
Blender character/gear/prop library, baked AO and environment lift. The
|
entrance no longer share the Hollywood layout. It builds on v0.6.1's fitted NPC
|
||||||
realism and visual-alignment passes are part of the same release. See
|
gear and v0.6.0's Blender asset library, baked AO and environment lift. See
|
||||||
[`docs/development-status.md`](docs/development-status.md) for the full
|
[`docs/development-status.md`](docs/development-status.md) for the full
|
||||||
implemented list and [`TODO.md`](TODO.md) for what's next. Manual verification
|
implemented list and [`TODO.md`](TODO.md) for what's next. Manual verification
|
||||||
uses the [`docs/smoke-checklist.md`](docs/smoke-checklist.md).
|
uses the [`docs/smoke-checklist.md`](docs/smoke-checklist.md).
|
||||||
|
|||||||
@@ -3,56 +3,10 @@
|
|||||||
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.
|
||||||
|
|
||||||
## 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
|
|
||||||
no visible shaft. A second desktop build can render it properly without touching the CI/default
|
|
||||||
path.
|
|
||||||
|
|
||||||
- [ ] Write the preset as an `override.cfg` (Godot reads it beside the binary/project) that sets
|
|
||||||
`rendering/renderer/rendering_method="forward_plus"`, enables `volumetric_fog_*` and SDFGI,
|
|
||||||
and keeps the Compatibility build as the default (`project.godot` unchanged).
|
|
||||||
- [ ] Gate the effect in code: only give the flashlight a volumetric component when the renderer
|
|
||||||
is Forward+ (`RenderingServer.get_rendering_device() != null`), so Web/CI builds are
|
|
||||||
untouched and the existing headless tests keep passing.
|
|
||||||
- [ ] Tune `light_volumetric_fog_energy`/`fog_density` for the LATE phase and re-check the
|
|
||||||
entrance so the shaft does not wash the facade again (the reason the wide cone was cut).
|
|
||||||
- [ ] A/B the two renderers with `capture_realism.gd` (same stages, two `--out` dirs) and record
|
|
||||||
the diff + judge scores in `docs/screens/realism/<date>-forward-plus/`.
|
|
||||||
- [ ] Add the preset to `tools/build_releases.sh` and `docs/RELEASING.md` as an optional
|
|
||||||
"desktop HQ" artifact, and note the runtime requirement (Vulkan driver) in `README.md`.
|
|
||||||
- [ ] Test: a headless run must still boot when Forward+ is unavailable (the preset must degrade
|
|
||||||
to Compatibility instead of failing to start).
|
|
||||||
|
|
||||||
## Realism follow-ups (deferred)
|
## Realism follow-ups (deferred)
|
||||||
|
|
||||||
|
- [x] Re-run the screen-design judge for `12-joke-shop`, `13-studio-arrival` and the
|
||||||
|
Forward+ A/B with the valid key: **13/13 match, 0 diff**; the HQ A/B is in the report.
|
||||||
- [ ] Capture a hooded/winter NPC close-up once a scenario spawns types 5-7 (the explicit
|
- [ ] 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`).
|
`--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,
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ time you export, either:
|
|||||||
`Godot_v4.3-stable_export_templates.tpz` contents into
|
`Godot_v4.3-stable_export_templates.tpz` contents into
|
||||||
`~/.local/share/godot/export_templates/4.3.stable/`.
|
`~/.local/share/godot/export_templates/4.3.stable/`.
|
||||||
|
|
||||||
Verify with:
|
The templates must match the editor binary you use locally. CI pins Godot 4.3;
|
||||||
|
the local development machine uses 4.7.2 with its matching templates. Verify with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
godot --headless --export-release "Linux" build/gig-line-smoke.x86_64
|
godot --headless --export-release "Linux" build/gig-line-smoke.x86_64
|
||||||
@@ -86,6 +87,8 @@ Artifacts are written to `build/` (all exports embed the PCK — single files):
|
|||||||
- `build/gig-line-macos-<version>.zip`
|
- `build/gig-line-macos-<version>.zip`
|
||||||
- `build/gig-line-web-<version>.zip` — browser build (non-threaded Godot web
|
- `build/gig-line-web-<version>.zip` — browser build (non-threaded Godot web
|
||||||
template; unzip and serve the folder, no COOP/COEP headers required)
|
template; unzip and serve the folder, no COOP/COEP headers required)
|
||||||
|
- `build/gig-line-desktop-hq-<version>.zip` — optional Forward+ Windows/Linux
|
||||||
|
build with `override.cfg`; requires a Vulkan-capable GPU/driver
|
||||||
|
|
||||||
When run in CI (tags/dispatch), the build job also packages:
|
When run in CI (tags/dispatch), the build job also packages:
|
||||||
|
|
||||||
@@ -94,6 +97,10 @@ When run in CI (tags/dispatch), the build job also packages:
|
|||||||
- `build/gig-line-<version>-x86_64.AppImage` — Linux AppImage (`packaging/linux/build_appimage.sh`,
|
- `build/gig-line-<version>-x86_64.AppImage` — Linux AppImage (`packaging/linux/build_appimage.sh`,
|
||||||
appimagetool via `APPIMAGE_EXTRACT_AND_RUN`)
|
appimagetool via `APPIMAGE_EXTRACT_AND_RUN`)
|
||||||
|
|
||||||
|
The optional Desktop HQ zip is assembled by `tools/build_releases.sh` from
|
||||||
|
`packaging/desktop_hq/`; keep `override.cfg` next to the extracted executable.
|
||||||
|
It is not part of the default Compatibility/Web build.
|
||||||
|
|
||||||
Locally you can build the AppImage with `bash packaging/linux/build_appimage.sh <version>`;
|
Locally you can build the AppImage with `bash packaging/linux/build_appimage.sh <version>`;
|
||||||
the NSIS installer requires `makensis` (covered by CI).
|
the NSIS installer requires `makensis` (covered by CI).
|
||||||
|
|
||||||
@@ -114,7 +121,8 @@ GITEA_TOKEN=your_api_token ./tools/publish_release.sh 0.2.1
|
|||||||
|
|
||||||
The script creates (or reuses) the release from `project.godot`'s
|
The script creates (or reuses) the release from `project.godot`'s
|
||||||
`config/version`, uploads the three portable artifacts **plus** the NSIS
|
`config/version`, uploads the three portable artifacts **plus** the NSIS
|
||||||
installer and AppImage when present, and prints the release URL. If CI already
|
installer, AppImage, Web zip and Desktop HQ zip when present, and prints the
|
||||||
|
release URL. If CI already
|
||||||
published the release, re-running the script only adds missing assets.
|
published the release, re-running the script only adds missing assets.
|
||||||
|
|
||||||
> **Host note**: the release is fully built by CI, but anonymous users can only
|
> **Host note**: the release is fully built by CI, but anonymous users can only
|
||||||
|
|||||||
@@ -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.1`). 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.2`). 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).
|
||||||
@@ -146,8 +146,8 @@ Future options include external analytics, cloud saves, or replay sharing, but t
|
|||||||
A continuous-integration pipeline keeps the headless test suite green on every change and publishes tagged releases.
|
A continuous-integration pipeline keeps the headless test suite green on every change and publishes tagged releases.
|
||||||
|
|
||||||
- **Workflow:** `.gitea/workflows/build.yml` runs on pushes, pull requests, tags, and manual dispatches. The `checks` job installs Godot 4.3, imports the project, and runs `res://src/test/test_runner.gd`; it fails on a non-zero exit code or any `SCRIPT ERROR` in the output.
|
- **Workflow:** `.gitea/workflows/build.yml` runs on pushes, pull requests, tags, and manual dispatches. The `checks` job installs Godot 4.3, imports the project, and runs `res://src/test/test_runner.gd`; it fails on a non-zero exit code or any `SCRIPT ERROR` in the output.
|
||||||
- **Build job:** runs for tags and manual dispatches. It installs export templates, exports Linux/Windows/macOS and the non-threaded Web build, packages the NSIS installer and Linux AppImage, verifies the installer contents, and uploads the artifacts.
|
- **Build job:** runs for tags and manual dispatches. It installs export templates, exports Linux/Windows/macOS and the non-threaded Web build, packages the NSIS installer, Linux AppImage and optional Desktop HQ zip, verifies the installer contents, and uploads the artifacts.
|
||||||
- **Publish job:** tag builds call `tools/publish_release.sh` to create the Gitea release and upload the six artifacts. The script uses `GITEA_TOKEN` when configured and otherwise reuses the runner's checkout extraheader credentials.
|
- **Publish job:** tag builds call `tools/publish_release.sh` to create the Gitea release and upload the standard artifacts plus the optional Desktop HQ zip. The script uses `GITEA_TOKEN` when configured and otherwise reuses the runner's checkout extraheader credentials.
|
||||||
- **Hosting:** the repository lives on Gitea (`https://git.allucanget.biz/zwitschi/security-game`). Export templates must match the pinned Godot version; macOS builds are unsigned and need quarantine guidance or notarization for wider distribution.
|
- **Hosting:** the repository lives on Gitea (`https://git.allucanget.biz/zwitschi/security-game`). Export templates must match the pinned Godot version; macOS builds are unsigned and need quarantine guidance or notarization for wider distribution.
|
||||||
|
|
||||||
### Release management
|
### Release management
|
||||||
@@ -160,7 +160,7 @@ Releases are produced from a tagged commit and published to the repository host'
|
|||||||
- Packaging — NSIS builds the Windows installer; `packaging/linux/build_appimage.sh` builds the AppImage.
|
- Packaging — NSIS builds the Windows installer; `packaging/linux/build_appimage.sh` builds the AppImage.
|
||||||
- [`RELEASING.md`](RELEASING.md) — step-by-step guide and release checklist.
|
- [`RELEASING.md`](RELEASING.md) — step-by-step guide and release checklist.
|
||||||
|
|
||||||
The release process is: bump the version, run the full test suite and `tools/check_screenshots.sh`, run `tools/build_releases.sh`, tag and push (CI publishes), then verify the README "Downloads" links and the six artifacts.
|
The release process is: bump the version, run the full test suite and `tools/check_screenshots.sh`, run `tools/build_releases.sh`, tag and push (CI publishes), then verify the README "Downloads" links and the release artifacts.
|
||||||
|
|
||||||
## 8. Cross-Cutting Concepts
|
## 8. Cross-Cutting Concepts
|
||||||
|
|
||||||
|
|||||||
@@ -185,8 +185,15 @@ python3 tools/scene_concepts.py realism --run docs/screens/realism/<run> --judge
|
|||||||
--model google/gemini-2.5-flash --repeats 3
|
--model google/gemini-2.5-flash --repeats 3
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Judges default to `--model auto`: `tools/openrouter_judge.py` reads the live
|
||||||
|
OpenRouter catalogue, keeps image-input models that support `response_format`,
|
||||||
|
and ranks them by advertised token/image price. The run falls through the list
|
||||||
|
on provider/model errors and aborts on an invalid key. Use
|
||||||
|
`GIG_LINE_JUDGE_MODEL` or `--model <id>` for a pinned comparison.
|
||||||
|
|
||||||
`capture_realism.gd` runs six stages (day, portrait, evening, late, Joke Shop, studio);
|
`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
|
`--stages=day,late` limits it while tuning; `--flashlight` turns on the guard lamp for
|
||||||
|
night stages (used by the Desktop HQ A/B). The `portrait` shot freezes one NPC on open sidewalk
|
||||||
and frames the face, which is the only capture that can judge avatar detail
|
and frames the face, which is the only capture that can judge avatar detail
|
||||||
(`--portrait-type=N` picks a role). The texture-adoption run,
|
(`--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),
|
[`docs/screens/realism/2026-11-01-adopt/`](../docs/screens/realism/2026-11-01-adopt/report.md),
|
||||||
@@ -194,7 +201,10 @@ compares the pre-adoption captures with the current build: on the shots both set
|
|||||||
judge's like-for-like means rose on every sub-score (overall 0.8 → 1.0, backdrop 0.6 → 1.6) and
|
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
|
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
|
([`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
|
records the new faces, hands and sleeve cuffs. The
|
||||||
|
Forward+ A/B ([`2026-11-01-forward-plus/`](../docs/screens/realism/2026-11-01-forward-plus/report.md))
|
||||||
|
compares the Compatibility entrance with the optional Desktop HQ preset.
|
||||||
|
The gear-fit run
|
||||||
([`2026-11-01-gear/`](../docs/screens/realism/2026-11-01-gear/report.md)) checks
|
([`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
|
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
|
`python3 tools/blender_assets.py fit`; it also fixed the portrait yaw and hid
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Implemented so far:
|
|||||||
- Godot 4.3 project scaffold, input map, procedural world builder
|
- Godot 4.3 project scaffold, input map, procedural world builder
|
||||||
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
|
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
|
||||||
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
|
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
|
||||||
- Headless unit tests: **229 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, input remaps, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material, player movement, NPC character layers/LOD/queue, input bindings)
|
- Headless unit tests: **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
|
- Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event
|
||||||
- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
|
- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
|
||||||
- Control pass: focus cone on NPC heads with a billboarded blue circle in front of the face and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`)
|
- Control pass: focus cone on NPC heads with a billboarded blue circle in front of the face and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`)
|
||||||
@@ -41,6 +41,9 @@ Implemented so far:
|
|||||||
- Visual alignment: start/venue menu, pause, settings, shift review, radio, wand, inspection, timeline, HUD target chip/escalation bar, and radial menu now use or match their AI references in `assets/images/ui/`. The runtime art loader (`src/game/ui/art.gd`) falls back cleanly when a file is missing.
|
- Visual alignment: start/venue menu, pause, settings, shift review, radio, wand, inspection, timeline, HUD target chip/escalation bar, and radial menu now use or match their AI references in `assets/images/ui/`. The runtime art loader (`src/game/ui/art.gd`) falls back cleanly when a file is missing.
|
||||||
- Realism adoption (post-v0.6.0): v2 per-phase skylines and surface tiles with normal maps, retuned sun/exposure/fog, wet asphalt, denser foliage, a visible additive flashlight beam, and refreshed staging captures. `tools/check_screenshots.sh` is the repeatable capture/compare gate; judge is clean of `diff` verdicts.
|
- Realism adoption (post-v0.6.0): v2 per-phase skylines and surface tiles with normal maps, retuned sun/exposure/fog, wet asphalt, denser foliage, a visible additive flashlight beam, and refreshed staging captures. `tools/check_screenshots.sh` is the repeatable capture/compare gate; judge is clean of `diff` verdicts.
|
||||||
- Input/reliability fixes: selecting a venue now releases the start-menu input lock and recaptures the mouse; the body-camera overlay no longer covers the shift status.
|
- 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.
|
||||||
|
- Vision judge (v0.7.2): discovers the cheapest current OpenRouter image+JSON model, reports the model/fallbacks used, and clears the full screen-design judge (13/13 match, 0 diff).
|
||||||
|
- Desktop HQ (v0.7.1): optional Forward+ Windows/Linux zip with volumetric fog, SDFGI and a volumetric flashlight; the default Compatibility/Web builds and headless tests gate the effect off. A/B captures live in `docs/screens/realism/2026-11-01-forward-plus/`.
|
||||||
|
- Venue geometry (v0.7.0): the Joke Shop now builds as a narrow brick alley with a warm wall lantern, shutters, dumpster/AC clutter and no skyline; the TV Studio builds a stone facade, bulb canopy, glossy black car and barrier line. `WorldBuilder.VENUE_SPECS` drives facade/street/nav dimensions, and `GameManager` rebuilds only the world container when the venue changes.
|
||||||
- Gear fit (post-v0.6.0): Blender gear is measured against the avatar head/skirt silhouette and the checks run in both the Python tooling and the headless suite. Sunglasses, the shoulder bag and the firefighter helmet were fitted to the new faces; the Influencer portrait and gallery were recaptured with a before/after report.
|
- Gear fit (post-v0.6.0): Blender gear is measured against the avatar head/skirt silhouette and the checks run in both the Python tooling and the headless suite. Sunglasses, the shoulder bag and the firefighter helmet were fitted to the new faces; the Influencer portrait and gallery were recaptured with a before/after report.
|
||||||
|
|
||||||
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)).
|
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)).
|
||||||
@@ -26,11 +26,11 @@ Authorized people must either be recognized by the guard or present a valid wris
|
|||||||
|
|
||||||
### The Joke Shop
|
### 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.
|
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
|
### 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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. Download the Godot 4.3 editor (official build) and put `godot` on your PATH.
|
1. Download the Godot 4.3+ editor (official build) and put `godot` on your PATH. CI pins 4.3; local development is verified with 4.7.2.
|
||||||
2. Open or run the project from this directory.
|
2. Open or run the project from this directory.
|
||||||
|
|
||||||
## Run and Test
|
## Run and Test
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
{
|
||||||
|
"run": "docs/screens/realism/2026-11-01-forward-plus",
|
||||||
|
"judge_model": "z-ai/glm-5.3-flash",
|
||||||
|
"judge_fallbacks": [
|
||||||
|
"z-ai/glm-5.3-flash",
|
||||||
|
"qwen/qwen3.7-flash",
|
||||||
|
"google/gemma-3-4b-it",
|
||||||
|
"google/gemma-3-12b-it",
|
||||||
|
"inclusionai/ling-3.0-flash-vl",
|
||||||
|
"prism-ml/ternary-bonsai-2-9b"
|
||||||
|
],
|
||||||
|
"sets": {
|
||||||
|
"baseline": {
|
||||||
|
"entrance_late": {
|
||||||
|
"target": "docs/screens/concepts/hollywood_entrance_rain_night.png",
|
||||||
|
"metrics": {
|
||||||
|
"edge_density": 21.3,
|
||||||
|
"fine_detail": 9.55,
|
||||||
|
"contrast": 68.03,
|
||||||
|
"saturation_mean": 127.45,
|
||||||
|
"colour_spread": 67.44
|
||||||
|
},
|
||||||
|
"target_metrics": {
|
||||||
|
"edge_density": 8.43,
|
||||||
|
"fine_detail": 3.51,
|
||||||
|
"contrast": 27.34,
|
||||||
|
"saturation_mean": 159.44,
|
||||||
|
"colour_spread": 26.61
|
||||||
|
},
|
||||||
|
"metric_gap": 1.568,
|
||||||
|
"judge": {
|
||||||
|
"scores": {
|
||||||
|
"characters": 4.0,
|
||||||
|
"overall": 5.0,
|
||||||
|
"lighting": 5.0,
|
||||||
|
"texture_detail": 6.0,
|
||||||
|
"materials": 6.0,
|
||||||
|
"backdrop": 5.0
|
||||||
|
},
|
||||||
|
"score_range": {
|
||||||
|
"characters": [
|
||||||
|
4.0,
|
||||||
|
4.0
|
||||||
|
],
|
||||||
|
"overall": [
|
||||||
|
5.0,
|
||||||
|
5.0
|
||||||
|
],
|
||||||
|
"lighting": [
|
||||||
|
5.0,
|
||||||
|
5.0
|
||||||
|
],
|
||||||
|
"texture_detail": [
|
||||||
|
6.0,
|
||||||
|
6.0
|
||||||
|
],
|
||||||
|
"materials": [
|
||||||
|
6.0,
|
||||||
|
6.0
|
||||||
|
],
|
||||||
|
"backdrop": [
|
||||||
|
5.0,
|
||||||
|
5.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"runs": 1,
|
||||||
|
"model": "z-ai/glm-5.3-flash",
|
||||||
|
"fixes": [
|
||||||
|
"Increase contrast and deepen shadows across the entire scene, especially in the foreground and under the building overhang, to achieve a more cinematic mood.",
|
||||||
|
"Boost the reflectivity and wetness of the pavement to create dramatic, high-contrast reflections of the streetlights and sign.",
|
||||||
|
"Increase the density and volumetric scattering of the atmospheric haze/fog to enhance the sense of depth and distance, matching the target's mood.",
|
||||||
|
"Improve the character's silhouette and integration by adding strong rim lighting or darkening the model to make it feel more like a dramatic figure rather than a visible asset.",
|
||||||
|
"Add more micro-detail, grime, and color variation to the brick and concrete materials to make them feel less uniform and more weathered."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"street_late": {
|
||||||
|
"target": "docs/screens/concepts/boulevard_skyline_dusk.png",
|
||||||
|
"metrics": {
|
||||||
|
"edge_density": 10.57,
|
||||||
|
"fine_detail": 6.21,
|
||||||
|
"contrast": 26.84,
|
||||||
|
"saturation_mean": 74.1,
|
||||||
|
"colour_spread": 26.55
|
||||||
|
},
|
||||||
|
"target_metrics": {
|
||||||
|
"edge_density": 8.83,
|
||||||
|
"fine_detail": 4.61,
|
||||||
|
"contrast": 33.26,
|
||||||
|
"saturation_mean": 188.16,
|
||||||
|
"colour_spread": 35.38
|
||||||
|
},
|
||||||
|
"metric_gap": 0.247,
|
||||||
|
"judge": {
|
||||||
|
"scores": {},
|
||||||
|
"score_range": {},
|
||||||
|
"runs": 1,
|
||||||
|
"model": "z-ai/glm-5.3-flash",
|
||||||
|
"fixes": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"candidate": {
|
||||||
|
"entrance_late": {
|
||||||
|
"target": "docs/screens/concepts/hollywood_entrance_rain_night.png",
|
||||||
|
"metrics": {
|
||||||
|
"edge_density": 19.31,
|
||||||
|
"fine_detail": 8.37,
|
||||||
|
"contrast": 55.27,
|
||||||
|
"saturation_mean": 104.53,
|
||||||
|
"colour_spread": 54.55
|
||||||
|
},
|
||||||
|
"target_metrics": {
|
||||||
|
"edge_density": 8.43,
|
||||||
|
"fine_detail": 3.51,
|
||||||
|
"contrast": 27.34,
|
||||||
|
"saturation_mean": 159.44,
|
||||||
|
"colour_spread": 26.61
|
||||||
|
},
|
||||||
|
"metric_gap": 1.187,
|
||||||
|
"judge": {
|
||||||
|
"scores": {
|
||||||
|
"characters": 5.0,
|
||||||
|
"overall": 5.0,
|
||||||
|
"lighting": 5.0,
|
||||||
|
"texture_detail": 6.0,
|
||||||
|
"materials": 6.0,
|
||||||
|
"backdrop": 4.0
|
||||||
|
},
|
||||||
|
"score_range": {
|
||||||
|
"characters": [
|
||||||
|
5.0,
|
||||||
|
5.0
|
||||||
|
],
|
||||||
|
"overall": [
|
||||||
|
5.0,
|
||||||
|
5.0
|
||||||
|
],
|
||||||
|
"lighting": [
|
||||||
|
5.0,
|
||||||
|
5.0
|
||||||
|
],
|
||||||
|
"texture_detail": [
|
||||||
|
6.0,
|
||||||
|
6.0
|
||||||
|
],
|
||||||
|
"materials": [
|
||||||
|
6.0,
|
||||||
|
6.0
|
||||||
|
],
|
||||||
|
"backdrop": [
|
||||||
|
4.0,
|
||||||
|
4.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"runs": 1,
|
||||||
|
"model": "z-ai/glm-5.3-flash",
|
||||||
|
"fixes": [
|
||||||
|
"Implement volumetric fog and heavy atmospheric haze to increase depth and match the target's moody atmosphere.",
|
||||||
|
"Increase ground material specularity and darken the asphalt to enhance the wet, reflective look.",
|
||||||
|
"Adjust global illumination and ambient light to increase contrast, allowing for deeper shadows and more dramatic highlights (e.g., on the sign).",
|
||||||
|
"Apply rim lighting to the character to integrate them into the scene's dramatic lighting scheme.",
|
||||||
|
"Enhance the color grading towards a high-contrast, desaturated, warm-yellow/orange cinematic palette."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"street_late": {
|
||||||
|
"target": "docs/screens/concepts/boulevard_skyline_dusk.png",
|
||||||
|
"metrics": {
|
||||||
|
"edge_density": 11.4,
|
||||||
|
"fine_detail": 6.21,
|
||||||
|
"contrast": 27.58,
|
||||||
|
"saturation_mean": 77.86,
|
||||||
|
"colour_spread": 27.49
|
||||||
|
},
|
||||||
|
"target_metrics": {
|
||||||
|
"edge_density": 8.83,
|
||||||
|
"fine_detail": 4.61,
|
||||||
|
"contrast": 33.26,
|
||||||
|
"saturation_mean": 188.16,
|
||||||
|
"colour_spread": 35.38
|
||||||
|
},
|
||||||
|
"metric_gap": 0.258,
|
||||||
|
"judge": {
|
||||||
|
"scores": {
|
||||||
|
"characters": 3.0,
|
||||||
|
"overall": 4.0,
|
||||||
|
"lighting": 4.0,
|
||||||
|
"texture_detail": 3.0,
|
||||||
|
"materials": 3.0,
|
||||||
|
"backdrop": 5.0
|
||||||
|
},
|
||||||
|
"score_range": {
|
||||||
|
"characters": [
|
||||||
|
3.0,
|
||||||
|
3.0
|
||||||
|
],
|
||||||
|
"overall": [
|
||||||
|
4.0,
|
||||||
|
4.0
|
||||||
|
],
|
||||||
|
"lighting": [
|
||||||
|
4.0,
|
||||||
|
4.0
|
||||||
|
],
|
||||||
|
"texture_detail": [
|
||||||
|
3.0,
|
||||||
|
3.0
|
||||||
|
],
|
||||||
|
"materials": [
|
||||||
|
3.0,
|
||||||
|
3.0
|
||||||
|
],
|
||||||
|
"backdrop": [
|
||||||
|
5.0,
|
||||||
|
5.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"runs": 1,
|
||||||
|
"model": "z-ai/glm-5.3-flash",
|
||||||
|
"fixes": [
|
||||||
|
"Implement advanced volumetric lighting and bloom effects on street lamps to achieve the dramatic contrast seen in the target.",
|
||||||
|
"Upgrade material shaders (PBR) for primary surfaces (glass, concrete) to increase definition and reduce the current muddy, low-resolution appearance.",
|
||||||
|
"Enhance atmospheric scattering and haze in the distance to create greater depth and match the cinematic quality of the target's skyline.",
|
||||||
|
"Increase the resolution and detail of foreground assets (building facades, road textures) to move away from the current blocky look.",
|
||||||
|
"Refine the sky's color gradient and lighting to better capture the specific warm, dramatic twilight palette of the target."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Realism report: `docs/screens/realism/2026-11-01-forward-plus`
|
||||||
|
|
||||||
|
Vision judge: `z-ai/glm-5.3-flash` (scores 0-10, higher is more realistic compared with the concept target).
|
||||||
|
|
||||||
|
| set | shot | target | gap ↓ | edges | detail | contrast | overall | lighting | materials | texture | backdrop |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| baseline | entrance_late | hollywood_entrance_rain_night | 1.568 | 21.3 | 9.55 | 68.03 | 5.0 | 5.0 | 6.0 | 6.0 | 5.0 |
|
||||||
|
| baseline | street_late | boulevard_skyline_dusk | 0.247 | 10.57 | 6.21 | 26.84 | - | - | - | - | - |
|
||||||
|
| candidate | entrance_late | hollywood_entrance_rain_night | 1.187 | 19.31 | 8.37 | 55.27 | 5.0 | 5.0 | 6.0 | 6.0 | 4.0 |
|
||||||
|
| candidate | street_late | boulevard_skyline_dusk | 0.258 | 11.4 | 6.21 | 27.58 | 4.0 | 4.0 | 3.0 | 3.0 | 5.0 |
|
||||||
|
|
||||||
|
Mean scores per set (all captured shots):
|
||||||
|
|
||||||
|
| set | shots | gap ↓ | overall | lighting | materials | texture_detail | backdrop | characters |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| baseline | 2 | 0.907 | 5.0 | 5.0 | 6.0 | 6.0 | 5.0 | 4.0 |
|
||||||
|
| candidate | 2 | 0.723 | 4.5 | 4.5 | 4.5 | 4.5 | 4.5 | 4.0 |
|
||||||
|
|
||||||
|
**baseline / entrance_late** fixes:
|
||||||
|
- Increase contrast and deepen shadows across the entire scene, especially in the foreground and under the building overhang, to achieve a more cinematic mood.
|
||||||
|
- Boost the reflectivity and wetness of the pavement to create dramatic, high-contrast reflections of the streetlights and sign.
|
||||||
|
- Increase the density and volumetric scattering of the atmospheric haze/fog to enhance the sense of depth and distance, matching the target's mood.
|
||||||
|
- Improve the character's silhouette and integration by adding strong rim lighting or darkening the model to make it feel more like a dramatic figure rather than a visible asset.
|
||||||
|
- Add more micro-detail, grime, and color variation to the brick and concrete materials to make them feel less uniform and more weathered.
|
||||||
|
|
||||||
|
**candidate / entrance_late** fixes:
|
||||||
|
- Implement volumetric fog and heavy atmospheric haze to increase depth and match the target's moody atmosphere.
|
||||||
|
- Increase ground material specularity and darken the asphalt to enhance the wet, reflective look.
|
||||||
|
- Adjust global illumination and ambient light to increase contrast, allowing for deeper shadows and more dramatic highlights (e.g., on the sign).
|
||||||
|
- Apply rim lighting to the character to integrate them into the scene's dramatic lighting scheme.
|
||||||
|
- Enhance the color grading towards a high-contrast, desaturated, warm-yellow/orange cinematic palette.
|
||||||
|
|
||||||
|
**candidate / street_late** fixes:
|
||||||
|
- Implement advanced volumetric lighting and bloom effects on street lamps to achieve the dramatic contrast seen in the target.
|
||||||
|
- Upgrade material shaders (PBR) for primary surfaces (glass, concrete) to increase definition and reduce the current muddy, low-resolution appearance.
|
||||||
|
- Enhance atmospheric scattering and haze in the distance to create greater depth and match the cinematic quality of the target's skyline.
|
||||||
|
- Increase the resolution and detail of foreground assets (building facades, road textures) to move away from the current blocky look.
|
||||||
|
- Refine the sky's color gradient and lighting to better capture the specific warm, dramatic twilight palette of the target.
|
||||||
|
|
||||||
|
|
||||||
|
### Judge notes
|
||||||
|
|
||||||
|
The judge selected `z-ai/glm-5.3-flash` automatically from the live
|
||||||
|
OpenRouter catalogue (cheapest image+JSON model at the time), with fallback
|
||||||
|
candidates `qwen/qwen3.7-flash`, `google/gemma-3-4b-it`,
|
||||||
|
`google/gemma-3-12b-it`, `inclusionai/ling-3.0-flash-vl` and
|
||||||
|
`prism-ml/ternary-bonsai-2-9b`. One complete judge/A-B run cost about
|
||||||
|
**$0.006** of the key budget.
|
||||||
|
|
||||||
|
The baseline entrance scored **5.0 overall** and the candidate **5.0 overall**
|
||||||
|
with the small model; its material/texture sub-scores were noisy, so the
|
||||||
|
Pillow metric gap is the more stable signal for this A/B. The candidate's
|
||||||
|
slightly lower street_late/overall mean reflects the extra fog rather than a
|
||||||
|
Compatibility regression.
|
||||||
|
After Width: | Height: | Size: 140 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,16 @@
|
|||||||
|
Gig Line — Desktop HQ build
|
||||||
|
===========================
|
||||||
|
|
||||||
|
This build uses Godot's Forward+ renderer instead of the default
|
||||||
|
GL Compatibility renderer, enabling volumetric fog and SDFGI.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- A Vulkan-capable GPU/driver (Linux or Windows).
|
||||||
|
- Keep override.cfg next to the executable. Without it the binary falls back
|
||||||
|
to the normal Compatibility build.
|
||||||
|
|
||||||
|
The game still runs if Forward+ is unavailable, but the desktop-only effects
|
||||||
|
will be disabled.
|
||||||
|
|
||||||
|
Linux: chmod +x gig-line-linux-*.x86_64 && ./gig-line-linux-*.x86_64
|
||||||
|
Windows: run gig-line-windows-*.exe
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
; Gig Line desktop HQ preset. Place this file next to the exported executable.
|
||||||
|
; It switches only that copy to Forward+; project.godot and the default builds
|
||||||
|
; stay on GL Compatibility for Web/CI parity.
|
||||||
|
[rendering]
|
||||||
|
|
||||||
|
renderer/rendering_method="forward_plus"
|
||||||
|
renderer/rendering_method.mobile="forward_plus"
|
||||||
|
environment/volumetric_fog/volume_size=128
|
||||||
|
environment/volumetric_fog/volume_depth=128
|
||||||
|
environment/volumetric_fog/use_filter=1
|
||||||
|
global_illumination/sdfgi/frames_to_converge=4
|
||||||
|
global_illumination/sdfgi/frames_to_update_lights=2
|
||||||
@@ -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.1"
|
config/version="0.7.2"
|
||||||
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
|
||||||
@@ -51,7 +54,7 @@ const PHASE_LIGHTING := {
|
|||||||
"sky_ground": Color(0.45, 0.5, 0.55), "ambient": Color(0.75, 0.78, 0.85), "night": false,
|
"sky_ground": Color(0.45, 0.5, 0.55), "ambient": Color(0.75, 0.78, 0.85), "night": false,
|
||||||
"exposure": 0.6, "glow": 0.45, "fog_color": Color(0.87, 0.83, 0.76),
|
"exposure": 0.6, "glow": 0.45, "fog_color": Color(0.87, 0.83, 0.76),
|
||||||
"fog_density": 0.0034, "fog_energy": 0.5, "contrast": 1.05, "saturation": 1.04,
|
"fog_density": 0.0034, "fog_energy": 0.5, "contrast": 1.05, "saturation": 1.04,
|
||||||
"ambient_energy": 0.1,
|
"ambient_energy": 0.1, "volumetric_density": 0.004, "volumetric_energy": 0.0,
|
||||||
},
|
},
|
||||||
Rules.Phase.MIDDAY: {
|
Rules.Phase.MIDDAY: {
|
||||||
"sun_energy": 1.0, "sun_angle": -62.0, "sun_yaw": -30.0, "sun_color": Color(1.0, 0.99, 0.96),
|
"sun_energy": 1.0, "sun_angle": -62.0, "sun_yaw": -30.0, "sun_color": Color(1.0, 0.99, 0.96),
|
||||||
@@ -59,7 +62,7 @@ const PHASE_LIGHTING := {
|
|||||||
"sky_ground": Color(0.5, 0.54, 0.58), "ambient": Color(0.85, 0.88, 0.95), "night": false,
|
"sky_ground": Color(0.5, 0.54, 0.58), "ambient": Color(0.85, 0.88, 0.95), "night": false,
|
||||||
"exposure": 0.6, "glow": 0.4, "fog_color": Color(0.88, 0.87, 0.83),
|
"exposure": 0.6, "glow": 0.4, "fog_color": Color(0.88, 0.87, 0.83),
|
||||||
"fog_density": 0.0028, "fog_energy": 0.5, "contrast": 1.06, "saturation": 1.04,
|
"fog_density": 0.0028, "fog_energy": 0.5, "contrast": 1.06, "saturation": 1.04,
|
||||||
"ambient_energy": 0.28,
|
"ambient_energy": 0.28, "volumetric_density": 0.003, "volumetric_energy": 0.0,
|
||||||
},
|
},
|
||||||
# Evening is dusk, not a bright sunset: the sun is almost down and the street
|
# Evening is dusk, not a bright sunset: the sun is almost down and the street
|
||||||
# is carried by the marquee, the lamps and the lit windows (realism pass).
|
# is carried by the marquee, the lamps and the lit windows (realism pass).
|
||||||
@@ -69,7 +72,7 @@ const PHASE_LIGHTING := {
|
|||||||
"sky_ground": Color(0.1, 0.09, 0.12), "ambient": Color(0.2, 0.19, 0.26), "night": true,
|
"sky_ground": Color(0.1, 0.09, 0.12), "ambient": Color(0.2, 0.19, 0.26), "night": true,
|
||||||
"exposure": 0.72, "glow": 0.8, "fog_color": Color(0.34, 0.27, 0.3),
|
"exposure": 0.72, "glow": 0.8, "fog_color": Color(0.34, 0.27, 0.3),
|
||||||
"fog_density": 0.0026, "fog_energy": 0.45, "contrast": 1.1, "saturation": 1.05,
|
"fog_density": 0.0026, "fog_energy": 0.45, "contrast": 1.1, "saturation": 1.05,
|
||||||
"ambient_energy": 0.12,
|
"ambient_energy": 0.12, "volumetric_density": 0.014, "volumetric_energy": 0.10,
|
||||||
},
|
},
|
||||||
Rules.Phase.LATE: {
|
Rules.Phase.LATE: {
|
||||||
"sun_energy": 0.02, "sun_angle": -8.0, "sun_color": Color(0.3, 0.35, 0.5),
|
"sun_energy": 0.02, "sun_angle": -8.0, "sun_color": Color(0.3, 0.35, 0.5),
|
||||||
@@ -77,7 +80,7 @@ const PHASE_LIGHTING := {
|
|||||||
"sky_ground": Color(0.03, 0.03, 0.05), "ambient": Color(0.06, 0.07, 0.12), "night": true,
|
"sky_ground": Color(0.03, 0.03, 0.05), "ambient": Color(0.06, 0.07, 0.12), "night": true,
|
||||||
"exposure": 0.95, "glow": 1.15, "fog_color": Color(0.08, 0.1, 0.2),
|
"exposure": 0.95, "glow": 1.15, "fog_color": Color(0.08, 0.1, 0.2),
|
||||||
"fog_density": 0.0028, "fog_energy": 0.45, "contrast": 1.12, "saturation": 1.0,
|
"fog_density": 0.0028, "fog_energy": 0.45, "contrast": 1.12, "saturation": 1.0,
|
||||||
"ambient_energy": 0.22,
|
"ambient_energy": 0.22, "volumetric_density": 0.020, "volumetric_energy": 0.14,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
var hud: Hud = null
|
var hud: Hud = null
|
||||||
@@ -135,8 +138,11 @@ func _ready() -> void:
|
|||||||
scene_root = Node3D.new()
|
scene_root = Node3D.new()
|
||||||
scene_root.name = "SceneRoot"
|
scene_root.name = "SceneRoot"
|
||||||
add_child(scene_root)
|
add_child(scene_root)
|
||||||
|
world_root = Node3D.new()
|
||||||
|
world_root.name = "World"
|
||||||
|
scene_root.add_child(world_root)
|
||||||
|
|
||||||
world_builder = WorldBuilder.new(scene_root)
|
world_builder = WorldBuilder.new(world_root, active_scenario_id)
|
||||||
world_builder.build()
|
world_builder.build()
|
||||||
_ensure_lighting()
|
_ensure_lighting()
|
||||||
|
|
||||||
@@ -190,11 +196,14 @@ func _build_guard() -> GuardController:
|
|||||||
# Aim straight ahead: tilting it down made the cone hit the ground right in
|
# Aim straight ahead: tilting it down made the cone hit the ground right in
|
||||||
# front of the guard instead of the person or door being checked.
|
# front of the guard instead of the person or door being checked.
|
||||||
flashlight.rotation_degrees = Vector3.ZERO
|
flashlight.rotation_degrees = Vector3.ZERO
|
||||||
flashlight.spot_range = 20.0
|
flashlight.spot_range = 30.0 if Materials.modern_light_falloff() else 20.0
|
||||||
flashlight.spot_angle = 14.0
|
flashlight.spot_angle = 14.0
|
||||||
flashlight.light_energy = 4.2
|
flashlight.light_energy = 4.2
|
||||||
flashlight.spot_attenuation = 0.9
|
flashlight.spot_attenuation = 0.0 if Materials.modern_light_falloff() else 0.9
|
||||||
flashlight.spot_angle_attenuation = 0.9
|
flashlight.spot_angle_attenuation = 0.9
|
||||||
|
# The volumetric shaft is a Forward+ (desktop HQ) effect. Compatibility and
|
||||||
|
# headless builds leave the light non-volumetric, so their look is unchanged.
|
||||||
|
flashlight.light_volumetric_fog_energy = 3.0 if RenderingServer.get_rendering_device() != null else 0.0
|
||||||
head.add_child(flashlight)
|
head.add_child(flashlight)
|
||||||
var collider := CollisionShape3D.new()
|
var collider := CollisionShape3D.new()
|
||||||
var shape := CapsuleShape3D.new()
|
var shape := CapsuleShape3D.new()
|
||||||
@@ -235,9 +244,26 @@ func _build_scenario(id: String) -> VenueScenario:
|
|||||||
func switch_scenario(id: String) -> void:
|
func switch_scenario(id: String) -> void:
|
||||||
active_scenario_id = id
|
active_scenario_id = id
|
||||||
selected_scenario = _build_scenario(id)
|
selected_scenario = _build_scenario(id)
|
||||||
|
_rebuild_world_for_venue(id)
|
||||||
if coordinator:
|
if coordinator:
|
||||||
restart_shift()
|
restart_shift()
|
||||||
|
|
||||||
|
## Rebuild the procedural venue in place. The guard/UI stay under `SceneRoot`;
|
||||||
|
## only the `World` container is replaced, so navigation, props, lights and the
|
||||||
|
## venue-specific dressing match the selected scenario.
|
||||||
|
func _rebuild_world_for_venue(id: String) -> void:
|
||||||
|
if world_root == null:
|
||||||
|
return
|
||||||
|
for child in world_root.get_children():
|
||||||
|
child.free()
|
||||||
|
world_builder = WorldBuilder.new(world_root, id)
|
||||||
|
world_builder.build()
|
||||||
|
if guard != null:
|
||||||
|
guard.global_position = world_builder.spec.get("guard_spawn", Vector3(0, 0.1, -2.0))
|
||||||
|
guard.rotation.y = PI
|
||||||
|
if world_builder != null:
|
||||||
|
world_builder.set_door_locked(door.locked)
|
||||||
|
|
||||||
func restart_shift() -> void:
|
func restart_shift() -> void:
|
||||||
if shift_review_panel:
|
if shift_review_panel:
|
||||||
shift_review_panel.hide()
|
shift_review_panel.hide()
|
||||||
@@ -301,6 +327,7 @@ func _on_venue_selected(scenario_id: String) -> void:
|
|||||||
active_scenario_id = scenario_id
|
active_scenario_id = scenario_id
|
||||||
_venue_index = maxi(0, VENUE_ORDER.find(scenario_id))
|
_venue_index = maxi(0, VENUE_ORDER.find(scenario_id))
|
||||||
selected_scenario = _build_scenario(scenario_id)
|
selected_scenario = _build_scenario(scenario_id)
|
||||||
|
_rebuild_world_for_venue(scenario_id)
|
||||||
if hud:
|
if hud:
|
||||||
hud.set_wand_icon(false)
|
hud.set_wand_icon(false)
|
||||||
_reset_shift_state()
|
_reset_shift_state()
|
||||||
|
|||||||
@@ -152,9 +152,24 @@ static func window_pane() -> StandardMaterial3D:
|
|||||||
mat.emission_energy_multiplier = 0.0
|
mat.emission_energy_multiplier = 0.0
|
||||||
return mat
|
return mat
|
||||||
|
|
||||||
|
## Godot 4.7 changed the Compatibility light falloff in practice: the same
|
||||||
|
## attenuation/range that read well on 4.3 leaves the night scene nearly black.
|
||||||
|
## Use this switch for local lights until the project moves its baseline to 4.7.
|
||||||
|
static func modern_light_falloff() -> bool:
|
||||||
|
var info := Engine.get_version_info()
|
||||||
|
return int(info.get("major", 4)) > 4 \
|
||||||
|
or (int(info.get("major", 4)) == 4 and int(info.get("minor", 0)) >= 7)
|
||||||
|
|
||||||
|
## True only when a real RenderingDevice exists. The Compatibility renderer and
|
||||||
|
## the headless dummy renderer return null, so the desktop-only effects can be
|
||||||
|
## gated without Web/CI builds ever enabling them.
|
||||||
|
static func forward_plus_active() -> bool:
|
||||||
|
return RenderingServer.get_rendering_device() != null
|
||||||
|
|
||||||
## Environment look shared by every phase: ACES tonemapping, soft glow, aerial
|
## Environment look shared by every phase: ACES tonemapping, soft glow, aerial
|
||||||
## fog for depth, and a mild contrast/saturation lift. Phase values come from
|
## fog for depth, and a mild contrast/saturation lift. Phase values come from
|
||||||
## `GameManager.PHASE_LIGHTING`.
|
## `GameManager.PHASE_LIGHTING`. Volumetric fog and SDFGI are only enabled when
|
||||||
|
## Forward+ is actually available (the optional desktop preset).
|
||||||
static func configure_environment(env: Environment, cfg: Dictionary) -> void:
|
static func configure_environment(env: Environment, cfg: Dictionary) -> void:
|
||||||
if env == null:
|
if env == null:
|
||||||
return
|
return
|
||||||
@@ -180,6 +195,24 @@ static func configure_environment(env: Environment, cfg: Dictionary) -> void:
|
|||||||
env.fog_density = float(cfg.get("fog_density", 0.0035))
|
env.fog_density = float(cfg.get("fog_density", 0.0035))
|
||||||
env.fog_aerial_perspective = 0.25
|
env.fog_aerial_perspective = 0.25
|
||||||
env.fog_sky_affect = 0.4
|
env.fog_sky_affect = 0.4
|
||||||
|
var advanced := forward_plus_active()
|
||||||
|
env.volumetric_fog_enabled = advanced
|
||||||
|
env.sdfgi_enabled = advanced
|
||||||
|
if advanced:
|
||||||
|
# Desktop HQ: a shallow, cool fog gives the torch a visible shaft while
|
||||||
|
# keeping the entrance facade readable. Phase values are tuned per phase.
|
||||||
|
env.volumetric_fog_density = float(cfg.get("volumetric_density", 0.012))
|
||||||
|
env.volumetric_fog_albedo = cfg.get("volumetric_albedo", Color(0.55, 0.62, 0.72))
|
||||||
|
env.volumetric_fog_emission = cfg.get("volumetric_emission", Color.BLACK)
|
||||||
|
env.volumetric_fog_emission_energy = float(cfg.get("volumetric_energy", 0.0))
|
||||||
|
env.volumetric_fog_anisotropy = 0.25
|
||||||
|
env.volumetric_fog_length = 48.0
|
||||||
|
env.volumetric_fog_ambient_inject = 0.15
|
||||||
|
env.volumetric_fog_sky_affect = 0.2
|
||||||
|
env.sdfgi_use_occlusion = true
|
||||||
|
env.sdfgi_cascades = 4
|
||||||
|
env.sdfgi_min_cell_size = 0.2
|
||||||
|
env.sdfgi_energy = 0.7
|
||||||
env.adjustment_enabled = true
|
env.adjustment_enabled = true
|
||||||
env.adjustment_brightness = 1.0
|
env.adjustment_brightness = 1.0
|
||||||
env.adjustment_contrast = float(cfg.get("contrast", 1.06))
|
env.adjustment_contrast = float(cfg.get("contrast", 1.06))
|
||||||
|
|||||||
@@ -59,21 +59,116 @@ var library_nodes: Array[Node3D] = []
|
|||||||
## Total triangle budget for the Blender library pieces placed in the world.
|
## Total triangle budget for the Blender library pieces placed in the world.
|
||||||
const LIBRARY_TRIANGLE_BUDGET := 60000
|
const LIBRARY_TRIANGLE_BUDGET := 60000
|
||||||
|
|
||||||
func _init(root_node: Node3D) -> void:
|
## Per-venue geometry data. The Hollywood values are the fallback: every field
|
||||||
|
## here defaults to the long-standing boulevard layout, so adding a venue cannot
|
||||||
|
## silently regress the original scene. Joke Shop and TV Studio override the
|
||||||
|
## facade/street measurements and feature flags; their bespoke dressing lands in
|
||||||
|
## the following tasks.
|
||||||
|
const VENUE_SPECS := {
|
||||||
|
"hollywood_building": {
|
||||||
|
"display_name": "Hollywood Boulevard Building",
|
||||||
|
"facade_width": 40.0, "facade_height": 18.0, "facade_depth": 4.0,
|
||||||
|
"facade_center_z": -6.0, "facade_texture": T_BRICK,
|
||||||
|
"facade_tint": Color(0.86, 0.85, 0.83), "facade_role": "brick",
|
||||||
|
"window_columns": [-17.0, -13.6, -10.2, -6.8, 6.8, 10.2, 13.6, 17.0],
|
||||||
|
"pilaster_columns": [-15.3, -11.9, -8.5, -5.1, 5.1, 8.5, 11.9, 15.3],
|
||||||
|
"window_rows": [9.2, 12.4],
|
||||||
|
"stair_width": 8.0, "stair_count": 4, "stair_tread": 0.6, "stair_height": 0.16,
|
||||||
|
"sidewalk_width": 30.0, "sidewalk_depth": 8.0,
|
||||||
|
"street_width": 120.0, "street_depth": 26.0,
|
||||||
|
"nav_min": Vector2(-18.0, -0.6), "nav_max": Vector2(18.0, 20.0),
|
||||||
|
"guard_spawn": Vector3(0.0, 0.1, -2.0),
|
||||||
|
"features": {
|
||||||
|
"marquee": true, "skyline": true, "handrail": true, "sedan": true,
|
||||||
|
"wall_lanterns": true, "palms": true, "street_trees": true,
|
||||||
|
"street_lamps": true, "crosswalk": true, "bulb_canopy": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"joke_shop": {
|
||||||
|
"display_name": "The Joke Shop",
|
||||||
|
"facade_width": 12.0, "facade_height": 12.0, "facade_depth": 5.0,
|
||||||
|
"facade_center_z": -6.5, "facade_texture": T_BRICK,
|
||||||
|
"facade_tint": Color(0.78, 0.66, 0.58), "facade_role": "brick",
|
||||||
|
"window_columns": [-3.8, 3.8],
|
||||||
|
"pilaster_columns": [-5.2, 5.2],
|
||||||
|
"window_rows": [8.2],
|
||||||
|
"stair_width": 5.0, "stair_count": 2, "stair_tread": 0.65, "stair_height": 0.16,
|
||||||
|
"sidewalk_width": 22.0, "sidewalk_depth": 6.0,
|
||||||
|
"street_width": 75.0, "street_depth": 18.0,
|
||||||
|
"nav_min": Vector2(-9.0, -0.6), "nav_max": Vector2(9.0, 14.0),
|
||||||
|
"guard_spawn": Vector3(0.0, 0.1, -1.6),
|
||||||
|
"features": {
|
||||||
|
"marquee": false, "skyline": false, "handrail": false, "sedan": false,
|
||||||
|
"wall_lanterns": true, "palms": false, "street_trees": false,
|
||||||
|
"street_lamps": false, "crosswalk": false, "bulb_canopy": false,
|
||||||
|
},
|
||||||
|
"prop_placements": [],
|
||||||
|
},
|
||||||
|
"tv_studio": {
|
||||||
|
"display_name": "Late Night TV Studio",
|
||||||
|
"facade_width": 30.0, "facade_height": 15.0, "facade_depth": 4.0,
|
||||||
|
"facade_center_z": -6.0, "facade_texture": T_CONCRETE,
|
||||||
|
"facade_tint": Color(0.58, 0.57, 0.55), "facade_role": "stone",
|
||||||
|
"window_columns": [-11.0, -7.2, 7.2, 11.0],
|
||||||
|
"pilaster_columns": [-13.2, -9.0, -5.0, 5.0, 9.0, 13.2],
|
||||||
|
"window_rows": [10.5],
|
||||||
|
"stair_width": 7.0, "stair_count": 3, "stair_tread": 0.6, "stair_height": 0.16,
|
||||||
|
"sidewalk_width": 28.0, "sidewalk_depth": 7.0,
|
||||||
|
"street_width": 100.0, "street_depth": 24.0,
|
||||||
|
"nav_min": Vector2(-14.0, -0.6), "nav_max": Vector2(14.0, 18.0),
|
||||||
|
"guard_spawn": Vector3(0.0, 0.1, -1.8),
|
||||||
|
"sedan_position": Vector3(5.4, 0.1, 8.8), "sedan_rotation": 90.0,
|
||||||
|
"features": {
|
||||||
|
"marquee": false, "skyline": true, "handrail": true, "sedan": true,
|
||||||
|
"wall_lanterns": false, "palms": false, "street_trees": true,
|
||||||
|
"street_lamps": true, "crosswalk": true, "bulb_canopy": true,
|
||||||
|
},
|
||||||
|
"prop_placements": [
|
||||||
|
{"zone": "entrance", "prop": "stanchion", "x": -2.8, "z": 4.4},
|
||||||
|
{"zone": "entrance", "prop": "stanchion", "x": 2.8, "z": 4.4},
|
||||||
|
{"zone": "entrance", "prop": "crowd_barrier", "x": -5.2, "z": 4.2},
|
||||||
|
{"zone": "entrance", "prop": "crowd_barrier", "x": 5.2, "z": 4.2},
|
||||||
|
{"zone": "curb", "prop": "security_podium", "x": 4.6, "z": 5.9, "rot": -15.0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const DEFAULT_VENUE := "hollywood_building"
|
||||||
|
|
||||||
|
var venue_id := DEFAULT_VENUE
|
||||||
|
var spec: Dictionary = VENUE_SPECS[DEFAULT_VENUE]
|
||||||
|
|
||||||
|
func _init(root_node: Node3D, id: String = DEFAULT_VENUE) -> void:
|
||||||
root = root_node
|
root = root_node
|
||||||
|
set_venue(id)
|
||||||
|
|
||||||
|
## Select the geometry spec before `build()`. Unknown ids fall back to the
|
||||||
|
## boulevard so old callers and tests keep working.
|
||||||
|
func set_venue(id: String) -> void:
|
||||||
|
venue_id = id if VENUE_SPECS.has(id) else DEFAULT_VENUE
|
||||||
|
spec = VENUE_SPECS[venue_id]
|
||||||
|
|
||||||
|
func _feature(name: String) -> bool:
|
||||||
|
return bool((spec.get("features", {}) as Dictionary).get(name, false))
|
||||||
|
|
||||||
func build() -> void:
|
func build() -> void:
|
||||||
_build_ground()
|
_build_ground()
|
||||||
_build_building()
|
_build_building()
|
||||||
_build_skyline_backdrop()
|
if _feature("skyline"):
|
||||||
_build_marquee()
|
_build_skyline_backdrop()
|
||||||
|
if _feature("marquee"):
|
||||||
|
_build_marquee()
|
||||||
|
if _feature("bulb_canopy"):
|
||||||
|
_build_bulb_canopy()
|
||||||
_build_steps()
|
_build_steps()
|
||||||
_build_sidewalk()
|
_build_sidewalk()
|
||||||
_build_street()
|
_build_street()
|
||||||
_build_crosswalk()
|
if _feature("crosswalk"):
|
||||||
|
_build_crosswalk()
|
||||||
_build_street_furniture()
|
_build_street_furniture()
|
||||||
_build_street_detail()
|
_build_street_detail()
|
||||||
_build_library_dressing()
|
_build_library_dressing()
|
||||||
|
if venue_id == "joke_shop":
|
||||||
|
_build_alley_dressing()
|
||||||
_build_guard_spawn()
|
_build_guard_spawn()
|
||||||
_build_navmesh()
|
_build_navmesh()
|
||||||
set_night_mode(false)
|
set_night_mode(false)
|
||||||
@@ -111,41 +206,49 @@ func _build_ground() -> void:
|
|||||||
func _build_building() -> void:
|
func _build_building() -> void:
|
||||||
var building := StaticBody3D.new()
|
var building := StaticBody3D.new()
|
||||||
building.name = "Building"
|
building.name = "Building"
|
||||||
# Main facade (textured brick)
|
var w := float(spec["facade_width"])
|
||||||
_add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -6), Color(0.86, 0.85, 0.83), false, T_BRICK, Vector2(22, 10), "brick")
|
var h := float(spec["facade_height"])
|
||||||
|
var d := float(spec["facade_depth"])
|
||||||
|
var cz := float(spec["facade_center_z"])
|
||||||
|
var front := cz + d * 0.5
|
||||||
|
# Main facade (brick on the boulevard/alley, stone on the studio)
|
||||||
|
var facade_texture: Texture2D = spec.get("facade_texture", T_BRICK)
|
||||||
|
var facade_tint: Color = spec.get("facade_tint", Color(0.86, 0.85, 0.83))
|
||||||
|
var facade_role := str(spec.get("facade_role", "brick"))
|
||||||
|
_add_box(building, Vector3(w, h, d), Vector3(0, h * 0.5, cz), facade_tint, false, facade_texture, Vector2(w * 0.55, h * 0.55), facade_role)
|
||||||
# Stone base course so the facade has a footing instead of meeting the steps dead-on.
|
# Stone base course so the facade has a footing instead of meeting the steps dead-on.
|
||||||
_add_box(building, Vector3(40, 0.8, 0.5), Vector3(0, 0.4, -3.78), Color(0.52, 0.5, 0.47), false, null, Vector2.ONE, "stone")
|
_add_box(building, Vector3(w, 0.8, 0.5), Vector3(0, 0.4, front + 0.22), Color(0.52, 0.5, 0.47), false, null, Vector2.ONE, "stone")
|
||||||
# Facade depth, not a flat wall: a projecting plinth, a string course between
|
# Facade depth, not a flat wall: a projecting plinth, a string course between
|
||||||
# the window bands, a cornice under the roofline and pilasters at the bays.
|
# the window bands, a cornice under the roofline and pilasters at the bays.
|
||||||
_add_box(building, Vector3(40, 0.34, 0.22), Vector3(0, 0.93, -3.9), Color(0.6, 0.58, 0.55), true, null, Vector2.ONE, "stone")
|
_add_box(building, Vector3(w, 0.34, 0.22), Vector3(0, 0.93, front + 0.1), Color(0.6, 0.58, 0.55), true, null, Vector2.ONE, "stone")
|
||||||
_add_box(building, Vector3(40, 0.28, 0.28), Vector3(0, 7.6, -3.86), Color(0.6, 0.58, 0.55), true, null, Vector2.ONE, "stone")
|
_add_box(building, Vector3(w, 0.28, 0.28), Vector3(0, 7.6, front + 0.14), Color(0.6, 0.58, 0.55), true, null, Vector2.ONE, "stone")
|
||||||
_add_box(building, Vector3(40, 0.5, 0.5), Vector3(0, 17.7, -3.75), Color(0.56, 0.54, 0.51), true, null, Vector2.ONE, "stone")
|
_add_box(building, Vector3(w, 0.5, 0.5), Vector3(0, h - 0.3, front + 0.25), Color(0.56, 0.54, 0.51), true, null, Vector2.ONE, "stone")
|
||||||
_add_box(building, Vector3(40, 0.22, 0.2), Vector3(0, 10.9, -3.9), Color(0.58, 0.56, 0.53), true, null, Vector2.ONE, "stone")
|
_add_box(building, Vector3(w, 0.22, 0.2), Vector3(0, 10.9, front + 0.1), Color(0.58, 0.56, 0.53), true, null, Vector2.ONE, "stone")
|
||||||
for bay in [-15.3, -11.9, -8.5, -5.1, 5.1, 8.5, 11.9, 15.3]:
|
for bay in spec.get("pilaster_columns", [-15.3, -11.9, -8.5, -5.1, 5.1, 8.5, 11.9, 15.3]):
|
||||||
_add_box(building, Vector3(0.5, 8.4, 0.24), Vector3(bay, 12.6, -3.9),
|
_add_box(building, Vector3(0.5, 8.4, 0.24), Vector3(float(bay), 12.6, front + 0.1),
|
||||||
Color(0.8, 0.78, 0.76), true, T_BRICK, Vector2(0.3, 4.7), "brick")
|
Color(0.8, 0.78, 0.76), true, facade_texture, Vector2(0.3, 4.7), facade_role)
|
||||||
# Entrance recess + door recess frame
|
# Entrance recess + door recess frame
|
||||||
_add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -3.9), Color(0.08, 0.08, 0.09), false, null, Vector2.ONE, "stone")
|
_add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, front + 0.1), Color(0.08, 0.08, 0.09), false, null, Vector2.ONE, "stone")
|
||||||
# Door frame columns and lintel, so the entrance is framed rather than painted on.
|
# Door frame columns and lintel, so the entrance is framed rather than painted on.
|
||||||
for side in [-1.55, 1.55]:
|
for side in [-1.55, 1.55]:
|
||||||
_add_box(building, Vector3(0.28, 5.2, 0.42), Vector3(side, 2.9, -3.86), Color(0.30, 0.28, 0.26), true, null, Vector2.ONE, "stone")
|
_add_box(building, Vector3(0.28, 5.2, 0.42), Vector3(side, 2.9, front + 0.14), Color(0.30, 0.28, 0.26), true, null, Vector2.ONE, "stone")
|
||||||
_add_box(building, Vector3(3.4, 0.3, 0.42), Vector3(0, 5.55, -3.86), Color(0.30, 0.28, 0.26), true, null, Vector2.ONE, "stone")
|
_add_box(building, Vector3(3.4, 0.3, 0.42), Vector3(0, 5.55, front + 0.14), Color(0.30, 0.28, 0.26), true, null, Vector2.ONE, "stone")
|
||||||
# Door frame and dark door
|
# Door frame and dark door
|
||||||
_add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -3.98), Color(0.13, 0.12, 0.11), true, null, Vector2.ONE, "painted_metal")
|
_add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, front + 0.02), Color(0.13, 0.12, 0.11), true, null, Vector2.ONE, "painted_metal")
|
||||||
# Door glass (semi-transparent)
|
# Door glass (semi-transparent)
|
||||||
var glass := _add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -3.9), Color(0.6, 0.75, 0.9, 0.55))
|
var glass := _add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, front + 0.1), Color(0.6, 0.75, 0.9, 0.55))
|
||||||
glass.material_override = Materials.glass(Color(0.55, 0.7, 0.88, 0.55))
|
glass.material_override = Materials.glass(Color(0.55, 0.7, 0.88, 0.55))
|
||||||
# Door handles: two slim metal bars where the guard reaches for the lock.
|
# Door handles: two slim metal bars where the guard reaches for the lock.
|
||||||
for side in [-0.42, 0.42]:
|
for side in [-0.42, 0.42]:
|
||||||
_add_box(building, Vector3(0.06, 0.5, 0.06), Vector3(side, 2.6, -3.79), Color(0.75, 0.74, 0.70), false, null, Vector2.ONE, "metal")
|
_add_box(building, Vector3(0.06, 0.5, 0.06), Vector3(side, 2.6, front + 0.21), Color(0.75, 0.74, 0.70), false, null, Vector2.ONE, "metal")
|
||||||
# Door lock indicator (green = open, red = locked), placed beside the handle
|
# Door lock indicator (green = open, red = locked), placed beside the handle
|
||||||
door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -3.82), Color(0.1, 0.9, 0.3))
|
door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, front + 0.18), Color(0.1, 0.9, 0.3))
|
||||||
door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3))
|
door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3))
|
||||||
# Blender glazed double door in front of the recessed frame.
|
# Blender glazed double door in front of the recessed frame.
|
||||||
var door_model := _load_prop_scene("double_door")
|
var door_model := _load_prop_scene("double_door")
|
||||||
if door_model != null:
|
if door_model != null:
|
||||||
door_model.name = "LibraryDoubleDoor"
|
door_model.name = "LibraryDoubleDoor"
|
||||||
door_model.position = Vector3(0, 0.0, -3.84)
|
door_model.position = Vector3(0, 0.0, front + 0.16)
|
||||||
building.add_child(door_model)
|
building.add_child(door_model)
|
||||||
library_nodes.append(door_model)
|
library_nodes.append(door_model)
|
||||||
_apply_library_ao(door_model, "double_door")
|
_apply_library_ao(door_model, "double_door")
|
||||||
@@ -154,18 +257,21 @@ func _build_building() -> void:
|
|||||||
_lamp_bulb_mats.append(material)
|
_lamp_bulb_mats.append(material)
|
||||||
_build_facade_windows(building)
|
_build_facade_windows(building)
|
||||||
var col := BoxShape3D.new()
|
var col := BoxShape3D.new()
|
||||||
col.size = Vector3(40, 18, 4)
|
col.size = Vector3(w, h, d)
|
||||||
var so := building.create_shape_owner(building)
|
var so := building.create_shape_owner(building)
|
||||||
building.shape_owner_add_shape(so, col)
|
building.shape_owner_add_shape(so, col)
|
||||||
building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 9, -6)))
|
building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, h * 0.5, cz)))
|
||||||
root.add_child(building)
|
root.add_child(building)
|
||||||
|
|
||||||
## Regular window bands above the entrance. Panes are dark glass by day and
|
## Regular window bands above the entrance. Panes are dark glass by day and
|
||||||
## light up after dark, which gives the facade depth at night.
|
## light up after dark, which gives the facade depth at night.
|
||||||
func _build_facade_windows(parent: Node3D) -> void:
|
func _build_facade_windows(parent: Node3D) -> void:
|
||||||
var columns := [-17.0, -13.6, -10.2, -6.8, 6.8, 10.2, 13.6, 17.0]
|
var columns: Array = spec.get("window_columns", [-17.0, -13.6, -10.2, -6.8, 6.8, 10.2, 13.6, 17.0])
|
||||||
for y in [9.2, 12.4]:
|
var rows: Array = spec.get("window_rows", [9.2, 12.4])
|
||||||
for x in columns:
|
for value in rows:
|
||||||
|
var y := float(value)
|
||||||
|
for x_value in columns:
|
||||||
|
var x := float(x_value)
|
||||||
# Recessed reveal, then the pane: gives the window a dark frame edge.
|
# Recessed reveal, then the pane: gives the window a dark frame edge.
|
||||||
_add_box(parent, Vector3(1.96, 2.46, 0.1), Vector3(x, y, -3.99),
|
_add_box(parent, Vector3(1.96, 2.46, 0.1), Vector3(x, y, -3.99),
|
||||||
Color(0.22, 0.21, 0.20), false, null, Vector2.ONE, "stone")
|
Color(0.22, 0.21, 0.20), false, null, Vector2.ONE, "stone")
|
||||||
@@ -188,12 +294,13 @@ func _build_steps() -> void:
|
|||||||
# The ground and building provide the actual collision boundaries.
|
# The ground and building provide the actual collision boundaries.
|
||||||
var steps := StaticBody3D.new()
|
var steps := StaticBody3D.new()
|
||||||
steps.name = "Steps"
|
steps.name = "Steps"
|
||||||
var step_w := 8.0
|
var step_w := float(spec.get("stair_width", 8.0))
|
||||||
var tread := 0.6
|
var tread := float(spec.get("stair_tread", 0.6))
|
||||||
var height := 0.16
|
var height := float(spec.get("stair_height", 0.16))
|
||||||
for i in 4:
|
var count := int(spec.get("stair_count", 4))
|
||||||
|
for i in count:
|
||||||
var y: float = i * height
|
var y: float = i * height
|
||||||
var z: float = 4.0 * tread - i * tread
|
var z: float = float(count) * tread - i * tread
|
||||||
var w: float = step_w - i * 0.35
|
var w: float = step_w - i * 0.35
|
||||||
_add_box(steps, Vector3(w, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5))
|
_add_box(steps, Vector3(w, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5))
|
||||||
# Add collision shape to each tread
|
# Add collision shape to each tread
|
||||||
@@ -203,10 +310,11 @@ func _build_steps() -> void:
|
|||||||
steps.shape_owner_add_shape(owner_id, tread_col)
|
steps.shape_owner_add_shape(owner_id, tread_col)
|
||||||
steps.shape_owner_set_transform(owner_id, Transform3D(Basis(), Vector3(0, y + height / 2.0, z)))
|
steps.shape_owner_set_transform(owner_id, Transform3D(Basis(), Vector3(0, y + height / 2.0, z)))
|
||||||
# Landing at the top with collision
|
# Landing at the top with collision
|
||||||
var landing_y: float = 4 * height + 0.1
|
var landing_y: float = count * height + 0.1
|
||||||
_add_box(steps, Vector3(9, 0.2, 3), Vector3(0, landing_y, -1.5), Color(0.5, 0.47, 0.45))
|
var landing_w := step_w + 1.0
|
||||||
|
_add_box(steps, Vector3(landing_w, 0.2, 3), Vector3(0, landing_y, -1.5), Color(0.5, 0.47, 0.45))
|
||||||
var landing_col := BoxShape3D.new()
|
var landing_col := BoxShape3D.new()
|
||||||
landing_col.size = Vector3(9, 0.2, 3)
|
landing_col.size = Vector3(landing_w, 0.2, 3)
|
||||||
var landing_owner := steps.create_shape_owner(steps)
|
var landing_owner := steps.create_shape_owner(steps)
|
||||||
steps.shape_owner_add_shape(landing_owner, landing_col)
|
steps.shape_owner_add_shape(landing_owner, landing_col)
|
||||||
steps.shape_owner_set_transform(landing_owner, Transform3D(Basis(), Vector3(0, landing_y, -1.5)))
|
steps.shape_owner_set_transform(landing_owner, Transform3D(Basis(), Vector3(0, landing_y, -1.5)))
|
||||||
@@ -215,12 +323,14 @@ func _build_steps() -> void:
|
|||||||
func _build_sidewalk() -> void:
|
func _build_sidewalk() -> void:
|
||||||
var sidewalk := StaticBody3D.new()
|
var sidewalk := StaticBody3D.new()
|
||||||
sidewalk.name = "Sidewalk"
|
sidewalk.name = "Sidewalk"
|
||||||
_add_box(sidewalk, Vector3(30, 0.18, 8), Vector3(0, 0.09, 8), Color(0.55, 0.55, 0.54), false, T_CONCRETE, Vector2(9, 2.4), "concrete")
|
var w := float(spec.get("sidewalk_width", 30.0))
|
||||||
|
var d := float(spec.get("sidewalk_depth", 8.0))
|
||||||
|
_add_box(sidewalk, Vector3(w, 0.18, d), Vector3(0, 0.09, d * 0.5), Color(0.55, 0.55, 0.54), false, T_CONCRETE, Vector2(w * 0.3, d * 0.3), "concrete")
|
||||||
var col := BoxShape3D.new()
|
var col := BoxShape3D.new()
|
||||||
col.size = Vector3(30, 0.36, 8)
|
col.size = Vector3(w, 0.36, d)
|
||||||
var so := sidewalk.create_shape_owner(sidewalk)
|
var so := sidewalk.create_shape_owner(sidewalk)
|
||||||
sidewalk.shape_owner_add_shape(so, col)
|
sidewalk.shape_owner_add_shape(so, col)
|
||||||
sidewalk.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, 8)))
|
sidewalk.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, d * 0.5)))
|
||||||
root.add_child(sidewalk)
|
root.add_child(sidewalk)
|
||||||
|
|
||||||
func _build_street() -> void:
|
func _build_street() -> void:
|
||||||
@@ -230,20 +340,24 @@ func _build_street() -> void:
|
|||||||
# white looked like more pavement.
|
# white looked like more pavement.
|
||||||
# The street keeps its own material so night mode can switch to the wet
|
# The street keeps its own material so night mode can switch to the wet
|
||||||
# asphalt map (glossier, with lamp reflections) and back.
|
# asphalt map (glossier, with lamp reflections) and back.
|
||||||
_street_mat = Materials.asphalt(Color(0.62, 0.63, 0.66), Vector2(30, 7))
|
var street_w := float(spec.get("street_width", 120.0))
|
||||||
var street_mesh := _add_box(street, Vector3(120, 0.1, 26), Vector3(0, 0.05, 22),
|
var street_d := float(spec.get("street_depth", 26.0))
|
||||||
Color(0.62, 0.63, 0.66), false, T_ASPHALT, Vector2(30, 7), "asphalt")
|
var curb_z := float(spec.get("sidewalk_depth", 8.0)) + 4.1
|
||||||
|
var street_z := curb_z + street_d * 0.5 - 3.1
|
||||||
|
_street_mat = Materials.asphalt(Color(0.62, 0.63, 0.66), Vector2(street_w * 0.25, street_d * 0.27))
|
||||||
|
var street_mesh := _add_box(street, Vector3(street_w, 0.1, street_d), Vector3(0, 0.05, street_z),
|
||||||
|
Color(0.62, 0.63, 0.66), false, T_ASPHALT, Vector2(street_w * 0.25, street_d * 0.27), "asphalt")
|
||||||
street_mesh.material_override = _street_mat
|
street_mesh.material_override = _street_mat
|
||||||
# Center dashes along the street (x varied so they form a lane line, not a stack).
|
# Center dashes along the street (x varied so they form a lane line, not a stack).
|
||||||
for i in range(-40, 41, 4):
|
for i in range(-int(street_w / 3.0), int(street_w / 3.0) + 1, 4):
|
||||||
_add_box(street, Vector3(0.2, 0.02, 1.2), Vector3(float(i), 0.11, 22), Color(0.85, 0.85, 0.30), false, null, Vector2.ONE, "concrete")
|
_add_box(street, Vector3(0.2, 0.02, 1.2), Vector3(float(i), 0.11, street_z), Color(0.85, 0.85, 0.30), false, null, Vector2.ONE, "concrete")
|
||||||
# Curb between the sidewalk and the roadway.
|
# Curb between the sidewalk and the roadway.
|
||||||
_add_box(street, Vector3(120, 0.22, 0.5), Vector3(0, 0.11, 12.1), Color(0.62, 0.61, 0.58), false, null, Vector2.ONE, "concrete")
|
_add_box(street, Vector3(street_w, 0.22, 0.5), Vector3(0, 0.11, curb_z), Color(0.62, 0.61, 0.58), false, null, Vector2.ONE, "concrete")
|
||||||
var col := BoxShape3D.new()
|
var col := BoxShape3D.new()
|
||||||
col.size = Vector3(120, 0.2, 26)
|
col.size = Vector3(street_w, 0.2, street_d)
|
||||||
var so := street.create_shape_owner(street)
|
var so := street.create_shape_owner(street)
|
||||||
street.shape_owner_add_shape(so, col)
|
street.shape_owner_add_shape(so, col)
|
||||||
street.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, 22)))
|
street.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, street_z)))
|
||||||
root.add_child(street)
|
root.add_child(street)
|
||||||
|
|
||||||
func _build_navmesh() -> void:
|
func _build_navmesh() -> void:
|
||||||
@@ -256,9 +370,11 @@ func _build_navmesh() -> void:
|
|||||||
mesh.agent_max_climb = 0.8
|
mesh.agent_max_climb = 0.8
|
||||||
mesh.agent_max_slope = 45.0
|
mesh.agent_max_slope = 45.0
|
||||||
mesh.cell_size = 0.25
|
mesh.cell_size = 0.25
|
||||||
|
var nav_min: Vector2 = spec.get("nav_min", Vector2(-18.0, -0.6))
|
||||||
|
var nav_max: Vector2 = spec.get("nav_max", Vector2(18.0, 20.0))
|
||||||
var verts := PackedVector3Array([
|
var verts := PackedVector3Array([
|
||||||
Vector3(-18, 0.0, -0.6), Vector3(18, 0.0, -0.6),
|
Vector3(nav_min.x, 0.0, nav_min.y), Vector3(nav_max.x, 0.0, nav_min.y),
|
||||||
Vector3(18, 0.0, 20.0), Vector3(-18, 0.0, 20.0),
|
Vector3(nav_max.x, 0.0, nav_max.y), Vector3(nav_min.x, 0.0, nav_max.y),
|
||||||
])
|
])
|
||||||
mesh.set_vertices(verts)
|
mesh.set_vertices(verts)
|
||||||
mesh.add_polygon(PackedInt32Array([0, 1, 2, 3]))
|
mesh.add_polygon(PackedInt32Array([0, 1, 2, 3]))
|
||||||
@@ -377,7 +493,7 @@ func _build_marquee() -> void:
|
|||||||
## a parked sedan, and wall lanterns. Kept separate so the placement validator
|
## a parked sedan, and wall lanterns. Kept separate so the placement validator
|
||||||
## only sees grounded sidewalk furniture.
|
## only sees grounded sidewalk furniture.
|
||||||
func _build_library_dressing() -> void:
|
func _build_library_dressing() -> void:
|
||||||
if ResourceLoader.exists(BLENDER_PROP_DIR + "handrail.glb"):
|
if _feature("handrail") and ResourceLoader.exists(BLENDER_PROP_DIR + "handrail.glb"):
|
||||||
var rail := _load_prop_scene("handrail")
|
var rail := _load_prop_scene("handrail")
|
||||||
if rail != null:
|
if rail != null:
|
||||||
rail.name = "LibraryHandrail"
|
rail.name = "LibraryHandrail"
|
||||||
@@ -386,27 +502,146 @@ func _build_library_dressing() -> void:
|
|||||||
root.add_child(rail)
|
root.add_child(rail)
|
||||||
library_nodes.append(rail)
|
library_nodes.append(rail)
|
||||||
_apply_library_ao(rail, "handrail")
|
_apply_library_ao(rail, "handrail")
|
||||||
if ResourceLoader.exists(BLENDER_PROP_DIR + "sedan.glb"):
|
if _feature("sedan") and ResourceLoader.exists(BLENDER_PROP_DIR + "sedan.glb"):
|
||||||
var sedan := _add_prop_model(root, "sedan", Vector3(6.0, 0.1, 17.0), 180.0)
|
var sedan_pos: Vector3 = spec.get("sedan_position", Vector3(6.0, 0.1, 17.0))
|
||||||
|
var sedan_rot := float(spec.get("sedan_rotation", 180.0))
|
||||||
|
var sedan := _add_prop_model(root, "sedan", sedan_pos, sedan_rot)
|
||||||
sedan.name = "ParkedSedan"
|
sedan.name = "ParkedSedan"
|
||||||
|
if venue_id == "tv_studio":
|
||||||
|
_apply_car_paint(sedan)
|
||||||
if not library_nodes.has(sedan):
|
if not library_nodes.has(sedan):
|
||||||
library_nodes.append(sedan)
|
library_nodes.append(sedan)
|
||||||
for material in _emissive_materials(sedan):
|
for material in _emissive_materials(sedan):
|
||||||
if not _lamp_bulb_mats.has(material):
|
if not _lamp_bulb_mats.has(material):
|
||||||
_lamp_bulb_mats.append(material)
|
_lamp_bulb_mats.append(material)
|
||||||
if ResourceLoader.exists(BLENDER_PROP_DIR + "wall_lantern.glb"):
|
if _feature("wall_lanterns") and ResourceLoader.exists(BLENDER_PROP_DIR + "wall_lantern.glb"):
|
||||||
for side in [-4.9, 4.9]:
|
if venue_id == "joke_shop":
|
||||||
var lantern := _load_prop_scene("wall_lantern")
|
_add_wall_lantern(Vector3(3.2, 3.3, -3.72), true)
|
||||||
if lantern == null:
|
else:
|
||||||
continue
|
for side in [-4.9, 4.9]:
|
||||||
lantern.name = "WallLantern"
|
_add_wall_lantern(Vector3(side, 3.3, -3.78), false)
|
||||||
lantern.position = Vector3(side, 3.3, -3.78)
|
|
||||||
root.add_child(lantern)
|
## The TV studio entrance canopy: a dark metal slab with a row of warm bulbs,
|
||||||
library_nodes.append(lantern)
|
## replacing the boulevard marquee.
|
||||||
_apply_library_ao(lantern, "wall_lantern")
|
func _build_bulb_canopy() -> void:
|
||||||
for material in _emissive_materials(lantern):
|
var front := float(spec["facade_center_z"]) + float(spec["facade_depth"]) * 0.5
|
||||||
if not _lamp_bulb_mats.has(material):
|
var canopy := _add_box(root, Vector3(7.2, 0.22, 2.2),
|
||||||
_lamp_bulb_mats.append(material)
|
Vector3(0, 5.7, front + 1.0), Color(0.16, 0.16, 0.18),
|
||||||
|
true, null, Vector2.ONE, "metal")
|
||||||
|
canopy.name = "BulbCanopy"
|
||||||
|
for x in [-3.0, 3.0]:
|
||||||
|
_add_box(root, Vector3(0.12, 5.5, 0.12),
|
||||||
|
Vector3(x, 2.75, front + 1.95), Color(0.20, 0.20, 0.22),
|
||||||
|
true, null, Vector2.ONE, "metal")
|
||||||
|
for x in [-2.8, -1.4, 0.0, 1.4, 2.8]:
|
||||||
|
var bulb := MeshInstance3D.new()
|
||||||
|
var sphere := SphereMesh.new()
|
||||||
|
sphere.radius = 0.075
|
||||||
|
sphere.height = 0.15
|
||||||
|
bulb.mesh = sphere
|
||||||
|
var mat := _emissive_material(Color(1.0, 0.78, 0.42))
|
||||||
|
bulb.material_override = mat
|
||||||
|
bulb.position = Vector3(x, 5.53, front + 1.0)
|
||||||
|
root.add_child(bulb)
|
||||||
|
if not _marquee_emissive.has(mat):
|
||||||
|
_marquee_emissive.append(mat)
|
||||||
|
for x in [-2.2, 2.2]:
|
||||||
|
var omni := OmniLight3D.new()
|
||||||
|
omni.position = Vector3(x, 5.15, front + 1.4)
|
||||||
|
omni.omni_range = 12.0 if Materials.modern_light_falloff() else 8.0
|
||||||
|
omni.omni_attenuation = 0.0 if Materials.modern_light_falloff() else 1.0
|
||||||
|
omni.light_energy = 0.0
|
||||||
|
omni.light_color = Color(1.0, 0.72, 0.36)
|
||||||
|
root.add_child(omni)
|
||||||
|
_lamp_omni.append(omni)
|
||||||
|
|
||||||
|
## Glossy black paint for the studio's parked car. The library sedan keeps its
|
||||||
|
## tintable slots; the studio overrides the body meshes with a single paint.
|
||||||
|
func _apply_car_paint(holder: Node3D) -> void:
|
||||||
|
var paint := StandardMaterial3D.new()
|
||||||
|
paint.resource_name = "StudioCarPaint"
|
||||||
|
paint.albedo_color = Color(0.018, 0.020, 0.024)
|
||||||
|
paint.metallic = 0.85
|
||||||
|
paint.roughness = 0.12
|
||||||
|
paint.metallic_specular = 0.8
|
||||||
|
for node in holder.find_children("*", "MeshInstance3D", true, false):
|
||||||
|
var mi := node as MeshInstance3D
|
||||||
|
if mi != null:
|
||||||
|
mi.material_override = paint
|
||||||
|
|
||||||
|
## One wall lantern plus an optional warm light. The Joke Shop uses a single
|
||||||
|
## lantern as its key light; the boulevard keeps two unlit visual lanterns.
|
||||||
|
func _add_wall_lantern(pos: Vector3, with_light: bool) -> void:
|
||||||
|
var lantern := _load_prop_scene("wall_lantern")
|
||||||
|
if lantern == null:
|
||||||
|
return
|
||||||
|
lantern.name = "WallLantern"
|
||||||
|
lantern.position = pos
|
||||||
|
root.add_child(lantern)
|
||||||
|
library_nodes.append(lantern)
|
||||||
|
_apply_library_ao(lantern, "wall_lantern")
|
||||||
|
for material in _emissive_materials(lantern):
|
||||||
|
if not _lamp_bulb_mats.has(material):
|
||||||
|
_lamp_bulb_mats.append(material)
|
||||||
|
if with_light:
|
||||||
|
var omni := OmniLight3D.new()
|
||||||
|
omni.name = "LanternGlow"
|
||||||
|
omni.position = pos + Vector3(0.0, -0.1, 0.45)
|
||||||
|
omni.omni_range = 14.0 if Materials.modern_light_falloff() else 9.0
|
||||||
|
omni.omni_attenuation = 0.0 if Materials.modern_light_falloff() else 1.0
|
||||||
|
omni.light_energy = 0.0
|
||||||
|
omni.light_color = Color(1.0, 0.72, 0.38)
|
||||||
|
root.add_child(omni)
|
||||||
|
_lamp_omni.append(omni)
|
||||||
|
|
||||||
|
## The Joke Shop alley: two brick side walls frame a 12 m passage, with
|
||||||
|
## shutters, a recessed door step, a dumpster and wall-mounted AC boxes.
|
||||||
|
func _build_alley_dressing() -> void:
|
||||||
|
var front := float(spec["facade_center_z"]) + float(spec["facade_depth"]) * 0.5
|
||||||
|
var height := float(spec["facade_height"])
|
||||||
|
var wall_x := float(spec["facade_width"]) * 0.5 + 0.35
|
||||||
|
for side in [-1.0, 1.0]:
|
||||||
|
var wall := _add_box(root, Vector3(0.7, height, 15.0),
|
||||||
|
Vector3(side * wall_x, height * 0.5, front + 7.5), Color(0.72, 0.70, 0.68),
|
||||||
|
true, T_BRICK, Vector2(8, 6), "brick")
|
||||||
|
wall.name = "AlleyWall"
|
||||||
|
_add_box(root, Vector3(0.9, 0.35, 15.0),
|
||||||
|
Vector3(side * wall_x, 0.18, front + 7.5), Color(0.44, 0.42, 0.40),
|
||||||
|
true, null, Vector2.ONE, "stone")
|
||||||
|
# Shutters over the two upper windows.
|
||||||
|
for x in [-3.8, 3.8]:
|
||||||
|
_add_box(root, Vector3(2.0, 2.5, 0.12), Vector3(x, 8.2, front + 0.22),
|
||||||
|
Color(0.18, 0.24, 0.21), true, null, Vector2.ONE, "painted_metal")
|
||||||
|
for slat in range(-4, 5):
|
||||||
|
_add_box(root, Vector3(1.9, 0.07, 0.04),
|
||||||
|
Vector3(x, 8.2 + slat * 0.28, front + 0.30),
|
||||||
|
Color(0.24, 0.31, 0.27), false, null, Vector2.ONE, "painted_metal")
|
||||||
|
# The recessed door gets a low stone step into the alley.
|
||||||
|
_add_box(root, Vector3(3.4, 0.16, 0.55), Vector3(0, 0.08, front + 0.75),
|
||||||
|
Color(0.48, 0.45, 0.42), true, null, Vector2.ONE, "stone")
|
||||||
|
# Dumpster and its lid; a solid body so guests and the guard walk around it.
|
||||||
|
var dump := StaticBody3D.new()
|
||||||
|
dump.name = "AlleyDumpster"
|
||||||
|
dump.position = Vector3(-4.2, 0, front + 9.2)
|
||||||
|
_add_box(dump, Vector3(1.9, 1.15, 1.25), Vector3(0, 0.58, 0),
|
||||||
|
Color(0.16, 0.28, 0.21), true, null, Vector2.ONE, "painted_metal")
|
||||||
|
_add_box(dump, Vector3(2.0, 0.08, 1.35), Vector3(0, 1.18, -0.02),
|
||||||
|
Color(0.11, 0.19, 0.15), true, null, Vector2.ONE, "painted_metal")
|
||||||
|
var dump_col := BoxShape3D.new()
|
||||||
|
dump_col.size = Vector3(1.9, 1.2, 1.3)
|
||||||
|
var owner_id := dump.create_shape_owner(dump)
|
||||||
|
dump.shape_owner_add_shape(owner_id, dump_col)
|
||||||
|
dump.shape_owner_set_transform(owner_id, Transform3D(Basis(), Vector3(0, 0.6, 0)))
|
||||||
|
root.add_child(dump)
|
||||||
|
# AC boxes on the inside faces of the walls, above head height.
|
||||||
|
for side in [-1.0, 1.0]:
|
||||||
|
for z in [front + 3.0, front + 7.0]:
|
||||||
|
_add_box(root, Vector3(0.55, 0.8, 1.1),
|
||||||
|
Vector3(side * (wall_x - 0.5), 5.8, z),
|
||||||
|
Color(0.56, 0.57, 0.55), true, null, Vector2.ONE, "metal")
|
||||||
|
_add_box(root, Vector3(0.08, 0.55, 0.8),
|
||||||
|
Vector3(side * (wall_x - 0.78), 5.8, z),
|
||||||
|
Color(0.22, 0.23, 0.22), false, null, Vector2.ONE, "metal")
|
||||||
|
|
||||||
## Emissive materials under a prop, used to tie library bulbs/LEDs to night mode.
|
## Emissive materials under a prop, used to tie library bulbs/LEDs to night mode.
|
||||||
func _emissive_materials(holder: Node3D) -> Array[StandardMaterial3D]:
|
func _emissive_materials(holder: Node3D) -> Array[StandardMaterial3D]:
|
||||||
@@ -426,26 +661,29 @@ func _build_street_furniture() -> void:
|
|||||||
# Blender library street lamps (bulbs + omni lights, off by day). The lamp
|
# Blender library street lamps (bulbs + omni lights, off by day). The lamp
|
||||||
# arm points to local -X, so the left side is rotated 180 degrees to face
|
# arm points to local -X, so the left side is rotated 180 degrees to face
|
||||||
# the sidewalk.
|
# the sidewalk.
|
||||||
for side in [-7.5, 7.5]:
|
if _feature("street_lamps"):
|
||||||
var holder := _add_prop_model(root, "street_lamp",
|
for side in [-7.5, 7.5]:
|
||||||
Vector3(side, 0.18, 8.5), 180.0 if side < 0.0 else 0.0)
|
var holder := _add_prop_model(root, "street_lamp",
|
||||||
holder.name = "Lamp_%d" % int(side)
|
Vector3(side, 0.18, 8.5), 180.0 if side < 0.0 else 0.0)
|
||||||
var omni := OmniLight3D.new()
|
holder.name = "Lamp_%d" % int(side)
|
||||||
omni.position = Vector3(-0.85, 4.65, 0)
|
var omni := OmniLight3D.new()
|
||||||
omni.omni_range = 12.0
|
omni.position = Vector3(-0.85, 4.65, 0)
|
||||||
omni.light_energy = 0.0
|
omni.omni_range = 16.0 if Materials.modern_light_falloff() else 12.0
|
||||||
omni.light_color = Color(1, 0.93, 0.75)
|
omni.omni_attenuation = 0.0 if Materials.modern_light_falloff() else 1.0
|
||||||
holder.add_child(omni)
|
omni.light_energy = 0.0
|
||||||
_lamp_omni.append(omni)
|
omni.light_color = Color(1, 0.93, 0.75)
|
||||||
_collect_emissive(holder)
|
holder.add_child(omni)
|
||||||
|
_lamp_omni.append(omni)
|
||||||
|
_collect_emissive(holder)
|
||||||
# Blender library street trees replace the cone trees. Flank the walkway and
|
# Blender library street trees replace the cone trees. Flank the walkway and
|
||||||
# keep the sight line to the door clear.
|
# keep the sight line to the door clear.
|
||||||
for i in 6:
|
if _feature("street_trees"):
|
||||||
var side := -1.0 if i % 2 == 0 else 1.0
|
for i in 6:
|
||||||
var x := side * (6.5 + float(int(i / 2)) * 3.0)
|
var side := -1.0 if i % 2 == 0 else 1.0
|
||||||
var z := 4.0 + float((i * 7) % 5)
|
var x := side * (6.5 + float(int(i / 2)) * 3.0)
|
||||||
_add_prop_model(root, "street_tree", Vector3(x, 0.18, z),
|
var z := 4.0 + float((i * 7) % 5)
|
||||||
float((i * 37) % 360))
|
_add_prop_model(root, "street_tree", Vector3(x, 0.18, z),
|
||||||
|
float((i * 37) % 360))
|
||||||
|
|
||||||
## Total triangles across the placed Blender library pieces, for the draw budget
|
## Total triangles across the placed Blender library pieces, for the draw budget
|
||||||
## test. Counts indices when present, otherwise the vertex stream.
|
## test. Counts indices when present, otherwise the vertex stream.
|
||||||
@@ -585,8 +823,9 @@ func _build_street_detail() -> void:
|
|||||||
var detail := Node3D.new()
|
var detail := Node3D.new()
|
||||||
detail.name = "StreetDetail"
|
detail.name = "StreetDetail"
|
||||||
root.add_child(detail)
|
root.add_child(detail)
|
||||||
for index in PROP_PLACEMENTS.size():
|
var placements: Array = spec.get("prop_placements", PROP_PLACEMENTS)
|
||||||
var entry: Dictionary = PROP_PLACEMENTS[index]
|
for index in placements.size():
|
||||||
|
var entry: Dictionary = placements[index]
|
||||||
var base_rot := float(entry.get("rot", 0.0))
|
var base_rot := float(entry.get("rot", 0.0))
|
||||||
var variant := variant_for(index, base_rot)
|
var variant := variant_for(index, base_rot)
|
||||||
var holder := _add_prop_model(detail, str(entry["prop"]),
|
var holder := _add_prop_model(detail, str(entry["prop"]),
|
||||||
@@ -821,7 +1060,7 @@ func _build_guard_spawn() -> void:
|
|||||||
# The guard's head offset is 1.6m, so spawn at y=0.76-1.6=-0.84 to place feet on ground
|
# The guard's head offset is 1.6m, so spawn at y=0.76-1.6=-0.84 to place feet on ground
|
||||||
# But we want the guard at eye-level with the sidewalk, so adjust to y=0.1 for feet
|
# But we want the guard at eye-level with the sidewalk, so adjust to y=0.1 for feet
|
||||||
# Then the head will be at y=1.7 (1.6 offset + 0.1 base)
|
# Then the head will be at y=1.7 (1.6 offset + 0.1 base)
|
||||||
marker.position = Vector3(0, 0.1, -2.0)
|
marker.position = spec.get("guard_spawn", Vector3(0, 0.1, -2.0))
|
||||||
root.add_child(marker)
|
root.add_child(marker)
|
||||||
|
|
||||||
func _lock_indicator_material(color: Color) -> StandardMaterial3D:
|
func _lock_indicator_material(color: Color) -> StandardMaterial3D:
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ const HOODED_TYPES := [5, 6, 7] # TRESPASSER, VIOLENT_THREAT, MENTAL_CRISIS
|
|||||||
|
|
||||||
## Optional `--portrait-type=N` picks the NpcType a portrait should feature.
|
## Optional `--portrait-type=N` picks the NpcType a portrait should feature.
|
||||||
var _portrait_type := -1
|
var _portrait_type := -1
|
||||||
|
## Optional `--flashlight` turns the guard lamp on for night stages; the desktop
|
||||||
|
## HQ A/B uses it to show the volumetric shaft.
|
||||||
|
var _flashlight := false
|
||||||
## Where the portrait subject is placed, relative to the guard's spawn.
|
## Where the portrait subject is placed, relative to the guard's spawn.
|
||||||
var _portrait_spot := Vector3(0.0, 0.5, 9.0)
|
var _portrait_spot := Vector3(0.0, 0.5, 9.0)
|
||||||
var _portrait_subject: Node3D = null
|
var _portrait_subject: Node3D = null
|
||||||
@@ -114,6 +117,8 @@ func _parse_args() -> void:
|
|||||||
_only_stages[name.strip_edges()] = true
|
_only_stages[name.strip_edges()] = true
|
||||||
elif text.begins_with("--portrait-type="):
|
elif text.begins_with("--portrait-type="):
|
||||||
_portrait_type = int(text.substr(16))
|
_portrait_type = int(text.substr(16))
|
||||||
|
elif text == "--flashlight":
|
||||||
|
_flashlight = true
|
||||||
|
|
||||||
func _pair(text: String, into: Dictionary) -> void:
|
func _pair(text: String, into: Dictionary) -> void:
|
||||||
var cut := text.find("=", 6) # skip the "res://" scheme
|
var cut := text.find("=", 6) # skip the "res://" scheme
|
||||||
@@ -168,6 +173,10 @@ func _begin_stage() -> void:
|
|||||||
if venue != "" and venue != str(gm.get("active_scenario_id")):
|
if venue != "" and venue != str(gm.get("active_scenario_id")):
|
||||||
gm.call("switch_scenario", venue)
|
gm.call("switch_scenario", venue)
|
||||||
gm.coordinator.elapsed_s = float(stage["elapsed"])
|
gm.coordinator.elapsed_s = float(stage["elapsed"])
|
||||||
|
if _flashlight:
|
||||||
|
var want := bool(stage["night"])
|
||||||
|
if want != bool(gm.flashlight_on):
|
||||||
|
gm.call("_toggle_flashlight")
|
||||||
_apply(swaps)
|
_apply(swaps)
|
||||||
if bool(stage["night"]):
|
if bool(stage["night"]):
|
||||||
_apply(night_swaps)
|
_apply(night_swaps)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -43,8 +43,11 @@ func test_flashlight_aims_a_tight_forward_spot() -> bool:
|
|||||||
var c := TestUtils.check(lamp != null and lamp.spot_angle <= 20.0,
|
var c := TestUtils.check(lamp != null and lamp.spot_angle <= 20.0,
|
||||||
"flashlight cone is narrow enough to read as a torch")
|
"flashlight cone is narrow enough to read as a torch")
|
||||||
var d := TestUtils.check(lamp != null and not lamp.visible, "flashlight starts off")
|
var d := TestUtils.check(lamp != null and not lamp.visible, "flashlight starts off")
|
||||||
|
var e := TestUtils.check(lamp != null
|
||||||
|
and is_zero_approx(lamp.light_volumetric_fog_energy),
|
||||||
|
"headless/compatibility builds leave the flashlight non-volumetric")
|
||||||
root.free()
|
root.free()
|
||||||
return a and b and c and d
|
return a and b and c and d and e
|
||||||
|
|
||||||
func test_flashlight_widens_perception() -> bool:
|
func test_flashlight_widens_perception() -> bool:
|
||||||
## Without the flashlight, an NPC at AWARE_RADIUS + 3 m is unaware.
|
## Without the flashlight, an NPC at AWARE_RADIUS + 3 m is unaware.
|
||||||
|
|||||||
@@ -77,14 +77,19 @@ func test_environment_tuning_applies_phase_values() -> bool:
|
|||||||
var f := TestUtils.check(
|
var f := TestUtils.check(
|
||||||
env.fog_light_color.is_equal_approx(late["fog_color"]),
|
env.fog_light_color.is_equal_approx(late["fog_color"]),
|
||||||
"phase fog colour is used")
|
"phase fog colour is used")
|
||||||
return a and b and c and d and e and f
|
var advanced := Materials.forward_plus_active()
|
||||||
|
var g := TestUtils.check(env.volumetric_fog_enabled == advanced,
|
||||||
|
"volumetric fog is gated to Forward+ (advanced=%s)" % advanced)
|
||||||
|
var h := TestUtils.check(env.sdfgi_enabled == advanced,
|
||||||
|
"SDFGI is gated to Forward+ (advanced=%s)" % advanced)
|
||||||
|
return a and b and c and d and e and f and g and h
|
||||||
|
|
||||||
func test_every_phase_has_environment_keys() -> bool:
|
func test_every_phase_has_environment_keys() -> bool:
|
||||||
var ok := true
|
var ok := true
|
||||||
for phase in [Rules.Phase.EARLY, Rules.Phase.MIDDAY, Rules.Phase.EVENING, Rules.Phase.LATE]:
|
for phase in [Rules.Phase.EARLY, Rules.Phase.MIDDAY, Rules.Phase.EVENING, Rules.Phase.LATE]:
|
||||||
var cfg: Dictionary = GameManager.PHASE_LIGHTING.get(phase, {})
|
var cfg: Dictionary = GameManager.PHASE_LIGHTING.get(phase, {})
|
||||||
for key in ["exposure", "glow", "fog_color", "fog_density", "fog_energy",
|
for key in ["exposure", "glow", "fog_color", "fog_density", "fog_energy",
|
||||||
"contrast", "saturation"]:
|
"contrast", "saturation", "volumetric_density", "volumetric_energy"]:
|
||||||
if not cfg.has(key):
|
if not cfg.has(key):
|
||||||
ok = false
|
ok = false
|
||||||
return TestUtils.check(ok, "all phases set exposure/glow/fog/grading")
|
return TestUtils.check(ok, "all phases set exposure/glow/fog/grading")
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
# build/gig-line-windows-<version>.exe
|
# build/gig-line-windows-<version>.exe
|
||||||
# build/gig-line-macos-<version>.zip
|
# build/gig-line-macos-<version>.zip
|
||||||
# build/gig-line-web-<version>.zip (Godot web build: static hosting, no threads)
|
# build/gig-line-web-<version>.zip (Godot web build: static hosting, no threads)
|
||||||
|
# build/gig-line-desktop-hq-<version>.zip (optional Forward+ Linux/Windows)
|
||||||
#
|
#
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -49,6 +50,30 @@ with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as archive:
|
|||||||
PYZIP
|
PYZIP
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "==> Packaging optional Desktop HQ build (Forward+)"
|
||||||
|
HQ_DIR="build/desktop-hq"
|
||||||
|
rm -rf "$HQ_DIR"
|
||||||
|
mkdir -p "$HQ_DIR"
|
||||||
|
cp "build/gig-line-linux-${VERSION}.x86_64" "$HQ_DIR/"
|
||||||
|
cp "build/gig-line-windows-${VERSION}.exe" "$HQ_DIR/"
|
||||||
|
cp packaging/desktop_hq/override.cfg "$HQ_DIR/"
|
||||||
|
cp packaging/desktop_hq/README.txt "$HQ_DIR/"
|
||||||
|
rm -f "build/gig-line-desktop-hq-${VERSION}.zip"
|
||||||
|
if command -v zip >/dev/null 2>&1; then
|
||||||
|
(cd "$HQ_DIR" && zip -q -r "../gig-line-desktop-hq-${VERSION}.zip" .)
|
||||||
|
else
|
||||||
|
python3 - "$PWD/$HQ_DIR" "$PWD/build/gig-line-desktop-hq-${VERSION}.zip" <<'PYZIP'
|
||||||
|
import os, sys, zipfile
|
||||||
|
src, out = sys.argv[1], sys.argv[2]
|
||||||
|
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
for root, _dirs, files in os.walk(src):
|
||||||
|
for name in files:
|
||||||
|
path = os.path.join(root, name)
|
||||||
|
archive.write(path, os.path.relpath(path, src))
|
||||||
|
PYZIP
|
||||||
|
fi
|
||||||
|
rm -rf "$HQ_DIR"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Artifacts:"
|
echo "Artifacts:"
|
||||||
ls -la build/*"${VERSION}"*
|
ls -la build/*"${VERSION}"*
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Pick affordable OpenRouter vision models for the screenshot judges.
|
||||||
|
|
||||||
|
The model catalogue is fetched from OpenRouter (no auth needed) and filtered to
|
||||||
|
models that accept image input and advertise `response_format`/structured output.
|
||||||
|
The ranking uses the advertised per-token prices, so it follows the account's
|
||||||
|
current catalogue instead of relying on one hard-coded model name. If the
|
||||||
|
catalogue cannot be fetched, a small known-good list keeps QA working offline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
MODELS_URL = "https://openrouter.ai/api/v1/models"
|
||||||
|
DEFAULT_MODEL = "auto"
|
||||||
|
|
||||||
|
# Used only when the catalogue is unavailable. Ordered by price/known quality.
|
||||||
|
FALLBACK_MODELS = [
|
||||||
|
"z-ai/glm-5.3-flash",
|
||||||
|
"qwen/qwen3.7-flash",
|
||||||
|
"google/gemma-3-4b-it",
|
||||||
|
"openai/gpt-4.1-nano",
|
||||||
|
"google/gemini-2.5-flash-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_models(timeout: int = 30) -> list[dict]:
|
||||||
|
"""Return the OpenRouter model catalogue, or [] when it is unreachable."""
|
||||||
|
req = urllib.request.Request(MODELS_URL, headers={"Accept": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
payload = __import__("json").loads(resp.read().decode("utf-8"))
|
||||||
|
except (OSError, ValueError, urllib.error.HTTPError):
|
||||||
|
return []
|
||||||
|
return list(payload.get("data") or [])
|
||||||
|
|
||||||
|
|
||||||
|
def _price(model: dict) -> float:
|
||||||
|
pricing = model.get("pricing") or {}
|
||||||
|
try:
|
||||||
|
prompt = float(pricing.get("prompt") or 0.0)
|
||||||
|
completion = float(pricing.get("completion") or 0.0)
|
||||||
|
image = float(pricing.get("image") or 0.0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return float("inf")
|
||||||
|
# A judge request is image-heavy and emits a short JSON verdict.
|
||||||
|
return prompt + image + completion * 0.25
|
||||||
|
|
||||||
|
|
||||||
|
def vision_candidates(catalog: list[dict], limit: int = 6) -> list[str]:
|
||||||
|
"""Cheap, image-capable models that can return structured JSON."""
|
||||||
|
ranked: list[tuple[float, str]] = []
|
||||||
|
for model in catalog:
|
||||||
|
model_id = str(model.get("id") or "")
|
||||||
|
if not model_id or model_id.startswith("~") or ":batch" in model_id:
|
||||||
|
continue
|
||||||
|
architecture = model.get("architecture") or {}
|
||||||
|
inputs = architecture.get("input_modalities") or []
|
||||||
|
outputs = architecture.get("output_modalities") or []
|
||||||
|
if "image" not in inputs or "text" not in outputs:
|
||||||
|
continue
|
||||||
|
params = model.get("supported_parameters") or []
|
||||||
|
# Our request uses response_format={"type": "json_object"}; models that
|
||||||
|
# only advertise structured_outputs may reject that envelope.
|
||||||
|
if "response_format" not in params:
|
||||||
|
continue
|
||||||
|
price = _price(model)
|
||||||
|
if price <= 0.0 or price == float("inf"):
|
||||||
|
continue
|
||||||
|
ranked.append((price, model_id))
|
||||||
|
ranked.sort()
|
||||||
|
out: list[str] = []
|
||||||
|
for _price_value, model_id in ranked:
|
||||||
|
if model_id not in out:
|
||||||
|
out.append(model_id)
|
||||||
|
if len(out) >= max(1, limit):
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_models(requested: str = DEFAULT_MODEL, *, catalog: list[dict] | None = None,
|
||||||
|
limit: int = 6) -> list[str]:
|
||||||
|
"""Resolve ``auto``/an explicit model to a fallback-ordered model list.
|
||||||
|
|
||||||
|
`GIG_LINE_JUDGE_MODEL` overrides the automatic choice for reproducible runs;
|
||||||
|
an explicit CLI `--model` still wins.
|
||||||
|
"""
|
||||||
|
requested = str(requested or "").strip()
|
||||||
|
if requested and requested != DEFAULT_MODEL:
|
||||||
|
return [requested]
|
||||||
|
env_model = os.environ.get("GIG_LINE_JUDGE_MODEL", "").strip()
|
||||||
|
if env_model:
|
||||||
|
return [env_model]
|
||||||
|
models = vision_candidates(catalog if catalog is not None else fetch_models(), limit)
|
||||||
|
if not models:
|
||||||
|
models = FALLBACK_MODELS[:max(1, limit)]
|
||||||
|
return models
|
||||||
|
|
||||||
|
|
||||||
|
def is_auth_error(exc: BaseException) -> bool:
|
||||||
|
text = str(exc)
|
||||||
|
return "HTTP 401" in text or "HTTP 403" in text or "API key not valid" in text
|
||||||
|
|
||||||
|
|
||||||
|
def call_with_fallback(models: list[str], make_body, call, attempts: int = 2):
|
||||||
|
"""Try each model, retrying provider errors, and return (model, response).
|
||||||
|
|
||||||
|
Authentication errors abort immediately: switching models cannot repair a
|
||||||
|
bad key. Model/provider errors move to the next candidate.
|
||||||
|
"""
|
||||||
|
last: BaseException | None = None
|
||||||
|
for model in models:
|
||||||
|
body = make_body(model)
|
||||||
|
for attempt in range(max(1, attempts)):
|
||||||
|
try:
|
||||||
|
return model, call(body)
|
||||||
|
except (ValueError, OSError) as exc:
|
||||||
|
last = exc
|
||||||
|
if is_auth_error(exc):
|
||||||
|
raise
|
||||||
|
if attempt + 1 >= max(1, attempts):
|
||||||
|
break
|
||||||
|
if last is not None:
|
||||||
|
raise last
|
||||||
|
raise ValueError("no judge model was available")
|
||||||
@@ -67,6 +67,7 @@ extras=(
|
|||||||
"build/gig-line-setup-$VERSION.exe"
|
"build/gig-line-setup-$VERSION.exe"
|
||||||
"build/gig-line-$VERSION-x86_64.AppImage"
|
"build/gig-line-$VERSION-x86_64.AppImage"
|
||||||
"build/gig-line-web-$VERSION.zip"
|
"build/gig-line-web-$VERSION.zip"
|
||||||
|
"build/gig-line-desktop-hq-$VERSION.zip"
|
||||||
)
|
)
|
||||||
all_artifacts=("${portables[@]}")
|
all_artifacts=("${portables[@]}")
|
||||||
for f in "${extras[@]}"; do
|
for f in "${extras[@]}"; do
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from PIL import Image, ImageFilter, ImageOps, ImageStat
|
|||||||
|
|
||||||
import asset_pipeline
|
import asset_pipeline
|
||||||
import generate_textures
|
import generate_textures
|
||||||
|
import openrouter_judge
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
RAW_DIR = ROOT / "assets" / "textures" / "ai" / "v2" / "raw"
|
RAW_DIR = ROOT / "assets" / "textures" / "ai" / "v2" / "raw"
|
||||||
@@ -46,7 +47,7 @@ CONCEPT_DIR = ROOT / "docs" / "screens" / "concepts"
|
|||||||
REALISM_DIR = ROOT / "docs" / "screens" / "realism"
|
REALISM_DIR = ROOT / "docs" / "screens" / "realism"
|
||||||
|
|
||||||
DEFAULT_MODEL = "openai/gpt-5-image-mini"
|
DEFAULT_MODEL = "openai/gpt-5-image-mini"
|
||||||
DEFAULT_JUDGE_MODEL = "google/gemini-2.5-flash-lite"
|
DEFAULT_JUDGE_MODEL = openrouter_judge.DEFAULT_MODEL
|
||||||
OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions"
|
OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||||
|
|
||||||
SCENE_W, SCENE_H = 1280, 720
|
SCENE_W, SCENE_H = 1280, 720
|
||||||
@@ -191,10 +192,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",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -755,25 +759,32 @@ def _call_openrouter(body: dict, timeout: int = 180) -> dict:
|
|||||||
raise ValueError(f"OpenRouter HTTP {exc.code}: {exc.read().decode('utf-8', 'replace')[:300]}")
|
raise ValueError(f"OpenRouter HTTP {exc.code}: {exc.read().decode('utf-8', 'replace')[:300]}")
|
||||||
|
|
||||||
|
|
||||||
def judge_capture(model: str, target: Path, capture: Path, attempts: int = 2,
|
def judge_capture(models, target: Path, capture: Path, attempts: int = 2,
|
||||||
repeats: int = 1) -> dict:
|
repeats: int = 1) -> dict:
|
||||||
"""Vision-judge one capture; retry transient errors, average repeat scores.
|
"""Vision-judge one capture; retry transient errors, average repeat scores.
|
||||||
|
|
||||||
|
`models` is a fallback-ordered list from `openrouter_judge.resolve_models`.
|
||||||
A single score from a small vision model swings by ±1-2, so `repeats` runs
|
A single score from a small vision model swings by ±1-2, so `repeats` runs
|
||||||
are averaged and the spread is kept (`score_range`) for the report.
|
are averaged and the spread is kept (`score_range`) for the report.
|
||||||
"""
|
"""
|
||||||
|
if isinstance(models, str):
|
||||||
|
models = [models]
|
||||||
runs = []
|
runs = []
|
||||||
error = None
|
error = None
|
||||||
|
used = set()
|
||||||
for _ in range(max(1, repeats)):
|
for _ in range(max(1, repeats)):
|
||||||
for _attempt in range(attempts):
|
try:
|
||||||
try:
|
model, payload = openrouter_judge.call_with_fallback(
|
||||||
runs.append(parse_realism_response(_call_openrouter(
|
models, lambda selected: build_realism_request(selected, target, capture),
|
||||||
build_realism_request(model, target, capture))))
|
_call_openrouter, attempts=attempts)
|
||||||
break
|
used.add(model)
|
||||||
except (ValueError, OSError) as exc:
|
runs.append(parse_realism_response(payload))
|
||||||
error = exc
|
except (ValueError, OSError) as exc:
|
||||||
|
if openrouter_judge.is_auth_error(exc):
|
||||||
|
raise
|
||||||
|
error = exc
|
||||||
if not runs:
|
if not runs:
|
||||||
return {"scores": {}, "fixes": [f"judge failed: {error}"]}
|
return {"scores": {}, "fixes": [f"judge failed: {error}"], "model": ""}
|
||||||
keys = {key for run in runs for key in run["scores"]}
|
keys = {key for run in runs for key in run["scores"]}
|
||||||
scores = {}
|
scores = {}
|
||||||
spread = {}
|
spread = {}
|
||||||
@@ -787,7 +798,7 @@ def judge_capture(model: str, target: Path, capture: Path, attempts: int = 2,
|
|||||||
if fix not in fixes:
|
if fix not in fixes:
|
||||||
fixes.append(fix)
|
fixes.append(fix)
|
||||||
return {"scores": scores, "score_range": spread, "runs": len(runs),
|
return {"scores": scores, "score_range": spread, "runs": len(runs),
|
||||||
"fixes": fixes[:5]}
|
"model": ", ".join(sorted(used)), "fixes": fixes[:5]}
|
||||||
|
|
||||||
|
|
||||||
def cmd_realism(args) -> int:
|
def cmd_realism(args) -> int:
|
||||||
@@ -797,10 +808,14 @@ def cmd_realism(args) -> int:
|
|||||||
if not sets:
|
if not sets:
|
||||||
print(f"no capture sets under {_rel(run)}", file=sys.stderr)
|
print(f"no capture sets under {_rel(run)}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
judge_models = []
|
||||||
if args.judge:
|
if args.judge:
|
||||||
_load_env_file()
|
_load_env_file()
|
||||||
report = {"run": _rel(run), "judge_model": args.model if args.judge else None,
|
judge_models = openrouter_judge.resolve_models(args.model)
|
||||||
"sets": {}}
|
print(f"judge models: {', '.join(judge_models)}", file=sys.stderr)
|
||||||
|
report = {"run": _rel(run),
|
||||||
|
"judge_model": (judge_models[0] if judge_models else None),
|
||||||
|
"judge_fallbacks": judge_models, "sets": {}}
|
||||||
for capture_set in sets:
|
for capture_set in sets:
|
||||||
rows = {}
|
rows = {}
|
||||||
for capture in sorted(capture_set.glob("*.png")):
|
for capture in sorted(capture_set.glob("*.png")):
|
||||||
@@ -812,8 +827,15 @@ def cmd_realism(args) -> int:
|
|||||||
row = {"target": _rel(target), "metrics": cap_m, "target_metrics": tgt_m,
|
row = {"target": _rel(target), "metrics": cap_m, "target_metrics": tgt_m,
|
||||||
"metric_gap": metric_gap(cap_m, tgt_m)}
|
"metric_gap": metric_gap(cap_m, tgt_m)}
|
||||||
if args.judge:
|
if args.judge:
|
||||||
row["judge"] = judge_capture(args.model, target, capture,
|
try:
|
||||||
repeats=args.repeats)
|
row["judge"] = judge_capture(judge_models, target, capture,
|
||||||
|
repeats=args.repeats)
|
||||||
|
except ValueError as exc:
|
||||||
|
if openrouter_judge.is_auth_error(exc):
|
||||||
|
print(f"error: OpenRouter authentication failed: {exc}",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
raise
|
||||||
rows[capture.stem] = row
|
rows[capture.stem] = row
|
||||||
report["sets"][capture_set.name] = rows
|
report["sets"][capture_set.name] = rows
|
||||||
json_path = next_free_path(run / "report.json")
|
json_path = next_free_path(run / "report.json")
|
||||||
@@ -972,7 +994,9 @@ def build_parser():
|
|||||||
real = sub.add_parser("realism")
|
real = sub.add_parser("realism")
|
||||||
real.add_argument("--run", required=True)
|
real.add_argument("--run", required=True)
|
||||||
real.add_argument("--judge", action="store_true")
|
real.add_argument("--judge", action="store_true")
|
||||||
real.add_argument("--model", default=DEFAULT_JUDGE_MODEL)
|
real.add_argument("--model", default=DEFAULT_JUDGE_MODEL,
|
||||||
|
help="OpenRouter model id, or 'auto' to pick the cheapest "
|
||||||
|
"available image+JSON model with fallbacks")
|
||||||
real.add_argument("--repeats", type=int, default=1,
|
real.add_argument("--repeats", type=int, default=1,
|
||||||
help="average N judge runs per shot (scores are noisy)")
|
help="average N judge runs per shot (scores are noisy)")
|
||||||
real.set_defaults(handler=cmd_realism)
|
real.set_defaults(handler=cmd_realism)
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ from pathlib import Path
|
|||||||
from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageOps, ImageStat
|
from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageOps, ImageStat
|
||||||
|
|
||||||
import generate_textures
|
import generate_textures
|
||||||
|
import openrouter_judge
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
## Runtime reference art (ADR-0014); docs/screens/designs/ is the reference-only
|
## Runtime reference art (ADR-0014); docs/screens/designs/ is the reference-only
|
||||||
@@ -68,7 +69,7 @@ ASPECT = 16 / 9
|
|||||||
DEFAULT_MODEL = "openai/gpt-5-image-mini"
|
DEFAULT_MODEL = "openai/gpt-5-image-mini"
|
||||||
|
|
||||||
# Vision model used by `judge`. Cheap, multimodal, and good at "same screen?".
|
# Vision model used by `judge`. Cheap, multimodal, and good at "same screen?".
|
||||||
DEFAULT_JUDGE_MODEL = "google/gemini-2.5-flash-lite"
|
DEFAULT_JUDGE_MODEL = openrouter_judge.DEFAULT_MODEL
|
||||||
OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions"
|
OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||||
|
|
||||||
# Shared art direction, appended to every prompt so the generated frames read
|
# Shared art direction, appended to every prompt so the generated frames read
|
||||||
@@ -369,7 +370,7 @@ SCREENS = [
|
|||||||
"kind": "scene",
|
"kind": "scene",
|
||||||
"title": "Joke Shop door queue",
|
"title": "Joke Shop door queue",
|
||||||
"source": "PLAN.md The Joke Shop — long line, ID and ticket checks",
|
"source": "PLAN.md The Joke Shop — long line, ID and ticket checks",
|
||||||
"ref": None,
|
"ref": "12-joke-shop.png",
|
||||||
"description": "Evening view of a small comedy-club entrance: a short queue "
|
"description": "Evening view of a small comedy-club entrance: a short queue "
|
||||||
"on the sidewalk, a door person checking an ID and a ticket "
|
"on the sidewalk, a door person checking an ID and a ticket "
|
||||||
"under a warm bulb, a red awning, and the show board.",
|
"under a warm bulb, a red awning, and the show board.",
|
||||||
@@ -391,7 +392,7 @@ SCREENS = [
|
|||||||
"kind": "scene",
|
"kind": "scene",
|
||||||
"title": "TV Studio celebrity arrival",
|
"title": "TV Studio celebrity arrival",
|
||||||
"source": "PLAN.md Late Night TV Studio — crowd gathers for an arrival",
|
"source": "PLAN.md Late Night TV Studio — crowd gathers for an arrival",
|
||||||
"ref": None,
|
"ref": "13-studio-arrival.png",
|
||||||
"description": "A celebrity arrival at the studio entrance: tourists and "
|
"description": "A celebrity arrival at the studio entrance: tourists and "
|
||||||
"influencers pressing toward the door, cameras raised, the "
|
"influencers pressing toward the door, cameras raised, the "
|
||||||
"guard holding a line on the steps, studio marquee behind.",
|
"guard holding a line on the steps, studio marquee behind.",
|
||||||
@@ -965,6 +966,10 @@ def judge(args):
|
|||||||
refs = {screen["name"]: screen["ref"]
|
refs = {screen["name"]: screen["ref"]
|
||||||
for screen in SCREENS if screen["ref"]}
|
for screen in SCREENS if screen["ref"]}
|
||||||
by_name = {screen["name"]: screen for screen in SCREENS}
|
by_name = {screen["name"]: screen for screen in SCREENS}
|
||||||
|
models = [args.model]
|
||||||
|
if not args.dry_run:
|
||||||
|
models = openrouter_judge.resolve_models(args.model)
|
||||||
|
print(f"judge models: {', '.join(models)}", file=sys.stderr)
|
||||||
results = []
|
results = []
|
||||||
for capture in sorted(SCREENSHOTS_DIR.glob("*.png")):
|
for capture in sorted(SCREENSHOTS_DIR.glob("*.png")):
|
||||||
if args.only and args.only not in capture.name:
|
if args.only and args.only not in capture.name:
|
||||||
@@ -982,23 +987,36 @@ def judge(args):
|
|||||||
"match": None, "confidence": 0.0,
|
"match": None, "confidence": 0.0,
|
||||||
"differences": ["missing reference design; run generate"]})
|
"differences": ["missing reference design; run generate"]})
|
||||||
continue
|
continue
|
||||||
body = build_judge_request(args.model, by_name[design_name],
|
|
||||||
capture, reference)
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
|
body = build_judge_request(args.model, by_name[design_name],
|
||||||
|
capture, reference)
|
||||||
results.append({
|
results.append({
|
||||||
"capture": capture.name, "screen": design_name, "match": None,
|
"capture": capture.name, "screen": design_name, "match": None,
|
||||||
"confidence": 0.0, "model": body["model"], "class": "unknown",
|
"confidence": 0.0, "model": body["model"], "class": "unknown",
|
||||||
"differences": [f"dry run: {len(body['messages'][0]['content'])} parts"],
|
"differences": [f"dry run: {len(body['messages'][0]['content'])} parts"],
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
# Providers occasionally answer a valid key with a transient HTTP 400;
|
|
||||||
# one retry keeps a whole judge run from dying on a single hiccup.
|
|
||||||
try:
|
try:
|
||||||
verdict = parse_judge_response(_call_openrouter(body))
|
model, payload = openrouter_judge.call_with_fallback(
|
||||||
|
models,
|
||||||
|
lambda selected: build_judge_request(
|
||||||
|
selected, by_name[design_name], capture, reference),
|
||||||
|
_call_openrouter)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
print(f"retrying {capture.name}: {exc}", file=sys.stderr)
|
if openrouter_judge.is_auth_error(exc):
|
||||||
verdict = parse_judge_response(_call_openrouter(body))
|
print(f"error: OpenRouter authentication failed: {exc}",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
print(f"error judging {capture.name}: {exc}", file=sys.stderr)
|
||||||
|
results.append({
|
||||||
|
"capture": capture.name, "screen": design_name, "match": None,
|
||||||
|
"confidence": 0.0, "model": "",
|
||||||
|
"class": "unknown", "differences": [f"judge failed: {exc}"],
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
verdict = parse_judge_response(payload)
|
||||||
verdict.update({"capture": capture.name, "screen": design_name,
|
verdict.update({"capture": capture.name, "screen": design_name,
|
||||||
|
"model": model,
|
||||||
"class": classify(verdict, args.fail_confidence)})
|
"class": classify(verdict, args.fail_confidence)})
|
||||||
results.append(verdict)
|
results.append(verdict)
|
||||||
|
|
||||||
@@ -1047,7 +1065,9 @@ def build_parser():
|
|||||||
|
|
||||||
judge_cmd = commands.add_parser(
|
judge_cmd = commands.add_parser(
|
||||||
"judge", help="ask a vision model whether captures match the designs")
|
"judge", help="ask a vision model whether captures match the designs")
|
||||||
judge_cmd.add_argument("--model", default=DEFAULT_JUDGE_MODEL)
|
judge_cmd.add_argument("--model", default=DEFAULT_JUDGE_MODEL,
|
||||||
|
help="OpenRouter model id, or 'auto' to pick the cheapest "
|
||||||
|
"available image+JSON model with fallbacks")
|
||||||
judge_cmd.add_argument("--json", action="store_true")
|
judge_cmd.add_argument("--json", action="store_true")
|
||||||
judge_cmd.add_argument("--dry-run", action="store_true",
|
judge_cmd.add_argument("--dry-run", action="store_true",
|
||||||
help="print requests instead of calling the API")
|
help="print requests instead of calling the API")
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import openrouter_judge as oj
|
||||||
|
|
||||||
|
|
||||||
|
def _model(model_id, prompt, completion, *, image=True, response_format=True, batch=False):
|
||||||
|
return {
|
||||||
|
"id": model_id,
|
||||||
|
"architecture": {
|
||||||
|
"input_modalities": ["text", "image"] if image else ["text"],
|
||||||
|
"output_modalities": ["text"],
|
||||||
|
},
|
||||||
|
"supported_parameters": ["response_format"] if response_format else ["temperature"],
|
||||||
|
"pricing": {"prompt": str(prompt), "completion": str(completion)},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ModelSelectionTests(unittest.TestCase):
|
||||||
|
def test_candidates_are_image_json_models_sorted_by_price(self):
|
||||||
|
catalog = [
|
||||||
|
_model("expensive/model", 0.10, 0.40),
|
||||||
|
_model("cheap/model", 0.01, 0.02),
|
||||||
|
_model("text-only", 0.001, 0.001, image=False),
|
||||||
|
_model("no-json", 0.001, 0.001, response_format=False),
|
||||||
|
_model("batch/model:batch", 0.0001, 0.0001),
|
||||||
|
]
|
||||||
|
self.assertEqual(oj.vision_candidates(catalog), ["cheap/model", "expensive/model"])
|
||||||
|
|
||||||
|
def test_explicit_model_wins_over_auto_and_env(self):
|
||||||
|
with mock.patch.dict(os.environ, {"GIG_LINE_JUDGE_MODEL": "env/model"}):
|
||||||
|
self.assertEqual(oj.resolve_models("explicit/model", catalog=[]),
|
||||||
|
["explicit/model"])
|
||||||
|
|
||||||
|
def test_auto_uses_env_override(self):
|
||||||
|
with mock.patch.dict(os.environ, {"GIG_LINE_JUDGE_MODEL": "env/model"}):
|
||||||
|
self.assertEqual(oj.resolve_models("auto", catalog=[]), ["env/model"])
|
||||||
|
|
||||||
|
def test_auto_uses_catalog_and_falls_back_offline(self):
|
||||||
|
catalog = [_model("cheap/model", 0.01, 0.02)]
|
||||||
|
self.assertEqual(oj.resolve_models("auto", catalog=catalog), ["cheap/model"])
|
||||||
|
self.assertEqual(oj.resolve_models("auto", catalog=[]), oj.FALLBACK_MODELS[:6])
|
||||||
|
|
||||||
|
|
||||||
|
class FallbackTests(unittest.TestCase):
|
||||||
|
def test_fallback_switches_model_after_provider_error(self):
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def call(body):
|
||||||
|
seen.append(body["model"])
|
||||||
|
if body["model"] == "bad/model":
|
||||||
|
raise ValueError("OpenRouter HTTP 400: provider rejected image")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
model, payload = oj.call_with_fallback(
|
||||||
|
["bad/model", "good/model"], lambda m: {"model": m}, call, attempts=1)
|
||||||
|
self.assertEqual(model, "good/model")
|
||||||
|
self.assertEqual(payload, {"ok": True})
|
||||||
|
self.assertEqual(seen, ["bad/model", "good/model"])
|
||||||
|
|
||||||
|
def test_auth_error_does_not_try_another_model(self):
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def call(body):
|
||||||
|
seen.append(body["model"])
|
||||||
|
raise ValueError("OpenRouter HTTP 401: API key not valid")
|
||||||
|
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
oj.call_with_fallback(["one", "two"], lambda m: {"model": m}, call)
|
||||||
|
self.assertEqual(seen, ["one"])
|
||||||
|
|
||||||
|
def test_transient_error_retries_same_model(self):
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def call(body):
|
||||||
|
seen.append(body["model"])
|
||||||
|
if len(seen) == 1:
|
||||||
|
raise ValueError("OpenRouter HTTP 500")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
model, _ = oj.call_with_fallback(
|
||||||
|
["one"], lambda m: {"model": m}, call, attempts=2)
|
||||||
|
self.assertEqual(model, "one")
|
||||||
|
self.assertEqual(seen, ["one", "one"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||