Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5e7b3c013 | ||
|
|
f2863abeac | ||
|
|
7f92b8861d | ||
|
|
afa1d6beec | ||
|
|
f763cff744 | ||
|
|
72b9378e69 | ||
|
|
74f14014b2 |
No files matched your search
@@ -519,10 +519,175 @@ Completed 2026-10-31 (working tree). Headless **139/139**; verified visually at
|
|||||||
|
|
||||||
Completed 2026-10-31 (working tree). Headless **149/149**; `src/test/smoke_scenario_content.gd` spawns the refusal event and the celebrity arrival through the real signal path.
|
Completed 2026-10-31 (working tree). Headless **149/149**; `src/test/smoke_scenario_content.gd` spawns the refusal event and the celebrity arrival through the real signal path.
|
||||||
|
|
||||||
|
|
||||||
- [x] Joke Shop: guest on the reservation list without ID; borrowed/mismatched ID; guest
|
- [x] Joke Shop: guest on the reservation list without ID; borrowed/mismatched ID; guest
|
||||||
refuses wand screening; late arrival during a performance.
|
refuses wand screening; late arrival during a performance.
|
||||||
- [x] Studio: filming-boundary repeat (influencer ignores a posted line); celebrity-arrival
|
- [x] Studio: filming-boundary repeat (influencer ignores a posted line); celebrity-arrival
|
||||||
crowd spike (more rubberneckers, not only one `arrive` spawn).
|
crowd spike (more rubberneckers, not only one `arrive` spawn).
|
||||||
- [x] Building: "activate body camera" and "request reason" once those verbs exist, used in
|
- [x] Building: "activate body camera" and "request reason" once those verbs exist, used in
|
||||||
at least one authored event.
|
at least one authored event.
|
||||||
|
|
||||||
|
## Distribution follow-up (host config)
|
||||||
|
|
||||||
|
Completed 2026-10-31 (working tree). Headless **153/153**.
|
||||||
|
|
||||||
|
Verified via API+host credentials (2026-10-31):
|
||||||
|
|
||||||
|
- [x] Repo is **public** (`private:false`) and account has admin rights.
|
||||||
|
- [x] Release `v0.2.1` now carries all five artifacts (portable binaries + NSIS installer +
|
||||||
|
AppImage).
|
||||||
|
- [x] **Host setting applied**: `[service] REQUIRE_SIGNIN_VIEW = false` in `/etc/gitea/app.ini`
|
||||||
|
(backup written alongside), Gitea restarted. Verified anonymously: repo page 200, API 200,
|
||||||
|
and a release asset downloads with the full 50.8 MB AppImage.
|
||||||
|
|
||||||
|
## Release pipeline follow-up
|
||||||
|
|
||||||
|
Completed 2026-10-31 (working tree). Headless **153/153**.
|
||||||
|
|
||||||
|
- [x] Fix `tools/publish_release.sh`: the `~/.git-credentials` branch never set `AUTH`,
|
||||||
|
so every tag build since v0.2.2 failed to publish.
|
||||||
|
- [x] Add a `github.token` fallback in `.gitea/workflows/build.yml` (the repo has no
|
||||||
|
`GITEA_TOKEN` secret).
|
||||||
|
- [x] Fall back to the checkout's git extraheader when no token/credentials exist (the
|
||||||
|
Gitea runner does not expose `github.token`).
|
||||||
|
- [x] Uploads now fail loudly and the release is verified to carry every artifact
|
||||||
|
(a silent HTTP 4xx used to leave an empty release with a green job).
|
||||||
|
- [x] Host disk expanded to 16G; v0.2.0 and v0.2.9 releases restored from the local artifacts.
|
||||||
|
- [x] Host maintenance via `root@192.168.88.30`: apt cache cleaned and journald capped
|
||||||
|
(`SystemMaxUse=300M` in `/etc/systemd/journald.conf.d/99-size.conf`); ~7.6G free.
|
||||||
|
- [x] Verified end to end: tagging v0.2.12 produced a release with all five artifacts
|
||||||
|
(AppImage, Linux, macOS, NSIS installer, Windows) from CI, with no repo secret.
|
||||||
|
- [x] Backfill decision: v0.2.4–v0.2.8 stay superseded; their tags and source remain, and the
|
||||||
|
release page points at the current build. No rebuilds planned.
|
||||||
|
|
||||||
|
## Open Polish
|
||||||
|
|
||||||
|
Completed 2026-10-31 (working tree). Headless **153/153**.
|
||||||
|
|
||||||
|
- [x] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting).
|
||||||
|
|
||||||
|
## Screen design references (QA tooling)
|
||||||
|
|
||||||
|
Completed 2026-10-31 (working tree). 30 Python tooling tests pass; the live judge run classed 3 captures as match and 3 as review (concept mockups are more stylised), with no confident mismatch.
|
||||||
|
|
||||||
|
- [x] `tools/screen_designs.py generate` writes one reference design per screen
|
||||||
|
(from the `docs/` descriptions) to `docs/screens/designs/`, with an offline
|
||||||
|
`--draft` wireframe mode and an OpenRouter `--ai` mockup mode.
|
||||||
|
- [x] `tools/screen_designs.py compare` checks `docs/screenshots/*.png` against the
|
||||||
|
designs (aspect ratio, size, blank-frame, and layout/palette/detail drift).
|
||||||
|
- [x] Vision-model judge for semantic screenshot comparison: `judge` sends the
|
||||||
|
capture + reference design to a vision model, classes verdicts as
|
||||||
|
match/review/diff with a confidence threshold, supports `--dry-run`, `--json`,
|
||||||
|
`--only`, and `--model`, and reads the repo `.env` for the API key.
|
||||||
|
|
||||||
|
## Tooling (when an editor workflow exists)
|
||||||
|
|
||||||
|
Completed 2026-10-31 (working tree). Headless **157/157**; the exported `.tres` venues round-trip through the resource loader.
|
||||||
|
|
||||||
|
- [x] Author venues as `.tres` resources: `tools/export_venues.gd` writes
|
||||||
|
`content/venues/<id>.tres` from the factory, and a test loads them back to check
|
||||||
|
nothing is lost (events, profiles, policy, phases, door lines, locations).
|
||||||
|
- [x] GUT decision: **not adopted.** The custom headless runner (`src/test/test_runner.gd`)
|
||||||
|
already covers 157 assertions without an editor plugin, runs in CI, and keeps the
|
||||||
|
project dependency-free; adding GUT would duplicate coverage and require the editor.
|
||||||
|
|
||||||
|
## In-Game Scene Examples
|
||||||
|
|
||||||
|
Completed 2026-10-31 (v0.3.0 working tree). `python3 tools/screen_designs.py list` reports
|
||||||
|
19 screens (8 scene, 11 UI); the Python tooling tests pass.
|
||||||
|
|
||||||
|
- [x] Extend the `tools/screen_designs.py` catalogue with the screens that exist in code but
|
||||||
|
had no reference: wand screening, Joke Shop door queue, TV Studio celebrity arrival,
|
||||||
|
crowd panic, responder arrival, body-camera timeline, escalation HUD.
|
||||||
|
- [x] Add a `list` subcommand so the catalogue is inspectable without generating images.
|
||||||
|
- [x] Cover the new entries with `tools/test_screen_designs.py` assertions.
|
||||||
|
- [x] Regenerate the deterministic draft references under `docs/screens/designs/`.
|
||||||
|
(`generate` now keeps existing references unless `--force` is passed, so a draft run
|
||||||
|
cannot replace an AI mockup; `--only` selects a subset.)
|
||||||
|
|
||||||
|
## Rendering Quality
|
||||||
|
|
||||||
|
Completed 2026-10-31 (v0.3.0 working tree). Screenshots regenerated; `screen_designs.py
|
||||||
|
compare` reports 6 good / 0 bad.
|
||||||
|
|
||||||
|
- [x] Add a shared surface-material module (`src/game/materials.gd`) with
|
||||||
|
roughness/metallic/uv-scale profiles per surface and a single environment tune-up
|
||||||
|
(tonemap, glow, fog, shadows).
|
||||||
|
- [x] Rebuild `WorldBuilder` surfaces with the material profiles and add street detail:
|
||||||
|
window bands, door trim, bollards, bench, bin, curb, planter foliage.
|
||||||
|
- [x] Per-phase environment values (fog, sky energy, glow) so night reads darker and the
|
||||||
|
marquee glows.
|
||||||
|
- [x] Improve NPC silhouette and materials: per-role clothing palette, caps/hoods/backpacks,
|
||||||
|
sleeves and hands, walking leg swing.
|
||||||
|
- [x] Cover materials/environment/avatar helpers with headless tests
|
||||||
|
(`test_materials.gd`, `test_target_cycle.gd`).
|
||||||
|
|
||||||
|
## AI 3D Model Tooling
|
||||||
|
|
||||||
|
Completed 2026-10-31 (v0.3.0 working tree). `model_pipeline.py build` + `audit` pass for all
|
||||||
|
six props; `test_generated_props.gd` proves they import as scenes and that the world places
|
||||||
|
them.
|
||||||
|
|
||||||
|
- [x] Add `tools/model_pipeline.py`: prop catalogue, deterministic low-poly GLB builder,
|
||||||
|
OpenRouter concept-image generation, palette sampling and GLB audit.
|
||||||
|
- [x] Add `tools/test_model_pipeline.py` (valid GLB structure, deterministic output, audit
|
||||||
|
failures, sRGB→linear conversion, role assignment).
|
||||||
|
- [x] Generate the prop models into `assets/models/generated/` and import them.
|
||||||
|
- [x] Load the generated props in `WorldBuilder` with a procedural fallback when the GLB is
|
||||||
|
missing.
|
||||||
|
|
||||||
|
## Controls, Movement and NPC Selection
|
||||||
|
|
||||||
|
Completed 2026-10-31 (v0.3.0 working tree). The windowed smoke now covers wheel cycling and
|
||||||
|
right-click clearing; the headless suite passes 177/177.
|
||||||
|
|
||||||
|
- [x] Record the re-evaluated control model as ADR-0011.
|
||||||
|
- [x] Rank focus candidates by view angle then distance (`NpcFocus.rank_indices`) instead of
|
||||||
|
insertion order.
|
||||||
|
- [x] Add mouse-wheel target cycling next to `Tab`, right-click clearing focus, and a `Shift`
|
||||||
|
precision walk speed.
|
||||||
|
- [x] Show the nearby-candidate count and the cycle hint in the HUD.
|
||||||
|
- [x] Cover ranking, cycling and walk speed with tests, then re-run the windowed smoke.
|
||||||
|
|
||||||
|
## Web Export Build Target
|
||||||
|
|
||||||
|
Completed 2026-10-31 (v0.3.0). Headless **181/181**; `godot --headless --export-release "Web"`
|
||||||
|
produces a 63 MB static bundle and it serves locally with HTTP 200s for html/wasm/pck.
|
||||||
|
|
||||||
|
- [x] Add a `Web` preset to `export_presets.cfg` using `variant/thread_support=false` so the
|
||||||
|
build runs on plain static hosting without COOP/COEP headers.
|
||||||
|
- [x] Export to `build/web/` and package `build/step-line-web-<version>.zip` from
|
||||||
|
`tools/build_releases.sh` (zip or Python fallback).
|
||||||
|
- [x] Publish the web zip with the desktop artifacts (`tools/publish_release.sh`).
|
||||||
|
- [x] Cover the preset, the non-threaded choice, and the release wiring in
|
||||||
|
`src/test/tests/test_web_export.gd`.
|
||||||
|
- [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).
|
||||||
@@ -15,18 +15,83 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
|
|
||||||
## Handoff (read this first)
|
## Handoff (read this first)
|
||||||
|
|
||||||
- Repo: `/run/media/system/DATA/Documents/06-games/security-game`. Godot: `~/.local/bin/godot` (4.3.stable).
|
- Repo: `/run/media/system/DATA/Documents/06-games/security-game` (also reachable at
|
||||||
Tests: `godot --headless --path . --script res://src/test/test_runner.gd` → **88/88**.
|
`/var/home/zwitschi/Documents/06-games/security-game`). Godot: `~/.local/bin/godot` (4.3.stable).
|
||||||
|
Tests: `godot --headless --path . --script res://src/test/test_runner.gd` → **177/177**.
|
||||||
|
Python tooling tests: `cd tools && python3 -m unittest test_asset_pipeline test_screen_designs test_model_pipeline` → **53**.
|
||||||
Session shell smoke: `godot --headless --path . --script res://src/test/smoke_session_shell.gd` → exit 0.
|
Session shell smoke: `godot --headless --path . --script res://src/test/smoke_session_shell.gd` → exit 0.
|
||||||
Windowed control smoke: `godot --path . --script res://src/test/smoke_focus_radial.gd` → exit 0.
|
Windowed control smoke: `godot --path . --script res://src/test/smoke_focus_radial.gd` → exit 0
|
||||||
|
(now also covers wheel cycling and right-click clearing).
|
||||||
- `main` is in sync with origin. **v0.2.2** tags the session-shell + world-follow-up commit;
|
- `main` is in sync with origin. **v0.2.2** tags the session-shell + world-follow-up commit;
|
||||||
CI builds the portable binaries, NSIS installer, and AppImage on the tag push.
|
CI builds the portable binaries, NSIS installer, and AppImage on the tag push.
|
||||||
- Next work: the remaining TODO sections — distribution host setting (`REQUIRE_SIGNIN_VIEW`),
|
- **v0.3.0 (2026-10-31, working tree):** rendering pass (`src/game/materials.gd`, phase-driven
|
||||||
open polish (`Strings.fmt` placeholders), the vision-model screenshot judge, and editor tooling.
|
environment), generated `.glb` props (`tools/model_pipeline.py`, `assets/models/generated/`),
|
||||||
|
aim-free selection with wheel cycling and Shift precision walk (ADR-0011/0012), and a **Web**
|
||||||
|
export target. `export_presets.cfg` gains a `Web` preset with `variant/thread_support=false`
|
||||||
|
(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
|
||||||
|
`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()`.
|
||||||
|
- **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
|
||||||
|
atlas, AI mockups for the new catalogue entries, image-to-mesh props). GUT was consciously not
|
||||||
|
adopted (custom runner covers it without an editor plugin).
|
||||||
- Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg.
|
- Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg.
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
|
- **v0.3.0 pass (2026-10-31, uncommitted):** rendering, generated prop models, screen
|
||||||
|
catalogue, aim-free selection. See ADR-0011 (selection) and ADR-0012 (materials/props).
|
||||||
|
- `src/game/materials.gd` (`SurfaceMaterials`) is now the single source of surface
|
||||||
|
recipes + `configure_environment()`. `GameManager.PHASE_LIGHTING` gained
|
||||||
|
`exposure/glow/fog_color/fog_density/fog_energy/contrast/saturation/ambient_energy`.
|
||||||
|
Renderer is **GL Compatibility**, so SSAO/SSIL are unavailable; glow+fog+ACES are used
|
||||||
|
instead and unsupported settings are ignored rather than gated in code.
|
||||||
|
- World: scaled brick (uv 22,10), framed windows with per-window night emission
|
||||||
|
(`i % 3 != 2` lit), stone base course, door trim/handles, curb, lane dashes (the old
|
||||||
|
loop had `Vector3(i * 0.0, …)` and stacked every dash at x=0), street lamps use the
|
||||||
|
metal profile. The two flat planters that clipped the steps were removed.
|
||||||
|
- `tools/model_pipeline.py` builds `assets/models/generated/*.glb` (bollard, bench,
|
||||||
|
trash_bin, planter_box, newspaper_box, fire_hydrant) from box/cylinder specs;
|
||||||
|
`WorldBuilder._add_prop_model()` loads them with a procedural fallback and records
|
||||||
|
`loaded_models` / `fallback_props`. **Gotcha:** glTF `baseColorFactor` is LINEAR —
|
||||||
|
writing sRGB made every prop pastel; `srgb_to_linear()` is applied in `build_glb`.
|
||||||
|
**Gotcha:** a new/changed `.glb` needs `godot --headless --path . --import` before
|
||||||
|
`ResourceLoader.exists()` / `load()` sees it (test_generated_props fails otherwise).
|
||||||
|
- `model_pipeline.py concept` (OpenRouter, reads repo `.env`) writes per-prop concept
|
||||||
|
sheets to `assets/models/concepts/`; `dominant_colors()` centre-crops and weights by
|
||||||
|
saturation (a grey studio backdrop otherwise wins), `assign_roles()` maps colours by
|
||||||
|
hue/value. Delete a concept file to fall back to the catalogue palette.
|
||||||
|
- NPC avatars: `outfit_for(type, seed)` (pure) gives shirt/trousers/hair/skin +
|
||||||
|
accessory (cap, hood, backpack, sunglasses, lanyard) and height scale; `_arms` and
|
||||||
|
`_legs` now swing (`limb_swing`) while walking. Keep `Rig/Head` named for focus.
|
||||||
|
- Selection: `NpcFocus.rank_indices` (angle then distance) — `pick_index` = first.
|
||||||
|
`GuardController.cycle_target(direction)` walks nearest-first `nearby_candidates()` and
|
||||||
|
sets `target_pinned`, which makes `_update_target()` skip the cone. `clear_target()`
|
||||||
|
sets `focus_suppress_s = 0.75` so the cone does not instantly re-acquire the person.
|
||||||
|
New input actions `walk_slow` (Shift), `cycle_target_next` / `cycle_target_prev`
|
||||||
|
(wheel down / up). Right click with nothing open clears focus (Esc still pauses).
|
||||||
|
- HUD: `nearby_label` + `set_nearby_hint(count)` show `hud.nearby`; `hud.controls` and
|
||||||
|
`hud.targeting` mention the wheel and Shift.
|
||||||
|
- `tools/screen_designs.py`: catalogue now 19 screens (added wand_screening,
|
||||||
|
joke_shop_queue, studio_arrival, crowd_panic, responder_arrival,
|
||||||
|
body_camera_timeline, escalation_hud), new `list` command, `generate --only/--force`
|
||||||
|
(existing designs are kept by default so a draft run cannot clobber AI mockups),
|
||||||
|
and `judge --dry-run` no longer raises `KeyError: 'class'`.
|
||||||
|
- Screenshot tool leak: `capture_screenshots.gd` intermittently prints
|
||||||
|
`ObjectDB instances leaked at exit`. Verified against a `git worktree` of HEAD~ — the
|
||||||
|
baseline leaks more often (3/3 vs 1/3), so it is a pre-existing quit-timing artifact,
|
||||||
|
not a regression. Normal boots are clean.
|
||||||
|
|
||||||
- Access artifacts: `src/core/access_loadout.gd` builds from event `override_access` + venue policy.
|
- Access artifacts: `src/core/access_loadout.gd` builds from event `override_access` + venue policy.
|
||||||
Spawn signal is now `(profile, intent, npc_id, event)`. Recognition is not auto-attached to every
|
Spawn signal is now `(profile, intent, npc_id, event)`. Recognition is not auto-attached to every
|
||||||
guest. `AccessVerifier.verify(..., already_screened=false)` so Joke Shop scoring uses
|
guest. `AccessVerifier.verify(..., already_screened=false)` so Joke Shop scoring uses
|
||||||
@@ -95,6 +160,24 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
`_begin_incident()` now starts one per focused NPC (`has_open_incident()` guard), verbs call
|
`_begin_incident()` now starts one per focused NPC (`has_open_incident()` guard), verbs call
|
||||||
`_ensure_incident()`, and `ShiftCoordinator._finish_shift()` closes leftovers as `unresolved`
|
`_ensure_incident()`, and `ShiftCoordinator._finish_shift()` closes leftovers as `unresolved`
|
||||||
before building the review.
|
before building the review.
|
||||||
|
- **Editor-ready venues (2026-10-31):** `tools/export_venues.gd` (a SceneTree script) saves the
|
||||||
|
three factory scenarios to `content/venues/<id>.tres` with `ResourceSaver`; the `.tres` files
|
||||||
|
round-trip through `ResourceLoader` (events, profiles, policy, shift phases, door lines,
|
||||||
|
locations). `test_venue_resources.gd` also fails if the exported files are missing or stale
|
||||||
|
relative to the factory, so re-run the export after changing `ScenarioFactory`.
|
||||||
|
- **Vision judge (2026-10-31):** `python3 tools/screen_designs.py judge` sends the capture plus
|
||||||
|
its concept mockup to a vision model (`DEFAULT_JUDGE_MODEL = google/gemini-2.5-flash-lite`,
|
||||||
|
override with `--model`; `openai/gpt-4o-mini` also works). Reads the repo `.env` via
|
||||||
|
`_load_env_file()` (never overrides real env). Verdicts are classed `match` / `review`
|
||||||
|
(mismatch below `--fail-confidence`, default 0.6) / `diff` (confident mismatch → exit 1) /
|
||||||
|
`unknown`. `--dry-run`, `--json`, `--only` supported. The prompt explicitly ignores art style,
|
||||||
|
because the references are stylised mockups; the live run gave 3 match, 3 review, 0 diff.
|
||||||
|
Python tests: `cd tools && python3 -m unittest test_asset_pipeline test_screen_designs`.
|
||||||
|
- **String formatting safety (2026-10-31):** `Strings.placeholder_count()` counts `%` specs
|
||||||
|
(ignoring `%%`), `fmt()` warns and returns the template when the arg count does not match
|
||||||
|
(instead of raising an engine error), and `validate()` lints the table for trailing `%`,
|
||||||
|
unsupported conversions, and mixed positional/sequential placeholders. Tests cover all three
|
||||||
|
plus a formatting pass over every placeholder key with type-correct dummy args.
|
||||||
- **Authored scenario content (2026-10-31):** events can now carry `hint` (a string key logged
|
- **Authored scenario content (2026-10-31):** events can now carry `hint` (a string key logged
|
||||||
when the avatar spawns), `extra_spawns` (celebrity arrivals pull in tourists/influencers via
|
when the avatar spawns), `extra_spawns` (celebrity arrivals pull in tourists/influencers via
|
||||||
`_spawn_npc` with `kind: routine`), and `refuses_screening` (copied to
|
`_spawn_npc` with `kind: routine`), and `refuses_screening` (copied to
|
||||||
@@ -141,7 +224,9 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
needs `mipmaps/generate=true`.
|
needs `mipmaps/generate=true`.
|
||||||
- **Asset gotcha:** `assets/textures/game_ready/` is *not* imported (no `.import`). Use the
|
- **Asset gotcha:** `assets/textures/game_ready/` is *not* imported (no `.import`). Use the
|
||||||
imported `assets/textures/skyline.png` path. Untracked duplicate folder should not be referenced.
|
imported `assets/textures/skyline.png` path. Untracked duplicate folder should not be referenced.
|
||||||
- Host sign-in follow-up remains deferred.
|
- Host sign-in follow-up is **done**: `REQUIRE_SIGNIN_VIEW = false` in `/etc/gitea/app.ini`
|
||||||
|
(backup `app.ini.bak-*` alongside), Gitea restarted; anonymous repo page/API return 200 and
|
||||||
|
release assets download in full. Reverse by setting it back to `true` and restarting.
|
||||||
- **Release publishing was broken since v0.2.2 (fixed 2026-10-31):** `publish_release.sh`
|
- **Release publishing was broken since v0.2.2 (fixed 2026-10-31):** `publish_release.sh`
|
||||||
only built `AUTH` in the `GITEA_TOKEN` branch; the `~/.git-credentials` branch set `CRED`
|
only built `AUTH` in the `GITEA_TOKEN` branch; the `~/.git-credentials` branch set `CRED`
|
||||||
and then used unbound `${AUTH[@]}` under `set -u`, so every API call failed and the tag
|
and then used unbound `${AUTH[@]}` under `set -u`, so every API call failed and the tag
|
||||||
|
|||||||
@@ -452,7 +452,8 @@ The following commands apply once the Godot project is scaffolded:
|
|||||||
|
|
||||||
## Export Targets
|
## Export Targets
|
||||||
|
|
||||||
Desktop is the first target: Windows, Linux, and macOS. Web export is a later option.
|
Desktop is the first target: Windows, Linux, and macOS. A Web export now ships as well
|
||||||
|
(non-threaded, static hosting); it is published with each release as a zip.
|
||||||
|
|
||||||
## Player Control, Focus, and Action Menu (next pass)
|
## Player Control, Focus, and Action Menu (next pass)
|
||||||
|
|
||||||
@@ -590,6 +591,18 @@ AI textures in `assets/textures/ai/` stay out of this pass unless a specific map
|
|||||||
- `src/test/tests/test_focus_and_radial.gd` — cone pick, wrap, cancel emits nothing, `W` is not wand.
|
- `src/test/tests/test_focus_and_radial.gd` — cone pick, wrap, cancel emits nothing, `W` is not wand.
|
||||||
- README controls table and smoke checklist.
|
- README controls table and smoke checklist.
|
||||||
|
|
||||||
|
## Rendering, Models, and Selection (v0.3.0 pass)
|
||||||
|
|
||||||
|
Decisions: [ADR-0011](docs/adr/0011-aim-free-selection-and-precision-walk.md) aim-free selection and precision walk, [ADR-0012](docs/adr/0012-surface-materials-and-generated-props.md) surface materials and generated props.
|
||||||
|
|
||||||
|
The blockout look and the aim-first selection model were the two biggest gaps left by the control pass. This pass closes them without changing the game's rules.
|
||||||
|
|
||||||
|
**Look.** Surfaces get real profiles instead of stock materials: brick is rough and finely tiled, concrete is rougher still, asphalt is smoother and damp-looking, glass is smooth and slightly metallic. The environment gains ACES tonemapping, soft glow, aerial fog and a mild grade, all driven by the same per-phase configuration that already moves the sun and sky. The facade gains framed windows that light up after dark, a stone base course, door trim and handles. Street furniture becomes real geometry: bollards, benches, bins, planters, newspaper boxes and a hydrant, built as `.glb` props by `tools/model_pipeline.py` with a procedural fallback. NPCs get per-role clothing palettes, small role accessories (cap, hood, backpack, sunglasses, lanyard), hands, shoes, and an arm/leg walk swing. The rule stays: the player judges people from clothes, props and behaviour, never from an archetype label.
|
||||||
|
|
||||||
|
**Models.** Prop meshes are deterministic primitives, so the build works offline. An image model can supply the per-prop colour palette from a concept sheet; that is the only AI influence on geometry, and it is optional. Colours are converted from sRGB to linear before they reach glTF, because glTF factors are linear.
|
||||||
|
|
||||||
|
**Selection.** The focus cone ranks heads by angle from the crosshair then distance. `Tab` and the mouse wheel cycle nearby people nearest-first and pin the choice, so aim-free play works. Right click clears focus and briefly suppresses re-acquisition. Holding `Shift` walks at half speed for close work at the door. Escape keeps its single meaning (close the top panel, otherwise pause).
|
||||||
|
|
||||||
## Design Direction
|
## Design Direction
|
||||||
|
|
||||||
The game should feel like a tense workplace simulation with occasional crisis moments. The most satisfying outcome is not overpowering an NPC; it is recognizing the situation early and resolving it calmly.
|
The game should feel like a tense workplace simulation with occasional crisis moments. The most satisfying outcome is not overpowering an NPC; it is recognizing the situation early and resolving it calmly.
|
||||||
|
|||||||
@@ -25,12 +25,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>
|
||||||
|
|
||||||
> **Latest: v0.2.13** — authored scenario content: Joke Shop access edge cases, Studio filming
|
> Downloads are public (no sign-in required) since the host's instance-wide
|
||||||
> boundaries and celebrity crowd spikes, and Building events that use the body-camera and
|
> `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31.
|
||||||
> request-reason verbs.
|
>
|
||||||
|
> **Latest: v0.3.2** — every screen in the design catalogue now has an AI reference mockup.
|
||||||
> 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
|
||||||
@@ -138,8 +140,8 @@ The architecture maps onto Godot 4 nodes, scenes, and custom resources as descri
|
|||||||
- **Navigation:** `NavigationRegion3D` and `NavigationAgent3D`
|
- **Navigation:** `NavigationRegion3D` and `NavigationAgent3D`
|
||||||
- **NPC behavior:** Custom lightweight finite state machines driven by `NpcProfile` resources
|
- **NPC behavior:** Custom lightweight finite state machines driven by `NpcProfile` resources
|
||||||
- **UI and audio:** Godot `Control` nodes, themes, audio buses, and `AudioStreamPlayer` nodes
|
- **UI and audio:** Godot `Control` nodes, themes, audio buses, and `AudioStreamPlayer` nodes
|
||||||
- **Art assets:** authored PNGs and the in-game generated set in `assets/textures/ai/game_ready/`; structural geometry stays procedural (`WorldBuilder`). The legacy `tools/generate_textures.py --ai` batch remains available.
|
- **Art assets:** authored PNGs and the in-game generated set in `assets/textures/ai/game_ready/`; street-furniture models in `assets/models/generated/` build from `tools/model_pipeline.py`; structural geometry stays procedural (`WorldBuilder`). The legacy `tools/generate_textures.py --ai` batch remains available.
|
||||||
- **Asset tooling:** `tools/asset_pipeline.py` generates role-aware assets through OpenRouter and provides crop, resize, seamless-tile, and image-audit commands, including gameplay screenshot checks.
|
- **Asset tooling:** `tools/asset_pipeline.py` generates role-aware assets through OpenRouter and provides crop, resize, seamless-tile, and image-audit commands, including gameplay screenshot checks. `tools/model_pipeline.py` builds and audits the prop `.glb` models.
|
||||||
- **Testing:** GUT (Godot Unit Test) in headless mode
|
- **Testing:** GUT (Godot Unit Test) in headless mode
|
||||||
- **Source control:** git on a Gitea host with Gitea Actions (`.gitea/workflows/build.yml`) for headless tests and desktop release builds
|
- **Source control:** git on a Gitea host with Gitea Actions (`.gitea/workflows/build.yml`) for headless tests and desktop release builds
|
||||||
- **Targets:** Windows, Linux, and macOS desktop first; web later
|
- **Targets:** Windows, Linux, and macOS desktop first; web later
|
||||||
@@ -181,6 +183,41 @@ python3 -m unittest discover -s tools -p 'test_*.py'
|
|||||||
|
|
||||||
The audit classifies measurable fit issues as `good` or `bad` based on role-specific dimensions, aspect ratio, alpha requirements, and tile-edge continuity. It lists perspective/projection as a visual review item because image semantics cannot be judged reliably by these deterministic checks.
|
The audit classifies measurable fit issues as `good` or `bad` based on role-specific dimensions, aspect ratio, alpha requirements, and tile-edge continuity. It lists perspective/projection as a visual review item because image semantics cannot be judged reliably by these deterministic checks.
|
||||||
|
|
||||||
|
### Prop models
|
||||||
|
|
||||||
|
Street furniture (bollards, benches, bins, planters, newspaper boxes, a hydrant)
|
||||||
|
is real 3D geometry generated by `tools/model_pipeline.py` and written to
|
||||||
|
`assets/models/generated/*.glb`. Godot imports each file as a `PackedScene`, and
|
||||||
|
`WorldBuilder` instantiates it with a procedural fallback when the model or its
|
||||||
|
import is missing, so the world still builds on a fresh checkout.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Print the catalogue (parts, triangle count, whether a model/concept exists)
|
||||||
|
python3 tools/model_pipeline.py list
|
||||||
|
|
||||||
|
# Rebuild the models (deterministic, offline) and validate them
|
||||||
|
python3 tools/model_pipeline.py build
|
||||||
|
python3 tools/model_pipeline.py audit
|
||||||
|
|
||||||
|
# Optional: an image model supplies the colour palette per prop
|
||||||
|
# (requires OPENROUTER_API_KEY; the repo .env is read automatically)
|
||||||
|
python3 tools/model_pipeline.py concept
|
||||||
|
python3 tools/model_pipeline.py concept --only bench --force
|
||||||
|
```
|
||||||
|
|
||||||
|
Each prop is a small list of boxes and cylinders, so `build` needs no network and
|
||||||
|
the output is byte-identical between runs. `concept` asks an image model for a
|
||||||
|
studio-style reference; `dominant_colors` and `assign_roles` then map that image's
|
||||||
|
colours onto material roles (darkest → metal, warm saturated → wood, light grey →
|
||||||
|
stone, green → foliage). glTF stores colours in linear space, so the tool converts
|
||||||
|
the sRGB palettes before writing them. Re-run `build` and `godot --headless
|
||||||
|
--path . --import` after changing a prop. See
|
||||||
|
[ADR-0012](docs/adr/0012-surface-materials-and-generated-props.md).
|
||||||
|
|
||||||
|
Surface materials share one definition in `src/game/materials.gd`: named
|
||||||
|
roughness/metallic profiles per surface, plus the environment tune-up (ACES
|
||||||
|
tonemapping, glow, fog, colour grading) that the phase lighting applies.
|
||||||
|
|
||||||
### Screen design references
|
### Screen design references
|
||||||
|
|
||||||
Screen mockups and screenshot comparison use `tools/screen_designs.py`. It turns the
|
Screen mockups and screenshot comparison use `tools/screen_designs.py`. It turns the
|
||||||
@@ -194,8 +231,16 @@ shared art-direction suffix so the frames read as one coherent game. Requests us
|
|||||||
a 3:2 landscape source and crop to the 16:9 capture dimensions.
|
a 3:2 landscape source and crop to the 16:9 capture dimensions.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Deterministic wireframes (offline, no API key)
|
# Export the built-in scenarios to editor-authorable .tres resources
|
||||||
|
godot --headless --path . --script res://tools/export_venues.gd
|
||||||
|
|
||||||
|
# List the catalogue (name, kind, whether a reference design exists)
|
||||||
|
python3 tools/screen_designs.py list
|
||||||
|
|
||||||
|
# Deterministic wireframes (offline, no API key); existing designs are kept
|
||||||
|
# unless --force is passed, so an offline run never replaces an AI mockup
|
||||||
python3 tools/screen_designs.py generate --draft
|
python3 tools/screen_designs.py generate --draft
|
||||||
|
python3 tools/screen_designs.py generate --draft --only wand --force
|
||||||
|
|
||||||
# Realistic 3D scenes and in-game screen designs via OpenRouter
|
# Realistic 3D scenes and in-game screen designs via OpenRouter
|
||||||
# (requires OPENROUTER_API_KEY)
|
# (requires OPENROUTER_API_KEY)
|
||||||
@@ -204,13 +249,33 @@ python3 tools/screen_designs.py generate
|
|||||||
# Compare docs/screenshots/*.png against the designs
|
# Compare docs/screenshots/*.png against the designs
|
||||||
python3 tools/screen_designs.py compare
|
python3 tools/screen_designs.py compare
|
||||||
python3 tools/screen_designs.py compare --json
|
python3 tools/screen_designs.py compare --json
|
||||||
|
|
||||||
|
# Ask a vision model whether each capture realises its design's screen and layout
|
||||||
|
# (requires OPENROUTER_API_KEY; the repo .env is read automatically)
|
||||||
|
python3 tools/screen_designs.py judge
|
||||||
|
python3 tools/screen_designs.py judge --model openai/gpt-4o-mini --json
|
||||||
|
python3 tools/screen_designs.py judge --only 03- # one capture
|
||||||
|
python3 tools/screen_designs.py judge --dry-run # build requests, no network
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The catalogue covers 19 screens: the six README captures plus the start menu,
|
||||||
|
pause overlay, settings, shift review, radial menu, emergency radio, wand
|
||||||
|
screening, Joke Shop queue, TV Studio arrival, crowd panic, responder arrival,
|
||||||
|
body-camera timeline, and the escalation HUD. All 19 have an AI reference in
|
||||||
|
`docs/screens/designs/`; the six 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
|
||||||
semantic judge — pixel and perceptual metrics cannot decide whether a frame's
|
semantic judge — pixel and perceptual metrics cannot decide whether a frame's
|
||||||
composition is *right*, so a vision-model pass is the documented follow-up (see
|
composition is *right*.
|
||||||
`TODO.md`). Regenerate the designs after changing screen layout, then re-run
|
|
||||||
|
`judge` covers that gap: it sends each capture together with its concept mockup to
|
||||||
|
a vision model and asks whether the capture realises the same screen and layout
|
||||||
|
intent. Art style, fidelity, fonts, and placeholder text are explicitly ignored.
|
||||||
|
Verdicts are classed as `match`, `review` (a mismatch below `--fail-confidence`,
|
||||||
|
default 0.6 — expected when a concept mockup is more stylised than the game), or
|
||||||
|
`diff` (a confident mismatch, which exits non-zero). Regenerate the designs after changing screen layout, then re-run
|
||||||
`compare` after regenerating the game screenshots with
|
`compare` after regenerating the game screenshots with
|
||||||
`src/test/capture_screenshots.gd`.
|
`src/test/capture_screenshots.gd`.
|
||||||
|
|
||||||
@@ -237,7 +302,7 @@ godot --headless --path . --script res://src/test/test_runner.gd
|
|||||||
# Save a screenshot to user:// (windowed)
|
# Save a screenshot to user:// (windowed)
|
||||||
godot --path . --script res://src/test/take_screenshot.gd
|
godot --path . --script res://src/test/take_screenshot.gd
|
||||||
|
|
||||||
# Windowed control smoke: focus circle + action wheel, saves PNGs, exits 1 on failure
|
# Windowed control smoke: focus circle, action wheel, wheel cycling + right-click clear; saves PNGs, exits 1 on failure
|
||||||
godot --path . --script res://src/test/smoke_focus_radial.gd
|
godot --path . --script res://src/test/smoke_focus_radial.gd
|
||||||
|
|
||||||
# Session-shell smoke: menu → shift → pause → settings → menu (headless or windowed)
|
# Session-shell smoke: menu → shift → pause → settings → menu (headless or windowed)
|
||||||
@@ -285,7 +350,9 @@ The unit test runner is the project's own lightweight harness (no editor plugin
|
|||||||
| C | Handheld wand screening (wand venues only) |
|
| C | Handheld wand screening (wand venues only) |
|
||||||
| A / D | Allow / deny entry during inspection or wand check |
|
| A / D | Allow / deny entry during inspection or wand check |
|
||||||
| V | Cycle venue (debug shortcut; restart via V restarts the current shift) |
|
| V | Cycle venue (debug shortcut; restart via V restarts the current shift) |
|
||||||
| Tab | Cycle target among nearby NPCs (aim-free alternative) |
|
| Tab / mouse wheel ↓ / ↑ | Cycle the target among nearby NPCs, nearest first (aim-free; the target stays selected) |
|
||||||
|
| Right click (nothing open) | Clear the focused person and return to aim-based focus |
|
||||||
|
| Shift (hold) | Precision walk at roughly half speed for close work at the door |
|
||||||
| G | Toggle flashlight (widens NPC perception at night) |
|
| G | Toggle flashlight (widens NPC perception at night) |
|
||||||
| B | Toggle the body camera; a formal warning switches it on automatically |
|
| B | Toggle the body camera; a formal warning switches it on automatically |
|
||||||
| L | Lock / unlock the restricted door (thwarts forced entry) |
|
| L | Lock / unlock the restricted door (thwarts forced entry) |
|
||||||
@@ -312,7 +379,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: **149 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)
|
- 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`)
|
||||||
@@ -329,9 +396,16 @@ Implemented so far:
|
|||||||
- NPC theater: seated guests sit on the steps with bent legs, influencers and tourists raise a camera prop while photographing, performers sway their sign, and filming visitors pan slowly — all once they reach their intent's waypoint
|
- NPC theater: seated guests sit on the steps with bent legs, influencers and tourists raise a camera prop while photographing, performers sway their sign, and filming visitors pan slowly — all once they reach their intent's waypoint
|
||||||
- Audio: a looping procedural crowd bed (filtered murmur) whose volume follows `CrowdSystem.mood`, plus radio open/close crackle. All generated at runtime, no audio assets, and safe with a dummy audio server
|
- Audio: a looping procedural crowd bed (filtered murmur) whose volume follows `CrowdSystem.mood`, plus radio open/close crackle. All generated at runtime, no audio assets, and safe with a dummy audio server
|
||||||
- Accessibility: the escalation cue shows a shape meter plus the numeric level (`Escalation: Blocking [###--] L3/5`) so it reads in grayscale, and the HUD font scale from settings is applied at boot by resizing fonts (not Control.scale, which broke the right-aligned layout)
|
- Accessibility: the escalation cue shows a shape meter plus the numeric level (`Escalation: Blocking [###--] L3/5`) so it reads in grayscale, and the HUD font scale from settings is applied at boot by resizing fonts (not Control.scale, which broke the right-aligned layout)
|
||||||
|
- Editor-ready venues: `tools/export_venues.gd` writes `content/venues/<id>.tres` from `ScenarioFactory`, and the `.tres` files load back with events, profiles, policy, phases, door lines, and locations intact (the factory stays the source of truth)
|
||||||
|
- Localization safety: `Strings.fmt` validates the placeholder count before formatting and fails safe (warns, returns the template) instead of raising an engine error, `Strings.placeholder_count` ignores `%%`, and `Strings.validate()` lints the table for trailing `%`, unsupported conversions, and mixed positional/sequential placeholders
|
||||||
- Authored scenario content: Joke Shop reservation-without-ID, borrowed/mismatched ID, a guest who refuses the wand screen, and a late arrival during a performance; Studio filming-boundary repeats and a celebrity arrival that draws a small crowd; Building events that point the player at the body camera and request-reason verbs
|
- Authored scenario content: Joke Shop reservation-without-ID, borrowed/mismatched ID, a guest who refuses the wand screen, and a late arrival during a performance; Studio filming-boundary repeats and a celebrity arrival that draws a small crowd; Building events that point the player at the body camera and request-reason verbs
|
||||||
- Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets
|
- 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
|
||||||
|
- 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
|
||||||
|
- 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
|
||||||
|
|
||||||
Not yet implemented: live 3D body-camera replay, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`).
|
Not yet implemented: live 3D body-camera replay, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`).
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +1,16 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
Godot 4.3 prototype. Release **v0.2.13** is tagged. Headless suite **149/149**, plus the session,
|
Godot 4.3 prototype. Latest release **v0.3.2** (mockup set). Headless suite **182/182** plus
|
||||||
PLAN-verb, crowd, emergency, timeline, audio, scenario-content, and windowed focus/radial smokes.
|
**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.
|
||||||
|
|
||||||
Host `REQUIRE_SIGNIN_VIEW` stays deferred.
|
All planned backlog sections are complete (see `DONE.md`). Optional future ideas live below.
|
||||||
|
|
||||||
## Distribution follow-up (host config, deferred)
|
## Future ideas (optional, not scheduled)
|
||||||
|
|
||||||
Verified via API+host credentials (2026-10-31):
|
- [ ] Live 3D body-camera replay (the timeline is text-only today).
|
||||||
|
- [ ] LOD/culling for NPCs if `MAX_LIVE_NPCS` grows past the measured 16-avatar budget.
|
||||||
- [x] Repo is **public** (`private:false`) and account has admin rights.
|
- [ ] Additional venues or seasonal events authored from the new `.tres` files.
|
||||||
- [x] Release `v0.2.1` now carries all five artifacts (portable binaries + NSIS installer +
|
- [ ] Real voice/dialogue audio (the crowd bed and radio crackle are procedural).
|
||||||
AppImage).
|
- [ ] Swap the primitive prop builder for an image-to-mesh model API behind the same
|
||||||
- [ ] **Remaining host setting**: instance-wide `[service] REQUIRE_SIGNIN_VIEW=true` makes
|
`assets/models/generated/<prop>.glb` contract.
|
||||||
anonymous browsing/downloads redirect to login. Host admin: `REQUIRE_SIGNIN_VIEW = false`
|
|
||||||
under `[service]` in `app.ini`, restart Gitea. Alternative: mirror downloads.
|
|
||||||
|
|
||||||
## Release pipeline follow-up
|
|
||||||
|
|
||||||
- [x] Fix `tools/publish_release.sh`: the `~/.git-credentials` branch never set `AUTH`,
|
|
||||||
so every tag build since v0.2.2 failed to publish.
|
|
||||||
- [x] Add a `github.token` fallback in `.gitea/workflows/build.yml` (the repo has no
|
|
||||||
`GITEA_TOKEN` secret).
|
|
||||||
- [x] Fall back to the checkout's git extraheader when no token/credentials exist (the
|
|
||||||
Gitea runner does not expose `github.token`).
|
|
||||||
- [x] Uploads now fail loudly and the release is verified to carry every artifact
|
|
||||||
(a silent HTTP 4xx used to leave an empty release with a green job).
|
|
||||||
- [x] Host disk expanded to 16G; v0.2.0 and v0.2.9 releases restored from the local artifacts.
|
|
||||||
- [x] Host maintenance via `root@192.168.88.30`: apt cache cleaned and journald capped
|
|
||||||
(`SystemMaxUse=300M` in `/etc/systemd/journald.conf.d/99-size.conf`); ~7.6G free.
|
|
||||||
- [x] Verified end to end: tagging v0.2.12 produced a release with all five artifacts
|
|
||||||
(AppImage, Linux, macOS, NSIS installer, Windows) from CI, with no repo secret.
|
|
||||||
- [ ] Optionally backfill releases for v0.2.4–v0.2.8 (their CI artifacts were not
|
|
||||||
retained; would need local rebuilds) or leave them superseded by the latest.
|
|
||||||
|
|
||||||
## Open Polish
|
|
||||||
|
|
||||||
- [ ] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting).
|
|
||||||
|
|
||||||
## Screen design references (QA tooling)
|
|
||||||
|
|
||||||
- [x] `tools/screen_designs.py generate` writes one reference design per screen
|
|
||||||
(from the `docs/` descriptions) to `docs/screens/designs/`, with an offline
|
|
||||||
`--draft` wireframe mode and an OpenRouter `--ai` mockup mode.
|
|
||||||
- [x] `tools/screen_designs.py compare` checks `docs/screenshots/*.png` against the
|
|
||||||
designs (aspect ratio, size, blank-frame, and layout/palette/detail drift).
|
|
||||||
- [ ] Vision-model judge for semantic screenshot comparison. Pixel and perceptual
|
|
||||||
metrics cannot decide whether a frame's composition is correct; a follow-up
|
|
||||||
should send the capture + reference design to a vision model and ask whether
|
|
||||||
they depict the same screen.
|
|
||||||
|
|
||||||
## Tooling (when an editor workflow exists)
|
|
||||||
|
|
||||||
- [ ] IDE/editor-based GUT integration. Until then the custom headless runner stays.
|
|
||||||
- [ ] Optional later: author venues as `.tres` resources instead of `ScenarioFactory` functions.
|
|
||||||
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 311 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
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 |
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"hud.controls": "LMB/Space wheel · A/D step · Space confirm · Esc cancel · R emergency · E inspect · C wand · Q polite · B camera · Tab cycle · G light · L lock · F debug",
|
"hud.controls": "LMB/Space wheel · A/D step · Space confirm · Esc cancel · R emergency · E inspect · C wand · Q polite · B camera · Tab/wheel cycle · Shift slow · G light · L lock · F debug",
|
||||||
"radial.raise_hand": "Stop",
|
"radial.raise_hand": "Stop",
|
||||||
"radial.eye_contact": "Look",
|
"radial.eye_contact": "Look",
|
||||||
"radial.point": "Point",
|
"radial.point": "Point",
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"hud.cooldown": "EMERGENCY RADIO COOLDOWN: %.0fs",
|
"hud.cooldown": "EMERGENCY RADIO COOLDOWN: %.0fs",
|
||||||
"hud.aim_first": "Aim at someone first.",
|
"hud.aim_first": "Aim at someone first.",
|
||||||
"hud.no_one_nearby": "No one nearby.",
|
"hud.no_one_nearby": "No one nearby.",
|
||||||
"hud.targeting": "Targeting %s (Tab to cycle).",
|
"hud.targeting": "Targeting %s (Tab or mouse wheel cycles).",
|
||||||
"hud.no_wand": "No wand at this venue.",
|
"hud.no_wand": "No wand at this venue.",
|
||||||
"hud.screen_first": "Screen this guest with the wand (C) before admitting them.",
|
"hud.screen_first": "Screen this guest with the wand (C) before admitting them.",
|
||||||
"access.granted": "Entry granted: %s",
|
"access.granted": "Entry granted: %s",
|
||||||
@@ -85,5 +85,6 @@
|
|||||||
"timeline.next": "Next ▶",
|
"timeline.next": "Next ▶",
|
||||||
"timeline.close": "Close",
|
"timeline.close": "Close",
|
||||||
"timeline.hint": "Left/Right step through events · Esc closes · no live replay yet",
|
"timeline.hint": "Left/Right step through events · Esc closes · no live replay yet",
|
||||||
"review.timeline_button": "Body camera timeline"
|
"review.timeline_button": "Body camera timeline",
|
||||||
|
"hud.nearby": "%d nearby · Tab or mouse wheel cycles targets"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,794 @@
|
|||||||
|
[gd_resource type="Resource" script_class="VenueScenario" load_steps=17 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_2u28i"]
|
||||||
|
[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_acokf"]
|
||||||
|
[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_mqiqi"]
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_i8fga"]
|
||||||
|
script = ExtResource("1_2u28i")
|
||||||
|
required_artifacts = {
|
||||||
|
1: true
|
||||||
|
}
|
||||||
|
grant_if_any_required = false
|
||||||
|
minimum_age = 0
|
||||||
|
require_id = false
|
||||||
|
wand_screening_required = false
|
||||||
|
policy_summary = "Private property. Entry requires recognition or a valid wristband."
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_c7lhe"]
|
||||||
|
script = ExtResource("2_acokf")
|
||||||
|
id = "tourist"
|
||||||
|
display_name = "Tourist"
|
||||||
|
npc_type = 0
|
||||||
|
temperament = 0.1
|
||||||
|
base_compliance = 0.9
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_0t6ar"]
|
||||||
|
script = ExtResource("2_acokf")
|
||||||
|
id = "confused_visitor"
|
||||||
|
display_name = "Confused Visitor"
|
||||||
|
npc_type = 3
|
||||||
|
temperament = 0.2
|
||||||
|
base_compliance = 0.85
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_wyv8p"]
|
||||||
|
script = ExtResource("2_acokf")
|
||||||
|
id = "delivery"
|
||||||
|
display_name = "Delivery Worker"
|
||||||
|
npc_type = 10
|
||||||
|
temperament = 0.25
|
||||||
|
base_compliance = 0.8
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["Where should I leave this?"])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_v2hc4"]
|
||||||
|
script = ExtResource("2_acokf")
|
||||||
|
id = "street_performer"
|
||||||
|
display_name = "Street Performer"
|
||||||
|
npc_type = 1
|
||||||
|
temperament = 0.45
|
||||||
|
base_compliance = 0.55
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 55,
|
||||||
|
2: 15,
|
||||||
|
3: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_lg4td"]
|
||||||
|
script = ExtResource("2_acokf")
|
||||||
|
id = "influencer"
|
||||||
|
display_name = "Influencer"
|
||||||
|
npc_type = 2
|
||||||
|
temperament = 0.5
|
||||||
|
base_compliance = 0.5
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 40,
|
||||||
|
2: 10,
|
||||||
|
3: 30,
|
||||||
|
4: 20
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 30,
|
||||||
|
2: 20,
|
||||||
|
4: 35,
|
||||||
|
8: 15
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 10,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 30,
|
||||||
|
8: 25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["This is public sidewalk, you can't stop me from filming."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_ijl2s"]
|
||||||
|
script = ExtResource("2_acokf")
|
||||||
|
id = "intoxicated"
|
||||||
|
display_name = "Intoxicated Pedestrian"
|
||||||
|
npc_type = 4
|
||||||
|
temperament = 0.6
|
||||||
|
base_compliance = 0.4
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 30,
|
||||||
|
2: 20,
|
||||||
|
3: 50
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 40,
|
||||||
|
2: 35,
|
||||||
|
3: 15,
|
||||||
|
4: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 1.8
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_eycmk"]
|
||||||
|
script = ExtResource("2_acokf")
|
||||||
|
id = "trespasser"
|
||||||
|
display_name = "Trespasser"
|
||||||
|
npc_type = 5
|
||||||
|
temperament = 0.55
|
||||||
|
base_compliance = 0.35
|
||||||
|
return_probability = 0.6
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 30,
|
||||||
|
2: 25,
|
||||||
|
3: 45
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 40,
|
||||||
|
2: 35,
|
||||||
|
6: 25
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
1: 35,
|
||||||
|
2: 25,
|
||||||
|
6: 15,
|
||||||
|
7: 10,
|
||||||
|
8: 15
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 70,
|
||||||
|
7: 30
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 50,
|
||||||
|
7: 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["I'm just here to see a friend, come on.", "I'll be inside for ten seconds."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_ggtpw"]
|
||||||
|
script = ExtResource("2_acokf")
|
||||||
|
id = "mental_crisis"
|
||||||
|
display_name = "Person in Crisis"
|
||||||
|
npc_type = 7
|
||||||
|
temperament = 0.7
|
||||||
|
base_compliance = 0.3
|
||||||
|
return_probability = 0.4
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 25,
|
||||||
|
2: 20,
|
||||||
|
3: 55
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 25,
|
||||||
|
2: 35,
|
||||||
|
3: 30,
|
||||||
|
5: 10
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 25,
|
||||||
|
1: 15,
|
||||||
|
2: 30,
|
||||||
|
5: 30
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 40,
|
||||||
|
5: 35,
|
||||||
|
7: 25
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 25,
|
||||||
|
5: 60,
|
||||||
|
7: 15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 2.4
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_oa64f"]
|
||||||
|
script = ExtResource("2_acokf")
|
||||||
|
id = "violent_threat"
|
||||||
|
display_name = "Violent Threat"
|
||||||
|
npc_type = 6
|
||||||
|
temperament = 0.95
|
||||||
|
base_compliance = 0.05
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
2: 25,
|
||||||
|
3: 45,
|
||||||
|
5: 30
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
2: 30,
|
||||||
|
5: 45,
|
||||||
|
7: 25
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
1: 20,
|
||||||
|
5: 45,
|
||||||
|
7: 35
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 15,
|
||||||
|
5: 30,
|
||||||
|
7: 55
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_7t7dh"]
|
||||||
|
script = ExtResource("2_acokf")
|
||||||
|
id = "protester"
|
||||||
|
display_name = "Protester"
|
||||||
|
npc_type = 12
|
||||||
|
temperament = 0.5
|
||||||
|
base_compliance = 0.4
|
||||||
|
return_probability = 0.25
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 25,
|
||||||
|
2: 35,
|
||||||
|
3: 25,
|
||||||
|
4: 15
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 15,
|
||||||
|
2: 40,
|
||||||
|
3: 15,
|
||||||
|
4: 25,
|
||||||
|
8: 5
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 10,
|
||||||
|
1: 25,
|
||||||
|
2: 25,
|
||||||
|
4: 25,
|
||||||
|
8: 15
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 60,
|
||||||
|
2: 20,
|
||||||
|
7: 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["I have every right to be on public sidewalk.", "You can't touch me, this is a lawful protest."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_8tj1k"]
|
||||||
|
script = ExtResource("2_acokf")
|
||||||
|
id = "authorized_staff"
|
||||||
|
display_name = "Staff Member"
|
||||||
|
npc_type = 8
|
||||||
|
temperament = 0.15
|
||||||
|
base_compliance = 1.0
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_tnpxm"]
|
||||||
|
script = ExtResource("2_acokf")
|
||||||
|
id = "authorized_guest"
|
||||||
|
display_name = "Authorized Guest"
|
||||||
|
npc_type = 9
|
||||||
|
temperament = 0.2
|
||||||
|
base_compliance = 0.95
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 92,
|
||||||
|
2: 8
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 88,
|
||||||
|
2: 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("3_mqiqi")
|
||||||
|
id = "hollywood_building"
|
||||||
|
display_name = "Hollywood Boulevard Building"
|
||||||
|
description = "Steps and entrance of a private building off Hollywood Boulevard."
|
||||||
|
access_policy = SubResource("Resource_i8fga")
|
||||||
|
equipment = Array[String](["wristband_policy", "flashlight", "radio"])
|
||||||
|
locations = {
|
||||||
|
"entrance": Vector3(0, 0, 1),
|
||||||
|
"guard_post": Vector3(0, 0, -3),
|
||||||
|
"queue_area": Vector3(0, 0, 6),
|
||||||
|
"sidewalk": Vector3(0, 0, 9),
|
||||||
|
"steps_end": Vector3(0, 0, 3)
|
||||||
|
}
|
||||||
|
npc_profiles = Array[ExtResource("2_acokf")]([SubResource("Resource_c7lhe"), SubResource("Resource_0t6ar"), SubResource("Resource_wyv8p"), SubResource("Resource_v2hc4"), SubResource("Resource_lg4td"), SubResource("Resource_ijl2s"), SubResource("Resource_eycmk"), SubResource("Resource_ggtpw"), SubResource("Resource_oa64f"), SubResource("Resource_7t7dh"), SubResource("Resource_8tj1k"), SubResource("Resource_tnpxm")])
|
||||||
|
event_pool = Array[Dictionary]([{
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "tourist_photo",
|
||||||
|
"intent": "photo",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 0,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.42,
|
||||||
|
"time_range_s": Vector2(0, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "protest_gather",
|
||||||
|
"intent": "protest",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 12,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.18,
|
||||||
|
"time_range_s": Vector2(300, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "tourist_sit",
|
||||||
|
"intent": "sit",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 0,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.26,
|
||||||
|
"time_range_s": Vector2(0, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "confused_enter",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 3,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.25,
|
||||||
|
"time_range_s": Vector2(0, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "delivery_drop",
|
||||||
|
"intent": "deliver",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 10,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.3,
|
||||||
|
"time_range_s": Vector2(0, 600)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "staff_entry",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 8,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.35,
|
||||||
|
"time_range_s": Vector2(0, 600)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "guest_entry",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 9,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.25,
|
||||||
|
"time_range_s": Vector2(0, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "performer_show",
|
||||||
|
"intent": "perform",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 1,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.24,
|
||||||
|
"time_range_s": Vector2(300, 800)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"hint": "hint.body_camera_reason",
|
||||||
|
"id": "building_filming_boundary",
|
||||||
|
"intent": "film",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 2,
|
||||||
|
"phase": 1,
|
||||||
|
"probability": 0.4,
|
||||||
|
"time_range_s": Vector2(300, 600)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"hint": "hint.ask_reason",
|
||||||
|
"id": "building_unclear_intent",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 3,
|
||||||
|
"phase": 2,
|
||||||
|
"probability": 0.4,
|
||||||
|
"time_range_s": Vector2(600, 780)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "influencer_film",
|
||||||
|
"intent": "film",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 2,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.26,
|
||||||
|
"time_range_s": Vector2(300, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "intoxicated_wander",
|
||||||
|
"intent": "wander",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 4,
|
||||||
|
"phase": 1,
|
||||||
|
"probability": 0.15,
|
||||||
|
"time_range_s": Vector2(600, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "trespass_test",
|
||||||
|
"intent": "trespass",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 5,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.16,
|
||||||
|
"time_range_s": Vector2(300, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "crisis_event",
|
||||||
|
"intent": "crisis",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 7,
|
||||||
|
"phase": 2,
|
||||||
|
"probability": 0.5,
|
||||||
|
"time_range_s": Vector2(640, 740)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "violent_emergency_event",
|
||||||
|
"intent": "threat",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 6,
|
||||||
|
"phase": 3,
|
||||||
|
"probability": 0.28,
|
||||||
|
"requires_emergency": true,
|
||||||
|
"time_range_s": Vector2(760, 880)
|
||||||
|
}])
|
||||||
|
shift_phases = {
|
||||||
|
0: {
|
||||||
|
"start_s": 0.0
|
||||||
|
},
|
||||||
|
1: {
|
||||||
|
"start_s": 300.0
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
"start_s": 600.0
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
"start_s": 700.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shift_duration_s = 900.0
|
||||||
|
policy_summary = ""
|
||||||
|
door_staff_lines = {}
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
[gd_resource type="Resource" script_class="VenueScenario" load_steps=13 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_lxsgk"]
|
||||||
|
[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_wfc6m"]
|
||||||
|
[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_8xm7a"]
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_xha77"]
|
||||||
|
script = ExtResource("1_lxsgk")
|
||||||
|
required_artifacts = {
|
||||||
|
2: true,
|
||||||
|
3: true
|
||||||
|
}
|
||||||
|
grant_if_any_required = false
|
||||||
|
minimum_age = 21
|
||||||
|
require_id = true
|
||||||
|
wand_screening_required = true
|
||||||
|
policy_summary = "Government ID and a valid ticket or reservation required. Carry items are screened."
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_8ksyk"]
|
||||||
|
script = ExtResource("2_wfc6m")
|
||||||
|
id = "tourist"
|
||||||
|
display_name = "Tourist"
|
||||||
|
npc_type = 0
|
||||||
|
temperament = 0.1
|
||||||
|
base_compliance = 0.9
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_wt1op"]
|
||||||
|
script = ExtResource("2_wfc6m")
|
||||||
|
id = "confused_visitor"
|
||||||
|
display_name = "Confused Visitor"
|
||||||
|
npc_type = 3
|
||||||
|
temperament = 0.2
|
||||||
|
base_compliance = 0.85
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_g1jr2"]
|
||||||
|
script = ExtResource("2_wfc6m")
|
||||||
|
id = "influencer"
|
||||||
|
display_name = "Influencer"
|
||||||
|
npc_type = 2
|
||||||
|
temperament = 0.5
|
||||||
|
base_compliance = 0.5
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 40,
|
||||||
|
2: 10,
|
||||||
|
3: 30,
|
||||||
|
4: 20
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 30,
|
||||||
|
2: 20,
|
||||||
|
4: 35,
|
||||||
|
8: 15
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 10,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 30,
|
||||||
|
8: 25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["This is public sidewalk, you can't stop me from filming."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_ys0b0"]
|
||||||
|
script = ExtResource("2_wfc6m")
|
||||||
|
id = "intoxicated"
|
||||||
|
display_name = "Intoxicated Pedestrian"
|
||||||
|
npc_type = 4
|
||||||
|
temperament = 0.6
|
||||||
|
base_compliance = 0.4
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 30,
|
||||||
|
2: 20,
|
||||||
|
3: 50
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 40,
|
||||||
|
2: 35,
|
||||||
|
3: 15,
|
||||||
|
4: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 1.8
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_bv0jw"]
|
||||||
|
script = ExtResource("2_wfc6m")
|
||||||
|
id = "trespasser"
|
||||||
|
display_name = "Trespasser"
|
||||||
|
npc_type = 5
|
||||||
|
temperament = 0.55
|
||||||
|
base_compliance = 0.35
|
||||||
|
return_probability = 0.6
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 30,
|
||||||
|
2: 25,
|
||||||
|
3: 45
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 40,
|
||||||
|
2: 35,
|
||||||
|
6: 25
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
1: 35,
|
||||||
|
2: 25,
|
||||||
|
6: 15,
|
||||||
|
7: 10,
|
||||||
|
8: 15
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 70,
|
||||||
|
7: 30
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 50,
|
||||||
|
7: 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["I'm just here to see a friend, come on.", "I'll be inside for ten seconds."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_7vgx3"]
|
||||||
|
script = ExtResource("2_wfc6m")
|
||||||
|
id = "violent_threat"
|
||||||
|
display_name = "Violent Threat"
|
||||||
|
npc_type = 6
|
||||||
|
temperament = 0.95
|
||||||
|
base_compliance = 0.05
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
2: 25,
|
||||||
|
3: 45,
|
||||||
|
5: 30
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
2: 30,
|
||||||
|
5: 45,
|
||||||
|
7: 25
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
1: 20,
|
||||||
|
5: 45,
|
||||||
|
7: 35
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 15,
|
||||||
|
5: 30,
|
||||||
|
7: 55
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_x43ed"]
|
||||||
|
script = ExtResource("2_wfc6m")
|
||||||
|
id = "authorized_staff"
|
||||||
|
display_name = "Staff Member"
|
||||||
|
npc_type = 8
|
||||||
|
temperament = 0.15
|
||||||
|
base_compliance = 1.0
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_07lsg"]
|
||||||
|
script = ExtResource("2_wfc6m")
|
||||||
|
id = "authorized_guest"
|
||||||
|
display_name = "Authorized Guest"
|
||||||
|
npc_type = 9
|
||||||
|
temperament = 0.2
|
||||||
|
base_compliance = 0.95
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 92,
|
||||||
|
2: 8
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 88,
|
||||||
|
2: 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("3_8xm7a")
|
||||||
|
id = "joke_shop"
|
||||||
|
display_name = "The Joke Shop"
|
||||||
|
description = "Comedy club door: IDs, reservations, and wand screening."
|
||||||
|
access_policy = SubResource("Resource_xha77")
|
||||||
|
equipment = Array[String](["id_check", "guest_list", "wand", "radio"])
|
||||||
|
locations = {
|
||||||
|
"door_area": Vector3(0, 0, 3),
|
||||||
|
"entrance": Vector3(0, 0, 1),
|
||||||
|
"guard_post": Vector3(0, 0, -3),
|
||||||
|
"queue_area": Vector3(0, 0, 9),
|
||||||
|
"sidewalk": Vector3(0, 0, 9),
|
||||||
|
"steps_end": Vector3(0, 0, 3)
|
||||||
|
}
|
||||||
|
npc_profiles = Array[ExtResource("2_wfc6m")]([SubResource("Resource_8ksyk"), SubResource("Resource_wt1op"), SubResource("Resource_g1jr2"), SubResource("Resource_ys0b0"), SubResource("Resource_bv0jw"), SubResource("Resource_7vgx3"), SubResource("Resource_x43ed"), SubResource("Resource_07lsg")])
|
||||||
|
event_pool = Array[Dictionary]([{
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "guest_valid",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 9,
|
||||||
|
"override_access": {
|
||||||
|
"has_id": true,
|
||||||
|
"has_ticket": true
|
||||||
|
},
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.3,
|
||||||
|
"time_range_s": Vector2(0, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "guest_no_ticket",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 9,
|
||||||
|
"override_access": {
|
||||||
|
"has_id": true,
|
||||||
|
"has_ticket": false
|
||||||
|
},
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.18,
|
||||||
|
"time_range_s": Vector2(0, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "guest_expired_id",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 9,
|
||||||
|
"override_access": {
|
||||||
|
"has_id": true,
|
||||||
|
"has_ticket": true,
|
||||||
|
"id_status": 1
|
||||||
|
},
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.12,
|
||||||
|
"time_range_s": Vector2(0, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "guest_no_id",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 9,
|
||||||
|
"override_access": {
|
||||||
|
"has_id": false,
|
||||||
|
"has_ticket": true
|
||||||
|
},
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.12,
|
||||||
|
"time_range_s": Vector2(0, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"hint": "hint.reservation_no_id",
|
||||||
|
"id": "joke_guest_reservation_no_id",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 9,
|
||||||
|
"override_access": {
|
||||||
|
"guest_list_status": 0,
|
||||||
|
"has_guest_list": true,
|
||||||
|
"has_id": false,
|
||||||
|
"has_ticket": false
|
||||||
|
},
|
||||||
|
"phase": 2,
|
||||||
|
"probability": 0.45,
|
||||||
|
"time_range_s": Vector2(620, 860)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"hint": "hint.borrowed_id",
|
||||||
|
"id": "joke_borrowed_id",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 9,
|
||||||
|
"override_access": {
|
||||||
|
"has_id": true,
|
||||||
|
"has_ticket": true,
|
||||||
|
"id_status": 3,
|
||||||
|
"ticket_status": 0
|
||||||
|
},
|
||||||
|
"phase": 1,
|
||||||
|
"probability": 0.4,
|
||||||
|
"time_range_s": Vector2(320, 700)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"hint": "hint.refuses_wand",
|
||||||
|
"id": "joke_refuses_wand",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 9,
|
||||||
|
"override_access": {
|
||||||
|
"has_id": true,
|
||||||
|
"has_ticket": true,
|
||||||
|
"id_status": 0,
|
||||||
|
"ticket_status": 0
|
||||||
|
},
|
||||||
|
"phase": 2,
|
||||||
|
"probability": 0.4,
|
||||||
|
"refuses_screening": true,
|
||||||
|
"time_range_s": Vector2(650, 880)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"hint": "hint.late_arrival",
|
||||||
|
"id": "joke_late_arrival",
|
||||||
|
"intent": "arrive",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 9,
|
||||||
|
"override_access": {
|
||||||
|
"has_id": true,
|
||||||
|
"has_ticket": true,
|
||||||
|
"id_status": 0,
|
||||||
|
"ticket_status": 0
|
||||||
|
},
|
||||||
|
"phase": 2,
|
||||||
|
"probability": 0.35,
|
||||||
|
"time_range_s": Vector2(700, 860)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "joke_staff",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 8,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.25,
|
||||||
|
"time_range_s": Vector2(0, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "performer_show",
|
||||||
|
"intent": "perform",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 1,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.15,
|
||||||
|
"time_range_s": Vector2(300, 800)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "influencer_film",
|
||||||
|
"intent": "film",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 2,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.15,
|
||||||
|
"time_range_s": Vector2(300, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "joke_disruptive",
|
||||||
|
"intent": "force_entry",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 4,
|
||||||
|
"phase": 2,
|
||||||
|
"probability": 0.5,
|
||||||
|
"time_range_s": Vector2(650, 830)
|
||||||
|
}])
|
||||||
|
shift_phases = {
|
||||||
|
0: {
|
||||||
|
"start_s": 0.0
|
||||||
|
},
|
||||||
|
1: {
|
||||||
|
"start_s": 300.0
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
"start_s": 600.0
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
"start_s": 700.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shift_duration_s = 900.0
|
||||||
|
policy_summary = ""
|
||||||
|
door_staff_lines = {
|
||||||
|
"id_check": ["May I see your ID, please?", "Could you show me some identification?"],
|
||||||
|
"prohibited_item": ["I can't allow that item inside. Please leave it outside.", "That item isn't permitted in the venue."],
|
||||||
|
"refusal": ["I'm sorry, I can't let you in without that.", "Without a valid ID, I can't admit you tonight."],
|
||||||
|
"ticket_check": ["Your ticket or reservation, please.", "Do you have a ticket for tonight's show?"],
|
||||||
|
"wand_check": ["Please step forward for the wand check.", "Arms out, please — just a quick screening."]
|
||||||
|
}
|
||||||
@@ -0,0 +1,810 @@
|
|||||||
|
[gd_resource type="Resource" script_class="VenueScenario" load_steps=17 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://src/core/access_policy.gd" id="1_xxhx3"]
|
||||||
|
[ext_resource type="Script" path="res://src/core/npc_profile.gd" id="2_fiebn"]
|
||||||
|
[ext_resource type="Script" path="res://src/core/venue_scenario.gd" id="3_2dvuq"]
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_blmt0"]
|
||||||
|
script = ExtResource("1_xxhx3")
|
||||||
|
required_artifacts = {
|
||||||
|
1: true,
|
||||||
|
3: true,
|
||||||
|
4: true,
|
||||||
|
5: true
|
||||||
|
}
|
||||||
|
grant_if_any_required = true
|
||||||
|
minimum_age = 0
|
||||||
|
require_id = false
|
||||||
|
wand_screening_required = false
|
||||||
|
policy_summary = "Entry requires a wristband, credential, ticket, or guest-list confirmation."
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_oddac"]
|
||||||
|
script = ExtResource("2_fiebn")
|
||||||
|
id = "tourist"
|
||||||
|
display_name = "Tourist"
|
||||||
|
npc_type = 0
|
||||||
|
temperament = 0.1
|
||||||
|
base_compliance = 0.9
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_byir2"]
|
||||||
|
script = ExtResource("2_fiebn")
|
||||||
|
id = "confused_visitor"
|
||||||
|
display_name = "Confused Visitor"
|
||||||
|
npc_type = 3
|
||||||
|
temperament = 0.2
|
||||||
|
base_compliance = 0.85
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_3tt0b"]
|
||||||
|
script = ExtResource("2_fiebn")
|
||||||
|
id = "delivery"
|
||||||
|
display_name = "Delivery Worker"
|
||||||
|
npc_type = 10
|
||||||
|
temperament = 0.25
|
||||||
|
base_compliance = 0.8
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["Where should I leave this?"])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_ihxib"]
|
||||||
|
script = ExtResource("2_fiebn")
|
||||||
|
id = "street_performer"
|
||||||
|
display_name = "Street Performer"
|
||||||
|
npc_type = 1
|
||||||
|
temperament = 0.45
|
||||||
|
base_compliance = 0.55
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 55,
|
||||||
|
2: 15,
|
||||||
|
3: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_c57iy"]
|
||||||
|
script = ExtResource("2_fiebn")
|
||||||
|
id = "influencer"
|
||||||
|
display_name = "Influencer"
|
||||||
|
npc_type = 2
|
||||||
|
temperament = 0.5
|
||||||
|
base_compliance = 0.5
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 40,
|
||||||
|
2: 10,
|
||||||
|
3: 30,
|
||||||
|
4: 20
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 30,
|
||||||
|
2: 20,
|
||||||
|
4: 35,
|
||||||
|
8: 15
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 10,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 30,
|
||||||
|
8: 25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["This is public sidewalk, you can't stop me from filming."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_bk410"]
|
||||||
|
script = ExtResource("2_fiebn")
|
||||||
|
id = "intoxicated"
|
||||||
|
display_name = "Intoxicated Pedestrian"
|
||||||
|
npc_type = 4
|
||||||
|
temperament = 0.6
|
||||||
|
base_compliance = 0.4
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 30,
|
||||||
|
2: 20,
|
||||||
|
3: 50
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 40,
|
||||||
|
2: 35,
|
||||||
|
3: 15,
|
||||||
|
4: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 1.8
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_jp52w"]
|
||||||
|
script = ExtResource("2_fiebn")
|
||||||
|
id = "trespasser"
|
||||||
|
display_name = "Trespasser"
|
||||||
|
npc_type = 5
|
||||||
|
temperament = 0.55
|
||||||
|
base_compliance = 0.35
|
||||||
|
return_probability = 0.6
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 30,
|
||||||
|
2: 25,
|
||||||
|
3: 45
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 40,
|
||||||
|
2: 35,
|
||||||
|
6: 25
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
1: 35,
|
||||||
|
2: 25,
|
||||||
|
6: 15,
|
||||||
|
7: 10,
|
||||||
|
8: 15
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 70,
|
||||||
|
7: 30
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 50,
|
||||||
|
7: 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["I'm just here to see a friend, come on.", "I'll be inside for ten seconds."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_5kplf"]
|
||||||
|
script = ExtResource("2_fiebn")
|
||||||
|
id = "mental_crisis"
|
||||||
|
display_name = "Person in Crisis"
|
||||||
|
npc_type = 7
|
||||||
|
temperament = 0.7
|
||||||
|
base_compliance = 0.3
|
||||||
|
return_probability = 0.4
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 25,
|
||||||
|
2: 20,
|
||||||
|
3: 55
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 25,
|
||||||
|
2: 35,
|
||||||
|
3: 30,
|
||||||
|
5: 10
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 25,
|
||||||
|
1: 15,
|
||||||
|
2: 30,
|
||||||
|
5: 30
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 40,
|
||||||
|
5: 35,
|
||||||
|
7: 25
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 25,
|
||||||
|
5: 60,
|
||||||
|
7: 15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 2.4
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_6bscs"]
|
||||||
|
script = ExtResource("2_fiebn")
|
||||||
|
id = "violent_threat"
|
||||||
|
display_name = "Violent Threat"
|
||||||
|
npc_type = 6
|
||||||
|
temperament = 0.95
|
||||||
|
base_compliance = 0.05
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
2: 25,
|
||||||
|
3: 45,
|
||||||
|
5: 30
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
2: 30,
|
||||||
|
5: 45,
|
||||||
|
7: 25
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
1: 20,
|
||||||
|
5: 45,
|
||||||
|
7: 35
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 15,
|
||||||
|
5: 30,
|
||||||
|
7: 55
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_n4lxh"]
|
||||||
|
script = ExtResource("2_fiebn")
|
||||||
|
id = "protester"
|
||||||
|
display_name = "Protester"
|
||||||
|
npc_type = 12
|
||||||
|
temperament = 0.5
|
||||||
|
base_compliance = 0.4
|
||||||
|
return_probability = 0.25
|
||||||
|
reactions_by_level = {
|
||||||
|
1: {
|
||||||
|
0: 25,
|
||||||
|
2: 35,
|
||||||
|
3: 25,
|
||||||
|
4: 15
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 15,
|
||||||
|
2: 40,
|
||||||
|
3: 15,
|
||||||
|
4: 25,
|
||||||
|
8: 5
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 10,
|
||||||
|
1: 25,
|
||||||
|
2: 25,
|
||||||
|
4: 25,
|
||||||
|
8: 15
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 60,
|
||||||
|
2: 20,
|
||||||
|
7: 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 80,
|
||||||
|
2: 8,
|
||||||
|
3: 12
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 70,
|
||||||
|
1: 10,
|
||||||
|
2: 12,
|
||||||
|
4: 8
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
0: 55,
|
||||||
|
1: 15,
|
||||||
|
2: 20,
|
||||||
|
4: 10
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
1: 65,
|
||||||
|
2: 15,
|
||||||
|
7: 20
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
1: 30,
|
||||||
|
5: 40,
|
||||||
|
7: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String](["I have every right to be on public sidewalk.", "You can't touch me, this is a lawful protest."])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_6st6l"]
|
||||||
|
script = ExtResource("2_fiebn")
|
||||||
|
id = "authorized_staff"
|
||||||
|
display_name = "Staff Member"
|
||||||
|
npc_type = 8
|
||||||
|
temperament = 0.15
|
||||||
|
base_compliance = 1.0
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[sub_resource type="Resource" id="Resource_nf2oq"]
|
||||||
|
script = ExtResource("2_fiebn")
|
||||||
|
id = "authorized_guest"
|
||||||
|
display_name = "Authorized Guest"
|
||||||
|
npc_type = 9
|
||||||
|
temperament = 0.2
|
||||||
|
base_compliance = 0.95
|
||||||
|
return_probability = 0.0
|
||||||
|
reactions_by_level = {}
|
||||||
|
default_reactions = {
|
||||||
|
1: {
|
||||||
|
0: 92,
|
||||||
|
2: 8
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
0: 88,
|
||||||
|
2: 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reaction_delay = 0.6
|
||||||
|
dialogue_lines = Array[String]([])
|
||||||
|
carried_items = Array[String]([])
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("3_2dvuq")
|
||||||
|
id = "tv_studio"
|
||||||
|
display_name = "Hollywood Blvd Late Night TV Studio"
|
||||||
|
description = "Crowd, celebrities, and staff around a late-night studio."
|
||||||
|
access_policy = SubResource("Resource_blmt0")
|
||||||
|
equipment = Array[String](["wristband_policy", "guest_list", "radio", "flashlight"])
|
||||||
|
locations = {
|
||||||
|
"entrance": Vector3(0, 0, 1),
|
||||||
|
"guard_post": Vector3(0, 0, -3),
|
||||||
|
"queue_area": Vector3(0, 0, 9),
|
||||||
|
"sidewalk": Vector3(0, 0, 9),
|
||||||
|
"stage_door": Vector3(6, 0, -2),
|
||||||
|
"steps_end": Vector3(0, 0, 3)
|
||||||
|
}
|
||||||
|
npc_profiles = Array[ExtResource("2_fiebn")]([SubResource("Resource_oddac"), SubResource("Resource_byir2"), SubResource("Resource_3tt0b"), SubResource("Resource_ihxib"), SubResource("Resource_c57iy"), SubResource("Resource_bk410"), SubResource("Resource_jp52w"), SubResource("Resource_5kplf"), SubResource("Resource_6bscs"), SubResource("Resource_n4lxh"), SubResource("Resource_6st6l"), SubResource("Resource_nf2oq")])
|
||||||
|
event_pool = Array[Dictionary]([{
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "audience_ticket",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 9,
|
||||||
|
"override_access": {
|
||||||
|
"has_ticket": true
|
||||||
|
},
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.35,
|
||||||
|
"time_range_s": Vector2(0, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "protest_outside",
|
||||||
|
"intent": "protest",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 12,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.18,
|
||||||
|
"time_range_s": Vector2(300, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "celebrity_arrival",
|
||||||
|
"intent": "arrive",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 9,
|
||||||
|
"override_access": {
|
||||||
|
"has_guest_list": true
|
||||||
|
},
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.2,
|
||||||
|
"time_range_s": Vector2(300, 700)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "crew_shift",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 8,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.3,
|
||||||
|
"time_range_s": Vector2(0, 700)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "tourist_photo",
|
||||||
|
"intent": "photo",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 0,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.35,
|
||||||
|
"time_range_s": Vector2(0, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "confused_enter",
|
||||||
|
"intent": "enter",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 3,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.25,
|
||||||
|
"time_range_s": Vector2(0, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "influencer_film",
|
||||||
|
"intent": "film",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 2,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.28,
|
||||||
|
"time_range_s": Vector2(0, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "trespass_blend",
|
||||||
|
"intent": "blend_in",
|
||||||
|
"kind": "routine",
|
||||||
|
"npc_type": 5,
|
||||||
|
"phase": 0,
|
||||||
|
"probability": 0.15,
|
||||||
|
"time_range_s": Vector2(300, 900)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "studio_counterfeit",
|
||||||
|
"intent": "counterfeit_credential",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 5,
|
||||||
|
"override_access": {
|
||||||
|
"credential_status": 2,
|
||||||
|
"wristband_status": 2
|
||||||
|
},
|
||||||
|
"phase": 2,
|
||||||
|
"probability": 0.55,
|
||||||
|
"time_range_s": Vector2(620, 780)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"hint": "hint.film_boundary",
|
||||||
|
"id": "studio_film_boundary",
|
||||||
|
"intent": "film_boundary",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 2,
|
||||||
|
"phase": 1,
|
||||||
|
"probability": 0.5,
|
||||||
|
"time_range_s": Vector2(330, 560)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"hint": "hint.film_boundary_repeat",
|
||||||
|
"id": "studio_film_boundary_repeat",
|
||||||
|
"intent": "film_boundary",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 2,
|
||||||
|
"phase": 2,
|
||||||
|
"probability": 0.45,
|
||||||
|
"time_range_s": Vector2(640, 830)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"extra_spawns": [{
|
||||||
|
"intent": "photo",
|
||||||
|
"npc_type": 0
|
||||||
|
}, {
|
||||||
|
"intent": "photo",
|
||||||
|
"npc_type": 0
|
||||||
|
}, {
|
||||||
|
"intent": "film",
|
||||||
|
"npc_type": 2
|
||||||
|
}],
|
||||||
|
"hint": "hint.celebrity_arrival",
|
||||||
|
"id": "studio_celebrity_arrival",
|
||||||
|
"intent": "arrive",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 9,
|
||||||
|
"override_access": {
|
||||||
|
"guest_list_status": 0,
|
||||||
|
"has_guest_list": true
|
||||||
|
},
|
||||||
|
"phase": 2,
|
||||||
|
"probability": 0.5,
|
||||||
|
"time_range_s": Vector2(620, 760)
|
||||||
|
}, {
|
||||||
|
"base_weight": 1.0,
|
||||||
|
"id": "studio_follow_through",
|
||||||
|
"intent": "follow_authorized",
|
||||||
|
"kind": "scripted",
|
||||||
|
"npc_type": 5,
|
||||||
|
"override_access": {
|
||||||
|
"has_credential": false,
|
||||||
|
"has_guest_list": false,
|
||||||
|
"has_ticket": false,
|
||||||
|
"has_wristband": false
|
||||||
|
},
|
||||||
|
"phase": 2,
|
||||||
|
"probability": 0.45,
|
||||||
|
"time_range_s": Vector2(700, 860)
|
||||||
|
}])
|
||||||
|
shift_phases = {
|
||||||
|
0: {
|
||||||
|
"start_s": 0.0
|
||||||
|
},
|
||||||
|
1: {
|
||||||
|
"start_s": 300.0
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
"start_s": 600.0
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
"start_s": 700.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shift_duration_s = 900.0
|
||||||
|
policy_summary = ""
|
||||||
|
door_staff_lines = {}
|
||||||
@@ -84,6 +84,8 @@ Artifacts are written to `build/` (all exports embed the PCK — single files):
|
|||||||
- `build/step-line-linux-<version>.x86_64`
|
- `build/step-line-linux-<version>.x86_64`
|
||||||
- `build/step-line-windows-<version>.exe`
|
- `build/step-line-windows-<version>.exe`
|
||||||
- `build/step-line-macos-<version>.zip`
|
- `build/step-line-macos-<version>.zip`
|
||||||
|
- `build/step-line-web-<version>.zip` — browser build (non-threaded Godot web
|
||||||
|
template; unzip and serve the folder, no COOP/COEP headers required)
|
||||||
|
|
||||||
When run in CI (tags/dispatch), the build job also packages:
|
When run in CI (tags/dispatch), the build job also packages:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# ADR-0011: Aim-free target selection, precision walk, and focus clearing
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-10-31
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-0007 and ADR-0008 split input into locomotion, look, and a focus cone. A pass over the resulting controls found four problems in play:
|
||||||
|
|
||||||
|
1. **The cone ignored what the player sees.** `NpcFocus.pick_index` returned the first head inside the cone in group order, not the one nearest the crosshair. Two people in the cone gave an arbitrary pick.
|
||||||
|
2. **`Tab` cycled in spawn order.** `GameManager._cycle_nearby_target` walked the `npcs` array, so "next" meant "next spawned", not "next nearest".
|
||||||
|
3. **A cycled target was dropped within a few frames.** The cone re-evaluated every physics frame and cleared a target that was not under the crosshair after the 8-frame miss grace. Aim-free selection therefore only worked while the player also aimed.
|
||||||
|
4. **There was no way to dismiss someone.** `Esc` pauses the shift and right click only cancelled modals.
|
||||||
|
|
||||||
|
The movement pass also left one gap at the door: the only walking speed is 3.2 m/s, which overshoots when the player is trying to stand at a register or read an artifact at conversation distance.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Rank the cone by angle, then distance.** `NpcFocus.rank_indices` scores every head by its angle from the camera forward vector and breaks ties by distance; `pick_index` is the first ranked entry. The cone therefore picks the person closest to the crosshair, which is what the player is looking at.
|
||||||
|
|
||||||
|
**Make aim-free selection authoritative.** `GuardController.cycle_target(direction)` walks `nearby_candidates()` — everyone within 12 m, nearest first — starting from the current target and wrapping in both directions. The chosen avatar is *pinned*: while a pinned target is alive the cone does not re-evaluate, so `Tab` and the mouse wheel produce a stable selection the player can then act on.
|
||||||
|
|
||||||
|
**Bind the mouse wheel to the same ring.** `cycle_target_next` (wheel down) and `cycle_target_prev` (wheel up) are new input actions handled in `GameManager._handle_mouse_button`. `Tab` remains the keyboard path. The HUD prints how many people are in the ring (`hud.nearby`), so the player knows the control exists before they need it.
|
||||||
|
|
||||||
|
**Right click clears focus, with a short grace.** With no modal open, right click clears the target and suppresses cone acquisition for `FOCUS_SUPPRESS_S = 0.75 s`. Without the grace the cone re-acquired the same person on the next frame, which made the control feel broken. Any explicit pick (cone or cycle) cancels the suppression.
|
||||||
|
|
||||||
|
**Add a precision walk.** Holding `Shift` (`walk_slow`) multiplies the walk speed by `SLOW_WALK_FACTOR = 0.45`. This is a fine-positioning aid, not a sprint: PLAN.md deliberately excludes sprint and crouch, and this does not add a faster move.
|
||||||
|
|
||||||
|
Escape keeps its current meaning (close the top modal, otherwise pause). Selection is not overloaded onto the same key.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- The focused person is predictable: the one nearest the crosshair, or the one the player explicitly cycled to.
|
||||||
|
- Aim-free play works: cycle, act, cycle again, without fighting the cone.
|
||||||
|
- Right click has an unambiguous "never mind" meaning while remaining a modal cancel.
|
||||||
|
- Standing on a mark while reading an artifact is possible.
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
- Two more input actions and one more piece of guard state (`target_pinned`, `focus_suppress_s`).
|
||||||
|
- A cycled target stays selected until the player clears it, acts, or the avatar is freed; it does not expire on its own.
|
||||||
|
- The 0.75 s suppression is a feel constant that may need tuning against play testing.
|
||||||
|
|
||||||
|
## Follow-up
|
||||||
|
|
||||||
|
- The action wheel (ADR-0009) and inspection panels still take `A`/`D`; the wheel owns those keys while it is open, and the guard is frozen then, so no conflict is observable.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# ADR-0012: Surface material profiles and generated prop models
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-10-31
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The procedural street was built from `BoxMesh` primitives with `StandardMaterial3D` defaults: albedo colour (or a texture) and everything else stock. Every surface therefore shared one roughness, street furniture was a set of scaled boxes, and the sky was a flat `ProceduralSkyMaterial` with no tonemapping, glow, or fog. Captures looked like a blockout: brick tiles were metres wide, the sidewalk blew out to white, the facade had no windows, and night mode only switched the lamp bulbs and the marquee.
|
||||||
|
|
||||||
|
Two constraints shaped the fix:
|
||||||
|
|
||||||
|
- The project renders with the **GL Compatibility** backend so it stays small and portable, which rules out features that only exist in Forward+ (`SSAO`, `SSIL`).
|
||||||
|
- The repo must build offline in CI, so any asset step has to work without a network or an API key.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**One material module.** `src/game/materials.gd` (`SurfaceMaterials`) owns a named profile table (`brick`, `concrete`, `asphalt`, `awning`, `stone`, `metal`, `painted_metal`, `wood`, `cloth`, `skin`, `glass`, `rubber`) with roughness/metallic pairs, plus the recipes the world and avatars use (`brick()`, `concrete()`, `asphalt()`, `awning()`, `flat()`, `emissive()`, `glass()`, `window_pane()`). The world builder, the NPC avatars, and the prop fallbacks all call it, so a surface has one definition.
|
||||||
|
|
||||||
|
**One environment tune-up.** `SurfaceMaterials.configure_environment()` applies ACES tonemapping, soft glow (for the marquee, lamp bulbs, and lit windows), aerial fog, and a mild contrast/saturation lift. `GameManager.PHASE_LIGHTING` now carries the per-phase values (`exposure`, `glow`, `fog_color`, `fog_density`, `fog_energy`, `contrast`, `saturation`, `ambient_energy`) next to the existing sun/sky entries, so a phase change still happens in one place. Features unavailable in Compatibility are ignored by the driver rather than gated in code.
|
||||||
|
|
||||||
|
**Generated prop models.** `tools/model_pipeline.py` describes street furniture (bollard, bench, bin, planter, newspaper box, hydrant) as boxes and cylinders and writes binary glTF (`.glb`) into `assets/models/generated/`. Godot imports each file as a `PackedScene`, and `WorldBuilder._add_prop_model()` instantiates it with a procedural fallback when the file or its import is missing. The build is deterministic and needs no network.
|
||||||
|
|
||||||
|
**AI only supplies colour.** `model_pipeline.py concept` asks an image model (OpenRouter, same backend as the texture tools) for one studio-style concept per prop. `dominant_colors()` reads the centre of that image and ranks colours by frequency weighted by saturation (a grey backdrop otherwise wins), and `assign_roles()` maps the result onto material roles by hue and value: darkest tone → metal, warm saturated tone → wood, light desaturated tone → stone, green tone → foliage. Colours are converted from sRGB to **linear** before they are written into glTF `baseColorFactor`, because the factor is linear space and writing sRGB made every prop render pastel.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- Surfaces read as materials: brick has fine, correctly scaled tiling, asphalt is smoother than brick, glass is smooth and slightly metallic, and the night sky carries warm lit windows.
|
||||||
|
- Street furniture is real geometry with per-part materials instead of scaled boxes, and it costs ~700 triangles for the whole set.
|
||||||
|
- The prop pipeline works with no key and no network; the concept step is optional and only changes colours.
|
||||||
|
- Tests can assert material profiles, environment values, and GLB structure without rendering.
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
- The generated models are low-poly primitives, not sculpted assets; they are an improvement in silhouette, not photoreal props.
|
||||||
|
- Two colour spaces now exist in the tooling. `model_pipeline.srgb_to_linear` must be used for every colour written into glTF, which is easy to forget.
|
||||||
|
- Concept images are per-prop inputs that have to be regenerated (and reviewed) when the art direction changes; a poor concept now produces a poor palette.
|
||||||
|
- Compatibility rendering means no ambient occlusion, so contact shadows rely on the directional light only.
|
||||||
|
|
||||||
|
## Follow-up
|
||||||
|
|
||||||
|
- A 3D-model API (image-to-mesh) could replace the primitive builder behind the same `assets/models/generated/<prop>.glb` contract without touching `WorldBuilder`.
|
||||||
|
- Texture atlases for the props would let them share one material instead of one per part.
|
||||||
@@ -76,6 +76,9 @@ The player interacts with NPCs, venue entrances, access artifacts, equipment, st
|
|||||||
- `ReputationEvaluator` converts incidents and shift outcomes into performance feedback.
|
- `ReputationEvaluator` converts incidents and shift outcomes into performance feedback.
|
||||||
- `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, glass/emissive recipes, and the environment tune-up (tonemapping, glow, fog, grading) that the phase lighting applies.
|
||||||
|
- `WorldBuilder` assembles the procedural venue and instantiates the generated prop models from `assets/models/generated/` with a procedural fallback.
|
||||||
|
- `tools/model_pipeline.py` builds and audits those prop models; `tools/screen_designs.py` generates screen references and judges captures.
|
||||||
|
|
||||||
## 6. Runtime View
|
## 6. Runtime View
|
||||||
|
|
||||||
@@ -156,6 +159,8 @@ Architecture decisions are recorded as ADRs:
|
|||||||
- [ADR-0008: Cone focus and focus circle](adr/0008-cone-focus-and-head-ring.md)
|
- [ADR-0008: Cone focus and focus circle](adr/0008-cone-focus-and-head-ring.md)
|
||||||
- [ADR-0009: Radial action menu](adr/0009-radial-action-menu.md)
|
- [ADR-0009: Radial action menu](adr/0009-radial-action-menu.md)
|
||||||
- [ADR-0010: Procedural NPC presentation](adr/0010-procedural-npc-presentation.md)
|
- [ADR-0010: Procedural NPC presentation](adr/0010-procedural-npc-presentation.md)
|
||||||
|
- [ADR-0011: Aim-free selection and precision walk](adr/0011-aim-free-selection-and-precision-walk.md)
|
||||||
|
- [ADR-0012: Surface materials and generated props](adr/0012-surface-materials-and-generated-props.md)
|
||||||
|
|
||||||
## 10. Quality Requirements
|
## 10. Quality Requirements
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1007 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 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.4 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 986 KiB After Width: | Height: | Size: 838 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.3 MiB |
@@ -103,3 +103,39 @@ application/export_angle=0
|
|||||||
application/target_os_version=""
|
application/target_os_version=""
|
||||||
application/signature_flags=0
|
application/signature_flags=0
|
||||||
application/show_bundle_name=1
|
application/show_bundle_name=1
|
||||||
|
|
||||||
|
[preset.3]
|
||||||
|
|
||||||
|
name="Web"
|
||||||
|
platform="Web"
|
||||||
|
runnable=true
|
||||||
|
advanced_options=false
|
||||||
|
dedicated_server=false
|
||||||
|
custom_features=""
|
||||||
|
export_filter="all_resources"
|
||||||
|
include_filter=""
|
||||||
|
exclude_filter=""
|
||||||
|
export_path="build/web/index.html"
|
||||||
|
patches=PackedStringArray()
|
||||||
|
encryption_include_filters=""
|
||||||
|
encryption_exclude_filters=""
|
||||||
|
seed=0
|
||||||
|
encrypt_pck=false
|
||||||
|
encrypt_directory=false
|
||||||
|
script_export_mode=2
|
||||||
|
|
||||||
|
[preset.3.options]
|
||||||
|
|
||||||
|
custom_template/debug=""
|
||||||
|
custom_template/release=""
|
||||||
|
variant/extensions_support=false
|
||||||
|
variant/thread_support=false
|
||||||
|
vram_texture_compression/for_desktop=true
|
||||||
|
vram_texture_compression/for_mobile=false
|
||||||
|
html/export_icon=true
|
||||||
|
html/custom_html_shell=""
|
||||||
|
html/head_include=""
|
||||||
|
html/canvas_resize_policy=2
|
||||||
|
html/focus_canvas_on_start=true
|
||||||
|
html/experimental_virtual_keyboard=false
|
||||||
|
progressive_web_app/enabled=false
|
||||||
@@ -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.2.13"
|
config/version="0.3.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")
|
||||||
|
|
||||||
@@ -110,6 +110,21 @@ wand={
|
|||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":67,"key_label":0,"unicode":99,"location":0,"echo":false,"script":null)
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":67,"key_label":0,"unicode":99,"location":0,"echo":false,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
walk_slow={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194325,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
cycle_target_next={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":5,"canceled":false,"pressed":false,"double_click":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
cycle_target_prev={
|
||||||
|
"deadzone": 0.5,
|
||||||
|
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":4,"canceled":false,"pressed":false,"double_click":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
print_debug={
|
print_debug={
|
||||||
"deadzone": 0.5,
|
"deadzone": 0.5,
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":70,"key_label":0,"unicode":102,"location":0,"echo":false,"script":null)
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":70,"key_label":0,"unicode":102,"location":0,"echo":false,"script":null)
|
||||||
|
|||||||
@@ -34,12 +34,69 @@ static func lookup(key: String) -> String:
|
|||||||
_ensure_loaded()
|
_ensure_loaded()
|
||||||
return TABLE.get(key, key)
|
return TABLE.get(key, key)
|
||||||
|
|
||||||
|
## Number of `%` conversion placeholders in a template, ignoring `%%` escapes.
|
||||||
|
static func placeholder_count(template: String) -> int:
|
||||||
|
var count := 0
|
||||||
|
var i := 0
|
||||||
|
while i < template.length():
|
||||||
|
if template[i] == "%":
|
||||||
|
if i + 1 < template.length() and template[i + 1] == "%":
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
count += 1
|
||||||
|
i += 1
|
||||||
|
return count
|
||||||
|
|
||||||
## Format a string by key with positional arguments.
|
## Format a string by key with positional arguments.
|
||||||
|
##
|
||||||
|
## GDScript's `%` binds arguments by order, so a key whose placeholder count drifts
|
||||||
|
## from its call site would push an engine error. Validate first and fail safe: log
|
||||||
|
## a warning and return the raw template instead of crashing.
|
||||||
static func fmt(key: String, args: Array) -> String:
|
static func fmt(key: String, args: Array) -> String:
|
||||||
var template := lookup(key)
|
var template := lookup(key)
|
||||||
if template == key:
|
if template == key:
|
||||||
return template
|
return template
|
||||||
|
var needed := placeholder_count(template)
|
||||||
|
if needed != args.size():
|
||||||
|
push_warning("Strings.fmt(%s): template needs %d args, got %d" % [key, needed, args.size()])
|
||||||
|
return template
|
||||||
return template % args
|
return template % args
|
||||||
|
|
||||||
|
## Lint the loaded table for formatting hazards. Returns an array of problems.
|
||||||
|
## Checks: trailing `%`, invalid conversions, and mixed positional (`%1$s`) and
|
||||||
|
## sequential placeholders in one template.
|
||||||
|
static func validate() -> Array[String]:
|
||||||
|
_ensure_loaded()
|
||||||
|
var problems: Array[String] = []
|
||||||
|
for key in TABLE.keys():
|
||||||
|
var template := str(TABLE[key])
|
||||||
|
var positional := template.contains("$")
|
||||||
|
var sequential := 0
|
||||||
|
var i := 0
|
||||||
|
while i < template.length():
|
||||||
|
if template[i] != "%":
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if i + 1 >= template.length():
|
||||||
|
problems.append("%s: trailing %% escape" % key)
|
||||||
|
break
|
||||||
|
var next := template[i + 1]
|
||||||
|
if next == "%":
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
var j := i + 1
|
||||||
|
while j < template.length() and template[j] in "0123456789.$":
|
||||||
|
if template[j] == "$":
|
||||||
|
positional = true
|
||||||
|
j += 1
|
||||||
|
if j >= template.length() or not (template[j] in "sdfxXv"):
|
||||||
|
problems.append("%s: unsupported conversion near '%%%s'" % [key, template.substr(i + 1, 3)])
|
||||||
|
elif not positional:
|
||||||
|
sequential += 1
|
||||||
|
i = j + 1
|
||||||
|
if positional and sequential > 0:
|
||||||
|
problems.append("%s: mixes positional (%%1$s) and sequential (%%s) placeholders" % key)
|
||||||
|
return problems
|
||||||
|
|
||||||
func _init() -> void:
|
func _init() -> void:
|
||||||
push_error("Strings is a static utility class; use Strings.lookup / Strings.fmt.")
|
push_error("Strings is a static utility class; use Strings.lookup / Strings.fmt.")
|
||||||
@@ -16,6 +16,7 @@ const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
|
|||||||
const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd")
|
const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd")
|
||||||
const VenueScenario := preload("res://src/core/venue_scenario.gd")
|
const VenueScenario := preload("res://src/core/venue_scenario.gd")
|
||||||
const WorldBuilder := preload("res://src/game/world_builder.gd")
|
const WorldBuilder := preload("res://src/game/world_builder.gd")
|
||||||
|
const Materials := preload("res://src/game/materials.gd")
|
||||||
const GuardController := preload("res://src/game/guard_controller.gd")
|
const GuardController := preload("res://src/game/guard_controller.gd")
|
||||||
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
|
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
|
||||||
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||||
@@ -44,24 +45,34 @@ var _last_phase: int = -1
|
|||||||
## structure — phase-based lighting). Night phases switch street lamps on.
|
## structure — phase-based lighting). Night phases switch street lamps on.
|
||||||
const PHASE_LIGHTING := {
|
const PHASE_LIGHTING := {
|
||||||
Rules.Phase.EARLY: {
|
Rules.Phase.EARLY: {
|
||||||
"sun_energy": 1.2, "sun_angle": -50.0, "sun_color": Color(1.0, 0.98, 0.92),
|
"sun_energy": 1.05, "sun_angle": -50.0, "sun_color": Color(1.0, 0.97, 0.90),
|
||||||
"sky_top": Color(0.32, 0.55, 0.95), "sky_horizon": Color(0.82, 0.88, 0.95),
|
"sky_top": Color(0.32, 0.55, 0.95), "sky_horizon": Color(0.82, 0.88, 0.95),
|
||||||
"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.88, "glow": 0.35, "fog_color": Color(0.80, 0.85, 0.92),
|
||||||
|
"fog_density": 0.0028, "fog_energy": 0.75, "contrast": 1.04, "saturation": 1.06,
|
||||||
|
"ambient_energy": 0.45,
|
||||||
},
|
},
|
||||||
Rules.Phase.MIDDAY: {
|
Rules.Phase.MIDDAY: {
|
||||||
"sun_energy": 1.5, "sun_angle": -62.0, "sun_color": Color(1.0, 1.0, 0.98),
|
"sun_energy": 1.3, "sun_angle": -62.0, "sun_color": Color(1.0, 0.99, 0.96),
|
||||||
"sky_top": Color(0.3, 0.52, 0.95), "sky_horizon": Color(0.85, 0.9, 0.96),
|
"sky_top": Color(0.3, 0.52, 0.95), "sky_horizon": Color(0.85, 0.9, 0.96),
|
||||||
"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.85, "glow": 0.3, "fog_color": Color(0.84, 0.88, 0.94),
|
||||||
|
"fog_density": 0.0022, "fog_energy": 0.8, "contrast": 1.05, "saturation": 1.05,
|
||||||
|
"ambient_energy": 0.45,
|
||||||
},
|
},
|
||||||
Rules.Phase.EVENING: {
|
Rules.Phase.EVENING: {
|
||||||
"sun_energy": 0.7, "sun_angle": -16.0, "sun_color": Color(1.0, 0.72, 0.5),
|
"sun_energy": 0.7, "sun_angle": -16.0, "sun_color": Color(1.0, 0.72, 0.5),
|
||||||
"sky_top": Color(0.62, 0.32, 0.35), "sky_horizon": Color(1.0, 0.68, 0.42),
|
"sky_top": Color(0.62, 0.32, 0.35), "sky_horizon": Color(1.0, 0.68, 0.42),
|
||||||
"sky_ground": Color(0.3, 0.24, 0.3), "ambient": Color(0.55, 0.45, 0.5), "night": true,
|
"sky_ground": Color(0.3, 0.24, 0.3), "ambient": Color(0.55, 0.45, 0.5), "night": true,
|
||||||
|
"exposure": 1.05, "glow": 0.7, "fog_color": Color(0.95, 0.68, 0.48),
|
||||||
|
"fog_density": 0.0045, "fog_energy": 0.6, "contrast": 1.08, "saturation": 1.12,
|
||||||
},
|
},
|
||||||
Rules.Phase.LATE: {
|
Rules.Phase.LATE: {
|
||||||
"sun_energy": 0.05, "sun_angle": -8.0, "sun_color": Color(0.3, 0.35, 0.5),
|
"sun_energy": 0.05, "sun_angle": -8.0, "sun_color": Color(0.3, 0.35, 0.5),
|
||||||
"sky_top": Color(0.02, 0.03, 0.08), "sky_horizon": Color(0.1, 0.12, 0.2),
|
"sky_top": Color(0.02, 0.03, 0.08), "sky_horizon": Color(0.1, 0.12, 0.2),
|
||||||
"sky_ground": Color(0.05, 0.05, 0.08), "ambient": Color(0.08, 0.09, 0.16), "night": true,
|
"sky_ground": Color(0.05, 0.05, 0.08), "ambient": Color(0.08, 0.09, 0.16), "night": true,
|
||||||
|
"exposure": 1.25, "glow": 0.95, "fog_color": Color(0.10, 0.13, 0.24),
|
||||||
|
"fog_density": 0.0055, "fog_energy": 0.5, "contrast": 1.1, "saturation": 1.0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
var hud: Hud = null
|
var hud: Hud = null
|
||||||
@@ -103,12 +114,12 @@ var _venue_index := 0
|
|||||||
## RNG for venue door-staff lines so repeated interactions vary.
|
## RNG for venue door-staff lines so repeated interactions vary.
|
||||||
var _door_rng := RandomNumberGenerator.new()
|
var _door_rng := RandomNumberGenerator.new()
|
||||||
|
|
||||||
## Aim-free targeting: cycle through nearby NPCs with Tab (accessibility).
|
## Aim-free targeting lives on the guard (`GuardController.nearby_candidates`);
|
||||||
const NEARBY_RADIUS := 12.0
|
## this file only reacts to the signals it emits and logs the selection.
|
||||||
|
|
||||||
## Upper bound on live avatars. Routine traffic is dropped at the cap; scripted
|
## Upper bound on live avatars. Routine traffic is dropped at the cap; scripted
|
||||||
## events evict the oldest unfocused NPC so authored moments still spawn.
|
## events evict the oldest unfocused NPC so authored moments still spawn.
|
||||||
const MAX_LIVE_NPCS := 16
|
const MAX_LIVE_NPCS := 16
|
||||||
var _nearby_index := -1
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
_door_rng.randomize()
|
_door_rng.randomize()
|
||||||
@@ -219,7 +230,6 @@ func _reset_shift_state() -> void:
|
|||||||
current_incident_npc_id = ""
|
current_incident_npc_id = ""
|
||||||
targeted_npc = null
|
targeted_npc = null
|
||||||
_focused_avatar = null
|
_focused_avatar = null
|
||||||
_nearby_index = -1
|
|
||||||
inspection_open = false
|
inspection_open = false
|
||||||
wand_open = false
|
wand_open = false
|
||||||
_wand_cooldown = 0.0
|
_wand_cooldown = 0.0
|
||||||
@@ -429,8 +439,9 @@ func _ensure_lighting() -> void:
|
|||||||
sky.sky_material = _sky
|
sky.sky_material = _sky
|
||||||
env.sky = sky
|
env.sky = sky
|
||||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
|
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
|
||||||
env.ambient_light_energy = 0.5
|
# Tonemap, glow, fog and colour grading live in SurfaceMaterials so the
|
||||||
env.reflected_light_source = Environment.REFLECTION_SOURCE_SKY
|
# world builder and the environment tune-up stay in one place.
|
||||||
|
Materials.configure_environment(env, PHASE_LIGHTING[Rules.Phase.EARLY])
|
||||||
_env_node.environment = env
|
_env_node.environment = env
|
||||||
scene_root.add_child(_env_node)
|
scene_root.add_child(_env_node)
|
||||||
|
|
||||||
@@ -448,6 +459,8 @@ func _apply_phase_lighting() -> void:
|
|||||||
_sky.sky_horizon_color = cfg.get("sky_horizon", Color.WHITE)
|
_sky.sky_horizon_color = cfg.get("sky_horizon", Color.WHITE)
|
||||||
_sky.ground_bottom_color = cfg.get("sky_ground", Color(0.5, 0.5, 0.5))
|
_sky.ground_bottom_color = cfg.get("sky_ground", Color(0.5, 0.5, 0.5))
|
||||||
_sky.ground_horizon_color = cfg.get("sky_horizon", Color.WHITE)
|
_sky.ground_horizon_color = cfg.get("sky_horizon", Color.WHITE)
|
||||||
|
if _env_node and _env_node.environment:
|
||||||
|
Materials.configure_environment(_env_node.environment, cfg)
|
||||||
if world_builder:
|
if world_builder:
|
||||||
world_builder.set_night_mode(cfg.get("night", false))
|
world_builder.set_night_mode(cfg.get("night", false))
|
||||||
|
|
||||||
@@ -642,12 +655,14 @@ func _on_guard_target(target: Node3D) -> void:
|
|||||||
targeted_npc = target
|
targeted_npc = target
|
||||||
_set_focused_avatar(target)
|
_set_focused_avatar(target)
|
||||||
_begin_incident(target)
|
_begin_incident(target)
|
||||||
|
_refresh_nearby_hint()
|
||||||
|
|
||||||
func _on_guard_clear() -> void:
|
func _on_guard_clear() -> void:
|
||||||
targeted_npc = null
|
targeted_npc = null
|
||||||
_set_focused_avatar(null)
|
_set_focused_avatar(null)
|
||||||
hud.set_target_label("")
|
hud.set_target_label("")
|
||||||
hud.set_action_hint("")
|
hud.set_action_hint("")
|
||||||
|
_refresh_nearby_hint()
|
||||||
|
|
||||||
func _set_focused_avatar(avatar: Node3D) -> void:
|
func _set_focused_avatar(avatar: Node3D) -> void:
|
||||||
if _focused_avatar and is_instance_valid(_focused_avatar) and _focused_avatar.has_method("set_focus_visual"):
|
if _focused_avatar and is_instance_valid(_focused_avatar) and _focused_avatar.has_method("set_focus_visual"):
|
||||||
@@ -699,6 +714,12 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||||||
_handle_key(event)
|
_handle_key(event)
|
||||||
|
|
||||||
func _handle_mouse_button(event: InputEventMouseButton) -> void:
|
func _handle_mouse_button(event: InputEventMouseButton) -> void:
|
||||||
|
if event.is_action_pressed("cycle_target_next"):
|
||||||
|
_cycle_nearby_target(1)
|
||||||
|
return
|
||||||
|
if event.is_action_pressed("cycle_target_prev"):
|
||||||
|
_cycle_nearby_target(-1)
|
||||||
|
return
|
||||||
if event.button_index != MOUSE_BUTTON_LEFT:
|
if event.button_index != MOUSE_BUTTON_LEFT:
|
||||||
return
|
return
|
||||||
if radial and radial.is_open:
|
if radial and radial.is_open:
|
||||||
@@ -794,6 +815,10 @@ func _handle_cancel(from_mouse: bool) -> void:
|
|||||||
if _close_top_modal():
|
if _close_top_modal():
|
||||||
return
|
return
|
||||||
if from_mouse:
|
if from_mouse:
|
||||||
|
# Right click with nothing open drops the current target, so the player can
|
||||||
|
# break focus without walking or looking away.
|
||||||
|
if targeted_npc != null:
|
||||||
|
_clear_focus()
|
||||||
return
|
return
|
||||||
if settings_panel and settings_panel.visible:
|
if settings_panel and settings_panel.visible:
|
||||||
return
|
return
|
||||||
@@ -974,30 +999,41 @@ func _cycle_venue() -> void:
|
|||||||
hud.set_wand_icon(false)
|
hud.set_wand_icon(false)
|
||||||
hud.set_shift_state(coordinator, selected_scenario)
|
hud.set_shift_state(coordinator, selected_scenario)
|
||||||
|
|
||||||
## Aim-free targeting: cycle through NPCs near the guard (Tab). Lets players
|
## Aim-free targeting: cycle through NPCs near the guard (Tab, mouse wheel).
|
||||||
## interact without precise aiming (accessibility).
|
## Lets players interact without precise aiming (accessibility) and pins the
|
||||||
func _cycle_nearby_target() -> void:
|
## target so the focus cone does not drop someone the player is not looking at.
|
||||||
|
func _cycle_nearby_target(direction: int = 1) -> void:
|
||||||
if guard == null:
|
if guard == null:
|
||||||
return
|
return
|
||||||
var nearby: Array = []
|
var next_target: Node3D = guard.cycle_target(direction)
|
||||||
for avatar in npcs:
|
if next_target == null:
|
||||||
if not is_instance_valid(avatar):
|
|
||||||
continue
|
|
||||||
if (avatar.global_position - guard.global_position).length() <= NEARBY_RADIUS:
|
|
||||||
nearby.append(avatar)
|
|
||||||
if nearby.is_empty():
|
|
||||||
hud.log(Strings.lookup("hud.no_one_nearby"))
|
hud.log(Strings.lookup("hud.no_one_nearby"))
|
||||||
|
_refresh_nearby_hint()
|
||||||
return
|
return
|
||||||
_nearby_index = (_nearby_index + 1) % nearby.size()
|
guard.pin_target(next_target)
|
||||||
var next_target: Node3D = nearby[_nearby_index]
|
|
||||||
targeted_npc = next_target
|
|
||||||
_set_focused_avatar(next_target)
|
|
||||||
_begin_incident(next_target)
|
|
||||||
var controller: Object = _targeted_controller()
|
var controller: Object = _targeted_controller()
|
||||||
var who: String = next_target.name
|
var who: String = next_target.name
|
||||||
if controller and controller.profile and controller.profile.display_name != "":
|
if controller and controller.profile and controller.profile.display_name != "":
|
||||||
who = str(controller.profile.display_name)
|
who = str(controller.profile.display_name)
|
||||||
hud.log(Strings.fmt("hud.targeting", [who]))
|
hud.log(Strings.fmt("hud.targeting", [who]))
|
||||||
|
_refresh_nearby_hint()
|
||||||
|
|
||||||
|
## Drop the focus and hand selection back to the aim cone (right click).
|
||||||
|
func _clear_focus() -> void:
|
||||||
|
if guard:
|
||||||
|
guard.clear_target()
|
||||||
|
targeted_npc = null
|
||||||
|
_set_focused_avatar(null)
|
||||||
|
if hud:
|
||||||
|
hud.set_target_label("")
|
||||||
|
hud.set_action_hint("")
|
||||||
|
_refresh_nearby_hint()
|
||||||
|
|
||||||
|
## How many people the aim-free ring can cycle through right now.
|
||||||
|
func _refresh_nearby_hint() -> void:
|
||||||
|
if hud == null or guard == null:
|
||||||
|
return
|
||||||
|
hud.set_nearby_hint(guard.nearby_candidates().size())
|
||||||
|
|
||||||
func _apply_interaction(level: int, action_name: String) -> void:
|
func _apply_interaction(level: int, action_name: String) -> void:
|
||||||
if targeted_npc == null:
|
if targeted_npc == null:
|
||||||
|
|||||||
@@ -15,6 +15,19 @@ var targeted_npc: Node3D = null
|
|||||||
var input_locked := false
|
var input_locked := false
|
||||||
var _miss_frames := 0
|
var _miss_frames := 0
|
||||||
const MISS_GRACE := 8
|
const MISS_GRACE := 8
|
||||||
|
## Loop `Shift` halves the walk speed for close work (inspection, a guest at the
|
||||||
|
## door). It is a precision modifier, not a sprint (PLAN.md keeps movement plain).
|
||||||
|
const SLOW_WALK_FACTOR := 0.45
|
||||||
|
## Aim-free cycling (Tab / mouse wheel) has to survive the focus cone: without a
|
||||||
|
## pin the cone would drop a target the player is not looking at within a few
|
||||||
|
## frames.
|
||||||
|
var target_pinned := false
|
||||||
|
## After the player clears the focus, the cone stays quiet briefly so the person
|
||||||
|
## they were looking at is not re-acquired on the very next frame.
|
||||||
|
var focus_suppress_s := 0.0
|
||||||
|
const FOCUS_SUPPRESS_S := 0.75
|
||||||
|
## Radius used by aim-free cycling.
|
||||||
|
const NEARBY_RADIUS := 12.0
|
||||||
|
|
||||||
@onready var camera: Camera3D = $Head/Camera3D
|
@onready var camera: Camera3D = $Head/Camera3D
|
||||||
@onready var head: Node3D = $Head
|
@onready var head: Node3D = $Head
|
||||||
@@ -33,6 +46,8 @@ func _unhandled_input(event: InputEvent) -> void:
|
|||||||
head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0))
|
head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0))
|
||||||
|
|
||||||
func _physics_process(_delta: float) -> void:
|
func _physics_process(_delta: float) -> void:
|
||||||
|
if focus_suppress_s > 0.0:
|
||||||
|
focus_suppress_s = maxf(0.0, focus_suppress_s - _delta)
|
||||||
if input_locked:
|
if input_locked:
|
||||||
velocity.x = 0.0
|
velocity.x = 0.0
|
||||||
velocity.z = 0.0
|
velocity.z = 0.0
|
||||||
@@ -58,9 +73,12 @@ func _physics_process(_delta: float) -> void:
|
|||||||
var desired := (forward * input_dir.y + right * input_dir.x)
|
var desired := (forward * input_dir.y + right * input_dir.x)
|
||||||
if desired.length() > 1.0:
|
if desired.length() > 1.0:
|
||||||
desired = desired.normalized()
|
desired = desired.normalized()
|
||||||
desired *= move_speed
|
var speed := move_speed
|
||||||
velocity.x = move_toward(velocity.x, desired.x, accel * _delta * move_speed)
|
if Input.is_action_pressed("walk_slow"):
|
||||||
velocity.z = move_toward(velocity.z, desired.z, accel * _delta * move_speed)
|
speed *= SLOW_WALK_FACTOR
|
||||||
|
desired *= speed
|
||||||
|
velocity.x = move_toward(velocity.x, desired.x, accel * _delta * speed)
|
||||||
|
velocity.z = move_toward(velocity.z, desired.z, accel * _delta * speed)
|
||||||
if is_on_floor():
|
if is_on_floor():
|
||||||
velocity.y = 0.0
|
velocity.y = 0.0
|
||||||
else:
|
else:
|
||||||
@@ -71,6 +89,19 @@ func _physics_process(_delta: float) -> void:
|
|||||||
func _update_target() -> void:
|
func _update_target() -> void:
|
||||||
if camera == null:
|
if camera == null:
|
||||||
return
|
return
|
||||||
|
# Right after "clear target" the cone stays quiet for a moment, otherwise it
|
||||||
|
# would re-acquire the person the player just dismissed.
|
||||||
|
if focus_suppress_s > 0.0:
|
||||||
|
if targeted_npc != null:
|
||||||
|
targeted_npc = null
|
||||||
|
cleared_target.emit()
|
||||||
|
return
|
||||||
|
# A pinned target (chosen with Tab or the mouse wheel) is not subject to the
|
||||||
|
# cone until it is freed or the player clears it.
|
||||||
|
if target_pinned:
|
||||||
|
if targeted_npc != null and is_instance_valid(targeted_npc):
|
||||||
|
return
|
||||||
|
target_pinned = false
|
||||||
var npcs := get_tree().get_nodes_in_group("npc") if get_tree() else []
|
var npcs := get_tree().get_nodes_in_group("npc") if get_tree() else []
|
||||||
var heads: Array = []
|
var heads: Array = []
|
||||||
var avatars: Array = []
|
var avatars: Array = []
|
||||||
@@ -121,3 +152,54 @@ func _head_visible(from: Vector3, to: Vector3) -> bool:
|
|||||||
return true
|
return true
|
||||||
n = n.get_parent()
|
n = n.get_parent()
|
||||||
return false
|
return false
|
||||||
|
|
||||||
|
## Avatars within cycling range, nearest first. Aim-free selection (Tab, mouse
|
||||||
|
## wheel) walks this list so the order is predictable: closest person next.
|
||||||
|
func nearby_candidates(max_range: float = NEARBY_RADIUS) -> Array:
|
||||||
|
if get_tree() == null:
|
||||||
|
return []
|
||||||
|
var here := global_position
|
||||||
|
var out: Array = []
|
||||||
|
for node in get_tree().get_nodes_in_group("npc"):
|
||||||
|
if not (node is Node3D) or not is_instance_valid(node):
|
||||||
|
continue
|
||||||
|
var avatar := node as Node3D
|
||||||
|
if (avatar.global_position - here).length() <= max_range:
|
||||||
|
out.append(avatar)
|
||||||
|
out.sort_custom(func(a: Node3D, b: Node3D) -> bool:
|
||||||
|
return (a.global_position - here).length_squared() < (b.global_position - here).length_squared())
|
||||||
|
return out
|
||||||
|
|
||||||
|
## Next target in the aim-free ring. `direction` is +1 forward, -1 backward.
|
||||||
|
## Returns null when nobody is in range (the caller logs "no one nearby").
|
||||||
|
func cycle_target(direction: int = 1) -> Node3D:
|
||||||
|
var list := nearby_candidates()
|
||||||
|
if list.is_empty():
|
||||||
|
target_pinned = false
|
||||||
|
return null
|
||||||
|
var index := list.find(targeted_npc)
|
||||||
|
var next_index := 0
|
||||||
|
if index >= 0:
|
||||||
|
next_index = posmod(index + direction, list.size())
|
||||||
|
elif direction < 0:
|
||||||
|
next_index = list.size() - 1
|
||||||
|
target_pinned = true
|
||||||
|
return list[next_index]
|
||||||
|
|
||||||
|
## Pin a target chosen outside the cone and announce it like a cone pick.
|
||||||
|
func pin_target(avatar: Node3D) -> void:
|
||||||
|
target_pinned = avatar != null
|
||||||
|
# An explicit pick overrides the post-clear suppression window.
|
||||||
|
focus_suppress_s = 0.0
|
||||||
|
if targeted_npc == avatar:
|
||||||
|
return
|
||||||
|
targeted_npc = avatar
|
||||||
|
if avatar != null:
|
||||||
|
picked_target.emit(avatar)
|
||||||
|
else:
|
||||||
|
cleared_target.emit()
|
||||||
|
|
||||||
|
## Drop the target and hand focus back to the focus cone.
|
||||||
|
func clear_target() -> void:
|
||||||
|
target_pinned = false
|
||||||
|
focus_suppress_s = FOCUS_SUPPRESS_S
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
class_name SurfaceMaterials
|
||||||
|
extends RefCounted
|
||||||
|
## Shared surface recipes for the procedural world (ADR-0012).
|
||||||
|
##
|
||||||
|
## Godot's `StandardMaterial3D` defaults are flat: every surface comes out with
|
||||||
|
## the same roughness and a plain albedo, which is what made the street read as
|
||||||
|
## grey boxes. These helpers give each surface a physically plausible profile
|
||||||
|
## (brick is rough, glass is smooth, metal is metallic) and keep the textures,
|
||||||
|
## UV scaling, and texture filtering in one place so the world builder, the NPC
|
||||||
|
## avatars, and the props all look like one scene.
|
||||||
|
|
||||||
|
const T_BRICK := preload("res://assets/textures/ai/game_ready/brick_facade.png")
|
||||||
|
const T_CONCRETE := preload("res://assets/textures/ai/game_ready/concrete.png")
|
||||||
|
const T_ASPHALT := preload("res://assets/textures/ai/game_ready/asphalt.png")
|
||||||
|
const T_AWNING := preload("res://assets/textures/ai/game_ready/awning.png")
|
||||||
|
|
||||||
|
## Roughness / metallic pairs per surface role. Exposed so tests and tooling can
|
||||||
|
## assert the profiles instead of hard-coding numbers twice.
|
||||||
|
const PROFILES := {
|
||||||
|
"brick": {"roughness": 0.88, "metallic": 0.0},
|
||||||
|
"concrete": {"roughness": 0.94, "metallic": 0.0},
|
||||||
|
"asphalt": {"roughness": 0.72, "metallic": 0.0},
|
||||||
|
"awning": {"roughness": 0.80, "metallic": 0.0},
|
||||||
|
"stone": {"roughness": 0.88, "metallic": 0.0},
|
||||||
|
"metal": {"roughness": 0.32, "metallic": 0.75},
|
||||||
|
"painted_metal": {"roughness": 0.48, "metallic": 0.35},
|
||||||
|
"wood": {"roughness": 0.70, "metallic": 0.0},
|
||||||
|
"cloth": {"roughness": 0.85, "metallic": 0.0},
|
||||||
|
"skin": {"roughness": 0.62, "metallic": 0.0},
|
||||||
|
"glass": {"roughness": 0.12, "metallic": 0.10},
|
||||||
|
"rubber": {"roughness": 0.95, "metallic": 0.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
static func profile(role: String) -> Dictionary:
|
||||||
|
return PROFILES.get(role, PROFILES["concrete"])
|
||||||
|
|
||||||
|
## Base textured surface with a named profile.
|
||||||
|
static func textured(texture: Texture2D, tint: Color, uv_scale: Vector2,
|
||||||
|
role: String = "concrete") -> StandardMaterial3D:
|
||||||
|
var mat := StandardMaterial3D.new()
|
||||||
|
mat.albedo_texture = texture
|
||||||
|
mat.albedo_color = tint
|
||||||
|
mat.uv1_scale = Vector3(uv_scale.x, uv_scale.y, 1.0)
|
||||||
|
mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC
|
||||||
|
mat.roughness = float(profile(role).get("roughness", 0.9))
|
||||||
|
mat.metallic = float(profile(role).get("metallic", 0.0))
|
||||||
|
return mat
|
||||||
|
|
||||||
|
static func brick(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(10, 5)) -> StandardMaterial3D:
|
||||||
|
return textured(T_BRICK, tint, uv_scale, "brick")
|
||||||
|
|
||||||
|
static func concrete(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(9, 2.4)) -> StandardMaterial3D:
|
||||||
|
return textured(T_CONCRETE, tint, uv_scale, "concrete")
|
||||||
|
|
||||||
|
static func asphalt(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(30, 7)) -> StandardMaterial3D:
|
||||||
|
var mat := textured(T_ASPHALT, tint, uv_scale, "asphalt")
|
||||||
|
# Damp asphalt at night picks up the street lamps and the marquee.
|
||||||
|
mat.metallic_specular = 0.6
|
||||||
|
return mat
|
||||||
|
|
||||||
|
static func awning(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(4, 1)) -> StandardMaterial3D:
|
||||||
|
return textured(T_AWNING, tint, uv_scale, "awning")
|
||||||
|
|
||||||
|
## Flat-coloured surface with a profile (props, trim, limbs, clothing).
|
||||||
|
static func flat(color: Color, role: String = "concrete") -> StandardMaterial3D:
|
||||||
|
var mat := StandardMaterial3D.new()
|
||||||
|
mat.albedo_color = color
|
||||||
|
mat.roughness = float(profile(role).get("roughness", 0.9))
|
||||||
|
mat.metallic = float(profile(role).get("metallic", 0.0))
|
||||||
|
return mat
|
||||||
|
|
||||||
|
## Self-lit surface (marquee bars, lamp bulbs, lock indicator).
|
||||||
|
static func emissive(color: Color, energy: float = 1.4) -> StandardMaterial3D:
|
||||||
|
var mat := StandardMaterial3D.new()
|
||||||
|
mat.albedo_color = color
|
||||||
|
mat.emission_enabled = true
|
||||||
|
mat.emission = color
|
||||||
|
mat.emission_energy_multiplier = energy
|
||||||
|
mat.roughness = 0.4
|
||||||
|
return mat
|
||||||
|
|
||||||
|
## Dark glass for windows and the entrance door. The alpha keeps the interior
|
||||||
|
## visible, and the low roughness gives the facade a highlight instead of a hole.
|
||||||
|
static func glass(color: Color = Color(0.42, 0.56, 0.72, 0.75)) -> StandardMaterial3D:
|
||||||
|
var mat := StandardMaterial3D.new()
|
||||||
|
mat.albedo_color = color
|
||||||
|
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||||
|
mat.roughness = float(profile("glass").get("roughness", 0.12))
|
||||||
|
mat.metallic = 0.15
|
||||||
|
mat.metallic_specular = 0.8
|
||||||
|
return mat
|
||||||
|
|
||||||
|
## Window pane that can be lit from the inside at night. Solid dark glass reads
|
||||||
|
## as a window at midday; alpha glass just looked like white paper on the brick.
|
||||||
|
static func window_pane() -> StandardMaterial3D:
|
||||||
|
var mat := StandardMaterial3D.new()
|
||||||
|
mat.albedo_color = Color(0.09, 0.12, 0.17)
|
||||||
|
mat.roughness = 0.18
|
||||||
|
mat.metallic = 0.35
|
||||||
|
mat.metallic_specular = 0.6
|
||||||
|
mat.emission_enabled = true
|
||||||
|
mat.emission = Color(1.0, 0.86, 0.62)
|
||||||
|
mat.emission_energy_multiplier = 0.0
|
||||||
|
return mat
|
||||||
|
|
||||||
|
## Environment look shared by every phase: ACES tonemapping, soft glow, aerial
|
||||||
|
## fog for depth, and a mild contrast/saturation lift. Phase values come from
|
||||||
|
## `GameManager.PHASE_LIGHTING`.
|
||||||
|
static func configure_environment(env: Environment, cfg: Dictionary) -> void:
|
||||||
|
if env == null:
|
||||||
|
return
|
||||||
|
env.background_mode = Environment.BG_SKY
|
||||||
|
env.tonemap_mode = Environment.TONE_MAPPER_ACES
|
||||||
|
env.tonemap_exposure = float(cfg.get("exposure", 1.0))
|
||||||
|
env.tonemap_white = 6.0
|
||||||
|
env.ambient_light_energy = float(cfg.get("ambient_energy", 0.5))
|
||||||
|
env.reflected_light_source = Environment.REFLECTION_SOURCE_SKY
|
||||||
|
# Glow makes the marquee, lamp bulbs, and windows read as light sources.
|
||||||
|
env.glow_enabled = true
|
||||||
|
env.glow_normalized = true
|
||||||
|
env.glow_intensity = float(cfg.get("glow", 0.55))
|
||||||
|
env.glow_strength = 1.0
|
||||||
|
env.glow_bloom = 0.08
|
||||||
|
env.glow_hdr_threshold = 1.0
|
||||||
|
env.glow_blend_mode = Environment.GLOW_BLEND_MODE_SOFTLIGHT
|
||||||
|
# Aerial perspective: the skyline ring sits ~200 m out, so light fog gives the
|
||||||
|
# distance its haze without hiding the street.
|
||||||
|
env.fog_enabled = true
|
||||||
|
env.fog_light_color = cfg.get("fog_color", Color(0.72, 0.78, 0.88))
|
||||||
|
env.fog_light_energy = float(cfg.get("fog_energy", 0.7))
|
||||||
|
env.fog_density = float(cfg.get("fog_density", 0.0035))
|
||||||
|
env.fog_aerial_perspective = 0.25
|
||||||
|
env.fog_sky_affect = 0.4
|
||||||
|
env.adjustment_enabled = true
|
||||||
|
env.adjustment_brightness = 1.0
|
||||||
|
env.adjustment_contrast = float(cfg.get("contrast", 1.06))
|
||||||
|
env.adjustment_saturation = float(cfg.get("saturation", 1.08))
|
||||||
@@ -5,6 +5,7 @@ extends CharacterBody3D
|
|||||||
|
|
||||||
const NPCController := preload("res://src/core/npc_controller.gd")
|
const NPCController := preload("res://src/core/npc_controller.gd")
|
||||||
const Rules := preload("res://src/core/rules.gd")
|
const Rules := preload("res://src/core/rules.gd")
|
||||||
|
const Materials := preload("res://src/game/materials.gd")
|
||||||
|
|
||||||
var npc: NPCController = null
|
var npc: NPCController = null
|
||||||
var spawn_position: Vector3 = Vector3.ZERO
|
var spawn_position: Vector3 = Vector3.ZERO
|
||||||
@@ -95,6 +96,7 @@ var _name_tag: Label3D = null
|
|||||||
var _photo_prop: MeshInstance3D = null
|
var _photo_prop: MeshInstance3D = null
|
||||||
var _sign_prop: MeshInstance3D = null
|
var _sign_prop: MeshInstance3D = null
|
||||||
var _legs: Array[MeshInstance3D] = []
|
var _legs: Array[MeshInstance3D] = []
|
||||||
|
var _arms: Array[MeshInstance3D] = []
|
||||||
var _theater_t := 0.0
|
var _theater_t := 0.0
|
||||||
var _photo_prop_base_y := 0.0
|
var _photo_prop_base_y := 0.0
|
||||||
var _sign_prop_base_y := 0.0
|
var _sign_prop_base_y := 0.0
|
||||||
@@ -102,43 +104,105 @@ const SKIN_TONE := Color(0.88, 0.72, 0.60)
|
|||||||
const PANTS_COLOR := Color(0.22, 0.22, 0.26)
|
const PANTS_COLOR := Color(0.22, 0.22, 0.26)
|
||||||
const HAIR_COLORS := [Color(0.12, 0.1, 0.08), Color(0.35, 0.22, 0.12), Color(0.55, 0.42, 0.28), Color(0.15, 0.15, 0.18)]
|
const HAIR_COLORS := [Color(0.12, 0.1, 0.08), Color(0.35, 0.22, 0.12), Color(0.55, 0.42, 0.28), Color(0.15, 0.15, 0.18)]
|
||||||
|
|
||||||
|
## Which small accessory a role wears. This is look-only: the player must still
|
||||||
|
## read intent from behaviour, never from the archetype name (ADR-0010).
|
||||||
|
const ACCESSORIES := {
|
||||||
|
Rules.NpcType.AUTHORIZED_STAFF: "cap",
|
||||||
|
Rules.NpcType.RESPONDER: "cap",
|
||||||
|
Rules.NpcType.DELIVERY: "cap",
|
||||||
|
Rules.NpcType.TOURIST: "backpack",
|
||||||
|
Rules.NpcType.INFLUENCER: "sunglasses",
|
||||||
|
Rules.NpcType.TRESPASSER: "hood",
|
||||||
|
Rules.NpcType.VIOLENT_THREAT: "hood",
|
||||||
|
Rules.NpcType.MENTAL_CRISIS: "hood",
|
||||||
|
Rules.NpcType.AUTHORIZED_GUEST: "lanyard",
|
||||||
|
Rules.NpcType.PERFORMER: "none",
|
||||||
|
Rules.NpcType.PROTESTER: "none",
|
||||||
|
Rules.NpcType.INTOXICATED: "none",
|
||||||
|
Rules.NpcType.CONFUSED_VISITOR: "none",
|
||||||
|
}
|
||||||
|
|
||||||
|
## Clothing palette for one avatar. Pure and seed-driven so crowds read as
|
||||||
|
## individuals and the result is deterministic (tests rely on that).
|
||||||
|
static func outfit_for(npc_type: int, seed_value: int) -> Dictionary:
|
||||||
|
var base: Color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
|
||||||
|
var frac := float((seed_value * 1103515245 + 12345) % 1000) / 1000.0
|
||||||
|
var shirt := base.lerp(Color(0.92, 0.92, 0.94), frac * 0.22)
|
||||||
|
shirt = shirt.darkened(frac * 0.12)
|
||||||
|
# Jeans through khaki/chino, so not every avatar wears the same trousers.
|
||||||
|
var pants := Color(0.16, 0.18, 0.23).lerp(Color(0.46, 0.38, 0.28), frac)
|
||||||
|
var hair_index := (seed_value * 7) % HAIR_COLORS.size()
|
||||||
|
return {
|
||||||
|
"shirt": shirt,
|
||||||
|
"sleeves": shirt.darkened(0.16),
|
||||||
|
"pants": pants,
|
||||||
|
"hair": HAIR_COLORS[hair_index],
|
||||||
|
"skin": SKIN_TONE.darkened(frac * 0.18),
|
||||||
|
"accessory": ACCESSORIES.get(npc_type, "none"),
|
||||||
|
"height_scale": 0.92 + 0.16 * frac,
|
||||||
|
"blend": frac,
|
||||||
|
}
|
||||||
|
|
||||||
func _build_body(npc_type: int) -> void:
|
func _build_body(npc_type: int) -> void:
|
||||||
_rig = Node3D.new()
|
_rig = Node3D.new()
|
||||||
_rig.name = "Rig"
|
_rig.name = "Rig"
|
||||||
add_child(_rig)
|
add_child(_rig)
|
||||||
var base_color: Color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
|
var outfit := outfit_for(npc_type, _visual_seed)
|
||||||
# Per-seed variation: slight height and brightness differences so crowds
|
var shirt: Color = outfit["shirt"]
|
||||||
# read as individuals (deterministic LCG, no RNG side effects).
|
var sleeves: Color = outfit["sleeves"]
|
||||||
var frac := float((_visual_seed * 1103515245 + 12345) % 1000) / 1000.0
|
var pants: Color = outfit["pants"]
|
||||||
var height_scale := 0.92 + 0.16 * frac
|
var skin: Color = outfit["skin"]
|
||||||
_rig.scale.y = height_scale
|
var hair: Color = outfit["hair"]
|
||||||
var shader_delta := 0.12 * frac
|
_rig.scale.y = float(outfit["height_scale"])
|
||||||
base_color = base_color.lightened(shader_delta) if frac < 0.5 else base_color.darkened(shader_delta)
|
var limbs_color := sleeves
|
||||||
var limbs_color := base_color.darkened(0.25)
|
# Torso, with a shoulder block and hips so the silhouette is not a tube.
|
||||||
# Torso
|
_add_capsule(_rig, 0.30, 0.90, Vector3(0, 1.02, 0), shirt, 0.0, "cloth")
|
||||||
_add_capsule(_rig, 0.30, 0.95, Vector3(0, 1.05, 0), base_color)
|
_add_box_mesh(_rig, Vector3(0.58, 0.14, 0.28), Vector3(0, 1.36, 0), shirt, "cloth")
|
||||||
# Legs
|
_add_box_mesh(_rig, Vector3(0.44, 0.18, 0.26), Vector3(0, 0.62, 0), pants, "cloth")
|
||||||
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), PANTS_COLOR))
|
# Legs and arms are tracked so the walk cycle can swing them.
|
||||||
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), PANTS_COLOR))
|
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), pants, 0.0, "cloth"))
|
||||||
# Arms
|
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), pants, 0.0, "cloth"))
|
||||||
_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0)
|
_arms.append(_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0, "cloth"))
|
||||||
_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0)
|
_arms.append(_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0, "cloth"))
|
||||||
|
# Hands and shoes give the limbs an end point at conversation distance.
|
||||||
|
_add_sphere(_rig, 0.065, Vector3(-0.47, 0.94, 0.03), skin, "skin")
|
||||||
|
_add_sphere(_rig, 0.065, Vector3(0.47, 0.94, 0.03), skin, "skin")
|
||||||
# Neck, head, face, hair, shoes so the body reads as a person (ADR-0010).
|
# Neck, head, face, hair, shoes so the body reads as a person (ADR-0010).
|
||||||
_add_capsule(_rig, 0.08, 0.16, Vector3(0, 1.48, 0), SKIN_TONE)
|
_add_capsule(_rig, 0.08, 0.16, Vector3(0, 1.48, 0), skin, 0.0, "skin")
|
||||||
_head = Node3D.new()
|
_head = Node3D.new()
|
||||||
_head.name = "Head"
|
_head.name = "Head"
|
||||||
_head.position = Vector3(0, 1.62, 0)
|
_head.position = Vector3(0, 1.62, 0)
|
||||||
_add_sphere(_head, 0.17, Vector3.ZERO, SKIN_TONE)
|
_add_sphere(_head, 0.17, Vector3.ZERO, skin, "skin")
|
||||||
var hair_i := (_visual_seed * 7) % HAIR_COLORS.size()
|
_add_sphere(_head, 0.18, Vector3(0, 0.08, -0.02), hair, "cloth")
|
||||||
_add_sphere(_head, 0.18, Vector3(0, 0.08, -0.02), HAIR_COLORS[hair_i])
|
_add_sphere(_head, 0.035, Vector3(-0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1), "glass")
|
||||||
_add_sphere(_head, 0.035, Vector3(-0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1))
|
_add_sphere(_head, 0.035, Vector3(0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1), "glass")
|
||||||
_add_sphere(_head, 0.035, Vector3(0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1))
|
|
||||||
_rig.add_child(_head)
|
_rig.add_child(_head)
|
||||||
_add_box_prop(_rig, Vector3(0.16, 0.08, 0.28), Vector3(-0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14))
|
_add_box_mesh(_rig, Vector3(0.16, 0.08, 0.28), Vector3(-0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14), "rubber")
|
||||||
_add_box_prop(_rig, Vector3(0.16, 0.08, 0.28), Vector3(0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14))
|
_add_box_mesh(_rig, Vector3(0.16, 0.08, 0.28), Vector3(0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14), "rubber")
|
||||||
|
_add_accessory(str(outfit["accessory"]), shirt)
|
||||||
_build_focus_circle()
|
_build_focus_circle()
|
||||||
_add_props(npc_type)
|
_add_props(npc_type)
|
||||||
|
|
||||||
|
## Small role accessory: cap, hood, backpack, sunglasses or a staff lanyard.
|
||||||
|
func _add_accessory(kind: String, shirt: Color) -> void:
|
||||||
|
match kind:
|
||||||
|
"cap":
|
||||||
|
_add_sphere(_head, 0.175, Vector3(0, 0.12, 0), shirt.darkened(0.15), "cloth")
|
||||||
|
_add_box_mesh(_head, Vector3(0.30, 0.03, 0.16), Vector3(0, 0.10, 0.16), shirt.darkened(0.22), "cloth")
|
||||||
|
"hood":
|
||||||
|
_add_sphere(_head, 0.205, Vector3(0, 0.03, -0.03), Color(0.16, 0.16, 0.18), "cloth")
|
||||||
|
"backpack":
|
||||||
|
_add_box_mesh(_rig, Vector3(0.34, 0.42, 0.16), Vector3(0, 1.06, -0.24), Color(0.24, 0.28, 0.36), "cloth")
|
||||||
|
_add_box_mesh(_rig, Vector3(0.30, 0.06, 0.02), Vector3(0, 1.24, -0.33), Color(0.42, 0.44, 0.5), "cloth")
|
||||||
|
"sunglasses":
|
||||||
|
_add_box_mesh(_head, Vector3(0.24, 0.05, 0.03), Vector3(0, 0.04, 0.15), Color(0.06, 0.06, 0.08), "glass")
|
||||||
|
"lanyard":
|
||||||
|
_add_box_mesh(_rig, Vector3(0.05, 0.06, 0.02), Vector3(0, 1.20, 0.16), Color(0.85, 0.78, 0.30), "painted_metal")
|
||||||
|
_add_box_mesh(_rig, Vector3(0.02, 0.20, 0.02), Vector3(-0.08, 1.34, 0.14), Color(0.3, 0.32, 0.36), "cloth")
|
||||||
|
_add_box_mesh(_rig, Vector3(0.02, 0.20, 0.02), Vector3(0.08, 1.34, 0.14), Color(0.3, 0.32, 0.36), "cloth")
|
||||||
|
_:
|
||||||
|
pass
|
||||||
|
|
||||||
func _build_focus_circle() -> void:
|
func _build_focus_circle() -> void:
|
||||||
_focus_circle = MeshInstance3D.new()
|
_focus_circle = MeshInstance3D.new()
|
||||||
_focus_circle.name = "FocusCircle"
|
_focus_circle.name = "FocusCircle"
|
||||||
@@ -180,7 +244,8 @@ func set_focus_visual(focused: bool, menu_open: bool = false) -> void:
|
|||||||
if _name_tag:
|
if _name_tag:
|
||||||
_name_tag.visible = focused
|
_name_tag.visible = focused
|
||||||
|
|
||||||
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> MeshInstance3D:
|
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3,
|
||||||
|
color: Color, z_rot: float = 0.0, role: String = "cloth") -> MeshInstance3D:
|
||||||
var mi := MeshInstance3D.new()
|
var mi := MeshInstance3D.new()
|
||||||
var mesh := CapsuleMesh.new()
|
var mesh := CapsuleMesh.new()
|
||||||
mesh.radius = radius
|
mesh.radius = radius
|
||||||
@@ -188,24 +253,34 @@ func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, co
|
|||||||
mi.mesh = mesh
|
mi.mesh = mesh
|
||||||
mi.position = pos
|
mi.position = pos
|
||||||
mi.rotation_degrees.z = z_rot
|
mi.rotation_degrees.z = z_rot
|
||||||
mi.material_override = _material(color)
|
mi.material_override = _material(color, role)
|
||||||
parent.add_child(mi)
|
parent.add_child(mi)
|
||||||
return mi
|
return mi
|
||||||
|
|
||||||
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color) -> void:
|
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color,
|
||||||
|
role: String = "skin") -> void:
|
||||||
var mi := MeshInstance3D.new()
|
var mi := MeshInstance3D.new()
|
||||||
var mesh := SphereMesh.new()
|
var mesh := SphereMesh.new()
|
||||||
mesh.radius = radius
|
mesh.radius = radius
|
||||||
mesh.height = radius * 2.0
|
mesh.height = radius * 2.0
|
||||||
mi.mesh = mesh
|
mi.mesh = mesh
|
||||||
mi.position = pos
|
mi.position = pos
|
||||||
mi.material_override = _material(color)
|
mi.material_override = _material(color, role)
|
||||||
parent.add_child(mi)
|
parent.add_child(mi)
|
||||||
|
|
||||||
func _material(color: Color) -> StandardMaterial3D:
|
func _add_box_mesh(parent: Node3D, size: Vector3, pos: Vector3, color: Color,
|
||||||
var mat := StandardMaterial3D.new()
|
role: String = "cloth") -> MeshInstance3D:
|
||||||
mat.albedo_color = color
|
var mi := MeshInstance3D.new()
|
||||||
return mat
|
var mesh := BoxMesh.new()
|
||||||
|
mesh.size = size
|
||||||
|
mi.mesh = mesh
|
||||||
|
mi.position = pos
|
||||||
|
mi.material_override = _material(color, role)
|
||||||
|
parent.add_child(mi)
|
||||||
|
return mi
|
||||||
|
|
||||||
|
func _material(color: Color, role: String = "cloth") -> StandardMaterial3D:
|
||||||
|
return Materials.flat(color, role)
|
||||||
|
|
||||||
## Small per-archetype props so silhouettes differ (camera, box, hat, sign…).
|
## Small per-archetype props so silhouettes differ (camera, box, hat, sign…).
|
||||||
func _add_props(npc_type: int) -> void:
|
func _add_props(npc_type: int) -> void:
|
||||||
@@ -358,6 +433,11 @@ static func prop_lift_for_pose(pose: String, phase: float) -> float:
|
|||||||
return 0.0
|
return 0.0
|
||||||
return maxf(0.0, sin(phase * 1.7)) * 0.16
|
return maxf(0.0, sin(phase * 1.7)) * 0.16
|
||||||
|
|
||||||
|
## Opposite arm/leg swing for the walk cycle. `side` is 0 or 1; pure so the gait
|
||||||
|
## can be checked without a scene tree.
|
||||||
|
static func limb_swing(phase: float, side: int, amplitude: float) -> float:
|
||||||
|
return sin(phase + float(side) * PI) * amplitude
|
||||||
|
|
||||||
## Sway amount for a performer playing to the sidewalk.
|
## Sway amount for a performer playing to the sidewalk.
|
||||||
static func sway_for_pose(pose: String, phase: float) -> float:
|
static func sway_for_pose(pose: String, phase: float) -> float:
|
||||||
if pose != "perform":
|
if pose != "perform":
|
||||||
@@ -374,6 +454,10 @@ func _animate(delta: float) -> void:
|
|||||||
_bob_phase += delta * (6.0 + speed)
|
_bob_phase += delta * (6.0 + speed)
|
||||||
_rig.position.y = sin(_bob_phase) * 0.035
|
_rig.position.y = sin(_bob_phase) * 0.035
|
||||||
_rig.rotation.x = sin(_bob_phase * 0.5) * 0.04
|
_rig.rotation.x = sin(_bob_phase * 0.5) * 0.04
|
||||||
|
for i in _legs.size():
|
||||||
|
_legs[i].rotation.x = limb_swing(_bob_phase, i, 0.5)
|
||||||
|
for i in _arms.size():
|
||||||
|
_arms[i].rotation.x = limb_swing(_bob_phase, i, -0.38)
|
||||||
elif pose != "idle" or not is_zero_approx(_rig.position.y):
|
elif pose != "idle" or not is_zero_approx(_rig.position.y):
|
||||||
# Idle theater: seated, photographing, filming, or performing.
|
# Idle theater: seated, photographing, filming, or performing.
|
||||||
_theater_t += delta
|
_theater_t += delta
|
||||||
@@ -393,10 +477,16 @@ func _animate(delta: float) -> void:
|
|||||||
var leg_angle := deg_to_rad(-78.0) if pose == "sit" else 0.0
|
var leg_angle := deg_to_rad(-78.0) if pose == "sit" else 0.0
|
||||||
_legs[0].rotation.x = lerpf(_legs[0].rotation.x, leg_angle, 0.12)
|
_legs[0].rotation.x = lerpf(_legs[0].rotation.x, leg_angle, 0.12)
|
||||||
_legs[1].rotation.x = lerpf(_legs[1].rotation.x, leg_angle, 0.12)
|
_legs[1].rotation.x = lerpf(_legs[1].rotation.x, leg_angle, 0.12)
|
||||||
|
for arm in _arms:
|
||||||
|
arm.rotation.x = lerpf(arm.rotation.x, 0.0, 0.14)
|
||||||
else:
|
else:
|
||||||
_bob_phase = 0.0
|
_bob_phase = 0.0
|
||||||
_rig.position.y = lerpf(_rig.position.y, 0.0, 0.2)
|
_rig.position.y = lerpf(_rig.position.y, 0.0, 0.2)
|
||||||
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
|
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
|
||||||
|
for i in _legs.size():
|
||||||
|
_legs[i].rotation.x = lerpf(_legs[i].rotation.x, 0.0, 0.2)
|
||||||
|
for arm in _arms:
|
||||||
|
arm.rotation.x = lerpf(arm.rotation.x, 0.0, 0.2)
|
||||||
# The focus circle billboards via its material; no per-frame look_at needed.
|
# The focus circle billboards via its material; no per-frame look_at needed.
|
||||||
# Attention cue: head turns toward the guard when awareness was raised.
|
# Attention cue: head turns toward the guard when awareness was raised.
|
||||||
if _head != null and npc.guard_noticed:
|
if _head != null and npc.guard_noticed:
|
||||||
|
|||||||
@@ -20,14 +20,26 @@ static func score_head(cam_origin: Vector3, cam_forward: Vector3, head_pos: Vect
|
|||||||
return ang
|
return ang
|
||||||
|
|
||||||
static func pick_index(cam_origin: Vector3, cam_forward: Vector3, head_positions: Array, max_range: float = MAX_RANGE, max_angle: float = MAX_ANGLE_RAD) -> int:
|
static func pick_index(cam_origin: Vector3, cam_forward: Vector3, head_positions: Array, max_range: float = MAX_RANGE, max_angle: float = MAX_ANGLE_RAD) -> int:
|
||||||
var best_i := -1
|
var ranked := rank_indices(cam_origin, cam_forward, head_positions, max_range, max_angle)
|
||||||
var best_score := 999.0
|
return ranked[0] if not ranked.is_empty() else -1
|
||||||
|
|
||||||
|
## Every candidate inside the cone, best first: smallest angle to the crosshair,
|
||||||
|
## then nearest. The cone used to return position in the group, which made the
|
||||||
|
## pick order depend on spawn order; ranking by angle matches what the player
|
||||||
|
## sees and gives `Tab` / the mouse wheel a stable order too.
|
||||||
|
static func rank_indices(cam_origin: Vector3, cam_forward: Vector3, head_positions: Array, max_range: float = MAX_RANGE, max_angle: float = MAX_ANGLE_RAD) -> Array:
|
||||||
|
var scored: Array = []
|
||||||
for i in head_positions.size():
|
for i in head_positions.size():
|
||||||
var pos: Vector3 = head_positions[i]
|
var pos: Vector3 = head_positions[i]
|
||||||
var score := score_head(cam_origin, cam_forward, pos, max_range, max_angle)
|
var score := score_head(cam_origin, cam_forward, pos, max_range, max_angle)
|
||||||
if score < 0.0:
|
if score < 0.0:
|
||||||
continue
|
continue
|
||||||
if score < best_score:
|
scored.append([score, (pos - cam_origin).length(), i])
|
||||||
best_score = score
|
scored.sort_custom(func(a: Array, b: Array) -> bool:
|
||||||
best_i = i
|
if not is_equal_approx(float(a[0]), float(b[0])):
|
||||||
return best_i
|
return float(a[0]) < float(b[0])
|
||||||
|
return float(a[1]) < float(b[1]))
|
||||||
|
var ranked: Array = []
|
||||||
|
for entry in scored:
|
||||||
|
ranked.append(int(entry[2]))
|
||||||
|
return ranked
|
||||||
@@ -26,6 +26,7 @@ const CARD_TEXTURES := {
|
|||||||
var top_label: Label
|
var top_label: Label
|
||||||
var time_label: Label
|
var time_label: Label
|
||||||
var target_label: Label
|
var target_label: Label
|
||||||
|
var nearby_label: Label
|
||||||
var hint_label: Label
|
var hint_label: Label
|
||||||
var log_label: Label
|
var log_label: Label
|
||||||
var debug_label: Label
|
var debug_label: Label
|
||||||
@@ -85,6 +86,15 @@ func _init() -> void:
|
|||||||
target_label.add_theme_font_size_override("font_size", 16)
|
target_label.add_theme_font_size_override("font_size", 16)
|
||||||
vbox.add_child(target_label)
|
vbox.add_child(target_label)
|
||||||
|
|
||||||
|
nearby_label = Label.new()
|
||||||
|
nearby_label.add_theme_font_size_override("font_size", 12)
|
||||||
|
nearby_label.modulate = Color(0.92, 0.95, 1.0)
|
||||||
|
# A shadow keeps the hint readable over the bright sidewalk and the sky.
|
||||||
|
nearby_label.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.7))
|
||||||
|
nearby_label.add_theme_constant_override("shadow_offset_x", 1)
|
||||||
|
nearby_label.add_theme_constant_override("shadow_offset_y", 1)
|
||||||
|
vbox.add_child(nearby_label)
|
||||||
|
|
||||||
hint_label = Label.new()
|
hint_label = Label.new()
|
||||||
hint_label.modulate = Color(1, 0.9, 0.6)
|
hint_label.modulate = Color(1, 0.9, 0.6)
|
||||||
hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
@@ -270,6 +280,12 @@ func _update_radio_cooldown(coordinator: Object) -> void:
|
|||||||
func set_target_label(text: String) -> void:
|
func set_target_label(text: String) -> void:
|
||||||
target_label.text = text
|
target_label.text = text
|
||||||
|
|
||||||
|
## How many people the aim-free ring (Tab / mouse wheel) can cycle through.
|
||||||
|
func set_nearby_hint(count: int) -> void:
|
||||||
|
if nearby_label == null:
|
||||||
|
return
|
||||||
|
nearby_label.text = Strings.fmt("hud.nearby", [count]) if count > 0 else ""
|
||||||
|
|
||||||
var _flashlight := false
|
var _flashlight := false
|
||||||
var _door_locked := false
|
var _door_locked := false
|
||||||
|
|
||||||
@@ -308,7 +324,7 @@ func set_font_scale(scale: float) -> void:
|
|||||||
var clamped := clampf(scale, 0.75, 1.75)
|
var clamped := clampf(scale, 0.75, 1.75)
|
||||||
_font_scale = clamped
|
_font_scale = clamped
|
||||||
var scaled: Array[Label] = [top_label, log_label, target_label, hint_label,
|
var scaled: Array[Label] = [top_label, log_label, target_label, hint_label,
|
||||||
time_label, mode_label, escalation_label, radio_cooldown_label, debug_label]
|
nearby_label, time_label, mode_label, escalation_label, radio_cooldown_label, debug_label]
|
||||||
for label in scaled:
|
for label in scaled:
|
||||||
if label == null:
|
if label == null:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
class_name WorldBuilder
|
class_name WorldBuilder
|
||||||
## Procedurally assembles a minimal Hollywood Boulevard entrance prototype:
|
## Procedurally assembles a minimal Hollywood Boulevard entrance prototype:
|
||||||
|
|
||||||
|
const Materials := preload("res://src/game/materials.gd")
|
||||||
const T_BRICK := preload("res://assets/textures/ai/game_ready/brick_facade.png")
|
const T_BRICK := preload("res://assets/textures/ai/game_ready/brick_facade.png")
|
||||||
const T_CONCRETE := preload("res://assets/textures/ai/game_ready/concrete.png")
|
const T_CONCRETE := preload("res://assets/textures/ai/game_ready/concrete.png")
|
||||||
const T_ASPHALT := preload("res://assets/textures/ai/game_ready/asphalt.png")
|
const T_ASPHALT := preload("res://assets/textures/ai/game_ready/asphalt.png")
|
||||||
const T_AWNING := preload("res://assets/textures/ai/game_ready/awning.png")
|
const T_AWNING := preload("res://assets/textures/ai/game_ready/awning.png")
|
||||||
const T_SKYLINE := preload("res://assets/textures/skyline.png")
|
const T_SKYLINE := preload("res://assets/textures/skyline.png")
|
||||||
## steps, building facade, restricted door, sidewalk, pavement, and street edge.
|
## steps, building facade, restricted door, sidewalk, pavement, and street edge.
|
||||||
## No art assets required; all geometry is BoxMesh with flat materials.
|
## Surfaces use the shared PBR profiles in `src/game/materials.gd`; street
|
||||||
|
## furniture comes from the generated GLB props (tools/model_pipeline.py) with a
|
||||||
|
## procedural fallback so a headless checkout without imports still builds.
|
||||||
|
|
||||||
|
## Generated prop models, one `.glb` per street-furniture piece.
|
||||||
|
const MODEL_DIR := "res://assets/models/generated/"
|
||||||
|
const MODEL_PROPS := ["bollard", "bench", "trash_bin", "planter_box",
|
||||||
|
"newspaper_box", "fire_hydrant"]
|
||||||
|
|
||||||
var root: Node3D = null
|
var root: Node3D = null
|
||||||
## Door lock indicator mesh near the handle; toggled by GameManager.
|
## Door lock indicator mesh near the handle; toggled by GameManager.
|
||||||
@@ -19,6 +27,12 @@ var _lamp_omni: Array[OmniLight3D] = []
|
|||||||
var _lamp_bulb_mats: Array[StandardMaterial3D] = []
|
var _lamp_bulb_mats: Array[StandardMaterial3D] = []
|
||||||
var marquee_label: Label3D = null
|
var marquee_label: Label3D = null
|
||||||
var _marquee_emissive: Array[StandardMaterial3D] = []
|
var _marquee_emissive: Array[StandardMaterial3D] = []
|
||||||
|
## Facade windows that light up after dark.
|
||||||
|
var _window_mats: Array[StandardMaterial3D] = []
|
||||||
|
## Names of the generated props that actually loaded (procedural fallbacks are
|
||||||
|
## not listed), so tests and the debug overlay can report real model usage.
|
||||||
|
var loaded_models: Array[String] = []
|
||||||
|
var fallback_props: Array[String] = []
|
||||||
|
|
||||||
func _init(root_node: Node3D) -> void:
|
func _init(root_node: Node3D) -> void:
|
||||||
root = root_node
|
root = root_node
|
||||||
@@ -33,19 +47,21 @@ func build() -> void:
|
|||||||
_build_street()
|
_build_street()
|
||||||
_build_crosswalk()
|
_build_crosswalk()
|
||||||
_build_street_furniture()
|
_build_street_furniture()
|
||||||
|
_build_street_detail()
|
||||||
_build_guard_spawn()
|
_build_guard_spawn()
|
||||||
_build_navmesh()
|
_build_navmesh()
|
||||||
set_night_mode(false)
|
set_night_mode(false)
|
||||||
|
|
||||||
func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_shadow: bool = false,
|
func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_shadow: bool = false,
|
||||||
texture: Texture2D = null, uv_scale: Vector2 = Vector2(1, 1)) -> MeshInstance3D:
|
texture: Texture2D = null, uv_scale: Vector2 = Vector2(1, 1),
|
||||||
|
role: String = "concrete") -> MeshInstance3D:
|
||||||
var mesh := BoxMesh.new()
|
var mesh := BoxMesh.new()
|
||||||
mesh.size = size
|
mesh.size = size
|
||||||
var mat := StandardMaterial3D.new()
|
var mat: StandardMaterial3D
|
||||||
mat.albedo_color = color
|
|
||||||
if texture:
|
if texture:
|
||||||
mat.albedo_texture = texture
|
mat = Materials.textured(texture, color, uv_scale, role)
|
||||||
mat.uv1_scale = Vector3(uv_scale.x, uv_scale.y, 1.0)
|
else:
|
||||||
|
mat = Materials.flat(color, role)
|
||||||
mesh.material = mat
|
mesh.material = mat
|
||||||
var mi := MeshInstance3D.new()
|
var mi := MeshInstance3D.new()
|
||||||
mi.mesh = mesh
|
mi.mesh = mesh
|
||||||
@@ -59,7 +75,7 @@ func _build_ground() -> void:
|
|||||||
ground.name = "Ground"
|
ground.name = "Ground"
|
||||||
# Large enough that the ground meets the distant skyline ring (no gap of sky
|
# Large enough that the ground meets the distant skyline ring (no gap of sky
|
||||||
# under the buildings when looking down the boulevard).
|
# under the buildings when looking down the boulevard).
|
||||||
_add_box(root, Vector3(500, 0.5, 500), Vector3(0, -0.25, 20), Color(0.22, 0.24, 0.26))
|
_add_box(root, Vector3(500, 0.5, 500), Vector3(0, -0.25, 20), Color(0.22, 0.24, 0.26), false, null, Vector2.ONE, "asphalt")
|
||||||
var col := BoxShape3D.new()
|
var col := BoxShape3D.new()
|
||||||
col.size = Vector3(500, 1, 500)
|
col.size = Vector3(500, 1, 500)
|
||||||
var shape_owner := ground.create_shape_owner(ground)
|
var shape_owner := ground.create_shape_owner(ground)
|
||||||
@@ -70,22 +86,27 @@ func _build_building() -> void:
|
|||||||
var building := StaticBody3D.new()
|
var building := StaticBody3D.new()
|
||||||
building.name = "Building"
|
building.name = "Building"
|
||||||
# Main facade (textured brick)
|
# Main facade (textured brick)
|
||||||
_add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -6), Color(0.85, 0.8, 0.78), false, T_BRICK, Vector2(10, 5))
|
_add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -6), Color(0.80, 0.76, 0.74), false, T_BRICK, Vector2(22, 10), "brick")
|
||||||
|
# Stone base course so the facade has a footing instead of meeting the steps dead-on.
|
||||||
|
_add_box(building, Vector3(40, 0.8, 0.35), Vector3(0, 0.4, -3.85), Color(0.58, 0.56, 0.53), false, null, Vector2.ONE, "stone")
|
||||||
# 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))
|
_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")
|
||||||
|
# Door frame columns and lintel, so the entrance is framed rather than painted on.
|
||||||
|
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(3.4, 0.3, 0.42), Vector3(0, 5.55, -3.86), 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))
|
_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")
|
||||||
# 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, -3.9), Color(0.6, 0.75, 0.9, 0.55))
|
||||||
var glass_mat := StandardMaterial3D.new()
|
glass.material_override = Materials.glass(Color(0.55, 0.7, 0.88, 0.55))
|
||||||
glass_mat.albedo_color = Color(0.6, 0.75, 0.9, 0.45)
|
# Door handles: two slim metal bars where the guard reaches for the lock.
|
||||||
glass_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
for side in [-0.42, 0.42]:
|
||||||
glass_mat.roughness = 0.15
|
_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")
|
||||||
glass_mat.metallic = 0.1
|
|
||||||
glass.material_override = glass_mat
|
|
||||||
# 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, -3.82), 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))
|
||||||
|
_build_facade_windows(building)
|
||||||
var col := BoxShape3D.new()
|
var col := BoxShape3D.new()
|
||||||
col.size = Vector3(40, 18, 4)
|
col.size = Vector3(40, 18, 4)
|
||||||
var so := building.create_shape_owner(building)
|
var so := building.create_shape_owner(building)
|
||||||
@@ -93,6 +114,24 @@ func _build_building() -> void:
|
|||||||
building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 9, -6)))
|
building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 9, -6)))
|
||||||
root.add_child(building)
|
root.add_child(building)
|
||||||
|
|
||||||
|
## Regular window bands above the entrance. Panes are dark glass by day and
|
||||||
|
## light up after dark, which gives the facade depth at night.
|
||||||
|
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]
|
||||||
|
for y in [9.2, 12.4]:
|
||||||
|
for x in columns:
|
||||||
|
# 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),
|
||||||
|
Color(0.22, 0.21, 0.20), false, null, Vector2.ONE, "stone")
|
||||||
|
var pane := _add_box(parent, Vector3(1.7, 2.2, 0.12),
|
||||||
|
Vector3(x, y, -3.94), Color(1, 1, 1))
|
||||||
|
var pane_mat := Materials.window_pane()
|
||||||
|
pane.material_override = pane_mat
|
||||||
|
_window_mats.append(pane_mat)
|
||||||
|
# Sill below the frame.
|
||||||
|
_add_box(parent, Vector3(2.1, 0.12, 0.3), Vector3(x, y - 1.3, -3.86),
|
||||||
|
Color(0.55, 0.53, 0.50), false, null, Vector2.ONE, "stone")
|
||||||
|
|
||||||
func _build_steps() -> void:
|
func _build_steps() -> void:
|
||||||
# Steps have collision so NPCs and the guard can walk up the entrance.
|
# Steps have collision so NPCs and the guard can walk up the entrance.
|
||||||
# The ground and building provide the actual collision boundaries.
|
# The ground and building provide the actual collision boundaries.
|
||||||
@@ -125,7 +164,7 @@ 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(1, 1, 1), false, T_CONCRETE, Vector2(9, 2.4))
|
_add_box(sidewalk, Vector3(30, 0.18, 8), Vector3(0, 0.09, 8), Color(0.74, 0.74, 0.72), false, T_CONCRETE, Vector2(9, 2.4), "concrete")
|
||||||
var col := BoxShape3D.new()
|
var col := BoxShape3D.new()
|
||||||
col.size = Vector3(30, 0.36, 8)
|
col.size = Vector3(30, 0.36, 8)
|
||||||
var so := sidewalk.create_shape_owner(sidewalk)
|
var so := sidewalk.create_shape_owner(sidewalk)
|
||||||
@@ -136,10 +175,14 @@ func _build_sidewalk() -> void:
|
|||||||
func _build_street() -> void:
|
func _build_street() -> void:
|
||||||
var street := StaticBody3D.new()
|
var street := StaticBody3D.new()
|
||||||
street.name = "Street"
|
street.name = "Street"
|
||||||
_add_box(street, Vector3(120, 0.1, 26), Vector3(0, 0.05, 22), Color(1, 1, 1), false, T_ASPHALT, Vector2(30, 7))
|
# The roadway reads darker than the sidewalk; the raw asphalt texture tinted
|
||||||
# Center dashes
|
# white looked like more pavement.
|
||||||
|
_add_box(street, Vector3(120, 0.1, 26), Vector3(0, 0.05, 22), Color(0.52, 0.53, 0.57), false, T_ASPHALT, Vector2(30, 7), "asphalt")
|
||||||
|
# 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(-40, 41, 4):
|
||||||
_add_box(street, Vector3(0.2, 0.02, 1.2), Vector3(i * 0.0, 0.11, 22), Color(0.85, 0.85, 0.30))
|
_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")
|
||||||
|
# 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")
|
||||||
var col := BoxShape3D.new()
|
var col := BoxShape3D.new()
|
||||||
col.size = Vector3(120, 0.2, 26)
|
col.size = Vector3(120, 0.2, 26)
|
||||||
var so := street.create_shape_owner(street)
|
var so := street.create_shape_owner(street)
|
||||||
@@ -215,7 +258,7 @@ var _skyline_mat: StandardMaterial3D = null
|
|||||||
|
|
||||||
func _build_marquee() -> void:
|
func _build_marquee() -> void:
|
||||||
## Striped awning above the door, then the neon sign strip + label.
|
## Striped awning above the door, then the neon sign strip + label.
|
||||||
var awning := _add_box(root, Vector3(10.5, 0.08, 1.8), Vector3(0, 6.55, -3.7), Color(1, 1, 1), false, T_AWNING)
|
var awning := _add_box(root, Vector3(10.5, 0.08, 1.8), Vector3(0, 6.55, -3.7), Color(1, 1, 1), true, T_AWNING, Vector2(4, 1), "awning")
|
||||||
awning.name = "Awning"
|
awning.name = "Awning"
|
||||||
## Neon-style marquee strip above the door: emissive bars + a label.
|
## Neon-style marquee strip above the door: emissive bars + a label.
|
||||||
var strip_parent := Node3D.new()
|
var strip_parent := Node3D.new()
|
||||||
@@ -244,8 +287,8 @@ func _build_street_furniture() -> void:
|
|||||||
for side in [-7.5, 7.5]:
|
for side in [-7.5, 7.5]:
|
||||||
var lamp := Node3D.new()
|
var lamp := Node3D.new()
|
||||||
lamp.name = "Lamp_%d" % int(side)
|
lamp.name = "Lamp_%d" % int(side)
|
||||||
_add_box(lamp, Vector3(0.22, 5.0, 0.22), Vector3(side, 2.5, 8.5), Color(0.16, 0.16, 0.18))
|
_add_box(lamp, Vector3(0.22, 5.0, 0.22), Vector3(side, 2.5, 8.5), Color(0.16, 0.16, 0.18), true, null, Vector2.ONE, "metal")
|
||||||
_add_box(lamp, Vector3(1.6, 0.15, 0.15), Vector3(side + (0.8 if side < 0 else -0.8), 4.9, 8.5), Color(0.16, 0.16, 0.18))
|
_add_box(lamp, Vector3(1.6, 0.15, 0.15), Vector3(side + (0.8 if side < 0 else -0.8), 4.9, 8.5), Color(0.16, 0.16, 0.18), true, null, Vector2.ONE, "metal")
|
||||||
var bulb := _add_box(lamp, Vector3(0.16, 0.16, 0.5), Vector3(side + (0.8 if side < 0 else -0.8), 4.85, 8.5), Color(1, 1, 0.85))
|
var bulb := _add_box(lamp, Vector3(0.16, 0.16, 0.5), Vector3(side + (0.8 if side < 0 else -0.8), 4.85, 8.5), Color(1, 1, 0.85))
|
||||||
var bulb_mat := _emissive_material(Color(1.0, 1.0, 0.8))
|
var bulb_mat := _emissive_material(Color(1.0, 1.0, 0.8))
|
||||||
bulb.material_override = bulb_mat
|
bulb.material_override = bulb_mat
|
||||||
@@ -253,17 +296,15 @@ func _build_street_furniture() -> void:
|
|||||||
_lamp_bulb_mats.append(bulb_mat)
|
_lamp_bulb_mats.append(bulb_mat)
|
||||||
var omni := OmniLight3D.new()
|
var omni := OmniLight3D.new()
|
||||||
omni.position = Vector3(side + (0.8 if side < 0 else -0.8), 4.7, 8.5)
|
omni.position = Vector3(side + (0.8 if side < 0 else -0.8), 4.7, 8.5)
|
||||||
omni.omni_range = 14.0
|
omni.omni_range = 12.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)
|
||||||
lamp.add_child(omni)
|
lamp.add_child(omni)
|
||||||
_lamp_omni.append(omni)
|
_lamp_omni.append(omni)
|
||||||
root.add_child(lamp)
|
root.add_child(lamp)
|
||||||
_street_lamps.append(bulb)
|
_street_lamps.append(bulb)
|
||||||
# Planters beside the steps.
|
# Planters beside the steps were flat boxes that clipped the treads; the
|
||||||
for side in [-4.6, 4.6]:
|
# generated planter_box props replace them in _build_street_detail().
|
||||||
var planter := _add_box(root, Vector3(1.1, 0.7, 1.1), Vector3(side, 0.35, 1.0), Color(0.42, 0.34, 0.28))
|
|
||||||
planter.name = "Planter_%d" % int(side)
|
|
||||||
# Low-poly trees along the street front.
|
# Low-poly trees along the street front.
|
||||||
var trunk_color := Color(0.35, 0.25, 0.18)
|
var trunk_color := Color(0.35, 0.25, 0.18)
|
||||||
var leaf_color := Color(0.16, 0.42, 0.2)
|
var leaf_color := Color(0.16, 0.42, 0.2)
|
||||||
@@ -280,7 +321,7 @@ func _build_street_furniture() -> void:
|
|||||||
trunk_mesh.bottom_radius = 0.14
|
trunk_mesh.bottom_radius = 0.14
|
||||||
trunk_mesh.height = 1.4
|
trunk_mesh.height = 1.4
|
||||||
trunk.mesh = trunk_mesh
|
trunk.mesh = trunk_mesh
|
||||||
trunk.material_override = _plain_material(trunk_color)
|
trunk.material_override = _plain_material(trunk_color, "wood")
|
||||||
trunk.position = Vector3(x, 0.7, z)
|
trunk.position = Vector3(x, 0.7, z)
|
||||||
tree.add_child(trunk)
|
tree.add_child(trunk)
|
||||||
var crown := MeshInstance3D.new()
|
var crown := MeshInstance3D.new()
|
||||||
@@ -289,11 +330,108 @@ func _build_street_furniture() -> void:
|
|||||||
crown_mesh.bottom_radius = 0.85
|
crown_mesh.bottom_radius = 0.85
|
||||||
crown_mesh.height = 2.2
|
crown_mesh.height = 2.2
|
||||||
crown.mesh = crown_mesh
|
crown.mesh = crown_mesh
|
||||||
crown.material_override = _plain_material(leaf_color)
|
crown.material_override = _plain_material(leaf_color, "foliage")
|
||||||
crown.position = Vector3(x, 2.1, z)
|
crown.position = Vector3(x, 2.1, z)
|
||||||
tree.add_child(crown)
|
tree.add_child(crown)
|
||||||
root.add_child(tree)
|
root.add_child(tree)
|
||||||
|
|
||||||
|
## Street furniture built from the generated GLB props (tools/model_pipeline.py).
|
||||||
|
## The meshes are real 3D models (bollards, benches, bins, planters, newspaper
|
||||||
|
## boxes, a hydrant) rather than scaled boxes, and every placement is fixed so
|
||||||
|
## the street looks the same on every run.
|
||||||
|
func _build_street_detail() -> void:
|
||||||
|
var detail := Node3D.new()
|
||||||
|
detail.name = "StreetDetail"
|
||||||
|
root.add_child(detail)
|
||||||
|
# Bollards flank the foot of the steps.
|
||||||
|
for x in [-5.6, -4.2, 4.2, 5.6]:
|
||||||
|
_add_prop_model(detail, "bollard", Vector3(x, 0.18, 4.7))
|
||||||
|
# Benches face the boulevard.
|
||||||
|
_add_prop_model(detail, "bench", Vector3(-8.8, 0.18, 6.4), 180.0)
|
||||||
|
_add_prop_model(detail, "bench", Vector3(8.8, 0.18, 6.4), 180.0)
|
||||||
|
# Bins where people leave the steps.
|
||||||
|
_add_prop_model(detail, "trash_bin", Vector3(-6.6, 0.18, 4.9))
|
||||||
|
_add_prop_model(detail, "trash_bin", Vector3(6.6, 0.18, 4.9))
|
||||||
|
# Planters break up the long sidewalk run.
|
||||||
|
_add_prop_model(detail, "planter_box", Vector3(-11.8, 0.18, 8.8))
|
||||||
|
_add_prop_model(detail, "planter_box", Vector3(11.8, 0.18, 8.8))
|
||||||
|
# Newspaper boxes and a hydrant along the curb.
|
||||||
|
_add_prop_model(detail, "newspaper_box", Vector3(-9.6, 0.18, 11.0), -12.0)
|
||||||
|
_add_prop_model(detail, "newspaper_box", Vector3(-8.9, 0.18, 11.0), -12.0)
|
||||||
|
_add_prop_model(detail, "fire_hydrant", Vector3(8.4, 0.18, 11.2))
|
||||||
|
|
||||||
|
## Place one generated prop. Falls back to simple primitives when the `.glb` is
|
||||||
|
## missing (fresh checkout before `godot --import`) so the world still builds.
|
||||||
|
func _add_prop_model(parent: Node3D, prop: String, pos: Vector3,
|
||||||
|
rot_y_deg: float = 0.0) -> Node3D:
|
||||||
|
var holder := Node3D.new()
|
||||||
|
holder.name = "Prop_%s_%d" % [prop, parent.get_child_count()]
|
||||||
|
holder.position = pos
|
||||||
|
holder.rotation_degrees.y = rot_y_deg
|
||||||
|
var model := _load_prop_scene(prop)
|
||||||
|
if model != null:
|
||||||
|
holder.add_child(model)
|
||||||
|
loaded_models.append(prop)
|
||||||
|
else:
|
||||||
|
holder.add_child(_fallback_prop(prop))
|
||||||
|
fallback_props.append(prop)
|
||||||
|
parent.add_child(holder)
|
||||||
|
return holder
|
||||||
|
|
||||||
|
func _load_prop_scene(prop: String) -> Node3D:
|
||||||
|
var path := MODEL_DIR + prop + ".glb"
|
||||||
|
if not ResourceLoader.exists(path):
|
||||||
|
return null
|
||||||
|
var res: Resource = ResourceLoader.load(path)
|
||||||
|
if res is PackedScene:
|
||||||
|
var node: Node = (res as PackedScene).instantiate()
|
||||||
|
if node is Node3D:
|
||||||
|
return node as Node3D
|
||||||
|
node.queue_free()
|
||||||
|
return null
|
||||||
|
|
||||||
|
## Rough primitive stand-in per prop, used only when the model is unavailable.
|
||||||
|
func _fallback_prop(prop: String) -> Node3D:
|
||||||
|
var node := Node3D.new()
|
||||||
|
match prop:
|
||||||
|
"bollard":
|
||||||
|
_add_cylinder_mesh(node, 0.09, 0.92, Vector3(0, 0.46, 0), Color(0.16, 0.17, 0.19), "metal")
|
||||||
|
"bench":
|
||||||
|
_add_box(node, Vector3(1.8, 0.08, 0.5), Vector3(0, 0.46, 0), Color(0.42, 0.28, 0.17), true, null, Vector2.ONE, "wood")
|
||||||
|
_add_box(node, Vector3(1.8, 0.42, 0.07), Vector3(0, 0.72, -0.22), Color(0.42, 0.28, 0.17), true, null, Vector2.ONE, "wood")
|
||||||
|
for x in [-0.72, 0.72]:
|
||||||
|
_add_box(node, Vector3(0.09, 0.46, 0.46), Vector3(x, 0.23, 0), Color(0.16, 0.17, 0.19), true, null, Vector2.ONE, "metal")
|
||||||
|
"trash_bin":
|
||||||
|
_add_cylinder_mesh(node, 0.27, 0.78, Vector3(0, 0.39, 0), Color(0.22, 0.30, 0.24), "painted_metal")
|
||||||
|
_add_cylinder_mesh(node, 0.29, 0.06, Vector3(0, 0.81, 0), Color(0.16, 0.17, 0.19), "metal")
|
||||||
|
"planter_box":
|
||||||
|
_add_box(node, Vector3(0.96, 0.54, 0.96), Vector3(0, 0.27, 0), Color(0.52, 0.51, 0.48), true, null, Vector2.ONE, "stone")
|
||||||
|
_add_cylinder_mesh(node, 0.30, 0.55, Vector3(0, 0.86, 0), Color(0.18, 0.42, 0.20), "foliage")
|
||||||
|
"newspaper_box":
|
||||||
|
_add_box(node, Vector3(0.42, 0.72, 0.34), Vector3(0, 0.49, 0), Color(0.16, 0.27, 0.45), true, null, Vector2.ONE, "painted_metal")
|
||||||
|
_add_box(node, Vector3(0.44, 0.10, 0.36), Vector3(0, 0.90, 0), Color(0.85, 0.72, 0.28), true, null, Vector2.ONE, "painted_metal")
|
||||||
|
"fire_hydrant":
|
||||||
|
_add_cylinder_mesh(node, 0.15, 0.62, Vector3(0, 0.31, 0), Color(0.6, 0.12, 0.12), "painted_metal")
|
||||||
|
_add_box(node, Vector3(0.52, 0.10, 0.12), Vector3(0, 0.42, 0), Color(0.75, 0.74, 0.70), true, null, Vector2.ONE, "metal")
|
||||||
|
_:
|
||||||
|
_add_box(node, Vector3(0.5, 0.5, 0.5), Vector3(0, 0.25, 0), Color(0.5, 0.5, 0.5))
|
||||||
|
return node
|
||||||
|
|
||||||
|
func _add_cylinder_mesh(parent: Node3D, radius: float, height: float,
|
||||||
|
pos: Vector3, color: Color, role: String = "metal") -> MeshInstance3D:
|
||||||
|
var mi := MeshInstance3D.new()
|
||||||
|
var mesh := CylinderMesh.new()
|
||||||
|
mesh.top_radius = radius
|
||||||
|
mesh.bottom_radius = radius
|
||||||
|
mesh.height = height
|
||||||
|
mesh.radial_segments = 12
|
||||||
|
mi.mesh = mesh
|
||||||
|
mi.position = pos
|
||||||
|
mi.material_override = Materials.flat(color, role)
|
||||||
|
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_ON
|
||||||
|
parent.add_child(mi)
|
||||||
|
return mi
|
||||||
|
|
||||||
func _build_crosswalk() -> void:
|
func _build_crosswalk() -> void:
|
||||||
# White crosswalk stripes in front of the entrance on the street.
|
# White crosswalk stripes in front of the entrance on the street.
|
||||||
for i in 8:
|
for i in 8:
|
||||||
@@ -305,24 +443,24 @@ func set_night_mode(night: bool) -> void:
|
|||||||
for mat in _lamp_bulb_mats:
|
for mat in _lamp_bulb_mats:
|
||||||
mat.emission_energy_multiplier = (2.0 if night else 0.0)
|
mat.emission_energy_multiplier = (2.0 if night else 0.0)
|
||||||
for omni in _lamp_omni:
|
for omni in _lamp_omni:
|
||||||
omni.light_energy = (3.0 if night else 0.0)
|
# Two lamps at full energy washed the whole sidewalk grey; keep visible
|
||||||
|
# pools of light instead of flat night-time brightness.
|
||||||
|
omni.light_energy = (1.8 if night else 0.0)
|
||||||
for mat in _marquee_emissive:
|
for mat in _marquee_emissive:
|
||||||
mat.emission_energy_multiplier = (2.0 if night else 0.2)
|
mat.emission_energy_multiplier = (2.0 if night else 0.2)
|
||||||
|
# Light most, not all, of the windows: a fully lit grid reads as a light box
|
||||||
|
# instead of an occupied building.
|
||||||
|
for i in _window_mats.size():
|
||||||
|
var lit := night and i % 3 != 2
|
||||||
|
_window_mats[i].emission_energy_multiplier = (1.1 if lit else 0.0)
|
||||||
if marquee_label:
|
if marquee_label:
|
||||||
marquee_label.modulate.a = (1.0 if night else 0.9)
|
marquee_label.modulate.a = (1.0 if night else 0.9)
|
||||||
|
|
||||||
func _emissive_material(color: Color) -> StandardMaterial3D:
|
func _emissive_material(color: Color) -> StandardMaterial3D:
|
||||||
var mat := StandardMaterial3D.new()
|
return Materials.emissive(color)
|
||||||
mat.albedo_color = color
|
|
||||||
mat.emission_enabled = true
|
|
||||||
mat.emission = color
|
|
||||||
mat.emission_energy_multiplier = 1.0
|
|
||||||
return mat
|
|
||||||
|
|
||||||
func _plain_material(color: Color) -> StandardMaterial3D:
|
func _plain_material(color: Color, role: String = "concrete") -> StandardMaterial3D:
|
||||||
var mat := StandardMaterial3D.new()
|
return Materials.flat(color, role)
|
||||||
mat.albedo_color = color
|
|
||||||
return mat
|
|
||||||
|
|
||||||
func _build_guard_spawn() -> void:
|
func _build_guard_spawn() -> void:
|
||||||
var marker := Marker3D.new()
|
var marker := Marker3D.new()
|
||||||
|
|||||||
@@ -93,8 +93,53 @@ func _advance() -> void:
|
|||||||
if state_frames >= 5:
|
if state_frames >= 5:
|
||||||
if gm.radial.is_open:
|
if gm.radial.is_open:
|
||||||
_fail("action wheel did not close on cancel")
|
_fail("action wheel did not close on cancel")
|
||||||
|
state = 4
|
||||||
|
state_frames = 0
|
||||||
|
4:
|
||||||
|
# Aim-free selection (ADR-0011): the cycled target stays pinned, the
|
||||||
|
# HUD reports how many people can be cycled, and the mouse wheel is
|
||||||
|
# bound to the same ring.
|
||||||
|
if state_frames >= 5:
|
||||||
|
if gm.targeted_npc == null:
|
||||||
|
_fail("target lost after the wheel closed")
|
||||||
|
if not gm.guard.target_pinned:
|
||||||
|
_fail("cycled target was not pinned")
|
||||||
|
if gm.hud.nearby_label.text == "":
|
||||||
|
_fail("nearby hint empty while people are in range")
|
||||||
|
_wheel(true)
|
||||||
|
state = 5
|
||||||
|
state_frames = 0
|
||||||
|
5:
|
||||||
|
if state_frames >= 5:
|
||||||
|
if gm.targeted_npc == null:
|
||||||
|
_fail("mouse wheel dropped the target")
|
||||||
|
elif not gm.guard.target_pinned:
|
||||||
|
_fail("mouse wheel did not pin the cycled target")
|
||||||
|
print("smoke: wheel cycled to %s" % gm.targeted_npc.name)
|
||||||
|
# Right click with nothing open returns to cone focus.
|
||||||
|
gm.call("_handle_cancel", true)
|
||||||
|
state = 6
|
||||||
|
state_frames = 0
|
||||||
|
6:
|
||||||
|
if state_frames >= 5:
|
||||||
|
if gm.targeted_npc != null:
|
||||||
|
_fail("right click did not clear the target")
|
||||||
|
if gm.guard.target_pinned:
|
||||||
|
_fail("clearing the target left it pinned")
|
||||||
_finish()
|
_finish()
|
||||||
|
|
||||||
|
## Synthesised mouse-wheel event, fed through the real handler so the input-map
|
||||||
|
## binding is exercised as well as the cycling logic.
|
||||||
|
func _wheel(down: bool) -> void:
|
||||||
|
var ev := InputEventMouseButton.new()
|
||||||
|
ev.button_index = MOUSE_BUTTON_WHEEL_DOWN if down else MOUSE_BUTTON_WHEEL_UP
|
||||||
|
ev.pressed = true
|
||||||
|
ev.position = Vector2(640, 360)
|
||||||
|
ev.global_position = ev.position
|
||||||
|
if not ev.is_action_pressed("cycle_target_next"):
|
||||||
|
_fail("cycle_target_next is not bound to the mouse wheel")
|
||||||
|
gm.call("_handle_mouse_button", ev)
|
||||||
|
|
||||||
func _stage_target() -> void:
|
func _stage_target() -> void:
|
||||||
if stage_npc == null or not is_instance_valid(stage_npc):
|
if stage_npc == null or not is_instance_valid(stage_npc):
|
||||||
stage_npc = gm.npcs[0] if gm.npcs.size() > 0 else null
|
stage_npc = gm.npcs[0] if gm.npcs.size() > 0 else null
|
||||||
@@ -127,7 +172,7 @@ func _fail(message: String) -> void:
|
|||||||
|
|
||||||
func _finish() -> void:
|
func _finish() -> void:
|
||||||
if failures.is_empty():
|
if failures.is_empty():
|
||||||
print("SMOKE OK: focus circle + action wheel opened and cancelled")
|
print("SMOKE OK: focus circle + action wheel + aim-free cycling")
|
||||||
quit(0)
|
quit(0)
|
||||||
else:
|
else:
|
||||||
for message in failures:
|
for message in failures:
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Headless tests for the generated prop models (tools/model_pipeline.py) and
|
||||||
|
## their placement in the procedural world.
|
||||||
|
|
||||||
|
const WorldBuilder := preload("res://src/game/world_builder.gd")
|
||||||
|
const MODEL_DIR := "res://assets/models/generated/"
|
||||||
|
|
||||||
|
func test_prop_glb_files_exist() -> bool:
|
||||||
|
var missing: Array[String] = []
|
||||||
|
for prop in WorldBuilder.MODEL_PROPS:
|
||||||
|
if not FileAccess.file_exists(MODEL_DIR + str(prop) + ".glb"):
|
||||||
|
missing.append(str(prop))
|
||||||
|
return TestUtils.check(missing.is_empty(), "all prop models built (missing: %s)" % str(missing))
|
||||||
|
|
||||||
|
func test_prop_glb_files_are_imported_scenes() -> bool:
|
||||||
|
## `load()` only returns a PackedScene once Godot has imported the `.glb`;
|
||||||
|
## a null here means the checkout is missing the import or the model is invalid.
|
||||||
|
var problems: Array[String] = []
|
||||||
|
for prop in WorldBuilder.MODEL_PROPS:
|
||||||
|
var path := MODEL_DIR + str(prop) + ".glb"
|
||||||
|
var packed := ResourceLoader.load(path) as PackedScene
|
||||||
|
if packed == null:
|
||||||
|
problems.append("%s not imported as a scene" % prop)
|
||||||
|
elif not FileAccess.file_exists(path + ".import"):
|
||||||
|
problems.append("%s has no .import file" % prop)
|
||||||
|
return TestUtils.check(problems.is_empty(), "prop models import as scenes (%s)" % str(problems))
|
||||||
|
|
||||||
|
func test_prop_scenes_instantiate_with_meshes() -> bool:
|
||||||
|
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)
|
||||||
|
if meshes.is_empty():
|
||||||
|
problems.append("%s: no mesh instances" % prop)
|
||||||
|
instance.free()
|
||||||
|
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:
|
||||||
|
var root := Node3D.new()
|
||||||
|
var builder := WorldBuilder.new(root)
|
||||||
|
builder.build()
|
||||||
|
var detail := root.get_node_or_null("StreetDetail")
|
||||||
|
var a := TestUtils.check(detail != null, "StreetDetail exists")
|
||||||
|
var placed: int = detail.get_child_count() if detail else 0
|
||||||
|
var b := TestUtils.check(placed >= 10, "street furniture placed (%d)" % placed)
|
||||||
|
var c := TestUtils.check(builder.loaded_models.size() >= WorldBuilder.MODEL_PROPS.size(),
|
||||||
|
"generated models loaded instead of fallbacks")
|
||||||
|
var d := TestUtils.check(builder.fallback_props.is_empty(),
|
||||||
|
"no procedural fallback used (fallbacks: %s)" % str(builder.fallback_props))
|
||||||
|
root.free()
|
||||||
|
return a and b and c and d
|
||||||
|
|
||||||
|
func test_world_facade_windows_light_at_night() -> bool:
|
||||||
|
var root := Node3D.new()
|
||||||
|
var builder := WorldBuilder.new(root)
|
||||||
|
builder.build()
|
||||||
|
var a := TestUtils.check(builder._window_mats.size() >= 8, "window panes built")
|
||||||
|
builder.set_night_mode(true)
|
||||||
|
var lit: bool = builder._window_mats[0].emission_energy_multiplier > 0.0
|
||||||
|
builder.set_night_mode(false)
|
||||||
|
var dark: bool = is_zero_approx(builder._window_mats[0].emission_energy_multiplier)
|
||||||
|
root.free()
|
||||||
|
return a and TestUtils.check(lit, "windows light up at night") and TestUtils.check(dark, "windows are dark by day")
|
||||||
|
|
||||||
|
func test_fallback_prop_is_usable() -> bool:
|
||||||
|
var root := Node3D.new()
|
||||||
|
var builder := WorldBuilder.new(root)
|
||||||
|
var fallback := builder._fallback_prop("bench")
|
||||||
|
var meshes := fallback.find_children("*", "MeshInstance3D", true, false)
|
||||||
|
var ok := TestUtils.check(meshes.size() >= 3, "fallback bench has geometry")
|
||||||
|
fallback.free()
|
||||||
|
root.free()
|
||||||
|
return ok
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Headless tests for the shared surface/environment recipes (ADR-0012).
|
||||||
|
|
||||||
|
const Materials := preload("res://src/game/materials.gd")
|
||||||
|
const GameManager := preload("res://src/game/game_manager.gd")
|
||||||
|
const Rules := preload("res://src/core/rules.gd")
|
||||||
|
|
||||||
|
func test_every_profile_has_physical_values() -> bool:
|
||||||
|
var ok := true
|
||||||
|
for role in ["brick", "concrete", "asphalt", "awning", "stone", "metal",
|
||||||
|
"painted_metal", "wood", "cloth", "skin", "glass", "rubber"]:
|
||||||
|
var profile: Dictionary = Materials.profile(role)
|
||||||
|
var roughness: float = float(profile.get("roughness", -1.0))
|
||||||
|
var metallic: float = float(profile.get("metallic", -1.0))
|
||||||
|
if roughness < 0.0 or roughness > 1.0:
|
||||||
|
ok = false
|
||||||
|
if metallic < 0.0 or metallic > 1.0:
|
||||||
|
ok = false
|
||||||
|
return TestUtils.check(ok, "all roles have roughness/metallic in 0..1")
|
||||||
|
|
||||||
|
func test_textured_recipes_keep_their_texture_and_profile() -> bool:
|
||||||
|
var brick := Materials.brick()
|
||||||
|
var concrete := Materials.concrete()
|
||||||
|
var asphalt := Materials.asphalt()
|
||||||
|
var awning := Materials.awning()
|
||||||
|
var a := TestUtils.check(brick.albedo_texture != null, "brick keeps its texture")
|
||||||
|
var b := TestUtils.check(concrete.uv1_scale.x > 1.0, "concrete tiles across the sidewalk")
|
||||||
|
var c := TestUtils.check(asphalt.roughness < brick.roughness, "asphalt is smoother than brick")
|
||||||
|
var d := TestUtils.check(awning.albedo_texture != null, "awning keeps its texture")
|
||||||
|
var e := TestUtils.check(Materials.flat(Color.RED, "metal").metallic > 0.5, "metal role is metallic")
|
||||||
|
return a and b and c and d and e
|
||||||
|
|
||||||
|
func test_glass_and_emissive_recipes() -> bool:
|
||||||
|
var glass := Materials.glass()
|
||||||
|
var pane := Materials.window_pane()
|
||||||
|
var bar := Materials.emissive(Color(1.0, 0.35, 0.75), 2.5)
|
||||||
|
var a := TestUtils.check(glass.transparency == BaseMaterial3D.TRANSPARENCY_ALPHA,
|
||||||
|
"glass is alpha blended")
|
||||||
|
var b := TestUtils.check(glass.roughness < 0.2, "glass is smooth")
|
||||||
|
var c := TestUtils.check(pane.emission_enabled, "window panes can light up")
|
||||||
|
var d := TestUtils.check(is_equal_approx(pane.emission_energy_multiplier, 0.0),
|
||||||
|
"window panes start dark")
|
||||||
|
var e := TestUtils.check(is_equal_approx(bar.emission_energy_multiplier, 2.5),
|
||||||
|
"emissive energy is applied")
|
||||||
|
return a and b and c and d and e
|
||||||
|
|
||||||
|
func test_environment_tuning_applies_phase_values() -> bool:
|
||||||
|
var env := Environment.new()
|
||||||
|
var late: Dictionary = GameManager.PHASE_LIGHTING[Rules.Phase.LATE]
|
||||||
|
Materials.configure_environment(env, late)
|
||||||
|
var a := TestUtils.check(env.tonemap_mode == Environment.TONE_MAPPER_ACES,
|
||||||
|
"ACES tonemapping")
|
||||||
|
var b := TestUtils.check(env.glow_enabled, "glow enabled for the marquee")
|
||||||
|
var c := TestUtils.check(env.fog_enabled, "fog enabled for distance haze")
|
||||||
|
var d := TestUtils.check(env.fog_density > 0.0, "fog has a density")
|
||||||
|
var e := TestUtils.check(env.adjustment_enabled, "colour grading enabled")
|
||||||
|
var f := TestUtils.check(
|
||||||
|
env.fog_light_color.is_equal_approx(late["fog_color"]),
|
||||||
|
"phase fog colour is used")
|
||||||
|
return a and b and c and d and e and f
|
||||||
|
|
||||||
|
func test_every_phase_has_environment_keys() -> bool:
|
||||||
|
var ok := true
|
||||||
|
for phase in [Rules.Phase.EARLY, Rules.Phase.MIDDAY, Rules.Phase.EVENING, Rules.Phase.LATE]:
|
||||||
|
var cfg: Dictionary = GameManager.PHASE_LIGHTING.get(phase, {})
|
||||||
|
for key in ["exposure", "glow", "fog_color", "fog_density", "fog_energy",
|
||||||
|
"contrast", "saturation"]:
|
||||||
|
if not cfg.has(key):
|
||||||
|
ok = false
|
||||||
|
return TestUtils.check(ok, "all phases set exposure/glow/fog/grading")
|
||||||
|
|
||||||
|
func test_night_phases_are_night() -> bool:
|
||||||
|
var evening: Dictionary = GameManager.PHASE_LIGHTING[Rules.Phase.EVENING]
|
||||||
|
var late: Dictionary = GameManager.PHASE_LIGHTING[Rules.Phase.LATE]
|
||||||
|
var early: Dictionary = GameManager.PHASE_LIGHTING[Rules.Phase.EARLY]
|
||||||
|
return TestUtils.check(bool(evening["night"]) and bool(late["night"]) and not bool(early["night"]),
|
||||||
|
"night flag matches the phase")
|
||||||
@@ -23,6 +23,70 @@ func test_strings_json_backed_all_resolve() -> bool:
|
|||||||
return TestUtils.fail("key %s resolves to itself — table entry missing" % key)
|
return TestUtils.fail("key %s resolves to itself — table entry missing" % key)
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
func test_placeholder_count() -> bool:
|
||||||
|
var a := TestUtils.check_eq(Strings.placeholder_count("no args"), 0, "plain text has no placeholders")
|
||||||
|
var b := TestUtils.check_eq(Strings.placeholder_count("%s complies"), 1, "one %s")
|
||||||
|
var c := TestUtils.check_eq(Strings.placeholder_count("%s: \"%s\""), 2, "two %s")
|
||||||
|
var d := TestUtils.check_eq(Strings.placeholder_count("100%% sure"), 0, "%% is an escape, not an argument")
|
||||||
|
var e := TestUtils.check_eq(Strings.placeholder_count("%.0f%% mood"), 1, "float plus percent sign")
|
||||||
|
return a and b and c and d and e
|
||||||
|
|
||||||
|
func test_fmt_fails_safe_on_arg_mismatch() -> bool:
|
||||||
|
## A drifting placeholder count must not raise an engine error at runtime.
|
||||||
|
var result: String = Strings.fmt("react.comply", [])
|
||||||
|
var a := TestUtils.check_eq(result, Strings.lookup("react.comply"), "wrong arg count returns the raw template")
|
||||||
|
var extra: String = Strings.fmt("react.comply", ["A", "B"])
|
||||||
|
var b := TestUtils.check_eq(extra, Strings.lookup("react.comply"), "too many args also fails safe")
|
||||||
|
return a and b
|
||||||
|
|
||||||
|
func test_table_validates_cleanly() -> bool:
|
||||||
|
var problems: Array = Strings.validate()
|
||||||
|
if not problems.is_empty():
|
||||||
|
return TestUtils.fail("string table format problems: %s" % str(problems))
|
||||||
|
return true
|
||||||
|
|
||||||
|
func test_every_placeholder_key_formats() -> bool:
|
||||||
|
## Feed each template one dummy arg per placeholder, with a type that matches
|
||||||
|
## the conversion (%s text, %d int, %f float). Nothing may be left unformatted.
|
||||||
|
var problems: Array[String] = []
|
||||||
|
for key in Strings.TABLE.keys():
|
||||||
|
var template: String = str(Strings.TABLE[key])
|
||||||
|
var specs: Array[String] = _specifiers(template)
|
||||||
|
if specs.is_empty():
|
||||||
|
continue
|
||||||
|
var args := []
|
||||||
|
for spec in specs:
|
||||||
|
var conv: String = spec.substr(spec.length() - 1)
|
||||||
|
if conv == "d" or conv == "x" or conv == "X":
|
||||||
|
args.append(7)
|
||||||
|
elif conv == "f":
|
||||||
|
args.append(1.5)
|
||||||
|
else:
|
||||||
|
args.append("x")
|
||||||
|
var out: String = Strings.fmt(key, args)
|
||||||
|
if out == template:
|
||||||
|
problems.append("%s did not format (%s)" % [key, str(specs)])
|
||||||
|
return TestUtils.check(problems.is_empty(), "all placeholder keys format (problems: %s)" % str(problems))
|
||||||
|
|
||||||
|
func _specifiers(template: String) -> Array[String]:
|
||||||
|
var out: Array[String] = []
|
||||||
|
var i := 0
|
||||||
|
while i < template.length():
|
||||||
|
if template[i] != "%":
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if i + 1 < template.length() and template[i + 1] == "%":
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
var j := i + 1
|
||||||
|
while j < template.length() and template[j] in "0123456789.$":
|
||||||
|
j += 1
|
||||||
|
if j < template.length():
|
||||||
|
out.append(template.substr(i, j - i + 1))
|
||||||
|
j += 1
|
||||||
|
i = j
|
||||||
|
return out
|
||||||
|
|
||||||
func test_strings_cover_ui_keys() -> bool:
|
func test_strings_cover_ui_keys() -> bool:
|
||||||
## Every key referenced by the HUD and game manager must exist.
|
## Every key referenced by the HUD and game manager must exist.
|
||||||
var required := [
|
var required := [
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Headless tests for focus ranking, aim-free cycling helpers, and the NPC
|
||||||
|
## outfit/gait helpers added in the control re-evaluation (ADR-0011).
|
||||||
|
|
||||||
|
const NpcFocus := preload("res://src/game/npc_focus.gd")
|
||||||
|
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
|
||||||
|
const Rules := preload("res://src/core/rules.gd")
|
||||||
|
|
||||||
|
func test_rank_orders_by_angle_then_distance() -> bool:
|
||||||
|
var origin := Vector3.ZERO
|
||||||
|
var forward := Vector3(0, 0, -1)
|
||||||
|
# Index 2 is dead ahead and near, index 0 dead ahead but far, index 1 off to
|
||||||
|
# the side. Ranking must be angle first, then distance.
|
||||||
|
var heads := [Vector3(0, 0, -9), Vector3(0.9, 0, -6), Vector3(0, 0, -5)]
|
||||||
|
var ranked: Array = NpcFocus.rank_indices(origin, forward, heads)
|
||||||
|
var a := TestUtils.check_eq(ranked, [2, 0, 1], "angle first, then distance")
|
||||||
|
var b := TestUtils.check_eq(NpcFocus.pick_index(origin, forward, heads), 2,
|
||||||
|
"pick_index returns the best-ranked head")
|
||||||
|
return a and b
|
||||||
|
|
||||||
|
func test_rank_excludes_out_of_cone_and_range() -> bool:
|
||||||
|
var origin := Vector3.ZERO
|
||||||
|
var forward := Vector3(0, 0, -1)
|
||||||
|
var heads := [
|
||||||
|
Vector3(0, 0, 40), # too far
|
||||||
|
Vector3(8, 0, -5), # too wide
|
||||||
|
Vector3(0, 0, 6), # behind the camera
|
||||||
|
Vector3(0.2, 0, -7), # valid
|
||||||
|
]
|
||||||
|
var ranked: Array = NpcFocus.rank_indices(origin, forward, heads)
|
||||||
|
return TestUtils.check_eq(ranked, [3], "only the in-cone head is ranked")
|
||||||
|
|
||||||
|
func test_rank_is_empty_when_nobody_is_in_the_cone() -> bool:
|
||||||
|
var ranked: Array = NpcFocus.rank_indices(
|
||||||
|
Vector3.ZERO, Vector3(0, 0, -1), [Vector3(0, 0, 30)])
|
||||||
|
var picked := NpcFocus.pick_index(Vector3.ZERO, Vector3(0, 0, -1), [Vector3(0, 0, 30)])
|
||||||
|
return TestUtils.check(ranked.is_empty() and picked == -1, "empty ranking and -1 pick")
|
||||||
|
|
||||||
|
func test_limb_swing_is_opposite_per_side() -> bool:
|
||||||
|
var left := NpcAvatar.limb_swing(PI / 2.0, 0, 1.0)
|
||||||
|
var right := NpcAvatar.limb_swing(PI / 2.0, 1, 1.0)
|
||||||
|
var still := NpcAvatar.limb_swing(0.0, 0, 1.0)
|
||||||
|
var a := TestUtils.check(absf(left - 1.0) < 0.001, "left limb swings to +amplitude")
|
||||||
|
var b := TestUtils.check(absf(right + 1.0) < 0.001, "right limb swings the other way")
|
||||||
|
var c := TestUtils.check(absf(still) < 0.001, "phase 0 is the neutral pose")
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_outfit_is_deterministic_per_seed() -> bool:
|
||||||
|
var first: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, 991)
|
||||||
|
var second: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, 991)
|
||||||
|
var a := TestUtils.check(first["shirt"] == second["shirt"], "same seed, same shirt")
|
||||||
|
var b := TestUtils.check(first["pants"] == second["pants"], "same seed, same trousers")
|
||||||
|
var c := TestUtils.check(first["accessory"] == second["accessory"], "same seed, same accessory")
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_outfit_varies_across_seeds() -> bool:
|
||||||
|
var shirts := {}
|
||||||
|
var pants := {}
|
||||||
|
for seed_value in [1, 2, 3, 4, 5, 6, 7, 8]:
|
||||||
|
var outfit: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, seed_value * 977)
|
||||||
|
shirts[outfit["shirt"]] = true
|
||||||
|
pants[outfit["pants"]] = true
|
||||||
|
var a := TestUtils.check(shirts.size() > 1, "shirt colour varies between people")
|
||||||
|
var b := TestUtils.check(pants.size() > 1, "trouser colour varies between people")
|
||||||
|
return a and b
|
||||||
|
|
||||||
|
func test_accessories_follow_the_role() -> bool:
|
||||||
|
var a := TestUtils.check_eq(
|
||||||
|
NpcAvatar.outfit_for(Rules.NpcType.RESPONDER, 5)["accessory"], "cap",
|
||||||
|
"responders wear a cap")
|
||||||
|
var b := TestUtils.check_eq(
|
||||||
|
NpcAvatar.outfit_for(Rules.NpcType.TRESPASSER, 5)["accessory"], "hood",
|
||||||
|
"trespassers wear a hood")
|
||||||
|
var c := TestUtils.check_eq(
|
||||||
|
NpcAvatar.outfit_for(Rules.NpcType.AUTHORIZED_GUEST, 5)["accessory"], "lanyard",
|
||||||
|
"guests wear a lanyard")
|
||||||
|
var d := TestUtils.check_eq(
|
||||||
|
NpcAvatar.outfit_for(Rules.NpcType.INTOXICATED, 5)["accessory"], "none",
|
||||||
|
"roles without an accessory say none")
|
||||||
|
return a and b and c and d
|
||||||
|
|
||||||
|
func test_height_scale_stays_human() -> bool:
|
||||||
|
var ok := true
|
||||||
|
for seed_value in range(1, 40):
|
||||||
|
var scale_y: float = float(NpcAvatar.outfit_for(Rules.NpcType.TOURIST, seed_value * 131)["height_scale"])
|
||||||
|
if scale_y < 0.9 or scale_y > 1.1:
|
||||||
|
ok = false
|
||||||
|
return TestUtils.check(ok, "avatar height scale stays between 0.9 and 1.1")
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Venues can be authored as `.tres` resources: the built-in factories export and
|
||||||
|
## round-trip without losing their data (optional editor workflow, TODO tooling).
|
||||||
|
|
||||||
|
const Factory := preload("res://src/content/scenario_factory.gd")
|
||||||
|
const Rules := preload("res://src/core/rules.gd")
|
||||||
|
const VenueScenario := preload("res://src/core/venue_scenario.gd")
|
||||||
|
|
||||||
|
func test_venues_round_trip_through_tres() -> bool:
|
||||||
|
var scenarios := [Factory.hollywood_building(), Factory.joke_shop(), Factory.tv_studio()]
|
||||||
|
for scenario in scenarios:
|
||||||
|
var path := "user://%s_roundtrip.tres" % scenario.id
|
||||||
|
var err: int = ResourceSaver.save(scenario, path)
|
||||||
|
if err != OK:
|
||||||
|
return TestUtils.fail("could not save %s (error %d)" % [scenario.id, err])
|
||||||
|
var loaded = ResourceLoader.load(path)
|
||||||
|
if not (loaded is VenueScenario):
|
||||||
|
return TestUtils.fail("%s did not load back as VenueScenario" % scenario.id)
|
||||||
|
var a := TestUtils.check_eq(loaded.id, scenario.id, "%s id survives" % scenario.id)
|
||||||
|
var b := TestUtils.check_eq(loaded.display_name, scenario.display_name, "%s name survives" % scenario.id)
|
||||||
|
var c := TestUtils.check_eq(loaded.event_pool.size(), scenario.event_pool.size(), "%s event pool survives" % scenario.id)
|
||||||
|
var d := TestUtils.check_eq(loaded.npc_profiles.size(), scenario.npc_profiles.size(), "%s profiles survive" % scenario.id)
|
||||||
|
var e := TestUtils.check_eq(loaded.waypoint_for_intent("sit"), scenario.waypoint_for_intent("sit"), "%s locations survive" % scenario.id)
|
||||||
|
if not (a and b and c and d and e):
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
|
func test_exported_tres_files_match_the_factory() -> bool:
|
||||||
|
## content/venues/*.tres is generated by tools/export_venues.gd; make sure it is
|
||||||
|
## present and not stale relative to the factory.
|
||||||
|
for scenario in [Factory.hollywood_building(), Factory.joke_shop(), Factory.tv_studio()]:
|
||||||
|
var path := "res://content/venues/%s.tres" % scenario.id
|
||||||
|
if not FileAccess.file_exists(path):
|
||||||
|
return TestUtils.fail("missing exported venue %s — run tools/export_venues.gd" % path)
|
||||||
|
var loaded = ResourceLoader.load(path)
|
||||||
|
if not (loaded is VenueScenario):
|
||||||
|
return TestUtils.fail("%s is not a VenueScenario" % path)
|
||||||
|
if loaded.event_pool.size() != scenario.event_pool.size():
|
||||||
|
return TestUtils.fail("%s is stale (events %d vs %d) — re-run tools/export_venues.gd" % [
|
||||||
|
path, loaded.event_pool.size(), scenario.event_pool.size()])
|
||||||
|
if not TestUtils.check_eq(loaded.shift_duration_s, scenario.shift_duration_s, "%s duration matches" % scenario.id):
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
|
func test_exported_policy_and_events_intact() -> bool:
|
||||||
|
var loaded = ResourceLoader.load("res://content/venues/joke_shop.tres")
|
||||||
|
var a := TestUtils.check(loaded != null and loaded.access_policy != null, "access policy survives export")
|
||||||
|
var b := TestUtils.check_eq(loaded.access_policy.require_id, true, "joke shop still requires ID")
|
||||||
|
var c := TestUtils.check_eq(loaded.access_policy.wand_screening_required, true, "wand screening still required")
|
||||||
|
var d := TestUtils.check_eq(loaded.equipment.has("wand"), true, "equipment survives export")
|
||||||
|
var ids: Array = []
|
||||||
|
for event in loaded.event_pool:
|
||||||
|
ids.append(str((event as Dictionary).get("id", "")))
|
||||||
|
var e := TestUtils.check(ids.has("joke_refuses_wand"), "authored events survive export")
|
||||||
|
var f := TestUtils.check(loaded.door_staff_lines.has("refusal"), "door-staff lines survive export")
|
||||||
|
return a and b and c and d and e and f
|
||||||
|
|
||||||
|
func test_exported_phases_survive() -> bool:
|
||||||
|
var loaded = ResourceLoader.load("res://content/venues/hollywood_building.tres")
|
||||||
|
var a := TestUtils.check(loaded.shift_phases.has(Rules.Phase.LATE), "late phase survives export")
|
||||||
|
var b := TestUtils.check_eq(int(loaded.shift_phases[Rules.Phase.LATE].get("start_s", -1)), 700, "late start preserved")
|
||||||
|
return a and b
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Web export target: the preset exists, uses the non-threaded template (so it
|
||||||
|
## runs on plain static hosting without COOP/COEP headers), and the release
|
||||||
|
## scripts build and publish it.
|
||||||
|
|
||||||
|
func _preset() -> String:
|
||||||
|
## Return the `[preset.N]` block whose name is Web plus its `.options` block.
|
||||||
|
var lines := FileAccess.get_file_as_string("res://export_presets.cfg").split("\n")
|
||||||
|
var sections := {}
|
||||||
|
var current := ""
|
||||||
|
var web_id := ""
|
||||||
|
for line in lines:
|
||||||
|
if line.begins_with("[") and line.ends_with("]"):
|
||||||
|
current = line
|
||||||
|
sections[current] = PackedStringArray()
|
||||||
|
continue
|
||||||
|
if sections.has(current):
|
||||||
|
# PackedStringArray is a value type: mutate a local and write it back.
|
||||||
|
var block: PackedStringArray = sections[current]
|
||||||
|
block.append(line)
|
||||||
|
sections[current] = block
|
||||||
|
if line == 'name="Web"':
|
||||||
|
web_id = current.trim_prefix("[").trim_suffix("]")
|
||||||
|
if web_id == "":
|
||||||
|
return ""
|
||||||
|
var out: PackedStringArray = []
|
||||||
|
out.append("[%s]" % web_id)
|
||||||
|
out.append_array(sections.get("[%s]" % web_id, PackedStringArray()))
|
||||||
|
var options := "[%s.options]" % web_id
|
||||||
|
if sections.has(options):
|
||||||
|
out.append(options)
|
||||||
|
out.append_array(sections[options])
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
|
func test_web_preset_exists() -> bool:
|
||||||
|
var preset := _preset()
|
||||||
|
var a := TestUtils.check(preset != "", "a Web export preset exists")
|
||||||
|
var b := TestUtils.check(preset.contains('platform="Web"'), "preset targets the Web platform")
|
||||||
|
var c := TestUtils.check(preset.contains('export_path="build/web/index.html"'), "preset writes build/web/index.html")
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_web_preset_is_static_hosting_friendly() -> bool:
|
||||||
|
var preset := _preset()
|
||||||
|
return TestUtils.check(preset.contains("variant/thread_support=false"),
|
||||||
|
"threads are disabled so no COOP/COEP headers are required")
|
||||||
|
|
||||||
|
func test_release_scripts_build_the_web_zip() -> bool:
|
||||||
|
var build := FileAccess.get_file_as_string("res://tools/build_releases.sh")
|
||||||
|
var a := TestUtils.check(build.contains('--export-release "Web"'), "build script exports Web")
|
||||||
|
var b := TestUtils.check(build.contains("step-line-web-${VERSION}.zip"), "build script names the web zip")
|
||||||
|
var publish := FileAccess.get_file_as_string("res://tools/publish_release.sh")
|
||||||
|
var c := TestUtils.check(publish.contains("step-line-web-$VERSION.zip"), "publish script uploads the web zip")
|
||||||
|
return a and b and c
|
||||||
|
|
||||||
|
func test_web_build_files_present_when_exported() -> bool:
|
||||||
|
## Only asserts shape if a web build exists in build/web (created by the
|
||||||
|
## release script); missing build output is not a test failure by itself.
|
||||||
|
var index := "res://build/web/index.html"
|
||||||
|
if not FileAccess.file_exists(index):
|
||||||
|
return true
|
||||||
|
var html := FileAccess.get_file_as_string(index)
|
||||||
|
var a := TestUtils.check(html.contains("index.js"), "index.html loads index.js")
|
||||||
|
var b := TestUtils.check(FileAccess.file_exists("res://build/web/index.wasm"), "wasm payload exists")
|
||||||
|
var c := TestUtils.check(FileAccess.file_exists("res://build/web/index.pck"), "pck payload exists")
|
||||||
|
return a and b and c
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
# build/step-line-linux-<version>.x86_64
|
# build/step-line-linux-<version>.x86_64
|
||||||
# build/step-line-windows-<version>.exe
|
# build/step-line-windows-<version>.exe
|
||||||
# build/step-line-macos-<version>.zip
|
# build/step-line-macos-<version>.zip
|
||||||
|
# build/step-line-web-<version>.zip (Godot web build: static hosting, no threads)
|
||||||
#
|
#
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -28,6 +29,26 @@ echo "==> Exporting Windows Desktop"
|
|||||||
echo "==> Exporting macOS"
|
echo "==> Exporting macOS"
|
||||||
"$GODOT" --headless --export-release "macOS" "build/step-line-macos-${VERSION}.zip"
|
"$GODOT" --headless --export-release "macOS" "build/step-line-macos-${VERSION}.zip"
|
||||||
|
|
||||||
|
echo "==> Exporting Web (no threads, static-hosting friendly)"
|
||||||
|
rm -rf build/web
|
||||||
|
mkdir -p build/web
|
||||||
|
"$GODOT" --headless --export-release "Web" "build/web/index.html"
|
||||||
|
# Ship the web build as a single zip so it can be downloaded and served as-is.
|
||||||
|
rm -f "build/step-line-web-${VERSION}.zip"
|
||||||
|
if command -v zip >/dev/null 2>&1; then
|
||||||
|
(cd build/web && zip -q -r "../step-line-web-${VERSION}.zip" .)
|
||||||
|
else
|
||||||
|
python3 - "$PWD/build/web" "$PWD/build/step-line-web-${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
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Artifacts:"
|
echo "Artifacts:"
|
||||||
ls -la build/*"${VERSION}"*
|
ls -la build/*"${VERSION}"*
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Export the built-in scenarios to `.tres` resources for editor authoring.
|
||||||
|
##
|
||||||
|
## Usage:
|
||||||
|
## godot --headless --path . --script res://tools/export_venues.gd
|
||||||
|
##
|
||||||
|
## Writes content/venues/<id>.tres. These mirror ScenarioFactory exactly; the
|
||||||
|
## factory stays the source of truth for the prototype, and the exported files are
|
||||||
|
## what an editor-based workflow would edit and ship.
|
||||||
|
|
||||||
|
const Factory := preload("res://src/content/scenario_factory.gd")
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
var out_dir := "res://content/venues"
|
||||||
|
DirAccess.make_dir_recursive_absolute(out_dir)
|
||||||
|
var scenarios := [Factory.hollywood_building(), Factory.joke_shop(), Factory.tv_studio()]
|
||||||
|
var failures := 0
|
||||||
|
for scenario in scenarios:
|
||||||
|
var path := "%s/%s.tres" % [out_dir, scenario.id]
|
||||||
|
var err: int = ResourceSaver.save(scenario, path)
|
||||||
|
if err == OK:
|
||||||
|
print("wrote %s" % path)
|
||||||
|
else:
|
||||||
|
printerr("failed to write %s (error %d)" % [path, err])
|
||||||
|
failures += 1
|
||||||
|
quit(0 if failures == 0 else 1)
|
||||||
@@ -0,0 +1,846 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build the low-poly prop models used in the Step Line street scene.
|
||||||
|
|
||||||
|
The scene needs street furniture (bollards, benches, bins, planters, newspaper
|
||||||
|
boxes, a hydrant) that reads as solid geometry instead of flat boxes. This tool
|
||||||
|
keeps that art direction in one place:
|
||||||
|
|
||||||
|
* every prop is described as a small list of boxes and cylinders, so the
|
||||||
|
build is deterministic and runs offline in CI;
|
||||||
|
* an optional AI concept image per prop (OpenRouter, the same backend as
|
||||||
|
`generate_textures.py`) supplies the colour palette, so a model can look
|
||||||
|
like the concept without a 3D-model API;
|
||||||
|
* `build` writes standard glTF binary (`.glb`) files that Godot 4 imports as
|
||||||
|
`PackedScene`s, and `audit` checks the files are structurally valid.
|
||||||
|
|
||||||
|
Commands (run from the repo root):
|
||||||
|
|
||||||
|
python3 tools/model_pipeline.py list
|
||||||
|
python3 tools/model_pipeline.py build
|
||||||
|
python3 tools/model_pipeline.py audit
|
||||||
|
python3 tools/model_pipeline.py concept # needs OPENROUTER_API_KEY
|
||||||
|
python3 tools/model_pipeline.py concept --only bench
|
||||||
|
|
||||||
|
The concept step is optional: `build` never needs the network. If
|
||||||
|
`assets/models/concepts/<prop>.png` exists, its dominant colours replace the
|
||||||
|
role colours from the catalogue, which is the only AI influence on the mesh.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
MODELS_DIR = ROOT / "assets" / "models" / "generated"
|
||||||
|
CONCEPTS_DIR = ROOT / "assets" / "models" / "concepts"
|
||||||
|
|
||||||
|
# Reuse the validated OpenRouter image backend; keep the import lazy so `list`,
|
||||||
|
# `build` and `audit` work without Pillow installed? Pillow is needed for the
|
||||||
|
# concept palette step, so it stays a normal import inside those functions.
|
||||||
|
DEFAULT_CONCEPT_MODEL = "openai/gpt-5-image-mini"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Catalogue. `role` names the palette slot a part uses:
|
||||||
|
# body / accent / metal / wood / foliage / stone / glass
|
||||||
|
# `concept` prompts ask for a single object on a plain background.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PROPS = [
|
||||||
|
{
|
||||||
|
"name": "bollard",
|
||||||
|
"concept": ("product photo of a single short black steel street bollard "
|
||||||
|
"with a reflective band, plain light grey studio background"),
|
||||||
|
"parts": [
|
||||||
|
{"shape": "cylinder", "radius": 0.09, "height": 0.92,
|
||||||
|
"at": [0, 0.46, 0], "segments": 12, "role": "metal"},
|
||||||
|
{"shape": "cylinder", "radius": 0.095, "height": 0.07,
|
||||||
|
"at": [0, 0.80, 0], "segments": 12, "role": "accent"},
|
||||||
|
{"shape": "cylinder", "radius": 0.10, "height": 0.05,
|
||||||
|
"at": [0, 0.94, 0], "segments": 12, "role": "metal"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bench",
|
||||||
|
"concept": ("product photo of a single park bench with wooden slats and "
|
||||||
|
"dark metal legs, plain light grey studio background"),
|
||||||
|
"parts": [
|
||||||
|
{"shape": "box", "size": [1.80, 0.08, 0.52],
|
||||||
|
"at": [0, 0.46, 0], "role": "wood"},
|
||||||
|
{"shape": "box", "size": [1.80, 0.42, 0.07],
|
||||||
|
"at": [0, 0.72, -0.22], "role": "wood"},
|
||||||
|
{"shape": "box", "size": [0.09, 0.46, 0.46],
|
||||||
|
"at": [-0.72, 0.23, 0], "role": "metal"},
|
||||||
|
{"shape": "box", "size": [0.09, 0.46, 0.46],
|
||||||
|
"at": [0.72, 0.23, 0], "role": "metal"},
|
||||||
|
{"shape": "box", "size": [0.07, 0.40, 0.07],
|
||||||
|
"at": [-0.72, 0.72, -0.20], "role": "metal"},
|
||||||
|
{"shape": "box", "size": [0.07, 0.40, 0.07],
|
||||||
|
"at": [0.72, 0.72, -0.20], "role": "metal"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "trash_bin",
|
||||||
|
"concept": ("product photo of a single dark green cylindrical street "
|
||||||
|
"litter bin with a domed lid, plain light grey studio background"),
|
||||||
|
"parts": [
|
||||||
|
{"shape": "cylinder", "radius": 0.27, "height": 0.78,
|
||||||
|
"at": [0, 0.39, 0], "segments": 14, "role": "body"},
|
||||||
|
{"shape": "cylinder", "radius": 0.29, "height": 0.06,
|
||||||
|
"at": [0, 0.81, 0], "segments": 14, "role": "metal"},
|
||||||
|
{"shape": "cylinder", "radius": 0.24, "height": 0.04,
|
||||||
|
"at": [0, 0.86, 0], "segments": 14, "role": "accent"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "planter_box",
|
||||||
|
"concept": ("product photo of a single square concrete planter box with a "
|
||||||
|
"small green shrub, plain light grey studio background"),
|
||||||
|
"parts": [
|
||||||
|
{"shape": "box", "size": [0.96, 0.54, 0.96],
|
||||||
|
"at": [0, 0.27, 0], "role": "stone"},
|
||||||
|
{"shape": "box", "size": [0.84, 0.04, 0.84],
|
||||||
|
"at": [0, 0.55, 0], "role": "body"},
|
||||||
|
{"shape": "cylinder", "radius": 0.30, "height": 0.55,
|
||||||
|
"at": [0, 0.86, 0], "segments": 9, "role": "foliage"},
|
||||||
|
{"shape": "cylinder", "radius": 0.22, "height": 0.42,
|
||||||
|
"at": [-0.18, 1.10, 0.10], "segments": 8, "role": "foliage"},
|
||||||
|
{"shape": "cylinder", "radius": 0.18, "height": 0.34,
|
||||||
|
"at": [0.20, 1.06, -0.12], "segments": 8, "role": "foliage"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "newspaper_box",
|
||||||
|
"concept": ("product photo of a single blue newspaper vending box on a "
|
||||||
|
"short stand, plain light grey studio background"),
|
||||||
|
"parts": [
|
||||||
|
{"shape": "box", "size": [0.44, 0.10, 0.38],
|
||||||
|
"at": [0, 0.08, 0], "role": "metal"},
|
||||||
|
{"shape": "box", "size": [0.42, 0.72, 0.34],
|
||||||
|
"at": [0, 0.49, 0], "role": "body"},
|
||||||
|
{"shape": "box", "size": [0.34, 0.30, 0.03],
|
||||||
|
"at": [0, 0.60, 0.18], "role": "glass"},
|
||||||
|
{"shape": "box", "size": [0.44, 0.10, 0.36],
|
||||||
|
"at": [0, 0.90, 0], "role": "accent"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fire_hydrant",
|
||||||
|
"concept": ("product photo of a single red fire hydrant with silver caps, "
|
||||||
|
"plain light grey studio background"),
|
||||||
|
"parts": [
|
||||||
|
{"shape": "cylinder", "radius": 0.15, "height": 0.62,
|
||||||
|
"at": [0, 0.31, 0], "segments": 12, "role": "body"},
|
||||||
|
{"shape": "cylinder", "radius": 0.17, "height": 0.06,
|
||||||
|
"at": [0, 0.65, 0], "segments": 12, "role": "accent"},
|
||||||
|
{"shape": "cylinder", "radius": 0.19, "height": 0.09,
|
||||||
|
"at": [0, 0.05, 0], "segments": 12, "role": "metal"},
|
||||||
|
{"shape": "box", "size": [0.52, 0.10, 0.12],
|
||||||
|
"at": [0, 0.42, 0], "role": "metal"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Default palette per role (linear-ish sRGB values the GLB stores directly).
|
||||||
|
PALETTE = {
|
||||||
|
"body": [0.22, 0.30, 0.24],
|
||||||
|
"accent": [0.85, 0.72, 0.28],
|
||||||
|
"metal": [0.16, 0.17, 0.19],
|
||||||
|
"wood": [0.42, 0.28, 0.17],
|
||||||
|
"foliage": [0.18, 0.42, 0.20],
|
||||||
|
"stone": [0.52, 0.51, 0.48],
|
||||||
|
"glass": [0.35, 0.48, 0.62],
|
||||||
|
}
|
||||||
|
|
||||||
|
PBR = {
|
||||||
|
"metal": (0.75, 0.35),
|
||||||
|
"wood": (0.0, 0.75),
|
||||||
|
"foliage": (0.0, 0.9),
|
||||||
|
"stone": (0.0, 0.85),
|
||||||
|
"glass": (0.1, 0.12),
|
||||||
|
"body": (0.15, 0.65),
|
||||||
|
"accent": (0.25, 0.5),
|
||||||
|
}
|
||||||
|
|
||||||
|
DIRECTORY_BY_NAME = {prop["name"]: prop for prop in PROPS}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Geometry helpers. Every quad/triangle is wound so its geometric normal agrees
|
||||||
|
# with the intended outward normal — Godot culls back faces, so this matters.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _sub(a, b):
|
||||||
|
return (a[0] - b[0], a[1] - b[1], a[2] - b[2])
|
||||||
|
|
||||||
|
|
||||||
|
def _cross(a, b):
|
||||||
|
return (a[1] * b[2] - a[2] * b[1],
|
||||||
|
a[2] * b[0] - a[0] * b[2],
|
||||||
|
a[0] * b[1] - a[1] * b[0])
|
||||||
|
|
||||||
|
|
||||||
|
def _dot(a, b):
|
||||||
|
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(v):
|
||||||
|
length = math.sqrt(_dot(v, v))
|
||||||
|
if length <= 1e-9:
|
||||||
|
return (0.0, 1.0, 0.0)
|
||||||
|
return (v[0] / length, v[1] / length, v[2] / length)
|
||||||
|
|
||||||
|
|
||||||
|
class MeshData:
|
||||||
|
"""Accumulates one flat-shaded, indexed triangle list."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.positions = []
|
||||||
|
self.normals = []
|
||||||
|
self.indices = []
|
||||||
|
|
||||||
|
def triangle_count(self) -> int:
|
||||||
|
return len(self.indices) // 3
|
||||||
|
|
||||||
|
def add_quad(self, corners, want_normal):
|
||||||
|
a, b, c = corners[0], corners[1], corners[2]
|
||||||
|
normal = _cross(_sub(b, a), _sub(c, a))
|
||||||
|
if _dot(normal, want_normal) < 0.0:
|
||||||
|
corners = [corners[0], corners[3], corners[2], corners[1]]
|
||||||
|
normal = (-normal[0], -normal[1], -normal[2])
|
||||||
|
normal = _normalize(normal)
|
||||||
|
base = len(self.positions)
|
||||||
|
for corner in corners:
|
||||||
|
self.positions.append(corner)
|
||||||
|
self.normals.append(normal)
|
||||||
|
self.indices.extend([base, base + 1, base + 2,
|
||||||
|
base, base + 2, base + 3])
|
||||||
|
|
||||||
|
def add_triangle(self, v0, v1, v2, want_normal):
|
||||||
|
normal = _cross(_sub(v1, v0), _sub(v2, v0))
|
||||||
|
if _dot(normal, want_normal) < 0.0:
|
||||||
|
v1, v2 = v2, v1
|
||||||
|
normal = (-normal[0], -normal[1], -normal[2])
|
||||||
|
normal = _normalize(normal)
|
||||||
|
base = len(self.positions)
|
||||||
|
for vertex in (v0, v1, v2):
|
||||||
|
self.positions.append(vertex)
|
||||||
|
self.normals.append(normal)
|
||||||
|
self.indices.extend([base, base + 1, base + 2])
|
||||||
|
|
||||||
|
|
||||||
|
def _add_box(mesh: MeshData, size, at):
|
||||||
|
hx, hy, hz = size[0] / 2.0, size[1] / 2.0, size[2] / 2.0
|
||||||
|
cx, cy, cz = at
|
||||||
|
faces = [
|
||||||
|
((0, 0, 1), [(-hx, -hy, hz), (hx, -hy, hz),
|
||||||
|
(hx, hy, hz), (-hx, hy, hz)]),
|
||||||
|
((0, 0, -1), [(hx, -hy, -hz), (-hx, -hy, -hz),
|
||||||
|
(-hx, hy, -hz), (hx, hy, -hz)]),
|
||||||
|
((1, 0, 0), [(hx, -hy, hz), (hx, -hy, -hz),
|
||||||
|
(hx, hy, -hz), (hx, hy, hz)]),
|
||||||
|
((-1, 0, 0), [(-hx, -hy, -hz), (-hx, -hy, hz),
|
||||||
|
(-hx, hy, hz), (-hx, hy, -hz)]),
|
||||||
|
((0, 1, 0), [(-hx, hy, hz), (hx, hy, hz),
|
||||||
|
(hx, hy, -hz), (-hx, hy, -hz)]),
|
||||||
|
((0, -1, 0), [(-hx, -hy, -hz), (hx, -hy, -hz),
|
||||||
|
(hx, -hy, hz), (-hx, -hy, hz)]),
|
||||||
|
]
|
||||||
|
for normal, corners in faces:
|
||||||
|
mesh.add_quad([(cx + x, cy + y, cz + z)
|
||||||
|
for x, y, z in corners], normal)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_cylinder(mesh: MeshData, radius, height, at, segments=12, top_radius=None):
|
||||||
|
r0 = float(radius)
|
||||||
|
r1 = r0 if top_radius is None else float(top_radius)
|
||||||
|
cx, cy, cz = at
|
||||||
|
y0 = cy - height / 2.0
|
||||||
|
y1 = cy + height / 2.0
|
||||||
|
segments = max(3, int(segments))
|
||||||
|
for i in range(segments):
|
||||||
|
t0 = 2.0 * math.pi * i / segments
|
||||||
|
t1 = 2.0 * math.pi * (i + 1) / segments
|
||||||
|
b0 = (cx + r0 * math.cos(t0), y0, cz + r0 * math.sin(t0))
|
||||||
|
b1 = (cx + r0 * math.cos(t1), y0, cz + r0 * math.sin(t1))
|
||||||
|
u1 = (cx + r1 * math.cos(t1), y1, cz + r1 * math.sin(t1))
|
||||||
|
u0 = (cx + r1 * math.cos(t0), y1, cz + r1 * math.sin(t0))
|
||||||
|
want = ((math.cos(t0) + math.cos(t1)) * 0.5, 0.0,
|
||||||
|
(math.sin(t0) + math.sin(t1)) * 0.5)
|
||||||
|
mesh.add_quad([b0, b1, u1, u0], want)
|
||||||
|
bottom_center = (cx, y0, cz)
|
||||||
|
top_center = (cx, y1, cz)
|
||||||
|
mesh.add_triangle(bottom_center, b0, b1, (0.0, -1.0, 0.0))
|
||||||
|
mesh.add_triangle(top_center, u0, u1, (0.0, 1.0, 0.0))
|
||||||
|
|
||||||
|
|
||||||
|
def build_part_mesh(part: dict) -> MeshData:
|
||||||
|
"""Geometry for a single part. Each part becomes one glTF primitive so it
|
||||||
|
can carry its own material."""
|
||||||
|
mesh = MeshData()
|
||||||
|
if part["shape"] == "box":
|
||||||
|
_add_box(mesh, part["size"], part["at"])
|
||||||
|
elif part["shape"] == "cylinder":
|
||||||
|
_add_cylinder(mesh, part["radius"], part["height"], part["at"],
|
||||||
|
part.get("segments", 12), part.get("top_radius"))
|
||||||
|
else:
|
||||||
|
raise ValueError(f"unknown shape {part['shape']!r}")
|
||||||
|
return mesh
|
||||||
|
|
||||||
|
|
||||||
|
def build_prop_mesh(prop: dict) -> MeshData:
|
||||||
|
"""All parts merged into one mesh (stats and tests; the GLB keeps them
|
||||||
|
separate so each part keeps its material)."""
|
||||||
|
merged = MeshData()
|
||||||
|
for part in prop["parts"]:
|
||||||
|
mesh = build_part_mesh(part)
|
||||||
|
base = len(merged.positions)
|
||||||
|
merged.positions.extend(mesh.positions)
|
||||||
|
merged.normals.extend(mesh.normals)
|
||||||
|
merged.indices.extend(index + base for index in mesh.indices)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AI concept palette (optional). The image only supplies colours.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _load_env_file(path=None):
|
||||||
|
path = Path(path) if path else ROOT / ".env"
|
||||||
|
if not path.is_file():
|
||||||
|
return
|
||||||
|
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
os.environ.setdefault(key.strip(), value.strip().strip("\"'"))
|
||||||
|
|
||||||
|
|
||||||
|
def _hsv(rgb):
|
||||||
|
"""Hue (0..1), saturation (0..1), value (0..1) for a 0..1 RGB triple."""
|
||||||
|
r, g, b = rgb
|
||||||
|
mx, mn = max(rgb), min(rgb)
|
||||||
|
delta = mx - mn
|
||||||
|
if mx <= 0.0:
|
||||||
|
return 0.0, 0.0, 0.0
|
||||||
|
sat = delta / mx
|
||||||
|
if delta <= 1e-6:
|
||||||
|
return 0.0, sat, mx
|
||||||
|
if mx == r:
|
||||||
|
hue = ((g - b) / delta) % 6.0
|
||||||
|
elif mx == g:
|
||||||
|
hue = (b - r) / delta + 2.0
|
||||||
|
else:
|
||||||
|
hue = (r - g) / delta + 4.0
|
||||||
|
return hue / 6.0, sat, mx
|
||||||
|
|
||||||
|
|
||||||
|
def _luminance(rgb):
|
||||||
|
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
|
||||||
|
|
||||||
|
|
||||||
|
def dominant_colors(image, count=4):
|
||||||
|
"""Most characteristic colours of the concept subject, 0..1 RGB triples.
|
||||||
|
|
||||||
|
A studio backdrop is a large grey gradient, so raw pixel frequency just
|
||||||
|
returns the background. Two things fix that: read the centre of the frame
|
||||||
|
(the subject), and rank colours by ``count * (0.25 + saturation)`` so a
|
||||||
|
saturated wood or paint beats a big flat grey.
|
||||||
|
"""
|
||||||
|
source = image.convert("RGB")
|
||||||
|
width, height = source.size
|
||||||
|
box = (int(width * 0.22), int(height * 0.22),
|
||||||
|
int(width * 0.78), int(height * 0.78))
|
||||||
|
small = source.crop(box).resize((96, 96))
|
||||||
|
quantized = small.quantize(colors=max(8, count * 4), method=2)
|
||||||
|
palette = quantized.getpalette() or []
|
||||||
|
scored = []
|
||||||
|
for tally, index in (quantized.getcolors() or []):
|
||||||
|
r, g, b = palette[index * 3:index * 3 + 3]
|
||||||
|
rgb = [r / 255.0, g / 255.0, b / 255.0]
|
||||||
|
_hue, sat, value = _hsv(rgb)
|
||||||
|
if value > 0.96 and sat < 0.05:
|
||||||
|
continue # white studio backdrop
|
||||||
|
if value < 0.05:
|
||||||
|
continue # pure shadow
|
||||||
|
scored.append((tally * (0.25 + sat), rgb))
|
||||||
|
scored.sort(key=lambda item: item[0], reverse=True)
|
||||||
|
return [rgb for _score, rgb in scored[:count]]
|
||||||
|
|
||||||
|
|
||||||
|
def assign_roles(colors, base=None):
|
||||||
|
"""Map sampled colours onto material roles using their character.
|
||||||
|
|
||||||
|
Frequency alone cannot tell a black cast-iron leg from a darker wood slat.
|
||||||
|
The bench concept has warm teak slats and near-black legs; the planter
|
||||||
|
concept has grey concrete and a green shrub. Assign by hue and value
|
||||||
|
instead: the darkest tone is metal, a warm saturated tone is wood, a light
|
||||||
|
desaturated tone is stone, a green tone is foliage.
|
||||||
|
"""
|
||||||
|
palette = {role: list(rgb) for role, rgb in (base or PALETTE).items()}
|
||||||
|
if not colors:
|
||||||
|
return palette
|
||||||
|
|
||||||
|
def sat(rgb):
|
||||||
|
return _hsv(rgb)[1]
|
||||||
|
|
||||||
|
def hue(rgb):
|
||||||
|
return _hsv(rgb)[0]
|
||||||
|
|
||||||
|
darkest = min(colors, key=_luminance)
|
||||||
|
if _luminance(darkest) < 0.35:
|
||||||
|
palette["metal"] = darkest
|
||||||
|
|
||||||
|
warm = [c for c in colors
|
||||||
|
if 0.02 <= hue(c) <= 0.16 and sat(c) >= 0.25
|
||||||
|
and 0.06 <= _luminance(c) <= 0.8]
|
||||||
|
if warm:
|
||||||
|
palette["wood"] = max(warm, key=sat)
|
||||||
|
|
||||||
|
greens = [c for c in colors
|
||||||
|
if 0.20 <= hue(c) <= 0.45 and sat(c) >= 0.2
|
||||||
|
and _luminance(c) >= 0.10]
|
||||||
|
if greens:
|
||||||
|
palette["foliage"] = max(greens, key=sat)
|
||||||
|
|
||||||
|
greys = [c for c in colors
|
||||||
|
if sat(c) < 0.18 and 0.08 <= _luminance(c) <= 0.85]
|
||||||
|
if greys:
|
||||||
|
# Clamp concrete: a near-white sample renders as a glowing crate under the
|
||||||
|
# street lamps, so keep stone a believable mid grey.
|
||||||
|
stone = max(greys, key=_luminance)
|
||||||
|
palette["stone"] = [min(0.60, channel) for channel in stone]
|
||||||
|
|
||||||
|
candidates = [c for c in colors
|
||||||
|
if sat(c) >= 0.12 and _luminance(c) >= 0.08]
|
||||||
|
if candidates:
|
||||||
|
body = max(candidates, key=lambda c: sat(c) * (0.3 + _luminance(c)))
|
||||||
|
palette["body"] = body
|
||||||
|
others = [c for c in candidates
|
||||||
|
if c is not body and sat(c) > sat(body) + 0.05]
|
||||||
|
if others:
|
||||||
|
palette["accent"] = max(others, key=sat)
|
||||||
|
else:
|
||||||
|
palette["accent"] = [min(1.0, channel * 1.35 + 0.05)
|
||||||
|
for channel in body]
|
||||||
|
return palette
|
||||||
|
|
||||||
|
|
||||||
|
def palette_for_prop(prop_name: str) -> dict:
|
||||||
|
"""Role palette for a prop: defaults, refined by its AI concept image."""
|
||||||
|
concept = CONCEPTS_DIR / f"{prop_name}.png"
|
||||||
|
if not concept.is_file():
|
||||||
|
return {role: list(rgb) for role, rgb in PALETTE.items()}
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
with Image.open(concept) as image:
|
||||||
|
image.load()
|
||||||
|
colors = dominant_colors(image)
|
||||||
|
except Exception as exc: # noqa: BLE001 - a bad concept must not break build
|
||||||
|
print(
|
||||||
|
f"warning: could not read {concept.name}: {exc}", file=sys.stderr)
|
||||||
|
return {role: list(rgb) for role, rgb in PALETTE.items()}
|
||||||
|
return assign_roles(colors)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_concepts(only="", model=DEFAULT_CONCEPT_MODEL, size=1024,
|
||||||
|
force=False) -> list:
|
||||||
|
"""Ask the image model for one concept sheet per prop (needs API key)."""
|
||||||
|
_load_env_file()
|
||||||
|
import generate_textures
|
||||||
|
CONCEPTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
written = []
|
||||||
|
for prop in PROPS:
|
||||||
|
if only and only not in prop["name"]:
|
||||||
|
continue
|
||||||
|
path = CONCEPTS_DIR / f"{prop['name']}.png"
|
||||||
|
if path.is_file() and not force:
|
||||||
|
print(f"skip concept {prop['name']} (exists; --force to replace)")
|
||||||
|
continue
|
||||||
|
print(f"concept {prop['name']} ...")
|
||||||
|
image = generate_textures.generate_image(
|
||||||
|
f"{prop['concept']}. Three-quarter view, soft studio lighting, "
|
||||||
|
"no text, no watermark.", model, size, transparent=False)
|
||||||
|
image.save(path)
|
||||||
|
written.append(path)
|
||||||
|
print(f"wrote {path.relative_to(ROOT)} {image.size}")
|
||||||
|
return written
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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:
|
||||||
|
remainder = len(data) % 4
|
||||||
|
return data if remainder == 0 else data + fill * (4 - remainder)
|
||||||
|
|
||||||
|
|
||||||
|
def build_glb(prop: dict, palette: dict = None, atlas: dict = None) -> bytes:
|
||||||
|
"""Serialise one prop as a self-contained .glb (glTF 2.0) file.
|
||||||
|
|
||||||
|
All parts share one material backed by a colour atlas and a
|
||||||
|
metallic/roughness atlas, so the prop draws with a single material.
|
||||||
|
"""
|
||||||
|
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()
|
||||||
|
buffer_views = []
|
||||||
|
accessors = []
|
||||||
|
|
||||||
|
def add_view(payload: bytes, target=None) -> int:
|
||||||
|
view = {
|
||||||
|
"buffer": 0,
|
||||||
|
"byteOffset": len(binary),
|
||||||
|
"byteLength": len(payload),
|
||||||
|
}
|
||||||
|
if target is not None:
|
||||||
|
view["target"] = target
|
||||||
|
buffer_views.append(view)
|
||||||
|
binary.extend(_pad(payload, b"\x00"))
|
||||||
|
return len(buffer_views) - 1
|
||||||
|
|
||||||
|
pos_payload = b"".join(struct.pack("<3f", *p) for p in positions)
|
||||||
|
nrm_payload = b"".join(struct.pack("<3f", *n) for n in normals)
|
||||||
|
uv_payload = b"".join(struct.pack("<2f", *uv) for uv in uvs)
|
||||||
|
idx_payload = b"".join(struct.pack("<H", i) for i in indices)
|
||||||
|
|
||||||
|
pos_view = add_view(pos_payload, 34962)
|
||||||
|
nrm_view = add_view(nrm_payload, 34962)
|
||||||
|
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)
|
||||||
|
accessors.append({"bufferView": pos_view, "componentType": 5126,
|
||||||
|
"count": len(positions), "type": "VEC3",
|
||||||
|
"min": [min(xs), min(ys), min(zs)],
|
||||||
|
"max": [max(xs), max(ys), max(zs)]})
|
||||||
|
nrm_accessor = len(accessors)
|
||||||
|
accessors.append({"bufferView": nrm_view, "componentType": 5126,
|
||||||
|
"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)
|
||||||
|
accessors.append({"bufferView": idx_view, "componentType": 5123,
|
||||||
|
"count": len(indices), "type": "SCALAR"})
|
||||||
|
|
||||||
|
base_view = add_view(atlas["base_png"])
|
||||||
|
mr_view = add_view(atlas["metal_rough_png"])
|
||||||
|
|
||||||
|
gltf = {
|
||||||
|
"asset": {"version": "2.0", "generator": "step-line model_pipeline"},
|
||||||
|
"scene": 0,
|
||||||
|
"scenes": [{"name": prop["name"], "nodes": [0]}],
|
||||||
|
"nodes": [{"name": prop["name"], "mesh": 0}],
|
||||||
|
"meshes": [{
|
||||||
|
"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,
|
||||||
|
"bufferViews": buffer_views,
|
||||||
|
"buffers": [{"byteLength": len(binary)}],
|
||||||
|
}
|
||||||
|
|
||||||
|
json_chunk = _pad(json.dumps(gltf, separators=(",", ":")).encode("utf-8"),
|
||||||
|
b" ")
|
||||||
|
bin_chunk = _pad(bytes(binary), b"\x00")
|
||||||
|
total = 12 + 8 + len(json_chunk) + 8 + len(bin_chunk)
|
||||||
|
out = bytearray()
|
||||||
|
out.extend(struct.pack("<III", 0x46546C67, 2, total))
|
||||||
|
out.extend(struct.pack("<II", len(json_chunk), 0x4E4F534A))
|
||||||
|
out.extend(json_chunk)
|
||||||
|
out.extend(struct.pack("<II", len(bin_chunk), 0x004E4942))
|
||||||
|
out.extend(bin_chunk)
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def build(only="", quiet=False) -> list:
|
||||||
|
MODELS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
written = []
|
||||||
|
for prop in PROPS:
|
||||||
|
if only and only not in prop["name"]:
|
||||||
|
continue
|
||||||
|
palette = palette_for_prop(prop["name"])
|
||||||
|
payload = build_glb(prop, palette)
|
||||||
|
path = MODELS_DIR / f"{prop['name']}.glb"
|
||||||
|
path.write_bytes(payload)
|
||||||
|
written.append(path)
|
||||||
|
if not quiet:
|
||||||
|
mesh = build_prop_mesh(prop)
|
||||||
|
print(f"wrote {path.relative_to(ROOT)} "
|
||||||
|
f"{mesh.triangle_count()} tris {len(payload)} bytes")
|
||||||
|
return written
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Audit.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def audit_glb(path: Path) -> dict:
|
||||||
|
"""Check a .glb file's container, JSON chunk, and triangle count."""
|
||||||
|
issues = []
|
||||||
|
if not path.is_file():
|
||||||
|
return {"path": str(path), "ok": False,
|
||||||
|
"issues": ["missing file"], "triangles": 0}
|
||||||
|
data = path.read_bytes()
|
||||||
|
if len(data) < 20:
|
||||||
|
return {"path": str(path), "ok": False,
|
||||||
|
"issues": ["file too short"], "triangles": 0}
|
||||||
|
magic, version, length = struct.unpack_from("<III", data, 0)
|
||||||
|
if magic != 0x46546C67:
|
||||||
|
issues.append("bad magic (not a glTF binary)")
|
||||||
|
if version != 2:
|
||||||
|
issues.append(f"unexpected glTF version {version}")
|
||||||
|
if length != len(data):
|
||||||
|
issues.append(f"header length {length} != file size {len(data)}")
|
||||||
|
json_len, json_type = struct.unpack_from("<II", data, 12)
|
||||||
|
if json_type != 0x4E4F534A:
|
||||||
|
issues.append("first chunk is not JSON")
|
||||||
|
gltf = {}
|
||||||
|
try:
|
||||||
|
gltf = json.loads(data[20:20 + json_len].decode("utf-8"))
|
||||||
|
except (ValueError, UnicodeDecodeError) as exc:
|
||||||
|
issues.append(f"unreadable JSON chunk: {exc}")
|
||||||
|
triangles = 0
|
||||||
|
for mesh in gltf.get("meshes", []):
|
||||||
|
for primitive in mesh.get("primitives", []):
|
||||||
|
indices = primitive.get("indices")
|
||||||
|
if indices is None:
|
||||||
|
issues.append("primitive without indices")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
triangles += gltf["accessors"][indices]["count"] // 3
|
||||||
|
except (KeyError, IndexError, TypeError):
|
||||||
|
issues.append("primitive references a missing accessor")
|
||||||
|
if triangles <= 0:
|
||||||
|
issues.append("no triangles")
|
||||||
|
materials = gltf.get("materials", [])
|
||||||
|
if not 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", []):
|
||||||
|
if int(buffer.get("byteLength", 0)) > len(data):
|
||||||
|
issues.append("buffer larger than the file")
|
||||||
|
return {"path": str(path), "ok": not issues, "issues": issues,
|
||||||
|
"triangles": triangles}
|
||||||
|
|
||||||
|
|
||||||
|
def audit(only="", as_json=False) -> int:
|
||||||
|
results = []
|
||||||
|
for prop in PROPS:
|
||||||
|
if only and only not in prop["name"]:
|
||||||
|
continue
|
||||||
|
results.append(audit_glb(MODELS_DIR / f"{prop['name']}.glb"))
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps(results, indent=2))
|
||||||
|
else:
|
||||||
|
for result in results:
|
||||||
|
name = Path(result["path"]).name
|
||||||
|
state = "OK " if result["ok"] else "BAD "
|
||||||
|
print(f"{state} {name:24} tris={result['triangles']}")
|
||||||
|
for issue in result["issues"]:
|
||||||
|
print(f" - {issue}")
|
||||||
|
return 0 if all(result["ok"] for result in results) else 1
|
||||||
|
|
||||||
|
|
||||||
|
def list_props(args) -> int:
|
||||||
|
for prop in PROPS:
|
||||||
|
concept = CONCEPTS_DIR / f"{prop['name']}.png"
|
||||||
|
built = MODELS_DIR / f"{prop['name']}.glb"
|
||||||
|
mesh = build_prop_mesh(prop)
|
||||||
|
print(f"{prop['name']:16} parts={len(prop['parts']):2} "
|
||||||
|
f"tris={mesh.triangle_count():4} "
|
||||||
|
f"glb={'yes' if built.is_file() else '- '} "
|
||||||
|
f"concept={'yes' if concept.is_file() else '-'}")
|
||||||
|
print(f"Total: {len(PROPS)} props")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Build low-poly prop models for Step Line.")
|
||||||
|
commands = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
listing = commands.add_parser("list", help="print the prop catalogue")
|
||||||
|
listing.set_defaults(handler=list_props)
|
||||||
|
|
||||||
|
building = commands.add_parser(
|
||||||
|
"build", help="write assets/models/generated/*.glb")
|
||||||
|
building.add_argument("--only", default="",
|
||||||
|
help="only build props whose name contains this text")
|
||||||
|
building.set_defaults(handler=lambda args: 0 if build(args.only) else 1)
|
||||||
|
|
||||||
|
auditing = commands.add_parser(
|
||||||
|
"audit", help="validate the built GLB files")
|
||||||
|
auditing.add_argument("--only", default="")
|
||||||
|
auditing.add_argument("--json", action="store_true")
|
||||||
|
auditing.set_defaults(handler=lambda args: audit(args.only, args.json))
|
||||||
|
|
||||||
|
concept = commands.add_parser(
|
||||||
|
"concept", help="generate concept images via OpenRouter")
|
||||||
|
concept.add_argument("--only", default="")
|
||||||
|
concept.add_argument("--model", default=DEFAULT_CONCEPT_MODEL)
|
||||||
|
concept.add_argument("--size", type=int, default=1024)
|
||||||
|
concept.add_argument("--force", action="store_true")
|
||||||
|
concept.set_defaults(
|
||||||
|
handler=lambda args: 0 if generate_concepts(
|
||||||
|
args.only, args.model, args.size, args.force) is not None else 1)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
try:
|
||||||
|
return args.handler(args)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -66,6 +66,7 @@ portables=(
|
|||||||
extras=(
|
extras=(
|
||||||
"build/step-line-setup-$VERSION.exe"
|
"build/step-line-setup-$VERSION.exe"
|
||||||
"build/step-line-$VERSION-x86_64.AppImage"
|
"build/step-line-$VERSION-x86_64.AppImage"
|
||||||
|
"build/step-line-web-$VERSION.zip"
|
||||||
)
|
)
|
||||||
all_artifacts=("${portables[@]}")
|
all_artifacts=("${portables[@]}")
|
||||||
for f in "${extras[@]}"; do
|
for f in "${extras[@]}"; do
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ Two commands:
|
|||||||
--draft deterministic wireframe sketches drawn with Pillow
|
--draft deterministic wireframe sketches drawn with Pillow
|
||||||
(no network or API key; good for CI and layout checks).
|
(no network or API key; good for CI and layout checks).
|
||||||
|
|
||||||
|
judge send each capture together with its reference design to a vision
|
||||||
|
model and ask whether they depict the same screen. Pixel and
|
||||||
|
perceptual metrics cannot judge composition; this closes that gap.
|
||||||
|
Requires OPENROUTER_API_KEY (falls back to the repo .env).
|
||||||
|
--dry-run build and print the requests without calling the API.
|
||||||
|
|
||||||
compare compare docs/screenshots/*.png against the reference designs and
|
compare compare docs/screenshots/*.png against the reference designs and
|
||||||
flag gross drift. Semantic composition is judged with coarse,
|
flag gross drift. Semantic composition is judged with coarse,
|
||||||
Pillow-only signals (aspect ratio, frame size, blank-frame
|
Pillow-only signals (aspect ratio, frame size, blank-frame
|
||||||
@@ -25,14 +31,19 @@ Two commands:
|
|||||||
regressions; they are NOT a pixel-exact or semantic match. A
|
regressions; they are NOT a pixel-exact or semantic match. A
|
||||||
vision-model pass is the documented next step (see PLAN.md).
|
vision-model pass is the documented next step (see PLAN.md).
|
||||||
|
|
||||||
|
list print the catalogue: name, kind, whether a reference design
|
||||||
|
exists, and which capture pairs with it.
|
||||||
|
|
||||||
Run from the repo root:
|
Run from the repo root:
|
||||||
|
|
||||||
|
python3 tools/screen_designs.py list
|
||||||
python3 tools/screen_designs.py generate
|
python3 tools/screen_designs.py generate
|
||||||
python3 tools/screen_designs.py generate --draft
|
python3 tools/screen_designs.py generate --draft
|
||||||
python3 tools/screen_designs.py compare
|
python3 tools/screen_designs.py compare
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -54,6 +65,10 @@ ASPECT = 16 / 9
|
|||||||
# the model asset_pipeline.py already trusts for non-alpha roles).
|
# the model asset_pipeline.py already trusts for non-alpha roles).
|
||||||
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?".
|
||||||
|
DEFAULT_JUDGE_MODEL = "google/gemini-2.5-flash-lite"
|
||||||
|
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
|
||||||
# as one coherent game. "scene" entries are first-person 3D world views;
|
# as one coherent game. "scene" entries are first-person 3D world views;
|
||||||
# "ui" entries are in-game panels overlaid on a softened scene.
|
# "ui" entries are in-game panels overlaid on a softened scene.
|
||||||
@@ -322,6 +337,162 @@ SCREENS = [
|
|||||||
(0.36, 0.64, 0.64, 0.68, "cooldown"),
|
(0.36, 0.64, 0.64, 0.68, "cooldown"),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "wand_screening",
|
||||||
|
"kind": "ui",
|
||||||
|
"title": "Wand screening",
|
||||||
|
"source": "README screenshot gallery · PLAN The Joke Shop · src/core/wand_screener.gd",
|
||||||
|
"ref": None,
|
||||||
|
"description": "Handheld wand screening panel for The Joke Shop: the alert "
|
||||||
|
"result, the items the guest is carrying, and allow/deny "
|
||||||
|
"buttons. A phone, keys or a belt buckle can raise a false "
|
||||||
|
"alarm, so the alert alone never proves a weapon.",
|
||||||
|
"prompt": (
|
||||||
|
"Realistic 3D game screenshot with an in-game security wand screening "
|
||||||
|
"panel overlaid: a handheld metal-detector alert indicator, a short "
|
||||||
|
"list of carried items such as keys, phone and belt buckle, a warning "
|
||||||
|
"note that an alert can be a false alarm, and two buttons labelled "
|
||||||
|
"Allow and Deny, dark translucent panel over a softly blurred comedy "
|
||||||
|
"club entrance at night"),
|
||||||
|
"regions": [
|
||||||
|
(0.26, 0.12, 0.74, 0.88, "wand panel"),
|
||||||
|
(0.32, 0.20, 0.68, 0.32, "alert result"),
|
||||||
|
(0.32, 0.38, 0.68, 0.56, "carried items"),
|
||||||
|
(0.30, 0.70, 0.48, 0.78, "allow"),
|
||||||
|
(0.52, 0.70, 0.70, 0.78, "deny"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "joke_shop_queue",
|
||||||
|
"kind": "scene",
|
||||||
|
"title": "Joke Shop door queue",
|
||||||
|
"source": "PLAN.md The Joke Shop — long line, ID and ticket checks",
|
||||||
|
"ref": None,
|
||||||
|
"description": "Evening view of a small comedy-club entrance: a short queue "
|
||||||
|
"on the sidewalk, a door person checking an ID and a ticket "
|
||||||
|
"under a warm bulb, a red awning, and the show board.",
|
||||||
|
"prompt": (
|
||||||
|
"Realistic 3D first-person game scene: a narrow comedy club entrance at "
|
||||||
|
"dusk with a short queue of guests on the sidewalk, a door person "
|
||||||
|
"checking an ID and a paper ticket under a warm lamp, red fabric awning, "
|
||||||
|
"a hand-lettered show board, wet pavement reflections, warm interior "
|
||||||
|
"light spilling through the door"),
|
||||||
|
"regions": [
|
||||||
|
(0.33, 0.30, 0.67, 0.86, "door + door person"),
|
||||||
|
(0.00, 0.55, 0.30, 0.95, "queue"),
|
||||||
|
(0.24, 0.16, 0.76, 0.36, "awning + show board"),
|
||||||
|
(0.72, 0.55, 1.00, 0.95, "sidewalk"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "studio_arrival",
|
||||||
|
"kind": "scene",
|
||||||
|
"title": "TV Studio celebrity arrival",
|
||||||
|
"source": "PLAN.md Late Night TV Studio — crowd gathers for an arrival",
|
||||||
|
"ref": None,
|
||||||
|
"description": "A celebrity arrival at the studio entrance: tourists and "
|
||||||
|
"influencers pressing toward the door, cameras raised, the "
|
||||||
|
"guard holding a line on the steps, studio marquee behind.",
|
||||||
|
"prompt": (
|
||||||
|
"Realistic 3D first-person game scene: a busy late-night television "
|
||||||
|
"studio entrance with a small crowd of tourists and influencers raising "
|
||||||
|
"cameras toward a black car at the curb, a security guard holding a line "
|
||||||
|
"on the steps, glowing studio marquee and lit canopy, night time"),
|
||||||
|
"regions": [
|
||||||
|
(0.30, 0.30, 0.70, 0.80, "crowd + cameras"),
|
||||||
|
(0.40, 0.10, 0.60, 0.34, "studio marquee"),
|
||||||
|
(0.00, 0.60, 0.25, 0.95, "car at the curb"),
|
||||||
|
(0.78, 0.55, 1.00, 0.90, "guard line"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "crowd_panic",
|
||||||
|
"kind": "scene",
|
||||||
|
"title": "Crowd panic",
|
||||||
|
"source": "PLAN.md Public Crowd Reaction · CrowdSystem.reaction_for_event",
|
||||||
|
"ref": None,
|
||||||
|
"description": "After a panic event the sidewalk empties away from the "
|
||||||
|
"door: people running toward the street, one person still "
|
||||||
|
"near the steps, the guard between them and the entrance.",
|
||||||
|
"prompt": (
|
||||||
|
"Realistic 3D first-person game scene: bystanders scattering away from a "
|
||||||
|
"building entrance across a wide sidewalk, motion blur on the fleeing "
|
||||||
|
"figures, one person left near the steps, a security guard standing "
|
||||||
|
"between them and the door, tense evening light, long shadows"),
|
||||||
|
"regions": [
|
||||||
|
(0.00, 0.45, 0.42, 0.90, "fleeing crowd"),
|
||||||
|
(0.55, 0.40, 0.78, 0.85, "guard between crowd and door"),
|
||||||
|
(0.38, 0.10, 0.80, 0.45, "building entrance"),
|
||||||
|
(0.72, 0.50, 1.00, 0.95, "empty sidewalk"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "responder_arrival",
|
||||||
|
"kind": "scene",
|
||||||
|
"title": "Responder arrival",
|
||||||
|
"source": "PLAN.md Level 5 Emergency Response · radio_system.gd responder dispatch",
|
||||||
|
"ref": None,
|
||||||
|
"description": "A dispatched responder walks up the steps toward a locked "
|
||||||
|
"door while the entrance is being cleared; blue light washes "
|
||||||
|
"the facade and the lock indicator reads red.",
|
||||||
|
"prompt": (
|
||||||
|
"Realistic 3D first-person game scene: an emergency responder in dark "
|
||||||
|
"uniform walking up wide concrete steps toward a locked building door, "
|
||||||
|
"blue light washing the brick facade, a red lock indicator beside the "
|
||||||
|
"door, people clearing from the entrance, night"),
|
||||||
|
"regions": [
|
||||||
|
(0.40, 0.28, 0.60, 0.85, "responder on the steps"),
|
||||||
|
(0.42, 0.14, 0.58, 0.32, "locked door + indicator"),
|
||||||
|
(0.00, 0.00, 1.00, 0.30, "facade + blue light"),
|
||||||
|
(0.00, 0.70, 0.28, 0.98, "people clearing"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "body_camera_timeline",
|
||||||
|
"kind": "ui",
|
||||||
|
"title": "Body-camera timeline",
|
||||||
|
"source": "README screenshot gallery · ADR-0005 · src/game/ui/event_timeline.gd",
|
||||||
|
"ref": None,
|
||||||
|
"description": "The shift review's body-camera timeline: a text list of every "
|
||||||
|
"recorded incident event stamped mm:ss with the person and the "
|
||||||
|
"action or outcome, one row highlighted, and previous/next "
|
||||||
|
"buttons.",
|
||||||
|
"prompt": (
|
||||||
|
"Realistic 3D game screenshot with an in-game body-camera event timeline "
|
||||||
|
"overlaid: a tall dark panel listing time-stamped rows such as "
|
||||||
|
"02:14 formal warning, 02:31 body camera on and 03:02 access denied, one "
|
||||||
|
"row highlighted, Previous and Next buttons at the bottom, over a "
|
||||||
|
"softly blurred night street background"),
|
||||||
|
"regions": [
|
||||||
|
(0.28, 0.10, 0.72, 0.90, "timeline panel"),
|
||||||
|
(0.32, 0.16, 0.68, 0.26, "timeline header"),
|
||||||
|
(0.32, 0.28, 0.68, 0.72, "timestamped rows"),
|
||||||
|
(0.32, 0.78, 0.68, 0.85, "previous / next"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "escalation_hud",
|
||||||
|
"kind": "ui",
|
||||||
|
"title": "Escalation HUD",
|
||||||
|
"source": "README accessibility note · Hud.escalation_meter (shape meter · level)",
|
||||||
|
"ref": None,
|
||||||
|
"description": "The in-shift HUD: a shape meter plus numeric escalation level "
|
||||||
|
"(`Escalation: Blocking [###--] L3/5`), the targeted person's "
|
||||||
|
"name, flashlight / door / body-camera indicators, shift clock, "
|
||||||
|
"and the reaction log.",
|
||||||
|
"prompt": (
|
||||||
|
"Realistic 3D game screenshot with a minimal in-game HUD overlaid: an "
|
||||||
|
"escalation meter drawn as a shape bar with the text Blocking and L3/5, "
|
||||||
|
"a target name at the bottom centre, small indicator icons for flashlight, "
|
||||||
|
"door lock and body camera, a shift clock at the top right and a short "
|
||||||
|
"reaction log at the left, over a softly blurred daytime street"),
|
||||||
|
"regions": [
|
||||||
|
(0.30, 0.02, 0.70, 0.10, "escalation shape meter"),
|
||||||
|
(0.30, 0.86, 0.70, 0.96, "target name + hint"),
|
||||||
|
(0.78, 0.02, 0.98, 0.14, "shift clock"),
|
||||||
|
(0.02, 0.60, 0.26, 0.95, "reaction log"),
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -330,6 +501,20 @@ def resolve_path(value):
|
|||||||
return path if path.is_absolute() else ROOT / path
|
return path if path.is_absolute() else ROOT / path
|
||||||
|
|
||||||
|
|
||||||
|
def list_screens(args):
|
||||||
|
"""Print the screen catalogue and what state each entry is in."""
|
||||||
|
for screen in SCREENS:
|
||||||
|
design = _design_path(screen["name"])
|
||||||
|
state = "design" if design.is_file() else "- "
|
||||||
|
capture = screen["ref"] or "-"
|
||||||
|
print(f"{screen['name']:22} {screen['kind']:5} {state} "
|
||||||
|
f"capture={capture:24} {screen['title']}")
|
||||||
|
scenes = sum(1 for screen in SCREENS if screen["kind"] == "scene")
|
||||||
|
print(f"Total: {len(SCREENS)} screens "
|
||||||
|
f"({scenes} scene, {len(SCREENS) - scenes} ui)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _design_path(name):
|
def _design_path(name):
|
||||||
return DESIGNS_DIR / f"{name}.png"
|
return DESIGNS_DIR / f"{name}.png"
|
||||||
|
|
||||||
@@ -367,10 +552,11 @@ def _draw_wireframe(screen, path):
|
|||||||
int(x1 * WIDTH), int(y1 * HEIGHT))
|
int(x1 * WIDTH), int(y1 * HEIGHT))
|
||||||
draw.rounded_rectangle(box, radius=10,
|
draw.rounded_rectangle(box, radius=10,
|
||||||
outline=(120, 130, 170), width=2)
|
outline=(120, 130, 170), width=2)
|
||||||
cx = (box[0] + box[2]) / 2
|
# Anchor the label to the top-left corner: nested regions (a panel plus
|
||||||
cy = (box[1] + box[3]) / 2
|
# the cards inside it) would otherwise stack their centred labels on top
|
||||||
draw.text((cx - draw.textlength(label, font=label_font) / 2,
|
# of each other.
|
||||||
cy - 14), label, font=label_font, fill=(170, 180, 210))
|
draw.text((box[0] + 10, box[1] + 6), label,
|
||||||
|
font=label_font, fill=(170, 180, 210))
|
||||||
|
|
||||||
image.save(path)
|
image.save(path)
|
||||||
shown = path.relative_to(ROOT) if path.is_relative_to(ROOT) else path
|
shown = path.relative_to(ROOT) if path.is_relative_to(ROOT) else path
|
||||||
@@ -406,13 +592,26 @@ 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 = []
|
||||||
for screen in SCREENS:
|
selected = [screen for screen in SCREENS
|
||||||
|
if not args.only or args.only in screen["name"]]
|
||||||
|
if not selected:
|
||||||
|
print(f"no screen matches --only {args.only!r}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
for screen in selected:
|
||||||
|
path = _design_path(screen["name"])
|
||||||
|
# Draft mode is the offline fallback; do not clobber existing AI-crafted
|
||||||
|
# references unless the caller explicitly asks for it.
|
||||||
|
if path.is_file() and not args.force:
|
||||||
|
print(
|
||||||
|
f"skip {screen['name']} (reference exists; --force to replace)")
|
||||||
|
continue
|
||||||
print(f"generating {screen['name']} ...")
|
print(f"generating {screen['name']} ...")
|
||||||
try:
|
try:
|
||||||
if args.draft:
|
if args.draft:
|
||||||
_draw_wireframe(screen, _design_path(screen["name"]))
|
_draw_wireframe(screen, path)
|
||||||
else:
|
else:
|
||||||
_generate_ai(screen, args.model, args.request_size)
|
_generate_ai(screen, args.model, args.request_size)
|
||||||
except SystemExit as exc:
|
except SystemExit as exc:
|
||||||
@@ -582,6 +781,196 @@ def compare(args):
|
|||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Vision judge — semantic "same screen?" check against the reference designs.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
JUDGE_INSTRUCTIONS = (
|
||||||
|
"You are reviewing screenshots of a low-poly 3D security-guard game. "
|
||||||
|
"IMAGE 1 is a CONCEPT MOCKUP of the intended screen (it may be painted in a "
|
||||||
|
"different art style and show different actors); IMAGE 2 is the ACTUAL "
|
||||||
|
"capture from the game. Different art style, fidelity, fonts, and character "
|
||||||
|
"models are EXPECTED and must be ignored.\n\n"
|
||||||
|
"Judge only whether IMAGE 2 realises the same SCREEN and LAYOUT INTENT as "
|
||||||
|
"IMAGE 1: is it the same kind of screen (world view, HUD overlay, modal "
|
||||||
|
"panel), are the main interface regions present in roughly the same places "
|
||||||
|
"(target line, inspection card, report form, on-screen buttons), and is the "
|
||||||
|
"subject of the shot comparable (entrance, street, person)?\n\n"
|
||||||
|
"Answer with JSON only: {\"match\": true|false, \"confidence\": 0..1, "
|
||||||
|
"\"differences\": [\"short point\", ...]}. Set match=true when the capture "
|
||||||
|
"realises the mockup's screen even if it looks much simpler."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_env_file(path=None):
|
||||||
|
"""Populate os.environ from a .env file without overriding real variables."""
|
||||||
|
path = Path(path) if path else ROOT / ".env"
|
||||||
|
if not path.is_file():
|
||||||
|
return
|
||||||
|
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
os.environ.setdefault(key.strip(), value.strip().strip("\"'"))
|
||||||
|
|
||||||
|
|
||||||
|
def _data_url(path):
|
||||||
|
import base64
|
||||||
|
payload = Path(path).read_bytes()
|
||||||
|
return "data:image/png;base64," + base64.b64encode(payload).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def build_judge_request(model, screen, capture_path, design_path):
|
||||||
|
"""Build the OpenRouter chat payload for one capture/design pair."""
|
||||||
|
prompt = (f"{JUDGE_INSTRUCTIONS}\n\nScreen: {screen['title']} "
|
||||||
|
f"({screen['source']}). Design notes: {screen['description']}")
|
||||||
|
return {
|
||||||
|
"model": model,
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": prompt},
|
||||||
|
{"type": "image_url", "image_url": {
|
||||||
|
"url": _data_url(design_path)}},
|
||||||
|
{"type": "image_url", "image_url": {
|
||||||
|
"url": _data_url(capture_path)}},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
"temperature": 0,
|
||||||
|
"response_format": {"type": "json_object"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_judge_response(payload):
|
||||||
|
"""Extract a verdict dict from an OpenRouter chat response."""
|
||||||
|
try:
|
||||||
|
text = payload["choices"][0]["message"]["content"]
|
||||||
|
except (KeyError, IndexError, TypeError):
|
||||||
|
return {"match": None, "confidence": 0.0,
|
||||||
|
"differences": ["unreadable model response"]}
|
||||||
|
if isinstance(text, list): # some providers return content parts
|
||||||
|
text = "".join(part.get("text", "") for part in text
|
||||||
|
if isinstance(part, dict))
|
||||||
|
text = str(text).strip()
|
||||||
|
start, end = text.find("{"), text.rfind("}")
|
||||||
|
if start >= 0 and end > start:
|
||||||
|
text = text[start:end + 1]
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except ValueError:
|
||||||
|
return {"match": None, "confidence": 0.0,
|
||||||
|
"differences": [f"non-JSON response: {text[:120]}"]}
|
||||||
|
differences = data.get("differences") or []
|
||||||
|
if not isinstance(differences, list):
|
||||||
|
differences = [str(differences)]
|
||||||
|
return {
|
||||||
|
"match": data.get("match") if isinstance(data.get("match"), bool) else None,
|
||||||
|
"confidence": float(data.get("confidence", 0.0) or 0.0),
|
||||||
|
"differences": [str(item) for item in differences],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def classify(verdict, threshold=0.6):
|
||||||
|
"""Turn a model verdict into a QA class.
|
||||||
|
|
||||||
|
The reference images are concept mockups, so a low-confidence disagreement is
|
||||||
|
a review note, not a failure. Only a confident mismatch fails the check.
|
||||||
|
"""
|
||||||
|
if verdict.get("match") is True:
|
||||||
|
return "match"
|
||||||
|
if verdict.get("match") is False:
|
||||||
|
return "bad" if float(verdict.get("confidence", 0.0)) >= threshold else "review"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _call_openrouter(body, timeout=180):
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
key = os.environ.get("OPENROUTER_API_KEY", "").strip()
|
||||||
|
if not key:
|
||||||
|
raise ValueError("OPENROUTER_API_KEY is not set (repo .env is read "
|
||||||
|
"automatically); use --dry-run to inspect requests")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
OPENROUTER_CHAT_URL,
|
||||||
|
data=json.dumps(body).encode("utf-8"),
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"HTTP-Referer": "https://git.allucanget.biz/zwitschi/security-game",
|
||||||
|
"X-Title": "Step Line",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode("utf-8", "replace")
|
||||||
|
raise ValueError(f"OpenRouter HTTP {exc.code}: {detail[:300]}")
|
||||||
|
|
||||||
|
|
||||||
|
def judge(args):
|
||||||
|
_load_env_file()
|
||||||
|
if not SCREENSHOTS_DIR.is_dir():
|
||||||
|
sys.exit(f"error: no screenshots directory at {SCREENSHOTS_DIR}")
|
||||||
|
refs = {screen["name"]: screen["ref"]
|
||||||
|
for screen in SCREENS if screen["ref"]}
|
||||||
|
by_name = {screen["name"]: screen for screen in SCREENS}
|
||||||
|
results = []
|
||||||
|
for capture in sorted(SCREENSHOTS_DIR.glob("*.png")):
|
||||||
|
if args.only and args.only not in capture.name:
|
||||||
|
continue
|
||||||
|
design_name = next((n for n, ref in refs.items()
|
||||||
|
if ref == capture.name), None)
|
||||||
|
if design_name is None:
|
||||||
|
results.append({"capture": capture.name, "screen": None,
|
||||||
|
"match": None, "confidence": 0.0,
|
||||||
|
"differences": ["no design reference for this capture"]})
|
||||||
|
continue
|
||||||
|
reference = _design_path(design_name)
|
||||||
|
if not reference.is_file():
|
||||||
|
results.append({"capture": capture.name, "screen": design_name,
|
||||||
|
"match": None, "confidence": 0.0,
|
||||||
|
"differences": ["missing reference design; run generate"]})
|
||||||
|
continue
|
||||||
|
body = build_judge_request(args.model, by_name[design_name],
|
||||||
|
capture, reference)
|
||||||
|
if args.dry_run:
|
||||||
|
results.append({
|
||||||
|
"capture": capture.name, "screen": design_name, "match": None,
|
||||||
|
"confidence": 0.0, "model": body["model"], "class": "unknown",
|
||||||
|
"differences": [f"dry run: {len(body['messages'][0]['content'])} parts"],
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
verdict = parse_judge_response(_call_openrouter(body))
|
||||||
|
verdict.update({"capture": capture.name, "screen": design_name,
|
||||||
|
"class": classify(verdict, args.fail_confidence)})
|
||||||
|
results.append(verdict)
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(results, indent=2))
|
||||||
|
else:
|
||||||
|
labels = {"match": "MATCH", "review": "REVIEW", "bad": "DIFF",
|
||||||
|
"unknown": "UNKNOWN"}
|
||||||
|
for result in results:
|
||||||
|
print(f"{labels[result['class']]:7} {result['capture']} "
|
||||||
|
f"(design: {result['screen']}) "
|
||||||
|
f"confidence={result['confidence']:.2f}")
|
||||||
|
for difference in result["differences"]:
|
||||||
|
print(f" - {difference}")
|
||||||
|
counts = {key: sum(1 for r in results if r["class"] == key)
|
||||||
|
for key in labels}
|
||||||
|
print(f"Summary: {counts['match']}/{len(results)} match, "
|
||||||
|
f"{counts['review']} review, {counts['bad']} diff "
|
||||||
|
f"(fail threshold confidence >= {args.fail_confidence:.2f})")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
return 0
|
||||||
|
return 1 if any(r["class"] == "bad" for r in results) else 0
|
||||||
|
|
||||||
|
|
||||||
def build_parser():
|
def build_parser():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Generate and compare Step Line screen-design references.")
|
description="Generate and compare Step Line screen-design references.")
|
||||||
@@ -597,13 +986,33 @@ def build_parser():
|
|||||||
gen.add_argument("--model", default=DEFAULT_MODEL)
|
gen.add_argument("--model", default=DEFAULT_MODEL)
|
||||||
gen.add_argument("--request-size", type=int,
|
gen.add_argument("--request-size", type=int,
|
||||||
default=generate_textures.GEN_SIZE)
|
default=generate_textures.GEN_SIZE)
|
||||||
|
gen.add_argument("--only", default="",
|
||||||
|
help="only generate screens whose name contains this text")
|
||||||
|
gen.add_argument("--force", action="store_true",
|
||||||
|
help="replace reference designs that already exist")
|
||||||
gen.set_defaults(handler=generate, draft=False)
|
gen.set_defaults(handler=generate, draft=False)
|
||||||
|
|
||||||
|
judge_cmd = commands.add_parser(
|
||||||
|
"judge", help="ask a vision model whether captures match the designs")
|
||||||
|
judge_cmd.add_argument("--model", default=DEFAULT_JUDGE_MODEL)
|
||||||
|
judge_cmd.add_argument("--json", action="store_true")
|
||||||
|
judge_cmd.add_argument("--dry-run", action="store_true",
|
||||||
|
help="print requests instead of calling the API")
|
||||||
|
judge_cmd.add_argument("--only", default="",
|
||||||
|
help="only judge captures whose name contains this text")
|
||||||
|
judge_cmd.add_argument("--fail-confidence", type=float, default=0.6,
|
||||||
|
help="a mismatch at or above this confidence fails")
|
||||||
|
judge_cmd.set_defaults(handler=judge)
|
||||||
|
|
||||||
cmp = commands.add_parser(
|
cmp = commands.add_parser(
|
||||||
"compare", help="compare docs/screenshots/*.png against the designs")
|
"compare", help="compare docs/screenshots/*.png against the designs")
|
||||||
cmp.add_argument("--json", action="store_true")
|
cmp.add_argument("--json", action="store_true")
|
||||||
cmp.set_defaults(handler=compare)
|
cmp.set_defaults(handler=compare)
|
||||||
|
|
||||||
|
listing = commands.add_parser(
|
||||||
|
"list", help="print the screen catalogue and its state")
|
||||||
|
listing.set_defaults(handler=list_screens)
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import json
|
||||||
|
import struct
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
import model_pipeline
|
||||||
|
|
||||||
|
|
||||||
|
def parse_glb(payload: bytes):
|
||||||
|
magic, version, length = struct.unpack_from("<III", payload, 0)
|
||||||
|
json_len, json_type = struct.unpack_from("<II", payload, 12)
|
||||||
|
gltf = json.loads(payload[20:20 + json_len].decode("utf-8"))
|
||||||
|
bin_offset = 20 + json_len
|
||||||
|
bin_len, bin_type = struct.unpack_from("<II", payload, bin_offset)
|
||||||
|
binary = payload[bin_offset + 8:bin_offset + 8 + bin_len]
|
||||||
|
return {
|
||||||
|
"magic": magic,
|
||||||
|
"version": version,
|
||||||
|
"length": length,
|
||||||
|
"json_type": json_type,
|
||||||
|
"bin_type": bin_type,
|
||||||
|
"bin_len": bin_len,
|
||||||
|
"binary": binary,
|
||||||
|
"gltf": gltf,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def prop(name):
|
||||||
|
return model_pipeline.DIRECTORY_BY_NAME[name]
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogueTests(unittest.TestCase):
|
||||||
|
def test_props_are_well_formed(self):
|
||||||
|
names = [p["name"] for p in model_pipeline.PROPS]
|
||||||
|
self.assertEqual(len(names), len(set(names)),
|
||||||
|
"prop names must be unique")
|
||||||
|
for entry in model_pipeline.PROPS:
|
||||||
|
self.assertTrue(entry["concept"].strip())
|
||||||
|
self.assertTrue(entry["parts"])
|
||||||
|
for part in entry["parts"]:
|
||||||
|
self.assertIn(part["shape"], ("box", "cylinder"))
|
||||||
|
self.assertEqual(len(part["at"]), 3)
|
||||||
|
self.assertIn(part["role"], model_pipeline.PALETTE)
|
||||||
|
if part["shape"] == "box":
|
||||||
|
self.assertEqual(len(part["size"]), 3)
|
||||||
|
self.assertTrue(all(value > 0 for value in part["size"]))
|
||||||
|
else:
|
||||||
|
self.assertGreater(part["radius"], 0)
|
||||||
|
self.assertGreater(part["height"], 0)
|
||||||
|
|
||||||
|
def test_every_prop_has_triangles(self):
|
||||||
|
for entry in model_pipeline.PROPS:
|
||||||
|
mesh = model_pipeline.build_prop_mesh(entry)
|
||||||
|
self.assertGreater(mesh.triangle_count(), 0, entry["name"])
|
||||||
|
self.assertLess(mesh.triangle_count(), 4000, entry["name"])
|
||||||
|
self.assertEqual(len(mesh.positions), len(mesh.normals))
|
||||||
|
|
||||||
|
|
||||||
|
class MeshWindingTests(unittest.TestCase):
|
||||||
|
def test_box_face_normals_point_outward(self):
|
||||||
|
mesh = model_pipeline.MeshData()
|
||||||
|
model_pipeline._add_box(mesh, (2.0, 2.0, 2.0), (0.0, 0.0, 0.0))
|
||||||
|
for tri in range(0, len(mesh.indices), 3):
|
||||||
|
a, b, c = (mesh.positions[mesh.indices[tri + i]] for i in range(3))
|
||||||
|
normal = mesh.normals[mesh.indices[tri]]
|
||||||
|
centroid = tuple((a[i] + b[i] + c[i]) / 3.0 for i in range(3))
|
||||||
|
self.assertGreater(
|
||||||
|
model_pipeline._dot(normal, centroid), 0.0,
|
||||||
|
"box face normal must point away from the centre")
|
||||||
|
|
||||||
|
def test_cylinder_caps_face_up_and_down(self):
|
||||||
|
mesh = model_pipeline.MeshData()
|
||||||
|
model_pipeline._add_cylinder(mesh, 0.5, 1.0, (0.0, 0.5, 0.0), 8)
|
||||||
|
ups = [n for n in mesh.normals if n[1] > 0.9]
|
||||||
|
downs = [n for n in mesh.normals if n[1] < -0.9]
|
||||||
|
self.assertTrue(ups, "cylinder needs an upward cap")
|
||||||
|
self.assertTrue(downs, "cylinder needs a downward cap")
|
||||||
|
|
||||||
|
|
||||||
|
class GlbTests(unittest.TestCase):
|
||||||
|
def test_glb_container_and_structure(self):
|
||||||
|
payload = model_pipeline.build_glb(prop("bench"))
|
||||||
|
parsed = parse_glb(payload)
|
||||||
|
self.assertEqual(parsed["magic"], 0x46546C67)
|
||||||
|
self.assertEqual(parsed["version"], 2)
|
||||||
|
self.assertEqual(parsed["length"], len(payload))
|
||||||
|
self.assertEqual(parsed["json_type"], 0x4E4F534A)
|
||||||
|
self.assertEqual(parsed["bin_type"], 0x004E4942)
|
||||||
|
self.assertEqual(len(payload) % 4, 0)
|
||||||
|
gltf = parsed["gltf"]
|
||||||
|
self.assertEqual(gltf["asset"]["version"], "2.0")
|
||||||
|
self.assertEqual(len(gltf["scenes"]), 1)
|
||||||
|
primitives = gltf["meshes"][0]["primitives"]
|
||||||
|
self.assertEqual(len(primitives), 1,
|
||||||
|
"parts share one primitive (one material)")
|
||||||
|
self.assertEqual(len(gltf["materials"]), 1,
|
||||||
|
"the prop draws with a single material")
|
||||||
|
for primitive in primitives:
|
||||||
|
self.assertIn("POSITION", primitive["attributes"])
|
||||||
|
self.assertIn("NORMAL", primitive["attributes"])
|
||||||
|
self.assertIn("TEXCOORD_0", primitive["attributes"],
|
||||||
|
"atlas UVs are required")
|
||||||
|
self.assertIsNotNone(primitive["indices"])
|
||||||
|
self.assertEqual(primitive["material"], 0)
|
||||||
|
for accessor in gltf["accessors"]:
|
||||||
|
self.assertIn(accessor["bufferView"],
|
||||||
|
range(len(gltf["bufferViews"])))
|
||||||
|
self.assertGreater(accessor["count"], 0)
|
||||||
|
self.assertLessEqual(gltf["buffers"][0]["byteLength"],
|
||||||
|
len(parsed["binary"]))
|
||||||
|
|
||||||
|
def test_glb_is_deterministic(self):
|
||||||
|
first = model_pipeline.build_glb(prop("bollard"))
|
||||||
|
second = model_pipeline.build_glb(prop("bollard"))
|
||||||
|
self.assertEqual(first, second)
|
||||||
|
|
||||||
|
def test_palette_override_changes_the_atlas_colour(self):
|
||||||
|
palette = {role: list(rgb)
|
||||||
|
for role, rgb in model_pipeline.PALETTE.items()}
|
||||||
|
palette["metal"] = [0.9, 0.1, 0.1]
|
||||||
|
atlas = model_pipeline.atlas_for(prop("bollard"), palette)
|
||||||
|
self.assertIn((0.9, 0.1, 0.1), atlas["colours"].values())
|
||||||
|
default = model_pipeline.build_glb(prop("bollard"))
|
||||||
|
overridden = model_pipeline.build_glb(prop("bollard"), palette)
|
||||||
|
self.assertNotEqual(default, overridden,
|
||||||
|
"the atlas texture carries the palette")
|
||||||
|
|
||||||
|
def test_material_uses_both_atlas_textures(self):
|
||||||
|
gltf = parse_glb(model_pipeline.build_glb(prop("bollard")))["gltf"]
|
||||||
|
pbr = gltf["materials"][0]["pbrMetallicRoughness"]
|
||||||
|
self.assertEqual(pbr["metallicFactor"], 1.0)
|
||||||
|
self.assertEqual(pbr["roughnessFactor"], 1.0)
|
||||||
|
self.assertIn("baseColorTexture", pbr)
|
||||||
|
self.assertIn("metallicRoughnessTexture", pbr)
|
||||||
|
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_atlas_is_deterministic_and_power_of_two(self):
|
||||||
|
first = model_pipeline.atlas_for(prop("planter_box"))
|
||||||
|
second = model_pipeline.atlas_for(prop("planter_box"))
|
||||||
|
self.assertEqual(first["base_png"], second["base_png"])
|
||||||
|
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):
|
||||||
|
payload = model_pipeline.build_glb(prop("planter_box"))
|
||||||
|
parsed = parse_glb(payload)
|
||||||
|
gltf = parsed["gltf"]
|
||||||
|
binary = parsed["binary"]
|
||||||
|
for primitive in gltf["meshes"][0]["primitives"]:
|
||||||
|
position_accessor = gltf["accessors"][primitive["attributes"]["POSITION"]]
|
||||||
|
index_accessor = gltf["accessors"][primitive["indices"]]
|
||||||
|
view = gltf["bufferViews"][index_accessor["bufferView"]]
|
||||||
|
raw = binary[view["byteOffset"] :view["byteOffset"] + view["byteLength"]]
|
||||||
|
indices = struct.unpack(f"<{len(raw) // 2}H", raw)
|
||||||
|
self.assertLess(max(indices), position_accessor["count"])
|
||||||
|
self.assertEqual(index_accessor["count"], len(indices))
|
||||||
|
|
||||||
|
|
||||||
|
class AuditTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temp_dir.name)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_audit_accepts_a_written_file(self):
|
||||||
|
path = self.root / "bench.glb"
|
||||||
|
path.write_bytes(model_pipeline.build_glb(prop("bench")))
|
||||||
|
result = model_pipeline.audit_glb(path)
|
||||||
|
self.assertTrue(result["ok"], result["issues"])
|
||||||
|
self.assertGreater(result["triangles"], 0)
|
||||||
|
|
||||||
|
def test_audit_reports_missing_file(self):
|
||||||
|
result = model_pipeline.audit_glb(self.root / "nope.glb")
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertIn("missing file", result["issues"])
|
||||||
|
|
||||||
|
def test_audit_reports_truncated_file(self):
|
||||||
|
path = self.root / "cut.glb"
|
||||||
|
path.write_bytes(model_pipeline.build_glb(prop("bench"))[:40])
|
||||||
|
result = model_pipeline.audit_glb(path)
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertTrue(result["issues"])
|
||||||
|
|
||||||
|
def test_build_writes_into_the_models_directory(self):
|
||||||
|
old = model_pipeline.MODELS_DIR
|
||||||
|
model_pipeline.MODELS_DIR = self.root
|
||||||
|
try:
|
||||||
|
written = model_pipeline.build("bollard", quiet=True)
|
||||||
|
self.assertEqual(len(written), 1)
|
||||||
|
self.assertTrue(written[0].is_file())
|
||||||
|
self.assertTrue(model_pipeline.audit_glb(written[0])["ok"])
|
||||||
|
finally:
|
||||||
|
model_pipeline.MODELS_DIR = old
|
||||||
|
|
||||||
|
|
||||||
|
class PaletteTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temp_dir.name)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_dominant_colors_ignores_white_background(self):
|
||||||
|
image = Image.new("RGB", (96, 96), (250, 250, 250))
|
||||||
|
for x in range(20, 70):
|
||||||
|
for y in range(20, 70):
|
||||||
|
image.putpixel((x, y), (150, 40, 40))
|
||||||
|
colors = model_pipeline.dominant_colors(image)
|
||||||
|
self.assertTrue(colors)
|
||||||
|
self.assertLess(max(colors[0]), 0.9,
|
||||||
|
"background must not win the vote")
|
||||||
|
|
||||||
|
def test_palette_uses_concept_image_when_present(self):
|
||||||
|
old = model_pipeline.CONCEPTS_DIR
|
||||||
|
model_pipeline.CONCEPTS_DIR = self.root
|
||||||
|
try:
|
||||||
|
image = Image.new("RGB", (96, 96), (250, 250, 250))
|
||||||
|
for x in range(20, 70):
|
||||||
|
for y in range(20, 70):
|
||||||
|
image.putpixel((x, y), (30, 90, 40))
|
||||||
|
image.save(self.root / "bench.png")
|
||||||
|
palette = model_pipeline.palette_for_prop("bench")
|
||||||
|
self.assertLess(palette["body"][1], 0.6)
|
||||||
|
self.assertGreater(palette["body"][1], palette["body"][0])
|
||||||
|
finally:
|
||||||
|
model_pipeline.CONCEPTS_DIR = old
|
||||||
|
|
||||||
|
def test_palette_falls_back_to_defaults(self):
|
||||||
|
old = model_pipeline.CONCEPTS_DIR
|
||||||
|
model_pipeline.CONCEPTS_DIR = self.root
|
||||||
|
try:
|
||||||
|
palette = model_pipeline.palette_for_prop("bench")
|
||||||
|
self.assertEqual(palette["wood"], model_pipeline.PALETTE["wood"])
|
||||||
|
finally:
|
||||||
|
model_pipeline.CONCEPTS_DIR = old
|
||||||
|
|
||||||
|
def test_assign_roles_separates_wood_from_metal(self):
|
||||||
|
colors = [[0.62, 0.37, 0.18], [0.05, 0.05, 0.05], [0.70, 0.70, 0.70]]
|
||||||
|
palette = model_pipeline.assign_roles(colors)
|
||||||
|
self.assertAlmostEqual(palette["wood"][0], 0.62, places=3)
|
||||||
|
self.assertLess(model_pipeline._luminance(palette["metal"]), 0.1)
|
||||||
|
|
||||||
|
def test_assign_roles_finds_foliage_and_stone(self):
|
||||||
|
colors = [[0.55, 0.55, 0.53], [0.18, 0.42, 0.20]]
|
||||||
|
palette = model_pipeline.assign_roles(colors)
|
||||||
|
self.assertGreater(palette["foliage"][1], palette["foliage"][0])
|
||||||
|
self.assertGreater(palette["stone"][0], 0.5)
|
||||||
|
|
||||||
|
def test_assign_roles_without_colours_keeps_defaults(self):
|
||||||
|
palette = model_pipeline.assign_roles([])
|
||||||
|
self.assertEqual(palette["body"], model_pipeline.PALETTE["body"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -43,6 +43,48 @@ class ScreenDesignsTests(unittest.TestCase):
|
|||||||
self.assertEqual(image.size,
|
self.assertEqual(image.size,
|
||||||
(screen_designs.WIDTH, screen_designs.HEIGHT))
|
(screen_designs.WIDTH, screen_designs.HEIGHT))
|
||||||
|
|
||||||
|
def test_catalogue_covers_the_shipped_screens(self):
|
||||||
|
names = [screen["name"] for screen in screen_designs.SCREENS]
|
||||||
|
required = [
|
||||||
|
"venue_overview", "targeting_npc", "access_inspection",
|
||||||
|
"radio_report", "late_night_avenue", "night_flashlight",
|
||||||
|
"radial_menu", "shift_review", "start_menu", "pause_overlay",
|
||||||
|
"settings_panel", "emergency_radio", "wand_screening",
|
||||||
|
"joke_shop_queue", "studio_arrival", "crowd_panic",
|
||||||
|
"responder_arrival", "body_camera_timeline", "escalation_hud",
|
||||||
|
]
|
||||||
|
for name in required:
|
||||||
|
self.assertIn(name, names)
|
||||||
|
self.assertGreaterEqual(len(screen_designs.SCREENS), len(required))
|
||||||
|
for screen in screen_designs.SCREENS:
|
||||||
|
self.assertTrue(screen["title"].strip())
|
||||||
|
|
||||||
|
def test_list_command_prints_the_catalogue(self):
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
buffer = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(buffer):
|
||||||
|
code = screen_designs.main(["list"])
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
output = buffer.getvalue()
|
||||||
|
self.assertIn("escalation_hud", output)
|
||||||
|
self.assertIn("wand_screening", output)
|
||||||
|
self.assertIn("Total:", output)
|
||||||
|
|
||||||
|
def test_judge_dry_run_builds_requests_without_network(self):
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
if not screen_designs.SCREENSHOTS_DIR.is_dir():
|
||||||
|
self.skipTest("no screenshots checked out")
|
||||||
|
buffer = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(buffer):
|
||||||
|
code = screen_designs.main(["judge", "--dry-run"])
|
||||||
|
# A dry run must not call the API and must not crash on missing verdicts.
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
output = buffer.getvalue()
|
||||||
|
self.assertIn("dry run", output)
|
||||||
|
self.assertIn("Summary:", output)
|
||||||
|
|
||||||
def test_compose_prompt_appends_kind_specific_style(self):
|
def test_compose_prompt_appends_kind_specific_style(self):
|
||||||
scene = {"kind": "scene", "prompt": "a street"}
|
scene = {"kind": "scene", "prompt": "a street"}
|
||||||
ui = {"kind": "ui", "prompt": "a panel"}
|
ui = {"kind": "ui", "prompt": "a panel"}
|
||||||
@@ -86,5 +128,98 @@ class ScreenDesignsTests(unittest.TestCase):
|
|||||||
self.assertTrue(any("aspect" in e for e in report["errors"]))
|
self.assertTrue(any("aspect" in e for e in report["errors"]))
|
||||||
|
|
||||||
|
|
||||||
|
class VisionJudgeTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temp_dir.name)
|
||||||
|
self.capture = self.root / "capture.png"
|
||||||
|
self.design = self.root / "design.png"
|
||||||
|
Image.new("RGB", (64, 36), (20, 30, 40)).save(self.capture)
|
||||||
|
Image.new("RGB", (64, 36), (200, 190, 180)).save(self.design)
|
||||||
|
self.screen = screen_designs.SCREENS[0]
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_judge_request_embeds_both_images(self):
|
||||||
|
body = screen_designs.build_judge_request(
|
||||||
|
"model-x", self.screen, self.capture, self.design)
|
||||||
|
self.assertEqual(body["model"], "model-x")
|
||||||
|
self.assertEqual(body["temperature"], 0)
|
||||||
|
self.assertEqual(body["response_format"], {"type": "json_object"})
|
||||||
|
parts = body["messages"][0]["content"]
|
||||||
|
self.assertEqual(parts[0]["type"], "text")
|
||||||
|
images = [p for p in parts if p["type"] == "image_url"]
|
||||||
|
self.assertEqual(
|
||||||
|
len(images), 2, "design and capture are both attached")
|
||||||
|
for image in images:
|
||||||
|
self.assertTrue(image["image_url"]["url"].startswith(
|
||||||
|
"data:image/png;base64,"))
|
||||||
|
self.assertIn(self.screen["title"], parts[0]["text"])
|
||||||
|
self.assertIn("mockup", parts[0]["text"].lower())
|
||||||
|
|
||||||
|
def test_parse_plain_json_verdict(self):
|
||||||
|
payload = {"choices": [{"message": {"content":
|
||||||
|
'{"match": true, "confidence": 0.9, "differences": ["style"]}'}}]}
|
||||||
|
verdict = screen_designs.parse_judge_response(payload)
|
||||||
|
self.assertTrue(verdict["match"])
|
||||||
|
self.assertAlmostEqual(verdict["confidence"], 0.9)
|
||||||
|
self.assertEqual(verdict["differences"], ["style"])
|
||||||
|
|
||||||
|
def test_parse_fenced_json_verdict(self):
|
||||||
|
payload = {"choices": [{"message": {"content":
|
||||||
|
'Sure:\n```json\n{"match": false, "confidence": 0.7}\n```'}}]}
|
||||||
|
verdict = screen_designs.parse_judge_response(payload)
|
||||||
|
self.assertFalse(verdict["match"])
|
||||||
|
self.assertAlmostEqual(verdict["confidence"], 0.7)
|
||||||
|
|
||||||
|
def test_parse_content_parts(self):
|
||||||
|
payload = {"choices": [{"message": {"content": [
|
||||||
|
{"type": "text", "text": '{"match": true, "confidence": 0.5}'}]}}]}
|
||||||
|
verdict = screen_designs.parse_judge_response(payload)
|
||||||
|
self.assertTrue(verdict["match"])
|
||||||
|
|
||||||
|
def test_parse_non_json_is_unknown(self):
|
||||||
|
payload = {"choices": [{"message": {"content": "no idea"}}]}
|
||||||
|
verdict = screen_designs.parse_judge_response(payload)
|
||||||
|
self.assertIsNone(verdict["match"])
|
||||||
|
self.assertTrue(verdict["differences"])
|
||||||
|
|
||||||
|
def test_parse_missing_choices_is_unknown(self):
|
||||||
|
verdict = screen_designs.parse_judge_response({})
|
||||||
|
self.assertIsNone(verdict["match"])
|
||||||
|
|
||||||
|
def test_classify_thresholds(self):
|
||||||
|
self.assertEqual(screen_designs.classify(
|
||||||
|
{"match": True, "confidence": 0.1}), "match")
|
||||||
|
self.assertEqual(screen_designs.classify(
|
||||||
|
{"match": False, "confidence": 0.9}), "bad")
|
||||||
|
self.assertEqual(screen_designs.classify(
|
||||||
|
{"match": False, "confidence": 0.3}), "review")
|
||||||
|
self.assertEqual(screen_designs.classify(
|
||||||
|
{"match": None, "confidence": 0.0}), "unknown")
|
||||||
|
self.assertEqual(screen_designs.classify(
|
||||||
|
{"match": False, "confidence": 0.55}, threshold=0.5), "bad")
|
||||||
|
|
||||||
|
def test_env_file_does_not_override_existing(self):
|
||||||
|
env_path = self.root / ".env"
|
||||||
|
env_path.write_text('OPENROUTER_API_KEY=from-file\nOTHER=1\n',
|
||||||
|
encoding="utf-8")
|
||||||
|
import os
|
||||||
|
old = os.environ.get("OPENROUTER_API_KEY")
|
||||||
|
os.environ["OPENROUTER_API_KEY"] = "from-env"
|
||||||
|
os.environ.pop("OTHER", None)
|
||||||
|
try:
|
||||||
|
screen_designs._load_env_file(env_path)
|
||||||
|
self.assertEqual(os.environ["OPENROUTER_API_KEY"], "from-env")
|
||||||
|
self.assertEqual(os.environ.get("OTHER"), "1")
|
||||||
|
finally:
|
||||||
|
if old is None:
|
||||||
|
os.environ.pop("OPENROUTER_API_KEY", None)
|
||||||
|
else:
|
||||||
|
os.environ["OPENROUTER_API_KEY"] = old
|
||||||
|
os.environ.pop("OTHER", None)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||