Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3025ba1cef | ||
|
|
a5e7b3c013 | ||
|
|
f2863abeac | ||
|
|
7f92b8861d |
No files matched your search
@@ -661,3 +661,51 @@ produces a 63 MB static bundle and it serves locally with HTTP 200s for html/was
|
|||||||
- [x] Cover the preset, the non-threaded choice, and the release wiring in
|
- [x] Cover the preset, the non-threaded choice, and the release wiring in
|
||||||
`src/test/tests/test_web_export.gd`.
|
`src/test/tests/test_web_export.gd`.
|
||||||
- [x] Document the artifact in `docs/RELEASING.md`, the README gallery/status, and `PLAN.md`.
|
- [x] Document the artifact in `docs/RELEASING.md`, the README gallery/status, and `PLAN.md`.
|
||||||
|
|
||||||
|
## Prop Texture Atlas
|
||||||
|
|
||||||
|
Completed 2026-10-31 (v0.3.1). Headless **182/182**, Python tooling **53/53**; screenshots
|
||||||
|
regenerated and `screen_designs.py compare` still reports 6 good / 0 bad.
|
||||||
|
|
||||||
|
- [x] `model_pipeline.atlas_for()` packs each prop's palette roles into one small power-of-two
|
||||||
|
PNG and a matching metallic/roughness atlas (roughness in G, metallic in B), with nearest
|
||||||
|
sampling so a palette cell stays a flat colour.
|
||||||
|
- [x] `build_glb()` merges every part into a single primitive with per-vertex atlas UVs and one
|
||||||
|
material (`baseColorTexture` + `metallicRoughnessTexture`), so a prop is one draw material
|
||||||
|
instead of one per part.
|
||||||
|
- [x] `audit_glb()` checks the single-material/atlas contract (one material, both textures,
|
||||||
|
TEXCOORD_0 on every primitive, two images).
|
||||||
|
- [x] Tests: atlas determinism and power-of-two sizing, UV range, palette override reaching the
|
||||||
|
atlas, and a Godot test that each imported prop scene uses exactly one material.
|
||||||
|
- [x] Removed the now-unused sRGB→linear factor helpers (colours travel in the sRGB texture).
|
||||||
|
|
||||||
|
## Complete AI Mockup Set
|
||||||
|
|
||||||
|
Completed 2026-10-31 (v0.3.2). All 19 catalogue screens now have an AI reference; Python
|
||||||
|
tooling **53/53**.
|
||||||
|
|
||||||
|
- [x] Generated mockups for the seven entries that only had wireframes: wand screening,
|
||||||
|
Joke Shop queue, TV Studio arrival, crowd panic, responder arrival, body-camera
|
||||||
|
timeline, escalation HUD.
|
||||||
|
- [x] `generate` loads `.env` like `judge` already did, so `--ai` works without exporting
|
||||||
|
`OPENROUTER_API_KEY` by hand.
|
||||||
|
- [x] Verified every reference is 1280x720, reviewed the new images visually, and confirmed
|
||||||
|
`list` reports 19 designs (12 kept, 7 new).
|
||||||
|
|
||||||
|
## Staged Captures For The New Screens
|
||||||
|
|
||||||
|
Completed 2026-11-01 (v0.3.3). `screen_designs.py compare` reports **9 good / 0 bad**;
|
||||||
|
vision judge: wand **match** (0.90), timeline **match** (1.00), escalation **review**, 0 diff.
|
||||||
|
Python tooling **53/53**, headless **182/182**.
|
||||||
|
|
||||||
|
- [x] `capture_screenshots.gd` now continues past the night set: switches to The Joke Shop,
|
||||||
|
stages a guest, retries the wand until it alerts (falls back to the documented keys
|
||||||
|
false alarm), applies formal warning + blocking, and opens the body-camera timeline.
|
||||||
|
- [x] New captures `07-wand-screening.png`, `08-escalation-hud.png`, `09-body-camera-timeline.png`
|
||||||
|
added to `docs/screenshots/` and to the README gallery.
|
||||||
|
- [x] Wired `ref` for `wand_screening`, `escalation_hud` and `body_camera_timeline` so
|
||||||
|
`compare` and `judge` include them (9 of 19 screens now have a staged capture).
|
||||||
|
- [x] Regenerated the escalation HUD mockup as a third-person over-the-shoulder night shot so
|
||||||
|
the reference matches the game camera.
|
||||||
|
- [x] Added `Hud.clear_log()` so staged captures drop target-cycling noise; the escalation shot
|
||||||
|
keeps the real reaction lines.
|
||||||
@@ -31,6 +31,21 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
(static hosting, no COOP/COEP); `tools/build_releases.sh` exports `build/web/` and zips it to
|
(static hosting, no COOP/COEP); `tools/build_releases.sh` exports `build/web/` and zips it to
|
||||||
`build/step-line-web-<ver>.zip`, and `publish_release.sh` uploads it. Covered by
|
`build/step-line-web-<ver>.zip`, and `publish_release.sh` uploads it. Covered by
|
||||||
`src/test/tests/test_web_export.gd`.
|
`src/test/tests/test_web_export.gd`.
|
||||||
|
- **Prop atlas (v0.3.1):** `model_pipeline.atlas_for()` builds a per-prop power-of-two PNG with
|
||||||
|
one 8×8 cell per palette role, plus a metallic/roughness atlas (G=roughness, B=metallic). The
|
||||||
|
GLB merges all parts into one primitive with constant per-vertex UVs (cell centre) and a single
|
||||||
|
material (`baseColorTexture` + `metallicRoughnessTexture`, NEAREST sampling). Colours are sRGB in
|
||||||
|
the texture, so the old `srgb_to_linear`/`_linear_rgba` helpers were removed. `audit_glb` fails
|
||||||
|
if a prop is not exactly one material or misses TEXCOORD_0/images.
|
||||||
|
- **Mockups complete (v0.3.2):** `docs/screens/designs/` holds AI references for all 19
|
||||||
|
catalogue screens. Only the six staged captures (01–06) are compared/judged; the other 13
|
||||||
|
have no `capture`, so adding staged captures for the wand panel, timeline and escalation HUD
|
||||||
|
would extend automated visual checks. `generate` now calls `_load_env_file()`.
|
||||||
|
- **Staged captures (v0.3.3):** `capture_screenshots.gd` now emits nine shots. States 7–10 switch
|
||||||
|
to The Joke Shop at elapsed 620 s (EVENING has routine spawns; LATE has none), retry the wand
|
||||||
|
until it alerts, then jump to 760 s after staging for night lighting. The timeline needs
|
||||||
|
`recorder.close_all_open()`, because `to_dict()` only lists closed incidents. The escalation
|
||||||
|
HUD mockup is third-person now; `judge` still classes it `review` (no diff).
|
||||||
- **Backlog complete:** every planned TODO section is done and moved to `DONE.md`; `TODO.md`
|
- **Backlog complete:** every planned TODO section is done and moved to `DONE.md`; `TODO.md`
|
||||||
now only lists optional future ideas (live 3D replay, NPC LOD, more venues, real audio, prop
|
now only lists optional future ideas (live 3D replay, NPC LOD, more venues, real audio, prop
|
||||||
atlas, AI mockups for the new catalogue entries, image-to-mesh props). GUT was consciously not
|
atlas, AI mockups for the new catalogue entries, image-to-mesh props). GUT was consciously not
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ The central design goal is simple:
|
|||||||
|  |  |
|
|  |  |
|
||||||
|  |  |
|
|  |  |
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|  |  |
|
||||||
|
|  | |
|
||||||
|
|
||||||
*Screenshots are generated by [`src/test/capture_screenshots.gd`](src/test/capture_screenshots.gd) from the current build — day and late-night shifts.*
|
*Screenshots are generated by [`src/test/capture_screenshots.gd`](src/test/capture_screenshots.gd) from the current build — day, late-night, wand screening, escalation, and shift-review timeline.*
|
||||||
|
|
||||||
## ⬇️ Downloads
|
## ⬇️ Downloads
|
||||||
|
|
||||||
@@ -25,14 +27,14 @@ The central design goal is simple:
|
|||||||
| **Linux — AppImage** | [`step-line-<ver>-x86_64.AppImage`](https://git.allucanget.biz/zwitschi/security-game/releases) · single file, `chmod +x` and run |
|
| **Linux — AppImage** | [`step-line-<ver>-x86_64.AppImage`](https://git.allucanget.biz/zwitschi/security-game/releases) · single file, `chmod +x` and run |
|
||||||
| **Linux — portable** | [`step-line-linux-<ver>.x86_64`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, `chmod +x` and run |
|
| **Linux — portable** | [`step-line-linux-<ver>.x86_64`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, `chmod +x` and run |
|
||||||
| **macOS** | [`step-line-macos-<ver>.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unsigned build, quarantine note below |
|
| **macOS** | [`step-line-macos-<ver>.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unsigned build, quarantine note below |
|
||||||
|
| **Web (browser)** | [`step-line-web-<ver>.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unzip and serve the folder from any static host (no special headers) |
|
||||||
|
|
||||||
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
|
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
|
||||||
|
|
||||||
> Downloads are public (no sign-in required) since the host's instance-wide
|
> Downloads are public (no sign-in required) since the host's instance-wide
|
||||||
> `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31.
|
> `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31.
|
||||||
>
|
>
|
||||||
> **Latest: v0.3.0** — rendering and NPC presentation pass, generated prop models, aim-free
|
> **Latest: v0.3.3** — staged captures for the wand panel, escalation HUD, and body-camera timeline now feed the visual checks.
|
||||||
> selection with precision walk, and a Web (browser) build.
|
|
||||||
> 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
|
||||||
@@ -258,10 +260,12 @@ python3 tools/screen_designs.py judge --only 03- # one capture
|
|||||||
python3 tools/screen_designs.py judge --dry-run # build requests, no network
|
python3 tools/screen_designs.py judge --dry-run # build requests, no network
|
||||||
```
|
```
|
||||||
|
|
||||||
The catalogue covers 19 screens: the six README captures plus the start menu,
|
The catalogue covers 19 screens: the nine README captures plus the start menu,
|
||||||
pause overlay, settings, shift review, radial menu, emergency radio, wand
|
pause overlay, settings, shift review, radial menu, emergency radio, wand
|
||||||
screening, Joke Shop queue, TV Studio arrival, crowd panic, responder arrival,
|
screening, Joke Shop queue, TV Studio arrival, crowd panic, responder arrival,
|
||||||
body-camera timeline, and the escalation HUD.
|
body-camera timeline, and the escalation HUD. All 19 have an AI reference in
|
||||||
|
`docs/screens/designs/`; the nine screens with staged captures are also compared
|
||||||
|
or judged against the running game.
|
||||||
|
|
||||||
`compare` flags gross drift: wrong aspect ratio, undersized frame, near-blank
|
`compare` flags gross drift: wrong aspect ratio, undersized frame, near-blank
|
||||||
frame, and large layout/palette/detail distance. It is a regression gate, not a
|
frame, and large layout/palette/detail distance. It is a regression gate, not a
|
||||||
@@ -377,7 +381,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: **181 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset)
|
- Headless unit tests: **182 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material)
|
||||||
- Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event
|
- 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`)
|
||||||
@@ -400,6 +404,7 @@ Implemented so far:
|
|||||||
- Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets
|
- Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets
|
||||||
- Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock)
|
- Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock)
|
||||||
- Rendering pass (v0.3.0 work, [ADR-0012](docs/adr/0012-surface-materials-and-generated-props.md)): one material module with roughness/metallic profiles per surface, ACES tonemapping, glow, aerial fog and colour grading driven by the shift phase, correctly scaled brick, framed facade windows that light up at night, door trim and handles, a curb and lane dashes, and the glowing marquee/lamp bulbs. NPCs gained per-role clothing palettes, caps/hoods/backpacks/sunglasses/lanyards, hands and shoes, and an arm/leg walk swing
|
- Rendering pass (v0.3.0 work, [ADR-0012](docs/adr/0012-surface-materials-and-generated-props.md)): one material module with roughness/metallic profiles per surface, ACES tonemapping, glow, aerial fog and colour grading driven by the shift phase, correctly scaled brick, framed facade windows that light up at night, door trim and handles, a curb and lane dashes, and the glowing marquee/lamp bulbs. NPCs gained per-role clothing palettes, caps/hoods/backpacks/sunglasses/lanyards, hands and shoes, and an arm/leg walk swing
|
||||||
|
- Prop atlas (v0.3.1): `tools/model_pipeline.py` packs each prop's palette into one power-of-two colour atlas plus a metallic/roughness atlas and emits a single material with per-vertex UVs, so a prop is one draw material rather than one per part; `audit` enforces the contract
|
||||||
- Generated prop models (v0.3.0 work): `tools/model_pipeline.py` builds bollards, benches, bins, planters, newspaper boxes and a hydrant as `.glb` props with per-part materials, loaded by `WorldBuilder` with a procedural fallback; the optional AI concept step (`model_pipeline.py concept`) supplies per-prop colour palettes
|
- Generated prop models (v0.3.0 work): `tools/model_pipeline.py` builds bollards, benches, bins, planters, newspaper boxes and a hydrant as `.glb` props with per-part materials, loaded by `WorldBuilder` with a procedural fallback; the optional AI concept step (`model_pipeline.py concept`) supplies per-prop colour palettes
|
||||||
- Web build: `build/step-line-web-<version>.zip` (Godot's non-threaded web template, no COOP/COEP headers needed) built by `tools/build_releases.sh` and published with the desktop artifacts; serve the unzipped folder from any static host
|
- Web build: `build/step-line-web-<version>.zip` (Godot's non-threaded web template, no COOP/COEP headers needed) built by `tools/build_releases.sh` and published with the desktop artifacts; serve the unzipped folder from any static host
|
||||||
- Aim-free selection pass (v0.3.0 work, [ADR-0011](docs/adr/0011-aim-free-selection-and-precision-walk.md)): the focus cone ranks candidates by angle then distance, `Tab` and the mouse wheel cycle nearby people nearest-first and pin the selection, right click clears focus with a short re-acquire grace, the HUD reports how many people are in the ring, and holding `Shift` walks at half speed for close work
|
- Aim-free selection pass (v0.3.0 work, [ADR-0011](docs/adr/0011-aim-free-selection-and-precision-walk.md)): the focus cone ranks candidates by angle then distance, `Tab` and the mouse wheel cycle nearby people nearest-first and pin the selection, right click clears focus with a short re-acquire grace, the HUD reports how many people are in the ring, and holding `Shift` walks at half speed for close work
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
Godot 4.3 prototype. Latest release **v0.3.0** (rendering, model pipeline, selection pass and a
|
Godot 4.3 prototype. Latest release **v0.3.2** (mockup set). Headless suite **182/182** plus
|
||||||
Web build). Headless suite **181/181** plus **53** Python tooling tests; the session, PLAN-verb, crowd, emergency, timeline, audio, scenario-content, and
|
**53** Python tooling tests; the session, PLAN-verb, crowd, emergency, timeline, audio, scenario-content, and
|
||||||
windowed focus/radial smokes pass. Anonymous downloads are enabled on the host.
|
windowed focus/radial smokes pass. Anonymous downloads are enabled on the host.
|
||||||
|
|
||||||
All planned backlog sections are complete (see `DONE.md`). Optional future ideas live below.
|
All planned backlog sections are complete (see `DONE.md`). Optional future ideas live below.
|
||||||
@@ -14,6 +14,3 @@ All planned backlog sections are complete (see `DONE.md`). Optional future ideas
|
|||||||
- [ ] Real voice/dialogue audio (the crowd bed and radio crackle are procedural).
|
- [ ] Real voice/dialogue audio (the crowd bed and radio crackle are procedural).
|
||||||
- [ ] Swap the primitive prop builder for an image-to-mesh model API behind the same
|
- [ ] Swap the primitive prop builder for an image-to-mesh model API behind the same
|
||||||
`assets/models/generated/<prop>.glb` contract.
|
`assets/models/generated/<prop>.glb` contract.
|
||||||
- [ ] Texture atlas for the props so they share one material instead of one per part.
|
|
||||||
- [ ] Regenerate the AI screen mockups for the seven new catalogue entries (only the
|
|
||||||
deterministic wireframes exist so far).
|
|
||||||
|
After Width: | Height: | Size: 89 B |
|
After Width: | Height: | Size: 86 B |
|
After Width: | Height: | Size: 89 B |
|
After Width: | Height: | Size: 86 B |
|
After Width: | Height: | Size: 96 B |
|
After Width: | Height: | Size: 92 B |
|
After Width: | Height: | Size: 99 B |
|
After Width: | Height: | Size: 94 B |
|
After Width: | Height: | Size: 92 B |
|
After Width: | Height: | Size: 92 B |
|
After Width: | Height: | Size: 96 B |
|
After Width: | Height: | Size: 92 B |
|
After Width: | Height: | Size: 1007 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1021 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 838 KiB After Width: | Height: | Size: 836 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 528 KiB |
@@ -8,7 +8,7 @@ config_version=5
|
|||||||
|
|
||||||
config/name="Step Line"
|
config/name="Step 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.3.0"
|
config/version="0.3.3"
|
||||||
run/main_scene="res://scenes/main.tscn"
|
run/main_scene="res://scenes/main.tscn"
|
||||||
config/features=PackedStringArray("4.3", "GL Compatibility")
|
config/features=PackedStringArray("4.3", "GL Compatibility")
|
||||||
|
|
||||||
|
|||||||
@@ -402,6 +402,12 @@ func set_escalation(level: int) -> void:
|
|||||||
if vignette_rect:
|
if vignette_rect:
|
||||||
vignette_rect.modulate = Color(tint.r, tint.g, tint.b, 0.05 + 0.2 * float(level) / 5.0)
|
vignette_rect.modulate = Color(tint.r, tint.g, tint.b, 0.05 + 0.2 * float(level) / 5.0)
|
||||||
|
|
||||||
|
## Dev helper: drop queued log lines so a staged screenshot shows only the
|
||||||
|
## lines that belong to the moment.
|
||||||
|
func clear_log() -> void:
|
||||||
|
_log_lines.clear()
|
||||||
|
log_label.text = ""
|
||||||
|
|
||||||
func log(text: String) -> void:
|
func log(text: String) -> void:
|
||||||
_log_lines.append(text)
|
_log_lines.append(text)
|
||||||
if _log_lines.size() > 8:
|
if _log_lines.size() > 8:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ var state := 0
|
|||||||
var state_frames := 0
|
var state_frames := 0
|
||||||
var targeted := false
|
var targeted := false
|
||||||
var camera_staged := false
|
var camera_staged := false
|
||||||
|
var wand_attempts := 0
|
||||||
|
|
||||||
func _initialize() -> void:
|
func _initialize() -> void:
|
||||||
root.size = Vector2i(1280, 720)
|
root.size = Vector2i(1280, 720)
|
||||||
@@ -89,7 +90,7 @@ func _advance() -> void:
|
|||||||
if gm != null:
|
if gm != null:
|
||||||
gm.radio_panel.hide()
|
gm.radio_panel.hide()
|
||||||
gm.hud.set_action_hint("")
|
gm.hud.set_action_hint("")
|
||||||
gm.coordinator.elapsed_s = 750.0
|
gm.coordinator.elapsed_s = 620.0
|
||||||
state = 5
|
state = 5
|
||||||
state_frames = 0
|
state_frames = 0
|
||||||
5:
|
5:
|
||||||
@@ -110,9 +111,101 @@ func _advance() -> void:
|
|||||||
gm.call("_toggle_flashlight")
|
gm.call("_toggle_flashlight")
|
||||||
if state_frames >= 30:
|
if state_frames >= 30:
|
||||||
_shoot("06-night-flashlight.png")
|
_shoot("06-night-flashlight.png")
|
||||||
|
# Move to The Joke Shop for the wand/shift-review set, and keep
|
||||||
|
# the late-night phase so the UI sits on the night scene.
|
||||||
|
if gm != null:
|
||||||
|
gm.call("_toggle_flashlight")
|
||||||
|
gm.switch_scenario("joke_shop")
|
||||||
|
gm.coordinator.elapsed_s = 620.0
|
||||||
state = 7
|
state = 7
|
||||||
|
state_frames = 0
|
||||||
|
7:
|
||||||
|
# Wand screening: wait for guests and keep re-targeting until one
|
||||||
|
# accepts the screen (some authored Joke Shop guests refuse it).
|
||||||
|
if state_frames > 40 and state_frames % 20 == 0 and gm != null and gm.npcs.size() > 0:
|
||||||
|
# Prefer a guest whose items make the wand alert, so the panel
|
||||||
|
# shows the interesting case instead of "Alert: none".
|
||||||
|
if gm.wand_open:
|
||||||
|
if _wand_alerts():
|
||||||
|
state = 8
|
||||||
|
state_frames = 0
|
||||||
|
elif wand_attempts >= 6:
|
||||||
|
# Nobody nearby carried an alerting item: stage the documented
|
||||||
|
# false-alarm case (keys) on this guest and re-open the panel.
|
||||||
|
var guest: Object = gm.call("_targeted_controller")
|
||||||
|
if guest != null and guest.profile != null:
|
||||||
|
var false_alarm: Array[String] = ["keys"]
|
||||||
|
guest.profile.carried_items = false_alarm
|
||||||
|
gm.call("_toggle_wand_screening")
|
||||||
|
gm.call("_toggle_wand_screening")
|
||||||
|
state = 8
|
||||||
|
state_frames = 0
|
||||||
|
else:
|
||||||
|
gm.call("_toggle_wand_screening")
|
||||||
|
wand_attempts += 1
|
||||||
|
else:
|
||||||
|
gm.call("_cycle_nearby_target")
|
||||||
|
_face_target()
|
||||||
|
gm.call("_toggle_wand_screening")
|
||||||
|
wand_attempts += 1
|
||||||
|
elif state_frames > 600 and gm != null:
|
||||||
|
# Fallback: real controller, panel shown directly.
|
||||||
|
var controller: Object = gm.call("_targeted_controller")
|
||||||
|
if controller != null and controller.profile != null:
|
||||||
|
gm.hud.show_wand(controller, gm.wand_screener.screen(controller.profile.carried_items))
|
||||||
|
state = 8
|
||||||
|
state_frames = 0
|
||||||
|
8:
|
||||||
|
if state_frames == 1 and gm != null:
|
||||||
|
gm.hud.clear_log()
|
||||||
|
_stage_closeup()
|
||||||
|
if state_frames >= 45:
|
||||||
|
_shoot("07-wand-screening.png")
|
||||||
|
if gm != null:
|
||||||
|
if gm.wand_open:
|
||||||
|
gm.call("_toggle_wand_screening")
|
||||||
|
# Real escalation path: formal warning arms the body camera
|
||||||
|
# (a timeline row), then blocking raises the meter to L3.
|
||||||
|
gm.call("_apply_interaction", 2, "formal warning")
|
||||||
|
gm.call("_apply_interaction", 3, "blocking")
|
||||||
|
state = 9
|
||||||
|
state_frames = 0
|
||||||
|
9:
|
||||||
|
if state_frames == 1 and gm != null:
|
||||||
|
# Jump into the late phase now that guests exist: LATE has no
|
||||||
|
# routine spawn pool, so this happens after staging the target.
|
||||||
|
gm.coordinator.elapsed_s = 760.0
|
||||||
|
_stage_closeup()
|
||||||
|
if state_frames >= 45:
|
||||||
|
_shoot("08-escalation-hud.png")
|
||||||
|
if gm != null and gm.coordinator != null:
|
||||||
|
# Close the open incident so the recorder's review includes it
|
||||||
|
# (to_dict only lists closed incidents).
|
||||||
|
gm.coordinator.recorder.close_all_open("unresolved")
|
||||||
|
gm.call("_open_timeline", {"incidents": gm.coordinator.recorder.to_dict()})
|
||||||
|
state = 10
|
||||||
|
state_frames = 0
|
||||||
|
10:
|
||||||
|
if state_frames >= 30:
|
||||||
|
_shoot("09-body-camera-timeline.png")
|
||||||
quit(0)
|
quit(0)
|
||||||
|
|
||||||
|
## Put the guard a couple of steps in front of the target so a staged UI capture
|
||||||
|
## frames the person the panel is about.
|
||||||
|
func _wand_alerts() -> bool:
|
||||||
|
if gm == null or gm.hud == null or gm.hud.wand_text == null:
|
||||||
|
return false
|
||||||
|
return gm.hud.wand_text.text.contains("Alert: YES")
|
||||||
|
|
||||||
|
func _stage_closeup() -> void:
|
||||||
|
var target: Node3D = gm.targeted_npc
|
||||||
|
if target == null or not is_instance_valid(target):
|
||||||
|
return
|
||||||
|
gm.guard.global_position = target.global_position + Vector3(0, -0.4, 2.2)
|
||||||
|
gm.guard.global_position.y = 0.1
|
||||||
|
_face_target()
|
||||||
|
gm.guard.head.rotation.x = deg_to_rad(1.0)
|
||||||
|
|
||||||
func _face_target() -> void:
|
func _face_target() -> void:
|
||||||
var target: Node3D = gm.targeted_npc
|
var target: Node3D = gm.targeted_npc
|
||||||
if target == null:
|
if target == null:
|
||||||
|
|||||||
@@ -39,6 +39,35 @@ func test_prop_scenes_instantiate_with_meshes() -> bool:
|
|||||||
instance.free()
|
instance.free()
|
||||||
return TestUtils.check(problems.is_empty(), "every prop scene has geometry (%s)" % str(problems))
|
return TestUtils.check(problems.is_empty(), "every prop scene has geometry (%s)" % str(problems))
|
||||||
|
|
||||||
|
func test_prop_scenes_share_one_atlas_material() -> bool:
|
||||||
|
## All parts of a prop share one material backed by the colour atlas, so a
|
||||||
|
## prop is a single draw material instead of one per part.
|
||||||
|
var problems: Array[String] = []
|
||||||
|
for prop in WorldBuilder.MODEL_PROPS:
|
||||||
|
var packed := ResourceLoader.load(MODEL_DIR + str(prop) + ".glb") as PackedScene
|
||||||
|
if packed == null:
|
||||||
|
problems.append("%s: no scene" % prop)
|
||||||
|
continue
|
||||||
|
var instance := packed.instantiate()
|
||||||
|
var meshes := instance.find_children("*", "MeshInstance3D", true, false)
|
||||||
|
var materials := {}
|
||||||
|
var surfaces := 0
|
||||||
|
for node in meshes:
|
||||||
|
var mesh: Mesh = (node as MeshInstance3D).mesh
|
||||||
|
if mesh == null:
|
||||||
|
continue
|
||||||
|
for index in mesh.get_surface_count():
|
||||||
|
surfaces += 1
|
||||||
|
var material := mesh.surface_get_material(index)
|
||||||
|
if material != null:
|
||||||
|
materials[material.get_instance_id()] = true
|
||||||
|
if surfaces == 0:
|
||||||
|
problems.append("%s: no surfaces" % prop)
|
||||||
|
elif materials.size() != 1:
|
||||||
|
problems.append("%s: %d materials (expected 1)" % [prop, materials.size()])
|
||||||
|
instance.free()
|
||||||
|
return TestUtils.check(problems.is_empty(), "one atlas material per prop (%s)" % str(problems))
|
||||||
|
|
||||||
func test_world_places_generated_props() -> bool:
|
func test_world_places_generated_props() -> bool:
|
||||||
var root := Node3D.new()
|
var root := Node3D.new()
|
||||||
var builder := WorldBuilder.new(root)
|
var builder := WorldBuilder.new(root)
|
||||||
|
|||||||
@@ -475,99 +475,196 @@ def generate_concepts(only="", model=DEFAULT_CONCEPT_MODEL, size=1024,
|
|||||||
# glTF binary writer.
|
# glTF binary writer.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ATLAS_CELL = 8
|
||||||
|
|
||||||
|
|
||||||
|
def _next_pow2(value: int) -> int:
|
||||||
|
size = 1
|
||||||
|
while size < value:
|
||||||
|
size *= 2
|
||||||
|
return size
|
||||||
|
|
||||||
|
|
||||||
|
def _png_bytes(width: int, height: int, rgba: bytearray) -> bytes:
|
||||||
|
"""Encode raw RGBA rows as a PNG without requiring Pillow."""
|
||||||
|
import zlib
|
||||||
|
rows = bytearray()
|
||||||
|
stride = width * 4
|
||||||
|
for y in range(height):
|
||||||
|
rows.append(0) # filter type 0
|
||||||
|
rows.extend(rgba[y * stride:(y + 1) * stride])
|
||||||
|
|
||||||
|
def chunk(tag: bytes, payload: bytes) -> bytes:
|
||||||
|
return (struct.pack(">I", len(payload)) + tag + payload +
|
||||||
|
struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF))
|
||||||
|
|
||||||
|
header = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
|
||||||
|
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) +
|
||||||
|
chunk(b"IDAT", zlib.compress(bytes(rows), 9)) + chunk(b"IEND", b""))
|
||||||
|
|
||||||
|
|
||||||
|
def atlas_for(prop: dict, palette: dict = None) -> dict:
|
||||||
|
"""Pack every colour this prop uses into one tiny texture atlas.
|
||||||
|
|
||||||
|
Each part gets a constant UV pointing at its palette cell, so all parts can
|
||||||
|
share a single material instead of one material per part. The same layout
|
||||||
|
is written into a metallic/roughness atlas (glTF reads roughness from G and
|
||||||
|
metallic from B), keeping the per-role surface response.
|
||||||
|
"""
|
||||||
|
palette = palette or PALETTE
|
||||||
|
slots = {}
|
||||||
|
for part in prop["parts"]:
|
||||||
|
role = part.get("role", "body")
|
||||||
|
colour = tuple(round(float(c), 5)
|
||||||
|
for c in palette.get(role, PALETTE[role])[:3])
|
||||||
|
metallic, roughness = PBR.get(role, PBR["body"])
|
||||||
|
slots[role] = (colour, metallic, roughness)
|
||||||
|
roles = sorted(slots.keys())
|
||||||
|
grid = max(1, int(math.ceil(math.sqrt(len(roles)))))
|
||||||
|
size = _next_pow2(grid * ATLAS_CELL)
|
||||||
|
base = bytearray(size * size * 4)
|
||||||
|
metal_rough = bytearray(size * size * 4)
|
||||||
|
role_uv = {}
|
||||||
|
for index, role in enumerate(roles):
|
||||||
|
colour, metallic, roughness = slots[role]
|
||||||
|
col = index % grid
|
||||||
|
row = index // grid
|
||||||
|
x0 = col * ATLAS_CELL
|
||||||
|
y0 = row * ATLAS_CELL
|
||||||
|
rgb = bytes((int(round(colour[0] * 255)), int(round(colour[1] * 255)),
|
||||||
|
int(round(colour[2] * 255)), 255))
|
||||||
|
mr = bytes((0, int(round(roughness * 255)), int(round(metallic * 255)), 255))
|
||||||
|
for y in range(ATLAS_CELL):
|
||||||
|
for x in range(ATLAS_CELL):
|
||||||
|
offset = ((y0 + y) * size + (x0 + x)) * 4
|
||||||
|
base[offset:offset + 4] = rgb
|
||||||
|
metal_rough[offset:offset + 4] = mr
|
||||||
|
role_uv[role] = ((x0 + ATLAS_CELL / 2.0) / size,
|
||||||
|
(y0 + ATLAS_CELL / 2.0) / size)
|
||||||
|
return {
|
||||||
|
"size": size,
|
||||||
|
"roles": roles,
|
||||||
|
"role_uv": role_uv,
|
||||||
|
"colours": {role: slots[role][0] for role in roles},
|
||||||
|
"base_png": _png_bytes(size, size, base),
|
||||||
|
"metal_rough_png": _png_bytes(size, size, metal_rough),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _pad(data: bytes, fill: bytes) -> bytes:
|
def _pad(data: bytes, fill: bytes) -> bytes:
|
||||||
remainder = len(data) % 4
|
remainder = len(data) % 4
|
||||||
return data if remainder == 0 else data + fill * (4 - remainder)
|
return data if remainder == 0 else data + fill * (4 - remainder)
|
||||||
|
|
||||||
|
|
||||||
def srgb_to_linear(channel: float) -> float:
|
def build_glb(prop: dict, palette: dict = None, atlas: dict = None) -> bytes:
|
||||||
"""Convert one 0..1 sRGB channel to linear.
|
"""Serialise one prop as a self-contained .glb (glTF 2.0) file.
|
||||||
|
|
||||||
glTF stores `baseColorFactor` in linear space, while the catalogue palettes
|
All parts share one material backed by a colour atlas and a
|
||||||
(and the colours sampled from a concept PNG) are sRGB. Writing sRGB numbers
|
metallic/roughness atlas, so the prop draws with a single material.
|
||||||
straight into the factor makes every prop render several shades too light,
|
|
||||||
which is why the first build's bins and newspaper boxes looked pastel.
|
|
||||||
"""
|
"""
|
||||||
if channel <= 0.04045:
|
|
||||||
return channel / 12.92
|
|
||||||
return ((channel + 0.055) / 1.055) ** 2.4
|
|
||||||
|
|
||||||
|
|
||||||
def _linear_rgba(color) -> list:
|
|
||||||
return [round(srgb_to_linear(float(c)), 5) for c in color[:3]] + [1.0]
|
|
||||||
|
|
||||||
|
|
||||||
def build_glb(prop: dict, palette: dict = None) -> bytes:
|
|
||||||
"""Serialise one prop as a self-contained .glb (glTF 2.0) file."""
|
|
||||||
palette = palette or PALETTE
|
palette = palette or PALETTE
|
||||||
|
atlas = atlas or atlas_for(prop, palette)
|
||||||
|
|
||||||
|
positions = []
|
||||||
|
normals = []
|
||||||
|
uvs = []
|
||||||
|
indices = []
|
||||||
|
for part in prop["parts"]:
|
||||||
|
mesh = build_part_mesh(part)
|
||||||
|
base = len(positions)
|
||||||
|
uv = atlas["role_uv"][part.get("role", "body")]
|
||||||
|
for position, normal in zip(mesh.positions, mesh.normals):
|
||||||
|
positions.append(position)
|
||||||
|
normals.append(normal)
|
||||||
|
uvs.append(uv)
|
||||||
|
indices.extend(index + base for index in mesh.indices)
|
||||||
|
|
||||||
binary = bytearray()
|
binary = bytearray()
|
||||||
buffer_views = []
|
buffer_views = []
|
||||||
accessors = []
|
accessors = []
|
||||||
materials = []
|
|
||||||
primitives = []
|
|
||||||
|
|
||||||
def add_view(payload: bytes, target: int) -> int:
|
def add_view(payload: bytes, target=None) -> int:
|
||||||
buffer_views.append({
|
view = {
|
||||||
"buffer": 0,
|
"buffer": 0,
|
||||||
"byteOffset": len(binary),
|
"byteOffset": len(binary),
|
||||||
"byteLength": len(payload),
|
"byteLength": len(payload),
|
||||||
"target": target,
|
}
|
||||||
})
|
if target is not None:
|
||||||
|
view["target"] = target
|
||||||
|
buffer_views.append(view)
|
||||||
binary.extend(_pad(payload, b"\x00"))
|
binary.extend(_pad(payload, b"\x00"))
|
||||||
return len(buffer_views) - 1
|
return len(buffer_views) - 1
|
||||||
|
|
||||||
# One primitive per part: the parts share a mesh but need separate
|
pos_payload = b"".join(struct.pack("<3f", *p) for p in positions)
|
||||||
# materials, and a glTF primitive is the unit of material assignment.
|
nrm_payload = b"".join(struct.pack("<3f", *n) for n in normals)
|
||||||
for part in prop["parts"]:
|
uv_payload = b"".join(struct.pack("<2f", *uv) for uv in uvs)
|
||||||
mesh = build_part_mesh(part)
|
idx_payload = b"".join(struct.pack("<H", i) for i in indices)
|
||||||
positions = b"".join(struct.pack("<3f", *p) for p in mesh.positions)
|
|
||||||
normals = b"".join(struct.pack("<3f", *n) for n in mesh.normals)
|
|
||||||
indices = b"".join(struct.pack("<H", i) for i in mesh.indices)
|
|
||||||
pos_view = add_view(positions, 34962)
|
|
||||||
nrm_view = add_view(normals, 34962)
|
|
||||||
idx_view = add_view(indices, 34963)
|
|
||||||
|
|
||||||
xs = [p[0] for p in mesh.positions] or [0.0]
|
pos_view = add_view(pos_payload, 34962)
|
||||||
ys = [p[1] for p in mesh.positions] or [0.0]
|
nrm_view = add_view(nrm_payload, 34962)
|
||||||
zs = [p[2] for p in mesh.positions] or [0.0]
|
uv_view = add_view(uv_payload, 34962)
|
||||||
|
idx_view = add_view(idx_payload, 34963)
|
||||||
|
|
||||||
|
xs = [p[0] for p in positions] or [0.0]
|
||||||
|
ys = [p[1] for p in positions] or [0.0]
|
||||||
|
zs = [p[2] for p in positions] or [0.0]
|
||||||
pos_accessor = len(accessors)
|
pos_accessor = len(accessors)
|
||||||
accessors.append({"bufferView": pos_view, "componentType": 5126,
|
accessors.append({"bufferView": pos_view, "componentType": 5126,
|
||||||
"count": len(mesh.positions), "type": "VEC3",
|
"count": len(positions), "type": "VEC3",
|
||||||
"min": [min(xs), min(ys), min(zs)],
|
"min": [min(xs), min(ys), min(zs)],
|
||||||
"max": [max(xs), max(ys), max(zs)]})
|
"max": [max(xs), max(ys), max(zs)]})
|
||||||
nrm_accessor = len(accessors)
|
nrm_accessor = len(accessors)
|
||||||
accessors.append({"bufferView": nrm_view, "componentType": 5126,
|
accessors.append({"bufferView": nrm_view, "componentType": 5126,
|
||||||
"count": len(mesh.normals), "type": "VEC3"})
|
"count": len(normals), "type": "VEC3"})
|
||||||
|
uv_accessor = len(accessors)
|
||||||
|
accessors.append({"bufferView": uv_view, "componentType": 5126,
|
||||||
|
"count": len(uvs), "type": "VEC2"})
|
||||||
idx_accessor = len(accessors)
|
idx_accessor = len(accessors)
|
||||||
accessors.append({"bufferView": idx_view, "componentType": 5123,
|
accessors.append({"bufferView": idx_view, "componentType": 5123,
|
||||||
"count": len(mesh.indices), "type": "SCALAR"})
|
"count": len(indices), "type": "SCALAR"})
|
||||||
|
|
||||||
role = part.get("role", "body")
|
base_view = add_view(atlas["base_png"])
|
||||||
color = palette.get(role, PALETTE[role])
|
mr_view = add_view(atlas["metal_rough_png"])
|
||||||
metallic, roughness = PBR.get(role, PBR["body"])
|
|
||||||
materials.append({
|
|
||||||
"name": role,
|
|
||||||
"doubleSided": False,
|
|
||||||
"pbrMetallicRoughness": {
|
|
||||||
"baseColorFactor": _linear_rgba(color),
|
|
||||||
"metallicFactor": metallic,
|
|
||||||
"roughnessFactor": roughness,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
primitives.append({
|
|
||||||
"attributes": {"POSITION": pos_accessor, "NORMAL": nrm_accessor},
|
|
||||||
"indices": idx_accessor,
|
|
||||||
"material": len(materials) - 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
gltf = {
|
gltf = {
|
||||||
"asset": {"version": "2.0", "generator": "step-line model_pipeline"},
|
"asset": {"version": "2.0", "generator": "step-line model_pipeline"},
|
||||||
"scene": 0,
|
"scene": 0,
|
||||||
"scenes": [{"name": prop["name"], "nodes": [0]}],
|
"scenes": [{"name": prop["name"], "nodes": [0]}],
|
||||||
"nodes": [{"name": prop["name"], "mesh": 0}],
|
"nodes": [{"name": prop["name"], "mesh": 0}],
|
||||||
"meshes": [{"name": prop["name"], "primitives": primitives}],
|
"meshes": [{
|
||||||
"materials": materials,
|
"name": prop["name"],
|
||||||
|
"primitives": [{
|
||||||
|
"attributes": {"POSITION": pos_accessor,
|
||||||
|
"NORMAL": nrm_accessor,
|
||||||
|
"TEXCOORD_0": uv_accessor},
|
||||||
|
"indices": idx_accessor,
|
||||||
|
"material": 0,
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
"materials": [{
|
||||||
|
"name": "prop_palette",
|
||||||
|
"doubleSided": False,
|
||||||
|
"pbrMetallicRoughness": {
|
||||||
|
"baseColorTexture": {"index": 0},
|
||||||
|
"metallicRoughnessTexture": {"index": 1},
|
||||||
|
"metallicFactor": 1.0,
|
||||||
|
"roughnessFactor": 1.0,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
"textures": [
|
||||||
|
{"sampler": 0, "source": 0},
|
||||||
|
{"sampler": 0, "source": 1},
|
||||||
|
],
|
||||||
|
"samplers": [{
|
||||||
|
"magFilter": 9728, # NEAREST, so a palette cell stays a flat colour
|
||||||
|
"minFilter": 9728,
|
||||||
|
"wrapS": 33071, # CLAMP_TO_EDGE
|
||||||
|
"wrapT": 33071,
|
||||||
|
}],
|
||||||
|
"images": [
|
||||||
|
{"bufferView": base_view, "mimeType": "image/png"},
|
||||||
|
{"bufferView": mr_view, "mimeType": "image/png"},
|
||||||
|
],
|
||||||
"accessors": accessors,
|
"accessors": accessors,
|
||||||
"bufferViews": buffer_views,
|
"bufferViews": buffer_views,
|
||||||
"buffers": [{"byteLength": len(binary)}],
|
"buffers": [{"byteLength": len(binary)}],
|
||||||
@@ -646,8 +743,25 @@ def audit_glb(path: Path) -> dict:
|
|||||||
issues.append("primitive references a missing accessor")
|
issues.append("primitive references a missing accessor")
|
||||||
if triangles <= 0:
|
if triangles <= 0:
|
||||||
issues.append("no triangles")
|
issues.append("no triangles")
|
||||||
if not gltf.get("materials"):
|
materials = gltf.get("materials", [])
|
||||||
|
if not materials:
|
||||||
issues.append("no materials")
|
issues.append("no materials")
|
||||||
|
elif len(materials) != 1:
|
||||||
|
issues.append(f"expected 1 shared material, found {len(materials)}")
|
||||||
|
else:
|
||||||
|
pbr = materials[0].get("pbrMetallicRoughness", {})
|
||||||
|
if "baseColorTexture" not in pbr:
|
||||||
|
issues.append("shared material has no baseColorTexture")
|
||||||
|
if "metallicRoughnessTexture" not in pbr:
|
||||||
|
issues.append("shared material has no metallicRoughnessTexture")
|
||||||
|
if len(gltf.get("textures", [])) < 2:
|
||||||
|
issues.append("expected colour and metallic/roughness textures")
|
||||||
|
if len(gltf.get("images", [])) < 2:
|
||||||
|
issues.append("expected colour and metallic/roughness atlas images")
|
||||||
|
for mesh in gltf.get("meshes", []):
|
||||||
|
for primitive in mesh.get("primitives", []):
|
||||||
|
if "TEXCOORD_0" not in primitive.get("attributes", {}):
|
||||||
|
issues.append("primitive has no atlas UVs (TEXCOORD_0)")
|
||||||
for buffer in gltf.get("buffers", []):
|
for buffer in gltf.get("buffers", []):
|
||||||
if int(buffer.get("byteLength", 0)) > len(data):
|
if int(buffer.get("byteLength", 0)) > len(data):
|
||||||
issues.append("buffer larger than the file")
|
issues.append("buffer larger than the file")
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ SCREENS = [
|
|||||||
"kind": "ui",
|
"kind": "ui",
|
||||||
"title": "Wand screening",
|
"title": "Wand screening",
|
||||||
"source": "README screenshot gallery · PLAN The Joke Shop · src/core/wand_screener.gd",
|
"source": "README screenshot gallery · PLAN The Joke Shop · src/core/wand_screener.gd",
|
||||||
"ref": None,
|
"ref": "07-wand-screening.png",
|
||||||
"description": "Handheld wand screening panel for The Joke Shop: the alert "
|
"description": "Handheld wand screening panel for The Joke Shop: the alert "
|
||||||
"result, the items the guest is carrying, and allow/deny "
|
"result, the items the guest is carrying, and allow/deny "
|
||||||
"buttons. A phone, keys or a belt buckle can raise a false "
|
"buttons. A phone, keys or a belt buckle can raise a false "
|
||||||
@@ -452,7 +452,7 @@ SCREENS = [
|
|||||||
"kind": "ui",
|
"kind": "ui",
|
||||||
"title": "Body-camera timeline",
|
"title": "Body-camera timeline",
|
||||||
"source": "README screenshot gallery · ADR-0005 · src/game/ui/event_timeline.gd",
|
"source": "README screenshot gallery · ADR-0005 · src/game/ui/event_timeline.gd",
|
||||||
"ref": None,
|
"ref": "09-body-camera-timeline.png",
|
||||||
"description": "The shift review's body-camera timeline: a text list of every "
|
"description": "The shift review's body-camera timeline: a text list of every "
|
||||||
"recorded incident event stamped mm:ss with the person and the "
|
"recorded incident event stamped mm:ss with the person and the "
|
||||||
"action or outcome, one row highlighted, and previous/next "
|
"action or outcome, one row highlighted, and previous/next "
|
||||||
@@ -475,22 +475,25 @@ SCREENS = [
|
|||||||
"kind": "ui",
|
"kind": "ui",
|
||||||
"title": "Escalation HUD",
|
"title": "Escalation HUD",
|
||||||
"source": "README accessibility note · Hud.escalation_meter (shape meter · level)",
|
"source": "README accessibility note · Hud.escalation_meter (shape meter · level)",
|
||||||
"ref": None,
|
"ref": "08-escalation-hud.png",
|
||||||
"description": "The in-shift HUD: a shape meter plus numeric escalation level "
|
"description": "The in-shift HUD: a shape meter plus numeric escalation level "
|
||||||
"(`Escalation: Blocking [###--] L3/5`), the targeted person's "
|
"(`Escalation: Blocking [###--] L3/5`), the targeted person's "
|
||||||
"name, flashlight / door / body-camera indicators, shift clock, "
|
"name, flashlight / door / body-camera indicators, shift clock, "
|
||||||
"and the reaction log.",
|
"and the reaction log.",
|
||||||
"prompt": (
|
"prompt": (
|
||||||
"Realistic 3D game screenshot with a minimal in-game HUD overlaid: an "
|
"Third-person over-the-shoulder 3D game screenshot at night: a security "
|
||||||
"escalation meter drawn as a shape bar with the text Blocking and L3/5, "
|
"guard in the foreground facing a guest in front of a venue entrance, "
|
||||||
"a target name at the bottom centre, small indicator icons for flashlight, "
|
"with a minimal in-game HUD overlaid. Bottom left: an escalation meter "
|
||||||
"door lock and body camera, a shift clock at the top right and a short "
|
"drawn as a shape bar reading Escalation: Blocking [###--] L3/5. Centre: "
|
||||||
"reaction log at the left, over a softly blurred daytime street"),
|
"a small target name label above the guest. Top right: a shift clock, "
|
||||||
|
"reputation and crowd percentage. Left: a short stacked reaction log. "
|
||||||
|
"Bottom centre: a control hint bar. Realistic lighting, muted colours"),
|
||||||
"regions": [
|
"regions": [
|
||||||
(0.30, 0.02, 0.70, 0.10, "escalation shape meter"),
|
(0.02, 0.80, 0.34, 0.95, "escalation shape meter"),
|
||||||
(0.30, 0.86, 0.70, 0.96, "target name + hint"),
|
(0.35, 0.28, 0.75, 0.40, "target name label"),
|
||||||
(0.78, 0.02, 0.98, 0.14, "shift clock"),
|
(0.78, 0.02, 0.98, 0.16, "shift clock + crowd"),
|
||||||
(0.02, 0.60, 0.26, 0.95, "reaction log"),
|
(0.02, 0.20, 0.36, 0.65, "reaction log"),
|
||||||
|
(0.02, 0.94, 0.98, 0.99, "control hints"),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -592,6 +595,7 @@ def _generate_ai(screen, model, request_size):
|
|||||||
|
|
||||||
|
|
||||||
def generate(args):
|
def generate(args):
|
||||||
|
_load_env_file()
|
||||||
DESIGNS_DIR.mkdir(parents=True, exist_ok=True)
|
DESIGNS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
failed = []
|
failed = []
|
||||||
selected = [screen for screen in SCREENS
|
selected = [screen for screen in SCREENS
|
||||||
|
|||||||
@@ -94,14 +94,17 @@ class GlbTests(unittest.TestCase):
|
|||||||
self.assertEqual(gltf["asset"]["version"], "2.0")
|
self.assertEqual(gltf["asset"]["version"], "2.0")
|
||||||
self.assertEqual(len(gltf["scenes"]), 1)
|
self.assertEqual(len(gltf["scenes"]), 1)
|
||||||
primitives = gltf["meshes"][0]["primitives"]
|
primitives = gltf["meshes"][0]["primitives"]
|
||||||
self.assertEqual(len(primitives), len(prop("bench")["parts"]),
|
self.assertEqual(len(primitives), 1,
|
||||||
"one primitive per part")
|
"parts share one primitive (one material)")
|
||||||
self.assertEqual(len(gltf["materials"]), len(primitives))
|
self.assertEqual(len(gltf["materials"]), 1,
|
||||||
|
"the prop draws with a single material")
|
||||||
for primitive in primitives:
|
for primitive in primitives:
|
||||||
self.assertIn("POSITION", primitive["attributes"])
|
self.assertIn("POSITION", primitive["attributes"])
|
||||||
self.assertIn("NORMAL", primitive["attributes"])
|
self.assertIn("NORMAL", primitive["attributes"])
|
||||||
|
self.assertIn("TEXCOORD_0", primitive["attributes"],
|
||||||
|
"atlas UVs are required")
|
||||||
self.assertIsNotNone(primitive["indices"])
|
self.assertIsNotNone(primitive["indices"])
|
||||||
self.assertLess(primitive["material"], len(gltf["materials"]))
|
self.assertEqual(primitive["material"], 0)
|
||||||
for accessor in gltf["accessors"]:
|
for accessor in gltf["accessors"]:
|
||||||
self.assertIn(accessor["bufferView"],
|
self.assertIn(accessor["bufferView"],
|
||||||
range(len(gltf["bufferViews"])))
|
range(len(gltf["bufferViews"])))
|
||||||
@@ -114,31 +117,41 @@ class GlbTests(unittest.TestCase):
|
|||||||
second = model_pipeline.build_glb(prop("bollard"))
|
second = model_pipeline.build_glb(prop("bollard"))
|
||||||
self.assertEqual(first, second)
|
self.assertEqual(first, second)
|
||||||
|
|
||||||
def test_palette_override_changes_material_colour(self):
|
def test_palette_override_changes_the_atlas_colour(self):
|
||||||
palette = {role: list(rgb)
|
palette = {role: list(rgb)
|
||||||
for role, rgb in model_pipeline.PALETTE.items()}
|
for role, rgb in model_pipeline.PALETTE.items()}
|
||||||
palette["metal"] = [0.9, 0.1, 0.1]
|
palette["metal"] = [0.9, 0.1, 0.1]
|
||||||
gltf = parse_glb(model_pipeline.build_glb(
|
atlas = model_pipeline.atlas_for(prop("bollard"), palette)
|
||||||
prop("bollard"), palette))["gltf"]
|
self.assertIn((0.9, 0.1, 0.1), atlas["colours"].values())
|
||||||
factors = [m["pbrMetallicRoughness"]["baseColorFactor"]
|
default = model_pipeline.build_glb(prop("bollard"))
|
||||||
for m in gltf["materials"]]
|
overridden = model_pipeline.build_glb(prop("bollard"), palette)
|
||||||
self.assertIn(model_pipeline._linear_rgba([0.9, 0.1, 0.1]), factors)
|
self.assertNotEqual(default, overridden,
|
||||||
|
"the atlas texture carries the palette")
|
||||||
|
|
||||||
def test_glb_colours_are_written_in_linear_space(self):
|
def test_material_uses_both_atlas_textures(self):
|
||||||
# glTF baseColorFactor is linear; writing sRGB made props render pastel.
|
|
||||||
gltf = parse_glb(model_pipeline.build_glb(prop("bollard")))["gltf"]
|
gltf = parse_glb(model_pipeline.build_glb(prop("bollard")))["gltf"]
|
||||||
factor = gltf["materials"][0]["pbrMetallicRoughness"]["baseColorFactor"]
|
pbr = gltf["materials"][0]["pbrMetallicRoughness"]
|
||||||
srgb_metal = model_pipeline.PALETTE["metal"][0]
|
self.assertEqual(pbr["metallicFactor"], 1.0)
|
||||||
self.assertLess(factor[0], srgb_metal)
|
self.assertEqual(pbr["roughnessFactor"], 1.0)
|
||||||
self.assertAlmostEqual(factor[0],
|
self.assertIn("baseColorTexture", pbr)
|
||||||
model_pipeline.srgb_to_linear(srgb_metal),
|
self.assertIn("metallicRoughnessTexture", pbr)
|
||||||
places=4)
|
self.assertEqual(len(gltf["images"]), 2)
|
||||||
|
self.assertEqual(len(gltf["textures"]), 2)
|
||||||
|
for image in gltf["images"]:
|
||||||
|
self.assertEqual(image["mimeType"], "image/png")
|
||||||
|
self.assertEqual(gltf["samplers"][0]["magFilter"], 9728,
|
||||||
|
"nearest sampling keeps palette cells flat")
|
||||||
|
|
||||||
def test_srgb_to_linear_reference_values(self):
|
def test_atlas_is_deterministic_and_power_of_two(self):
|
||||||
self.assertAlmostEqual(model_pipeline.srgb_to_linear(0.0), 0.0)
|
first = model_pipeline.atlas_for(prop("planter_box"))
|
||||||
self.assertAlmostEqual(model_pipeline.srgb_to_linear(1.0), 1.0)
|
second = model_pipeline.atlas_for(prop("planter_box"))
|
||||||
self.assertAlmostEqual(model_pipeline.srgb_to_linear(0.5), 0.2140,
|
self.assertEqual(first["base_png"], second["base_png"])
|
||||||
places=4)
|
size = first["size"]
|
||||||
|
self.assertEqual(size & (size - 1), 0, "atlas size is a power of two")
|
||||||
|
for u, v in first["role_uv"].values():
|
||||||
|
self.assertTrue(0.0 < u < 1.0 and 0.0 < v < 1.0,
|
||||||
|
"UVs stay on the texture")
|
||||||
|
self.assertEqual(len(first["roles"]), len(set(first["roles"])))
|
||||||
|
|
||||||
def test_indices_stay_inside_their_primitive(self):
|
def test_indices_stay_inside_their_primitive(self):
|
||||||
payload = model_pipeline.build_glb(prop("planter_box"))
|
payload = model_pipeline.build_glb(prop("planter_box"))
|
||||||
|
|||||||