diff --git a/MEMORY.md b/MEMORY.md index 8475c02..000f384 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -61,6 +61,13 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o gallery is 13 good / 0 bad, the screen-design judge is 13/13 and a clean portrait measured a 0.732 vision gap against the photoreal concept. No release build, version bump or tag was created at this gate. +- **NPC visual baseline (0.9.4, 2026-11-02):** `src/test/capture_npc_baseline.gd` is the + neutral-studio 8-variant × 6-view baseline (front, three-quarter, profile, back, full body, + crowd). It uses a fixed orbit camera, mid-grey studio backdrop, key/fill lights, no HUD/crowd/animation; + raw frames stay in `tmp/`, and `tools/npc_baseline.py sheet` builds the committed + `sheet_npc_baseline.jpg`. Criteria and measurements are in `docs/npc-visual-review.md`. The first + ranked gaps are: tourist photo prop duplicates the backpack silhouette; shallow/faceted face; + backpack straps read as rigid bars. No model/GLB changes were made in the baseline step. - **UI flow smoke (2026-11-02):** `smoke_ui_flows.gd` boots the real scene and exercises inspection allow/deny, normal vs emergency radio, Help filtering, wand allow/deny and review return-to-menu. Keep this smoke in the 0.9.6 @@ -734,7 +741,7 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o - Venue `door_staff_lines` are logged through `GameManager` (inspect / wand / refuse). They are not a separate dialogue UI. - Access loadout is implemented; do not re-fix `override_access` as if it were still unread. - `Save.SETTINGS_FILE` is declared and unused; settings persistence goes through `Save.load_settings()` / `save_settings()`. -- Headless suite is **261/261**; Python tooling is **97/97**; screenshot compare is **21 good / 0 bad** and the automatic judge is **21/21 match, 0 diff**. Remaining visual follow-ups are in `TODO.md`. +- Headless suite is **261/261**; Python tooling is **99/99**; screenshot compare is **21 good / 0 bad** and the automatic judge is **21/21 match, 0 diff**. Remaining visual follow-ups are in `TODO.md`. - Typed spawn signal extra args: handlers must accept the 4th `event: Dictionary` or Godot will not call them (`test_shift_coordinator_runs_a_shift` failed until `_on_spawn` took four args). - GDScript inference trap that broke the main scene: a `var` typed as a base class (`radial: CanvasLayer`) exposes script members as `Variant`, so `var x := radial.is_open` is a diff --git a/README.md b/README.md index dd1a915..7b4b8eb 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ Full setup, every run/test/smoke command, and the controls table are in - [`docs/development-status.md`](docs/development-status.md) — Implemented features and what's next. - [`docs/smoke-checklist.md`](docs/smoke-checklist.md) — Manual smoke-test checklist. - [`docs/blender-assets.md`](docs/blender-assets.md) — Blender asset library: characters, gear, props, attach points. +- [`docs/npc-visual-review.md`](docs/npc-visual-review.md) — 0.9.4 NPC baseline, acceptance criteria and ranked visual gaps. - [`docs/adr/`](docs/adr/) — Architecture Decision Records. - [`TODO.md`](TODO.md) — Active tasks plus the 0.x gap analysis and milestone roadmap. diff --git a/TODO.md b/TODO.md index d6f1424..800703e 100644 --- a/TODO.md +++ b/TODO.md @@ -7,7 +7,7 @@ see `AGENTS.md` for the workflow and `MEMORY.md` for the decisions behind these Baseline: **v0.9.3** (2026-11-02). Three playable venues, tutorial/campaign/checkpoint resume, difficulty/help, audio/accessibility settings, a 24-NPC budget baseline, a complete -escalation/access/radio loop, shift review, the Blender asset library, 261 headless + 97 Python +escalation/access/radio loop, shift review, the Blender asset library, 261 headless + 99 Python tests, a 21/21 clean screen-design judge and an optional Forward+ Desktop HQ build. This is a strong release-candidate foundation, not yet a 0.10.0 release candidate. @@ -58,15 +58,26 @@ fallback behavior. ### Reference and acceptance criteria -- [ ] Establish a portrait/contact-sheet baseline for all eight variants: front, three-quarter, - profile, back, full body and a crowd-distance silhouette. -- [ ] Define acceptance criteria for proportions, face readability, hair, hands, shoes, clothing - silhouette, gear fit and animation at the gameplay camera height (1.7 m). -- [ ] Record the camera/lighting/background used for comparison so model changes can be judged - consistently; keep the existing portrait capture path deterministic. -- [ ] Identify the highest-impact gaps from the current portraits (e.g. face depth, hair mass, +- [x] Establish a portrait/contact-sheet baseline for all eight variants: front, three-quarter, + profile, back, full body and a crowd-distance silhouette. (`capture_npc_baseline.gd` + produces 48 frames; `sheet_npc_baseline.jpg` is committed.) +- [x] Define acceptance criteria for proportions, face readability, hair, hands, shoes, clothing + silhouette, gear fit and animation at the gameplay camera height (1.7 m). (Eight criteria + in `docs/npc-visual-review.md`; the current 6,000-triangle/5,000-vertex contract is explicit.) +- [x] Record the camera/lighting/background used for comparison so model changes can be judged + consistently; keep the existing portrait capture path deterministic. (Neutral studio, fixed + orbit cameras, no HUD/crowd/animation; commands recorded in `docs/npc-visual-review.md`.) +- [x] Identify the highest-impact gaps from the current portraits (e.g. face depth, hair mass, neck/shoulder transition, hand/foot shape, coat/dress hem) and rank them by player visibility. -- [ ] Publish a before/after NPC comparison sheet for review before changing the library. + (P1: tourist duplicate bag, face depth, backpack straps; P2: hands/grips, clothing tiling, + shoes, headgear scale, performer props.) +- [x] Publish a before/after NPC comparison sheet for review before changing the library. + (`sheet_npc_baseline.jpg` is the before artifact; `tools/npc_baseline.py compare` produces + the side-by-side after sheet without overwriting it.) + +Completed 2026-11-02: 48-frame neutral studio baseline, eight criteria, current fit and +triangle/vertex measurements, and ranked P1/P2 gaps are in `docs/npc-visual-review.md`. No +character model was changed in this pass. ### Body proportions and silhouette diff --git a/docs/README.md b/docs/README.md index 06edc58..100392c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ This folder holds the project documentation, visual references and generated QA - [`architecture.md`](architecture.md) — arc42 system architecture - [`getting-started.md`](getting-started.md) — setup, run/test commands and controls - [`asset-tooling.md`](asset-tooling.md) — image/model pipelines and screenshot gates +- [`npc-visual-review.md`](npc-visual-review.md) — NPC visual baseline, acceptance criteria and ranked gaps - [`development-status.md`](development-status.md) — implemented features and current gaps - [`smoke-checklist.md`](smoke-checklist.md) — manual verification - [`ui-copy.md`](ui-copy.md) — screen, copy and button inventory diff --git a/docs/asset-tooling.md b/docs/asset-tooling.md index 7c9b470..091eca8 100644 --- a/docs/asset-tooling.md +++ b/docs/asset-tooling.md @@ -213,6 +213,20 @@ and firefighter helmet so the badge clears the jacket and the brim does not sit over the eyes; these are `NpcAvatar.GEAR_OFFSETS`, not changes to the gear contract. +`capture_npc_baseline.gd` is the 0.9.4 neutral baseline: eight characters × +front/three-quarter/profile/back/full-body/crowd = 48 frames with a fixed +camera, studio lighting and no venue/HUD/animation. The raw PNGs stay in +`tmp/`; `tools/npc_baseline.py sheet` builds the committed contact sheet, and +`tools/npc_baseline.py compare --before DIR --after DIR` builds the review +comparison after a model change. See +[`npc-visual-review.md`](npc-visual-review.md). + +```bash +godot --path . --script res://src/test/capture_npc_baseline.gd -- \ + --out=res://tmp/npc-baseline +python3 tools/npc_baseline.py sheet --dir tmp/npc-baseline +``` + `capture_realism.gd` runs seven stages (day, portrait, evening, late, Joke Shop day, Joke Shop night, studio); `--stages=day,late` limits it while tuning; `--flashlight` turns on the guard lamp for night stages (used by the Desktop HQ A/B). The `portrait` shot freezes one NPC on open sidewalk diff --git a/docs/blender-assets.md b/docs/blender-assets.md index 6b23b95..6458087 100644 --- a/docs/blender-assets.md +++ b/docs/blender-assets.md @@ -5,7 +5,10 @@ Architecture context: the asset contracts and fallback path are summarized in Status: built, audited, and integrated (2026-11-01). `NpcAvatar` selects the character variants and attaches the gear; `WorldBuilder` loads the prop library -with colliders and phase-driven emissives. +with colliders and phase-driven emissives. The 0.9.4 neutral baseline and its +acceptance criteria are in [`npc-visual-review.md`](npc-visual-review.md); +the committed 8 × 6 contact sheet is +[`blender_previews/sheet_npc_baseline.jpg`](blender_previews/sheet_npc_baseline.jpg). Generator: [`tools/blender_assets.py`](../tools/blender_assets.py). Manifest (the source of truth for integration): @@ -197,3 +200,7 @@ carry baked AO and the shared neutral material maps described above. the legs through the hem. - Preview colours (`PREVIEW_TINTS`) only stand in for `outfit_for()`. The GLBs stay neutral. +- The 0.9.4 baseline found three P1 portrait issues: the tourist's photo prop + duplicates the backpack silhouette, the face is shallow/faceted at portrait + range, and the backpack straps read as rigid bars. The full ranked list and + accepted/deferred items are in [`npc-visual-review.md`](npc-visual-review.md). diff --git a/docs/blender_previews/README.md b/docs/blender_previews/README.md index 509a292..bcc91a2 100644 --- a/docs/blender_previews/README.md +++ b/docs/blender_previews/README.md @@ -43,6 +43,7 @@ Committed contact sheets and local Blender renders for the character, gear and p | security_podium.png | [`security_podium.png`](security_podium.png) | | sedan.png | [`sedan.png`](sedan.png) | | sheet_npc.jpg | [`sheet_npc.jpg`](sheet_npc.jpg) | +| sheet_npc_baseline.jpg | [`sheet_npc_baseline.jpg`](sheet_npc_baseline.jpg) | | sheet_npc_gear.jpg | [`sheet_npc_gear.jpg`](sheet_npc_gear.jpg) | | sheet_props.jpg | [`sheet_props.jpg`](sheet_props.jpg) | | shoulder_bag.png | [`shoulder_bag.png`](shoulder_bag.png) | diff --git a/docs/blender_previews/sheet_npc_baseline.jpg b/docs/blender_previews/sheet_npc_baseline.jpg new file mode 100644 index 0000000..7adf5eb Binary files /dev/null and b/docs/blender_previews/sheet_npc_baseline.jpg differ diff --git a/docs/development-status.md b/docs/development-status.md index e524a93..09f2a1f 100644 --- a/docs/development-status.md +++ b/docs/development-status.md @@ -39,6 +39,7 @@ Implemented so far: - Aim-free selection pass (v0.3.0 work, [ADR-0011](adr/0011-aim-free-selection-and-precision-walk.md)): the focus cone ranks candidates by angle then distance, `Tab` and the mouse wheel cycle nearby people nearest-first and pin the selection, right click clears focus with a short re-acquire grace, the HUD reports how many people are in the ring, and holding `Shift` walks at half speed for close work - Visual alignment: start/venue menu, pause, settings, shift review, radio, wand, inspection, timeline, HUD target chip/escalation bar, and radial menu now use or match their AI references in `assets/images/ui/`. The runtime art loader (`src/game/ui/art.gd`) falls back cleanly when a file is missing. The wand panel lists the guest's carried items, and multiline HUD/wand strings render real line breaks. +- NPC visual baseline (0.9.4 work): `src/test/capture_npc_baseline.gd` captures all eight library characters from front, three-quarter, profile, back, full-body and crowd views in a neutral studio (48 frames). The criteria, measurements and ranked gaps are in [`npc-visual-review.md`](npc-visual-review.md); the committed sheet is [`blender_previews/sheet_npc_baseline.jpg`](blender_previews/sheet_npc_baseline.jpg). No model changes were made in this baseline step. - UI-state capture pass (0.9.3 work): `src/test/capture_ui_states.gd` adds committed captures 14–21 for the start menu, pause, settings, shift review, emergency radio, action wheel, crowd panic and responder arrival. Every one of the 21 catalogue entries now has a current-build capture; `tools/check_screenshots.sh` reports 21 good / 0 bad and the automatic judge reports 21/21 match, 0 diff. Crowd panic and responder arrival are staged presentation frames, not gameplay changes. - Realism adoption (post-v0.6.0): v2 per-phase skylines and surface tiles with normal maps, retuned sun/exposure/fog, wet asphalt, denser foliage, a visible additive flashlight beam, and refreshed staging captures. `tools/check_screenshots.sh` is the repeatable capture/compare gate; judge is clean of `diff` verdicts. - Input/reliability fixes: selecting a venue releases the start-menu input lock and recaptures the mouse; the body-camera overlay no longer covers the shift status. Mouse look now reads at `_input`, restores capture on window focus/click-back, and the HUD is mouse-transparent. Normal radio reports submit on click; emergency calls still require the hold-to-confirm path. diff --git a/docs/npc-visual-review.md b/docs/npc-visual-review.md new file mode 100644 index 0000000..7572e3b --- /dev/null +++ b/docs/npc-visual-review.md @@ -0,0 +1,183 @@ +# NPC visual baseline and acceptance criteria + +Status: baseline captured 2026-11-02. No character GLB, texture, gear or runtime +mesh was changed in this step. The baseline is the reference for the 0.9.4 NPC +visual optimization. + +## What was captured + +`src/test/capture_npc_baseline.gd` stages all eight library characters in a +neutral studio and captures six views per character: + +| View | Camera | Purpose | +| --- | --- | --- | +| `front` | 1.85 m, 32° FOV, head/upper body | face, hair, collar, shoulders | +| `three_quarter` | 1.85 m, yaw 35° | cheek/jaw, ear, profile transition | +| `profile` | 1.85 m, yaw 90° | head depth, nose, ear, chest silhouette | +| `back` | 1.85 m, yaw 180° | hair cap, neck, coat/backpack fit | +| `full_body` | 4.2 m, 38° FOV | proportions, hands, shoes, hem | +| `crowd` | 8.0 m, 45° FOV | silhouette read at normal crowd distance | + +The stage is deliberately separate from the venue review harness: a mid-grey +solid background, ground plane, one key light and one cool fill light, no HUD, +no crowd, no flashlight, no phase lighting and no animation. Front is Godot +Z, +matching `NpcAvatar.yaw_towards()`. The camera orbits at the recorded yaw rather +than rotating the avatar, so the pose and foot contact stay fixed between views. +The full command is: + +```bash +godot --path . --script res://src/test/capture_npc_baseline.gd -- \ + --out=res://tmp/npc-baseline +python3 tools/npc_baseline.py sheet --dir tmp/npc-baseline +``` + +The 48 raw PNGs are local QA under `tmp/` (gitignored). The committed contact +sheet is [`blender_previews/sheet_npc_baseline.jpg`](blender_previews/sheet_npc_baseline.jpg). +The capture is deterministic apart from engine rasterisation; re-running into +the same directory keeps the earlier files and writes `-vN` copies rather than +overwriting them. + +## Acceptance criteria + +The criteria below are for the stylized target, not photorealism. A change must +pass every applicable item before a library rebuild is accepted. + +1. **Asset contract:** `npc_base` mesh node, the required bones, five tint slots, + front +Z, feet within 3 cm of y=0, 1.6–2.1 m height, at most 6,000 triangles + and 5,000 glTF vertices per character. Gear remains at most 1,500 triangles. +2. **Proportion and silhouette:** head, neck, shoulders, torso, hips, limbs and + feet read as one body; front/three-quarter/profile silhouettes agree with the + rest of the eight-character library; broad/slim/average build variation is + preserved. +3. **Face:** at 1.85 m the eyes, iris, brows, nose, mouth and ears are distinct; + the head volume has a readable jaw/cheek/chin transition; no face-plane seams, + inverted normals or z-fighting. The face direction must remain unambiguous. +4. **Hair and headgear:** hair covers the scalp without gaps or scalp-through + seams; fringes, buns and beards keep their silhouette. Caps, beanies, hoods, + sunglasses and the firefighter helmet clear the brow, eyes and ears. Headgear + may sit outside the 1.55–1.81 m face band, but its brim must not cover the + eyes. +5. **Hands and footwear:** at conversation distance the fingers and thumb read + as a hand, not a mitten; hand items contact the palm rather than floating; + shoes and boots are grounded with a readable toe/heel silhouette and preserve + the responder/firefighter footwear language. +6. **Clothing:** collars, cuffs, plackets, waistbands, pockets, badges and + reflective bands transition cleanly; no z-fighting or floating detail; coat, + dress and skirt hems do not show legs through the fabric during idle, walk, + block, sit or turn; the five tint slots and fixed detail colours remain + unchanged. +7. **Gear:** head gear clears the refined face/hair; the lanyard badge remains + readable in front of the jacket; backpack, shoulder bag and SCBA tank straps + lie outside the torso and do not read as a second bag; hand items use the + authored grip points. +8. **Crowd and performance:** the silhouette remains readable at the 8 m crowd + frame and the LOD/impostor tiers match the full mesh; the 24-NPC budget stays + within the existing 6.93 ms baseline (no regression beyond the measured + variance), and no new per-frame material or mesh churn is introduced. + +Asset-space `fit` values are measurements of the untouched GLBs, not a pass/fail +result. A negative `x_clearance` means the gear's narrowest face is inside the +body reference; a negative `radial_clearance` means it extends beyond the body +silhouette (normal for a backpack, suspicious when it is a strap). Runtime +`NpcAvatar.GEAR_OFFSETS` is applied after attachment and is not part of the GLB +measurement. + +## Baseline measurements + +From `python3 tools/blender_assets.py fit` and the manifest: + +| Character | Tris | Verts | Head width | Clothing radius | Notable gear clearances | +| --- | ---: | ---: | ---: | ---: | --- | +| `npc_casual` | 3,974 | 3,744 | 0.315 m | 0.191 m (hip) | backpack x −0.041, radial −0.162 | +| `npc_jacket` | 4,394 | 4,175 | 0.315 m | 0.191 m (hip) | lanyard x −0.116, radial +0.023 (runtime +4 cm forward) | +| `npc_coat` | 5,026 | 4,807 | 0.315 m | 0.225 m (skirt) | sunglasses width ratio 0.641; bag x +0.037, radial −0.130 | +| `npc_vest` | 4,474 | 4,340 | 0.315 m | 0.191 m (hip) | cap width ratio 1.067 | +| `npc_dress` | 4,428 | 4,026 | 0.315 m | 0.260 m (skirt) | bag x +0.002, radial −0.095 | +| `npc_hoodie` | 4,826 | 4,504 | 0.315 m | 0.190 m (hip) | — | +| `npc_firefighter` | 4,848 | 4,735 | 0.315 m | 0.255 m (skirt) | helmet width ratio 1.651; SCBA x −0.105, radial −0.076 | +| `npc_guard` | 4,560 | 4,529 | 0.315 m | 0.194 m (hip) | cap width ratio 1.067 | + +The widest character is `npc_coat` at 5,026 triangles and 4,807 vertices, so +the current headroom is 974 triangles and 193 vertices per character. Any face +or hand refinement must come out of that budget or replace existing detail. + +## Baseline findings, ranked + +### P1 — fix in the first model pass + +1. **The tourist carries two bag silhouettes.** `_add_props()` creates a + 0.30 × 0.36 × 0.14 box at `(-0.34, 1.15, -0.18)` for the tourist photo pose + while `_add_accessory("backpack")` also attaches the library backpack. From + the three-quarter/back view the two read as separate bags. The photo prop + should be hidden outside the photo pose, merged into the backpack, or moved to + a clearly held camera. +2. **The face is the largest portrait gap.** The eyes, nose and lips are + readable but sit on a shallow, faceted head: the eye whites protrude, the + eyelid/cheek transition is missing, and the mouth is a dark slot. This is + intentionally stylized, but the head volume and eye socket are the first + things a player sees at conversation range. +3. **Backpack straps read as rigid black bars.** The back gear's straps cross + the chest as thin, straight geometry with visible gaps from the shoulders, + not as fabric sitting on the torso. A player sees this on the most common + tourist silhouette in every crowd. + +### P2 — fix after the first P1 pass + +4. **Hands and grips are not proven at conversation distance.** The full-body + frame shows small block hands; the camera, phone and clipboard need a + close-up grip check before the hand mesh is accepted. +5. **Clothing detail is tile-driven and transitional seams are visible.** The + fabric pattern repeats strongly on the casual, coat and dress variants, and + collar/cuff/waist edges are abrupt. This is more visible than the shoe/hem + details in the current portraits. +6. **Shoes are simple blocks.** They are grounded, but the toe/heel silhouette + is minimal; the responder and firefighter footwear should remain distinct + after any refinement. +7. **Headgear scale needs a visual, not just numeric, check.** The cap and + helmet width ratios are 1.067 and 1.651; those are expected for headgear, but + the brim/ear clearance and the helmet's relationship to the head still need a + face-on check. +8. **Performer props dominate the silhouette.** The red cone hat and the + primitive sign are intentionally large stage props, but they should be + checked during the pose pass so they do not read as head/body geometry. + +### Accepted or deferred for this baseline + +- The hooded variants intentionally cover part of the face; face readability is + judged on the visible lower face and silhouette, not on hidden eyes. +- The low-poly style is the target. The acceptance bar is readability and clean + construction, not photoreal skin or hair. +- Hem-through-leg clipping during a walk cannot be judged from a rest pose; it + stays in the body/clothing workstream. +- The 8 m `crowd` view is still LOD 0. The separate LOD/impostor check remains + required; this baseline does not claim to validate it. + +## Before/after workflow + +Keep the baseline directory, rebuild the same capture into a new directory after +the model changes, then produce a comparison sheet. Do not overwrite the +committed baseline image: + +```bash +godot --path . --script res://src/test/capture_npc_baseline.gd -- \ + --out=res://tmp/npc-baseline-after +python3 tools/npc_baseline.py compare \ + --before tmp/npc-baseline --after tmp/npc-baseline-after \ + --out tmp/npc-baseline-compare.jpg +``` + +The comparison sheet places the before/after pair for each view side by side. +If the change is accepted, copy the after sheet to a new committed name +(`sheet_npc_baseline_after.jpg`) and link both from the preview index. The +baseline is not replaced. + +## Next implementation order + +1. Remove the duplicate tourist bag silhouette and refit the backpack straps. +2. Refine the head/eye socket/jaw volume without changing the face direction or + the five tint slots. +3. Add a conversation-distance hand/grip capture and fix hand geometry before + judging hand items. +4. Audit hems and footwear in walk/sit/block poses. +5. Re-run the 48-frame baseline, the 96-frame final-context review, the 24-NPC + budget and `blender_assets.py audit/fit`; publish the before/after sheet. diff --git a/src/test/capture_npc_baseline.gd b/src/test/capture_npc_baseline.gd new file mode 100644 index 0000000..b4349b9 --- /dev/null +++ b/src/test/capture_npc_baseline.gd @@ -0,0 +1,194 @@ +extends SceneTree +## Neutral-studio contact-sheet baseline for the 0.9.4 NPC visual pass. +## +## Stages all eight library characters at a fixed camera height against a +## mid-grey studio backdrop: front, three-quarter, profile and back portraits, then a +## full-body front and a crowd-distance silhouette. No venue, crowd, HUD, +## flashlight or phase lighting is involved, so the same frames are directly +## comparable before and after a model change. +## +## Usage (windowed): +## godot --path . --script res://src/test/capture_npc_baseline.gd -- \ +## --out=res://tmp/npc-baseline +## +## The outputs are local QA art. Build the contact sheet with: +## python3 tools/npc_baseline.py sheet --dir tmp/npc-baseline + +const Rules := preload("res://src/core/rules.gd") +const NpcAvatar := preload("res://src/game/npc_avatar.gd") +const NpcProfile := preload("res://src/core/npc_profile.gd") + +const VARIANTS := [ + {"name": "npc_casual", "type": Rules.NpcType.TOURIST}, + {"name": "npc_jacket", "type": Rules.NpcType.AUTHORIZED_GUEST}, + {"name": "npc_coat", "type": Rules.NpcType.INFLUENCER}, + {"name": "npc_vest", "type": Rules.NpcType.DELIVERY}, + {"name": "npc_dress", "type": Rules.NpcType.PERFORMER}, + {"name": "npc_hoodie", "type": Rules.NpcType.VIOLENT_THREAT}, + {"name": "npc_firefighter", "type": Rules.NpcType.RESPONDER}, + {"name": "npc_guard", "type": Rules.NpcType.CONFUSED_VISITOR}, +] +## Camera orbits the subject at yaw 0 (front = Godot +Z). Portrait views keep +## the eye/head in frame; the last two show the full body and crowd silhouette. +const VIEWS := [ + {"name": "front", "yaw": 0.0, "distance": 1.85, "height": 1.55, + "target_y": 1.52, "fov": 32.0}, + {"name": "three_quarter", "yaw": 35.0, "distance": 1.85, "height": 1.55, + "target_y": 1.52, "fov": 32.0}, + {"name": "profile", "yaw": 90.0, "distance": 1.85, "height": 1.55, + "target_y": 1.52, "fov": 32.0}, + {"name": "back", "yaw": 180.0, "distance": 1.85, "height": 1.55, + "target_y": 1.52, "fov": 32.0}, + {"name": "full_body", "yaw": 0.0, "distance": 4.2, "height": 0.95, + "target_y": 0.95, "fov": 38.0}, + {"name": "crowd", "yaw": 0.0, "distance": 8.0, "height": 1.45, + "target_y": 1.0, "fov": 45.0}, +] +const SETTLE_FRAMES := 4 +const MAX_FRAMES := 4000 + +var out_dir := "user://screenshots/npc_baseline" +var frames := 0 +var _phase := "stage_variant" +var _variant_i := 0 +var _view_i := 0 +var _wait := 0 +var _avatar: NpcAvatar = null +var _camera: Camera3D = null +var _stage: Node3D = null + +func _initialize() -> void: + for arg in OS.get_cmdline_user_args(): + if str(arg).begins_with("--out="): + out_dir = str(arg).substr(6) + root.size = Vector2i(1280, 720) + _build_stage() + +func _process(_delta: float) -> bool: + frames += 1 + if frames > MAX_FRAMES: + push_error("capture_npc_baseline timed out") + quit(1) + return true + match _phase: + "stage_variant": + _stage_variant() + _wait = SETTLE_FRAMES + _phase = "settle" + "settle": + _wait -= 1 + if _wait <= 0: + _frame_view() + _phase = "shoot" + "shoot": + _shoot() + _view_i += 1 + if _view_i >= VIEWS.size(): + _view_i = 0 + _variant_i += 1 + if _variant_i >= VARIANTS.size(): + print("capture_npc_baseline done -> %s" % out_dir) + quit(0) + return true + _phase = "stage_variant" + else: + _wait = 1 + _phase = "settle" + return false + +func _build_stage() -> void: + _stage = Node3D.new() + _stage.name = "NpcBaselineStage" + root.add_child(_stage) + + var env := Environment.new() + env.background_mode = Environment.BG_COLOR + env.background_color = Color(0.56, 0.59, 0.63) + env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + env.ambient_light_color = Color(0.72, 0.75, 0.80) + env.ambient_light_energy = 0.55 + env.tonemap_mode = Environment.TONE_MAPPER_ACES + var world_env := WorldEnvironment.new() + world_env.environment = env + _stage.add_child(world_env) + + var sun := DirectionalLight3D.new() + sun.name = "KeyLight" + sun.rotation_degrees = Vector3(-38.0, -32.0, 0.0) + sun.light_energy = 1.35 + sun.light_color = Color(1.0, 0.97, 0.92) + sun.shadow_enabled = true + _stage.add_child(sun) + var fill := DirectionalLight3D.new() + fill.name = "FillLight" + fill.rotation_degrees = Vector3(-12.0, 145.0, 0.0) + fill.light_energy = 0.45 + fill.light_color = Color(0.75, 0.82, 1.0) + _stage.add_child(fill) + + var ground := MeshInstance3D.new() + var plane := PlaneMesh.new() + plane.size = Vector2(24.0, 24.0) + ground.mesh = plane + var mat := StandardMaterial3D.new() + mat.albedo_color = Color(0.30, 0.32, 0.35) + mat.roughness = 0.92 + ground.material_override = mat + _stage.add_child(ground) + + _camera = Camera3D.new() + _camera.name = "BaselineCamera" + _camera.current = true + _stage.add_child(_camera) + +func _stage_variant() -> void: + if _avatar != null and is_instance_valid(_avatar): + _avatar.queue_free() + _avatar = null + var variant: Dictionary = VARIANTS[_variant_i] + var profile := NpcProfile.new() + profile.id = "baseline_" + str(variant["name"]) + profile.display_name = str(variant["name"]).to_upper() + profile.npc_type = int(variant["type"]) + var avatar := NpcAvatar.new() + avatar.name = "BaselineSubject" + avatar.character_override = str(variant["name"]) + avatar.setup(profile, "roam", 4242, Vector3.ZERO, Vector3(0, 0, 2), + Vector3(0, 0.5, 1.2), str(profile.id)) + _stage.add_child(avatar) + avatar.global_position = Vector3.ZERO + avatar.rotation.y = 0.0 + avatar.set_physics_process(false) + avatar.set_process(false) + for node in avatar.find_children("*", "Label3D", true, false): + (node as Label3D).visible = false + for node in avatar.find_children("FocusCircle", "MeshInstance3D", true, false): + (node as MeshInstance3D).visible = false + _avatar = avatar + +func _frame_view() -> void: + if _avatar == null or not is_instance_valid(_avatar): + return + var view: Dictionary = VIEWS[_view_i] + var yaw := deg_to_rad(float(view["yaw"])) + var distance := float(view["distance"]) + var target := Vector3(0.0, float(view["target_y"]), 0.0) + _camera.position = target + Vector3(sin(yaw) * distance, float(view["height"]) - target.y, + cos(yaw) * distance) + _camera.fov = float(view["fov"]) + _camera.look_at(target, Vector3.UP) + +func _shoot() -> void: + var variant: Dictionary = VARIANTS[_variant_i] + var view: Dictionary = VIEWS[_view_i] + var dir := ProjectSettings.globalize_path(out_dir) + DirAccess.make_dir_recursive_absolute(dir) + var base := "%s__%s" % [variant["name"], view["name"]] + var path := dir.path_join(base + ".png") + var index := 2 + while FileAccess.file_exists(path): + path = dir.path_join("%s-v%d.png" % [base, index]) + index += 1 + var img: Image = root.get_texture().get_image() + var err := img.save_png(path) + print("%s -> err=%d" % [base, err]) diff --git a/tools/npc_baseline.py b/tools/npc_baseline.py new file mode 100644 index 0000000..aee17b0 --- /dev/null +++ b/tools/npc_baseline.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Assemble the local NPC visual-baseline contact sheet. + +The frames are produced by `src/test/capture_npc_baseline.gd`; this script only +labels and tiles them so the eight variants and six views can be reviewed in +one image. It is deliberately offline and has no game-engine dependency. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +VARIANTS = [ + "npc_casual", + "npc_jacket", + "npc_coat", + "npc_vest", + "npc_dress", + "npc_hoodie", + "npc_firefighter", + "npc_guard", +] +VIEWS = ["front", "three_quarter", "profile", "back", "full_body", "crowd"] + + +def expected_files() -> list[tuple[str, str, str]]: + """Return (variant, view, filename) for the complete 8 x 6 baseline.""" + return [(variant, view, f"{variant}__{view}.png") + for variant in VARIANTS for view in VIEWS] + + +def latest_for(directory: Path, stem: str) -> Path | None: + """Return the newest capture for a stem, ignoring the no-overwrite -vN copies.""" + candidates = [directory / f"{stem}.png"] + candidates.extend(directory.glob(f"{stem}-v*.png")) + existing = [path for path in candidates if path.is_file()] + if not existing: + return None + return max(existing, key=lambda path: path.stat().st_mtime_ns) + + +def next_free_path(path: Path) -> Path: + if not path.exists(): + return path + index = 2 + while True: + candidate = path.with_name(f"{path.stem}-v{index}{path.suffix}") + if not candidate.exists(): + return candidate + index += 1 + + +def build_sheet(source: Path, output: Path) -> Path: + from PIL import Image, ImageDraw + + cell_w, cell_h = 320, 180 + label_h = 22 + header_h = 28 + sheet_w = cell_w * len(VIEWS) + sheet_h = header_h + (cell_h + label_h) * len(VARIANTS) + sheet = Image.new("RGB", (sheet_w, sheet_h), (24, 26, 30)) + draw = ImageDraw.Draw(sheet) + for column, view in enumerate(VIEWS): + draw.text((column * cell_w + 8, 8), view.replace("_", " ").title(), + fill=(220, 224, 230)) + for row, variant in enumerate(VARIANTS): + for column, view in enumerate(VIEWS): + path = latest_for(source, f"{variant}__{view}") + if path is None: + raise FileNotFoundError( + f"missing baseline frame: {variant}__{view}.png in {source}") + with Image.open(path) as image: + tile = image.convert("RGB").resize((cell_w, cell_h), + Image.Resampling.LANCZOS) + x = column * cell_w + y = header_h + row * (cell_h + label_h) + sheet.paste(tile, (x, y)) + draw.text((x + 8, y + cell_h + 4), variant, fill=(230, 230, 230)) + output.parent.mkdir(parents=True, exist_ok=True) + sheet.save(output, quality=86, optimize=True) + return output + + +def build_comparison(before: Path, after: Path, output: Path) -> Path: + """Tile the before and after frames as one review sheet. + + The layout is variant-major, with each view represented by a before/after + pair. Frames are deliberately smaller than the single-run sheet so all 96 + cells remain viewable on a normal desktop. + """ + from PIL import Image, ImageDraw + + cell_w, cell_h = 200, 112 + label_h = 16 + header_h = 28 + pair_w = cell_w * 2 + sheet_w = pair_w * len(VIEWS) + sheet_h = header_h + (cell_h + label_h) * len(VARIANTS) + sheet = Image.new("RGB", (sheet_w, sheet_h), (24, 26, 30)) + draw = ImageDraw.Draw(sheet) + for column, view in enumerate(VIEWS): + x = column * pair_w + draw.text((x + 4, 8), f"{view.replace('_', ' ').title()} before", + fill=(220, 224, 230)) + draw.text((x + cell_w + 4, 8), "after", fill=(220, 224, 230)) + for row, variant in enumerate(VARIANTS): + for column, view in enumerate(VIEWS): + for offset, source in enumerate((before, after)): + path = latest_for(source, f"{variant}__{view}") + if path is None: + raise FileNotFoundError( + f"missing {variant}__{view}.png in {source}") + with Image.open(path) as image: + tile = image.convert("RGB").resize((cell_w, cell_h), + Image.Resampling.LANCZOS) + x = column * pair_w + offset * cell_w + y = header_h + row * (cell_h + label_h) + sheet.paste(tile, (x, y)) + draw.text((x + 4, y + cell_h + 2), variant, fill=(230, 230, 230)) + output.parent.mkdir(parents=True, exist_ok=True) + sheet.save(output, quality=86, optimize=True) + return output + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="NPC baseline contact sheet") + sub = parser.add_subparsers(dest="command", required=True) + sheet = sub.add_parser("sheet") + sheet.add_argument("--dir", required=True, type=Path, + help="directory containing the capture_npc_baseline.gd PNGs") + sheet.add_argument("--out", type=Path, default=None, + help="output JPEG (default: /sheet.jpg; never overwritten)") + compare = sub.add_parser("compare") + compare.add_argument("--before", required=True, type=Path) + compare.add_argument("--after", required=True, type=Path) + compare.add_argument("--out", type=Path, required=True) + args = parser.parse_args(argv) + if args.command == "sheet": + source = args.dir if args.dir.is_absolute() else ROOT / args.dir + output = args.out or source / "sheet.jpg" + if not output.is_absolute(): + output = ROOT / output + output = next_free_path(output) + try: + written = build_sheet(source, output) + except FileNotFoundError as exc: + print(exc) + return 2 + print(f"wrote {written.relative_to(ROOT)}") + return 0 + if args.command == "compare": + before = args.before if args.before.is_absolute() else ROOT / args.before + after = args.after if args.after.is_absolute() else ROOT / args.after + output = args.out if args.out.is_absolute() else ROOT / args.out + output = next_free_path(output) + try: + written = build_comparison(before, after, output) + except FileNotFoundError as exc: + print(exc) + return 2 + print(f"wrote {written.relative_to(ROOT)}") + return 0 + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/test_npc_baseline.py b/tools/test_npc_baseline.py new file mode 100644 index 0000000..e81227f --- /dev/null +++ b/tools/test_npc_baseline.py @@ -0,0 +1,28 @@ +import os +import tempfile +import unittest +from pathlib import Path + +import npc_baseline + + +class NpcBaselineTests(unittest.TestCase): + def test_expected_files_cover_all_variants_and_views(self): + rows = npc_baseline.expected_files() + self.assertEqual(len(rows), 48) + self.assertEqual({row[0] for row in rows}, set(npc_baseline.VARIANTS)) + self.assertEqual({row[1] for row in rows}, set(npc_baseline.VIEWS)) + self.assertEqual(len({row[2] for row in rows}), 48) + + def test_latest_capture_prefers_the_full_stem(self): + with tempfile.TemporaryDirectory() as tmp: + folder = Path(tmp) + (folder / "npc_casual__front.png").write_bytes(b"old") + newer = folder / "npc_casual__front-v2.png" + newer.write_bytes(b"new") + os.utime(newer, (2_000_000_000, 2_000_000_000)) + self.assertEqual(npc_baseline.latest_for(folder, "npc_casual__front"), newer) + + +if __name__ == "__main__": + unittest.main()