Compare commits

..
7 Commits
Author SHA1 Message Date
zwitschi 659b0e35b2 Make the vision judge discover cost-effective OpenRouter models
Full build / Import + headless tests (push) Successful in 40s
Full build / Full desktop build + optional release (push) Successful in 4m6s
Add openrouter_judge.py to rank live image+JSON models by advertised price,
with ordered fallbacks, auth-error handling and auto selection. Wire both judges
to --model auto, record the model used, and add fake-catalogue/mocked-call
tests. Re-run the judges with the valid key: 13/13 screen designs match, 0 diff;
the Forward+ A/B report records the selected model and ~$0.006 spend. Headless
231/231, Python 94/94.
2026-11-01 19:55:43 +01:00
zwitschi 28f2a48811 Add the optional Forward+ desktop HQ build
Full build / Import + headless tests (push) Successful in 40s
Full build / Full desktop build + optional release (push) Successful in 5m20s
Package a Forward+ preset with volumetric fog, SDFGI and a volumetric flashlight
for a separate Linux/Windows Desktop HQ zip. Gate the effects on a real
RenderingDevice, tune LATE fog, and adapt local light falloff for Godot 4.7.
Record the Compatibility/Forward+ A/B captures and metrics, add the artifact to
the build/publish scripts, and keep the default Compatibility/Web path
unchanged. Headless 231/231, Python 87/87; exported HQ binary boots with Vulkan.
2026-11-01 19:40:42 +01:00
zwitschi 64f0c6a198 Document the local Godot 4.7.2 toolchain
Full build / Full desktop build + optional release (push) Skipped
Full build / Import + headless tests (push) Successful in 38s
Record the upgraded 4.7.2 binary, the retained 4.3 backup and the matching
export templates. Keep the project/CI baseline at 4.3 and note that 4.7.2
verified import, tests, the session smoke and a Linux export. Hide the
4.7-generated per-script UID sidecars from this clone only.
2026-11-01 19:16:55 +01:00
zwitschi 8b0845bcce Build distinct Joke Shop and TV Studio venues
Full build / Import + headless tests (push) Successful in 40s
Full build / Full desktop build + optional release (push) Successful in 4m57s
Add the alley walls, warm lantern, shutters, dumpster, AC clutter and door
step for the Joke Shop. Build the studio stone facade, bulb canopy, glossy
black car, stanchions and barriers. Rebuild only the world container when the
venue changes, so the guard and UI survive; add per-venue navigation checks.
Capture gallery shots 12/13, update the concept mapping and docs. Headless
231/231, Python 87/87, screenshot gate 13 good/0 bad. The vision-judge retry is
tracked separately because the API key is invalid.
2026-11-01 19:09:47 +01:00
zwitschi 4cca2af2dd Extract per-venue geometry specs into WorldBuilder
Add the Hollywood, Joke Shop and TV Studio tables for facade, window,
stair, street, nav, guard-spawn and feature data. Unknown ids fall back to
the boulevard. Use the specs for the core dimensions and gates, with a headless
test for the distinct venue data. Headless 230/230.
2026-11-01 19:03:20 +01:00
zwitschi 99f390078d Restore the full changelog history 2026-11-01 19:00:53 +01:00
zwitschi a9fe6b9944 Extend the agent workflow with docs and changelog maintenance 2026-11-01 18:56:15 +01:00
42 changed files with 1564 additions and 195 deletions

No files matched your search

+34 -6
View File
@@ -14,6 +14,14 @@ The project should be developed in small, reviewable steps. Agents must keep pla
- `MEMORY.md` — Persistent session memory for important decisions, discoveries, unresolved questions, constraints, and useful implementation context. - `MEMORY.md` — Persistent session memory for important decisions, discoveries, unresolved questions, constraints, and useful implementation context.
- `README.md` — Root-level documentation for the project. It should explain what the game is, how to work on it, how to run it, and where to find project information. - `README.md` — Root-level documentation for the project. It should explain what the game is, how to work on it, how to run it, and where to find project information.
- `AGENTS.md` — This collaboration and agent workflow guide. - `AGENTS.md` — This collaboration and agent workflow guide.
- `CHANGELOG.md` — The version history of user-facing changes. Keep an entry for each patch, minor, and major version.
- `docs/development-status.md` — The implemented-features list and what is not yet implemented. Update it whenever a feature lands, is renamed, or is removed.
- `docs/architecture.md` — The arc42-style architecture documentation (goals, constraints, building blocks, runtime views, decisions, risks).
- `docs/gameplay.md` — Gameplay systems, venues, and the escalation model.
- `docs/asset-tooling.md` — Asset pipelines, model tools, and screenshot gates.
- `docs/getting-started.md` — Setup, run/test commands, and the controls table.
- `docs/smoke-checklist.md` — The manual smoke-test checklist.
- `docs/adr/` — Architecture Decision Records.
## Required Workflow ## Required Workflow
@@ -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.
+265 -25
View File
@@ -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).
+94
View File
@@ -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).
+20 -1
View File
@@ -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
+2 -2
View File
@@ -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
+11 -9
View File
@@ -15,9 +15,11 @@ The central design goal is simple:
| ![Late night: street lamps lit, marquee glowing, dark Hollywood Boulevard](docs/screenshots/05-late-night-avenue.png) | ![Night shift: guard flashlight cutting through the dark](docs/screenshots/06-night-flashlight.png) | | ![Late night: street lamps lit, marquee glowing, dark Hollywood Boulevard](docs/screenshots/05-late-night-avenue.png) | ![Night shift: guard flashlight cutting through the dark](docs/screenshots/06-night-flashlight.png) |
| ![Wand screening at The Joke Shop: false alarm on keys](docs/screenshots/07-wand-screening.png) | ![Escalation HUD at L3 with body camera armed and reaction log](docs/screenshots/08-escalation-hud.png) | | ![Wand screening at The Joke Shop: false alarm on keys](docs/screenshots/07-wand-screening.png) | ![Escalation HUD at L3 with body camera armed and reaction log](docs/screenshots/08-escalation-hud.png) |
| ![Body-camera timeline: formal warning, body camera on, blocking](docs/screenshots/09-body-camera-timeline.png) | ![Prop gallery: the six generated street-furniture models on a studio backdrop](docs/screenshots/10-prop-gallery.png) | | ![Body-camera timeline: formal warning, body camera on, blocking](docs/screenshots/09-body-camera-timeline.png) | ![Prop gallery: the six generated street-furniture models on a studio backdrop](docs/screenshots/10-prop-gallery.png) |
| ![Precision walk at the venue steps with the HUD chip visible](docs/screenshots/11-precision-walk.png) | | | ![Precision walk at the venue steps with the HUD chip visible](docs/screenshots/11-precision-walk.png) | ![The Joke Shop: a narrow brick alley with shuttered windows, a warm wall lantern and a dumpster](docs/screenshots/12-joke-shop.png) |
| ![Late-night TV studio arrival: stone facade, bulb canopy, black car and barriers](docs/screenshots/13-studio-arrival.png) | |
*Screenshots are generated by [`src/test/capture_screenshots.gd`](src/test/capture_screenshots.gd) and [`src/test/capture_prop_gallery.gd`](src/test/capture_prop_gallery.gd) from the current build — day, late-night, wand screening, escalation, shift-review timeline, precision walk, and the prop lineup.* *Screenshots are generated by [`src/test/capture_screenshots.gd`](src/test/capture_screenshots.gd) and [`src/test/capture_prop_gallery.gd`](src/test/capture_prop_gallery.gd) from the current build — day, late-night, wand screening, escalation, shift-review timeline,
precision walk, the Joke Shop alley, the TV Studio arrival, and the prop lineup.*
## ⬇️ Downloads ## ⬇️ Downloads
@@ -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).
+2 -48
View File
@@ -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,
+10 -2
View File
@@ -15,7 +15,8 @@ time you export, either:
`Godot_v4.3-stable_export_templates.tpz` contents into `Godot_v4.3-stable_export_templates.tpz` contents into
`~/.local/share/godot/export_templates/4.3.stable/`. `~/.local/share/godot/export_templates/4.3.stable/`.
Verify with: The templates must match the editor binary you use locally. CI pins Godot 4.3;
the local development machine uses 4.7.2 with its matching templates. Verify with:
```bash ```bash
godot --headless --export-release "Linux" build/gig-line-smoke.x86_64 godot --headless --export-release "Linux" build/gig-line-smoke.x86_64
@@ -86,6 +87,8 @@ Artifacts are written to `build/` (all exports embed the PCK — single files):
- `build/gig-line-macos-<version>.zip` - `build/gig-line-macos-<version>.zip`
- `build/gig-line-web-<version>.zip` — browser build (non-threaded Godot web - `build/gig-line-web-<version>.zip` — browser build (non-threaded Godot web
template; unzip and serve the folder, no COOP/COEP headers required) template; unzip and serve the folder, no COOP/COEP headers required)
- `build/gig-line-desktop-hq-<version>.zip` — optional Forward+ Windows/Linux
build with `override.cfg`; requires a Vulkan-capable GPU/driver
When run in CI (tags/dispatch), the build job also packages: When run in CI (tags/dispatch), the build job also packages:
@@ -94,6 +97,10 @@ When run in CI (tags/dispatch), the build job also packages:
- `build/gig-line-<version>-x86_64.AppImage` — Linux AppImage (`packaging/linux/build_appimage.sh`, - `build/gig-line-<version>-x86_64.AppImage` — Linux AppImage (`packaging/linux/build_appimage.sh`,
appimagetool via `APPIMAGE_EXTRACT_AND_RUN`) appimagetool via `APPIMAGE_EXTRACT_AND_RUN`)
The optional Desktop HQ zip is assembled by `tools/build_releases.sh` from
`packaging/desktop_hq/`; keep `override.cfg` next to the extracted executable.
It is not part of the default Compatibility/Web build.
Locally you can build the AppImage with `bash packaging/linux/build_appimage.sh <version>`; Locally you can build the AppImage with `bash packaging/linux/build_appimage.sh <version>`;
the NSIS installer requires `makensis` (covered by CI). the NSIS installer requires `makensis` (covered by CI).
@@ -114,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
+5 -5
View File
@@ -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
+12 -2
View File
@@ -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
+4 -1
View File
@@ -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)).
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 756 KiB

