diff --git a/DONE.md b/DONE.md index 664df09..beb9136 100644 --- a/DONE.md +++ b/DONE.md @@ -752,3 +752,65 @@ Completed 2026-11-01. Python tooling **56/56**, headless **188/188**, prop galle triangle/vertex budget in the pipeline docstring. - [x] README lists the gallery capture command and image; the pipeline docstring and audit docs describe the budget checks. + +## Better NPC Characters + +Completed 2026-11-01 (v0.5.0). Headless suite **208/208**; NPC budget smoke reports 16 avatars, +534 meshes, ~6.9 ms average frame time; PLAN-verb, crowd, emergency, and timeline smokes pass. + +- [x] Replaced the rigid capsules with an articulated rig: hip -> thigh -> knee -> shin -> shoe and + shoulder -> upper arm -> elbow -> forearm -> hand, with knee/elbow pivots driven by the walk + cycle and the seated pose. +- [x] Layered clothing (`layer_for`): jacket, hi-vis vest, coat, and dress over the base outfit, + with role-appropriate defaults and deterministic colourways. +- [x] Extended accessories and hand items: bag, umbrella, phone, camera, clipboard, cup, plus the + existing cap/hood/backpack/sunglasses/lanyard. Hand items attach to the right hand socket. +- [x] Face and hair variety: ears, brows, mouth, five hair silhouettes (long, bun, beard, bald, + short), height range, and deterministic per-seed appearance. +- [x] Added idle behaviour layers (`idle_style_for`): breathing, weight shift, look-around, phone, + clipboard, and crossed arms; escalation gestures raise both hands (L3+) or point (L2), and + noticed NPCs turn their head toward the guard. +- [x] Walk cycle now swings hips/shoulders, bends knees and elbows, and uses a faster, longer + stride when fleeing. +- [x] Door queue staging: GameManager sorts waiting guests and assigns spaced `queue_slot_for` + offsets, so one person is at the door and the rest wait behind without overlapping. +- [x] Reaction readability: a billboarded symbol above the head (`!!` panic, `?!` watching, `!` + blocked, `?` warned) updates from the reaction/escalation state. +- [x] Three LOD tiers with hysteresis: full detail, simplified (props/shadows off), and a + billboard impostor; `apply_lod` is tested for the swap. +- [x] Re-measured the live-NPC budget with the full rig (16 avatars, 534 meshes) and added + `smoke_npc_budget.gd`, which fails above the documented 50 ms frame budget. +- [x] Tests: deterministic layers/hand items/hair, role layers, queue slot spacing, LOD + thresholds/hysteresis, reaction labels, articulated pivots, mesh budget, and the + walk-to-idle animation transition. + +## Better Player Movement + +Completed 2026-11-01 (v0.5.0). Windowed `smoke_movement.gd` passes; headless tests cover surface +lookup, constants, gamepad bindings, and settings persistence. + +- [x] Split acceleration and braking (`accel` 22, `decel` 30 m/s²) so stopping is snappier than + starting, and fixed the inverted forward axis found while staging the movement smoke: W now + walks toward the camera's forward vector instead of moonwalking. +- [x] Step-up assist (0.32 m) climbs kerbs and the entrance steps, with wall sliding left to + `move_and_slide`; the smoke walks the guard from the sidewalk onto the landing. +- [x] Camera feel: speed-scaled head bob, a landing dip, and a precision-walk FOV change, all + gated by the new "Head bob and camera motion" setting; a motion-sickness toggle persists. +- [x] Precision walk shows a HUD chip and plays a short tick when it engages, in addition to the + slower speed. +- [x] Surface-aware footsteps (concrete/asphalt/stone) synced to stride distance, plus a landing + cue; both have observable counters for tests. +- [x] Gamepad support: left-stick movement, right-stick look with deadzone, A/B/X/Y face buttons, + LB slow, RB wand; bindings are added at runtime and the control legend swaps to gamepad + hints when a pad event is seen. +- [x] Moved the remaining hard-coded gameplay keys (Q/F/G/L/B/V, Tab cycle) onto named InputMap + actions, and the panel allow/deny keys onto movement actions. +- [x] Key/button remapping in the settings panel: click a binding, press a key or pad button, + persisted as `input_bindings` through `Save`; `InputBindings.apply` rewrites only key/button + events and leaves mouse and gamepad axes alone. +- [x] Added invert-Y and head-bob settings (with save/load round-trip tests) alongside the + existing look sensitivity. +- [x] Tests: surface lookup, step/stride/precision constants, gamepad binding presence and + deadzone, remap descriptor/apply/labels, and settings persistence for camera and bindings. +- [x] Added `smoke_movement.gd` (windowed) and `11-precision-walk.png` with a movement catalogue + entry, AI mockup, and compare coverage. diff --git a/MEMORY.md b/MEMORY.md index 4d852f9..1192499 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -55,7 +55,19 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o - **Prop pipeline (done):** audit enforces 400 tris / 600 verts, ground contact, UV range, no degenerate tris, one material. `capture_prop_gallery.gd` writes `10-prop-gallery.png` (3x2 studio line-up, back row on a platform) and the `prop_gallery` catalogue entry compares it. -- **Presentation pass remaining (TODO):** NPC characters and player movement. Grounding facts: the +- **NPC character pass (v0.5.0):** Rig is articulated (`Rig/Hip* -> Knee`, `Shoulder -> Elbow -> + Hand`); `outfit_for` adds layer/hair_style/hand_item; `_idle_layer` drives phone/clipboard/crossed + and escalation gestures; `_reaction_tag` shows `!!`, `?!`, `!`, `?`; `apply_lod` swaps to a + billboard impostor at 26 m (hysteresis 1.5 m); GameManager `_refresh_queue` assigns queue offsets. + Perf: 16 avatars = 534 meshes, ~6.9 ms headless. +- **Player movement (v0.5.0):** **W was inverted** (`get_axis(forward, back)` is negative for W and + the code multiplied it directly); now `forward * -input_dir.y`. Step-up uses test_move + a raised + copy (0.32 m). Guard emits footstep/landed/precision_changed; GameManager forwards them to + AudioFeedback and the HUD. Gamepad actions are added at runtime by + `GuardController._ensure_gamepad_bindings`; remaps persist via `Save` `input_bindings` and are + applied by `InputBindings.apply` (key/button events only). Windowed `smoke_movement.gd` guards it. +- **Scheduled TODO pass complete:** only the optional Future ideas remain (live replay, extra + venues, voice audio, image-to-mesh). Grounding facts: the NPC rig is rigid capsules with a whole-limb swing, and `guard_controller.gd` uses one `accel` value. Each task should get its own windowed smoke or capture where it changes feel/looks. - **Backlog complete:** every planned TODO section is done and moved to `DONE.md`; `TODO.md` diff --git a/README.md b/README.md index 5aa0cd1..54ba559 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,9 @@ The central design goal is simple: | ![Late night: street lamps lit, marquee glowing, dark Hollywood Boulevard](docs/screenshots/05-late-night-avenue.png) | ![Night shift: guard flashlight cutting through the dark](docs/screenshots/06-night-flashlight.png) | | ![Wand screening at The Joke Shop: false alarm on keys](docs/screenshots/07-wand-screening.png) | ![Escalation HUD at L3 with body camera armed and reaction log](docs/screenshots/08-escalation-hud.png) | | ![Body-camera timeline: formal warning, body camera on, blocking](docs/screenshots/09-body-camera-timeline.png) | ![Prop gallery: the six generated street-furniture models on a studio backdrop](docs/screenshots/10-prop-gallery.png) | +| ![Precision walk at the venue steps with the HUD chip visible](docs/screenshots/11-precision-walk.png) | | -*Screenshots are generated by [`src/test/capture_screenshots.gd`](src/test/capture_screenshots.gd) and [`src/test/capture_prop_gallery.gd`](src/test/capture_prop_gallery.gd) from the current build — day, late-night, wand screening, escalation, shift-review timeline, and the prop lineup.* +*Screenshots are generated by [`src/test/capture_screenshots.gd`](src/test/capture_screenshots.gd) and [`src/test/capture_prop_gallery.gd`](src/test/capture_prop_gallery.gd) from the current build — day, late-night, wand screening, escalation, shift-review timeline, precision walk, and the prop lineup.* ## ⬇️ Downloads @@ -34,7 +35,7 @@ The central design goal is simple: > Downloads are public (no sign-in required) since the host's instance-wide > `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31. > -> **Latest: v0.4.0** — props have colliders and navigation obstacles, with zoned placements and deterministic variants. +> **Latest: v0.5.0** — articulated NPC characters with LOD and idle behaviour, plus movement feel, gamepad support, and key remapping. > Releases are built automatically when a `vX.Y.Z` tag is pushed > (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use > `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline @@ -263,11 +264,11 @@ 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 nine README captures plus the start menu, +The catalogue covers 21 screens: the eleven 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 nine screens with staged captures are also compared +`docs/screens/designs/`; the eleven screens with staged captures are also compared or judged against the running game. `compare` flags gross drift: wrong aspect ratio, undersized frame, near-blank @@ -319,6 +320,12 @@ godot --headless --path . --script res://src/test/smoke_plan_verbs.gd # Prop-collision smoke (windowed): guard stops at a bench, guest routes around it godot --path . --script res://src/test/smoke_props.gd +# NPC budget smoke: 16 full-rig avatars against the documented frame budget +godot --headless --path . --script res://src/test/smoke_npc_budget.gd + +# Movement smoke (windowed): steps, precision walk, footsteps and landing +godot --path . --script res://src/test/smoke_movement.gd + # Crowd smoke: rubbernecking on aggression, panic clearing the entrance godot --headless --path . --script res://src/test/smoke_crowd.gd @@ -339,6 +346,11 @@ The unit test runner is the project's own lightweight harness (no editor plugin ### Controls +Keyboard and mouse are the default; a gamepad works without configuration (left stick moves, +right stick looks, A opens/confirms the wheel, B cancels, X inspects, Y calls the radio, LB walks +slowly, RB uses the wand). Keys and pad buttons can be rebound in Settings, and the remaps persist +in `user://settings.json`. The HUD legend switches to gamepad hints when a pad event arrives. + | Input | Action | | --- | --- | | W/A/S/D + mouse | Move and look | @@ -387,7 +399,7 @@ Implemented so far: - 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` - Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools -- Headless unit tests: **187 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) +- Headless unit tests: **208 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, input remaps, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material, player movement, NPC character layers/LOD/queue, input bindings) - Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event - 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`) @@ -410,6 +422,8 @@ Implemented so far: - Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets - 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 +- NPC characters (v0.5.0): articulated limb rigs, layered outfits, hand items, face/hair variety, idle behaviour (phone, clipboard, crossed arms, look-around), escalation gestures, readable reaction symbols, door-queue offsets, and three LOD tiers with a billboard impostor +- Player movement (v0.5.0): separate acceleration/braking, step-up assist, head bob and landing dip (toggleable), precision-walk FOV/chip/tick, surface-aware footsteps, gamepad support, and persistent key/button remapping in settings - Prop collision and placement (v0.4.0): every prop fits a static collider from its mesh bounds and publishes a navigation obstacle, so the guard and guests cannot walk through benches, bins, or bollards; placements are grouped into zones with a validator, and deterministic variants add rotation, scale, and small clutter - 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 diff --git a/TODO.md b/TODO.md index 8d341ac..7911999 100644 --- a/TODO.md +++ b/TODO.md @@ -1,77 +1,11 @@ # TODO -Godot 4.3 prototype. Latest release **v0.4.0** (prop collision, placement, variants). Headless -suite **187/187** plus **53** Python tooling tests; the session, PLAN-verb, crowd, emergency, -timeline, audio, scenario-content, props, and windowed focus/radial smokes pass. Anonymous -downloads are enabled on the host. +Godot 4.3 prototype. Latest release **v0.5.0** (NPC characters and player movement). Headless +suite **208/208** plus **56** Python tooling tests; the session, PLAN-verb, crowd, emergency, +timeline, audio, scenario-content, props, NPC-budget, movement, and windowed focus/radial smokes +pass. Anonymous downloads are enabled on the host. -The sections below are the rest of the presentation pass: NPC characters and player movement. -Each task is meant to be a small, reviewable slice with its own verification. - -## Better NPC Characters - -### Appearance and rig - -- [ ] Replace the rigid capsule limbs with an articulated rig: shoulder, elbow, hip, and knee - joints, so a walk cycle can bend knees and swing arms instead of rotating whole capsules. -- [ ] Add layered clothing: jacket over shirt, hi-vis vest, security uniform, dress/skirt, and - coat, with deterministic per-profile colourways and material roles. -- [ ] Extend the accessory catalogue: bag, umbrella, phone, camera, clipboard, headphones, and a - coffee cup; attach them to the correct hand/back socket. -- [ ] Add face and hair variety: several hair silhouettes, beards, hats, eye/brow shapes, age and - height ranges, and a small set of face textures or decal planes so heads are not identical. -- [ ] Keep appearance deterministic per `(profile, seed)` and add a test that the same seed - produces the same outfit while different types stay recognisable. - -### Animation and staging - -- [ ] Add an idle layer: breathing, weight shift, occasional look-around, phone check, and - arms-crossed, blended with the current walk and reaction states. -- [ ] Add conversation gestures: facing the guard when addressed, talking hand movement, nodding, - backing away, and pointing, driven by the existing reaction/escalation events. -- [ ] Improve the walk cycle: stride length tied to speed, foot planting without sliding, and a - distinct hurry/flee gait that matches `FLEE_SPEED`. -- [ ] Stage a door queue: waiting offsets, one-at-a-time approach, no overlapping capsules, and a - step-aside after the admission decision. -- [ ] Make reactions readable at a distance with body language plus a short icon/label - (rubberneck, panic, calm, angry) so crowd state is legible without the debug panel. - -### Performance and budgets - -- [ ] Add three LOD tiers (full rig, simplified mesh, billboard) with distance hysteresis, and - disable shadows for far tiers; keep the switch deterministic and testable. -- [ ] Re-measure the live-NPC budget with the full rig at `MAX_LIVE_NPCS`, and add a perf smoke - that fails when frame time crosses the documented budget. -- [ ] Add tests for animation state transitions (idle → walk → react) and for queue spacing at - the door. - -## Better Player Movement - -### Feel and handling - -- [ ] Split acceleration and deceleration in `guard_controller.gd` (faster stop than start, no - ice-skating), then tune `move_speed` and `accel` against a windowed feel pass. -- [ ] Add step-up assist for kerbs and the entrance steps (about 0.3 m) and keep wall sliding - smooth so the guard does not stick on doorframes, railings, or prop colliders. -- [ ] Add subtle head bob tied to stride and speed, a small FOV change in precision walk, and a - landing dip; expose a motion-sickness toggle in settings with persistence. -- [ ] Give precision walk (Shift) visible feedback: a HUD state chip and an audio tick when it - engages, in addition to the slower speed. -- [ ] Add surface-aware footsteps (concrete, asphalt, stone steps) with stride-synced timing and - a distinct landing sound, reusing the procedural audio helpers. - -### Input and accessibility - -- [ ] Add gamepad support: move/look actions, radial and action bindings, deadzone and look - sensitivity, with prompts that follow the active device. -- [ ] Move the remaining hard-coded keys into `InputMap` actions and persist any remaps through - the settings panel (keyboard and gamepad). -- [ ] Add a mouse-sensitivity and invert-Y setting if missing, and test the values survive a - settings save/load round trip. -- [ ] Add movement tests: acceleration ramp, step-up height limit, precision-walk speed, wall - slide, and gamepad deadzone. -- [ ] Run one windowed movement smoke per change above (walk the full block, climb the steps, - strafe along the wall) and capture a short screenshot set for the compare tool. +The scheduled presentation pass is complete (see `DONE.md`). Only optional future ideas remain. ## Future ideas (optional, not scheduled) diff --git a/content/strings.en.json b/content/strings.en.json index e46536e..a8b47d8 100644 --- a/content/strings.en.json +++ b/content/strings.en.json @@ -86,5 +86,7 @@ "timeline.close": "Close", "timeline.hint": "Left/Right step through events · Esc closes · no live replay yet", "review.timeline_button": "Body camera timeline", - "hud.nearby": "%d nearby · Tab or mouse wheel cycles targets" + "hud.nearby": "%d nearby · Tab or mouse wheel cycles targets", + "hud.precision": "Precision walk (Shift)", + "hud.controls_gamepad": "Left stick move · Right stick look · A wheel/confirm · B cancel · X inspect · Y radio · LB slow · RB wand" } diff --git a/docs/screens/designs/movement_precision.png b/docs/screens/designs/movement_precision.png new file mode 100644 index 0000000..4fa5577 Binary files /dev/null and b/docs/screens/designs/movement_precision.png differ diff --git a/docs/screenshots/01-venue-overview.png b/docs/screenshots/01-venue-overview.png index 249b065..f5d7f1a 100644 Binary files a/docs/screenshots/01-venue-overview.png and b/docs/screenshots/01-venue-overview.png differ diff --git a/docs/screenshots/02-targeting-npc.png b/docs/screenshots/02-targeting-npc.png index 54b7a51..1f0adcb 100644 Binary files a/docs/screenshots/02-targeting-npc.png and b/docs/screenshots/02-targeting-npc.png differ diff --git a/docs/screenshots/03-access-inspection.png b/docs/screenshots/03-access-inspection.png index a778e07..f7b8504 100644 Binary files a/docs/screenshots/03-access-inspection.png and b/docs/screenshots/03-access-inspection.png differ diff --git a/docs/screenshots/04-radio-report.png b/docs/screenshots/04-radio-report.png index 95758e2..002dc2b 100644 Binary files a/docs/screenshots/04-radio-report.png and b/docs/screenshots/04-radio-report.png differ diff --git a/docs/screenshots/05-late-night-avenue.png b/docs/screenshots/05-late-night-avenue.png index 831f613..4b41b36 100644 Binary files a/docs/screenshots/05-late-night-avenue.png and b/docs/screenshots/05-late-night-avenue.png differ diff --git a/docs/screenshots/06-night-flashlight.png b/docs/screenshots/06-night-flashlight.png index 90cf257..b1d9160 100644 Binary files a/docs/screenshots/06-night-flashlight.png and b/docs/screenshots/06-night-flashlight.png differ diff --git a/docs/screenshots/07-wand-screening.png b/docs/screenshots/07-wand-screening.png index 04d8b1d..1ab2dab 100644 Binary files a/docs/screenshots/07-wand-screening.png and b/docs/screenshots/07-wand-screening.png differ diff --git a/docs/screenshots/08-escalation-hud.png b/docs/screenshots/08-escalation-hud.png index fe29a83..7544626 100644 Binary files a/docs/screenshots/08-escalation-hud.png and b/docs/screenshots/08-escalation-hud.png differ diff --git a/docs/screenshots/09-body-camera-timeline.png b/docs/screenshots/09-body-camera-timeline.png index a11633c..e2e2b95 100644 Binary files a/docs/screenshots/09-body-camera-timeline.png and b/docs/screenshots/09-body-camera-timeline.png differ diff --git a/docs/screenshots/11-precision-walk.png b/docs/screenshots/11-precision-walk.png new file mode 100644 index 0000000..5dc79f5 Binary files /dev/null and b/docs/screenshots/11-precision-walk.png differ diff --git a/project.godot b/project.godot index 4609034..97c3860 100644 --- a/project.godot +++ b/project.godot @@ -8,7 +8,7 @@ config_version=5 config/name="Step Line" config/description="Workplace security simulation about observation, access verification, and proportionate escalation." -config/version="0.4.0" +config/version="0.5.0" run/main_scene="res://scenes/main.tscn" config/features=PackedStringArray("4.3", "GL Compatibility") diff --git a/src/autoload/save.gd b/src/autoload/save.gd index 0f68078..acaf433 100644 --- a/src/autoload/save.gd +++ b/src/autoload/save.gd @@ -43,6 +43,9 @@ func save_shift_result(result: Dictionary) -> Error: const DEFAULT_SETTINGS := { "look_sensitivity": 0.0022, "hud_font_scale": 1.0, + "invert_y": false, + "head_bob": true, + "input_bindings": {}, "ai_difficulty": "normal", } diff --git a/src/core/input_bindings.gd b/src/core/input_bindings.gd new file mode 100644 index 0000000..0ba5c74 --- /dev/null +++ b/src/core/input_bindings.gd @@ -0,0 +1,67 @@ +class_name InputBindings +extends RefCounted +## Key/button remaps persisted through the settings panel. Games keep working +## with the defaults in `project.godot`; only actions the player rebinds are +## rewritten, and remaps never remove mouse or gamepad axis events. + +const REMAPPABLE := [ + "move_forward", "move_back", "move_left", "move_right", + "walk_slow", "inspect", "wand", "radio_emergency", "action_menu", +] + +## Turn an input event into a storable descriptor, or {} when it is not a key or +## pad button (mouse buttons and axes are deliberately not remappable here). +static func descriptor_from_event(event: InputEvent) -> Dictionary: + if event is InputEventKey: + var key := event as InputEventKey + var code := key.physical_keycode if key.physical_keycode != 0 else key.keycode + if code == 0: + return {} + return {"type": "key", "code": int(code)} + if event is InputEventJoypadButton: + return {"type": "joy", "button": int((event as InputEventJoypadButton).button_index)} + return {} + +static func label_for(descriptor: Dictionary) -> String: + match str(descriptor.get("type", "")): + "key": + return OS.get_keycode_string(int(descriptor.get("code", 0))) + "joy": + return "Pad %d" % int(descriptor.get("button", 0)) + _: + return "" + +## First key/button currently bound to the action, used to label the UI. +static func current_descriptor(action: String) -> Dictionary: + if not InputMap.has_action(action): + return {} + for event in InputMap.action_get_events(action): + var descriptor := descriptor_from_event(event) + if not descriptor.is_empty(): + return descriptor + return {} + +## Rewrite the actions present in `bindings`. Existing key/button events for +## those actions are replaced; mouse and joypad-axis events are left alone. +static func apply(bindings: Dictionary) -> void: + for action in bindings.keys(): + if not InputMap.has_action(action): + continue + var descriptor: Dictionary = bindings[action] + if descriptor.is_empty(): + continue + for event in InputMap.action_get_events(action): + if event is InputEventKey or event is InputEventJoypadButton: + InputMap.action_erase_event(action, event) + InputMap.action_add_event(action, event_for(descriptor)) + +static func event_for(descriptor: Dictionary) -> InputEvent: + match str(descriptor.get("type", "")): + "joy": + var button := InputEventJoypadButton.new() + button.button_index = int(descriptor.get("button", 0)) + return button + _: + var key := InputEventKey.new() + key.physical_keycode = int(descriptor.get("code", 0)) + return key diff --git a/src/game/audio_feedback.gd b/src/game/audio_feedback.gd index d53f4de..bd742ff 100644 --- a/src/game/audio_feedback.gd +++ b/src/game/audio_feedback.gd @@ -13,6 +13,9 @@ var _bed_player: AudioStreamPlayer = null var crowd_level: float = 0.0 var bed_playing := false var radio_crackles := 0 +var footsteps := 0 +var landings := 0 +var precision_ticks := 0 func _ready() -> void: # Pre-create a few players so overlapping cues don't cut each other off. @@ -72,6 +75,30 @@ func play_radio_close() -> void: radio_crackles += 1 _play_noise(0.06, 0.25, 1400.0) +## Surface-aware footstep: a short filtered burst, lower on asphalt, brighter on +## the stone entrance steps. +func play_footstep(surface: String) -> void: + footsteps += 1 + var cutoff := 2600.0 + match surface: + "asphalt": + cutoff = 1500.0 + "stone": + cutoff = 3400.0 + _: + cutoff = 2400.0 + _play_noise(0.045, 0.22, cutoff) + +## Landing thump after a step or drop. +func play_land() -> void: + landings += 1 + _play_tone(92.0, 0.09, 0.4) + +## Quiet tick when the precision-walk modifier engages. +func play_precision_tick() -> void: + precision_ticks += 1 + _play_tone(1180.0, 0.035, 0.22) + ## Play a denial/error cue (low buzz). func play_deny() -> void: _play_tone(220.0, 0.20, 0.5) diff --git a/src/game/game_manager.gd b/src/game/game_manager.gd index 4b7e336..483b379 100644 --- a/src/game/game_manager.gd +++ b/src/game/game_manager.gd @@ -17,6 +17,7 @@ const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd") const VenueScenario := preload("res://src/core/venue_scenario.gd") const WorldBuilder := preload("res://src/game/world_builder.gd") const Materials := preload("res://src/game/materials.gd") +const InputBindings := preload("res://src/core/input_bindings.gd") const GuardController := preload("res://src/game/guard_controller.gd") const NpcAvatar := preload("res://src/game/npc_avatar.gd") const NpcProfile := preload("res://src/core/npc_profile.gd") @@ -84,6 +85,8 @@ var pause_overlay: PauseOverlay = null var settings_panel: SettingsPanel = null var event_timeline: EventTimeline = null var _timeline_from_review := false +var _queue_tick := 0.0 +var _input_device := "keyboard" var _shift_running := false var player_settings: Dictionary = {} var _focused_avatar: Node3D = null @@ -122,6 +125,7 @@ var _door_rng := RandomNumberGenerator.new() const MAX_LIVE_NPCS := 16 func _ready() -> void: + _ensure_action_bindings() _door_rng.randomize() player_settings = Save.load_settings() if Save else {} scene_root = Node3D.new() @@ -195,8 +199,25 @@ func _build_guard() -> GuardController: collider.position = Vector3(0, 0.85, 0) g.add_child(collider) g.add_to_group("guard") + g.footstep.connect(_on_guard_footstep) + g.landed.connect(_on_guard_landed) + g.precision_changed.connect(_on_precision_changed) return g +func _on_guard_footstep(surface: String) -> void: + if audio: + audio.play_footstep(surface) + +func _on_guard_landed() -> void: + if audio: + audio.play_land() + +func _on_precision_changed(active: bool) -> void: + if hud: + hud.set_precision(active) + if audio and active: + audio.play_precision_tick() + func _build_scenario(id: String) -> VenueScenario: match id: "joke_shop": @@ -282,6 +303,9 @@ func _apply_settings(settings: Dictionary) -> void: player_settings = settings if guard: guard.look_sensitivity = float(settings.get("look_sensitivity", 0.0022)) + guard.invert_y = bool(settings.get("invert_y", false)) + guard.head_bob_enabled = bool(settings.get("head_bob", true)) + InputBindings.apply(settings.get("input_bindings", {})) if hud and hud.has_method("set_font_scale"): hud.set_font_scale(float(settings.get("hud_font_scale", 1.0))) @@ -651,6 +675,33 @@ func _on_shift_ended(review: Dictionary) -> void: shift_review_panel.show_review(review, selected_scenario, Save.load_shift_results() if Save else []) _refresh_freeze() +## Keep the people waiting at the door in a deterministic queue: nearest first, +## lateral slots behind the door, so they do not stack on one point. +func _process(delta: float) -> void: + if coordinator == null or npcs.is_empty(): + return + _queue_tick -= delta + if _queue_tick > 0.0: + return + _queue_tick = 0.3 + _refresh_queue() + +func _refresh_queue() -> void: + var waiting: Array = [] + for node in npcs: + if not is_instance_valid(node) or node.npc == null: + continue + if node.awaiting_interaction and not node.npc.leave_requested: + waiting.append(node) + else: + node.set_queue_offset(Vector3.ZERO) + waiting.sort_custom(func(a, b) -> bool: + var da: float = a.global_position.distance_to(a.door_position) + var db: float = b.global_position.distance_to(b.door_position) + return da < db) + for index in waiting.size(): + waiting[index].set_queue_offset(NpcAvatar.queue_slot_for(index)) + func _on_guard_target(target: Node3D) -> void: targeted_npc = target _set_focused_avatar(target) @@ -699,9 +750,43 @@ func _begin_incident(avatar: Node3D) -> void: hint = "Likely authorized. Press E to verify access." hud.set_action_hint(hint) +## Bind the remaining gameplay keys as named actions, so remaps can target them +## (and gamepads can grow onto them). Existing actions are left untouched. +static func _ensure_action_bindings() -> void: + var defaults := { + "polite_direction": KEY_Q, + "toggle_debug": KEY_F, + "toggle_flashlight": KEY_G, + "door_lock": KEY_L, + "body_camera": KEY_B, + "cycle_venue": KEY_V, + } + for action in defaults.keys(): + if not InputMap.has_action(action): + InputMap.add_action(action) + var has_key := false + for event in InputMap.action_get_events(action): + if event is InputEventKey: + has_key = true + if has_key: + continue + var key := InputEventKey.new() + key.physical_keycode = defaults[action] + InputMap.action_add_event(action, key) + if InputMap.has_action("cycle_target_next"): + var has_tab := false + for event in InputMap.action_get_events("cycle_target_next"): + if event is InputEventKey and event.physical_keycode == KEY_TAB: + has_tab = true + if not has_tab: + var tab := InputEventKey.new() + tab.physical_keycode = KEY_TAB + InputMap.action_add_event("cycle_target_next", tab) + # ------------------------------------------------------------------ input --- func _unhandled_input(event: InputEvent) -> void: + _track_input_device(event) if event is InputEventKey and event.echo: return if event.is_action_pressed("cancel"): @@ -713,6 +798,20 @@ func _unhandled_input(event: InputEvent) -> void: if event is InputEventKey and event.pressed: _handle_key(event) +## Switch the control legend when the player picks up a gamepad or goes back to +## mouse/keyboard. Cheap: only the legend text changes. +func _track_input_device(event: InputEvent) -> void: + var device := "" + if event is InputEventJoypadButton or event is InputEventJoypadMotion: + device = "gamepad" + elif event is InputEventKey or event is InputEventMouse: + device = "keyboard" + if device == "" or device == _input_device: + return + _input_device = device + if hud: + hud.set_input_device(device) + func _handle_mouse_button(event: InputEventMouseButton) -> void: if event.is_action_pressed("cycle_target_next"): _cycle_nearby_target(1) @@ -760,53 +859,58 @@ func _handle_key(event: InputEvent) -> void: if targeted_npc != null and not inspection_open and not wand_open: _toggle_wand_screening() return - match keycode: - KEY_Q: - if targeted_npc != null and not inspection_open: - _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "polite direction") - KEY_TAB: - _cycle_nearby_target() - KEY_F: - hud.toggle_debug() - KEY_G: - _toggle_flashlight() - KEY_L: - _toggle_door_lock() - KEY_B: - _toggle_body_camera() - KEY_V: - _cycle_venue() - _: - _handle_action_key(keycode) + if event.is_action_pressed("polite_direction"): + if targeted_npc != null and not inspection_open: + _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "polite direction") + return + 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.is_action_pressed("toggle_debug"): + hud.toggle_debug() + return + if event.is_action_pressed("toggle_flashlight"): + _toggle_flashlight() + return + if event.is_action_pressed("door_lock"): + _toggle_door_lock() + return + if event.is_action_pressed("body_camera"): + _toggle_body_camera() + return + if event.is_action_pressed("cycle_venue"): + _cycle_venue() + return + _handle_action_key(event) -func _handle_action_key(keycode: Key) -> void: - match keycode: - KEY_1: - _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "raise hand") - KEY_2: - _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "point") - KEY_3: - _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "head shake") - KEY_4: - _apply_interaction(Rules.Level.FORMAL_WARNING, "formal warning") - KEY_5: - _apply_interaction(Rules.Level.BLOCKING, "blocking") - KEY_6: - _open_radio(false) - KEY_R: - _open_radio(true) - KEY_A: - if inspection_open: - decide_access(true) - elif wand_open: - decide_wand(true) - KEY_D: - if inspection_open: - decide_access(false) - elif wand_open: - decide_wand(false) - _: - pass +func _handle_action_key(event: InputEvent) -> void: + if event.is_action_pressed("gesture_raise_hand"): + _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "raise hand") + elif event.is_action_pressed("gesture_point"): + _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "point") + elif event.is_action_pressed("gesture_head_shake"): + _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "head shake") + elif event.is_action_pressed("escalate_warning"): + _apply_interaction(Rules.Level.FORMAL_WARNING, "formal warning") + elif event.is_action_pressed("escalate_block"): + _apply_interaction(Rules.Level.BLOCKING, "blocking") + elif event.is_action_pressed("radio_assist"): + _open_radio(false) + elif event.is_action_pressed("radio_emergency"): + _open_radio(true) + elif event.is_action_pressed("move_left"): + if inspection_open: + decide_access(true) + elif wand_open: + decide_wand(true) + elif event.is_action_pressed("move_right"): + if inspection_open: + decide_access(false) + elif wand_open: + decide_wand(false) ## `cancel` (Esc, RMB) closes the top modal. With nothing open, Esc pauses the ## shift (or resumes it when paused); RMB does nothing so it never grabs the diff --git a/src/game/guard_controller.gd b/src/game/guard_controller.gd index 08e68c6..3ee53d0 100644 --- a/src/game/guard_controller.gd +++ b/src/game/guard_controller.gd @@ -4,12 +4,21 @@ extends CharacterBody3D signal picked_target(target: Node) signal cleared_target() +signal footstep(surface: String) +signal landed() +signal precision_changed(active: bool) const NpcFocus := preload("res://src/game/npc_focus.gd") var move_speed: float = 3.2 var look_sensitivity: float = 0.0022 -var accel: float = 12.0 +var accel: float = 22.0 +## Separate braking rate: stopping is snappier than starting so the guard does +## not ice-skate into a panel or the door. +var decel: float = 30.0 +var invert_y := false +var head_bob_enabled := true +var gamepad_look_sensitivity := 2.6 var is_ready := false var targeted_npc: Node3D = null var input_locked := false @@ -28,21 +37,91 @@ var focus_suppress_s := 0.0 const FOCUS_SUPPRESS_S := 0.75 ## Radius used by aim-free cycling. const NEARBY_RADIUS := 12.0 +## Maximum kerb/step height the guard can walk up without jumping. +const STEP_HEIGHT := 0.32 +## Distance walked per footstep cue. +const STRIDE_LENGTH := 0.85 +const GAMEPAD_DEADZONE := 0.2 @onready var camera: Camera3D = $Head/Camera3D @onready var head: Node3D = $Head +var _camera_base_y := 0.0 +var _base_fov := 75.0 +var _bob_phase := 0.0 +var _stride := 0.0 +var _land_dip := 0.0 +var _was_on_floor := true +var _precision := false + func _ready() -> void: - floor_snap_length = 0.3 + floor_snap_length = 0.45 + _camera_base_y = camera.position.y + _base_fov = camera.fov + _ensure_gamepad_bindings() Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) is_ready = true +## Add gamepad axes/buttons to the named actions if they are not bound yet. Keeps +## `project.godot` keyboard-first while making a controller plug-and-play. +static func _ensure_gamepad_bindings() -> void: + var axes := { + "move_forward": [JOY_AXIS_LEFT_Y, -1.0], + "move_back": [JOY_AXIS_LEFT_Y, 1.0], + "move_left": [JOY_AXIS_LEFT_X, -1.0], + "move_right": [JOY_AXIS_LEFT_X, 1.0], + "look_left": [JOY_AXIS_RIGHT_X, -1.0], + "look_right": [JOY_AXIS_RIGHT_X, 1.0], + "look_up": [JOY_AXIS_RIGHT_Y, -1.0], + "look_down": [JOY_AXIS_RIGHT_Y, 1.0], + } + for action in axes.keys(): + if not InputMap.has_action(action): + InputMap.add_action(action, GAMEPAD_DEADZONE) + elif InputMap.action_get_deadzone(action) > GAMEPAD_DEADZONE: + InputMap.action_set_deadzone(action, GAMEPAD_DEADZONE) + var axis: int = axes[action][0] + var value: float = axes[action][1] + var bound := false + for event in InputMap.action_get_events(action): + if event is InputEventJoypadMotion and event.axis == axis \ + and is_equal_approx(event.axis_value, value): + bound = true + if bound: + continue + var motion := InputEventJoypadMotion.new() + motion.axis = axis + motion.axis_value = value + InputMap.action_add_event(action, motion) + var buttons := { + "action_menu": JOY_BUTTON_A, + "cancel": JOY_BUTTON_B, + "inspect": JOY_BUTTON_X, + "radio_assist": JOY_BUTTON_Y, + "walk_slow": JOY_BUTTON_LEFT_SHOULDER, + "wand": JOY_BUTTON_RIGHT_SHOULDER, + } + for action in buttons.keys(): + if not InputMap.has_action(action): + InputMap.add_action(action) + var button: int = buttons[action] + var bound := false + for event in InputMap.action_get_events(action): + if event is InputEventJoypadButton and event.button_index == button: + bound = true + if not bound: + var press := InputEventJoypadButton.new() + press.button_index = button + press.pressed = false + InputMap.action_add_event(action, press) + func _unhandled_input(event: InputEvent) -> void: if input_locked: return if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED: rotate_y(-event.relative.x * look_sensitivity) - head.rotate_x(-event.relative.y * look_sensitivity) + var pitch_sign := 1.0 if invert_y else -1.0 + head.rotate_x(pitch_sign * event.relative.y * look_sensitivity) head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0)) func _physics_process(_delta: float) -> void: @@ -64,28 +143,104 @@ func _physics_process(_delta: float) -> void: right.y = 0.0 right = right.normalized() + _update_gamepad_look(_delta) var input_dir := Vector2( Input.get_axis("move_left", "move_right"), Input.get_axis("move_forward", "move_back") ) if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED: input_dir = Vector2.ZERO - var desired := (forward * input_dir.y + right * input_dir.x) + # get_axis(forward, back) is negative when W is held, so negate it to walk + # toward the camera's forward vector instead of moonwalking. + var desired := (forward * -input_dir.y + right * input_dir.x) if desired.length() > 1.0: desired = desired.normalized() + var precision := Input.is_action_pressed("walk_slow") + if precision != _precision: + _precision = precision + precision_changed.emit(_precision) var speed := move_speed - if Input.is_action_pressed("walk_slow"): + if precision: 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) + var braking := desired.length() < 0.01 + var rate := decel if braking else accel + velocity.x = move_toward(velocity.x, desired.x, rate * _delta) + velocity.z = move_toward(velocity.z, desired.z, rate * _delta) if is_on_floor(): velocity.y = 0.0 + _try_step_up(Vector3(velocity.x, 0.0, velocity.z) * _delta) else: velocity.y -= 9.8 * _delta move_and_slide() + _update_feedback(_delta, speed) _update_target() +## Right-stick look, polled so it keeps turning while held. +func _update_gamepad_look(delta: float) -> void: + if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED: + return + if not InputMap.has_action("look_left"): + return + var look := Vector2( + Input.get_axis("look_left", "look_right"), + Input.get_axis("look_up", "look_down") + ) + if look.length() < GAMEPAD_DEADZONE: + return + rotate_y(-look.x * gamepad_look_sensitivity * delta) + head.rotate_x((1.0 if invert_y else -1.0) * look.y * gamepad_look_sensitivity * delta) + head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0)) + +## Walk up kerbs and the entrance steps without jumping. Tests the blocked +## motion first, then a raised copy, and only commits when the step is short. +func _try_step_up(motion: Vector3) -> void: + if motion.length() < 0.001 or not is_on_floor(): + return + var start := global_transform + if not test_move(start, motion): + return + var up := Vector3.UP * STEP_HEIGHT + if test_move(start, up): + return + var raised := start.translated(up) + if test_move(raised, motion): + return + global_transform = raised + if not test_move(global_transform, -up): + global_transform = global_transform.translated(-up) + +## Landing dip, head bob, precision FOV and stride-synced footsteps. +func _update_feedback(delta: float, speed: float) -> void: + var on_floor := is_on_floor() + if on_floor and not _was_on_floor: + _land_dip = 0.055 + landed.emit() + _was_on_floor = on_floor + var horizontal := Vector2(velocity.x, velocity.z).length() + if on_floor and horizontal > 0.4: + _bob_phase += delta * (5.0 + horizontal) + _stride += horizontal * delta + var stride := STRIDE_LENGTH * (1.35 if _precision else 1.0) + if _stride >= stride: + _stride = 0.0 + footstep.emit(_surface_at(global_position)) + _land_dip = move_toward(_land_dip, 0.0, delta * 0.55) + var bob := 0.0 + if head_bob_enabled and on_floor: + bob = sin(_bob_phase * 2.0) * 0.018 * clampf(horizontal / move_speed, 0.0, 1.0) + camera.position.y = _camera_base_y + bob - _land_dip + var target_fov := _base_fov - (6.0 if _precision and horizontal > 0.1 else 0.0) + camera.fov = lerpf(camera.fov, target_fov, minf(1.0, delta * 8.0)) + +## Rough surface under the guard for the footstep cue. +func _surface_at(pos: Vector3) -> String: + if pos.z < 4.3 and absf(pos.x) < 5.0: + return "stone" + if pos.z < 12.2: + return "concrete" + return "asphalt" + func _update_target() -> void: if camera == null: return diff --git a/src/game/npc_avatar.gd b/src/game/npc_avatar.gd index cb49dee..236020f 100644 --- a/src/game/npc_avatar.gd +++ b/src/game/npc_avatar.gd @@ -26,6 +26,11 @@ var _reaction_timer := 0.0 const RUBBERNECK_S := 12.0 const FLEE_S := 20.0 const FLEE_SPEED := 2.4 +## LOD distances in metres. Hysteresis keeps the tier from flickering at a +## boundary; the impostor is used far enough away that the silhouette is all +## that reads. +const LOD_TIERS := [14.0, 26.0] +const LOD_HYSTERESIS := 1.5 var agent: NavigationAgent3D = null ## Set once the nav agent proves it has a path; direct movement is the fallback. var _nav_usable := false @@ -64,7 +69,9 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector position = p_spawn npc.fsm.state_changed.connect(_on_state_changed) add_to_group("npc") + _idle_style = idle_style_for(profile.npc_type, seed_value) _build_body(profile.npc_type) + _build_impostor() _nav_usable = false _nav_try_frames = 0 agent = NavigationAgent3D.new() @@ -104,9 +111,23 @@ var _name_tag: Label3D = null ## Theater props and limbs for the idle poses (ADR-0010 rig). var _photo_prop: MeshInstance3D = null var _sign_prop: MeshInstance3D = null -var _legs: Array[MeshInstance3D] = [] -var _arms: Array[MeshInstance3D] = [] +var _legs: Array[Node3D] = [] +var _arms: Array[Node3D] = [] +var _knees: Array[Node3D] = [] +var _elbows: Array[Node3D] = [] +var _hands: Array[Node3D] = [] var _theater_t := 0.0 +var _idle_t := 0.0 +var _idle_style := "breathe" +var _reaction_tag: Label3D = null +var _impostor: Node3D = null +var _detail_nodes: Array[Node3D] = [] +var _lod_tier := 0 +var _lod_check_s := 0.0 +var _queue_offset := Vector3.ZERO +var _height_scale := 1.0 +var _shirt_color := Color(0.7, 0.7, 0.7) +var _skin_color := Color(0.88, 0.72, 0.60) var _avoidance_active := false var _photo_prop_base_y := 0.0 var _sign_prop_base_y := 0.0 @@ -132,6 +153,50 @@ const ACCESSORIES := { Rules.NpcType.CONFUSED_VISITOR: "none", } +const HAIR_STYLES := ["short", "long", "bun", "beard", "bald", "short"] +const HAND_ITEMS := ["phone", "cup", "clipboard", "umbrella", "camera", "none"] + +## Outer layer per role/seed. Pure so the same person always dresses the same. +static func layer_for(npc_type: int, seed_value: int) -> String: + match npc_type: + Rules.NpcType.RESPONDER: + return "vest" + Rules.NpcType.DELIVERY: + return "vest" + Rules.NpcType.AUTHORIZED_STAFF: + return "jacket" + Rules.NpcType.INFLUENCER: + return "coat" + Rules.NpcType.PERFORMER: + return "dress" if seed_value % 2 == 0 else "coat" + Rules.NpcType.PROTESTER: + return "jacket" + Rules.NpcType.AUTHORIZED_GUEST: + return "jacket" if seed_value % 3 == 0 else "none" + Rules.NpcType.TRESPASSER, Rules.NpcType.VIOLENT_THREAT: + return "coat" + _: + return "none" + +## Deterministic hand item so silhouettes differ without changing the role read. +static func hand_item_for(npc_type: int, seed_value: int) -> String: + match npc_type: + Rules.NpcType.AUTHORIZED_STAFF, Rules.NpcType.DELIVERY: + return "clipboard" + Rules.NpcType.TOURIST: + return "camera" + Rules.NpcType.INFLUENCER: + return "phone" + Rules.NpcType.AUTHORIZED_GUEST: + return "phone" if seed_value % 4 == 0 else "none" + Rules.NpcType.CONFUSED_VISITOR: + return "cup" + Rules.NpcType.PERFORMER, Rules.NpcType.PROTESTER: + return "none" + _: + return HAND_ITEMS[(seed_value * 5 + 1) % 3 + 3] \ + if seed_value % 5 == 0 else "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: @@ -142,17 +207,70 @@ static func outfit_for(npc_type: int, seed_value: int) -> Dictionary: # 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() + var style_index := (seed_value * 13 + npc_type * 3) % HAIR_STYLES.size() return { "shirt": shirt, "sleeves": shirt.darkened(0.16), "pants": pants, "hair": HAIR_COLORS[hair_index], + "hair_style": HAIR_STYLES[style_index], "skin": SKIN_TONE.darkened(frac * 0.18), "accessory": ACCESSORIES.get(npc_type, "none"), + "layer": layer_for(npc_type, seed_value), + "hand_item": hand_item_for(npc_type, seed_value), "height_scale": 0.92 + 0.16 * frac, "blend": frac, } +## Idle behaviour style: someone chatting on a phone, arms crossed, looking +## around, or just breathing. Pure so it is deterministic and testable. +static func idle_style_for(npc_type: int, seed_value: int) -> String: + match npc_type: + Rules.NpcType.INFLUENCER, Rules.NpcType.TOURIST: + return "phone" + Rules.NpcType.PROTESTER, Rules.NpcType.MENTAL_CRISIS: + return "look" + Rules.NpcType.AUTHORIZED_STAFF, Rules.NpcType.DELIVERY: + return "clipboard" + Rules.NpcType.TRESPASSER, Rules.NpcType.VIOLENT_THREAT: + return "look" + _: + if seed_value % 3 == 0: + return "crossed" + return "breathe" + +## Queue slot behind the door for the nth person waiting, so arrivals stack +## instead of sharing one point. +static func queue_slot_for(index: int) -> Vector3: + var slots := [ + Vector3(0.0, 0.0, 0.0), + Vector3(0.85, 0.0, 0.45), + Vector3(-0.85, 0.0, 0.45), + Vector3(0.85, 0.0, 1.15), + Vector3(-0.85, 0.0, 1.15), + Vector3(0.0, 0.0, 1.85), + ] + return slots[clampi(index, 0, slots.size() - 1)] + +static func lod_tier_for_distance(distance: float) -> int: + if distance < LOD_TIERS[0]: + return 0 + if distance < LOD_TIERS[1]: + return 1 + return 2 + +## Short reaction symbol shown above the head: readable crowd state at a glance. +static func reaction_label(mode: String, escalation: int) -> String: + if mode == "flee": + return "!!" + if mode == "watch": + return "?!" + if escalation >= 3: + return "!" + if escalation >= 2: + return "?" + return "" + func _build_body(npc_type: int) -> void: _rig = Node3D.new() _rig.name = "Rig" @@ -163,53 +281,184 @@ func _build_body(npc_type: int) -> void: var pants: Color = outfit["pants"] var skin: Color = outfit["skin"] var hair: Color = outfit["hair"] - _rig.scale.y = float(outfit["height_scale"]) + _height_scale = float(outfit["height_scale"]) + _rig.scale.y = _height_scale + _shirt_color = shirt + _skin_color = skin var limbs_color := sleeves - # Torso, with a shoulder block and hips so the silhouette is not a tube. - _add_capsule(_rig, 0.30, 0.90, Vector3(0, 1.02, 0), shirt, 0.0, "cloth") - _add_box_mesh(_rig, Vector3(0.58, 0.14, 0.28), Vector3(0, 1.36, 0), shirt, "cloth") - _add_box_mesh(_rig, Vector3(0.44, 0.18, 0.26), Vector3(0, 0.62, 0), pants, "cloth") - # 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, 0.0, "cloth")) - _legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), pants, 0.0, "cloth")) - _arms.append(_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0, "cloth")) - _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). + # Hips and torso, with a shoulder block so the silhouette is not a tube. + _add_box_mesh(_rig, Vector3(0.42, 0.64, 0.27), Vector3(0, 1.05, 0), shirt, "cloth") + _add_box_mesh(_rig, Vector3(0.50, 0.13, 0.26), Vector3(0, 1.38, 0), shirt, "cloth") + _add_box_mesh(_rig, Vector3(0.38, 0.20, 0.25), Vector3(0, 0.70, 0), pants, "cloth") + _add_layer(str(outfit["layer"]), shirt) + # Articulated legs: hip pivot -> thigh -> knee -> shin -> shoe. + for side in [-1.0, 1.0]: + var hip := Node3D.new() + hip.name = "Hip%s" % ("L" if side < 0.0 else "R") + hip.position = Vector3(0.13 * side, 0.64, 0) + _rig.add_child(hip) + _add_capsule(hip, 0.105, 0.36, Vector3(0, -0.17, 0), pants, 0.0, "cloth") + var knee := Node3D.new() + knee.name = "Knee" + knee.position = Vector3(0, -0.34, 0) + hip.add_child(knee) + _add_capsule(knee, 0.09, 0.32, Vector3(0, -0.15, 0), pants, 0.0, "cloth") + _add_box_mesh(knee, Vector3(0.16, 0.10, 0.30), + Vector3(0, -0.33, 0.05), Color(0.12, 0.12, 0.14), "rubber") + _legs.append(hip) + _knees.append(knee) + # Articulated arms: shoulder pivot -> upper arm -> elbow -> forearm -> hand. + for side in [-1.0, 1.0]: + var shoulder := Node3D.new() + shoulder.name = "Shoulder%s" % ("L" if side < 0.0 else "R") + shoulder.position = Vector3(0.33 * side, 1.38, 0) + _rig.add_child(shoulder) + _add_capsule(shoulder, 0.075, 0.32, Vector3(0, -0.15, 0), limbs_color, 0.0, "cloth") + var elbow := Node3D.new() + elbow.name = "Elbow" + elbow.position = Vector3(0, -0.30, 0) + shoulder.add_child(elbow) + _add_capsule(elbow, 0.065, 0.28, Vector3(0, -0.14, 0), limbs_color, 0.0, "cloth") + var hand := Node3D.new() + hand.name = "Hand" + hand.position = Vector3(0, -0.30, 0.02) + elbow.add_child(hand) + _add_sphere(hand, 0.065, Vector3.ZERO, skin, "skin") + _arms.append(shoulder) + _elbows.append(elbow) + _hands.append(hand) + # Neck, head, face, hair, so the body reads as a person (ADR-0010). _add_capsule(_rig, 0.08, 0.16, Vector3(0, 1.48, 0), skin, 0.0, "skin") _head = Node3D.new() _head.name = "Head" _head.position = Vector3(0, 1.62, 0) _add_sphere(_head, 0.17, Vector3.ZERO, skin, "skin") - _add_sphere(_head, 0.18, Vector3(0, 0.08, -0.02), hair, "cloth") - _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), "glass") + _add_ears(_head, skin) + _add_face(_head) + _add_hair(_head, str(outfit["hair_style"]), hair) _rig.add_child(_head) - _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_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) + _add_hand_item(str(outfit["hand_item"]), skin) _build_focus_circle() _add_props(npc_type) +## Outer layer: jacket, hi-vis vest, coat, or dress over the base shirt. +func _add_layer(kind: String, shirt: Color) -> void: + match kind: + "jacket": + _add_box_mesh(_rig, Vector3(0.46, 0.54, 0.31), Vector3(0, 1.08, 0), + shirt.darkened(0.28), "cloth") + _add_box_mesh(_rig, Vector3(0.05, 0.48, 0.02), Vector3(0, 1.12, 0.165), + shirt.darkened(0.42), "cloth") + "vest": + _add_box_mesh(_rig, Vector3(0.45, 0.44, 0.30), Vector3(0, 1.06, 0), + Color(0.92, 0.72, 0.12), "cloth") + _add_box_mesh(_rig, Vector3(0.48, 0.06, 0.31), Vector3(0, 0.84, 0), + Color(0.82, 0.82, 0.84), "cloth") + "coat": + _add_box_mesh(_rig, Vector3(0.47, 0.80, 0.33), Vector3(0, 1.01, 0), + shirt.darkened(0.35), "cloth") + _add_box_mesh(_rig, Vector3(0.54, 0.08, 0.30), Vector3(0, 1.41, 0), + shirt.darkened(0.42), "cloth") + _add_box_mesh(_rig, Vector3(0.04, 0.60, 0.02), Vector3(0, 1.02, 0.17), + shirt.darkened(0.5), "cloth") + "dress": + var skirt := CylinderMesh.new() + skirt.top_radius = 0.24 + skirt.bottom_radius = 0.38 + skirt.height = 0.52 + var mi := MeshInstance3D.new() + mi.mesh = skirt + mi.position = Vector3(0, 0.82, 0) + mi.material_override = _material(shirt.darkened(0.1), "cloth") + _rig.add_child(mi) + _: + pass + +func _add_ears(head: Node3D, skin: Color) -> void: + for side in [-1.0, 1.0]: + _add_sphere(head, 0.045, Vector3(0.165 * side, 0.0, 0.0), skin, "skin") + +func _add_face(head: Node3D) -> void: + var eye := Color(0.08, 0.08, 0.1) + for side in [-1.0, 1.0]: + _add_sphere(head, 0.032, Vector3(0.06 * side, 0.02, 0.145), eye, "glass") + _add_box_mesh(head, Vector3(0.07, 0.015, 0.02), + Vector3(0.06 * side, 0.075, 0.152), Color(0.16, 0.12, 0.10), "cloth") + _add_box_mesh(head, Vector3(0.09, 0.015, 0.02), Vector3(0, -0.075, 0.15), + Color(0.42, 0.22, 0.20), "cloth") + +func _add_hair(head: Node3D, style: String, color: Color) -> void: + match style: + "long": + _add_sphere(head, 0.185, Vector3(0, 0.07, -0.02), color, "cloth") + _add_box_mesh(head, Vector3(0.30, 0.36, 0.12), Vector3(0, -0.14, -0.11), + color, "cloth") + "bun": + _add_sphere(head, 0.185, Vector3(0, 0.07, -0.02), color, "cloth") + _add_sphere(head, 0.09, Vector3(0, 0.20, -0.10), color, "cloth") + "beard": + _add_sphere(head, 0.185, Vector3(0, 0.07, -0.02), color, "cloth") + _add_box_mesh(head, Vector3(0.22, 0.14, 0.10), Vector3(0, -0.11, 0.09), + color.darkened(0.1), "cloth") + "bald": + pass + _: + _add_sphere(head, 0.185, Vector3(0, 0.07, -0.02), color, "cloth") + +## Hand-held item attached to the right hand socket: phone, cup, clipboard, +## umbrella, camera, or nothing. +func _add_hand_item(kind: String, skin: Color) -> void: + if _hands.size() < 2: + return + var hand: Node3D = _hands[1] + match kind: + "phone": + _detail(_add_box_mesh(hand, Vector3(0.055, 0.11, 0.012), Vector3(0, -0.03, 0.02), + Color(0.10, 0.11, 0.14), "glass")) + "cup": + _detail(_add_capsule(hand, 0.035, 0.10, Vector3(0, -0.05, 0.02), + Color(0.90, 0.88, 0.82), 0.0, "cloth")) + "clipboard": + _detail(_add_box_mesh(hand, Vector3(0.22, 0.28, 0.02), Vector3(0, -0.08, 0.05), + Color(0.55, 0.40, 0.22), "wood")) + _detail(_add_box_mesh(hand, Vector3(0.16, 0.02, 0.01), Vector3(0, 0.05, 0.07), + Color(0.85, 0.85, 0.88), "metal")) + "umbrella": + _detail(_add_capsule(hand, 0.014, 0.85, Vector3(0, -0.30, 0.02), + Color(0.15, 0.16, 0.20), 0.0, "metal")) + var canopy := CylinderMesh.new() + canopy.top_radius = 0.02 + canopy.bottom_radius = 0.34 + canopy.height = 0.22 + var mi := MeshInstance3D.new() + mi.mesh = canopy + mi.position = Vector3(0, 0.12, 0.02) + mi.material_override = _material(skin.lerp(Color(0.2, 0.25, 0.4), 0.7), "cloth") + hand.add_child(mi) + _detail(mi) + "camera": + _detail(_add_box_mesh(hand, Vector3(0.16, 0.10, 0.09), Vector3(0, -0.04, 0.02), + Color(0.10, 0.10, 0.12), "metal")) + _detail(_add_sphere(hand, 0.045, Vector3(0, -0.02, 0.08), Color(0.2, 0.35, 0.5), "glass")) + ## 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") + _detail(_add_sphere(_head, 0.175, Vector3(0, 0.12, 0), shirt.darkened(0.15), "cloth")) + _detail(_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") + _detail(_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") + _detail(_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")) + _detail(_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") + _detail(_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") + _detail(_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")) + _detail(_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")) + _detail(_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 @@ -243,6 +492,14 @@ func _build_focus_circle() -> void: _name_tag.modulate = Color(0.95, 0.95, 0.9, 0.9) _name_tag.visible = false _head.add_child(_name_tag) + _reaction_tag = Label3D.new() + _reaction_tag.name = "ReactionTag" + _reaction_tag.font_size = 72 + _reaction_tag.pixel_size = 0.005 + _reaction_tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED + _reaction_tag.position = Vector3(0, 2.05, 0) + _reaction_tag.visible = false + _rig.add_child(_reaction_tag) func set_focus_visual(focused: bool, menu_open: bool = false) -> void: if _focus_circle: @@ -268,7 +525,7 @@ func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, return mi func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color, - role: String = "skin") -> void: + role: String = "skin") -> MeshInstance3D: var mi := MeshInstance3D.new() var mesh := SphereMesh.new() mesh.radius = radius @@ -277,6 +534,12 @@ func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color, mi.position = pos mi.material_override = _material(color, role) parent.add_child(mi) + return mi + +## Register a small dress detail so the mid-distance LOD can hide it. +func _detail(node: Node3D) -> Node3D: + _detail_nodes.append(node) + return node func _add_box_mesh(parent: Node3D, size: Vector3, pos: Vector3, color: Color, role: String = "cloth") -> MeshInstance3D: @@ -399,6 +662,12 @@ func _face_reaction_point(delta: float) -> void: func _physics_process(delta: float) -> void: if npc == null or npc.fsm == null: return + _tick_lod(delta) + if awaiting_interaction and _queue_offset != Vector3.ZERO: + var queued := door_position + _queue_offset + if target_position.distance_to(queued) > 0.05: + target_position = queued + arrived = false _advance_fsm(delta) _tick_reaction(delta) if reaction_mode == "watch": @@ -459,20 +728,32 @@ func _animate(delta: float) -> void: return var speed := Vector2(velocity.x, velocity.z).length() var pose := theater_pose() + var escalation: int = npc.highest_escalation_received if npc != null else 0 var crouch := crouch_for_pose(pose) if speed > 0.3: - _bob_phase += delta * (6.0 + speed) - _rig.position.y = sin(_bob_phase) * 0.035 - _rig.rotation.x = sin(_bob_phase * 0.5) * 0.04 + _idle_t = 0.0 + _bob_phase += delta * (6.0 + speed * 1.7) + var urgency := 1.35 if reaction_mode == "flee" else 1.0 + var stride := clampf(0.36 + speed * 0.10, 0.42, 0.7) * urgency + _rig.position.y = sin(_bob_phase * 2.0) * 0.03 + _rig.position.x = 0.0 + _rig.rotation.x = sin(_bob_phase) * 0.03 + _rig.rotation.z = sin(_bob_phase) * 0.02 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) + var swing := limb_swing(_bob_phase, i, stride) + _legs[i].rotation.x = swing + # Bend the knee as the leg travels behind the body. + var bend := maxf(0.0, -swing) * (1.7 if reaction_mode == "flee" else 1.15) + _knees[i].rotation.x = lerpf(_knees[i].rotation.x, bend, 0.35) + _arms[i].rotation.x = limb_swing(_bob_phase, i, -0.34 * urgency) + _elbows[i].rotation.x = -0.18 - absf(swing) * 0.55 elif pose != "idle" or not is_zero_approx(_rig.position.y): # Idle theater: seated, photographing, filming, or performing. _theater_t += delta + _idle_t = 0.0 _bob_phase = 0.0 _rig.position.y = lerpf(_rig.position.y, crouch, 0.12) + _rig.position.x = lerpf(_rig.position.x, 0.0, 0.2) _rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2) var lift := prop_lift_for_pose(pose, _theater_t) if _photo_prop: @@ -487,28 +768,182 @@ func _animate(delta: float) -> void: 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[1].rotation.x = lerpf(_legs[1].rotation.x, leg_angle, 0.12) + var knee_angle := deg_to_rad(80.0) if pose == "sit" else 0.0 + _knees[0].rotation.x = lerpf(_knees[0].rotation.x, knee_angle, 0.12) + _knees[1].rotation.x = lerpf(_knees[1].rotation.x, knee_angle, 0.12) for arm in _arms: arm.rotation.x = lerpf(arm.rotation.x, 0.0, 0.14) + for elbow in _elbows: + elbow.rotation.x = lerpf(elbow.rotation.x, -0.1, 0.14) else: _bob_phase = 0.0 _rig.position.y = lerpf(_rig.position.y, 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) + _knees[i].rotation.x = lerpf(_knees[i].rotation.x, 0.0, 0.2) + for i in _arms.size(): + _arms[i].rotation.x = lerpf(_arms[i].rotation.x, 0.0, 0.18) + _elbows[i].rotation.x = lerpf(_elbows[i].rotation.x, -0.08, 0.18) + _idle_t += delta + _idle_layer(pose, escalation) # The focus circle billboards via its material; no per-frame look_at needed. # Attention cue: head turns toward the guard when awareness was raised. - if _head != null and npc.guard_noticed: - if _guard_ref == null or not is_instance_valid(_guard_ref): - _guard_ref = get_tree().get_first_node_in_group("guard") as Node3D - if _guard_ref: - var to_guard := (_guard_ref.global_position - global_position) - to_guard.y = 0.0 - var world_yaw := atan2(to_guard.x, to_guard.z) - var fwd := -_rig.global_transform.basis.z - var parent_yaw := atan2(fwd.x, fwd.z) - _head.rotation.y = lerp_angle(_head.rotation.y, world_yaw - parent_yaw, 0.1) + if _head != null: + if npc != null and npc.guard_noticed: + if _guard_ref == null or not is_instance_valid(_guard_ref): + _guard_ref = get_tree().get_first_node_in_group("guard") as Node3D + if _guard_ref: + var to_guard := (_guard_ref.global_position - global_position) + to_guard.y = 0.0 + var world_yaw := atan2(to_guard.x, to_guard.z) + var fwd := -_rig.global_transform.basis.z + var parent_yaw := atan2(fwd.x, fwd.z) + _head.rotation.y = lerp_angle(_head.rotation.y, world_yaw - parent_yaw, 0.1) + elif speed <= 0.3 and pose == "idle": + # Idle look-around, unless the person is already engaged. + _head.rotation.y = lerpf(_head.rotation.y, sin(_idle_t * 0.45) * 0.3, 0.05) + _update_reaction_label(escalation) + +## Idle behaviour layered on top of the breathing pose: phone, clipboard, +## crossed arms, or looking around, plus escalation body language. +func _idle_layer(pose: String, escalation: int) -> void: + if _rig == null: + return + # Breathing and a slow weight shift keep idle people from looking frozen. + var breath := 1.0 + sin(_idle_t * 1.8) * 0.006 + _rig.scale.y = lerpf(_rig.scale.y, _height_scale * breath, 0.15) + _rig.position.x = lerpf(_rig.position.x, sin(_idle_t * 0.7) * 0.015, 0.1) + _rig.rotation.z = lerpf(_rig.rotation.z, sin(_idle_t * 0.7) * 0.015, 0.1) + if pose != "idle" or _arms.size() < 2: + return + var right := _arms[1] + var right_elbow := _elbows[1] + if escalation >= 3: + # Both hands up: the person is being blocked or warned. + _arms[0].rotation.x = lerpf(_arms[0].rotation.x, -1.75, 0.14) + right.rotation.x = lerpf(right.rotation.x, -1.75, 0.14) + _elbows[0].rotation.x = lerpf(_elbows[0].rotation.x, -0.5, 0.14) + right_elbow.rotation.x = lerpf(right_elbow.rotation.x, -0.5, 0.14) + _rig.rotation.x = lerpf(_rig.rotation.x, -0.06, 0.1) + return + if escalation >= 2: + # One arm out, pointing forward while arguing. + right.rotation.x = lerpf(right.rotation.x, -1.15, 0.12) + right_elbow.rotation.x = lerpf(right_elbow.rotation.x, -0.15, 0.12) + return + match _idle_style: + "phone": + # Phone held up near the head in bursts. + var raise := 0.55 + maxf(0.0, sin(_idle_t * 0.6)) * 0.45 + right.rotation.x = lerpf(right.rotation.x, -1.35 * raise, 0.08) + right_elbow.rotation.x = lerpf(right_elbow.rotation.x, -1.75 * raise, 0.1) + "clipboard": + _arms[0].rotation.x = lerpf(_arms[0].rotation.x, -0.55, 0.1) + _elbows[0].rotation.x = lerpf(_elbows[0].rotation.x, -0.9, 0.1) + right.rotation.x = lerpf(right.rotation.x, -0.45, 0.1) + right_elbow.rotation.x = lerpf(right_elbow.rotation.x, -0.8, 0.1) + "crossed": + _arms[0].rotation.x = lerpf(_arms[0].rotation.x, -0.55, 0.1) + right.rotation.x = lerpf(right.rotation.x, -0.55, 0.1) + _elbows[0].rotation.x = lerpf(_elbows[0].rotation.x, -1.25, 0.1) + right_elbow.rotation.x = lerpf(right_elbow.rotation.x, -1.25, 0.1) + "look": + pass + _: + pass + +func _update_reaction_label(escalation: int) -> void: + if _reaction_tag == null: + return + var text := reaction_label(reaction_mode, escalation) + _reaction_tag.visible = text != "" + if text == "": + return + _reaction_tag.text = text + if reaction_mode == "flee": + _reaction_tag.modulate = Color(1.0, 0.26, 0.2) + elif reaction_mode == "watch": + _reaction_tag.modulate = Color(1.0, 0.72, 0.2) + elif escalation >= 3: + _reaction_tag.modulate = Color(1.0, 0.35, 0.3) + else: + _reaction_tag.modulate = Color(1.0, 0.85, 0.4) + +## Build the far-distance impostor: two billboard quads (body + head) in the +## avatar's clothing colours. +func _build_impostor() -> void: + _impostor = Node3D.new() + _impostor.name = "Impostor" + _impostor.visible = false + add_child(_impostor) + var body := MeshInstance3D.new() + var quad := QuadMesh.new() + quad.size = Vector2(0.56, 1.15) + body.mesh = quad + body.position = Vector3(0, 0.95, 0) + body.material_override = _billboard_material(_shirt_color) + _impostor.add_child(body) + var head := MeshInstance3D.new() + var head_quad := QuadMesh.new() + head_quad.size = Vector2(0.34, 0.34) + head.mesh = head_quad + head.position = Vector3(0, 1.63, 0) + head.material_override = _billboard_material(_skin_color) + _impostor.add_child(head) + +func _billboard_material(color: Color) -> StandardMaterial3D: + var material := StandardMaterial3D.new() + material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + material.albedo_color = color + material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED + return material + +## Apply a LOD tier: 0 full detail, 1 no small props/shadows, 2 impostor. +func apply_lod(tier: int) -> void: + _lod_tier = clampi(tier, 0, 2) + if _rig != null: + _rig.visible = _lod_tier < 2 + if _impostor != null: + _impostor.visible = _lod_tier == 2 + for node in _detail_nodes: + if is_instance_valid(node): + node.visible = _lod_tier == 0 + var shadows := _lod_tier == 0 + for node in find_children("*", "MeshInstance3D", true, false): + (node as MeshInstance3D).cast_shadow = \ + GeometryInstance3D.SHADOW_CASTING_SETTING_ON if shadows \ + else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + +func _lod_tier_for(distance: float) -> int: + # Hysteresis: stay in the current tier until the distance clears the edge. + if _lod_tier == 0: + return 1 if distance > LOD_TIERS[0] + LOD_HYSTERESIS else 0 + if _lod_tier == 1: + if distance < LOD_TIERS[0] - LOD_HYSTERESIS: + return 0 + return 2 if distance > LOD_TIERS[1] + LOD_HYSTERESIS else 1 + if distance < LOD_TIERS[1] - LOD_HYSTERESIS: + return 1 if distance >= LOD_TIERS[0] else 0 + return 2 + +func set_queue_offset(offset: Vector3) -> void: + _queue_offset = offset + +func _tick_lod(delta: float) -> void: + _lod_check_s -= delta + if _lod_check_s > 0.0: + return + _lod_check_s = 0.5 + if not is_inside_tree(): + return + var camera := get_viewport().get_camera_3d() + if camera == null: + return + var distance := global_position.distance_to(camera.global_position) + var desired := _lod_tier_for(distance) + if desired != _lod_tier: + apply_lod(desired) func _advance_fsm(delta: float) -> void: # Routine traffic despawns after a while once it has finished its walk, so diff --git a/src/game/ui/hud.gd b/src/game/ui/hud.gd index 7ba8031..6519586 100644 --- a/src/game/ui/hud.gd +++ b/src/game/ui/hud.gd @@ -45,6 +45,8 @@ var wand_panel: PanelContainer var wand_text: Label var wand_meta: Label var escalation_label: Label +var precision_label: Label +var _controls_label: Label var _debug_enabled := false var _log_lines: Array[String] = [] @@ -119,7 +121,9 @@ func _init() -> void: controls.text = Strings.lookup("hud.controls") controls.add_theme_font_size_override("font_size", 12) controls.modulate = Color(0.8, 0.8, 0.85) + controls.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART vbox.add_child(controls) + _controls_label = controls # --- emergency radio cooldown indicator --- radio_cooldown_label = Label.new() @@ -133,6 +137,12 @@ func _init() -> void: mode_label.text = "Door: open · Light: off" mode_label.add_theme_font_size_override("font_size", 13) mode_label.modulate = Color(0.85, 0.85, 0.9) + precision_label = Label.new() + precision_label.text = Strings.lookup("hud.precision") + precision_label.add_theme_font_size_override("font_size", 12) + precision_label.modulate = Color(0.55, 0.85, 1.0) + precision_label.visible = false + vbox.add_child(precision_label) vbox.add_child(mode_label) # --- escalation level indicator (color-coded) --- @@ -371,6 +381,20 @@ func _add_corner_brackets(color: Color) -> void: func set_action_hint(text: String) -> void: hint_label.text = text +## Precision-walk state chip next to the door/light indicators. +func set_precision(active: bool) -> void: + if precision_label: + precision_label.visible = active + +## Swap the control legend for the device the player is actually using. +func set_input_device(device: String) -> void: + if _controls_label == null: + return + if device == "gamepad": + _controls_label.text = Strings.lookup("hud.controls_gamepad") + else: + _controls_label.text = Strings.lookup("hud.controls") + ## Color-coded escalation feedback (PLAN.md: Polish and Accessibility). diff --git a/src/game/ui/settings_panel.gd b/src/game/ui/settings_panel.gd index 609e9e1..3bd9758 100644 --- a/src/game/ui/settings_panel.gd +++ b/src/game/ui/settings_panel.gd @@ -6,6 +6,8 @@ extends CanvasLayer ## Emits `applied(settings)` so the game manager can push values into the guard ## and HUD as soon as the player confirms. +const InputBindings := preload("res://src/core/input_bindings.gd") + signal applied(settings: Dictionary) signal closed() @@ -14,6 +16,10 @@ var _sensitivity: HSlider = null var _font_scale: HSlider = null var _sensitivity_value: Label = null var _font_scale_value: Label = null +var _invert_y: CheckButton = null +var _head_bob: CheckButton = null +var _bind_buttons := {} +var _listening_action := "" var settings: Dictionary = {} func _ready() -> void: @@ -66,6 +72,37 @@ func _build() -> void: _font_scale = _add_slider(font_row, 0.75, 1.75, 0.05) _font_scale.value_changed.connect(_on_font_scale_changed) + _invert_y = CheckButton.new() + _invert_y.text = "Invert vertical look" + _invert_y.toggled.connect(_on_invert_y_toggled) + column.add_child(_invert_y) + + _head_bob = CheckButton.new() + _head_bob.text = "Head bob and camera motion" + _head_bob.toggled.connect(_on_head_bob_toggled) + column.add_child(_head_bob) + + var controls_title := Label.new() + controls_title.text = "Controls (click a binding, then press a key or pad button)" + controls_title.add_theme_font_size_override("font_size", 13) + controls_title.modulate = Color(0.75, 0.8, 0.9) + column.add_child(controls_title) + var grid := GridContainer.new() + grid.columns = 4 + grid.add_theme_constant_override("h_separation", 10) + grid.add_theme_constant_override("v_separation", 6) + column.add_child(grid) + for action in InputBindings.REMAPPABLE: + var label := Label.new() + label.text = str(action).replace("_", " ").capitalize() + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + grid.add_child(label) + var button := Button.new() + button.custom_minimum_size = Vector2(130, 0) + button.pressed.connect(_on_rebind.bind(action)) + grid.add_child(button) + _bind_buttons[action] = button + var row := HBoxContainer.new() row.alignment = BoxContainer.ALIGNMENT_CENTER row.add_theme_constant_override("separation", 12) @@ -112,6 +149,12 @@ func open(current: Dictionary) -> void: _sensitivity.set_value_no_signal(clampf(sens, _sensitivity.min_value, _sensitivity.max_value)) if _font_scale: _font_scale.set_value_no_signal(clampf(scale, _font_scale.min_value, _font_scale.max_value)) + if _invert_y: + _invert_y.set_pressed_no_signal(bool(settings.get("invert_y", false))) + if _head_bob: + _head_bob.set_pressed_no_signal(bool(settings.get("head_bob", true))) + _listening_action = "" + _refresh_bind_buttons() _sync_labels() visible = true _root.visible = true @@ -135,6 +178,42 @@ func _on_font_scale_changed(value: float) -> void: settings["hud_font_scale"] = value _sync_labels() +func _on_invert_y_toggled(pressed: bool) -> void: + settings["invert_y"] = pressed + +func _on_head_bob_toggled(pressed: bool) -> void: + settings["head_bob"] = pressed + +func _refresh_bind_buttons() -> void: + var remaps: Dictionary = settings.get("input_bindings", {}) + for action in _bind_buttons.keys(): + var button: Button = _bind_buttons[action] + var descriptor: Dictionary = remaps.get(action, {}) + if descriptor.is_empty(): + descriptor = InputBindings.current_descriptor(action) + button.text = InputBindings.label_for(descriptor) + +func _on_rebind(action: String) -> void: + _listening_action = action + var button: Button = _bind_buttons.get(action) + if button: + button.text = "Press…" + if button.is_inside_tree(): + button.grab_focus() + +func _apply_remap(event: InputEvent) -> bool: + if _listening_action == "": + return false + var descriptor := InputBindings.descriptor_from_event(event) + if descriptor.is_empty(): + return false + var remaps: Dictionary = settings.get("input_bindings", {}) + remaps[_listening_action] = descriptor + settings["input_bindings"] = remaps + _listening_action = "" + _refresh_bind_buttons() + return true + func _on_apply() -> void: if Save: Save.save_settings(settings) @@ -149,6 +228,12 @@ func _on_cancel() -> void: func _unhandled_input(event: InputEvent) -> void: if not visible: return + if _listening_action != "": + var pressed: bool = (event is InputEventKey and event.pressed and not event.echo) \ + or (event is InputEventJoypadButton and event.pressed) + if pressed and _apply_remap(event): + get_viewport().set_input_as_handled() + return if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE: _on_cancel() get_viewport().set_input_as_handled() diff --git a/src/test/capture_screenshots.gd b/src/test/capture_screenshots.gd index 5a845f1..18bea70 100644 --- a/src/test/capture_screenshots.gd +++ b/src/test/capture_screenshots.gd @@ -188,6 +188,19 @@ func _advance() -> void: 10: if state_frames >= 30: _shoot("09-body-camera-timeline.png") + if gm != null: + gm.event_timeline.close() + gm.guard.global_position = Vector3(0, 0.1, 6.2) + gm.guard.rotation.y = 0.0 + Input.action_press("move_forward") + Input.action_press("walk_slow") + state = 11 + state_frames = 0 + 11: + if state_frames >= 50: + _shoot("11-precision-walk.png") + Input.action_release("move_forward") + Input.action_release("walk_slow") quit(0) ## Put the guard a couple of steps in front of the target so a staged UI capture diff --git a/src/test/smoke_movement.gd b/src/test/smoke_movement.gd new file mode 100644 index 0000000..2d7c0d1 --- /dev/null +++ b/src/test/smoke_movement.gd @@ -0,0 +1,117 @@ +extends SceneTree +## Movement smoke (windowed): walks the guard to the entrance to check step-up, +## measures precision-walk speed, and checks footstep/landing cues. The guard +## reads captured-mouse input, so run it windowed: +## +## godot --path . --script res://src/test/smoke_movement.gd +## +## Exit code 0 = pass, 1 = failure. + +const MAX_FRAMES := 1600 +var frame := 0 +var gm: Node = null +var started := false +var step := 0 +var step_frame := 0 +var failures: Array[String] = [] +var start_pos := Vector3.ZERO +var normal_distance := 0.0 +var footsteps_before := 0 + +func _initialize() -> void: + root.size = Vector2i(1280, 720) + root.add_child(load("res://scenes/main.tscn").instantiate()) + +func _process(_delta: float) -> bool: + frame += 1 + if gm == null: + gm = root.find_child("GameManager", true, false) + return false + if not started: + gm.call("_on_venue_selected", "hollywood_building") + Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) + started = true + return false + if frame > MAX_FRAMES: + _fail("timeout before the movement checks finished") + return true + step_frame += 1 + match step: + 0: + if step_frame < 20: + return false + _place_guard(8.5) + footsteps_before = gm.audio.footsteps + start_pos = gm.guard.global_position + Input.action_press("move_forward") + step = 1 + step_frame = 0 + 1: + if step_frame >= 280: + Input.action_release("move_forward") + var moved := start_pos.distance_to(gm.guard.global_position) + _check(moved > 2.0, "guard walked toward the entrance (%.1f m)" % moved) + _check(gm.guard.global_position.y > 0.35, + "guard climbed the steps (y=%.2f)" % gm.guard.global_position.y) + _check(gm.guard.global_position.z < 4.2, + "guard reached the steps (z=%.1f)" % gm.guard.global_position.z) + _check(gm.audio.footsteps > footsteps_before, + "footsteps are emitted while walking") + step = 2 + step_frame = 0 + 2: + if step_frame < 45: + return false + _place_guard(8.5) + normal_distance = 0.0 + start_pos = gm.guard.global_position + Input.action_press("move_forward") + Input.action_press("walk_slow") + step = 3 + step_frame = 0 + 3: + if step_frame >= 120: + Input.action_release("walk_slow") + Input.action_release("move_forward") + var moved := start_pos.distance_to(gm.guard.global_position) + _check(moved > 1.0 and moved < 4.6, + "precision walk covers less ground (%.1f m in 2 s)" % moved) + var chip = gm.hud.precision_label + _check(chip != null and chip.visible, + "precision walk shows its HUD chip") + step = 4 + step_frame = 0 + 4: + if gm.audio.landings == 0: + gm.guard.global_position += Vector3(0, 1.2, 0) + if step_frame >= 90: + _check(gm.audio.landings > 0, "landing cue fires after a drop") + _finish() + return true + return false + +func _place_guard(z: float) -> void: + gm.guard.global_position = Vector3(0, 0.1, z) + gm.guard.rotation.y = 0.0 + gm.guard.velocity = Vector3.ZERO + +func _check(condition: bool, message: String) -> void: + if condition: + print("ok: " + message) + else: + _fail(message) + +func _fail(message: String) -> void: + failures.append(message) + _finish() + +func _finish() -> void: + Input.action_release("move_forward") + Input.action_release("walk_slow") + if failures.is_empty(): + print("SMOKE OK: movement feel (steps, precision walk, footsteps)") + quit(0) + else: + for message in failures: + printerr("SMOKE FAIL: " + message) + quit(1) diff --git a/src/test/smoke_npc_budget.gd b/src/test/smoke_npc_budget.gd new file mode 100644 index 0000000..c9d9dbf --- /dev/null +++ b/src/test/smoke_npc_budget.gd @@ -0,0 +1,57 @@ +extends SceneTree +## NPC performance budget smoke: put the documented live-NPC count in the scene, +## let the animations run, then report the average frame time. The budget is +## 50 ms (20 FPS) so slow CI hardware does not fail on noise; the report is the +## useful part when tuning the rig. +## +## Usage: godot --headless --path . --script res://src/test/smoke_npc_budget.gd + +const NpcAvatar := preload("res://src/game/npc_avatar.gd") +const NpcProfile := preload("res://src/core/npc_profile.gd") +const Rules := preload("res://src/core/rules.gd") +const COUNT := 16 +const WARMUP_FRAMES := 60 +const SAMPLE_FRAMES := 180 +const BUDGET_MS := 50.0 + +var frame := 0 +var avatars: Array = [] +var sample_ms := 0.0 + +func _initialize() -> void: + root.size = Vector2i(1280, 720) + var stage := Node3D.new() + stage.name = "BudgetStage" + root.add_child(stage) + for index in COUNT: + var profile := NpcProfile.new() + profile.id = "budget_%d" % index + profile.npc_type = Rules.NpcType.TOURIST + profile.display_name = "Budget %d" % index + var avatar := NpcAvatar.new() + avatar.setup(profile, "roam", index + 1, + Vector3(-6.0 + (index % 8) * 1.7, 0.5, 4.0 + (index / 8) * 2.0), + Vector3(0, 0.5, 8.0), Vector3(0, 0.5, 1.2), profile.id) + stage.add_child(avatar) + avatars.append(avatar) + +func _process(delta: float) -> bool: + frame += 1 + if frame > WARMUP_FRAMES: + sample_ms += delta * 1000.0 + if frame >= WARMUP_FRAMES + SAMPLE_FRAMES: + var average := sample_ms / SAMPLE_FRAMES + var meshes := 0 + for avatar in avatars: + meshes += avatar.find_children("*", "MeshInstance3D", true, false).size() + print("NPC budget: %d avatars, %d meshes, avg frame %.2f ms (budget %.0f ms)" % [ + COUNT, meshes, average, BUDGET_MS]) + if average > BUDGET_MS: + printerr("SMOKE FAIL: frame time %.1f ms exceeds the %.0f ms budget" % [ + average, BUDGET_MS]) + quit(1) + return true + print("SMOKE OK: %d avatars stay inside the frame budget" % COUNT) + quit(0) + return true + return false diff --git a/src/test/smoke_props.gd b/src/test/smoke_props.gd index 74109a8..0a5858b 100644 --- a/src/test/smoke_props.gd +++ b/src/test/smoke_props.gd @@ -50,9 +50,8 @@ func _process(_delta: float) -> bool: var start := bench_center + Vector3(0, 0, 2.2) start.y = 0.1 gm.guard.global_position = start - # Face the bench (-z): with the guard's basis, forward input at yaw PI - # walks toward lower z. - gm.guard.rotation.y = PI + # Face the bench (-z): yaw 0 looks down -z, so W walks toward it. + gm.guard.rotation.y = 0.0 guard_start = start Input.action_press("move_forward") step = 1 diff --git a/src/test/tests/test_audio_feedback.gd b/src/test/tests/test_audio_feedback.gd index ca3630d..7e111cc 100644 --- a/src/test/tests/test_audio_feedback.gd +++ b/src/test/tests/test_audio_feedback.gd @@ -5,6 +5,18 @@ extends RefCounted const AudioFeedback := preload("res://src/game/audio_feedback.gd") const CrowdSystem := preload("res://src/core/crowd_system.gd") +func test_footstep_and_landing_cues_count() -> bool: + var audio := AudioFeedback.new() + audio.play_footstep("concrete") + audio.play_footstep("asphalt") + audio.play_land() + audio.play_precision_tick() + var a := TestUtils.check_eq(audio.footsteps, 2, "footsteps are counted") + var b := TestUtils.check_eq(audio.landings, 1, "landings are counted") + var c := TestUtils.check_eq(audio.precision_ticks, 1, "precision ticks are counted") + audio.free() + return a and b and c + func test_bed_volume_follows_mood() -> bool: var quiet: float = AudioFeedback._level_to_db(0.0) var loud: float = AudioFeedback._level_to_db(1.0) diff --git a/src/test/tests/test_input_bindings.gd b/src/test/tests/test_input_bindings.gd new file mode 100644 index 0000000..f5b0889 --- /dev/null +++ b/src/test/tests/test_input_bindings.gd @@ -0,0 +1,53 @@ +extends RefCounted +## Headless tests for the key/button remap helper. + +const InputBindings := preload("res://src/core/input_bindings.gd") + +func test_descriptor_from_events() -> bool: + var key := InputEventKey.new() + key.physical_keycode = KEY_Z + var a := TestUtils.check_eq(InputBindings.descriptor_from_event(key), + {"type": "key", "code": int(KEY_Z)}, "keys store a code") + var button := InputEventJoypadButton.new() + button.button_index = JOY_BUTTON_Y + var b := TestUtils.check_eq(InputBindings.descriptor_from_event(button), + {"type": "joy", "button": int(JOY_BUTTON_Y)}, "pad buttons store an index") + var c := TestUtils.check( + InputBindings.descriptor_from_event(InputEventMouseButton.new()).is_empty(), + "mouse buttons are not remappable") + return a and b and c + +func test_apply_rebinds_and_keeps_mouse_events() -> bool: + var old := InputBindings.current_descriptor("inspect") + var remap := {"inspect": {"type": "key", "code": int(KEY_Z)}} + InputBindings.apply(remap) + var rebound := InputBindings.current_descriptor("inspect") + var a := TestUtils.check_eq(rebound, {"type": "key", "code": int(KEY_Z)}, + "inspect now uses the new key") + var action_menu_mouse := 0 + for event in InputMap.action_get_events("action_menu"): + if event is InputEventMouseButton: + action_menu_mouse += 1 + var b := TestUtils.check(action_menu_mouse > 0, + "remapping leaves mouse events alone") + # Restore the original binding for later tests. + if not old.is_empty(): + InputBindings.apply({"inspect": old}) + return a and b + +func test_apply_accepts_pad_buttons() -> bool: + var remap := {"wand": {"type": "joy", "button": int(JOY_BUTTON_RIGHT_SHOULDER)}} + InputBindings.apply(remap) + var a := TestUtils.check_eq(InputBindings.current_descriptor("wand"), + {"type": "joy", "button": int(JOY_BUTTON_RIGHT_SHOULDER)}, + "actions can be rebound to a pad button") + InputBindings.apply({"wand": {"type": "key", "code": int(KEY_C)}}) + return a + +func test_labels_are_human_readable() -> bool: + var a := TestUtils.check(InputBindings.label_for( + {"type": "key", "code": int(KEY_W)}) == "W", "key label") + var b := TestUtils.check(InputBindings.label_for( + {"type": "joy", "button": 3}) == "Pad 3", "pad label") + var c := TestUtils.check_eq(InputBindings.label_for({}), "", "empty label") + return a and b and c diff --git a/src/test/tests/test_npc_character.gd b/src/test/tests/test_npc_character.gd new file mode 100644 index 0000000..0133c8c --- /dev/null +++ b/src/test/tests/test_npc_character.gd @@ -0,0 +1,138 @@ +extends RefCounted +## Headless tests for the character pass: layered outfits, hand items, idle +## styles, queue slots, LOD tiers/labels, and the articulated rig structure. + +const NpcAvatar := preload("res://src/game/npc_avatar.gd") +const NpcProfile := preload("res://src/core/npc_profile.gd") +const Rules := preload("res://src/core/rules.gd") + +func _avatar(npc_type: int, seed_value: int) -> NpcAvatar: + var profile := NpcProfile.new() + profile.id = "char_test" + profile.npc_type = npc_type + profile.display_name = "Test Person" + var avatar := NpcAvatar.new() + avatar.setup(profile, "enter", seed_value, + Vector3(0, 0.5, 6), Vector3(0, 0.5, 1.5), Vector3(0, 0.5, 1.2), "char_test") + return avatar + +func test_outfit_layers_and_items_are_deterministic() -> bool: + var a: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, 4242) + var b: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, 4242) + return TestUtils.check( + a["layer"] == b["layer"] and a["hand_item"] == b["hand_item"] + and a["hair_style"] == b["hair_style"], + "outfit layers, hand item and hair style follow the seed") + +func test_layers_follow_roles() -> bool: + var a := TestUtils.check_eq( + NpcAvatar.layer_for(Rules.NpcType.RESPONDER, 1), "vest", + "responders wear a hi-vis vest") + var b := TestUtils.check_eq( + NpcAvatar.layer_for(Rules.NpcType.INFLUENCER, 1), "coat", + "influencers wear a coat") + var c := TestUtils.check_eq( + NpcAvatar.layer_for(Rules.NpcType.AUTHORIZED_STAFF, 1), "jacket", + "staff wear a jacket") + return a and b and c + +func test_hand_items_are_known_and_deterministic() -> bool: + var allowed := ["phone", "cup", "clipboard", "umbrella", "camera", "none"] + var ok := true + for seed_value in range(1, 30): + var item := NpcAvatar.hand_item_for(Rules.NpcType.TOURIST, seed_value * 31) + ok = ok and item in allowed + ok = ok and item == NpcAvatar.hand_item_for( + Rules.NpcType.TOURIST, seed_value * 31) + return TestUtils.check(ok, "hand items stay in the catalogue and are deterministic") + +func test_idle_styles_are_known() -> bool: + var allowed := ["phone", "crossed", "look", "clipboard", "breathe"] + var ok := true + for seed_value in range(1, 30): + var style := NpcAvatar.idle_style_for(Rules.NpcType.TOURIST, seed_value) \ + if seed_value > 1 else NpcAvatar.idle_style_for(Rules.NpcType.INFLUENCER, 1) + ok = ok and style in allowed + return TestUtils.check(ok, "idle styles stay in the catalogue") + +func test_queue_slots_are_spaced_and_unique() -> bool: + var seen := {} + var ok := true + var previous := NpcAvatar.queue_slot_for(0) + for index in 6: + var slot := NpcAvatar.queue_slot_for(index) + ok = ok and not seen.has(slot) + seen[slot] = true + if index > 0: + ok = ok and slot.distance_to(previous) > 0.3 + previous = slot + return TestUtils.check(ok and NpcAvatar.queue_slot_for(0) == Vector3.ZERO, + "queue slots are unique, spaced, and start at the door") + +func test_lod_tiers_use_hysteresis() -> bool: + var avatar := NpcAvatar.new() + avatar._lod_tier = 0 + var a := TestUtils.check_eq(avatar._lod_tier_for(5.0), 0, "near stays full detail") + var b := TestUtils.check_eq(avatar._lod_tier_for(16.0), 1, + "past the hysteresis band switches to simplified") + avatar._lod_tier = 1 + var c := TestUtils.check_eq(avatar._lod_tier_for(13.0), 1, + "hysteresis keeps the simplified tier just inside the edge") + var d := TestUtils.check_eq(avatar._lod_tier_for(12.0), 0, + "clearing the hysteresis band returns to full detail") + avatar._lod_tier = 1 + var e := TestUtils.check_eq(avatar._lod_tier_for(30.0), 2, "far uses the impostor") + avatar.free() + return a and b and c and d and e + +func test_reaction_labels_match_state() -> bool: + var a := TestUtils.check_eq(NpcAvatar.reaction_label("flee", 0), "!!", "panic label") + var b := TestUtils.check_eq(NpcAvatar.reaction_label("watch", 0), "?!", "watching label") + var c := TestUtils.check_eq(NpcAvatar.reaction_label("", 3), "!", "blocked label") + var d := TestUtils.check_eq(NpcAvatar.reaction_label("", 0), "", "calm has no label") + return a and b and c and d + +func test_walk_to_idle_animation_transition() -> bool: + var avatar := _avatar(Rules.NpcType.TOURIST, 21) + var hip := avatar.find_child("HipL", true, false) as Node3D + var a := TestUtils.check(hip != null, "rig has a hip pivot") + avatar.velocity = Vector3(0, 0, 1.2) + avatar._animate(0.016) + var walking := absf(hip.rotation.x) > 0.01 + var b := TestUtils.check(walking, "walking swings the hip") + avatar.velocity = Vector3.ZERO + for i in 90: + avatar._animate(0.016) + var c := TestUtils.check(absf(hip.rotation.x) < 0.05, + "stopping settles the hip back to the idle pose (%.3f)" % hip.rotation.x) + avatar.free() + return a and b and c + +func test_rig_is_articulated() -> bool: + var avatar := _avatar(Rules.NpcType.TOURIST, 77) + var a := TestUtils.check(avatar.find_child("HipL", true, false) != null + and avatar.find_child("Knee", true, false) != null, + "legs have hip and knee pivots") + var b := TestUtils.check(avatar.find_child("ShoulderR", true, false) != null + and avatar.find_child("Elbow", true, false) != null + and avatar.find_child("Hand", true, false) != null, + "arms have shoulder, elbow and hand pivots") + var c := TestUtils.check(avatar.get_node_or_null("Rig/Head") != null, + "the focus code can still find Rig/Head") + avatar.free() + return a and b and c + +func test_avatar_mesh_budget_and_lod_swap() -> bool: + var avatar := _avatar(Rules.NpcType.TOURIST, 12) + var meshes := avatar.find_children("*", "MeshInstance3D", true, false) + var a := TestUtils.check(meshes.size() <= 40, + "avatar stays inside the mesh budget (%d)" % meshes.size()) + avatar.apply_lod(2) + var impostor := avatar.get_node_or_null("Impostor") as Node3D + var rig := avatar.get_node_or_null("Rig") as Node3D + var b := TestUtils.check(impostor != null and impostor.visible, "far LOD shows the impostor") + var c := TestUtils.check(rig != null and not rig.visible, "far LOD hides the full rig") + avatar.apply_lod(0) + var d := TestUtils.check(rig.visible and not impostor.visible, "near LOD restores the rig") + avatar.free() + return a and b and c and d diff --git a/src/test/tests/test_persistence.gd b/src/test/tests/test_persistence.gd index e2641d9..f0bbd8b 100644 --- a/src/test/tests/test_persistence.gd +++ b/src/test/tests/test_persistence.gd @@ -43,11 +43,45 @@ func test_settings_round_trip() -> bool: save.free() return a and b and c +func test_camera_settings_round_trip() -> bool: + var save := _save_instance() + var err: int = save.save_settings({ + "look_sensitivity": 0.0028, "hud_font_scale": 1.0, + "invert_y": true, "head_bob": false, + }) + var a := TestUtils.check_eq(err, OK, "camera settings save") + var loaded: Dictionary = save.load_settings() + var b := TestUtils.check_eq(bool(loaded.get("invert_y", false)), true, + "invert_y survives the round trip") + var c := TestUtils.check_eq(bool(loaded.get("head_bob", true)), false, + "head_bob survives the round trip") + save.save_settings(save.DEFAULT_SETTINGS) + save.free() + return a and b and c + +func test_input_bindings_round_trip() -> bool: + var save := _save_instance() + var bindings := {"inspect": {"type": "key", "code": int(KEY_Z)}} + var err: int = save.save_settings({"input_bindings": bindings}) + var a := TestUtils.check_eq(err, OK, "bindings save") + var loaded: Dictionary = save.load_settings() + var stored: Dictionary = loaded.get("input_bindings", {}) + var inspect: Dictionary = stored.get("inspect", {}) + var b := TestUtils.check_eq(int(inspect.get("code", 0)), int(KEY_Z), + "bindings survive the settings round trip") + var c := TestUtils.check_eq(str(inspect.get("type", "")), "key", + "binding type survives the settings round trip") + save.save_settings(save.DEFAULT_SETTINGS) + save.free() + return a and b and c + func test_settings_defaults_present() -> bool: var save := _save_instance() var defaults: Dictionary = save.load_settings() var ok := TestUtils.check( - defaults.has("look_sensitivity") and defaults.has("hud_font_scale"), + defaults.has("look_sensitivity") and defaults.has("hud_font_scale") + and defaults.has("invert_y") and defaults.has("head_bob") + and defaults.has("input_bindings"), "settings have documented defaults") save.free() return ok diff --git a/src/test/tests/test_player_movement.gd b/src/test/tests/test_player_movement.gd new file mode 100644 index 0000000..0dd5347 --- /dev/null +++ b/src/test/tests/test_player_movement.gd @@ -0,0 +1,54 @@ +extends RefCounted +## Headless tests for movement constants, surface lookup, gamepad bindings, and +## the settings that drive the camera feel. + +const GuardController := preload("res://src/game/guard_controller.gd") + +func test_surface_lookup_covers_the_block() -> bool: + var guard := GuardController.new() + var a := TestUtils.check_eq(guard._surface_at(Vector3(0, 0.2, 2.0)), "stone", + "the entrance steps are stone") + var b := TestUtils.check_eq(guard._surface_at(Vector3(3, 0.2, 8.0)), "concrete", + "the sidewalk is concrete") + var c := TestUtils.check_eq(guard._surface_at(Vector3(2, 0.1, 18.0)), "asphalt", + "the roadway is asphalt") + guard.free() + return a and b and c + +func test_step_and_stride_stay_modest() -> bool: + var a := TestUtils.check(GuardController.STEP_HEIGHT <= 0.35, + "step-up stays at kerb height") + var b := TestUtils.check(GuardController.STRIDE_LENGTH >= 0.5 + and GuardController.STRIDE_LENGTH <= 1.2, "stride stays human-sized") + var c := TestUtils.check(GuardController.SLOW_WALK_FACTOR < 1.0 + and GuardController.SLOW_WALK_FACTOR > 0.3, "precision walk is slower but usable") + return a and b and c + +func test_gamepad_bindings_are_added_once() -> bool: + GuardController._ensure_gamepad_bindings() + GuardController._ensure_gamepad_bindings() + var expected := ["look_left", "look_right", "look_up", "look_down", + "move_forward", "move_back", "move_left", "move_right"] + var problems: Array[String] = [] + for action in expected: + if not InputMap.has_action(action): + problems.append("%s missing" % action) + continue + var motions := 0 + for event in InputMap.action_get_events(action): + if event is InputEventJoypadMotion: + motions += 1 + if motions == 0: + problems.append("%s has no joypad axis" % action) + if InputMap.action_get_deadzone(action) > 0.25: + problems.append("%s deadzone too high" % action) + var buttons := {"action_menu": JOY_BUTTON_A, "cancel": JOY_BUTTON_B, + "inspect": JOY_BUTTON_X, "walk_slow": JOY_BUTTON_LEFT_SHOULDER} + for action in buttons.keys(): + var found := false + for event in InputMap.action_get_events(action): + if event is InputEventJoypadButton and event.button_index == buttons[action]: + found = true + if not found: + problems.append("%s has no gamepad button" % action) + return TestUtils.check(problems.is_empty(), "gamepad actions bound (%s)" % str(problems)) diff --git a/tools/screen_designs.py b/tools/screen_designs.py index 0b3d737..665c9ff 100644 --- a/tools/screen_designs.py +++ b/tools/screen_designs.py @@ -496,6 +496,28 @@ SCREENS = [ (0.02, 0.94, 0.98, 0.99, "control hints"), ], }, { + "name": "movement_precision", + "kind": "scene", + "title": "Precision walk", + "source": "src/game/guard_controller.gd · README controls", + "ref": "11-precision-walk.png", + "description": "First-person view at the venue steps while the guard uses the " + "precision-walk modifier: a small Precision walk chip on the HUD, " + "the doorway ahead, and a narrower field of view.", + "prompt": ( + "First-person 3D game screenshot at night from a security guard standing at " + "the bottom of venue steps, looking at a lit entrance door with a striped " + "awning and STEP LINE sign. Subtle head-height view, brick facade, dark blue " + "night sky, minimal HUD: a small cyan 'Precision walk (Shift)' chip at the " + "bottom left, shift clock top right, thin control hint line at the bottom. " + "Realistic lighting, muted colours, no people"), + "regions": [ + (0.00, 0.80, 0.20, 0.88, "precision walk chip"), + (0.30, 0.05, 0.70, 0.60, "entrance door and steps"), + (0.78, 0.02, 0.98, 0.16, "shift clock"), + ], + }, + { "name": "prop_gallery", "kind": "scene", "title": "Prop gallery",