After

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 856 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+16
View File
@@ -0,0 +1,16 @@
Gig Line — Desktop HQ build
===========================
This build uses Godot's Forward+ renderer instead of the default
GL Compatibility renderer, enabling volumetric fog and SDFGI.
Requirements:
- A Vulkan-capable GPU/driver (Linux or Windows).
- Keep override.cfg next to the executable. Without it the binary falls back
to the normal Compatibility build.
The game still runs if Forward+ is unavailable, but the desktop-only effects
will be disabled.
Linux: chmod +x gig-line-linux-*.x86_64 && ./gig-line-linux-*.x86_64
Windows: run gig-line-windows-*.exe
+12
View File
@@ -0,0 +1,12 @@
; Gig Line desktop HQ preset. Place this file next to the exported executable.
; It switches only that copy to Forward+; project.godot and the default builds
; stay on GL Compatibility for Web/CI parity.
[rendering]
renderer/rendering_method="forward_plus"
renderer/rendering_method.mobile="forward_plus"
environment/volumetric_fog/volume_size=128
environment/volumetric_fog/volume_depth=128
environment/volumetric_fog/use_filter=1
global_illumination/sdfgi/frames_to_converge=4
global_illumination/sdfgi/frames_to_update_lights=2
+1 -1
View File
@@ -12,7 +12,7 @@ config_version=5
config/name="Gig Line" config/name="Gig Line"
config/description="Workplace security simulation about observation, access verification, and proportionate escalation." config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
config/version="0.6.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")
+34 -7
View File
@@ -32,6 +32,9 @@ const SettingsPanel := preload("res://src/game/ui/settings_panel.gd")
const EventTimeline := preload("res://src/game/ui/event_timeline.gd") const EventTimeline := preload("res://src/game/ui/event_timeline.gd")
var world_builder: WorldBuilder = null var world_builder: WorldBuilder = null
## Dedicated container for the procedural world, so switching venues can rebuild
## the geometry without touching the guard, UI, or other scene-root children.
var world_root: Node3D = null
var door := DoorState.new() var door := DoorState.new()
var flashlight_on := false var flashlight_on := false
var body_camera_on := false var body_camera_on := false
@@ -51,7 +54,7 @@ const PHASE_LIGHTING := {
"sky_ground": Color(0.45, 0.5, 0.55), "ambient": Color(0.75, 0.78, 0.85), "night": false, "sky_ground": Color(0.45, 0.5, 0.55), "ambient": Color(0.75, 0.78, 0.85), "night": false,
"exposure": 0.6, "glow": 0.45, "fog_color": Color(0.87, 0.83, 0.76), "exposure": 0.6, "glow": 0.45, "fog_color": Color(0.87, 0.83, 0.76),
"fog_density": 0.0034, "fog_energy": 0.5, "contrast": 1.05, "saturation": 1.04, "fog_density": 0.0034, "fog_energy": 0.5, "contrast": 1.05, "saturation": 1.04,
"ambient_energy": 0.1, "ambient_energy": 0.1, "volumetric_density": 0.004, "volumetric_energy": 0.0,
}, },
Rules.Phase.MIDDAY: { Rules.Phase.MIDDAY: {
"sun_energy": 1.0, "sun_angle": -62.0, "sun_yaw": -30.0, "sun_color": Color(1.0, 0.99, 0.96), "sun_energy": 1.0, "sun_angle": -62.0, "sun_yaw": -30.0, "sun_color": Color(1.0, 0.99, 0.96),
@@ -59,7 +62,7 @@ const PHASE_LIGHTING := {
"sky_ground": Color(0.5, 0.54, 0.58), "ambient": Color(0.85, 0.88, 0.95), "night": false, "sky_ground": Color(0.5, 0.54, 0.58), "ambient": Color(0.85, 0.88, 0.95), "night": false,
"exposure": 0.6, "glow": 0.4, "fog_color": Color(0.88, 0.87, 0.83), "exposure": 0.6, "glow": 0.4, "fog_color": Color(0.88, 0.87, 0.83),
"fog_density": 0.0028, "fog_energy": 0.5, "contrast": 1.06, "saturation": 1.04, "fog_density": 0.0028, "fog_energy": 0.5, "contrast": 1.06, "saturation": 1.04,
"ambient_energy": 0.28, "ambient_energy": 0.28, "volumetric_density": 0.003, "volumetric_energy": 0.0,
}, },
# Evening is dusk, not a bright sunset: the sun is almost down and the street # Evening is dusk, not a bright sunset: the sun is almost down and the street
# is carried by the marquee, the lamps and the lit windows (realism pass). # is carried by the marquee, the lamps and the lit windows (realism pass).
@@ -69,7 +72,7 @@ const PHASE_LIGHTING := {
"sky_ground": Color(0.1, 0.09, 0.12), "ambient": Color(0.2, 0.19, 0.26), "night": true, "sky_ground": Color(0.1, 0.09, 0.12), "ambient": Color(0.2, 0.19, 0.26), "night": true,
"exposure": 0.72, "glow": 0.8, "fog_color": Color(0.34, 0.27, 0.3), "exposure": 0.72, "glow": 0.8, "fog_color": Color(0.34, 0.27, 0.3),
"fog_density": 0.0026, "fog_energy": 0.45, "contrast": 1.1, "saturation": 1.05, "fog_density": 0.0026, "fog_energy": 0.45, "contrast": 1.1, "saturation": 1.05,
"ambient_energy": 0.12, "ambient_energy": 0.12, "volumetric_density": 0.014, "volumetric_energy": 0.10,
}, },
Rules.Phase.LATE: { Rules.Phase.LATE: {
"sun_energy": 0.02, "sun_angle": -8.0, "sun_color": Color(0.3, 0.35, 0.5), "sun_energy": 0.02, "sun_angle": -8.0, "sun_color": Color(0.3, 0.35, 0.5),
@@ -77,7 +80,7 @@ const PHASE_LIGHTING := {
"sky_ground": Color(0.03, 0.03, 0.05), "ambient": Color(0.06, 0.07, 0.12), "night": true, "sky_ground": Color(0.03, 0.03, 0.05), "ambient": Color(0.06, 0.07, 0.12), "night": true,
"exposure": 0.95, "glow": 1.15, "fog_color": Color(0.08, 0.1, 0.2), "exposure": 0.95, "glow": 1.15, "fog_color": Color(0.08, 0.1, 0.2),
"fog_density": 0.0028, "fog_energy": 0.45, "contrast": 1.12, "saturation": 1.0, "fog_density": 0.0028, "fog_energy": 0.45, "contrast": 1.12, "saturation": 1.0,
"ambient_energy": 0.22, "ambient_energy": 0.22, "volumetric_density": 0.020, "volumetric_energy": 0.14,
}, },
} }
var hud: Hud = null var hud: Hud = null
@@ -135,8 +138,11 @@ func _ready() -> void:
scene_root = Node3D.new() scene_root = Node3D.new()
scene_root.name = "SceneRoot" scene_root.name = "SceneRoot"
add_child(scene_root) add_child(scene_root)
world_root = Node3D.new()
world_root.name = "World"
scene_root.add_child(world_root)
world_builder = WorldBuilder.new(scene_root) world_builder = WorldBuilder.new(world_root, active_scenario_id)
world_builder.build() world_builder.build()
_ensure_lighting() _ensure_lighting()
@@ -190,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()
+34 -1
View File
@@ -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))
+294 -55
View File
@@ -59,21 +59,116 @@ var library_nodes: Array[Node3D] = []
## Total triangle budget for the Blender library pieces placed in the world. ## Total triangle budget for the Blender library pieces placed in the world.
const LIBRARY_TRIANGLE_BUDGET := 60000 const LIBRARY_TRIANGLE_BUDGET := 60000
func _init(root_node: Node3D) -> void: ## Per-venue geometry data. The Hollywood values are the fallback: every field
## here defaults to the long-standing boulevard layout, so adding a venue cannot
## silently regress the original scene. Joke Shop and TV Studio override the
## facade/street measurements and feature flags; their bespoke dressing lands in
## the following tasks.
const VENUE_SPECS := {
"hollywood_building": {
"display_name": "Hollywood Boulevard Building",
"facade_width": 40.0, "facade_height": 18.0, "facade_depth": 4.0,
"facade_center_z": -6.0, "facade_texture": T_BRICK,
"facade_tint": Color(0.86, 0.85, 0.83), "facade_role": "brick",
"window_columns": [-17.0, -13.6, -10.2, -6.8, 6.8, 10.2, 13.6, 17.0],
"pilaster_columns": [-15.3, -11.9, -8.5, -5.1, 5.1, 8.5, 11.9, 15.3],
"window_rows": [9.2, 12.4],
"stair_width": 8.0, "stair_count": 4, "stair_tread": 0.6, "stair_height": 0.16,
"sidewalk_width": 30.0, "sidewalk_depth": 8.0,
"street_width": 120.0, "street_depth": 26.0,
"nav_min": Vector2(-18.0, -0.6), "nav_max": Vector2(18.0, 20.0),
"guard_spawn": Vector3(0.0, 0.1, -2.0),
"features": {
"marquee": true, "skyline": true, "handrail": true, "sedan": true,
"wall_lanterns": true, "palms": true, "street_trees": true,
"street_lamps": true, "crosswalk": true, "bulb_canopy": false,
},
},
"joke_shop": {
"display_name": "The Joke Shop",
"facade_width": 12.0, "facade_height": 12.0, "facade_depth": 5.0,
"facade_center_z": -6.5, "facade_texture": T_BRICK,
"facade_tint": Color(0.78, 0.66, 0.58), "facade_role": "brick",
"window_columns": [-3.8, 3.8],
"pilaster_columns": [-5.2, 5.2],
"window_rows": [8.2],
"stair_width": 5.0, "stair_count": 2, "stair_tread": 0.65, "stair_height": 0.16,
"sidewalk_width": 22.0, "sidewalk_depth": 6.0,
"street_width": 75.0, "street_depth": 18.0,
"nav_min": Vector2(-9.0, -0.6), "nav_max": Vector2(9.0, 14.0),
"guard_spawn": Vector3(0.0, 0.1, -1.6),
"features": {
"marquee": false, "skyline": false, "handrail": false, "sedan": false,
"wall_lanterns": true, "palms": false, "street_trees": false,
"street_lamps": false, "crosswalk": false, "bulb_canopy": false,
},
"prop_placements": [],
},
"tv_studio": {
"display_name": "Late Night TV Studio",
"facade_width": 30.0, "facade_height": 15.0, "facade_depth": 4.0,
"facade_center_z": -6.0, "facade_texture": T_CONCRETE,
"facade_tint": Color(0.58, 0.57, 0.55), "facade_role": "stone",
"window_columns": [-11.0, -7.2, 7.2, 11.0],
"pilaster_columns": [-13.2, -9.0, -5.0, 5.0, 9.0, 13.2],
"window_rows": [10.5],
"stair_width": 7.0, "stair_count": 3, "stair_tread": 0.6, "stair_height": 0.16,
"sidewalk_width": 28.0, "sidewalk_depth": 7.0,
"street_width": 100.0, "street_depth": 24.0,
"nav_min": Vector2(-14.0, -0.6), "nav_max": Vector2(14.0, 18.0),
"guard_spawn": Vector3(0.0, 0.1, -1.8),
"sedan_position": Vector3(5.4, 0.1, 8.8), "sedan_rotation": 90.0,
"features": {
"marquee": false, "skyline": true, "handrail": true, "sedan": true,
"wall_lanterns": false, "palms": false, "street_trees": true,
"street_lamps": true, "crosswalk": true, "bulb_canopy": true,
},
"prop_placements": [
{"zone": "entrance", "prop": "stanchion", "x": -2.8, "z": 4.4},
{"zone": "entrance", "prop": "stanchion", "x": 2.8, "z": 4.4},
{"zone": "entrance", "prop": "crowd_barrier", "x": -5.2, "z": 4.2},
{"zone": "entrance", "prop": "crowd_barrier", "x": 5.2, "z": 4.2},
{"zone": "curb", "prop": "security_podium", "x": 4.6, "z": 5.9, "rot": -15.0},
],
},
}
const DEFAULT_VENUE := "hollywood_building"
var venue_id := DEFAULT_VENUE
var spec: Dictionary = VENUE_SPECS[DEFAULT_VENUE]
func _init(root_node: Node3D, id: String = DEFAULT_VENUE) -> void:
root = root_node root = root_node
set_venue(id)
## Select the geometry spec before `build()`. Unknown ids fall back to the
## boulevard so old callers and tests keep working.
func set_venue(id: String) -> void:
venue_id = id if VENUE_SPECS.has(id) else DEFAULT_VENUE
spec = VENUE_SPECS[venue_id]
func _feature(name: String) -> bool:
return bool((spec.get("features", {}) as Dictionary).get(name, false))
func build() -> void: func build() -> void:
_build_ground() _build_ground()
_build_building() _build_building()
if _feature("skyline"):
_build_skyline_backdrop() _build_skyline_backdrop()
if _feature("marquee"):
_build_marquee() _build_marquee()
if _feature("bulb_canopy"):
_build_bulb_canopy()
_build_steps() _build_steps()
_build_sidewalk() _build_sidewalk()
_build_street() _build_street()
if _feature("crosswalk"):
_build_crosswalk() _build_crosswalk()
_build_street_furniture() _build_street_furniture()
_build_street_detail() _build_street_detail()
_build_library_dressing() _build_library_dressing()
if venue_id == "joke_shop":
_build_alley_dressing()
_build_guard_spawn() _build_guard_spawn()
_build_navmesh() _build_navmesh()
set_night_mode(false) set_night_mode(false)
@@ -111,41 +206,49 @@ func _build_ground() -> void:
func _build_building() -> void: func _build_building() -> void:
var building := StaticBody3D.new() var building := StaticBody3D.new()
building.name = "Building" building.name = "Building"
# Main facade (textured brick) var w := float(spec["facade_width"])
_add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -6), Color(0.86, 0.85, 0.83), false, T_BRICK, Vector2(22, 10), "brick") var h := float(spec["facade_height"])
var d := float(spec["facade_depth"])
var cz := float(spec["facade_center_z"])
var front := cz + d * 0.5
# Main facade (brick on the boulevard/alley, stone on the studio)
var facade_texture: Texture2D = spec.get("facade_texture", T_BRICK)
var facade_tint: Color = spec.get("facade_tint", Color(0.86, 0.85, 0.83))
var facade_role := str(spec.get("facade_role", "brick"))
_add_box(building, Vector3(w, h, d), Vector3(0, h * 0.5, cz), facade_tint, false, facade_texture, Vector2(w * 0.55, h * 0.55), facade_role)
# Stone base course so the facade has a footing instead of meeting the steps dead-on. # Stone base course so the facade has a footing instead of meeting the steps dead-on.
_add_box(building, Vector3(40, 0.8, 0.5), Vector3(0, 0.4, -3.78), Color(0.52, 0.5, 0.47), false, null, Vector2.ONE, "stone") _add_box(building, Vector3(w, 0.8, 0.5), Vector3(0, 0.4, front + 0.22), Color(0.52, 0.5, 0.47), false, null, Vector2.ONE, "stone")
# Facade depth, not a flat wall: a projecting plinth, a string course between # Facade depth, not a flat wall: a projecting plinth, a string course between
# the window bands, a cornice under the roofline and pilasters at the bays. # the window bands, a cornice under the roofline and pilasters at the bays.
_add_box(building, Vector3(40, 0.34, 0.22), Vector3(0, 0.93, -3.9), Color(0.6, 0.58, 0.55), true, null, Vector2.ONE, "stone") _add_box(building, Vector3(w, 0.34, 0.22), Vector3(0, 0.93, front + 0.1), Color(0.6, 0.58, 0.55), true, null, Vector2.ONE, "stone")
_add_box(building, Vector3(40, 0.28, 0.28), Vector3(0, 7.6, -3.86), Color(0.6, 0.58, 0.55), true, null, Vector2.ONE, "stone") _add_box(building, Vector3(w, 0.28, 0.28), Vector3(0, 7.6, front + 0.14), Color(0.6, 0.58, 0.55), true, null, Vector2.ONE, "stone")
_add_box(building, Vector3(40, 0.5, 0.5), Vector3(0, 17.7, -3.75), Color(0.56, 0.54, 0.51), true, null, Vector2.ONE, "stone") _add_box(building, Vector3(w, 0.5, 0.5), Vector3(0, h - 0.3, front + 0.25), Color(0.56, 0.54, 0.51), true, null, Vector2.ONE, "stone")
_add_box(building, Vector3(40, 0.22, 0.2), Vector3(0, 10.9, -3.9), Color(0.58, 0.56, 0.53), true, null, Vector2.ONE, "stone") _add_box(building, Vector3(w, 0.22, 0.2), Vector3(0, 10.9, front + 0.1), Color(0.58, 0.56, 0.53), true, null, Vector2.ONE, "stone")
for bay in [-15.3, -11.9, -8.5, -5.1, 5.1, 8.5, 11.9, 15.3]: for bay in spec.get("pilaster_columns", [-15.3, -11.9, -8.5, -5.1, 5.1, 8.5, 11.9, 15.3]):
_add_box(building, Vector3(0.5, 8.4, 0.24), Vector3(bay, 12.6, -3.9), _add_box(building, Vector3(0.5, 8.4, 0.24), Vector3(float(bay), 12.6, front + 0.1),
Color(0.8, 0.78, 0.76), true, T_BRICK, Vector2(0.3, 4.7), "brick") Color(0.8, 0.78, 0.76), true, facade_texture, Vector2(0.3, 4.7), facade_role)
# Entrance recess + door recess frame # Entrance recess + door recess frame
_add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -3.9), Color(0.08, 0.08, 0.09), false, null, Vector2.ONE, "stone") _add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, front + 0.1), Color(0.08, 0.08, 0.09), false, null, Vector2.ONE, "stone")
# Door frame columns and lintel, so the entrance is framed rather than painted on. # Door frame columns and lintel, so the entrance is framed rather than painted on.
for side in [-1.55, 1.55]: for side in [-1.55, 1.55]:
_add_box(building, Vector3(0.28, 5.2, 0.42), Vector3(side, 2.9, -3.86), Color(0.30, 0.28, 0.26), true, null, Vector2.ONE, "stone") _add_box(building, Vector3(0.28, 5.2, 0.42), Vector3(side, 2.9, front + 0.14), Color(0.30, 0.28, 0.26), true, null, Vector2.ONE, "stone")
_add_box(building, Vector3(3.4, 0.3, 0.42), Vector3(0, 5.55, -3.86), Color(0.30, 0.28, 0.26), true, null, Vector2.ONE, "stone") _add_box(building, Vector3(3.4, 0.3, 0.42), Vector3(0, 5.55, front + 0.14), Color(0.30, 0.28, 0.26), true, null, Vector2.ONE, "stone")
# Door frame and dark door # Door frame and dark door
_add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -3.98), Color(0.13, 0.12, 0.11), true, null, Vector2.ONE, "painted_metal") _add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, front + 0.02), Color(0.13, 0.12, 0.11), true, null, Vector2.ONE, "painted_metal")
# Door glass (semi-transparent) # Door glass (semi-transparent)
var glass := _add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -3.9), Color(0.6, 0.75, 0.9, 0.55)) var glass := _add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, front + 0.1), Color(0.6, 0.75, 0.9, 0.55))
glass.material_override = Materials.glass(Color(0.55, 0.7, 0.88, 0.55)) glass.material_override = Materials.glass(Color(0.55, 0.7, 0.88, 0.55))
# Door handles: two slim metal bars where the guard reaches for the lock. # Door handles: two slim metal bars where the guard reaches for the lock.
for side in [-0.42, 0.42]: for side in [-0.42, 0.42]:
_add_box(building, Vector3(0.06, 0.5, 0.06), Vector3(side, 2.6, -3.79), Color(0.75, 0.74, 0.70), false, null, Vector2.ONE, "metal") _add_box(building, Vector3(0.06, 0.5, 0.06), Vector3(side, 2.6, front + 0.21), Color(0.75, 0.74, 0.70), false, null, Vector2.ONE, "metal")
# Door lock indicator (green = open, red = locked), placed beside the handle # Door lock indicator (green = open, red = locked), placed beside the handle
door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -3.82), Color(0.1, 0.9, 0.3)) door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, front + 0.18), Color(0.1, 0.9, 0.3))
door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3)) door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3))
# Blender glazed double door in front of the recessed frame. # Blender glazed double door in front of the recessed frame.
var door_model := _load_prop_scene("double_door") var door_model := _load_prop_scene("double_door")
if door_model != null: if door_model != null:
door_model.name = "LibraryDoubleDoor" door_model.name = "LibraryDoubleDoor"
door_model.position = Vector3(0, 0.0, -3.84) door_model.position = Vector3(0, 0.0, front + 0.16)
building.add_child(door_model) building.add_child(door_model)
library_nodes.append(door_model) library_nodes.append(door_model)
_apply_library_ao(door_model, "double_door") _apply_library_ao(door_model, "double_door")
@@ -154,18 +257,21 @@ func _build_building() -> void:
_lamp_bulb_mats.append(material) _lamp_bulb_mats.append(material)
_build_facade_windows(building) _build_facade_windows(building)
var col := BoxShape3D.new() var col := BoxShape3D.new()
col.size = Vector3(40, 18, 4) col.size = Vector3(w, h, d)
var so := building.create_shape_owner(building) var so := building.create_shape_owner(building)
building.shape_owner_add_shape(so, col) building.shape_owner_add_shape(so, col)
building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 9, -6))) building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, h * 0.5, cz)))
root.add_child(building) root.add_child(building)
## Regular window bands above the entrance. Panes are dark glass by day and ## Regular window bands above the entrance. Panes are dark glass by day and
## light up after dark, which gives the facade depth at night. ## light up after dark, which gives the facade depth at night.
func _build_facade_windows(parent: Node3D) -> void: func _build_facade_windows(parent: Node3D) -> void:
var columns := [-17.0, -13.6, -10.2, -6.8, 6.8, 10.2, 13.6, 17.0] var columns: Array = spec.get("window_columns", [-17.0, -13.6, -10.2, -6.8, 6.8, 10.2, 13.6, 17.0])
for y in [9.2, 12.4]: var rows: Array = spec.get("window_rows", [9.2, 12.4])
for x in columns: for value in rows:
var y := float(value)
for x_value in columns:
var x := float(x_value)
# Recessed reveal, then the pane: gives the window a dark frame edge. # Recessed reveal, then the pane: gives the window a dark frame edge.
_add_box(parent, Vector3(1.96, 2.46, 0.1), Vector3(x, y, -3.99), _add_box(parent, Vector3(1.96, 2.46, 0.1), Vector3(x, y, -3.99),
Color(0.22, 0.21, 0.20), false, null, Vector2.ONE, "stone") Color(0.22, 0.21, 0.20), false, null, Vector2.ONE, "stone")
@@ -188,12 +294,13 @@ func _build_steps() -> void:
# The ground and building provide the actual collision boundaries. # The ground and building provide the actual collision boundaries.
var steps := StaticBody3D.new() var steps := StaticBody3D.new()
steps.name = "Steps" steps.name = "Steps"
var step_w := 8.0 var step_w := float(spec.get("stair_width", 8.0))
var tread := 0.6 var tread := float(spec.get("stair_tread", 0.6))
var height := 0.16 var height := float(spec.get("stair_height", 0.16))
for i in 4: var count := int(spec.get("stair_count", 4))
for i in count:
var y: float = i * height var y: float = i * height
var z: float = 4.0 * tread - i * tread var z: float = float(count) * tread - i * tread
var w: float = step_w - i * 0.35 var w: float = step_w - i * 0.35
_add_box(steps, Vector3(w, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5)) _add_box(steps, Vector3(w, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5))
# Add collision shape to each tread # Add collision shape to each tread
@@ -203,10 +310,11 @@ func _build_steps() -> void:
steps.shape_owner_add_shape(owner_id, tread_col) steps.shape_owner_add_shape(owner_id, tread_col)
steps.shape_owner_set_transform(owner_id, Transform3D(Basis(), Vector3(0, y + height / 2.0, z))) steps.shape_owner_set_transform(owner_id, Transform3D(Basis(), Vector3(0, y + height / 2.0, z)))
# Landing at the top with collision # Landing at the top with collision
var landing_y: float = 4 * height + 0.1 var landing_y: float = count * height + 0.1
_add_box(steps, Vector3(9, 0.2, 3), Vector3(0, landing_y, -1.5), Color(0.5, 0.47, 0.45)) var landing_w := step_w + 1.0
_add_box(steps, Vector3(landing_w, 0.2, 3), Vector3(0, landing_y, -1.5), Color(0.5, 0.47, 0.45))
var landing_col := BoxShape3D.new() var landing_col := BoxShape3D.new()
landing_col.size = Vector3(9, 0.2, 3) landing_col.size = Vector3(landing_w, 0.2, 3)
var landing_owner := steps.create_shape_owner(steps) var landing_owner := steps.create_shape_owner(steps)
steps.shape_owner_add_shape(landing_owner, landing_col) steps.shape_owner_add_shape(landing_owner, landing_col)
steps.shape_owner_set_transform(landing_owner, Transform3D(Basis(), Vector3(0, landing_y, -1.5))) steps.shape_owner_set_transform(landing_owner, Transform3D(Basis(), Vector3(0, landing_y, -1.5)))
@@ -215,12 +323,14 @@ func _build_steps() -> void:
func _build_sidewalk() -> void: func _build_sidewalk() -> void:
var sidewalk := StaticBody3D.new() var sidewalk := StaticBody3D.new()
sidewalk.name = "Sidewalk" sidewalk.name = "Sidewalk"
_add_box(sidewalk, Vector3(30, 0.18, 8), Vector3(0, 0.09, 8), Color(0.55, 0.55, 0.54), false, T_CONCRETE, Vector2(9, 2.4), "concrete") var w := float(spec.get("sidewalk_width", 30.0))
var d := float(spec.get("sidewalk_depth", 8.0))
_add_box(sidewalk, Vector3(w, 0.18, d), Vector3(0, 0.09, d * 0.5), Color(0.55, 0.55, 0.54), false, T_CONCRETE, Vector2(w * 0.3, d * 0.3), "concrete")
var col := BoxShape3D.new() var col := BoxShape3D.new()
col.size = Vector3(30, 0.36, 8) col.size = Vector3(w, 0.36, d)
var so := sidewalk.create_shape_owner(sidewalk) var so := sidewalk.create_shape_owner(sidewalk)
sidewalk.shape_owner_add_shape(so, col) sidewalk.shape_owner_add_shape(so, col)
sidewalk.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, 8))) sidewalk.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, d * 0.5)))
root.add_child(sidewalk) root.add_child(sidewalk)
func _build_street() -> void: func _build_street() -> void:
@@ -230,20 +340,24 @@ func _build_street() -> void:
# white looked like more pavement. # white looked like more pavement.
# The street keeps its own material so night mode can switch to the wet # The street keeps its own material so night mode can switch to the wet
# asphalt map (glossier, with lamp reflections) and back. # asphalt map (glossier, with lamp reflections) and back.
_street_mat = Materials.asphalt(Color(0.62, 0.63, 0.66), Vector2(30, 7)) var street_w := float(spec.get("street_width", 120.0))
var street_mesh := _add_box(street, Vector3(120, 0.1, 26), Vector3(0, 0.05, 22), var street_d := float(spec.get("street_depth", 26.0))
Color(0.62, 0.63, 0.66), false, T_ASPHALT, Vector2(30, 7), "asphalt") var curb_z := float(spec.get("sidewalk_depth", 8.0)) + 4.1
var street_z := curb_z + street_d * 0.5 - 3.1
_street_mat = Materials.asphalt(Color(0.62, 0.63, 0.66), Vector2(street_w * 0.25, street_d * 0.27))
var street_mesh := _add_box(street, Vector3(street_w, 0.1, street_d), Vector3(0, 0.05, street_z),
Color(0.62, 0.63, 0.66), false, T_ASPHALT, Vector2(street_w * 0.25, street_d * 0.27), "asphalt")
street_mesh.material_override = _street_mat street_mesh.material_override = _street_mat
# Center dashes along the street (x varied so they form a lane line, not a stack). # Center dashes along the street (x varied so they form a lane line, not a stack).
for i in range(-40, 41, 4): for i in range(-int(street_w / 3.0), int(street_w / 3.0) + 1, 4):
_add_box(street, Vector3(0.2, 0.02, 1.2), Vector3(float(i), 0.11, 22), Color(0.85, 0.85, 0.30), false, null, Vector2.ONE, "concrete") _add_box(street, Vector3(0.2, 0.02, 1.2), Vector3(float(i), 0.11, street_z), Color(0.85, 0.85, 0.30), false, null, Vector2.ONE, "concrete")
# Curb between the sidewalk and the roadway. # Curb between the sidewalk and the roadway.
_add_box(street, Vector3(120, 0.22, 0.5), Vector3(0, 0.11, 12.1), Color(0.62, 0.61, 0.58), false, null, Vector2.ONE, "concrete") _add_box(street, Vector3(street_w, 0.22, 0.5), Vector3(0, 0.11, curb_z), Color(0.62, 0.61, 0.58), false, null, Vector2.ONE, "concrete")
var col := BoxShape3D.new() var col := BoxShape3D.new()
col.size = Vector3(120, 0.2, 26) col.size = Vector3(street_w, 0.2, street_d)
var so := street.create_shape_owner(street) var so := street.create_shape_owner(street)
street.shape_owner_add_shape(so, col) street.shape_owner_add_shape(so, col)
street.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, 22))) street.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, street_z)))
root.add_child(street) root.add_child(street)
func _build_navmesh() -> void: func _build_navmesh() -> void:
@@ -256,9 +370,11 @@ func _build_navmesh() -> void:
mesh.agent_max_climb = 0.8 mesh.agent_max_climb = 0.8
mesh.agent_max_slope = 45.0 mesh.agent_max_slope = 45.0
mesh.cell_size = 0.25 mesh.cell_size = 0.25
var nav_min: Vector2 = spec.get("nav_min", Vector2(-18.0, -0.6))
var nav_max: Vector2 = spec.get("nav_max", Vector2(18.0, 20.0))
var verts := PackedVector3Array([ var verts := PackedVector3Array([
Vector3(-18, 0.0, -0.6), Vector3(18, 0.0, -0.6), Vector3(nav_min.x, 0.0, nav_min.y), Vector3(nav_max.x, 0.0, nav_min.y),
Vector3(18, 0.0, 20.0), Vector3(-18, 0.0, 20.0), Vector3(nav_max.x, 0.0, nav_max.y), Vector3(nav_min.x, 0.0, nav_max.y),
]) ])
mesh.set_vertices(verts) mesh.set_vertices(verts)
mesh.add_polygon(PackedInt32Array([0, 1, 2, 3])) mesh.add_polygon(PackedInt32Array([0, 1, 2, 3]))
@@ -377,7 +493,7 @@ func _build_marquee() -> void:
## a parked sedan, and wall lanterns. Kept separate so the placement validator ## a parked sedan, and wall lanterns. Kept separate so the placement validator
## only sees grounded sidewalk furniture. ## only sees grounded sidewalk furniture.
func _build_library_dressing() -> void: func _build_library_dressing() -> void:
if ResourceLoader.exists(BLENDER_PROP_DIR + "handrail.glb"): if _feature("handrail") and ResourceLoader.exists(BLENDER_PROP_DIR + "handrail.glb"):
var rail := _load_prop_scene("handrail") var rail := _load_prop_scene("handrail")
if rail != null: if rail != null:
rail.name = "LibraryHandrail" rail.name = "LibraryHandrail"
@@ -386,27 +502,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"):
if venue_id == "joke_shop":
_add_wall_lantern(Vector3(3.2, 3.3, -3.72), true)
else:
for side in [-4.9, 4.9]: for side in [-4.9, 4.9]:
_add_wall_lantern(Vector3(side, 3.3, -3.78), false)
## The TV studio entrance canopy: a dark metal slab with a row of warm bulbs,
## replacing the boulevard marquee.
func _build_bulb_canopy() -> void:
var front := float(spec["facade_center_z"]) + float(spec["facade_depth"]) * 0.5
var canopy := _add_box(root, Vector3(7.2, 0.22, 2.2),
Vector3(0, 5.7, front + 1.0), Color(0.16, 0.16, 0.18),
true, null, Vector2.ONE, "metal")
canopy.name = "BulbCanopy"
for x in [-3.0, 3.0]:
_add_box(root, Vector3(0.12, 5.5, 0.12),
Vector3(x, 2.75, front + 1.95), Color(0.20, 0.20, 0.22),
true, null, Vector2.ONE, "metal")
for x in [-2.8, -1.4, 0.0, 1.4, 2.8]:
var bulb := MeshInstance3D.new()
var sphere := SphereMesh.new()
sphere.radius = 0.075
sphere.height = 0.15
bulb.mesh = sphere
var mat := _emissive_material(Color(1.0, 0.78, 0.42))
bulb.material_override = mat
bulb.position = Vector3(x, 5.53, front + 1.0)
root.add_child(bulb)
if not _marquee_emissive.has(mat):
_marquee_emissive.append(mat)
for x in [-2.2, 2.2]:
var omni := OmniLight3D.new()
omni.position = Vector3(x, 5.15, front + 1.4)
omni.omni_range = 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") var lantern := _load_prop_scene("wall_lantern")
if lantern == null: if lantern == null:
continue return
lantern.name = "WallLantern" lantern.name = "WallLantern"
lantern.position = Vector3(side, 3.3, -3.78) lantern.position = pos
root.add_child(lantern) root.add_child(lantern)
library_nodes.append(lantern) library_nodes.append(lantern)
_apply_library_ao(lantern, "wall_lantern") _apply_library_ao(lantern, "wall_lantern")
for material in _emissive_materials(lantern): for material in _emissive_materials(lantern):
if not _lamp_bulb_mats.has(material): if not _lamp_bulb_mats.has(material):
_lamp_bulb_mats.append(material) _lamp_bulb_mats.append(material)
if with_light:
var omni := OmniLight3D.new()
omni.name = "LanternGlow"
omni.position = pos + Vector3(0.0, -0.1, 0.45)
omni.omni_range = 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,13 +661,15 @@ func _build_street_furniture() -> void:
# Blender library street lamps (bulbs + omni lights, off by day). The lamp # Blender library street lamps (bulbs + omni lights, off by day). The lamp
# arm points to local -X, so the left side is rotated 180 degrees to face # arm points to local -X, so the left side is rotated 180 degrees to face
# the sidewalk. # the sidewalk.
if _feature("street_lamps"):
for side in [-7.5, 7.5]: for side in [-7.5, 7.5]:
var holder := _add_prop_model(root, "street_lamp", var holder := _add_prop_model(root, "street_lamp",
Vector3(side, 0.18, 8.5), 180.0 if side < 0.0 else 0.0) Vector3(side, 0.18, 8.5), 180.0 if side < 0.0 else 0.0)
holder.name = "Lamp_%d" % int(side) holder.name = "Lamp_%d" % int(side)
var omni := OmniLight3D.new() var omni := OmniLight3D.new()
omni.position = Vector3(-0.85, 4.65, 0) omni.position = Vector3(-0.85, 4.65, 0)
omni.omni_range = 12.0 omni.omni_range = 16.0 if Materials.modern_light_falloff() else 12.0
omni.omni_attenuation = 0.0 if Materials.modern_light_falloff() else 1.0
omni.light_energy = 0.0 omni.light_energy = 0.0
omni.light_color = Color(1, 0.93, 0.75) omni.light_color = Color(1, 0.93, 0.75)
holder.add_child(omni) holder.add_child(omni)
@@ -440,6 +677,7 @@ func _build_street_furniture() -> void:
_collect_emissive(holder) _collect_emissive(holder)
# Blender library street trees replace the cone trees. Flank the walkway and # Blender library street trees replace the cone trees. Flank the walkway and
# keep the sight line to the door clear. # keep the sight line to the door clear.
if _feature("street_trees"):
for i in 6: for i in 6:
var side := -1.0 if i % 2 == 0 else 1.0 var side := -1.0 if i % 2 == 0 else 1.0
var x := side * (6.5 + float(int(i / 2)) * 3.0) var x := side * (6.5 + float(int(i / 2)) * 3.0)
@@ -585,8 +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:
+9
View File
@@ -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)
+30
View File
@@ -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
+4 -1
View File
@@ -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.
+7 -2
View File
@@ -77,14 +77,19 @@ func test_environment_tuning_applies_phase_values() -> bool:
var f := TestUtils.check( var f := TestUtils.check(
env.fog_light_color.is_equal_approx(late["fog_color"]), env.fog_light_color.is_equal_approx(late["fog_color"]),
"phase fog colour is used") "phase fog colour is used")
return a and b and c and d and e and f var advanced := Materials.forward_plus_active()
var g := TestUtils.check(env.volumetric_fog_enabled == advanced,
"volumetric fog is gated to Forward+ (advanced=%s)" % advanced)
var h := TestUtils.check(env.sdfgi_enabled == advanced,
"SDFGI is gated to Forward+ (advanced=%s)" % advanced)
return a and b and c and d and e and f and g and h
func test_every_phase_has_environment_keys() -> bool: func test_every_phase_has_environment_keys() -> bool:
var ok := true var ok := true
for phase in [Rules.Phase.EARLY, Rules.Phase.MIDDAY, Rules.Phase.EVENING, Rules.Phase.LATE]: for phase in [Rules.Phase.EARLY, Rules.Phase.MIDDAY, Rules.Phase.EVENING, Rules.Phase.LATE]:
var cfg: Dictionary = GameManager.PHASE_LIGHTING.get(phase, {}) var cfg: Dictionary = GameManager.PHASE_LIGHTING.get(phase, {})
for key in ["exposure", "glow", "fog_color", "fog_density", "fog_energy", for key in ["exposure", "glow", "fog_color", "fog_density", "fog_energy",
"contrast", "saturation"]: "contrast", "saturation", "volumetric_density", "volumetric_energy"]:
if not cfg.has(key): if not cfg.has(key):
ok = false ok = false
return TestUtils.check(ok, "all phases set exposure/glow/fog/grading") return TestUtils.check(ok, "all phases set exposure/glow/fog/grading")
+65
View File
@@ -19,6 +19,71 @@ func test_navmesh_built() -> bool:
root.free() root.free()
return a and b return a and b
func test_venue_geometry_specs_are_distinct_and_defaulted() -> bool:
var hollywood := WorldBuilder.new(Node3D.new())
var joke := WorldBuilder.new(Node3D.new(), "joke_shop")
var studio := WorldBuilder.new(Node3D.new(), "tv_studio")
var unknown := WorldBuilder.new(Node3D.new(), "not_a_venue")
var a := TestUtils.check(hollywood.venue_id == "hollywood_building",
"the default venue is Hollywood")
var b := TestUtils.check(float(hollywood.spec["facade_width"]) > float(joke.spec["facade_width"]),
"the Joke Shop alley is narrower than the boulevard")
var c := TestUtils.check(not bool(joke.spec["features"]["skyline"])
and not bool(joke.spec["features"]["marquee"]),
"the Joke Shop disables the skyline and marquee")
var d := TestUtils.check(bool(studio.spec["features"]["bulb_canopy"])
and not bool(studio.spec["features"]["marquee"]),
"the studio uses a bulb canopy instead of the marquee")
var e := TestUtils.check(unknown.venue_id == "hollywood_building",
"an unknown venue falls back to Hollywood")
var root := Node3D.new()
var builder := WorldBuilder.new(root, "joke_shop")
builder.build()
var body := root.find_child("Building", true, false)
var facade := body.get_child(0) as MeshInstance3D if body != null else null
var f := TestUtils.check(facade != null and (facade.mesh as BoxMesh).size.x < 20.0,
"the Joke Shop builds the narrow facade")
var g := TestUtils.check(root.find_child("SkylineBackdrop", true, false) == null,
"the Joke Shop builds no skyline ring")
hollywood.root.free()
joke.root.free()
studio.root.free()
unknown.root.free()
root.free()
return a and b and c and d and e and f and g
func test_navigation_mesh_matches_each_venue_bounds() -> bool:
var problems: Array[String] = []
for id in ["hollywood_building", "joke_shop", "tv_studio"]:
var root := Node3D.new()
var builder := WorldBuilder.new(root, id)
builder.build()
var region := root.find_child("NavRegion", true, false) as NavigationRegion3D
if region == null or region.navigation_mesh == null:
problems.append("%s has no navmesh" % id)
root.free()
continue
var verts := region.navigation_mesh.get_vertices()
var nav_min: Vector2 = builder.spec["nav_min"]
var nav_max: Vector2 = builder.spec["nav_max"]
var min_x := 999.0
var max_x := -999.0
var min_z := 999.0
var max_z := -999.0
for v in verts:
min_x = minf(min_x, v.x)
max_x = maxf(max_x, v.x)
min_z = minf(min_z, v.z)
max_z = maxf(max_z, v.z)
if absf(min_x - nav_min.x) > 0.01 or absf(max_x - nav_max.x) > 0.01 \
or absf(min_z - nav_min.y) > 0.01 or absf(max_z - nav_max.y) > 0.01:
problems.append("%s navmesh %s does not match spec %s" % [
id, str(Vector4(min_x, max_x, min_z, max_z)),
str(Vector4(nav_min.x, nav_max.x, nav_min.y, nav_max.y))])
root.free()
return TestUtils.check(problems.is_empty(),
"each venue builds its own navigation bounds (%s)" % str(problems))
func test_phase_lighting_config_covered() -> bool: func test_phase_lighting_config_covered() -> bool:
var ok := true var ok := true
for phase in [Rules.Phase.EARLY, Rules.Phase.MIDDAY, Rules.Phase.EVENING, Rules.Phase.LATE]: for phase in [Rules.Phase.EARLY, Rules.Phase.MIDDAY, Rules.Phase.EVENING, Rules.Phase.LATE]:
+25
View File
@@ -11,6 +11,7 @@
# build/gig-line-windows-<version>.exe # build/gig-line-windows-<version>.exe
# build/gig-line-macos-<version>.zip # build/gig-line-macos-<version>.zip
# build/gig-line-web-<version>.zip (Godot web build: static hosting, no threads) # build/gig-line-web-<version>.zip (Godot web build: static hosting, no threads)
# build/gig-line-desktop-hq-<version>.zip (optional Forward+ Linux/Windows)
# #
set -euo pipefail set -euo pipefail
@@ -49,6 +50,30 @@ with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as archive:
PYZIP PYZIP
fi fi
echo "==> Packaging optional Desktop HQ build (Forward+)"
HQ_DIR="build/desktop-hq"
rm -rf "$HQ_DIR"
mkdir -p "$HQ_DIR"
cp "build/gig-line-linux-${VERSION}.x86_64" "$HQ_DIR/"
cp "build/gig-line-windows-${VERSION}.exe" "$HQ_DIR/"
cp packaging/desktop_hq/override.cfg "$HQ_DIR/"
cp packaging/desktop_hq/README.txt "$HQ_DIR/"
rm -f "build/gig-line-desktop-hq-${VERSION}.zip"
if command -v zip >/dev/null 2>&1; then
(cd "$HQ_DIR" && zip -q -r "../gig-line-desktop-hq-${VERSION}.zip" .)
else
python3 - "$PWD/$HQ_DIR" "$PWD/build/gig-line-desktop-hq-${VERSION}.zip" <<'PYZIP'
import os, sys, zipfile
src, out = sys.argv[1], sys.argv[2]
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as archive:
for root, _dirs, files in os.walk(src):
for name in files:
path = os.path.join(root, name)
archive.write(path, os.path.relpath(path, src))
PYZIP
fi
rm -rf "$HQ_DIR"
echo "" echo ""
echo "Artifacts:" echo "Artifacts:"
ls -la build/*"${VERSION}"* ls -la build/*"${VERSION}"*
+127
View File
@@ -0,0 +1,127 @@
"""Pick affordable OpenRouter vision models for the screenshot judges.
The model catalogue is fetched from OpenRouter (no auth needed) and filtered to
models that accept image input and advertise `response_format`/structured output.
The ranking uses the advertised per-token prices, so it follows the account's
current catalogue instead of relying on one hard-coded model name. If the
catalogue cannot be fetched, a small known-good list keeps QA working offline.
"""
from __future__ import annotations
import os
import urllib.error
import urllib.request
MODELS_URL = "https://openrouter.ai/api/v1/models"
DEFAULT_MODEL = "auto"
# Used only when the catalogue is unavailable. Ordered by price/known quality.
FALLBACK_MODELS = [
"z-ai/glm-5.3-flash",
"qwen/qwen3.7-flash",
"google/gemma-3-4b-it",
"openai/gpt-4.1-nano",
"google/gemini-2.5-flash-lite",
]
def fetch_models(timeout: int = 30) -> list[dict]:
"""Return the OpenRouter model catalogue, or [] when it is unreachable."""
req = urllib.request.Request(MODELS_URL, headers={"Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
payload = __import__("json").loads(resp.read().decode("utf-8"))
except (OSError, ValueError, urllib.error.HTTPError):
return []
return list(payload.get("data") or [])
def _price(model: dict) -> float:
pricing = model.get("pricing") or {}
try:
prompt = float(pricing.get("prompt") or 0.0)
completion = float(pricing.get("completion") or 0.0)
image = float(pricing.get("image") or 0.0)
except (TypeError, ValueError):
return float("inf")
# A judge request is image-heavy and emits a short JSON verdict.
return prompt + image + completion * 0.25
def vision_candidates(catalog: list[dict], limit: int = 6) -> list[str]:
"""Cheap, image-capable models that can return structured JSON."""
ranked: list[tuple[float, str]] = []
for model in catalog:
model_id = str(model.get("id") or "")
if not model_id or model_id.startswith("~") or ":batch" in model_id:
continue
architecture = model.get("architecture") or {}
inputs = architecture.get("input_modalities") or []
outputs = architecture.get("output_modalities") or []
if "image" not in inputs or "text" not in outputs:
continue
params = model.get("supported_parameters") or []
# Our request uses response_format={"type": "json_object"}; models that
# only advertise structured_outputs may reject that envelope.
if "response_format" not in params:
continue
price = _price(model)
if price <= 0.0 or price == float("inf"):
continue
ranked.append((price, model_id))
ranked.sort()
out: list[str] = []
for _price_value, model_id in ranked:
if model_id not in out:
out.append(model_id)
if len(out) >= max(1, limit):
break
return out
def resolve_models(requested: str = DEFAULT_MODEL, *, catalog: list[dict] | None = None,
limit: int = 6) -> list[str]:
"""Resolve ``auto``/an explicit model to a fallback-ordered model list.
`GIG_LINE_JUDGE_MODEL` overrides the automatic choice for reproducible runs;
an explicit CLI `--model` still wins.
"""
requested = str(requested or "").strip()
if requested and requested != DEFAULT_MODEL:
return [requested]
env_model = os.environ.get("GIG_LINE_JUDGE_MODEL", "").strip()
if env_model:
return [env_model]
models = vision_candidates(catalog if catalog is not None else fetch_models(), limit)
if not models:
models = FALLBACK_MODELS[:max(1, limit)]
return models
def is_auth_error(exc: BaseException) -> bool:
text = str(exc)
return "HTTP 401" in text or "HTTP 403" in text or "API key not valid" in text
def call_with_fallback(models: list[str], make_body, call, attempts: int = 2):
"""Try each model, retrying provider errors, and return (model, response).
Authentication errors abort immediately: switching models cannot repair a
bad key. Model/provider errors move to the next candidate.
"""
last: BaseException | None = None
for model in models:
body = make_body(model)
for attempt in range(max(1, attempts)):
try:
return model, call(body)
except (ValueError, OSError) as exc:
last = exc
if is_auth_error(exc):
raise
if attempt + 1 >= max(1, attempts):
break
if last is not None:
raise last
raise ValueError("no judge model was available")
+1
View File
@@ -67,6 +67,7 @@ extras=(
"build/gig-line-setup-$VERSION.exe" "build/gig-line-setup-$VERSION.exe"
"build/gig-line-$VERSION-x86_64.AppImage" "build/gig-line-$VERSION-x86_64.AppImage"
"build/gig-line-web-$VERSION.zip" "build/gig-line-web-$VERSION.zip"
"build/gig-line-desktop-hq-$VERSION.zip"
) )
all_artifacts=("${portables[@]}") all_artifacts=("${portables[@]}")
for f in "${extras[@]}"; do for f in "${extras[@]}"; do
+38 -14
View File
@@ -38,6 +38,7 @@ from PIL import Image, ImageFilter, ImageOps, ImageStat
import asset_pipeline import asset_pipeline
import generate_textures import generate_textures
import openrouter_judge
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
RAW_DIR = ROOT / "assets" / "textures" / "ai" / "v2" / "raw" RAW_DIR = ROOT / "assets" / "textures" / "ai" / "v2" / "raw"
@@ -46,7 +47,7 @@ CONCEPT_DIR = ROOT / "docs" / "screens" / "concepts"
REALISM_DIR = ROOT / "docs" / "screens" / "realism" REALISM_DIR = ROOT / "docs" / "screens" / "realism"
DEFAULT_MODEL = "openai/gpt-5-image-mini" DEFAULT_MODEL = "openai/gpt-5-image-mini"
DEFAULT_JUDGE_MODEL = "google/gemini-2.5-flash-lite" DEFAULT_JUDGE_MODEL = openrouter_judge.DEFAULT_MODEL
OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions" OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions"
SCENE_W, SCENE_H = 1280, 720 SCENE_W, SCENE_H = 1280, 720
@@ -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:
runs.append(parse_realism_response(_call_openrouter( model, payload = openrouter_judge.call_with_fallback(
build_realism_request(model, target, capture)))) models, lambda selected: build_realism_request(selected, target, capture),
break _call_openrouter, attempts=attempts)
used.add(model)
runs.append(parse_realism_response(payload))
except (ValueError, OSError) as exc: except (ValueError, OSError) as exc:
if openrouter_judge.is_auth_error(exc):
raise
error = exc 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:
row["judge"] = judge_capture(judge_models, target, capture,
repeats=args.repeats) 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)
+30 -10
View File
@@ -51,6 +51,7 @@ from pathlib import Path
from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageOps, ImageStat from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageOps, ImageStat
import generate_textures import generate_textures
import openrouter_judge
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
## Runtime reference art (ADR-0014); docs/screens/designs/ is the reference-only ## Runtime reference art (ADR-0014); docs/screens/designs/ is the reference-only
@@ -68,7 +69,7 @@ ASPECT = 16 / 9
DEFAULT_MODEL = "openai/gpt-5-image-mini" DEFAULT_MODEL = "openai/gpt-5-image-mini"
# Vision model used by `judge`. Cheap, multimodal, and good at "same screen?". # Vision model used by `judge`. Cheap, multimodal, and good at "same screen?".
DEFAULT_JUDGE_MODEL = "google/gemini-2.5-flash-lite" DEFAULT_JUDGE_MODEL = openrouter_judge.DEFAULT_MODEL
OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions" OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions"
# Shared art direction, appended to every prompt so the generated frames read # Shared art direction, appended to every prompt so the generated frames read
@@ -369,7 +370,7 @@ SCREENS = [
"kind": "scene", "kind": "scene",
"title": "Joke Shop door queue", "title": "Joke Shop door queue",
"source": "PLAN.md The Joke Shop — long line, ID and ticket checks", "source": "PLAN.md The Joke Shop — long line, ID and ticket checks",
"ref": None, "ref": "12-joke-shop.png",
"description": "Evening view of a small comedy-club entrance: a short queue " "description": "Evening view of a small comedy-club entrance: a short queue "
"on the sidewalk, a door person checking an ID and a ticket " "on the sidewalk, a door person checking an ID and a ticket "
"under a warm bulb, a red awning, and the show board.", "under a warm bulb, a red awning, and the show board.",
@@ -391,7 +392,7 @@ SCREENS = [
"kind": "scene", "kind": "scene",
"title": "TV Studio celebrity arrival", "title": "TV Studio celebrity arrival",
"source": "PLAN.md Late Night TV Studio — crowd gathers for an arrival", "source": "PLAN.md Late Night TV Studio — crowd gathers for an arrival",
"ref": None, "ref": "13-studio-arrival.png",
"description": "A celebrity arrival at the studio entrance: tourists and " "description": "A celebrity arrival at the studio entrance: tourists and "
"influencers pressing toward the door, cameras raised, the " "influencers pressing toward the door, cameras raised, the "
"guard holding a line on the steps, studio marquee behind.", "guard holding a line on the steps, studio marquee behind.",
@@ -965,6 +966,10 @@ def judge(args):
refs = {screen["name"]: screen["ref"] refs = {screen["name"]: screen["ref"]
for screen in SCREENS if screen["ref"]} for screen in SCREENS if screen["ref"]}
by_name = {screen["name"]: screen for screen in SCREENS} by_name = {screen["name"]: screen for screen in SCREENS}
models = [args.model]
if not args.dry_run:
models = openrouter_judge.resolve_models(args.model)
print(f"judge models: {', '.join(models)}", file=sys.stderr)
results = [] results = []
for capture in sorted(SCREENSHOTS_DIR.glob("*.png")): for capture in sorted(SCREENSHOTS_DIR.glob("*.png")):
if args.only and args.only not in capture.name: if args.only and args.only not in capture.name:
@@ -982,23 +987,36 @@ def judge(args):
"match": None, "confidence": 0.0, "match": None, "confidence": 0.0,
"differences": ["missing reference design; run generate"]}) "differences": ["missing reference design; run generate"]})
continue continue
if args.dry_run:
body = build_judge_request(args.model, by_name[design_name], body = build_judge_request(args.model, by_name[design_name],
capture, reference) capture, reference)
if args.dry_run:
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")
+89
View File
@@ -0,0 +1,89 @@
import os
import unittest
from unittest import mock
import openrouter_judge as oj
def _model(model_id, prompt, completion, *, image=True, response_format=True, batch=False):
return {
"id": model_id,
"architecture": {
"input_modalities": ["text", "image"] if image else ["text"],
"output_modalities": ["text"],
},
"supported_parameters": ["response_format"] if response_format else ["temperature"],
"pricing": {"prompt": str(prompt), "completion": str(completion)},
}
class ModelSelectionTests(unittest.TestCase):
def test_candidates_are_image_json_models_sorted_by_price(self):
catalog = [
_model("expensive/model", 0.10, 0.40),
_model("cheap/model", 0.01, 0.02),
_model("text-only", 0.001, 0.001, image=False),
_model("no-json", 0.001, 0.001, response_format=False),
_model("batch/model:batch", 0.0001, 0.0001),
]
self.assertEqual(oj.vision_candidates(catalog), ["cheap/model", "expensive/model"])
def test_explicit_model_wins_over_auto_and_env(self):
with mock.patch.dict(os.environ, {"GIG_LINE_JUDGE_MODEL": "env/model"}):
self.assertEqual(oj.resolve_models("explicit/model", catalog=[]),
["explicit/model"])
def test_auto_uses_env_override(self):
with mock.patch.dict(os.environ, {"GIG_LINE_JUDGE_MODEL": "env/model"}):
self.assertEqual(oj.resolve_models("auto", catalog=[]), ["env/model"])
def test_auto_uses_catalog_and_falls_back_offline(self):
catalog = [_model("cheap/model", 0.01, 0.02)]
self.assertEqual(oj.resolve_models("auto", catalog=catalog), ["cheap/model"])
self.assertEqual(oj.resolve_models("auto", catalog=[]), oj.FALLBACK_MODELS[:6])
class FallbackTests(unittest.TestCase):
def test_fallback_switches_model_after_provider_error(self):
seen = []
def call(body):
seen.append(body["model"])
if body["model"] == "bad/model":
raise ValueError("OpenRouter HTTP 400: provider rejected image")
return {"ok": True}
model, payload = oj.call_with_fallback(
["bad/model", "good/model"], lambda m: {"model": m}, call, attempts=1)
self.assertEqual(model, "good/model")
self.assertEqual(payload, {"ok": True})
self.assertEqual(seen, ["bad/model", "good/model"])
def test_auth_error_does_not_try_another_model(self):
seen = []
def call(body):
seen.append(body["model"])
raise ValueError("OpenRouter HTTP 401: API key not valid")
with self.assertRaises(ValueError):
oj.call_with_fallback(["one", "two"], lambda m: {"model": m}, call)
self.assertEqual(seen, ["one"])
def test_transient_error_retries_same_model(self):
seen = []
def call(body):
seen.append(body["model"])
if len(seen) == 1:
raise ValueError("OpenRouter HTTP 500")
return {"ok": True}
model, _ = oj.call_with_fallback(
["one"], lambda m: {"model": m}, call, attempts=2)
self.assertEqual(model, "one")
self.assertEqual(seen, ["one", "one"])
if __name__ == "__main__":
unittest.main()