Compare commits

...
20 Commits
Author SHA1 Message Date
zwitschi f11b441c07 Implement control pass: focus cone, action wheel, named inputs, boot fixes
Full build / Import + headless tests (push) Successful in 22s
Full build / Full desktop build + optional release (push) Skipped
- Venue-correct access artifacts (AccessLoadout) and screened-aware verify
- Cone focus on NPC heads with billboarded ring + display name (ADR-0008)
- Action wheel with de-escalation-first slices (ADR-0009); Esc/RMB cancel
- Procedural NPC face/hair/neck/shoes (ADR-0010); named input actions
- Fix GameManager parse error (radial typed as ActionRadial)
- Fix first-person camera height and guard spawning inside the building
- Move building volume back so the door/awning/marquee are visible
- Pull the event scheduler once per simulated second; cap live NPCs
- Regenerate README screenshots; update TODO/DONE/MEMORY/README; 77 tests

Adds src/test/smoke_focus_radial.gd for the windowed focus/radial smoke.
2026-10-31 15:58:01 +01:00
zwitschi 69484866db Add new game assets and update asset paths in code 2026-10-31 15:20:08 +01:00
zwitschi 53e98cf3fe Update documentation and regenerate screenshots; enhance asset integration details 2026-10-31 15:19:58 +01:00
zwitschi 8cf7745c80 Plan remaining gaps in TODO.md after a PLAN/code audit
Control pass (ADRs 0007-0010) stays first. Added the holes the audit found:
venue-correct access artifacts (override_access unread), session shell,
leftover PLAN verbs, crowd rubberneck, emergency aftermath, replay, NPC
poses, real ambience, a few Joke Shop/Studio events, and doc drift.
Host sign-in remains deferred. Generated-asset integration stays out
(already in DONE).
2026-10-31 15:17:02 +01:00
zwitschi 2b631ae6d9 Add ADRs 0007–0010 for locomotion, focus ring, radial menu, NPC presentation
- 0007: input layers so WASD/look never fire person-verbs; freeze while modal
- 0008: cone focus + billboarded head ring; no type names in the world
- 0009: doughnut action menu (LMB/Space open, click or A/D then Space, Esc/RMB
  cancel); emergency stays on R
- 0010: keep procedural rigs, add face/hair/neck; no Mixamo this pass
Linked from architecture.md, PLAN.md, TODO.md, MEMORY.md.
2026-10-31 15:04:12 +01:00
zwitschi 5662baa14d Plan focus cone, head ring, and radial action menu; defer host sign-in
Document the next playable pass: WASD/look stop fighting action keys, cone
focus with a billboarded head ring, doughnut HUD opened by LMB or Space
(click or A/D then Space to confirm, Esc/RMB cancel). Emergency stays on R.
Wand leaves W. NPC rigs stay procedural but get a face/hair/neck. Host
REQUIRE_SIGNIN_VIEW follow-up deferred.
2026-10-31 15:00:11 +01:00
zwitschi b704a8ed61 Move generalized game asset tooling tasks from TODO.md to DONE.md 2026-10-31 14:39:26 +01:00
zwitschi f2d218cc27 Fix publish_release.sh to upload installer + AppImage; document host REQUIRE_SIGNIN_VIEW
Full build / Import + headless tests (push) Successful in 23s
Full build / Full desktop build + optional release (push) Skipped
- Script uploaded only the three portable binaries; v0.2.1 release was
  missing step-line-setup-0.2.1.exe and the AppImage. Now uploads the full
  set (extras when present), reuses existing releases (adds missing assets),
  and falls back to ~/.git-credentials basic auth when GITEA_TOKEN is unset
- Back-filled both missing assets onto the live v0.2.1 release via API
  (release now carries all 5 artifacts)
- TODO/RELEASING/MEMORY document the findings and the host admin steps for
  anonymous downloads ([service] REQUIRE_SIGNIN_VIEW)
2026-10-31 14:37:56 +01:00
zwitschi 3377545a3e Add Python-related entries to .gitignore for improved environment management 2026-10-31 14:33:46 +01:00
zwitschi 7ff0c28c03 Note host sign-in regression for v0.2.1 tag page in the distribution follow-up
Full build / Import + headless tests (push) Successful in 22s
Full build / Full desktop build + optional release (push) Skipped
2026-10-31 14:28:48 +01:00
zwitschi 2ca0e18018 Close out installer/AppImage verification; note v0.2.1 release; document NSIS/appstream gotchas
Full build / Import + headless tests (push) Successful in 24s
Full build / Full desktop build + optional release (push) Skipped
- CI run 1110 proved NSIS + AppImage packaging and published v0.2.1
  (installers uploaded); Mark TODO installer/appimage items done (moved to
  DONE.md), README Latest -> v0.2.1, MEMORY records NSIS -D/absolute-path/
  BMP-ICO gotchas and appstreamcli strictness
2026-10-31 14:28:36 +01:00
zwitschi 0f076205af Fix NSIS installer build: absolute paths via defines, space-free installed exe, BMP ICO
Full build / Import + headless tests (push) Successful in 31s
Full build / Full desktop build + optional release (push) Successful in 3m44s
- makensis resolves relative -DICON/-DARTIFACT against the script directory,
  so CI now passes $PWD-based absolute paths (reproduced locally in the same
  ubuntu:24.04 container; installer builds, 7z shows step-line.exe +
  uninstall.exe)
- Install as step-line.exe (no spaces: NSIS /oname splits on whitespace)
- OutFile overridable via -DOUT
- tools/generate_textures.py icon.ico now produced via ImageMagick with
  classic BMP entries (NSIS-compatible); PIL PNG-compressed ICOs rejected by
  NSIS
2026-10-31 14:22:55 +01:00
zwitschi 8bc84c1f36 Harden installer icon + NSIS rebuild path
Full build / Full desktop build + optional release (push) Failing after 3m24s
Full build / Import + headless tests (push) Successful in 27s
- Regenerate icon.ico with classic BMP entries (16/32/48) — PNG-compressed
  ICO entries can fail NSIS' icon loader on some builds
- CI copies assets/textures/icon.ico onto the packaging path right before
  makensis, removing any stale/missing-copy failures
- Recreate v0.2.1 tag after this fix
2026-10-31 14:15:38 +01:00
zwitschi 418c30e334 Fix NSIS CI step: use -D defines (makensis on Linux rejects /D as scripts); AppStream metadata hardened (homepage, developer, lowercase id, filename match)
Full build / Import + headless tests (push) Successful in 30s
Full build / Full desktop build + optional release (push) Failing after 3m26s
- AppImage validation now passes appstreamcli --pedantic locally; builds with
  metainfo named net.stepline.game.appdata.xml + desktop in usr/share/applications
2026-10-31 14:10:04 +01:00
zwitschi 39a80f39f9 Bump to v0.2.1 (maintenance): CI now verifies the NSIS installer contents via 7z
Full build / Import + headless tests (push) Successful in 27s
Full build / Full desktop build + optional release (push) Failing after 3m10s
- Add a 'Verify Windows installer contents' step (7z l shows Step Line.exe +
  uninstall.exe) so the TODO verification items are proven by CI output
- Rebuild 0.2.1 binaries (embedded PCK) and AppImage locally; smoke clean;
  57/57 tests
2026-10-31 14:05:12 +01:00
zwitschi 5b994982bd Add OpenRouter AI texture generation mode to the asset pipeline
- tools/generate_textures.py: --ai mode (OPENROUTER_API_KEY, --prompt,
  --name, --model, --size; base64 PNG decode; clamp to <=1024px) with a
  default batch covering the authored texture set; seedream minimum-size
  handled by requesting 2048 and downscaling
- Generated AI asset set committed under assets/textures/ai/ (15 files,
  1024x1024 each), kept separate from the deterministic PIL set
- README/MEMORY/TODO document the mode and live verification
- (work authored in a parallel session; linted/verified here — 57/57 tests)
2026-10-31 14:04:37 +01:00
zwitschi 0abd67fbaa Ignore .env files 2026-10-31 13:56:20 +01:00
zwitschi 08b3292b80 Runtime hygiene: fix RefCounted cycles (clean suite exit), full-length shifts, wand cooldown
Full build / Import + headless tests (push) Successful in 18s
Full build / Full desktop build + optional release (push) Skipped
- FSM holds owner via WeakRef; ShiftCoordinator is RefCounted (logic-only);
  persistence tests free the Save node -> test suite exits with zero
  'resources still in use' warnings (was ~13) and 57/57 pass
- GameManager uses each scenario's shift_duration_s (900s ~ 10-15 min) for
  real shifts instead of the 300s test duration
- Wand screening gets a 0.8s recharge cooldown with a wand.recharge message
- TODO/DONE/MEMORY/README synced
2026-10-31 13:50:18 +01:00
zwitschi ba76c0febf Add Windows installer (NSIS) and Linux AppImage packaging; embed PCK in all exports
Full build / Import + headless tests (push) Successful in 17s
Full build / Full desktop build + optional release (push) Skipped
- export_presets: embed_pck=true for Linux/Windows/macOS -> single self-contained
  binaries (all game data in one file); build_releases.sh cleans stale .pck
- App icon: assets/textures/icon.png (1024) + multi-size icon.ico generated by
  tools/generate_textures.py; copied into packaging/windows and packaging/linux
- packaging/windows/step-line.nsi: NSIS installer (shortcuts, uninstaller,
  registry Add/Remove Programs) output step-line-setup-<ver>.exe
- packaging/linux: step-line.desktop, AppRun, step-line.appdata.xml,
  build_appimage.sh (appimagetool, APPIMAGE_EXTRACT_AND_RUN) ->
  step-line-<ver>-x86_64.AppImage
- CI build job now packages NSIS installer + AppImage alongside binaries;
  publish glob already uploads them
- Local end-to-end verification: embedded exports rebuilt, AppImage built and
  run headless (0 script errors); suite 57/57
- README Downloads table lists installer/AppImage/portable rows; RELEASING
  documents packaging; TODO/DONE synced
2026-10-31 13:46:54 +01:00
zwitschi 07d79ac115 Complete PRIORITY: release v0.2.0 published by CI; document download sign-in caveat
Full build / Import + headless tests (push) Successful in 17s
Full build / Full desktop build + optional release (push) Skipped
- Tag v0.2.0 pushed; Gitea Actions build job exported Linux/Windows/macOS
  and published the release via the host's GITEA_TOKEN secret (CI run 1095:
  'Release live: .../releases/tag/v0.2.0', Job succeeded)
- Verified tag page + /releases are public (HTTP 200); anonymous asset
  downloads require sign-in on the host (tracked as host-config follow-up)
- TODO cleaned per AGENTS.md; PRIORITY section moved to DONE.md
- README Downloads notes v0.2.0 live + sign-in caveat; MEMORY updated
2026-10-31 13:41:37 +01:00
99 changed files with 3421 additions and 278 deletions

No files matched your search

+25
View File
@@ -77,6 +77,31 @@ jobs:
chmod +x tools/*.sh chmod +x tools/*.sh
./tools/build_releases.sh ./tools/build_releases.sh
- name: Package Windows installer (NSIS)
run: |
sudo apt-get update && sudo apt-get install -y nsis p7zip-full
VERSION="$(sed -n 's/.*config\/version="\([^"]*\)".*/\1/p' project.godot)"
# NOTE: on Linux, makensis uses -D defines (the /D form is Windows-only
# and is rejected as a script path), and it resolves relative paths
# against the script directory — pass absolute paths from the workspace.
cp assets/textures/icon.ico packaging/windows/step-line.ico
makensis \
-DVERSION="$VERSION" \
-DARTIFACT="$PWD/build/step-line-windows-$VERSION.exe" \
-DICON="$PWD/packaging/windows/step-line.ico" \
-DOUT="$PWD/build/step-line-setup-$VERSION.exe" \
packaging/windows/step-line.nsi
- name: Verify Windows installer contents
run: |
VERSION="$(sed -n 's/.*config\/version="\([^"]*\)".*/\1/p' project.godot)"
ls -la build/step-line-setup-$VERSION.exe
7z l build/step-line-setup-$VERSION.exe | grep -E "step-line.exe|uninstall.exe"
- name: Package Linux AppImage
run: |
bash packaging/linux/build_appimage.sh
- name: Upload installers (workflow_dispatch) - name: Upload installers (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
+26
View File
@@ -1,3 +1,27 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
*.pycache
*.pytest_cache
*.mypy_cache
*.coverage
coverage.xml
htmlcov/
dist/
build/
eggs/
*.egg-info/
.tox/
.nox/
*.venv/
venv/
ENV/
env/
env.bak/
venv.bak/
# Godot 4 # Godot 4
.godot/ .godot/
*.import *.import
@@ -18,3 +42,5 @@ user://
# CI runner workflow logs (written into the checkout by the Gitea runner) # CI runner workflow logs (written into the checkout by the Gitea runner)
.gitea/logs/ .gitea/logs/
build/ build/
.env
+157
View File
@@ -173,6 +173,61 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every
- [x] `config/version` → `0.2.0`; `build/` artifacts rebuilt and Linux binary smoke-tested. - [x] `config/version` → `0.2.0`; `build/` artifacts rebuilt and Linux binary smoke-tested.
- [x] New world/lighting headless tests (56 total). - [x] New world/lighting headless tests (56 total).
## Screenshot Refresh
- [x] Regenerate and verify the six README screenshots from the current build.
## 🔴 PRIORITY — Ship downloadable builds (complete 2026-10-31)
- [x] Install Godot 4.3 export templates locally.
- [x] `./tools/build_releases.sh` produces Linux/Windows/macOS artifacts (0.2.0).
- [x] Linux binary smoke-tested (headless, 0 script errors).
- [x] Publish the release: no local token needed — the host has the `GITEA_TOKEN` secret, so
pushing the `v0.2.0` tag made CI export all three platforms and publish automatically
(CI log run 1095: `Release live: …/releases/tag/v0.2.0`, Job succeeded).
- [x] Confirm links resolve: tag page and `/releases` return HTTP 200 publicly.
Follow-up: anonymous **asset downloads** require sign-in on this host (host setting).
- [x] CI packaging follow-up was already live (`.gitea/workflows/build.yml` on tags).
## 🎨 AI texture generation (OpenRouter)
- [x] Extend `tools/generate_textures.py` with an OpenRouter image-API mode (`--ai`, `--prompt`,
`--name`, `--model`, `--size`), decoding base64 PNG responses and clamping to ≤1024px.
- [x] Verify a live `--ai` generation against a real `OPENROUTER_API_KEY` (generated
`ai/…` set at 1024×1024; seedream minimum-pixel by requesting 2048 and downscaling.
## 🧹 Runtime hygiene + timeline/polish items
- [x] RefCounted cycle cleanup: `FSM` now holds its owner via `WeakRef`; `ShiftCoordinator` is
`RefCounted` (logic-only, no tree needs); persistence tests free the `Save` node.
Test suite now exits with **zero** “resources still in use” warnings.
- [x] Full-length shifts: the prototype now uses each scenario's `shift_duration_s` (900s ≈
10-15 min per PLAN.md) instead of a hardcoded 300s test duration.
- [x] Wand cooldown feel: a short `WAND_COOLDOWN_S` recharge between wand scans with a
`wand.recharge` message (Open Polish item).
## 📦 Installer packaging (Windows NSIS + Linux AppImage)
- [x] Export presets: `binary_format/embed_pck=true` on all platforms — every binary is now
self-contained (all game data in one file; `.pck` no longer shipped).
- [x] App icon: `assets/textures/icon.png` (1024) + multi-size `icon.ico`, copied to
`packaging/windows/step-line.ico` and `packaging/linux/step-line.png` via the generator.
- [x] NSIS script `packaging/windows/step-line.nsi` (installs `Step Line.exe`, uninstaller,
shortcuts, registry entry; `makensis /DVERSION=… /DARTIFACT=…`).
- [x] AppImage: `packaging/linux/step-line.desktop`, `AppRun`, appdata.xml, and
`build_appimage.sh` (appimagetool, `APPIMAGE_EXTRACT_AND_RUN=1`).
- [x] Local end-to-end: rebuilt embedded exports and produced `step-line-0.2.0-x86_64.AppImage`,
ran it headless (0 script errors).
- [x] CI: NSIS + AppImage steps added to `.gitea/workflows/build.yml` build job; publish glob
already uploads `build/step-line-*<ver>*` (covers setup/AppImage names).
- [x] CI run 1110 verified end-to-end (v0.2.1): NSIS installer `step-line-setup-0.2.1.exe`
built, `7z l` shows `step-line.exe` + `uninstall.exe`, AppImage built (33.9 MB),
release `v0.2.1` published, `Job succeeded`.
- Gotchas fixed on the way: makensis needs `-D` defines (not `/D`) and absolute paths
(it resolves relatives against the script dir); installed exe must be space-free
(`step-line.exe`); icon.ico must use classic BMP entries (ImageMagick; PIL emits
PNG-compressed ICOs NSIS rejects).
## 🎮 Assets & detailed graphics pass (textures, rigs, UI feedback) ## 🎮 Assets & detailed graphics pass (textures, rigs, UI feedback)
- [x] Authored texture set via `tools/generate_textures.py` (deterministic PIL): brick facade, - [x] Authored texture set via `tools/generate_textures.py` (deterministic PIL): brick facade,
@@ -233,3 +288,105 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every
- [x] Add visual and audio feedback per escalation level (colors, tones, shake). - [x] Add visual and audio feedback per escalation level (colors, tones, shake).
- [x] Add audio for crowd ambience, radio responses, and confirmation prompts. - [x] Add audio for crowd ambience, radio responses, and confirmation prompts.
- [x] Externalize dialogue and UI text for localization. - [x] Externalize dialogue and UI text for localization.
## Generalized Game Asset Tooling
- [x] Reuse the OpenRouter image backend for role-aware arbitrary asset generation.
- [x] Add exact-dimension image crop and aspect-preserving resize commands.
- [x] Audit existing assets for role-specific dimensions, aspect, alpha, and tile seams;
report projection/perspective checks for visual review.
- [x] Add automated tests and document asset-pipeline usage.
## Generated Asset Integration
- [x] Regenerate the AI asset set at role-specific aspect ratios and dimensions.
- [x] Audit, process, and wire every generated asset into the game runtime.
- [x] Add screenshot auditing and refresh the README screenshot gallery.
## Access artifacts actually match the venue
Completed in the working tree (uncommitted with the control pass). Headless **74/74**.
- [x] Build artifacts from the event (`override_access`, carried items) and the venue policy,
not from `NpcType` alone. (`src/core/access_loadout.gd`; spawn signal is
`(profile, intent, npc_id, event)`.)
- [x] Joke Shop: valid ID+ticket, ID without ticket, expired/missing ID. Recognition does not
auto-grant on every `AUTHORIZED_GUEST`.
- [x] Wand venues roll `carried_items` per spawn (seeded); override can force a pocket.
- [x] Studio counterfeit / follow-through events carry `override_access`.
- [x] `AccessVerifier.verify(..., already_screened)` so a screened guest is not still denied.
- [x] Joke Shop / Studio `sidewalk` and `steps_end` waypoints so film/photo intents do not walk
to the entrance.
- [x] Wand shortcut is `C`, not `W`. Tests: `src/test/tests/test_access_loadout.gd`.
## Player control, focus ring, and radial action menu
Completed 2026-10-31 with ADR-0007–0010. Headless **77/77**, plus a windowed smoke
(`src/test/smoke_focus_radial.gd`) that opens the wheel and saves screenshots.
### Locomotion
- [x] Wand off `W` (now `C` + wheel). `A`/`D` allow/deny only while inspect/wand is open
(they step the wheel while it is open). `Q` remains a closed-menu polite shortcut.
- [x] Accel/decel on walk; gravity only when airborne; `floor_snap_length` 0.3.
- [x] Mouse look only while captured and `not input_locked`. Freeze walk+look while radial /
inspect / wand / radio / review is up. Recapture on close.
- [x] `cancel` (Esc / RMB) closes the top modal first; with nothing open, Esc toggles mouse
capture and RMB does nothing.
### Focus and head ring
- [x] Focus cone in `src/game/npc_focus.gd` (~10 m, ~10°). Closest-to-forward wins; occlusion
ray skips building hits. `Tab` still cycles 12 m.
- [x] 8-frame miss grace so walk-bob does not drop focus.
- [x] Billboarded torus on `Rig/Head`; gold idle, brighter while the radial is open; `Label3D`
`display_name`. HUD target line uses the display name, not the node name.
- [x] Screen-center crosshair.
### Radial HUD
- [x] `src/game/ui/action_radial.gd`: LMB or `Space` opens when someone is focused. Opening
click ignored for 120 ms. Slices: raise hand, point, head shake, polite, inspect, wand
(grey if no wand), warning, block, radio assist. Emergency stays on `R`.
- [x] Highlight by mouse angle, or `A`/`D` / arrows. Click slice or `Space` confirms. `Esc` /
RMB closes with no action (no recorder event).
- [x] Inspect / wand / radio slices close the wheel then open the existing panels. Freeze until
that panel closes.
- [x] Number keys `1`–`6` still work when the wheel is closed.
### NPC display
- [x] Procedural rig: neck, shoes, hair cap from seed, two eye spheres. Props, height
variation, walk bob, notice-head-turn kept. Collision capsule scaled to rig height.
Named `Head` is the ring attach point. Group `npc`.
### Input, copy, tests
- [x] Named input map: `action_menu` (Space/LMB), `confirm` (Space/Enter), `cancel`
(Esc/RMB), `inspect` (E), `wand` (C). `GameManager` uses `is_action_pressed`.
- [x] Windowed smoke: focus ring + name, wheel open/step/confirm/cancel
(`src/test/smoke_focus_radial.gd`, exits non-zero on failure).
- [x] README controls table, smoke checklist, test count, implemented/not-yet lists.
## Runtime boot and spawn fixes
Found while running the windowed smoke; all verified with a boot run and regenerated
screenshots.
- [x] `GameManager` did not compile: `radial` was typed `CanvasLayer`, so
`radial.is_open` / `confirm_selected()` made `:=` inference fail. Typed as
`ActionRadial` (the parse error would have broken the whole main scene).
- [x] Camera sat at the guard's feet: `Head` had no height offset, and the new gravity pulled
the guard to the floor. `Head` is now at y=1.6, and the guard spawns on the landing.
- [x] Guard spawned inside the building collider and fell through the world. Building volume
moved back to z ∈ [-8, -4] so the door / awning / marquee sit on its front face; the
guard spawns in front of the door facing the street.
- [x] Steps rose away from the door; treads now rise toward the entrance.
- [x] Event scheduler was polled every physics frame (~60 Hz), spawning >1200 NPCs in
seconds. `ShiftCoordinator` now accumulates time and pulls once per simulated second;
regression test in `test_scenarios.gd`.
- [x] Live NPCs capped at `MAX_LIVE_NPCS` (16); routine traffic is dropped at the cap and a
scripted event evicts the oldest unfocused NPC.
- [x] Removed the skyline backdrop planes (opaque black texture). Tracked as a follow-up in
`TODO.md`.
- [x] README screenshots regenerated with the fixed eye height and entrance staging.
+112 -8
View File
@@ -10,17 +10,71 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
- Eventing: no autoload signal bus. `ShiftCoordinator` owns an `EventBus` instance (`src/core/event_bus.gd`) passed to subsystems; subsystems emit only when a bus is assigned. Autoloads at parse time are unreliable under `--script`. - Eventing: no autoload signal bus. `ShiftCoordinator` owns an `EventBus` instance (`src/core/event_bus.gd`) passed to subsystems; subsystems emit only when a bus is assigned. Autoloads at parse time are unreliable under `--script`.
- Static signals are not available in Godot 4.3 GDScript ("Expected func or var after static") — do not use them. - Static signals are not available in Godot 4.3 GDScript ("Expected func or var after static") — do not use them.
- GDScript lambdas capture local variables **by value**; mutating a captured local inside a lambda does not affect the outer variable. Use member functions for accumulators in tests. - GDScript lambdas capture local variables **by value**; mutating a captured local inside a lambda does not affect the outer variable. Use member functions for accumulators in tests.
- Prototype shift duration defaults to 300 s for testing; production targets 10-15 minutes per PLAN.md. - Shift length is the scenario's `shift_duration_s` (900 s in all three venues). Tests and the screenshot tool override elapsed time; they do not change the default.
- RNG: NPC reactions and event scheduling are seedable for deterministic tests. - RNG: NPC reactions and event scheduling are seedable for deterministic tests.
## Current State ## Handoff (read this first)
- Core simulation, three scenarios, headless tests (**52 passing**), and a first-person prototype with interaction HUD, access inspection, radio (assist + emergency hold-to-confirm), debug overlay, and shift review are implemented and green. - Repo: `/run/media/system/DATA/Documents/06-games/security-game`. Godot: `~/.local/bin/godot` (4.3.stable).
Tests: `godot --headless --path . --script res://src/test/test_runner.gd` → **77/77**.
Windowed control smoke: `godot --path . --script res://src/test/smoke_focus_radial.gd` → exit 0.
- `main` is **7 commits ahead of origin**, all committed locally, **not pushed**. The newest commit
is the control pass + boot/spawn fixes (access loadout, focus cone, action wheel, named input
actions, NPC rig, camera/building fixes, scheduler rate, NPC cap, screenshots, docs).
- Nothing is left uncommitted. Ask before pushing if you did not author the earlier commits.
- Next work, in order: (1) session shell in `TODO.md`; (2) world follow-ups listed at the top of
`TODO.md` (guard floats ~0.5 m, step colliders, skyline asset, marquee size); (3) push / release
when the user asks.
- Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg.
## Current State
- Access artifacts: `src/core/access_loadout.gd` builds from event `override_access` + venue policy.
Spawn signal is now `(profile, intent, npc_id, event)`. Recognition is not auto-attached to every
guest. `AccessVerifier.verify(..., already_screened=false)` so Joke Shop scoring uses
`controller.screened`. Joke Shop/Studio gained `sidewalk`/`steps_end` waypoints. Wand venues roll
`carried_items` per spawn (seeded). Tests in `src/test/tests/test_access_loadout.gd`.
- Wand shortcut is **`C`**, not `W` (`W` is walk). `radio_emergency` input map no longer includes W.
`hud.controls` / `hud.screen_first` updated.
- Control pass (in tree, uncommitted, smoke-tested): cone focus via `src/game/npc_focus.gd`,
accel/decel, gravity only airborne, `input_locked` freeze, Esc no longer on the guard.
`NpcAvatar` in group `npc`; neck/hair/eyes/shoes; torus head ring + `Label3D` name; `set_focus_visual`.
`src/game/ui/action_radial.gd` doughnut (LMB/Space open, A/D/arrows, Space/click confirm, Esc/RMB
cancel). Crosshair CanvasLayer. Emergency still `R` only. Number keys 1–6 when the wheel is closed.
- Named input actions now exist and are used: `action_menu`, `confirm`, `cancel` (Esc + RMB),
`inspect` (E), `wand` (C). The old unused `interact` action was removed.
- Runtime fixes from the smoke: `Head` is +1.6 above the guard root (root sits on the floor); guard
spawns in front of the door facing the street; building volume moved to z ∈ [-8,-4] so the door,
awning, and marquee are visible; step treads rise toward the door; `ShiftCoordinator` pulls the
event scheduler once per simulated second (was every physics frame → >1200 NPCs in seconds);
`GameManager.MAX_LIVE_NPCS = 16` caps live avatars; skyline backdrop removed (opaque black asset).
- Host sign-in follow-up remains deferred. PLAN.md + ADRs 0007–0010 still describe the intended UX.
- **Release v0.2.1 is LIVE** (2026-10-31): CI run 1110 built the NSIS installer + AppImage
and published — installers/AppImage are on the releases page. Releasing = bump version → tag → push.
- NSIS gotchas (validated in a local ubuntu:24.04 docker repro): makensis uses `-D` defines
(never `/D`), resolves relative `-DICON`/`-DARTIFACT` against the **script directory** so CI
passes `$PWD`-absolute paths; installed exe must be space-free (`step-line.exe`); ICO must be
classic BMP entries (ImageMagick `icon:auto-resize=48,32,16`; PIL emits PNG-compressed ICOs NSIS
cannot load).
- appimagetool runs system `appstreamcli` if present (strict, exit 3 on warnings) — project
metadata now passes `--pedantic`.
- Distribution facts (verified via Gitea API + host creds): repo is **public**, account is
admin; `v0.2.1` has all 5 assets; the installers were initially missing from the release
because `publish_release.sh` uploaded only portables — now fixed (uploads installer +
AppImage; falls back to `~/.git-credentials` when no `GITEA_TOKEN`).
- Host sign-in: instance `[service] REQUIRE_SIGNIN_VIEW=true` blocks anonymous browse/download
(not a repo/API setting). `~/.git-credentials` holds the Gitea creds (basic-auth usable);
`.env` only holds `OPENROUTER_API_KEY`.
- **Release v0.2.0 was LIVE**: pushing the `v0.2.0` tag made Gitea Actions run the build job
and publish automatically (host has the `GITEA_TOKEN` secret). Tag page and `/releases` are
public (HTTP 200). Caveat: anonymous **asset downloads** redirect to login on this host
(host setting — tracked in TODO). Future releases = bump `config/version`, `git tag vX.Y.Z`,
push. No local token needed.
- Core simulation, three scenarios, v0.2.1 installers/AppImage, first-person prototype (inspect/wand/radio, shift review, Tab targeting, V venue cycle) are on local `main`. Tagged v0.2.1 was 57 tests; this working tree is **74/74**.
- NPC avatars now walk to per-intent waypoints (`VenueScenario.waypoint_for_intent`): enter/trespass/deliver/arrive → entrance, photo/film/perform/wander/roam → sidewalk, sit → steps_end. `NpcAvatar.setup` takes an explicit `door_position`. - NPC avatars now walk to per-intent waypoints (`VenueScenario.waypoint_for_intent`): enter/trespass/deliver/arrive → entrance, photo/film/perform/wander/roam → sidewalk, sit → steps_end. `NpcAvatar.setup` takes an explicit `door_position`.
- Returning-trespasser behavior: `NpcProfile.return_probability` (trespasser 0.6, mental-crisis 0.4, others 0). `NPCController._maybe_schedule_return()` moves a directed-away NPC to `LOITERING_DISTANT`; `tick_return(delta)` fires after 20-60s and transitions back to `APPROACH`. `NpcAvatar._advance_fsm` re-targets the door on return. - Returning-trespasser behavior: `NpcProfile.return_probability` (trespasser 0.6, mental-crisis 0.4, others 0). `NPCController._maybe_schedule_return()` moves a directed-away NPC to `LOITERING_DISTANT`; `tick_return(delta)` fires after 20-60s and transitions back to `APPROACH`. `NpcAvatar._advance_fsm` re-targets the door on return.
- Perception pass: `NPCController.perceive_guard(distance)` returns `{noticed, band, reaction}` with bands `unaware` (>6m), `aware` (2-6m), `near` (<2m). Near + temperament ≥0.6 → ARGUE, else COMPLY. `GameManager._perception_pass()` feeds real guard distance each physics tick and logs first-time notice. - Perception pass: `NPCController.perceive_guard(distance)` returns `{noticed, band, reaction}` with bands `unaware` (>6m), `aware` (2-6m), `near` (<2m). Near + temperament ≥0.6 → ARGUE, else COMPLY. `GameManager._perception_pass()` feeds real guard distance each physics tick and logs first-time notice.
- Responder dispatch: `RadioSystem` tracks `pending_calls` and decrements `arrival_delay_s` in `tick`; emits `responder_arrived` when a call's delay elapses. `RadioSystem.responder_outcome(call)` maps danger/weapons → command/restraint/evacuation/arrest. `GameManager._on_responder_arrived` spawns a `RESPONDER` avatar at the entrance and logs the outcome. HUD shows an emergency-radio cooldown label via `_update_radio_cooldown`. - Responder dispatch: `RadioSystem` tracks `pending_calls` and decrements `arrival_delay_s` in `tick`; emits `responder_arrived` when a call's delay elapses. `RadioSystem.responder_outcome(call)` maps danger/weapons → command/restraint/evacuation/arrest. `GameManager._on_responder_arrived` spawns a `RESPONDER` avatar at the entrance and logs the outcome. HUD shows an emergency-radio cooldown label via `_update_radio_cooldown`.
- Wand screening (Joke Shop): `WandScreener` (new `src/core/wand_screener.gd`) classifies carried items as prohibited (`weapon/knife/gun/blade`) vs false-alarm (`keys/belt/phone/coins/watch/buckle`) vs clear. `NpcProfile.carried_items` holds guest items. `GameManager` binds `W` to `_toggle_wand_screening` and `A`/`D` to `decide_wand`; `decide_access` blocks admission at wand venues until `controller.screened` is true. `VenueScenario.door_staff_lines` holds venue-specific door-staff dialogue (Joke Shop has id/ticket/wand/refusal/prohibited lines). - Wand screening (Joke Shop): `WandScreener` classifies carried items as prohibited vs false-alarm vs clear. `NpcProfile.carried_items` is filled at spawn on wand venues. `GameManager` binds **`C`** to `_toggle_wand_screening` and `A`/`D` to `decide_wand` while inspect/wand is open. `decide_access` still blocks admission until `controller.screened` is true, and `verify` now takes that flag. Door-staff lines log through GameManager.
- Design change (2026-10-31): the selectable dialogue tone-of-voice system was **removed**. - Design change (2026-10-31): the selectable dialogue tone-of-voice system was **removed**.
`Rules.Tone`/`TONE_NAMES`, `NpcProfile.tone_modifiers`, and the tone param on `Rules.Tone`/`TONE_NAMES`, `NpcProfile.tone_modifiers`, and the tone param on
`NPCController.roll_reaction/apply_player_action` no longer exist; `NpcProfile.dialogue_lines` `NPCController.roll_reaction/apply_player_action` no longer exist; `NpcProfile.dialogue_lines`
@@ -54,20 +108,61 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
- Assets pipeline: `python3 tools/generate_textures.py` produces all authored PNGs under - Assets pipeline: `python3 tools/generate_textures.py` produces all authored PNGs under
`assets/textures/` (≤1024px, pow2); regenerate after changing the script, then run `assets/textures/` (≤1024px, pow2); regenerate after changing the script, then run
`godot --headless --import` before tests. 57 headless tests guard the pipeline. `godot --headless --import` before tests. 57 headless tests guard the pipeline.
- General image pipeline: `python3 tools/asset_pipeline.py` defaults to
`openai/gpt-5-image-mini`, requests supported 1:1/3:2 sources, then fits each role's target.
It provides crop, resize, tile edge blending, alpha-glyph recoloring, and role-aware audits.
Screenshot auditing checks 1280x720 minimum, 16:9 aspect, and contrast; visual composition
remains a manual check. Python tools require Pillow from `requirements-tools.txt`.
- AI texture mode: `python3 tools/generate_textures.py --ai` generates textures via the
OpenRouter image API (`POST https://openrouter.ai/api/v1/images`, model
`bytedance-seed/seedream-4.5` by default, base64 PNG in `data[].b64_json`). Requires
`OPENROUTER_API_KEY` (a gitignored `.env` in the repo root holds it locally; source it
with `set -a && . ./.env && set +a`). `--prompt`/`--name`/`--model`/`--size` override the
default batch. Output is downscaled to ≤1024px. NOTE: seedream rejects sizes below
3,686,400 px (1920×1920), so the script requests `GEN_SIZE=2048` and downscales — do not
lower `GEN_SIZE` below 1920. Verified end-to-end 2026-10-31 (3 textures generated).
Legacy `--ai` remains on Seedream 4.5 and still ignores `background: transparent`; do not
use it for alpha-dependent assets. Gemini 3.1 Flash Image advertised broad ratios but the
current provider rejected authentication. The validated role-aware model is GPT-5 Image Mini.
- Runtime assets: all 15 loaded world/HUD/card textures now live in
`assets/textures/ai/game_ready/`; each passes role audit. Generated tile edges are blended,
icon alpha masks are recolored white where needed, and the vignette uses the existing
deterministic generator because the API returned an empty transparent image. The six
`docs/screenshots/` captures were regenerated after wiring and pass screenshot audit.
- Detailed graphics: textured facade/awning/skyline, humanoid NPC rigs + props + walk bob + - Detailed graphics: textured facade/awning/skyline, humanoid NPC rigs + props + walk bob +
attention head-turn, inspection artifact cards, mode icons, escalation vignette, body-cam attention head-turn, inspection artifact cards, mode icons, escalation vignette, body-cam
overlay (REC + shift clock). Screenshot set regenerated (`docs/screenshots/`, day + night). overlay (REC + shift clock). Screenshot set regenerated (`docs/screenshots/`, day + night).
- Polish: wand scan cooldown (`WAND_COOLDOWN_S` + `wand.recharge` message).
- NOTE: collaborators occasionally add uncommitted work to this shared tree (e.g. the radio - NOTE: collaborators occasionally add uncommitted work to this shared tree (e.g. the radio
easter egg); check `git status` before committing and leave foreign changes untouched unless asked. easter egg); check `git status` before committing and leave foreign changes untouched unless asked.
## Known Issues / Notes ## Known Issues / Notes
- RefCounted reference cycle: `NPCController` holds an `FSM` that holds a back-reference to its owner, so test suites report "resources still in use at exit". Benign (process exit), but worth breaking later (e.g. `WeakRef` or explicit `dispose()`). - Reference cycles are fixed: `FSM` holds its owner via `WeakRef`, `ShiftCoordinator` is
`RefCounted`, and persistence tests free the `Save` node — the suite exits with no
"resources still in use" warnings (was ~13). If a future subsystem introduces a cycle,
repeat the WeakRef pattern.
- Shift length: `GameManager` uses the scenario's `shift_duration_s` (900s ≈ 10-15 min);
tests and the screenshot tool override elapsed time directly.
- Headless (dummy renderer) logs "Parameter m is null" from `mesh_get_surface_count`; it does not occur with a real GL driver and does not affect logic. - Headless (dummy renderer) logs "Parameter m is null" from `mesh_get_surface_count`; it does not occur with a real GL driver and does not affect logic.
- Steps are visual-only (no collider) so NPCs can path to the entrance; the guard spawns on the landing so it never needs to climb. - Steps are visual-only (no collider) so NPCs can path to the entrance; the guard spawns on the landing so it never needs to climb.
- Persistence tests write into the real `user://shift_results.json` (harmless local pollution of the demo history). - Persistence tests write into the real `user://shift_results.json` (harmless local pollution of the demo history).
- macOS export required `rendering/textures/vram_compression/import_etc2_astc=true` in `project.godot` (universal build demands ETC2 ASTC enabled). - macOS export required `rendering/textures/vram_compression/import_etc2_astc=true` in `project.godot` (universal build demands ETC2 ASTC enabled).
- Joke Shop `door_staff_lines` authored but not yet surfaced in the prototype UI (tracked in TODO). - Joke Shop `door_staff_lines` are logged through `GameManager` (inspect / wand / refuse). They are not a separate dialogue UI.
- Access loadout is implemented in the working tree (see Current State); do not re-fix `override_access` as if it were still unread.
- `Save.SETTINGS_FILE` is declared and unused; no mouse-sensitivity or pause menu yet.
- Headless suite is **77/77**; the windowed focus/radial smoke passes. Remaining visual follow-ups are at the top of `TODO.md`.
- Typed spawn signal extra args: handlers must accept the 4th `event: Dictionary` or Godot will not call them (`test_shift_coordinator_runs_a_shift` failed until `_on_spawn` took four args).
- GDScript inference trap that broke the main scene: a `var` typed as a base class
(`radial: CanvasLayer`) exposes script members as `Variant`, so `var x := radial.is_open` is a
parse error. Type it as the script type (`ActionRadial`) or annotate the local explicitly.
`--check-only --script` cannot see the `Save` autoload; use a real boot (`--quit-after 180`) to
verify a full-scene compile.
- Do not snapshot right after changing a transform in the same frame: `capture_screenshots.gd`
needs a state/frame gap or the capture uses the old camera orientation.
- `EventScheduler` probabilities are per simulated second, not per call. Never poll it per frame.
- Screenshot staging lives in `src/test/capture_screenshots.gd` (guard at z≈10.5 facing the door).
`docs/screenshots/` copies are manual: copy from `user://screenshots/`.
## Open Questions ## Open Questions
@@ -75,8 +170,17 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
- Localization target languages beyond English. - Localization target languages beyond English.
- macOS notarization approach for broad distribution. - macOS notarization approach for broad distribution.
## Plan vs TODO Gaps (evaluated 2026-10-31, all resolved) ## Plan vs code (re-evaluated)
All previously identified gaps are now implemented and covered by tests/smoke items: PROTESTER archetype, door lock/secure, flashlight, CALL_SOMEONE reaction, LATE phase fix, shift-result persistence, NavigationAgent3D movement, and JSON-backed localization. Remaining backlog lives in `TODO.md`. On local `main` (6 commits not on origin): v0.2.1 packages, ADRs 0007–0010, asset pipeline wired to
`assets/textures/ai/game_ready/`, screenshots refreshed.
Uncommitted: `AccessLoadout`, control pass (cone, ring, radial, NPC face, named input actions),
and boot/spawn fixes. Headless 77/77; windowed smoke passes. Access-artifact and control-pass
sections moved to `DONE.md`.
Still in `TODO.md`: world follow-ups (guard float, step colliders, skyline asset, marquee size);
session shell; leftover PLAN verbs; crowd rubberneck; emergency aftermath; body-cam replay;
sit/photo/film poses; real crowd audio; Joke Shop/Studio extra events; PLAN.md says GUT/GitHub.
<!-- b64: RWFzdGVyIGVnZzogYSByYXJlIHJhZGlvIHRyYW5zbWlzc2lvbiAiwr9Ew7NuZGUgZXN0w6EgbGEgbW90YT8iIGZpcmVzIHZpYSBHYW1lTWFuYWdlci5fbWF5YmVfcmFkaW9fZWFzdGVyX2VnZyAoMi41JSBjaGFuY2UgYWZ0ZXIgYSBzdWNjZXNzZnVsIHJhZGlvIGNhbGwpLiBTdHJpbmcga2V5IHJhZGlvLmVhc3Rlcl9lZ2cgaW4gY29udGVudC9zdHJpbmdzLmVuLmpzb24uIERvIG5vdCByZW1vdmUu --> <!-- b64: RWFzdGVyIGVnZzogYSByYXJlIHJhZGlvIHRyYW5zbWlzc2lvbiAiwr9Ew7NuZGUgZXN0w6EgbGEgbW90YT8iIGZpcmVzIHZpYSBHYW1lTWFuYWdlci5fbWF5YmVfcmFkaW9fZWFzdGVyX2VnZyAoMi41JSBjaGFuY2UgYWZ0ZXIgYSBzdWNjZXNzZnVsIHJhZGlvIGNhbGwpLiBTdHJpbmcga2V5IHJhZGlvLmVhc3Rlcl9lZ2cgaW4gY29udGVudC9zdHJpbmdzLmVuLmpzb24uIERvIG5vdCByZW1vdmUu -->
+136
View File
@@ -452,6 +452,142 @@ The following commands apply once the Godot project is scaffolded:
Desktop is the first target: Windows, Linux, and macOS. Web export is a later option. Desktop is the first target: Windows, Linux, and macOS. Web export is a later option.
## Player Control, Focus, and Action Menu (next pass)
The prototype already walks and looks in first person, but the control scheme fights itself and targeting is a hairline. This pass replaces that with three things that belong together: solid locomotion, a visible focus on a person, and a round action menu for what to do with them.
Decisions: [ADR-0007](docs/adr/0007-locomotion-and-input-layers.md) locomotion and input layers, [ADR-0008](docs/adr/0008-cone-focus-and-head-ring.md) cone focus and head ring, [ADR-0009](docs/adr/0009-radial-action-menu.md) radial action menu, [ADR-0010](docs/adr/0010-procedural-npc-presentation.md) procedural NPC presentation.
### What is wrong today
- `W` is walk-forward in the input map and wand-screening in `GameManager._handle_key`. Walking at someone with a wand venue fires the wand.
- `A` / `D` are strafe and also allow/deny during inspection.
- `Q` is polite direction and is also bound as `cancel`.
- Focus is an 8 m physics ray from the camera. Missing a capsule by a few pixels clears the target. There is no mark in the world that you have someone.
- NPC bodies are colored capsules with props. They read as markers, not people.
- Actions live on number keys `1`–`6` plus `Q` / `E` / `W` / `R`. That is a cheat-sheet, not a verb you pick while looking at a person.
- `Esc` only toggles mouse capture. There is no "I changed my mind" for an action in progress.
### Movement and look
Keep first-person WASD + mouse look. Fix the feel and stop stealing those keys.
- Body yaw from mouse X, head pitch from mouse Y, pitch clamped (already `-80` to `80`).
- Move relative to body yaw only (already). Add short acceleration / deceleration so starts and stops are not instant.
- Gravity only when `not is_on_floor()`; `floor_snap_length` so the steps work.
- Mouse look runs only while `MOUSE_MODE_CAPTURED` and no modal UI is open (radial, inspection, wand, radio, shift review).
- Walk speed stays around 3.2 m/s. No sprint, no crouch in this pass.
- While the radial is open: freeze look and WASD so `A` / `D` can cycle the menu. Recapture the mouse when the menu closes.
- `Esc` with a modal open closes that modal and does nothing else. `Esc` with nothing open keeps the current capture/release toggle.
Equipment stays on its own keys because it is not about a person: `G` flashlight, `L` door lock, `Tab` cycle nearby people, `V` venue (debug), `F` debug overlay. `R` stays the dedicated emergency radio (hold-to-confirm on the existing panel). Those do not go on the wheel.
Wand leaves `W`. It is a slice on the wheel (greyed when the venue has no wand) and an optional extra key that is not WASD, suggested `C`.
### Focus area
Replace the hairline ray as the primary picker.
- A **focus cone** in front of the camera: people within about 10 m whose head projects inside a screen-space circle around the crosshair (about 90 px / 10 degrees).
- If several heads sit in the circle, pick the one closest to the center, then the nearer one if tied.
- The person must be in front (dot with camera forward > 0) and not fully occluded by the building. A short ray to the head is enough for occlusion.
- `Tab` still cycles people inside 12 m as the aim-free path. Cycling sets the same focused person the cone would.
- Clearing: look away until the head leaves the circle for a few frames (small grace so a bob does not drop them), or they walk out of range.
### Head ring
When a person is in focus, draw a thin ring around their head.
- Attach to the avatar `Head` node, sitting just above the skull, billboarded to the camera so it stays a circle on screen.
- Slow spin is fine; no ping-pong scale spam.
- Idle focus: pale gold, modest size. Radial open on that person: brighter, slightly larger.
- Optional small name under the ring (`display_name` only, not the archetype cheat `Violent Threat` in the world). The existing HUD target line can keep the archetype for the prototype.
- One ring at a time. Everyone else has none.
Implementation sketch: `MeshInstance3D` torus or a thin unshaded ring mesh on the avatar, toggled from `GuardController` / `GameManager` when focus changes. Screen-space fallback (unproject head, draw a `Control` circle) is acceptable if the 3D torus z-fights on hats.
### Round action HUD
A doughnut / pie menu centered on the screen, opened on the focused person.
**Open**
- Left mouse button, or `Space`, while someone is in focus and no other modal is open.
- If nobody is in focus, log the existing "aim first" line and do not open.
- The click that opens the menu must not also pick a slice.
**While open**
- Mouse becomes visible. Look and walk freeze.
- Center of the doughnut shows the focused person's `display_name`.
- Slices, clockwise from the top, de-escalation first:
1. Raise hand (stop / wait)
2. Point away
3. Head shake
4. Polite direction (old `Q`)
5. Inspect access (old `E`)
6. Wand (grey if the venue has no wand)
7. Formal warning
8. Block
9. Radio assist
- Emergency is **not** on the wheel (`R` only).
- Highlight follows the mouse angle when the cursor is over the ring. `Left` / `Right` or `A` / `D` step the highlight. Wrap around.
**Confirm**
- Click a slice: that action runs, menu closes, mouse recaptured.
- `Space` while a slice is highlighted: same.
- Inspect or wand: close the wheel, then open the existing inspect / wand panel. Allow / deny on that panel stay `A` / `D` (walk is frozen only while the wheel is up; once the inspect panel is open, WASD can move again, so allow/deny on `A` / `D` remains a press handler and must ignore repeats / echo, which it already does). Safer: keep walk frozen for inspect/wand too, until the panel closes. Prefer freeze until the whole interaction ends.
- Radio assist: close the wheel, open the existing radio panel.
**Cancel**
- `Esc` or right mouse button: hide the menu, no action, no recorder event, recapture mouse.
- Focus and the head ring stay as they were.
Number keys `1`–`6` may remain as shortcuts that skip the wheel so a smoke-test script still works. They must not fire while the wheel is open (the wheel owns `A` / `D` / `Space`). Document both in the README.
### NPC display
Stay on the procedural capsule rig (no Mixamo / GLB dependency this pass). Make them read as people at conversation distance.
- Neck between torso and head; shoes; simple hair cap (color from seed); two dark eye spheres on the face so a head has a front.
- Keep archetype props (camera, box, hat, sign, bag). Keep height / brightness variation from the visual seed.
- Collision capsule matches the scaled height so the focus ray / cone hits the body you see.
- `Head` stays a named node other systems can find (`get_node("Rig/Head")`) for the ring and the cone.
- Walk bob and the "look at the guard when noticed" cue stay.
- Do not put the internal type name (`Trespasser`, `Violent Threat`) on the body. The player should judge from clothes, props, and behavior. The debug overlay (`F`) can still print the type.
AI textures in `assets/textures/ai/` stay out of this pass unless a specific map (shirt, hair) is already the right size.
### Input map (target)
| Action | Bind |
| --- | --- |
| Walk | WASD / arrows (arrows **do not** walk while the wheel is open; they step the highlight) |
| Look | Mouse, captured |
| Open / confirm action menu | LMB, `Space` |
| Cancel menu | RMB, `Esc` |
| Step highlight | `A` / `D`, left / right arrows |
| Emergency radio | `R` |
| Flashlight | `G` |
| Door lock | `L` |
| Cycle nearby people | `Tab` |
| Wand shortcut | `C` (not `W`) |
| Inspect shortcut | `E` still allowed when the wheel is closed |
### Files this pass is expected to touch
- `src/game/guard_controller.gd` — cone focus, no look while modal, accel, floor snap.
- `src/game/npc_avatar.gd` — face / hair / neck, head marker, ring toggle.
- `src/game/ui/action_radial.gd` — new Control, `_draw` doughnut, slice pick.
- `src/game/ui/hud.gd` — host the radial; stop teaching number-key verbs as the only path.
- `src/game/game_manager.gd` — input routing, freeze flags, open/confirm/cancel.
- `project.godot` — input map.
- `src/core/strings.gd` — controls line, aim-first, no new tone-of-voice.
- `src/test/tests/test_focus_and_radial.gd` — cone pick, wrap, cancel emits nothing, `W` is not wand.
- README controls table and smoke checklist.
## Design Direction ## Design Direction
The game should feel like a tense workplace simulation with occasional crisis moments. The most satisfying outcome is not overpowering an NPC; it is recognizing the situation early and resolving it calmly. The game should feel like a tense workplace simulation with occasional crisis moments. The most satisfying outcome is not overpowering an NPC; it is recognizing the situation early and resolving it calmly.
+75 -21
View File
@@ -20,15 +20,22 @@ The central design goal is simple:
| Platform | Package | | Platform | Package |
| --- | --- | | --- | --- |
| **Windows** | [`step-line-windows.exe`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, no install needed | | **Windows — installer** | [`step-line-setup-<ver>.exe`](https://git.allucanget.biz/zwitschi/security-game/releases) · contains all game data, shortcuts + uninstaller |
| **Linux** | [`step-line-linux.x86_64`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, `chmod +x` and run | | **Windows — portable** | [`step-line-windows-<ver>.exe`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, no install needed |
| **macOS** | [`step-line-macos.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unsigned build, quarantine note below | | **Linux — AppImage** | [`step-line-<ver>-x86_64.AppImage`](https://git.allucanget.biz/zwitschi/security-game/releases) · single file, `chmod +x` and run |
| **Linux — portable** | [`step-line-linux-<ver>.x86_64`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, `chmod +x` and run |
| **macOS** | [`step-line-macos-<ver>.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unsigned build, quarantine note below |
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases> **All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
> Releases are built automatically by Gitea Actions when a `vX.Y.Z` tag is > **Latest: v0.2.1** (2026-10-31) — built and published by Gitea Actions from the
> pushed (see [`docs/RELEASING.md`](docs/RELEASING.md)). Manual builds are > `v0.2.1` tag, including the Windows installer and Linux AppImage. Releases are built automatically when a `vX.Y.Z` tag is pushed
> produced with `./tools/build_releases.sh`. > (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
> (NSIS + appimagetool) and uploads with the portable binaries.
>
> Note: on this host, downloading release files currently requires sign-in;
> the page and tag URLs are public.
> >
> macOS builds are unsigned: right-click → Open, or clear the quarantine with > macOS builds are unsigned: right-click → Open, or clear the quarantine with
> `xattr -dr com.apple.quarantine step-line-macos.zip`. Build instructions and > `xattr -dr com.apple.quarantine step-line-macos.zip`. Build instructions and
@@ -129,11 +136,49 @@ The architecture maps onto Godot 4 nodes, scenes, and custom resources as descri
- **Navigation:** `NavigationRegion3D` and `NavigationAgent3D` - **Navigation:** `NavigationRegion3D` and `NavigationAgent3D`
- **NPC behavior:** Custom lightweight finite state machines driven by `NpcProfile` resources - **NPC behavior:** Custom lightweight finite state machines driven by `NpcProfile` resources
- **UI and audio:** Godot `Control` nodes, themes, audio buses, and `AudioStreamPlayer` nodes - **UI and audio:** Godot `Control` nodes, themes, audio buses, and `AudioStreamPlayer` nodes
- **Art assets:** authored PNGs in `assets/textures/` (≤1024px, power-of-two) produced by `tools/generate_textures.py` and imported by Godot; structural geometry stays procedural (`WorldBuilder`) - **Art assets:** authored PNGs and the in-game generated set in `assets/textures/ai/game_ready/`; structural geometry stays procedural (`WorldBuilder`). The legacy `tools/generate_textures.py --ai` batch remains available.
- **Asset tooling:** `tools/asset_pipeline.py` generates role-aware assets through OpenRouter and provides crop, resize, seamless-tile, and image-audit commands, including gameplay screenshot checks.
- **Testing:** GUT (Godot Unit Test) in headless mode - **Testing:** GUT (Godot Unit Test) in headless mode
- **Source control:** git on a Gitea host with Gitea Actions (`.gitea/workflows/build.yml`) for headless tests and desktop release builds - **Source control:** git on a Gitea host with Gitea Actions (`.gitea/workflows/build.yml`) for headless tests and desktop release builds
- **Targets:** Windows, Linux, and macOS desktop first; web later - **Targets:** Windows, Linux, and macOS desktop first; web later
### Image Asset Tools
Install the Pillow dependency used by the existing texture generator and the asset tools:
```bash
python3 -m pip install -r requirements-tools.txt
```
Generate a role-sized image (generation requires `OPENROUTER_API_KEY`):
```bash
python3 tools/asset_pipeline.py generate --prompt "weathered red brick" --role tile --output assets/textures/brick_extra.png
```
Generated images are resized to the selected role profile. Non-square requests use
the validated `openai/gpt-5-image-mini` source ratio and crop to the final role dimensions.
Transparent glyphs can be recolored while preserving their alpha mask with
`--foreground '#ffffff'`.
Crop uses the image center by default; `--x` and `--y` select an explicit crop origin. Resize defaults to aspect-preserving `contain`; use `--fit cover` to crop or `--fit stretch` to distort to the exact dimensions.
```bash
python3 tools/asset_pipeline.py crop assets/textures/source.png assets/textures/crop.png --width 256 --height 128
python3 tools/asset_pipeline.py resize assets/textures/source.png assets/images/backgrounds/scene.png --width 1920 --height 1080 --fit cover
python3 tools/asset_pipeline.py seamless assets/textures/rough_tile.png assets/textures/tile.png
```
Audit accepts image files or directories. `--role auto` infers common roles from paths and filenames; explicit roles include `tile`, `texture`, `icon`, `card`, `banner`, `backdrop`, `background`, `overlay`, `app_icon`, and `screenshot`.
```bash
python3 tools/asset_pipeline.py audit assets/textures
python3 tools/asset_pipeline.py audit assets/images/backgrounds --role background --json
python3 -m unittest discover -s tools -p 'test_*.py'
```
The audit classifies measurable fit issues as `good` or `bad` based on role-specific dimensions, aspect ratio, alpha requirements, and tile-edge continuity. It lists perspective/projection as a visual review item because image semantics cannot be judged reliably by these deterministic checks.
### Setup ### Setup
1. Download the Godot 4.3 editor (official build) and put `godot` on your PATH. 1. Download the Godot 4.3 editor (official build) and put `godot` on your PATH.
@@ -156,6 +201,9 @@ godot --headless --path . --script res://src/test/test_runner.gd
# Save a screenshot to user:// (windowed) # Save a screenshot to user:// (windowed)
godot --path . --script res://src/test/take_screenshot.gd godot --path . --script res://src/test/take_screenshot.gd
# Windowed control smoke: focus ring + action wheel, saves PNGs, exits 1 on failure
godot --path . --script res://src/test/smoke_focus_radial.gd
``` ```
The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor. The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor.
@@ -165,15 +213,18 @@ The unit test runner is the project's own lightweight harness (no editor plugin
| Input | Action | | Input | Action |
| --- | --- | | --- | --- |
| W/A/S/D + mouse | Move and look | | W/A/S/D + mouse | Move and look |
| Esc | Release / capture mouse | | Space / left click | Action wheel for the focused person |
| A / D or ←/→ | Step through the action wheel (while it is open) |
| Space / left click | Confirm the highlighted action (while the wheel is open) |
| Esc / right click | Close the top panel; Esc with nothing open releases / captures the mouse |
| 1 / 2 / 3 | Quick gestures without the wheel: raise hand, point, head shake (Level 1) |
| Q | Polite direction (Level 1) | | Q | Polite direction (Level 1) |
| 1 / 2 / 3 | Simple gestures: raise hand, point, head shake (Level 1) |
| 4 | Formal warning (Level 2) | | 4 | Formal warning (Level 2) |
| 5 | Blocking and positioning (Level 3) | | 5 | Blocking and positioning (Level 3) |
| 6 | Security assistance radio (Level 4) | | 6 | Security assistance radio (Level 4) |
| R | Emergency radio, hold on-screen button ~1.6s (Level 5) | | R | Emergency radio, hold on-screen button ~1.6s (Level 5) |
| E | Inspect access artifacts / close inspection | | E | Inspect access artifacts / close inspection |
| W | Handheld wand screening (wand venues only) | | C | Handheld wand screening (wand venues only) |
| A / D | Allow / deny entry during inspection or wand check | | A / D | Allow / deny entry during inspection or wand check |
| V | Cycle venue: Hollywood Blvd → The Joke Shop → TV Studio (restarts shift) | | V | Cycle venue: Hollywood Blvd → The Joke Shop → TV Studio (restarts shift) |
| Tab | Cycle target among nearby NPCs (aim-free alternative) | | Tab | Cycle target among nearby NPCs (aim-free alternative) |
@@ -202,28 +253,31 @@ Implemented so far:
- Godot 4.3 project scaffold, input map, procedural world builder - Godot 4.3 project scaffold, input map, procedural world builder
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem` - Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools - Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
- Headless unit tests: **52 passing** (access rules, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, navmesh, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings) - Headless unit tests: **77 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, navmesh, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone and action wheel)
- Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event - Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event
- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review - First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
- Control pass: focus cone on NPC heads with a billboarded gold ring 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`)
- Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets - Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets
- Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock) - Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock)
Not yet implemented: body-camera replay timeline, full 10-15 minute shift lengths, venue selection startup menu, editor-based GUT integration, RefCounted cycle cleanup, and crowd performance profiling/LOD (see `TODO.md`). Not yet implemented: body-camera replay timeline, venue selection startup menu, pause/settings shell, eye-contact and request-reason verbs, crowd rubbernecking, emergency aftermath, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`).
### Smoke Test Checklist ### Smoke Test Checklist
1. `godot --path .` starts the game without script errors. 1. `godot --path .` starts the game without script errors.
2. The guard moves and looks around; the building, steps, and sidewalk are visible. 2. The guard moves and looks around; the building, steps, and sidewalk are visible.
3. NPCs spawn and approach the entrance during the first minutes. 3. NPCs spawn and approach the entrance during the first minutes.
4. Aiming at an NPC shows its archetype and intent in the HUD. 4. Looking at an NPC inside the focus cone shows a gold head ring, the person's name, and the HUD target line.
5. Pressing `Q` produces a reaction log entry and reputation feedback. 5. `Space` or left click opens the action wheel; `A`/`D` highlight a slice, `Space` confirms it, `Esc`/right click cancels without a log entry.
6. `E` opens access inspection showing artifacts and policy result; `A`/`D` record a decision. 6. `E`/`C` open the inspection/wand panels from the wheel and freeze movement until they close; walking never fires the wand.
7. `6` opens the assistance form; submitting shows dispatch feedback. 7. Pressing `Q` produces a reaction log entry and reputation feedback.
8. `R` opens the emergency form; the button must be held ~1.6s or the call cancels. 8. `E` opens access inspection showing venue-correct artifacts and policy result; `A`/`D` record a decision.
9. `G` toggles the flashlight (indicator appears in the HUD); `L` toggles the door lock (indicator turns red when locked). 9. `6` opens the assistance form; submitting shows dispatch feedback.
10. `F` toggles the debug overlay with NPC states and shift stats. 10. `R` opens the emergency form; the button must be held ~1.6s or the call cancels.
11. NPCs route around the building using the navigation mesh (no stuck-at-wall behavior during a smoke run). 11. `G` toggles the flashlight (indicator appears in the HUD); `L` toggles the door lock (indicator turns red when locked).
12. When the shift timer reaches 0, the shift review screen shows grade, incident summary, and persisted shift history. 12. `F` toggles the debug overlay with NPC states and shift stats.
13. NPCs route around the building using the navigation mesh (no stuck-at-wall behavior during a smoke run).
14. When the shift timer reaches 0, the shift review screen shows grade, incident summary, and persisted shift history.
## Development Workflow ## Development Workflow
+118 -26
View File
@@ -1,38 +1,130 @@
# TODO # TODO
## 🔴 PRIORITY — Ship downloadable builds (so the README Downloads links work) Godot 4.3 prototype. Release **v0.2.1** is live on the host. The working tree (uncommitted)
has the access-loadout fix and the ADR-0007–0010 control pass; headless suite **77/77** and a
windowed focus/radial smoke pass.
Goal: users hitting the README **Downloads** section can download a playable version from Host `REQUIRE_SIGNIN_VIEW` stays deferred.
`https://git.allucanget.biz/zwitschi/security-game/releases`.
- [x] Install Godot 4.3 export templates locally. ## World and presentation follow-ups (found during the control smoke)
- [x] `./tools/build_releases.sh` produces Linux/Windows/macOS artifacts (built for 0.2.0).
- [x] Linux binary smoke-tested (headless, 0 script errors).
- [ ] Publish the release — **blocked on `GITEA_TOKEN`**: once the token is available run
`GITEA_TOKEN=... ./tools/publish_release.sh 0.2.0`, or (if the repo secret is already set
on the host) push the `v0.2.0` tag and let CI publish via `.gitea/workflows/build.yml`.
- [ ] Confirm the README Downloads links resolve: check
`https://git.allucanget.biz/zwitschi/security-game/releases` and download one artifact.
## Future Systems (design-level backlog) - [ ] The skyline backdrop was removed: `assets/textures/ai/game_ready/skyline.png` has an
opaque black background, so the planes rendered as black slabs with window dots. Re-add
the backdrop only with a transparent asset or a keyed shader.
- [ ] Guard settles ~0.5 m above the ground after spawning on the landing. Check the capsule
offset and the spawn `y`, and verify eye height while walking the steps.
- [ ] Steps and the landing are visual-only (no collider). The guard walks through them; add
simple step colliders or restage the landing so it does not intersect the walk path.
- [ ] Marquee text (`STEP LINE`) is tiny from the sidewalk. Scale the Label3D up.
- [ ] Focus ring and name tag are small at 2–3 m. Consider a larger torus / font after a
real playtest.
- [ ] NPC crowd cap (`MAX_LIVE_NPCS`) silently drops routine traffic at 16. Add a short
despawn/lifetime for ordinary guests so the entrance does not fill up with standers.
- [ ] Body-camera replay timeline in the shift review. ## Session shell
- [ ] Long (10-15 minute) shift lengths (phase lighting already implemented).
- [ ] Venue selection startup menu (keyboard cycling with `V` is implemented). The game boots into a live 900 s shift. `Save` declares `settings.json` and never uses it.
- [ ] IDE/editor-based GUT integration when a Godot editor workflow is introduced. `V` cycles venues by restarting the shift.
- [ ] Reduce RefCounted reference cycles (NPCController <-> FSM) noted in `MEMORY.md`.
- [ ] Startup menu: pick venue, start shift, quit. Keyboard + click. Replaces `V` as the
player-facing path (`V` may stay as a debug bind).
- [ ] Pause overlay when `Esc` releases the mouse (or a dedicated pause key): resume, restart
shift, settings, quit to menu.
- [ ] Settings: mouse look sensitivity, persist through `Save` / `settings.json`. Load on boot.
- [ ] Shift review "Run another shift" returns to the venue menu, not a silent restart of the
same scenario.
## Remaining PLAN verbs (radial exists)
- [ ] Eye contact as a Level 1 action (PLAN / glossary). Distinct from raise-hand. Add a
radial slice if it ships.
- [ ] "I recognize this person" is a player decision recorded as evidence, not a pre-attached
artifact on every staff/guest type. Staff still auto-recognize when `override_access` is
empty (`AccessLoadout`).
- [ ] Formal warning can activate the body-camera / security-light flag (PLAN Level 2). Overlay
is currently always on; toggling should write an incident event.
- [ ] Blocking stations the guard between the person and the door (PLAN Level 3). Today it only
sends `apply_player_action(BLOCKING)`.
- [ ] "Request reason for entry" as a Level 3 ask, with a short NPC reply from `dialogue_lines`.
## Crowd presentation
`CrowdSystem` is a mood/attention float. PLAN wants people watching, gathering, and panicking.
- [ ] On `aggressive_behavior` / `emergency_responder`, nearby idle NPCs stop and face the
incident (rubberneck). On `crowd_panic`, they move away from the door.
- [ ] Profile a busy entrance with textured materials and props; add LOD or culling for distant
NPCs if frame time drops.
## Emergency aftermath
Responder outcomes (`command` / `restraint` / `evacuation` / `arrest`) are log lines. PLAN also
wants evacuation, a closed street, and an investigation when the call was unjustified.
- [ ] `evacuation` outcome actually clears the entrance (NPCs leave, door stays locked).
- [ ] Unjustified emergency (low danger, no weapon) writes an investigation note on the shift
review and applies the existing false-report / reputation hit.
- [ ] Review copy names the responder outcome, not only battery and crowd %.
## Body-camera replay
- [ ] Shift review: a timeline of recorded incident events (ADR-0005 already stores them).
Scrub or step through; no live 3D replay required in the first version.
## NPC theater
Intents `sit` / `photo` / `film` / `perform` already pick waypoints. ADR-0010 rig (face/hair)
is in the working tree. Bodies still idle-stand at those spots.
- [ ] Sit pose at `steps_end`. Photo: brief raise of the influencer/tourist prop. Performer
stays put and plays the hat/sign.
## Audio
`AudioFeedback` is sine beeps. DONE called that "crowd ambience"; it is not.
- [ ] Loop a low crowd bed that rises with `CrowdSystem.mood`.
- [ ] Radio open/close crackle on the existing radio panel, still headless-safe (no-op without
an audio server).
## Accessibility
- [ ] Escalation colors that still read in grayscale (shape or label, not hue alone).
- [ ] HUD font scale in settings.
## Scenario content still missing vs PLAN lists
- [ ] Joke Shop: guest on the reservation list without ID; borrowed/mismatched ID; guest
refuses wand screening; late arrival during a performance.
- [ ] Studio: filming-boundary repeat (influencer ignores a posted line); celebrity-arrival
crowd spike (more rubberneckers, not only one `arrive` spawn).
- [ ] Building: "activate body camera" and "request reason" once those verbs exist, used in
at least one authored event.
## Documentation drift
- [x] README controls table, smoke checklist, test count, and implemented/not-yet lists
refreshed for the control pass (77 tests).
- [ ] `PLAN.md` Tech Stack / Planned Commands still say GUT and GitHub Actions; the project
uses `src/test/test_runner.gd` and `.gitea/workflows/build.yml`.
- [x] MEMORY: door_staff_lines log through `GameManager`; 300 s default removed.
## Distribution follow-up (host config, deferred)
Verified via API+host credentials (2026-10-31):
- [x] Repo is **public** (`private:false`) and account has admin rights.
- [x] Release `v0.2.1` now carries all five artifacts (portable binaries + NSIS installer +
AppImage).
- [ ] **Remaining host setting**: instance-wide `[service] REQUIRE_SIGNIN_VIEW=true` makes
anonymous browsing/downloads redirect to login. Host admin: `REQUIRE_SIGNIN_VIEW = false`
under `[service]` in `app.ini`, restart Gitea. Alternative: mirror downloads.
## Open Polish ## Open Polish
- [ ] Add a body/wand cooldown feel: wand screening currently has no reuse penalty; consider a
short per-guest scan animation.
- [ ] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting). - [ ] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting).
## Detailed Graphics — remaining performance items ## Tooling (when an editor workflow exists)
The visual pass is done (textures, rigs, HUD feedback — see `DONE.md`). Remaining work is - [ ] IDE/editor-based GUT integration. Until then the custom headless runner stays.
performance hardening only. - [ ] Optional later: author venues as `.tres` resources instead of `ScenarioFactory` functions.
- [ ] Profile the crowd: ensure textured materials and props hold target frame rate around a
busy entrance.
- [ ] Add LOD or culling for distant NPCs/props if the crowd grows.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 929 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 927 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 921 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 801 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+13 -3
View File
@@ -1,12 +1,21 @@
{ {
"hud.controls": "Q polite direction · 1-3 simple gestures · 4 warning · 5 block · 6 assist · R emergency · E inspect / close · W wand · A allow · D deny · V venue · G flashlight · L lock door · F debug", "hud.controls": "LMB/Space action menu · A/D or arrows highlight · Space confirm · Esc/RMB cancel · R emergency · E inspect · C wand · Q polite · 1-6 shortcuts · Tab cycle · G light · L lock · V venue · F debug",
"radial.raise_hand": "Stop",
"radial.point": "Point",
"radial.head_shake": "No",
"radial.polite": "Ask",
"radial.inspect": "Inspect",
"radial.wand": "Wand",
"radial.warning": "Warn",
"radial.block": "Block",
"radial.radio": "Radio",
"hud.escalation_prefix": "Escalation: %s", "hud.escalation_prefix": "Escalation: %s",
"hud.cooldown": "EMERGENCY RADIO COOLDOWN: %.0fs", "hud.cooldown": "EMERGENCY RADIO COOLDOWN: %.0fs",
"hud.aim_first": "Aim at someone first.", "hud.aim_first": "Aim at someone first.",
"hud.no_one_nearby": "No one nearby.", "hud.no_one_nearby": "No one nearby.",
"hud.targeting": "Targeting %s (Tab to cycle).", "hud.targeting": "Targeting %s (Tab to cycle).",
"hud.no_wand": "No wand at this venue.", "hud.no_wand": "No wand at this venue.",
"hud.screen_first": "Screen this guest with the wand (W) before admitting them.", "hud.screen_first": "Screen this guest with the wand (C) before admitting them.",
"access.granted": "Entry granted: %s", "access.granted": "Entry granted: %s",
"access.denied": "Entry denied: %s", "access.denied": "Entry denied: %s",
"access.policy_satisfied": "policy satisfied", "access.policy_satisfied": "policy satisfied",
@@ -40,5 +49,6 @@
"door.thwarted": "%s tries the locked door and fails.", "door.thwarted": "%s tries the locked door and fails.",
"flashlight.on": "Flashlight on.", "flashlight.on": "Flashlight on.",
"flashlight.off": "Flashlight off.", "flashlight.off": "Flashlight off.",
"hud.venue_switched": "Venue: %s" "hud.venue_switched": "Venue: %s",
"wand.recharge": "Wand scanner is recharging…"
} }
+23 -6
View File
@@ -57,12 +57,22 @@ godot --headless --path . --quit-after 120 # boot smoke t
./tools/build_releases.sh ./tools/build_releases.sh
``` ```
Artifacts are written to `build/`: Artifacts are written to `build/` (all exports embed the PCK — single files):
- `build/step-line-linux-<version>.x86_64` - `build/step-line-linux-<version>.x86_64`
- `build/step-line-windows-<version>.exe` - `build/step-line-windows-<version>.exe`
- `build/step-line-macos-<version>.zip` - `build/step-line-macos-<version>.zip`
When run in CI (tags/dispatch), the build job also packages:
- `build/step-line-setup-<version>.exe` — Windows installer (NSIS, incl. shortcuts +
uninstaller; apt `nsis` on the runner)
- `build/step-line-<version>-x86_64.AppImage` — Linux AppImage (`packaging/linux/build_appimage.sh`,
appimagetool via `APPIMAGE_EXTRACT_AND_RUN`)
Locally you can build the AppImage with `bash packaging/linux/build_appimage.sh <version>`;
the NSIS installer requires `makensis` (covered by CI).
> macOS builds are unsigned and will need `xattr -dr com.apple.quarantine` > macOS builds are unsigned and will need `xattr -dr com.apple.quarantine`
> guidance or notarization for production releases. > guidance or notarization for production releases.
@@ -70,15 +80,22 @@ Artifacts are written to `build/`:
The repository lives at `https://git.allucanget.biz/zwitschi/security-game`. The repository lives at `https://git.allucanget.biz/zwitschi/security-game`.
Preferred path — use the helper script with a token: Preferred path — use the helper script. It needs either a token or the same
credentials used by `git push` (it falls back to `~/.git-credentials`):
```bash ```bash
GITEA_TOKEN=your_api_token ./tools/publish_release.sh 0.1.0 GITEA_TOKEN=your_api_token ./tools/publish_release.sh 0.2.1
# or simply: ./tools/publish_release.sh 0.2.1 (uses ~/.git-credentials)
``` ```
The script creates the release from `project.godot`'s `config/version`, uploads The script creates (or reuses) the release from `project.godot`'s
all three `build/` artifacts, and prints the release URL. The token needs only `config/version`, uploads the three portable artifacts **plus** the NSIS
`write:repository` scope on the Gitea host. installer and AppImage when present, and prints the release URL. If CI already
published the release, re-running the script only adds missing assets.
> **Host note**: the release is fully built by CI, but anonymous users can only
> browse and download when the instance sets `[service] REQUIRE_SIGNIN_VIEW=false`
> in `app.ini` (the repo itself is public). See the TODO for the exact steps.
Manual alternative: Manual alternative:
@@ -0,0 +1,42 @@
# ADR-0007: Locomotion and input layers
- **Status:** Accepted
- **Date:** TBD
## Context
The prototype is first-person: WASD walks, mouse looks, `Esc` toggles capture. Action keys were then bound on the same letters. `W` is walk-forward in the input map and wand screening in `GameManager._handle_key`. `A` / `D` strafe and also allow or deny during inspection. `Q` is polite direction and is also bound as `cancel`.
A player who walks toward a guest on a wand venue therefore fires the wand. A player who strafes during an inspection can admit or refuse by accident. The mouse cannot click UI while it is captured, and `Esc` cannot mean both "release the mouse" and "I changed my mind."
## Decision
Treat input as layers, only one of which owns a key at a time.
1. **Locomotion** (default): WASD walk relative to body yaw, mouse look on the captured cursor, pitch clamped. Short acceleration and deceleration. Gravity only when airborne; snap to the floor so the steps work. Walk speed stays around 3.2 m/s. No sprint or crouch in this pass.
2. **Modal UI** (radial, inspect, wand, radio, shift review): freeze walk and look. Recapture the mouse when the modal closes. `A` / `D` and arrows belong to the modal while it is open.
3. **Equipment and global keys** stay off the person-verb set: `G` flashlight, `L` door lock, `Tab` cycle nearby people, `R` emergency radio, `F` debug, `V` venue (debug). Wand leaves `W`; the optional shortcut is `C`.
`Esc` closes the top modal first and does nothing else. With no modal open it keeps the capture/release toggle.
## Consequences
### Positive
- Walking cannot fire wand, inspect, or admission.
- The mouse can click the action menu because capture is released while it is open.
- Cancel has one meaning when a menu is up.
- Locomotion stays first-person, which matches the body-camera framing.
### Negative
- Freeze-while-modal means the player cannot sidestep during an inspect. That is accepted for this pass.
- Power users lose `W` as wand. The wheel and `C` replace it.
- The input map and `_unhandled_input` must agree, or the conflict returns.
## Alternatives considered
- **Keep letter-key verbs on WASD:** Rejected because walk and wand already collide in the prototype.
- **Third-person or click-to-move:** Rejected; the body-camera overlay and the "you are the guard" framing depend on first person.
- **Hold-right-mouse-to-look, free cursor otherwise:** Rejected for this pass. Constant look is the default security-sim stance; the menu is the exception.
- **Never freeze walk:** Rejected because `A` / `D` cannot both strafe and step a menu highlight.
+46
View File
@@ -0,0 +1,46 @@
# ADR-0008: Cone focus and head ring
- **Status:** Accepted
- **Date:** TBD
## Context
The prototype picks a person with an 8 m physics ray from the camera. The collider is a capsule. A few pixels off the body, or a bob during a walk, clears the target. The HUD then prints a name and hint, but nothing in the world shows who is selected. `Tab` already cycles people within 12 m as an aim-free path.
The next pass needs the player to look at a person, know they have them, and only then open an action menu. A hairline ray is too brittle for that, and a HUD line is easy to miss in a crowd.
## Decision
Focus is a **cone**, not a ray.
- People within about 10 m whose **head** projects inside a screen-space circle around the crosshair (about 90 px / 10 degrees) are candidates.
- If several heads sit in the circle, pick the one closest to the center; if tied, the nearer one.
- The person must be in front of the camera. A short ray to the head skips people fully hidden by the building.
- A few frames of grace keep walk-bob from dropping focus.
- `Tab` still cycles people inside 12 m and sets the same focused person.
When someone is in focus, draw a **thin ring** around their head, attached to the avatar `Head` node, billboarded so it stays a circle on screen. Pale gold while idle; brighter and slightly larger while the action menu is open on that person. Optional `display_name` under the ring. One ring at a time.
The world must not show the internal type name (`Trespasser`, `Violent Threat`). The debug overlay (`F`) can still print it.
## Consequences
### Positive
- Looking near a person is enough; the player does not have to pin a capsule.
- The ring is visible in the scene, so the HUD is not the only focus cue.
- `Tab` remains the accessibility path and shares the same focused person.
- The action menu (ADR-0009) has a clear subject.
### Negative
- Two heads in the same circle can steal focus; center-distance is a heuristic, not a lock-on.
- Hats and protester signs can z-fight a 3D torus. A screen-space circle unprojected from the head is an allowed fallback.
- Occlusion rays can fail on unusual geometry; grace frames hide some of that.
## Alternatives considered
- **Keep the hairline ray:** Rejected because it drops people too easily and has no world mark.
- **Sticky lock-on until cancelled:** Rejected because the job is to watch a crowd, not duel one person.
- **Screen reticle only, no world ring:** Rejected; in a crowd the player needs a mark on the head, not only a HUD line.
- **Click a person to select, then act:** Rejected for default play. The mouse is captured for look (ADR-0007). Click opens the action menu on whoever is already in the cone.
+60
View File
@@ -0,0 +1,60 @@
# ADR-0009: Radial action menu
- **Status:** Accepted
- **Date:** TBD
## Context
NPC verbs in the prototype live on `Q`, `E`, `W`, `1`–`6`, and `R`. That is a cheat sheet. It also collides with walk keys (ADR-0007). The design wants the player to look at a person (ADR-0008) and then choose the lowest useful action, not memorize a number row.
Inspection, wand screening, and radio already have their own panels. Emergency radio already has a hold-to-confirm on `R` because a misclick is costly. Those panels should stay. What is missing is a single, visible way to pick the verb.
## Decision
When someone is in focus, **left mouse button** or **Space** opens a round doughnut menu in the center of the screen. The click that opens it does not pick a slice. If nobody is in focus, do not open; keep the existing "aim first" log line.
Slices, clockwise from the top, lowest escalation first:
1. Raise hand (stop / wait)
2. Point away
3. Head shake
4. Polite direction
5. Inspect access
6. Wand (grey if the venue has no wand)
7. Formal warning
8. Block
9. Radio assist
Emergency is **not** on the wheel. It stays on `R` with the existing hold-to-confirm panel.
While the menu is open, walk and look freeze (ADR-0007) and the cursor is visible. Highlight follows the mouse angle over the ring, or steps with `A` / `D` or the left / right arrows, wrapping around. The center shows the focused person's `display_name`.
**Confirm:** click a slice, or `Space` on the highlighted slice. The menu closes, the mouse is recaptured, and that action runs. Inspect, wand, and radio assist close the wheel and then open the existing panels. Walk and look stay frozen until that panel closes.
**Cancel:** `Esc` or right mouse button. The menu hides. No action runs. No recorder event. Focus and the head ring stay. The mouse is recaptured.
Number keys `1`–`6` may remain as shortcuts when the wheel is closed, so existing smoke tests still work. They must not fire while the wheel is open.
## Consequences
### Positive
- The player sees the verbs in de-escalation order instead of recalling a number row.
- Mouse and keyboard both work (click, or arrows / `A`/`D` then Space).
- Cancel is explicit and leaves no incident noise.
- Emergency cannot be picked by a bad slice click.
- Inspect, wand, and radio keep the panels already tested.
### Negative
- Nine slices is dense on a 1280×720 view. Labels must stay short.
- Freeze-until-panel-closes stops the player sidestepping during an ID check.
- Keeping `1`–`6` as hidden shortcuts can confuse if the README leads with them. Document the wheel first.
## Alternatives considered
- **Hold-to-open, release-to-confirm (GTA / Destiny):** Rejected. The requested input is click or Space to open, then click or Space to confirm, with Esc / RMB to abort. That is a toggle, not a hold.
- **Vertical list / command menu:** Rejected because a round layout around a focused person matches the head ring and reads faster in a crowd.
- **One context-sensitive Use key:** Rejected; the game is about choosing among levels, not a single default verb.
- **Emergency on the wheel:** Rejected because a misclick would dispatch armed response. ADR-0002 already keeps that path rare and confirmed.
- **Drop number-key shortcuts immediately:** Deferred. They remain as a closed-menu fallback until the wheel is proven in play.
@@ -0,0 +1,53 @@
# ADR-0010: Procedural NPC presentation
- **Status:** Accepted
- **Date:** TBD
## Context
Avatars are `CharacterBody3D` capsules color-coded by `NpcType`, with a few props (camera, box, hat, sign, bag), height and brightness variation from a seed, a walk bob, and a head turn toward the guard when noticed. They work as markers. At conversation distance they do not read as people: no neck, no face direction, no shoes, no hair.
The player is supposed to judge from clothes, props, and behavior, not from a label. The HUD currently prints the internal type (`Violent Threat`, `Trespasser`) next to the name. Putting that name on the body would make the judgment for them.
A full Mixamo / GLB pipeline would also add animation imports, licenses, and a skeleton the rest of the sim does not need yet. AI textures in `assets/textures/ai/` are not sized or wired for clothing.
## Decision
Keep the procedural capsule rig for this pass. Do not add skinned meshes or Mixamo.
Make the body read as a person at conversation distance:
- Neck between torso and head.
- Shoes.
- Hair cap whose color comes from the visual seed.
- Two dark eye spheres so the head has a front.
- Named `Head` node (`Rig/Head`) as the attach point for the focus ring (ADR-0008) and the focus cone.
- Collision capsule scaled to the visible height so the cone hits the body the player sees.
Keep archetype props, height / brightness variation, walk bob, and the notice head-turn.
Do **not** put the internal type name on the body or as a world nametag. A focused `display_name` under the ring is allowed. The debug overlay (`F`) may still print type, intent, and FSM state. The HUD target line can keep the type during the prototype; removing it from the HUD is a later readability pass, not this one.
AI clothing textures stay out unless a map is already the right size and is wired on purpose.
## Consequences
### Positive
- People have a face direction the ring and the "noticed the guard" cue can use.
- No new asset toolchain, licenses, or animation retargeting.
- Head is a stable node other systems can find.
- The player is not told `Violent Threat` by a floating label.
### Negative
- Capsule people still look like a prototype at cinematic camera distance.
- Hair and eyes are spheres; they will not survive a close-up trailer.
- A later skinned-mesh pass will replace this rig and must keep the `Head` contract.
## Alternatives considered
- **Skinned Mixamo / GLB characters now:** Rejected. Cost and pipeline exceed this pass; the sim only needs a readable body and a head node.
- **Billboard sprites:** Rejected; first-person movement walks around people, and sprites fail at profile views.
- **Keep colored capsules as-is:** Rejected; the head ring and cone need a real head, and capsules do not read as people at two metres.
- **World labels with archetype names:** Rejected because they leak sim truth and skip observation.
+4
View File
@@ -152,6 +152,10 @@ Architecture decisions are recorded as ADRs:
- [ADR-0004: Local single-player first](adr/0004-local-single-player-first.md) - [ADR-0004: Local single-player first](adr/0004-local-single-player-first.md)
- [ADR-0005: Event-based incident recording](adr/0005-event-based-incident-recording.md) - [ADR-0005: Event-based incident recording](adr/0005-event-based-incident-recording.md)
- [ADR-0006: Godot 4 with GDScript](adr/0006-godot-4-gdscript.md) - [ADR-0006: Godot 4 with GDScript](adr/0006-godot-4-gdscript.md)
- [ADR-0007: Locomotion and input layers](adr/0007-locomotion-and-input-layers.md)
- [ADR-0008: Cone focus and head ring](adr/0008-cone-focus-and-head-ring.md)
- [ADR-0009: Radial action menu](adr/0009-radial-action-menu.md)
- [ADR-0010: Procedural NPC presentation](adr/0010-procedural-npc-presentation.md)
## 10. Quality Requirements ## 10. Quality Requirements
Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 953 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

+3 -3
View File
@@ -23,7 +23,7 @@ script_export_mode=2
custom_template/debug="" custom_template/debug=""
custom_template/release="" custom_template/release=""
debug/export_console_wrapper=1 debug/export_console_wrapper=1
binary_format/embed_pck=false binary_format/embed_pck=true
texture_format/s3tc_bptc=true texture_format/s3tc_bptc=true
texture_format/etc2_astc=false texture_format/etc2_astc=false
binary_format/architecture="x86_64" binary_format/architecture="x86_64"
@@ -54,7 +54,7 @@ script_export_mode=2
custom_template/debug="" custom_template/debug=""
custom_template/release="" custom_template/release=""
debug/export_console_wrapper=0 debug/export_console_wrapper=0
binary_format/embed_pck=false binary_format/embed_pck=true
texture_format/s3tc_bptc=true texture_format/s3tc_bptc=true
texture_format/etc2_astc=false texture_format/etc2_astc=false
codesign/enable=false codesign/enable=false
@@ -90,7 +90,7 @@ script_export_mode=2
custom_template/debug="" custom_template/debug=""
custom_template/release="" custom_template/release=""
debug/export_console_wrapper=0 debug/export_console_wrapper=0
binary_format/embed_pck=false binary_format/embed_pck=true
texture_format/s3tc_bptc=true texture_format/s3tc_bptc=true
texture_format/etc2_astc=true texture_format/etc2_astc=true
codesign/enable=false codesign/enable=false
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
# AppImage entry point: launch the bundled game binary.
SELF="$(readlink -f "$0")"
HERE="$(dirname "$SELF")"
exec "${HERE}/usr/bin/step-line" "$@"
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Build a self-contained Step Line AppImage (all game data included).
#
# Prereqs: exported Linux binary in build/ (embed_pck=true), curl.
# Usage: bash packaging/linux/build_appimage.sh [version]
# Output: build/step-line-<version>-x86_64.AppImage
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$HERE/../.." && pwd)"
VERSION="${1:-$(sed -n 's/.*config\/version="\([^"]*\)".*/\1/p' "$ROOT/project.godot")}"
SRC_BIN="$ROOT/build/step-line-linux-${VERSION}.x86_64"
if [[ ! -f "$SRC_BIN" ]]; then
echo "error: missing $SRC_BIN — run ./tools/build_releases.sh first" >&2
exit 1
fi
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
APPDIR="$WORK/StepLine.AppDir"
mkdir -p "$APPDIR/usr/bin"
cp "$SRC_BIN" "$APPDIR/usr/bin/step-line"
# The export embeds the PCK (embed_pck=true), so no extra data files are needed.
cp "$HERE/step-line.desktop" "$APPDIR/step-line.desktop"
cp "$HERE/AppRun" "$APPDIR/AppRun"
chmod +x "$APPDIR/AppRun"
cp "$HERE/step-line.png" "$APPDIR/step-line.png"
# Desktop entry + metainfo in standard locations (AppStream validation requires
# the metainfo filename to match the component id, and the .desktop discoverable).
mkdir -p "$APPDIR/usr/share/applications" "$APPDIR/usr/share/metainfo"
cp "$HERE/step-line.desktop" "$APPDIR/usr/share/applications/step-line.desktop"
cp "$HERE/step-line.appdata.xml" "$APPDIR/usr/share/metainfo/net.stepline.game.appdata.xml"
TOOL="${APPIMAGE_TOOL:-/tmp/appimagetool-x86_64.AppImage}"
if [[ ! -f "$TOOL" ]]; then
echo "==> Downloading appimagetool"
curl -sL -o "$TOOL" \
https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x "$TOOL"
fi
OUT="$ROOT/build/step-line-${VERSION}-x86_64.AppImage"
echo "==> Building AppImage -> $OUT"
# Extract-and-run avoids a FUSE dependency on the build host.
APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" "$APPDIR" "$OUT"
echo "AppImage ready: $(ls -la "$OUT" | awk '{print $5}') bytes"
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>net.stepline.game</id>
<name>Step Line</name>
<summary>Workplace security simulation on Hollywood Boulevard</summary>
<url type="homepage">https://git.allucanget.biz/zwitschi/security-game</url>
<developer id="net.stepline"><name>Step Line Team</name></developer>
<description>
<p>
Step Line is a workplace security simulation about observation, communication,
access verification, and proportionate escalation. Guard the steps and entrance
of venues around Hollywood Boulevard: direct tourists, verify wristbands and IDs,
screen guests, and escalate only when you have to.
</p>
</description>
<launchable type="desktop-id">step-line.desktop</launchable>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<content_rating type="oars-1.1" />
<releases>
<release version="0.2.0" date="2026-10-31"/>
</releases>
</component>
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Step Line
Comment=Workplace security simulation on Hollywood Boulevard
Exec=step-line
Icon=step-line
Terminal=false
Categories=Game;Simulation;
Keywords=security;simulation;hollywood;
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+74
View File
@@ -0,0 +1,74 @@
; Step Line Windows installer (NSIS 3).
; Build from the repo root with the exported self-contained exe:
; makensis /DVERSION=0.2.0 /DARTIFACT=build/step-line-windows-0.2.0.exe \
; /DICON=packaging/windows/step-line.ico packaging/windows/step-line.nsi
; Outputs: build/step-line-setup-<VERSION>.exe
!ifndef VERSION
!define VERSION "0.0.0"
!endif
!ifndef ARTIFACT
!error "ARTIFACT define required (path to exported windows exe)"
!endif
!ifndef ICON
!define ICON "packaging\windows\step-line.ico"
!endif
; The installed executable uses this clean name (no spaces — NSIS /oname tokens
; on whitespace, so a spaced name breaks the File directive).
!define INSTALLED_EXE "step-line.exe"
!ifndef OUT
!define OUT "build\step-line-setup-${VERSION}.exe"
!endif
!define COMPANY "Step Line Project"
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\Step Line"
Unicode true
ManifestDPIAware true
Icon "${ICON}"
UninstallIcon "${ICON}"
Name "Step Line ${VERSION}"
OutFile "${OUT}"
InstallDir "$PROGRAMFILES64\Step Line"
RequestExecutionLevel admin
Page directory
Page instfiles
UninstPage uninstConfirm
UninstPage instfiles
Section "Install"
SetOutPath "$INSTDIR"
; The self-contained executable contains ALL game data (embedded PCK).
File "/oname=${INSTALLED_EXE}" "${ARTIFACT}"
WriteUninstaller "$INSTDIR\uninstall.exe"
; Shortcuts
CreateDirectory "$SMPROGRAMS\Step Line"
CreateShortCut "$SMPROGRAMS\Step Line\Step Line.lnk" "$INSTDIR\${INSTALLED_EXE}"
CreateShortCut "$SMPROGRAMS\Step Line\Uninstall.lnk" "$INSTDIR\uninstall.exe"
CreateShortCut "$DESKTOP\Step Line.lnk" "$INSTDIR\${INSTALLED_EXE}"
; Registry (Add/Remove Programs)
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "Step Line"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${VERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${COMPANY}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${INSTALLED_EXE}"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" '"$INSTDIR\uninstall.exe"'
WriteRegDWORD HKLM "${UNINST_KEY}" "NoModify" 1
WriteRegDWORD HKLM "${UNINST_KEY}" "NoRepair" 1
SectionEnd
Section "Uninstall"
Delete "$INSTDIR\${INSTALLED_EXE}"
Delete "$INSTDIR\uninstall.exe"
RMDir "$INSTDIR"
Delete "$SMPROGRAMS\Step Line\Step Line.lnk"
Delete "$SMPROGRAMS\Step Line\Uninstall.lnk"
RMDir "$SMPROGRAMS\Step Line"
Delete "$DESKTOP\Step Line.lnk"
DeleteRegKey HKLM "${UNINST_KEY}"
SectionEnd
+30 -15
View File
@@ -8,7 +8,7 @@ config_version=5
config/name="Step Line" config/name="Step Line"
config/description="Workplace security simulation about observation, access verification, and proportionate escalation." config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
config/version="0.2.0" config/version="0.2.1"
run/main_scene="res://scenes/main.tscn" run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.3", "GL Compatibility") config/features=PackedStringArray("4.3", "GL Compatibility")
@@ -47,18 +47,6 @@ move_right={
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194321,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) , Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194321,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
] ]
} }
interact={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null)
]
}
cancel={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":81,"key_label":0,"unicode":113,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
gesture_raise_hand={ gesture_raise_hand={
"deadzone": 0.5, "deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":49,"key_label":0,"unicode":49,"location":0,"echo":false,"script":null) "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":49,"key_label":0,"unicode":49,"location":0,"echo":false,"script":null)
@@ -91,8 +79,35 @@ radio_assist={
} }
radio_emergency={ radio_emergency={
"deadzone": 0.5, "deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":87,"location":0,"echo":false,"script":null) "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":114,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":114,"location":0,"echo":false,"script":null) ]
}
action_menu={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null)
]
}
confirm={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194309,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
cancel={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":2,"canceled":false,"pressed":false,"double_click":false,"script":null)
]
}
inspect={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
]
}
wand={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":67,"key_label":0,"unicode":99,"location":0,"echo":false,"script":null)
] ]
} }
print_debug={ print_debug={
+1
View File
@@ -0,0 +1 @@
Pillow>=9.2
+1
View File
@@ -0,0 +1 @@
appimagetool.png
+10
View File
@@ -0,0 +1,10 @@
#! /bin/sh
set -e
this_dir="$(dirname -- "$(readlink -f -- "$0")")"
# make appimagetool prefer the bundled mksquashfs
export PATH="$this_dir"/usr/bin:"$PATH"
exec "$this_dir"/usr/bin/appimagetool "$@"
+1
View File
@@ -0,0 +1 @@
usr/share/applications/appimagetool.desktop
+1
View File
@@ -0,0 +1 @@
usr/share/icons/hicolor/128x128/apps/appimagetool.png
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=appimagetool
Exec=appimagetool
Comment=Tool to generate AppImages from AppDirs
Icon=appimagetool
Categories=Development;
Terminal=true
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop">
<id>appimagetool.desktop</id>
<metadata_license>MIT</metadata_license>
<project_license>MIT</project_license>
<name>appimagetool</name>
<summary>Create AppImage files</summary>
<description>
<p>
appimagetool is a tool that lets you generate AppImage files. An AppImage
is a self-running bundle that contains an application and everything
it needs to run that cannot reasonably expected to be part of each target system.
</p>
<p>
This can include libraries that are not commonly available on target systems,
resources such as translations, icons, fonts, and other auxiliary files.
appimagetool makes it easy to take an AppDir (e.g., generated by linuxdeployqt)
and turn it into an AppImage.
</p>
<p>
You can also embed update information so that your users can update the AppImage
using binary delta updates with the AppImageUpdate tool.
You can also sign the AppImage with GPG.
</p>
</description>
<url type="homepage">https://github.com/AppImage/AppImageKit</url>
<project_group>AppImage</project_group>
<provides>
<binary>appimagetool</binary>
</provides>
</component>
+22 -5
View File
@@ -393,6 +393,8 @@ static func joke_shop() -> VenueScenario:
"guard_post": Vector3(0, 0, -3), "guard_post": Vector3(0, 0, -3),
"entrance": Vector3(0, 0, 1.0), "entrance": Vector3(0, 0, 1.0),
"door_area": Vector3(0, 0, 3), "door_area": Vector3(0, 0, 3),
"steps_end": Vector3(0, 0, 3),
"sidewalk": Vector3(0, 0, 9),
"queue_area": Vector3(0, 0, 9), "queue_area": Vector3(0, 0, 9),
} }
scenario.door_staff_lines = { scenario.door_staff_lines = {
@@ -433,7 +435,7 @@ static func joke_shop() -> VenueScenario:
scenario.event_pool = [ scenario.event_pool = [
_joke_guest("guest_valid", true, true, Vector2(0, 900), 0.30), _joke_guest("guest_valid", true, true, Vector2(0, 900), 0.30),
_joke_guest("guest_no_ticket", false, true, Vector2(0, 900), 0.18), _joke_guest("guest_no_ticket", false, true, Vector2(0, 900), 0.18),
_joke_guest("guest_expired_id", true, true, Vector2(0, 900), 0.12), _joke_guest("guest_expired_id", true, true, Vector2(0, 900), 0.12, {"id_status": Rules.ArtifactStatus.EXPIRED}),
_joke_guest("guest_no_id", true, false, Vector2(0, 900), 0.12), _joke_guest("guest_no_id", true, false, Vector2(0, 900), 0.12),
_joke_staff_entry(Vector2(0, 900), 0.25), _joke_staff_entry(Vector2(0, 900), 0.25),
_joke_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.15), _joke_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.15),
@@ -475,6 +477,8 @@ static func tv_studio() -> VenueScenario:
"guard_post": Vector3(0, 0, -3), "guard_post": Vector3(0, 0, -3),
"entrance": Vector3(0, 0, 1.0), "entrance": Vector3(0, 0, 1.0),
"stage_door": Vector3(6, 0, -2), "stage_door": Vector3(6, 0, -2),
"steps_end": Vector3(0, 0, 3),
"sidewalk": Vector3(0, 0, 9),
"queue_area": Vector3(0, 0, 9), "queue_area": Vector3(0, 0, 9),
} }
@@ -492,9 +496,9 @@ static func tv_studio() -> VenueScenario:
]) ])
scenario.event_pool = [ scenario.event_pool = [
_studio_routine("audience_ticket", Rules.NpcType.AUTHORIZED_GUEST, "enter", Vector2(0, 900), 0.35), _studio_guest("audience_ticket", {"has_ticket": true}, Vector2(0, 900), 0.35),
_studio_routine("protest_outside", Rules.NpcType.PROTESTER, "protest", Vector2(300, 900), 0.18), _studio_routine("protest_outside", Rules.NpcType.PROTESTER, "protest", Vector2(300, 900), 0.18),
_studio_routine("celebrity_arrival", Rules.NpcType.AUTHORIZED_GUEST, "arrive", Vector2(300, 700), 0.20), _studio_guest("celebrity_arrival", {"has_guest_list": true}, Vector2(300, 700), 0.20, "arrive"),
_studio_routine("crew_shift", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 700), 0.30), _studio_routine("crew_shift", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 700), 0.30),
_studio_routine("tourist_photo", Rules.NpcType.TOURIST, "photo", Vector2(0, 900), 0.35), _studio_routine("tourist_photo", Rules.NpcType.TOURIST, "photo", Vector2(0, 900), 0.35),
_studio_routine("confused_enter", Rules.NpcType.CONFUSED_VISITOR, "enter", Vector2(0, 900), 0.25), _studio_routine("confused_enter", Rules.NpcType.CONFUSED_VISITOR, "enter", Vector2(0, 900), 0.25),
@@ -509,6 +513,7 @@ static func tv_studio() -> VenueScenario:
"time_range_s": Vector2(620, 780), "time_range_s": Vector2(620, 780),
"probability": 0.55, "probability": 0.55,
"base_weight": 1.0, "base_weight": 1.0,
"override_access": {"wristband_status": Rules.ArtifactStatus.COUNTERFEIT, "credential_status": Rules.ArtifactStatus.COUNTERFEIT},
}, },
{ {
"id": "studio_follow_through", "id": "studio_follow_through",
@@ -516,6 +521,7 @@ static func tv_studio() -> VenueScenario:
"kind": "scripted", "kind": "scripted",
"npc_type": Rules.NpcType.TRESPASSER, "npc_type": Rules.NpcType.TRESPASSER,
"intent": "follow_authorized", "intent": "follow_authorized",
"override_access": {"has_ticket": false, "has_wristband": false, "has_credential": false, "has_guest_list": false},
"time_range_s": Vector2(700, 860), "time_range_s": Vector2(700, 860),
"probability": 0.45, "probability": 0.45,
"base_weight": 1.0, "base_weight": 1.0,
@@ -549,12 +555,23 @@ static func _building_routine(id: String, npc_type: int, intent: String, time_ra
"time_range_s": time_range, "probability": prob, "base_weight": 1.0, "time_range_s": time_range, "probability": prob, "base_weight": 1.0,
} }
static func _joke_guest(id: String, has_ticket: bool, has_id: bool, time_range: Vector2, prob: float) -> Dictionary: static func _joke_guest(id: String, has_ticket: bool, has_id: bool, time_range: Vector2, prob: float, extra: Dictionary = {}) -> Dictionary:
var override := {"has_ticket": has_ticket, "has_id": has_id}
for key in extra.keys():
override[key] = extra[key]
return { return {
"id": id, "phase": _phase_for_range(time_range, 900.0), "id": id, "phase": _phase_for_range(time_range, 900.0),
"kind": "routine", "npc_type": Rules.NpcType.AUTHORIZED_GUEST, "intent": "enter", "kind": "routine", "npc_type": Rules.NpcType.AUTHORIZED_GUEST, "intent": "enter",
"time_range_s": time_range, "probability": prob, "base_weight": 1.0, "time_range_s": time_range, "probability": prob, "base_weight": 1.0,
"override_access": {"has_ticket": has_ticket, "has_id": has_id}, "override_access": override,
}
static func _studio_guest(id: String, override_access: Dictionary, time_range: Vector2, prob: float, intent: String = "enter") -> Dictionary:
return {
"id": id, "phase": _phase_for_range(time_range, 900.0),
"kind": "routine", "npc_type": Rules.NpcType.AUTHORIZED_GUEST, "intent": intent,
"time_range_s": time_range, "probability": prob, "base_weight": 1.0,
"override_access": override_access,
} }
static func _joke_staff_entry(time_range: Vector2, prob: float) -> Dictionary: static func _joke_staff_entry(time_range: Vector2, prob: float) -> Dictionary:
+134
View File
@@ -0,0 +1,134 @@
extends RefCounted
## Builds the access artifacts a person actually presents.
## Event `override_access` wins; otherwise a venue-appropriate default from
## type + policy. Recognition is never attached to every guest.
const Rules := preload("res://src/core/rules.gd")
const AccessArtifact := preload("res://src/core/access_artifact.gd")
const AccessPolicy := preload("res://src/core/access_policy.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd")
static func build(profile: NpcProfile, policy: AccessPolicy, override: Dictionary = {}) -> Array:
var npc_type: int = profile.npc_type if profile else Rules.NpcType.TOURIST
var out: Array = []
if override.get("recognized", false) or (override.is_empty() and npc_type == Rules.NpcType.AUTHORIZED_STAFF):
out.append(AccessArtifact.new(Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.VALID, {"known": true}))
if not override.is_empty():
_append_override(out, profile, override)
return out
if policy != null and (policy.require_id or policy.requires(Rules.AccessKind.ID)):
_append_id_ticket_defaults(out, profile, npc_type, policy)
return out
if policy != null and policy.grant_if_any_required:
_append_or_defaults(out, npc_type)
return out
_append_wristband_defaults(out, npc_type)
return out
static func roll_carried_items(seed_value: int, override: Dictionary = {}) -> Array:
if override.has("carried_items"):
return override["carried_items"]
var rng := RandomNumberGenerator.new()
rng.seed = seed_value if seed_value != 0 else 1
var roll := rng.randf()
if roll < 0.08:
return ["keys", "knife"]
if roll < 0.40:
return ["keys"]
if roll < 0.62:
return ["phone"]
if roll < 0.80:
return ["belt"]
return []
static func _append_override(out: Array, profile: NpcProfile, override: Dictionary) -> void:
if override.has("has_id") or override.has("id_status"):
var has_id := bool(override.get("has_id", true))
var status: int = int(override.get("id_status", Rules.ArtifactStatus.VALID if has_id else Rules.ArtifactStatus.MISSING))
if not has_id:
status = Rules.ArtifactStatus.MISSING
var attrs := {}
if status != Rules.ArtifactStatus.MISSING:
attrs["age"] = int(override.get("age", 24))
attrs["name"] = profile.display_name if profile else "Guest"
out.append(AccessArtifact.new(Rules.AccessKind.ID, status, attrs))
if override.has("has_ticket") or override.has("ticket_status"):
var has_ticket := bool(override.get("has_ticket", true))
var status: int = int(override.get("ticket_status", Rules.ArtifactStatus.VALID if has_ticket else Rules.ArtifactStatus.MISSING))
if not has_ticket:
status = Rules.ArtifactStatus.MISSING
out.append(AccessArtifact.new(Rules.AccessKind.TICKET, status, {}))
if override.has("has_wristband") or override.has("wristband_status"):
var status := _present_status(override, "has_wristband", "wristband_status")
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, status, _band_attrs(status)))
if override.has("has_credential") or override.has("credential_status"):
var status := _present_status(override, "has_credential", "credential_status")
out.append(AccessArtifact.new(Rules.AccessKind.CREDENTIAL, status, {"role": "staff"}))
if override.has("has_guest_list") or override.has("guest_list_status"):
var status := _present_status(override, "has_guest_list", "guest_list_status")
out.append(AccessArtifact.new(Rules.AccessKind.GUEST_LIST, status, {}))
static func _present_status(override: Dictionary, has_key: String, status_key: String) -> int:
if override.has(status_key):
return int(override[status_key])
return Rules.ArtifactStatus.VALID if bool(override.get(has_key, false)) else Rules.ArtifactStatus.MISSING
static func _append_id_ticket_defaults(out: Array, profile: NpcProfile, npc_type: int, policy: AccessPolicy) -> void:
if npc_type == Rules.NpcType.AUTHORIZED_STAFF:
return
if npc_type == Rules.NpcType.AUTHORIZED_GUEST:
out.append(AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.VALID, {
"age": 24, "name": profile.display_name if profile else "Guest",
}))
if policy.requires(Rules.AccessKind.TICKET):
out.append(AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.VALID, {}))
return
out.append(AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.MISSING, {}))
if policy.requires(Rules.AccessKind.TICKET):
out.append(AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.MISSING, {}))
static func _append_or_defaults(out: Array, npc_type: int) -> void:
match npc_type:
Rules.NpcType.AUTHORIZED_STAFF, Rules.NpcType.DELIVERY:
if not _has_kind(out, Rules.AccessKind.RECOGNITION):
out.append(AccessArtifact.new(Rules.AccessKind.CREDENTIAL, Rules.ArtifactStatus.VALID, {"role": "staff"}))
Rules.NpcType.AUTHORIZED_GUEST:
out.append(AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.VALID, {}))
Rules.NpcType.TRESPASSER:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, _band_attrs(Rules.ArtifactStatus.COUNTERFEIT)))
Rules.NpcType.CONFUSED_VISITOR:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.AMBIGUOUS, _band_attrs(Rules.ArtifactStatus.AMBIGUOUS)))
_:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {}))
static func _append_wristband_defaults(out: Array, npc_type: int) -> void:
match npc_type:
Rules.NpcType.AUTHORIZED_STAFF:
pass
Rules.NpcType.AUTHORIZED_GUEST, Rules.NpcType.DELIVERY:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.VALID, _band_attrs(Rules.ArtifactStatus.VALID)))
if npc_type == Rules.NpcType.DELIVERY:
out.append(AccessArtifact.new(Rules.AccessKind.CREDENTIAL, Rules.ArtifactStatus.VALID, {"role": "delivery"}))
Rules.NpcType.TRESPASSER:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, _band_attrs(Rules.ArtifactStatus.COUNTERFEIT)))
Rules.NpcType.CONFUSED_VISITOR:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.AMBIGUOUS, _band_attrs(Rules.ArtifactStatus.AMBIGUOUS)))
_:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {}))
static func _band_attrs(status: int) -> Dictionary:
match status:
Rules.ArtifactStatus.VALID:
return {"color": "red", "date": "2026-02-13", "event": "gala"}
Rules.ArtifactStatus.COUNTERFEIT:
return {"color": "red", "date": "2024-01-01"}
Rules.ArtifactStatus.AMBIGUOUS:
return {"color": "gold"}
_:
return {}
static func _has_kind(artifacts: Array, kind: int) -> bool:
for artifact in artifacts:
if artifact is AccessArtifact and artifact.kind == kind:
return true
return false
+2 -2
View File
@@ -15,7 +15,7 @@ func _init() -> void:
pass pass
## Verify a single person's set of artifacts (person_artifacts: Array[AccessArtifact]). ## Verify a single person's set of artifacts (person_artifacts: Array[AccessArtifact]).
func verify(person_artifacts: Array, policy: AccessPolicy) -> Dictionary: func verify(person_artifacts: Array, policy: AccessPolicy, already_screened: bool = false) -> Dictionary:
var reasons: Array[String] = [] var reasons: Array[String] = []
var issues: Array[String] = [] var issues: Array[String] = []
var checked: Array[String] = [] var checked: Array[String] = []
@@ -48,7 +48,7 @@ func verify(person_artifacts: Array, policy: AccessPolicy) -> Dictionary:
# Wand screening is processed by the interaction layer (false alarms), but a # Wand screening is processed by the interaction layer (false alarms), but a
# policy can hard-require it; a refused screen blocks entry. # policy can hard-require it; a refused screen blocks entry.
if policy.wand_screening_required and not person_screened(person_artifacts): if policy.wand_screening_required and not already_screened and not person_screened(person_artifacts):
issues.append("Screening required") issues.append("Screening required")
return _result(false, reasons, issues, checked) return _result(false, reasons, issues, checked)
+17 -6
View File
@@ -2,15 +2,26 @@ class_name FSM
extends RefCounted extends RefCounted
## Minimal generic finite state machine used by NPCController and ShiftCoordinator. ## Minimal generic finite state machine used by NPCController and ShiftCoordinator.
## Deterministic and headless-testable. ## Deterministic and headless-testable.
##
## The owner is held through a WeakRef so long-lived owners do not create a
## reference cycle (FSM <-> owner), which previously leaked RefCounted objects
## at process exit (see MEMORY.md).
signal state_changed(node: Object, from_state: String, to_state: String) signal state_changed(node: Object, from_state: String, to_state: String)
var current_state: String = "" var current_state: String = ""
var _transitions: Dictionary = {} # state_name -> Array[{to, guard: Callable}] var _transitions: Dictionary = {} # state_name -> Array[{to, guard: Callable}]
var _owner_node: Object = null var _owner_ref: WeakRef = null
func _init(owner_node: Object = null) -> void: func _init(owner_node: Object = null) -> void:
_owner_node = owner_node if owner_node != null:
_owner_ref = weakref(owner_node)
## The live owner object, or null if it was freed.
func _owner() -> Object:
if _owner_ref == null:
return null
return _owner_ref.get_ref() as Object
## Register a transition. guard is an optional Callable(node) -> bool. ## Register a transition. guard is an optional Callable(node) -> bool.
func add_transition(from_state: String, to_state: String, guard: Callable = Callable()) -> void: func add_transition(from_state: String, to_state: String, guard: Callable = Callable()) -> void:
@@ -20,7 +31,7 @@ func add_transition(from_state: String, to_state: String, guard: Callable = Call
func start(initial_state: String) -> void: func start(initial_state: String) -> void:
current_state = initial_state current_state = initial_state
state_changed.emit(_owner_node, "", initial_state) state_changed.emit(_owner(), "", initial_state)
## Attempt to move to `to_state`. Returns true when executed. ## Attempt to move to `to_state`. Returns true when executed.
func transition_to(to_state: String) -> bool: func transition_to(to_state: String) -> bool:
@@ -31,10 +42,10 @@ func transition_to(to_state: String) -> bool:
if t["to"] != to_state: if t["to"] != to_state:
continue continue
var guard: Callable = t["guard"] var guard: Callable = t["guard"]
if not guard.is_valid() or guard.call(_owner_node): if not guard.is_valid() or guard.call(_owner()):
var from := current_state var from := current_state
current_state = to_state current_state = to_state
state_changed.emit(_owner_node, from, to_state) state_changed.emit(_owner(), from, to_state)
return true return true
return false return false
@@ -42,6 +53,6 @@ func can_transition_to(to_state: String) -> bool:
for t in _transitions.get(current_state, []): for t in _transitions.get(current_state, []):
if t["to"] == to_state: if t["to"] == to_state:
var guard: Callable = t["guard"] var guard: Callable = t["guard"]
if not guard.is_valid() or guard.call(_owner_node): if not guard.is_valid() or guard.call(_owner()):
return true return true
return false return false
+3
View File
@@ -34,6 +34,9 @@ var guard_noticed := false
## Whether this NPC has passed the handheld wand screening (The Joke Shop). ## Whether this NPC has passed the handheld wand screening (The Joke Shop).
var screened := false var screened := false
## Event-level access payload (`has_id`, `has_ticket`, statuses). Empty means
## venue/type defaults from AccessLoadout.
var access_override: Dictionary = {}
func _init() -> void: func _init() -> void:
fsm = FSM.new(self) fsm = FSM.new(self)
+14 -4
View File
@@ -1,5 +1,5 @@
class_name ShiftCoordinator class_name ShiftCoordinator
extends Node extends RefCounted
const Rules := preload("res://src/core/rules.gd") const Rules := preload("res://src/core/rules.gd")
const EventBus := preload("res://src/core/event_bus.gd") const EventBus := preload("res://src/core/event_bus.gd")
@@ -13,7 +13,7 @@ const EventScheduler := preload("res://src/core/event_scheduler.gd")
## Drives one shift: advances time, schedules events, owns the shared systems ## Drives one shift: advances time, schedules events, owns the shared systems
## (recorder, reputation, radio, crowd), and produces the shift review. ## (recorder, reputation, radio, crowd), and produces the shift review.
signal npc_spawn_requested(profile: NpcProfile, intent: String, npc_id: String) signal npc_spawn_requested(profile: NpcProfile, intent: String, npc_id: String, event: Dictionary)
signal shift_started(payload: Dictionary) signal shift_started(payload: Dictionary)
signal shift_ended(review: Dictionary) signal shift_ended(review: Dictionary)
signal phase_changed(phase: int) signal phase_changed(phase: int)
@@ -34,6 +34,10 @@ var scheduler := EventScheduler.new()
var spawned_count := 0 var spawned_count := 0
var spawn_seed := 0 var spawn_seed := 0
var _shift_finished := false var _shift_finished := false
## The scheduler rolls once per simulated second. `tick()` runs at physics rate,
## so accumulate time and only pull due events on whole-second boundaries.
const SPAWN_INTERVAL_S := 1.0
var _spawn_accum_s := 0.0
func setup(p_scenario: VenueScenario, p_duration_s: float = 900.0, p_seed: int = 0) -> void: func setup(p_scenario: VenueScenario, p_duration_s: float = 900.0, p_seed: int = 0) -> void:
scenario = p_scenario scenario = p_scenario
@@ -56,6 +60,7 @@ func start() -> void:
running = true running = true
_shift_finished = false _shift_finished = false
elapsed_s = 0.0 elapsed_s = 0.0
_spawn_accum_s = 0.0
current_phase = _phase_for_time(0.0) current_phase = _phase_for_time(0.0)
event_bus.shift_state_changed.emit({"running": true, "elapsed_s": elapsed_s, "phase": current_phase}) event_bus.shift_state_changed.emit({"running": true, "elapsed_s": elapsed_s, "phase": current_phase})
shift_started.emit({"scenario_id": scenario.id, "duration_s": duration_s}) shift_started.emit({"scenario_id": scenario.id, "duration_s": duration_s})
@@ -77,7 +82,12 @@ func tick(delta: float) -> void:
if elapsed_s >= duration_s: if elapsed_s >= duration_s:
_finish_shift() _finish_shift()
return return
_spawn_due(delta) _spawn_accum_s += delta
if _spawn_accum_s >= SPAWN_INTERVAL_S:
_spawn_accum_s -= SPAWN_INTERVAL_S
if _spawn_accum_s > SPAWN_INTERVAL_S:
_spawn_accum_s = 0.0
_spawn_due(SPAWN_INTERVAL_S)
func _phase_for_time(t: float) -> int: func _phase_for_time(t: float) -> int:
if scenario == null or scenario.shift_phases.is_empty(): if scenario == null or scenario.shift_phases.is_empty():
@@ -102,7 +112,7 @@ func _spawn_due(delta: float) -> void:
var profile := scenario.profile_for(event.get("npc_type", Rules.NpcType.TOURIST)) var profile := scenario.profile_for(event.get("npc_type", Rules.NpcType.TOURIST))
if profile == null: if profile == null:
continue continue
npc_spawn_requested.emit(profile, event.get("intent", "roam"), npc_id) npc_spawn_requested.emit(profile, event.get("intent", "roam"), npc_id, event)
func _finish_shift() -> void: func _finish_shift() -> void:
if _shift_finished: if _shift_finished:
+286 -36
View File
@@ -6,6 +6,7 @@ extends Node
const Rules := preload("res://src/core/rules.gd") const Rules := preload("res://src/core/rules.gd")
const Strings := preload("res://src/core/strings.gd") const Strings := preload("res://src/core/strings.gd")
const AccessArtifact := preload("res://src/core/access_artifact.gd") const AccessArtifact := preload("res://src/core/access_artifact.gd")
const AccessLoadout := preload("res://src/core/access_loadout.gd")
const AccessVerifier := preload("res://src/core/access_verifier.gd") const AccessVerifier := preload("res://src/core/access_verifier.gd")
const DoorState := preload("res://src/core/door_state.gd") const DoorState := preload("res://src/core/door_state.gd")
const WandScreener := preload("res://src/core/wand_screener.gd") const WandScreener := preload("res://src/core/wand_screener.gd")
@@ -19,6 +20,7 @@ const NpcAvatar := preload("res://src/game/npc_avatar.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd") const NpcProfile := preload("res://src/core/npc_profile.gd")
const AudioFeedback := preload("res://src/game/audio_feedback.gd") const AudioFeedback := preload("res://src/game/audio_feedback.gd")
const Hud := preload("res://src/game/ui/hud.gd") const Hud := preload("res://src/game/ui/hud.gd")
const ActionRadial := preload("res://src/game/ui/action_radial.gd")
const RadioPanel := preload("res://src/game/ui/radio_panel.gd") const RadioPanel := preload("res://src/game/ui/radio_panel.gd")
const ShiftReview := preload("res://src/game/ui/shift_review.gd") const ShiftReview := preload("res://src/game/ui/shift_review.gd")
@@ -57,14 +59,20 @@ const PHASE_LIGHTING := {
}, },
} }
var hud: Hud = null var hud: Hud = null
var radial: ActionRadial = null
var radio_panel: RadioPanel = null var radio_panel: RadioPanel = null
var shift_review_panel: ShiftReview = null var shift_review_panel: ShiftReview = null
var _focused_avatar: Node3D = null
var audio: AudioFeedback = null var audio: AudioFeedback = null
var verifier := AccessVerifier.new() var verifier := AccessVerifier.new()
var wand_screener := WandScreener.new() var wand_screener := WandScreener.new()
var active_scenario_id := "hollywood_building" var active_scenario_id := "hollywood_building"
var scenario_duration_s: float = 300.0 # shortened prototype duration for testing ## Shift length mirrors the scenario default (900s / 10-15 min per PLAN.md) unless
## overridden for a test or debug build.
var scenario_duration_s: float = 900.0
var _wand_cooldown := 0.0
const WAND_COOLDOWN_S := 0.8
var scene_root: Node3D = null var scene_root: Node3D = null
var npcs: Array = [] var npcs: Array = []
var npc_counter := 0 var npc_counter := 0
@@ -84,6 +92,9 @@ var _door_rng := RandomNumberGenerator.new()
## Aim-free targeting: cycle through nearby NPCs with Tab (accessibility). ## Aim-free targeting: cycle through nearby NPCs with Tab (accessibility).
const NEARBY_RADIUS := 12.0 const NEARBY_RADIUS := 12.0
## Upper bound on live avatars. Routine traffic is dropped at the cap; scripted
## events evict the oldest unfocused NPC so authored moments still spawn.
const MAX_LIVE_NPCS := 16
var _nearby_index := -1 var _nearby_index := -1
func _ready() -> void: func _ready() -> void:
@@ -98,11 +109,15 @@ func _ready() -> void:
guard = _build_guard() guard = _build_guard()
scene_root.add_child(guard) scene_root.add_child(guard)
guard.global_position = Vector3(0, 1.6, -2.5) # Stand on the landing in front of the door, facing the street where guests
# arrive. The building volume now starts behind the door (world_builder).
guard.global_position = Vector3(0, 0.1, -2.0)
guard.rotation.y = PI
guard.picked_target.connect(_on_guard_target) guard.picked_target.connect(_on_guard_target)
guard.cleared_target.connect(_on_guard_clear) guard.cleared_target.connect(_on_guard_clear)
selected_scenario = _build_scenario(active_scenario_id) selected_scenario = _build_scenario(active_scenario_id)
scenario_duration_s = selected_scenario.shift_duration_s
coordinator = ShiftCoordinator.new() coordinator = ShiftCoordinator.new()
coordinator.setup(selected_scenario, scenario_duration_s, 4242) coordinator.setup(selected_scenario, scenario_duration_s, 4242)
coordinator.npc_spawn_requested.connect(_spawn_npc) coordinator.npc_spawn_requested.connect(_spawn_npc)
@@ -121,6 +136,9 @@ func _build_guard() -> GuardController:
g.name = "Guard" g.name = "Guard"
var head := Node3D.new() var head := Node3D.new()
head.name = "Head" head.name = "Head"
# Eye height. The guard root sits on the floor, so this keeps the camera at
# ~1.7 m while gravity/floor snapping handles the steps (ADR-0007).
head.position = Vector3(0, 1.6, 0)
g.add_child(head) g.add_child(head)
var camera := Camera3D.new() var camera := Camera3D.new()
camera.name = "Camera3D" camera.name = "Camera3D"
@@ -169,6 +187,7 @@ func restart_shift() -> void:
inspector_current_events = [] inspector_current_events = []
current_incident_active = false current_incident_active = false
current_incident_npc_id = "" current_incident_npc_id = ""
scenario_duration_s = selected_scenario.shift_duration_s
coordinator.setup(selected_scenario, scenario_duration_s, 4242) coordinator.setup(selected_scenario, scenario_duration_s, 4242)
coordinator.start() coordinator.start()
_last_phase = -1 _last_phase = -1
@@ -179,20 +198,52 @@ var inspector_current_events: Array = []
func _build_ui() -> void: func _build_ui() -> void:
hud = Hud.new() hud = Hud.new()
add_child(hud) add_child(hud)
_add_crosshair()
audio = AudioFeedback.new() audio = AudioFeedback.new()
add_child(audio) add_child(audio)
radial = ActionRadial.new()
add_child(radial)
radial.confirmed.connect(_on_radial_confirmed)
radial.cancelled.connect(_cancel_radial)
radio_panel = RadioPanel.new() radio_panel = RadioPanel.new()
add_child(radio_panel) add_child(radio_panel)
radio_panel.hide() radio_panel.hide()
radio_panel.submitted.connect(_on_radio_submitted) radio_panel.submitted.connect(_on_radio_submitted)
radio_panel.cancelled.connect(func(): radio_panel.cancelled.connect(func():
radio_panel.hide() radio_panel.hide()
hud.set_action_hint("")) hud.set_action_hint("")
_refresh_freeze())
shift_review_panel = ShiftReview.new() shift_review_panel = ShiftReview.new()
add_child(shift_review_panel) add_child(shift_review_panel)
shift_review_panel.hide() shift_review_panel.hide()
shift_review_panel.restart_requested.connect(restart_shift) shift_review_panel.restart_requested.connect(restart_shift)
func _add_crosshair() -> void:
var layer := CanvasLayer.new()
layer.layer = 9
layer.name = "Crosshair"
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
layer.add_child(center)
var box := Control.new()
box.custom_minimum_size = Vector2(16, 16)
box.mouse_filter = Control.MOUSE_FILTER_IGNORE
center.add_child(box)
var h := ColorRect.new()
h.color = Color(1, 1, 1, 0.75)
h.size = Vector2(12, 2)
h.position = Vector2(2, 7)
h.mouse_filter = Control.MOUSE_FILTER_IGNORE
box.add_child(h)
var v := ColorRect.new()
v.color = Color(1, 1, 1, 0.75)
v.size = Vector2(2, 12)
v.position = Vector2(7, 2)
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
box.add_child(v)
add_child(layer)
func _ensure_lighting() -> void: func _ensure_lighting() -> void:
_sun = DirectionalLight3D.new() _sun = DirectionalLight3D.new()
_sun.name = "Sun" _sun.name = "Sun"
@@ -233,7 +284,12 @@ func _apply_phase_lighting() -> void:
if world_builder: if world_builder:
world_builder.set_night_mode(cfg.get("night", false)) world_builder.set_night_mode(cfg.get("night", false))
func _spawn_npc(profile: NpcProfile, intent: String, _npc_id: String) -> void: func _spawn_npc(profile: NpcProfile, intent: String, npc_id: String, event: Dictionary = {}) -> void:
_purge_npcs()
if npcs.size() >= MAX_LIVE_NPCS and event.get("kind", "routine") == "routine":
return
if npcs.size() >= MAX_LIVE_NPCS:
_evict_oldest_npc()
npc_counter += 1 npc_counter += 1
var avatar := NpcAvatar.new() var avatar := NpcAvatar.new()
avatar.name = "Npc_%d" % npc_counter avatar.name = "Npc_%d" % npc_counter
@@ -244,10 +300,35 @@ func _spawn_npc(profile: NpcProfile, intent: String, _npc_id: String) -> void:
# of every NPC walking to the entrance (TODO: Core Simulation — NPC Perception). # of every NPC walking to the entrance (TODO: Core Simulation — NPC Perception).
var target_pos: Vector3 = selected_scenario.waypoint_for_intent(intent) var target_pos: Vector3 = selected_scenario.waypoint_for_intent(intent)
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2)) var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
avatar.setup(profile, intent, npc_counter * 991 + 17, spawn_pos, target_pos, door_pos) var spawn_profile: NpcProfile = profile.duplicate(true) if profile else profile
var override: Dictionary = event.get("override_access", {})
var seed_value := npc_counter * 991 + 17
if spawn_profile and selected_scenario.access_policy.wand_screening_required:
var items: Array[String] = []
for item in AccessLoadout.roll_carried_items(seed_value, override):
items.append(str(item))
spawn_profile.carried_items = items
avatar.setup(spawn_profile, intent, seed_value, spawn_pos, target_pos, door_pos, npc_id, override)
scene_root.add_child(avatar) scene_root.add_child(avatar)
npcs.append(avatar) npcs.append(avatar)
## Drop references to freed avatars so the cap and Tab cycling stay accurate.
func _purge_npcs() -> void:
for i in range(npcs.size() - 1, -1, -1):
if not is_instance_valid(npcs[i]):
npcs.remove_at(i)
## Make room for a scripted event by removing the oldest NPC the player is not
## currently focused on.
func _evict_oldest_npc() -> void:
for i in npcs.size():
var npc: Node = npcs[i]
if npc == targeted_npc or npc == _focused_avatar:
continue
npcs.remove_at(i)
npc.queue_free()
return
## Spawn a dispatched responder avatar when a radio call's arrival delay elapses ## Spawn a dispatched responder avatar when a radio call's arrival delay elapses
## (TODO: Escalation and Radio — Responder Dispatch). The responder walks to the ## (TODO: Escalation and Radio — Responder Dispatch). The responder walks to the
## entrance and resolves the incident with command/restraint/evacuation/arrest. ## entrance and resolves the incident with command/restraint/evacuation/arrest.
@@ -285,16 +366,27 @@ func _on_shift_ended(review: Dictionary) -> void:
if Save: if Save:
Save.save_shift_result(review) Save.save_shift_result(review)
shift_review_panel.show_review(review, selected_scenario, Save.load_shift_results() if Save else []) shift_review_panel.show_review(review, selected_scenario, Save.load_shift_results() if Save else [])
_refresh_freeze()
func _on_guard_target(target: Node3D) -> void: func _on_guard_target(target: Node3D) -> void:
targeted_npc = target targeted_npc = target
_set_focused_avatar(target)
_begin_incident(target) _begin_incident(target)
func _on_guard_clear() -> void: func _on_guard_clear() -> void:
targeted_npc = null targeted_npc = null
_set_focused_avatar(null)
hud.set_target_label("") hud.set_target_label("")
hud.set_action_hint("") hud.set_action_hint("")
func _set_focused_avatar(avatar: Node3D) -> void:
if _focused_avatar and is_instance_valid(_focused_avatar) and _focused_avatar.has_method("set_focus_visual"):
_focused_avatar.set_focus_visual(false, false)
_focused_avatar = avatar
var menu_open := radial != null and radial.is_open
if avatar and avatar.has_method("set_focus_visual"):
avatar.set_focus_visual(true, menu_open)
func _begin_incident(avatar: Node3D) -> void: func _begin_incident(avatar: Node3D) -> void:
var controller: Object = avatar.get_npc_controller() if avatar.has_method("get_npc_controller") else null var controller: Object = avatar.get_npc_controller() if avatar.has_method("get_npc_controller") else null
if controller == null: if controller == null:
@@ -303,8 +395,7 @@ func _begin_incident(avatar: Node3D) -> void:
current_incident_active = true current_incident_active = true
current_npc_truth = _read_npc_truth(controller) current_npc_truth = _read_npc_truth(controller)
var profile: Object = controller.profile var profile: Object = controller.profile
var label := "[%s] %s — %s" % [ var label := "%s — %s" % [
Rules.NPC_TYPE_NAMES.get((profile.npc_type if profile else -1), "?"),
profile.display_name if profile else "?", profile.display_name if profile else "?",
controller.intent, controller.intent,
] ]
@@ -321,20 +412,62 @@ func _begin_incident(avatar: Node3D) -> void:
# ------------------------------------------------------------------ input --- # ------------------------------------------------------------------ input ---
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo: if event is InputEventKey and event.echo:
_handle_key(event.keycode) return
if event.is_action_pressed("cancel"):
_handle_cancel(event is InputEventMouseButton)
return
if event is InputEventMouseButton and event.pressed:
_handle_mouse_button(event)
return
if event is InputEventKey and event.pressed:
_handle_key(event)
func _handle_key(keycode: Key) -> void: func _handle_mouse_button(event: InputEventMouseButton) -> void:
if event.button_index != MOUSE_BUTTON_LEFT:
return
if radial and radial.is_open:
return
if not _modal_blocks_radial() and targeted_npc != null:
_open_radial()
func _modal_blocks_radial() -> bool:
if inspection_open or wand_open:
return true
if radio_panel and radio_panel.visible:
return true
if shift_review_panel and shift_review_panel.visible:
return true
return false
func _handle_key(event: InputEvent) -> void:
var keycode: Key = event.keycode
if radial and radial.is_open:
if event.is_action_pressed("confirm"):
_confirm_radial()
return
match keycode:
KEY_A, KEY_LEFT:
radial.step(-1)
KEY_D, KEY_RIGHT:
radial.step(1)
return
if event.is_action_pressed("action_menu"):
if targeted_npc != null and not _modal_blocks_radial():
_open_radial()
return
if event.is_action_pressed("inspect"):
if targeted_npc != null and not inspection_open and not wand_open:
_toggle_inspection()
return
if event.is_action_pressed("wand"):
if targeted_npc != null and not inspection_open and not wand_open:
_toggle_wand_screening()
return
match keycode: match keycode:
KEY_E:
if targeted_npc != null and not inspection_open:
_toggle_inspection()
KEY_Q: KEY_Q:
if targeted_npc != null and not inspection_open: if targeted_npc != null and not inspection_open:
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "polite direction") _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "polite direction")
KEY_W:
if targeted_npc != null and not inspection_open:
_toggle_wand_screening()
KEY_TAB: KEY_TAB:
_cycle_nearby_target() _cycle_nearby_target()
KEY_F: KEY_F:
@@ -377,6 +510,113 @@ func _handle_action_key(keycode: Key) -> void:
_: _:
pass pass
## `cancel` (Esc, RMB) closes the top modal. With nothing open, Esc toggles mouse
## capture; RMB does nothing so it never grabs the mouse by accident.
func _handle_cancel(from_mouse: bool) -> void:
if _close_top_modal():
return
if from_mouse:
return
if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
else:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
func _close_top_modal() -> bool:
if radial and radial.is_open:
_cancel_radial()
return true
if inspection_open:
hud.hide_inspection()
inspection_open = false
_refresh_freeze()
return true
if wand_open:
hud.hide_wand()
wand_open = false
_refresh_freeze()
return true
if radio_panel and radio_panel.visible:
radio_panel.hide()
hud.set_action_hint("")
_refresh_freeze()
return true
return false
func _open_radial() -> void:
if targeted_npc == null:
hud.log(Strings.lookup("hud.aim_first"))
return
var controller: Object = _targeted_controller()
var p_name := "Person"
if controller and controller.profile:
p_name = str(controller.profile.display_name)
var wand_ok := selected_scenario != null and selected_scenario.equipment.has("wand")
radial.open_menu(p_name, wand_ok)
if targeted_npc and targeted_npc.has_method("set_focus_visual"):
targeted_npc.set_focus_visual(true, true)
_refresh_freeze()
func _confirm_radial() -> void:
if radial == null or not radial.is_open:
return
var slice := radial.confirm_selected()
radial.close_menu()
if targeted_npc and targeted_npc.has_method("set_focus_visual"):
targeted_npc.set_focus_visual(true, false)
_refresh_freeze()
if slice != "":
_on_radial_confirmed(slice)
func _cancel_radial() -> void:
if radial == null or not radial.is_open:
return
radial.close_menu()
if targeted_npc and targeted_npc.has_method("set_focus_visual"):
targeted_npc.set_focus_visual(true, false)
_refresh_freeze()
func _on_radial_confirmed(slice_id: String) -> void:
if radial and radial.is_open:
radial.close_menu()
if targeted_npc and targeted_npc.has_method("set_focus_visual"):
targeted_npc.set_focus_visual(true, false)
_refresh_freeze()
match slice_id:
"raise_hand":
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "raise hand")
"point":
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "point")
"head_shake":
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "head shake")
"polite":
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "polite direction")
"inspect":
_toggle_inspection()
"wand":
_toggle_wand_screening()
"warning":
_apply_interaction(Rules.Level.FORMAL_WARNING, "formal warning")
"block":
_apply_interaction(Rules.Level.BLOCKING, "blocking")
"radio":
_open_radio(false)
func _refresh_freeze() -> void:
var freeze := inspection_open or wand_open
if radial and radial.is_open:
freeze = true
if radio_panel and radio_panel.visible:
freeze = true
if shift_review_panel and shift_review_panel.visible:
freeze = true
if guard:
guard.input_locked = freeze
if freeze:
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
else:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
## Toggle the guard's flashlight. In evening/late phases it widens NPC ## Toggle the guard's flashlight. In evening/late phases it widens NPC
## perception radius (PLAN.md: Level 3 — flashlight at night). ## perception radius (PLAN.md: Level 3 — flashlight at night).
func _toggle_flashlight() -> void: func _toggle_flashlight() -> void:
@@ -423,8 +663,13 @@ func _cycle_nearby_target() -> void:
_nearby_index = (_nearby_index + 1) % nearby.size() _nearby_index = (_nearby_index + 1) % nearby.size()
var next_target: Node3D = nearby[_nearby_index] var next_target: Node3D = nearby[_nearby_index]
targeted_npc = next_target targeted_npc = next_target
_set_focused_avatar(next_target)
_begin_incident(next_target) _begin_incident(next_target)
hud.log(Strings.fmt("hud.targeting", [next_target.name])) var controller: Object = _targeted_controller()
var who: String = next_target.name
if controller and controller.profile and controller.profile.display_name != "":
who = str(controller.profile.display_name)
hud.log(Strings.fmt("hud.targeting", [who]))
func _apply_interaction(level: int, action_name: String) -> void: func _apply_interaction(level: int, action_name: String) -> void:
if targeted_npc == null: if targeted_npc == null:
@@ -500,9 +745,11 @@ func _open_radio(emergency: bool) -> void:
return return
radio_panel.open(controller, emergency, current_npc_truth) radio_panel.open(controller, emergency, current_npc_truth)
hud.set_action_hint("Fill in the radio report on screen.") hud.set_action_hint("Fill in the radio report on screen.")
_refresh_freeze()
func _on_radio_submitted(report: Dictionary, emergency: bool) -> void: func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
hud.set_action_hint("") hud.set_action_hint("")
_refresh_freeze()
var result: Dictionary = coordinator.radio.request_emergency(report) if emergency else coordinator.radio.request_assistance(report) var result: Dictionary = coordinator.radio.request_emergency(report) if emergency else coordinator.radio.request_assistance(report)
if not result.get("ok", false): if not result.get("ok", false):
hud.log(Strings.fmt("radio.blocked", [result.get("reason", "unknown")])) hud.log(Strings.fmt("radio.blocked", [result.get("reason", "unknown")]))
@@ -534,14 +781,16 @@ func _toggle_inspection() -> void:
if inspection_open: if inspection_open:
hud.hide_inspection() hud.hide_inspection()
inspection_open = false inspection_open = false
_refresh_freeze()
return return
var controller: Object = _targeted_controller() var controller: Object = _targeted_controller()
if controller == null: if controller == null:
return return
inspection_open = true inspection_open = true
var artifacts := _artifacts_for(controller) var artifacts := _artifacts_for(controller)
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy) var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy, controller.screened)
hud.show_inspection(controller, artifacts, result) hud.show_inspection(controller, artifacts, result)
_refresh_freeze()
# Venue door-staff flavor: an applicable check line (PLAN.md: door-staff dialogue). # Venue door-staff flavor: an applicable check line (PLAN.md: door-staff dialogue).
var line := _door_context_line() var line := _door_context_line()
if line != "": if line != "":
@@ -552,10 +801,14 @@ func _toggle_inspection() -> void:
## Handheld wand inspection (The Joke Shop). Only available when the venue ## Handheld wand inspection (The Joke Shop). Only available when the venue
## carries a wand. The wand reports an alert; the player interprets it. ## carries a wand. The wand reports an alert; the player interprets it.
func _toggle_wand_screening() -> void: func _toggle_wand_screening() -> void:
if _wand_cooldown > 0.0:
hud.log(Strings.lookup("wand.recharge"))
return
if wand_open: if wand_open:
hud.hide_wand() hud.hide_wand()
hud.set_wand_icon(false) hud.set_wand_icon(false)
wand_open = false wand_open = false
_refresh_freeze()
return return
if not selected_scenario.equipment.has("wand"): if not selected_scenario.equipment.has("wand"):
hud.log(Strings.lookup("hud.no_wand")) hud.log(Strings.lookup("hud.no_wand"))
@@ -570,6 +823,7 @@ func _toggle_wand_screening() -> void:
var result: Dictionary = wand_screener.screen(items) var result: Dictionary = wand_screener.screen(items)
wand_open = true wand_open = true
hud.show_wand(controller, result) hud.show_wand(controller, result)
_refresh_freeze()
var wand_line := selected_scenario.door_staff_line("wand_check", _door_rng) var wand_line := selected_scenario.door_staff_line("wand_check", _door_rng)
if wand_line != "": if wand_line != "":
hud.log(wand_line) hud.log(wand_line)
@@ -580,6 +834,7 @@ func decide_wand(allowed: bool) -> void:
if controller == null: if controller == null:
hud.hide_wand() hud.hide_wand()
wand_open = false wand_open = false
_refresh_freeze()
return return
var profile: Object = controller.profile var profile: Object = controller.profile
var items: Array = profile.carried_items if profile else [] var items: Array = profile.carried_items if profile else []
@@ -615,21 +870,26 @@ func decide_wand(allowed: bool) -> void:
controller.screened = true controller.screened = true
hud.hide_wand() hud.hide_wand()
wand_open = false wand_open = false
_refresh_freeze()
# Short scan cooldown: the wand must “recharge” between guests (PLAN.md: feel).
_wand_cooldown = WAND_COOLDOWN_S
func decide_access(allowed: bool) -> void: func decide_access(allowed: bool) -> void:
var controller: Object = _targeted_controller() var controller: Object = _targeted_controller()
if controller == null: if controller == null:
hud.hide_inspection() hud.hide_inspection()
inspection_open = false inspection_open = false
_refresh_freeze()
return return
# Screening-before-entry: at wand venues, a guest must be screened first. # Screening-before-entry: at wand venues, a guest must be screened first.
if allowed and selected_scenario.access_policy.wand_screening_required and not controller.screened: if allowed and selected_scenario.access_policy.wand_screening_required and not controller.screened:
hud.log(Strings.lookup("hud.screen_first")) hud.log(Strings.lookup("hud.screen_first"))
hud.hide_inspection() hud.hide_inspection()
inspection_open = false inspection_open = false
_refresh_freeze()
return return
var artifacts := _artifacts_for(controller) var artifacts := _artifacts_for(controller)
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy) var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy, controller.screened)
var granted: bool = result.get("granted", false) var granted: bool = result.get("granted", false)
coordinator.recorder.add_access_evidence(controller.npc_id, { coordinator.recorder.add_access_evidence(controller.npc_id, {
"decided": allowed, "decided": allowed,
@@ -661,6 +921,7 @@ func decide_access(allowed: bool) -> void:
audio.play_deny() audio.play_deny()
hud.hide_inspection() hud.hide_inspection()
inspection_open = false inspection_open = false
_refresh_freeze()
## Venue-specific opening line for the access inspection (e.g. The Joke Shop's ## Venue-specific opening line for the access inspection (e.g. The Joke Shop's
## ID / ticket check lines). Returns "" when the venue has no matching lines. ## ID / ticket check lines). Returns "" when the venue has no matching lines.
@@ -674,23 +935,10 @@ func _door_context_line() -> String:
return "" return ""
func _artifacts_for(controller: Object) -> Array: func _artifacts_for(controller: Object) -> Array:
var profile: Object = controller.profile var profile: NpcProfile = controller.profile as NpcProfile
var npc_type: int = profile.npc_type if profile else -1 var override: Dictionary = controller.access_override
var out: Array = [] var policy = selected_scenario.access_policy if selected_scenario else null
match npc_type: return AccessLoadout.build(profile, policy, override)
Rules.NpcType.AUTHORIZED_GUEST, Rules.NpcType.AUTHORIZED_STAFF:
out.append(AccessArtifact.new(Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.VALID, {"known": true}))
Rules.NpcType.DELIVERY:
out.append(AccessArtifact.new(Rules.AccessKind.CREDENTIAL, Rules.ArtifactStatus.VALID, {"role": "delivery"}))
Rules.NpcType.TRESPASSER:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, {"color": "red", "date": "2024-01-01"}))
Rules.NpcType.TOURIST:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {}))
Rules.NpcType.CONFUSED_VISITOR:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.AMBIGUOUS, {"color": "gold"}))
_:
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {}))
return out
func _read_npc_truth(controller: Object) -> Dictionary: func _read_npc_truth(controller: Object) -> Dictionary:
var profile: Object = controller.profile var profile: Object = controller.profile
@@ -714,6 +962,8 @@ func _close_incident(outcome: String) -> void:
current_incident_npc_id = "" current_incident_npc_id = ""
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
if _wand_cooldown > 0.0:
_wand_cooldown = maxf(0.0, _wand_cooldown - delta)
if coordinator == null or not coordinator.running: if coordinator == null or not coordinator.running:
return return
coordinator.tick(delta) coordinator.tick(delta)
+74 -34
View File
@@ -1,35 +1,47 @@
class_name GuardController class_name GuardController
extends CharacterBody3D extends CharacterBody3D
## First-person guard controller: WASD movement, mouse look, interact raycast. ## First-person guard: WASD + mouse look. Focus is a cone on NPC heads (ADR-0007, ADR-0008).
signal picked_target(target: Node) signal picked_target(target: Node)
signal cleared_target() signal cleared_target()
const NpcFocus := preload("res://src/game/npc_focus.gd")
var move_speed: float = 3.2 var move_speed: float = 3.2
var look_sensitivity: float = 0.0022 var look_sensitivity: float = 0.0022
var accel: float = 12.0
var is_ready := false var is_ready := false
var targeted_npc: Node3D = null var targeted_npc: Node3D = null
var input_locked := false
var _miss_frames := 0
const MISS_GRACE := 8
@onready var camera: Camera3D = $Head/Camera3D @onready var camera: Camera3D = $Head/Camera3D
@onready var head: Node3D = $Head @onready var head: Node3D = $Head
func _ready() -> void: func _ready() -> void:
floor_snap_length = 0.3
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
is_ready = true is_ready = true
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if input_locked:
return
if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED: if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
rotate_y(-event.relative.x * look_sensitivity) rotate_y(-event.relative.x * look_sensitivity)
head.rotate_x(-event.relative.y * look_sensitivity) head.rotate_x(-event.relative.y * look_sensitivity)
head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0)) head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0))
elif event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
else:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
func _physics_process(_delta: float) -> void: func _physics_process(_delta: float) -> void:
# Movement relative to the head's yaw only. if input_locked:
velocity.x = 0.0
velocity.z = 0.0
if not is_on_floor():
velocity.y -= 9.8 * _delta
else:
velocity.y = 0.0
move_and_slide()
return
var forward := -global_transform.basis.z var forward := -global_transform.basis.z
forward.y = 0.0 forward.y = 0.0
forward = forward.normalized() forward = forward.normalized()
@@ -41,28 +53,49 @@ func _physics_process(_delta: float) -> void:
Input.get_axis("move_left", "move_right"), Input.get_axis("move_left", "move_right"),
Input.get_axis("move_forward", "move_back") Input.get_axis("move_forward", "move_back")
) )
var direction := (forward * input_dir.y + right * input_dir.x).normalized() if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
velocity.x = direction.x * move_speed input_dir = Vector2.ZERO
velocity.z = direction.z * move_speed var desired := (forward * input_dir.y + right * input_dir.x)
velocity.y -= 9.8 * _delta if desired.length() > 1.0:
desired = desired.normalized()
desired *= move_speed
velocity.x = move_toward(velocity.x, desired.x, accel * _delta * move_speed)
velocity.z = move_toward(velocity.z, desired.z, accel * _delta * move_speed)
if is_on_floor():
velocity.y = 0.0
else:
velocity.y -= 9.8 * _delta
move_and_slide() move_and_slide()
_update_target() _update_target()
func _update_target() -> void: func _update_target() -> void:
var space := get_world_3d().direct_space_state if camera == null:
var from := camera.global_position return
var to := from + -camera.global_transform.basis.z * 8.0 var npcs := get_tree().get_nodes_in_group("npc") if get_tree() else []
var query := PhysicsRayQueryParameters3D.create(from, to) var heads: Array = []
query.collide_with_areas = true var avatars: Array = []
query.collide_with_bodies = true for node in npcs:
query.exclude = [get_rid()] if not (node is Node3D) or not is_instance_valid(node):
var hit := space.intersect_ray(query) continue
var avatar := node as Node3D
var head_node := avatar.get_node_or_null("Rig/Head") as Node3D
var pos: Vector3 = head_node.global_position if head_node else avatar.global_position + Vector3(0, 1.6, 0)
heads.append(pos)
avatars.append(avatar)
var cam_origin := camera.global_position
var cam_forward := -camera.global_transform.basis.z
var idx := NpcFocus.pick_index(cam_origin, cam_forward, heads)
var new_target: Node3D = null var new_target: Node3D = null
if hit: if idx >= 0:
var obj: Object = hit.get("collider") var candidate: Node3D = avatars[idx]
var parent_avatar: Node3D = _find_avatar(obj) if _head_visible(cam_origin, heads[idx]):
if parent_avatar != null: new_target = candidate
new_target = parent_avatar if new_target == null and targeted_npc != null:
_miss_frames += 1
if _miss_frames < MISS_GRACE:
return
else:
_miss_frames = 0
if new_target != targeted_npc: if new_target != targeted_npc:
targeted_npc = new_target targeted_npc = new_target
if new_target != null: if new_target != null:
@@ -70,14 +103,21 @@ func _update_target() -> void:
else: else:
cleared_target.emit() cleared_target.emit()
func _find_avatar(obj: Object) -> Node3D: func _head_visible(from: Vector3, to: Vector3) -> bool:
# Avatars are CharacterBody3D with a collision shape; guard against self and scene junk. var space := get_world_3d().direct_space_state
if space == null:
return true
var query := PhysicsRayQueryParameters3D.create(from, to)
query.collide_with_areas = false
query.collide_with_bodies = true
query.exclude = [get_rid()]
var hit := space.intersect_ray(query)
if hit.is_empty():
return true
var obj: Object = hit.get("collider")
var n := obj as Node var n := obj as Node
if n == null: while n != null:
return null if n.is_in_group("npc"):
var current: Node = n return true
while current != null: n = n.get_parent()
if current.has_method("get_npc_controller"): return false
return current as Node3D
current = current.get_parent()
return null
+62 -5
View File
@@ -39,14 +39,17 @@ func _init() -> void:
func get_npc_controller() -> NPCController: func get_npc_controller() -> NPCController:
return npc return npc
func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3, p_door: Vector3 = Vector3(0, 0.5, 1.2)) -> void: func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3, p_door: Vector3 = Vector3(0, 0.5, 1.2), p_npc_id: String = "", p_override: Dictionary = {}) -> void:
npc.setup("npc_%d" % seed_value, profile, intent, seed_value) var id := p_npc_id if p_npc_id != "" else "npc_%d" % seed_value
npc.setup(id, profile, intent, seed_value)
npc.access_override = p_override
_visual_seed = seed_value _visual_seed = seed_value
spawn_position = p_spawn spawn_position = p_spawn
target_position = p_target target_position = p_target
door_position = p_door door_position = p_door
position = p_spawn position = p_spawn
npc.fsm.state_changed.connect(_on_state_changed) npc.fsm.state_changed.connect(_on_state_changed)
add_to_group("npc")
_build_body(profile.npc_type) _build_body(profile.npc_type)
_nav_usable = false _nav_usable = false
_nav_try_frames = 0 _nav_try_frames = 0
@@ -62,9 +65,10 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector
var collider := CollisionShape3D.new() var collider := CollisionShape3D.new()
var shape := CapsuleShape3D.new() var shape := CapsuleShape3D.new()
shape.radius = 0.35 shape.radius = 0.35
shape.height = 1.6 var height_scale: float = _rig.scale.y if _rig else 1.0
shape.height = 1.6 * height_scale
collider.shape = shape collider.shape = shape
collider.position = Vector3(0, 0.8, 0) collider.position = Vector3(0, 0.8 * height_scale, 0)
add_child(collider) add_child(collider)
var _visual_seed := 0 var _visual_seed := 0
@@ -72,8 +76,11 @@ var _rig: Node3D = null
var _head: Node3D = null var _head: Node3D = null
var _bob_phase := 0.0 var _bob_phase := 0.0
var _guard_ref: Node3D = null var _guard_ref: Node3D = null
var _ring: MeshInstance3D = null
var _name_tag: Label3D = null
const SKIN_TONE := Color(0.88, 0.72, 0.60) const SKIN_TONE := Color(0.88, 0.72, 0.60)
const PANTS_COLOR := Color(0.22, 0.22, 0.26) const PANTS_COLOR := Color(0.22, 0.22, 0.26)
const HAIR_COLORS := [Color(0.12, 0.1, 0.08), Color(0.35, 0.22, 0.12), Color(0.55, 0.42, 0.28), Color(0.15, 0.15, 0.18)]
func _build_body(npc_type: int) -> void: func _build_body(npc_type: int) -> void:
_rig = Node3D.new() _rig = Node3D.new()
@@ -96,14 +103,60 @@ func _build_body(npc_type: int) -> void:
# Arms # Arms
_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0) _add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0)
_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0) _add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0)
# Head # Neck, head, face, hair, shoes so the body reads as a person (ADR-0010).
_add_capsule(_rig, 0.08, 0.16, Vector3(0, 1.48, 0), SKIN_TONE)
_head = Node3D.new() _head = Node3D.new()
_head.name = "Head" _head.name = "Head"
_head.position = Vector3(0, 1.62, 0) _head.position = Vector3(0, 1.62, 0)
_add_sphere(_head, 0.17, Vector3.ZERO, SKIN_TONE) _add_sphere(_head, 0.17, Vector3.ZERO, SKIN_TONE)
var hair_i := (_visual_seed * 7) % HAIR_COLORS.size()
_add_sphere(_head, 0.18, Vector3(0, 0.08, -0.02), HAIR_COLORS[hair_i])
_add_sphere(_head, 0.035, Vector3(-0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1))
_add_sphere(_head, 0.035, Vector3(0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1))
_rig.add_child(_head) _rig.add_child(_head)
_add_box_prop(_rig, Vector3(0.16, 0.08, 0.28), Vector3(-0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14))
_add_box_prop(_rig, Vector3(0.16, 0.08, 0.28), Vector3(0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14))
_build_focus_ring()
_add_props(npc_type) _add_props(npc_type)
func _build_focus_ring() -> void:
_ring = MeshInstance3D.new()
_ring.name = "FocusRing"
var torus := TorusMesh.new()
torus.inner_radius = 0.16
torus.outer_radius = 0.20
torus.rings = 12
torus.ring_segments = 16
_ring.mesh = torus
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.albedo_color = Color(0.95, 0.82, 0.28, 0.9)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
_ring.material_override = mat
_ring.position = Vector3(0, 0.28, 0)
_ring.visible = false
_head.add_child(_ring)
_name_tag = Label3D.new()
_name_tag.name = "NameTag"
_name_tag.text = npc.profile.display_name if npc and npc.profile else ""
_name_tag.font_size = 28
_name_tag.pixel_size = 0.004
_name_tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
_name_tag.position = Vector3(0, 0.42, 0)
_name_tag.modulate = Color(0.95, 0.95, 0.9, 0.9)
_name_tag.visible = false
_head.add_child(_name_tag)
func set_focus_visual(focused: bool, menu_open: bool = false) -> void:
if _ring:
_ring.visible = focused
var mat := _ring.material_override as StandardMaterial3D
if mat:
mat.albedo_color = Color(1.0, 0.92, 0.35, 1.0) if menu_open else Color(0.95, 0.82, 0.28, 0.85)
_ring.scale = Vector3.ONE * (1.15 if menu_open else 1.0)
if _name_tag:
_name_tag.visible = focused
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> void: func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> void:
var mi := MeshInstance3D.new() var mi := MeshInstance3D.new()
var mesh := CapsuleMesh.new() var mesh := CapsuleMesh.new()
@@ -187,6 +240,10 @@ func _animate(delta: float) -> void:
_bob_phase = 0.0 _bob_phase = 0.0
_rig.position.y = lerpf(_rig.position.y, 0.0, 0.2) _rig.position.y = lerpf(_rig.position.y, 0.0, 0.2)
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2) _rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
if _ring and _ring.visible:
var cam := get_viewport().get_camera_3d() if get_viewport() else null
if cam:
_ring.look_at(cam.global_position, Vector3.UP)
# Attention cue: head turns toward the guard when awareness was raised. # Attention cue: head turns toward the guard when awareness was raised.
if _head != null and npc.guard_noticed: if _head != null and npc.guard_noticed:
if _guard_ref == null or not is_instance_valid(_guard_ref): if _guard_ref == null or not is_instance_valid(_guard_ref):
+33
View File
@@ -0,0 +1,33 @@
extends RefCounted
## World-space focus cone (ADR-0008). Picks the head closest to the camera
## forward vector inside range and angle. No viewport needed, so tests run headless.
const MAX_RANGE := 10.0
const MAX_ANGLE_RAD := 0.175 # ~10 degrees
static func score_head(cam_origin: Vector3, cam_forward: Vector3, head_pos: Vector3, max_range: float = MAX_RANGE, max_angle: float = MAX_ANGLE_RAD) -> float:
var to := head_pos - cam_origin
var dist := to.length()
if dist < 0.05 or dist > max_range:
return -1.0
var dir := to / dist
var dot := cam_forward.dot(dir)
if dot <= 0.0:
return -1.0
var ang := acos(clampf(dot, -1.0, 1.0))
if ang > max_angle:
return -1.0
return ang
static func pick_index(cam_origin: Vector3, cam_forward: Vector3, head_positions: Array, max_range: float = MAX_RANGE, max_angle: float = MAX_ANGLE_RAD) -> int:
var best_i := -1
var best_score := 999.0
for i in head_positions.size():
var pos: Vector3 = head_positions[i]
var score := score_head(cam_origin, cam_forward, pos, max_range, max_angle)
if score < 0.0:
continue
if score < best_score:
best_score = score
best_i = i
return best_i
+176
View File
@@ -0,0 +1,176 @@
extends CanvasLayer
## Doughnut action menu (ADR-0009). Open on LMB/Space, confirm click/Space,
## cancel Esc/RMB. Emergency is not a slice.
signal confirmed(slice_id: String)
signal cancelled()
const Rules := preload("res://src/core/rules.gd")
const Strings := preload("res://src/core/strings.gd")
const SLICE_DEFS := [
{"id": "raise_hand", "label_key": "radial.raise_hand", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "raise hand"},
{"id": "point", "label_key": "radial.point", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "point"},
{"id": "head_shake", "label_key": "radial.head_shake", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "head shake"},
{"id": "polite", "label_key": "radial.polite", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "polite direction"},
{"id": "inspect", "label_key": "radial.inspect", "level": -1, "action": "inspect"},
{"id": "wand", "label_key": "radial.wand", "level": -1, "action": "wand"},
{"id": "warning", "label_key": "radial.warning", "level": Rules.Level.FORMAL_WARNING, "action": "formal warning"},
{"id": "block", "label_key": "radial.block", "level": Rules.Level.BLOCKING, "action": "blocking"},
{"id": "radio", "label_key": "radial.radio", "level": -1, "action": "radio assist"},
]
var selected := 0
var wand_available := false
var person_name := ""
var is_open := false
var _opened_ms := 0
var _wheel: Control = null
var _center_label: Label = null
func _init() -> void:
name = "ActionRadial"
layer = 25
var wheel := Control.new()
wheel.name = "Wheel"
wheel.set_anchors_preset(Control.PRESET_FULL_RECT)
wheel.mouse_filter = Control.MOUSE_FILTER_STOP
wheel.draw.connect(_on_wheel_draw)
wheel.gui_input.connect(_on_wheel_input)
add_child(wheel)
_wheel = wheel
_center_label = Label.new()
_center_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_center_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_center_label.set_anchors_preset(Control.PRESET_CENTER)
_center_label.offset_left = -120
_center_label.offset_right = 120
_center_label.offset_top = -24
_center_label.offset_bottom = 24
_center_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_center_label)
hide()
func slice_count() -> int:
return SLICE_DEFS.size()
func slice_id(index: int) -> String:
if index < 0 or index >= SLICE_DEFS.size():
return ""
return str(SLICE_DEFS[index]["id"])
func slice_def(index: int) -> Dictionary:
if index < 0 or index >= SLICE_DEFS.size():
return {}
return SLICE_DEFS[index]
func is_slice_enabled(index: int) -> bool:
if slice_id(index) == "wand":
return wand_available
return true
func open_menu(p_name: String, p_wand: bool) -> void:
person_name = p_name
wand_available = p_wand
selected = 0
is_open = true
_opened_ms = Time.get_ticks_msec()
_center_label.text = person_name
show()
_wheel.queue_redraw()
func close_menu() -> void:
is_open = false
hide()
func step(direction: int) -> void:
if not is_open or SLICE_DEFS.is_empty():
return
var n := SLICE_DEFS.size()
var next := selected
for i in n:
next = (next + direction) % n
if next < 0:
next += n
if is_slice_enabled(next):
selected = next
_wheel.queue_redraw()
return
func confirm_selected() -> String:
if not is_open:
return ""
if not is_slice_enabled(selected):
return ""
return slice_id(selected)
func angle_to_index(angle: float) -> int:
# angle from atan2, 0 = +x, we want 0 = top (-PI/2).
var n := float(SLICE_DEFS.size())
var shifted := angle + PI / 2.0
while shifted < 0.0:
shifted += TAU
while shifted >= TAU:
shifted -= TAU
var idx := int(floor(shifted / TAU * n)) % SLICE_DEFS.size()
return idx
func _on_wheel_input(event: InputEvent) -> void:
if not is_open:
return
if event is InputEventMouseMotion:
var idx := _index_at_mouse(event.position)
if idx >= 0 and is_slice_enabled(idx):
selected = idx
_wheel.queue_redraw()
elif event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_LEFT:
if Time.get_ticks_msec() - _opened_ms < 120:
return
var idx := _index_at_mouse(event.position)
if idx >= 0 and is_slice_enabled(idx):
selected = idx
confirmed.emit(slice_id(selected))
elif event.button_index == MOUSE_BUTTON_RIGHT:
cancelled.emit()
func _index_at_mouse(local_pos: Vector2) -> int:
var center := _wheel.size * 0.5
var delta := local_pos - center
if delta.length() < 36.0:
return -1
return angle_to_index(atan2(delta.y, delta.x))
func _on_wheel_draw() -> void:
if not is_open:
return
var center := _wheel.size * 0.5
var n := SLICE_DEFS.size()
var outer := 150.0
var inner := 58.0
for i in n:
var a0 := -PI / 2.0 + TAU * float(i) / float(n)
var a1 := -PI / 2.0 + TAU * float(i + 1) / float(n)
var col := Color(0.12, 0.14, 0.18, 0.82)
if i == selected:
col = Color(0.92, 0.78, 0.28, 0.9)
if not is_slice_enabled(i):
col = Color(0.2, 0.2, 0.22, 0.45)
_draw_slice(center, inner, outer, a0, a1, col)
var mid := (a0 + a1) * 0.5
var label_pos := center + Vector2(cos(mid), sin(mid)) * ((inner + outer) * 0.5)
var text := Strings.lookup(str(SLICE_DEFS[i]["label_key"]))
_wheel.draw_string(ThemeDB.fallback_font, label_pos + Vector2(-28, 4), text, HORIZONTAL_ALIGNMENT_LEFT, -1, 13, Color.WHITE)
func _draw_slice(center: Vector2, inner: float, outer: float, a0: float, a1: float, color: Color) -> void:
var pts := PackedVector2Array()
var steps := 8
for s in steps + 1:
var t := float(s) / float(steps)
var ang := lerpf(a0, a1, t)
pts.append(center + Vector2(cos(ang), sin(ang)) * outer)
for s in steps + 1:
var t := float(steps - s) / float(steps)
var ang := lerpf(a0, a1, t)
pts.append(center + Vector2(cos(ang), sin(ang)) * inner)
_wheel.draw_colored_polygon(pts, color)
+25 -17
View File
@@ -5,16 +5,16 @@ extends CanvasLayer
const Rules := preload("res://src/core/rules.gd") const Rules := preload("res://src/core/rules.gd")
const Strings := preload("res://src/core/strings.gd") const Strings := preload("res://src/core/strings.gd")
const T_VIGNETTE := preload("res://assets/textures/vignette.png") const T_VIGNETTE := preload("res://assets/textures/ai/game_ready/vignette.png")
const T_ICON_FLASH := preload("res://assets/textures/icons/flashlight_on.png") const T_ICON_FLASH := preload("res://assets/textures/ai/game_ready/icons/flashlight_on.png")
const T_ICON_DOOR := preload("res://assets/textures/icons/door_locked.png") const T_ICON_DOOR := preload("res://assets/textures/ai/game_ready/icons/door_locked.png")
const T_ICON_WAND := preload("res://assets/textures/icons/wand.png") const T_ICON_WAND := preload("res://assets/textures/ai/game_ready/icons/wand.png")
const T_ICON_REC := preload("res://assets/textures/icons/rec_dot.png") const T_ICON_REC := preload("res://assets/textures/ai/game_ready/icons/rec_dot.png")
const T_CARD_ID := preload("res://assets/textures/cards/id_card.png") const T_CARD_ID := preload("res://assets/textures/ai/game_ready/cards/id_card.png")
const T_CARD_WRIST := preload("res://assets/textures/cards/wristband.png") const T_CARD_WRIST := preload("res://assets/textures/ai/game_ready/cards/wristband.png")
const T_CARD_TICKET := preload("res://assets/textures/cards/ticket.png") const T_CARD_TICKET := preload("res://assets/textures/ai/game_ready/cards/ticket.png")
const T_CARD_CRED := preload("res://assets/textures/cards/credential.png") const T_CARD_CRED := preload("res://assets/textures/ai/game_ready/cards/credential.png")
const T_CARD_GUEST := preload("res://assets/textures/cards/guest_list.png") const T_CARD_GUEST := preload("res://assets/textures/ai/game_ready/cards/guest_list.png")
const CARD_TEXTURES := { const CARD_TEXTURES := {
Rules.AccessKind.ID: T_CARD_ID, Rules.AccessKind.ID: T_CARD_ID,
Rules.AccessKind.WRISTBAND: T_CARD_WRIST, Rules.AccessKind.WRISTBAND: T_CARD_WRIST,
@@ -140,7 +140,7 @@ func _init() -> void:
# --- body-camera overlay (REC + shift clock + corner brackets, top right) --- # --- body-camera overlay (REC + shift clock + corner brackets, top right) ---
var cam_box := VBoxContainer.new() var cam_box := VBoxContainer.new()
cam_box.set_anchors_preset(Control.PRESET_TOP_RIGHT) cam_box.set_anchors_preset(Control.PRESET_TOP_RIGHT)
cam_box.position = Vector2(-180, 12) cam_box.position = Vector2(-180, 50)
cam_box.alignment = BoxContainer.ALIGNMENT_END cam_box.alignment = BoxContainer.ALIGNMENT_END
add_child(cam_box) add_child(cam_box)
var rec_row := HBoxContainer.new() var rec_row := HBoxContainer.new()
@@ -180,9 +180,12 @@ func _init() -> void:
icon_row.add_child(wand_icon) icon_row.add_child(wand_icon)
# --- inspect panel (popup center) --- # --- inspect panel (popup center) ---
var inspect_center := CenterContainer.new()
inspect_center.set_anchors_preset(Control.PRESET_FULL_RECT)
inspect_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(inspect_center)
inspect_panel = PanelContainer.new() inspect_panel = PanelContainer.new()
inspect_panel.visible = false inspect_panel.visible = false
inspect_panel.set_anchors_preset(Control.PRESET_CENTER)
inspect_panel.modulate.a = 1.0 inspect_panel.modulate.a = 1.0
var ivbox := VBoxContainer.new() var ivbox := VBoxContainer.new()
inspect_panel.add_child(ivbox) inspect_panel.add_child(ivbox)
@@ -197,12 +200,15 @@ func _init() -> void:
insp_hint.text = "A = allow entry D = deny entry" insp_hint.text = "A = allow entry D = deny entry"
insp_hint.add_theme_font_size_override("font_size", 12) insp_hint.add_theme_font_size_override("font_size", 12)
ivbox.add_child(insp_hint) ivbox.add_child(insp_hint)
add_child(inspect_panel) inspect_center.add_child(inspect_panel)
# --- wand screening panel (popup center) --- # --- wand screening panel (popup center) ---
var wand_center := CenterContainer.new()
wand_center.set_anchors_preset(Control.PRESET_FULL_RECT)
wand_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(wand_center)
wand_panel = PanelContainer.new() wand_panel = PanelContainer.new()
wand_panel.visible = false wand_panel.visible = false
wand_panel.set_anchors_preset(Control.PRESET_CENTER)
var wvbox := VBoxContainer.new() var wvbox := VBoxContainer.new()
wand_panel.add_child(wvbox) wand_panel.add_child(wvbox)
wand_text = Label.new() wand_text = Label.new()
@@ -216,7 +222,7 @@ func _init() -> void:
wand_hint.text = "A = admit D = refuse entry" wand_hint.text = "A = admit D = refuse entry"
wand_hint.add_theme_font_size_override("font_size", 12) wand_hint.add_theme_font_size_override("font_size", 12)
wvbox.add_child(wand_hint) wvbox.add_child(wand_hint)
add_child(wand_panel) wand_center.add_child(wand_panel)
# --- debug overlay --- # --- debug overlay ---
debug_label = Label.new() debug_label = Label.new()
@@ -374,6 +380,7 @@ func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -
continue continue
var card := TextureRect.new() var card := TextureRect.new()
card.texture = tex card.texture = tex
card.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
card.custom_minimum_size = Vector2(92, 58) card.custom_minimum_size = Vector2(92, 58)
card.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED card.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
card.tooltip_text = artifact.summary() card.tooltip_text = artifact.summary()
@@ -382,7 +389,8 @@ func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -
card_row.add_child(card) card_row.add_child(card)
added_card = true added_card = true
if added_card: if added_card:
inspect_panel.add_child(card_row) var content := inspect_panel.get_child(0) as VBoxContainer
content.add_child(card_row)
var lines: Array[String] = [] var lines: Array[String] = []
for artifact in artifacts: for artifact in artifacts:
lines.append(" • " + artifact.summary()) lines.append(" • " + artifact.summary())
@@ -403,7 +411,7 @@ func _clear_card_row() -> void:
return return
var row := inspect_panel.find_child("INSPECT_CARD_ROW", true, false) as Control var row := inspect_panel.find_child("INSPECT_CARD_ROW", true, false) as Control
if row: if row:
inspect_panel.remove_child(row) row.get_parent().remove_child(row)
row.queue_free() row.queue_free()
func show_wand(controller: Object, result: Dictionary) -> void: func show_wand(controller: Object, result: Dictionary) -> void:
+5 -2
View File
@@ -34,9 +34,12 @@ func _init() -> void:
dim.set_anchors_preset(Control.PRESET_FULL_RECT) dim.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(dim) add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(center)
panel = PanelContainer.new() panel = PanelContainer.new()
panel.set_anchors_preset(Control.PRESET_CENTER) center.add_child(panel)
add_child(panel)
var vbox := VBoxContainer.new() var vbox := VBoxContainer.new()
panel.add_child(vbox) panel.add_child(vbox)
+15 -37
View File
@@ -1,11 +1,10 @@
class_name WorldBuilder class_name WorldBuilder
## Procedurally assembles a minimal Hollywood Boulevard entrance prototype: ## Procedurally assembles a minimal Hollywood Boulevard entrance prototype:
const T_BRICK := preload("res://assets/textures/brick_facade.png") const T_BRICK := preload("res://assets/textures/ai/game_ready/brick_facade.png")
const T_CONCRETE := preload("res://assets/textures/concrete.png") const T_CONCRETE := preload("res://assets/textures/ai/game_ready/concrete.png")
const T_ASPHALT := preload("res://assets/textures/asphalt.png") const T_ASPHALT := preload("res://assets/textures/ai/game_ready/asphalt.png")
const T_AWNING := preload("res://assets/textures/awning.png") const T_AWNING := preload("res://assets/textures/ai/game_ready/awning.png")
const T_SKYLINE := preload("res://assets/textures/skyline.png")
## steps, building facade, restricted door, sidewalk, pavement, and street edge. ## steps, building facade, restricted door, sidewalk, pavement, and street edge.
## No art assets required; all geometry is BoxMesh with flat materials. ## No art assets required; all geometry is BoxMesh with flat materials.
@@ -33,7 +32,6 @@ func build() -> void:
_build_crosswalk() _build_crosswalk()
_build_street_furniture() _build_street_furniture()
_build_guard_spawn() _build_guard_spawn()
_build_skyline_backdrop()
_build_navmesh() _build_navmesh()
set_night_mode(false) set_night_mode(false)
@@ -68,13 +66,13 @@ func _build_building() -> void:
var building := StaticBody3D.new() var building := StaticBody3D.new()
building.name = "Building" building.name = "Building"
# Main facade (textured brick) # Main facade (textured brick)
_add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -2), Color(0.85, 0.8, 0.78), false, T_BRICK, Vector2(10, 5)) _add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -6), Color(0.85, 0.8, 0.78), false, T_BRICK, Vector2(10, 5))
# Entrance recess + door recess frame # Entrance recess + door recess frame
_add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -4.0), Color(0.08, 0.08, 0.09)) _add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -3.9), Color(0.08, 0.08, 0.09))
# Door frame and dark door # Door frame and dark door
_add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -4.15), Color(0.13, 0.12, 0.11)) _add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -3.98), Color(0.13, 0.12, 0.11))
# Door glass (semi-transparent) # Door glass (semi-transparent)
var glass := _add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -4.0), Color(0.6, 0.75, 0.9, 0.55)) var glass := _add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -3.9), Color(0.6, 0.75, 0.9, 0.55))
var glass_mat := StandardMaterial3D.new() var glass_mat := StandardMaterial3D.new()
glass_mat.albedo_color = Color(0.6, 0.75, 0.9, 0.45) glass_mat.albedo_color = Color(0.6, 0.75, 0.9, 0.45)
glass_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA glass_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
@@ -82,13 +80,13 @@ func _build_building() -> void:
glass_mat.metallic = 0.1 glass_mat.metallic = 0.1
glass.material_override = glass_mat glass.material_override = glass_mat
# Door lock indicator (green = open, red = locked), placed beside the handle # Door lock indicator (green = open, red = locked), placed beside the handle
door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -4.35), Color(0.1, 0.9, 0.3)) door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -3.82), Color(0.1, 0.9, 0.3))
door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3)) door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3))
var col := BoxShape3D.new() var col := BoxShape3D.new()
col.size = Vector3(40, 18, 4) col.size = Vector3(40, 18, 4)
var so := building.create_shape_owner(building) var so := building.create_shape_owner(building)
building.shape_owner_add_shape(so, col) building.shape_owner_add_shape(so, col)
building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 9, -2))) building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 9, -6)))
root.add_child(building) root.add_child(building)
func _build_steps() -> void: func _build_steps() -> void:
@@ -102,7 +100,7 @@ func _build_steps() -> void:
var height := 0.16 var height := 0.16
for i in 4: for i in 4:
var y: float = i * height var y: float = i * height
var z: float = i * tread var z: float = 4.0 * tread - i * tread
_add_box(steps, Vector3(step_w - i * 0.35, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5)) _add_box(steps, Vector3(step_w - i * 0.35, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5))
# Landing at the top # Landing at the top
_add_box(steps, Vector3(9, 0.2, 3), Vector3(0, 4 * height + 0.1, -1.5), Color(0.5, 0.47, 0.45)) _add_box(steps, Vector3(9, 0.2, 3), Vector3(0, 4 * height + 0.1, -1.5), Color(0.5, 0.47, 0.45))
@@ -206,8 +204,10 @@ func _build_street_furniture() -> void:
# Low-poly trees along the street front. # Low-poly trees along the street front.
var trunk_color := Color(0.35, 0.25, 0.18) var trunk_color := Color(0.35, 0.25, 0.18)
var leaf_color := Color(0.16, 0.42, 0.2) var leaf_color := Color(0.16, 0.42, 0.2)
# Flank the walkway; keep the sight line to the door clear.
for i in 6: for i in 6:
var x := -11.0 + i * 4.4 var side := -1.0 if i % 2 == 0 else 1.0
var x := side * (6.5 + float(int(i / 2)) * 3.0)
var z := 4.0 + float((i * 7) % 5) var z := 4.0 + float((i * 7) % 5)
var tree := Node3D.new() var tree := Node3D.new()
tree.name = "Tree_%d" % (i + 1) tree.name = "Tree_%d" % (i + 1)
@@ -261,32 +261,10 @@ func _plain_material(color: Color) -> StandardMaterial3D:
mat.albedo_color = color mat.albedo_color = color
return mat return mat
func _build_skyline_backdrop() -> void:
## Distant procedural skyline on four unshaded planes around the block.
var mat := StandardMaterial3D.new()
mat.albedo_texture = T_SKYLINE
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
const DIST := 95.0
const HEIGHT := 90.0
for i in 4:
var plane := MeshInstance3D.new()
plane.name = "Skyline_%d" % i
var pm := PlaneMesh.new()
pm.size = Vector2(300, HEIGHT)
plane.mesh = pm
plane.material_override = mat
var angle := i * 90.0
plane.position = Vector3(sin(deg_to_rad(angle)) * DIST, HEIGHT / 2.0, cos(deg_to_rad(angle)) * DIST)
plane.rotation_degrees = Vector3(0, -angle + 90.0, 0)
plane.cast_shadow = (0 as GeometryInstance3D.ShadowCastingSetting)
root.add_child(plane)
func _build_guard_spawn() -> void: func _build_guard_spawn() -> void:
var marker := Marker3D.new() var marker := Marker3D.new()
marker.name = "GuardSpawn" marker.name = "GuardSpawn"
marker.position = Vector3(0, 1.6, -2.5) marker.position = Vector3(0, 0.1, -2.0)
root.add_child(marker) root.add_child(marker)
func _lock_indicator_material(color: Color) -> StandardMaterial3D: func _lock_indicator_material(color: Color) -> StandardMaterial3D:
+24 -1
View File
@@ -12,9 +12,10 @@ var gm: Node = null
var state := 0 var state := 0
var state_frames := 0 var state_frames := 0
var targeted := false var targeted := false
var camera_staged := false
func _initialize() -> void: func _initialize() -> void:
root.size = Vector2i(1600, 900) root.size = Vector2i(1280, 720)
var packed: PackedScene = load("res://scenes/main.tscn") var packed: PackedScene = load("res://scenes/main.tscn")
var inst: Node = packed.instantiate() var inst: Node = packed.instantiate()
root.add_child(inst) root.add_child(inst)
@@ -29,6 +30,11 @@ func _process(_delta: float) -> bool:
push_error("GameManager not found") push_error("GameManager not found")
quit(1) quit(1)
return true return true
if gm != null and not camera_staged:
gm.guard.global_position = Vector3(0, 0.1, 10.5)
gm.guard.rotation.y = 0.0
gm.guard.head.rotation.x = deg_to_rad(2.0)
camera_staged = true
if frames > MAX_FRAMES: if frames > MAX_FRAMES:
print("timeout reached") print("timeout reached")
quit(0) quit(0)
@@ -49,6 +55,7 @@ func _advance() -> void:
# Wait for a nearby NPC so the interaction HUD has a target. # Wait for a nearby NPC so the interaction HUD has a target.
if gm != null and gm.npcs.size() > 0: if gm != null and gm.npcs.size() > 0:
gm.call("_cycle_nearby_target") gm.call("_cycle_nearby_target")
_face_target()
targeted = true targeted = true
state = 2 state = 2
state_frames = 0 state_frames = 0
@@ -56,6 +63,7 @@ func _advance() -> void:
if state_frames >= 30: if state_frames >= 30:
_shoot("02-targeting-npc.png") _shoot("02-targeting-npc.png")
if targeted and gm != null: if targeted and gm != null:
_face_target()
gm.call("_toggle_inspection") gm.call("_toggle_inspection")
state = 3 state = 3
state_frames = 0 state_frames = 0
@@ -75,6 +83,8 @@ func _advance() -> void:
# Close the radio form, then jump the shift to the LATE phase. # Close the radio form, then jump the shift to the LATE phase.
if gm != null: if gm != null:
gm.radio_panel.hide() gm.radio_panel.hide()
gm.guard.rotation.y = 0.0
gm.guard.head.rotation.x = deg_to_rad(2.0)
gm.coordinator.elapsed_s = 750.0 gm.coordinator.elapsed_s = 750.0
state = 5 state = 5
state_frames = 0 state_frames = 0
@@ -82,6 +92,8 @@ func _advance() -> void:
# Let the phase transition (and street lamps / marquee) settle, then # Let the phase transition (and street lamps / marquee) settle, then
# take the night avenue shot. # take the night avenue shot.
if state_frames >= 90: if state_frames >= 90:
gm.guard.rotation.y = 0.0
gm.guard.head.rotation.x = deg_to_rad(2.0)
_shoot("05-late-night-avenue.png") _shoot("05-late-night-avenue.png")
if gm != null: if gm != null:
gm.call("_toggle_flashlight") gm.call("_toggle_flashlight")
@@ -93,6 +105,17 @@ func _advance() -> void:
state = 7 state = 7
quit(0) quit(0)
func _face_target() -> void:
var target: Node3D = gm.targeted_npc
if target == null:
return
var to: Vector3 = target.global_position - gm.guard.global_position
to.y = 0.0
if to.length() < 0.01:
return
gm.guard.rotation.y = atan2(-to.x, -to.z)
gm.guard.head.rotation.x = deg_to_rad(-4.0)
func _shoot(file_name: String) -> void: func _shoot(file_name: String) -> void:
var dir := DirAccess.open("user://") var dir := DirAccess.open("user://")
if dir == null or not dir.dir_exists("screenshots"): if dir == null or not dir.dir_exists("screenshots"):
+123
View File
@@ -0,0 +1,123 @@
extends SceneTree
## Windowed smoke helper for the ADR-0007–0010 control pass (focus ring + action
## wheel). Boots the real main scene, stages one NPC in front of the guard, opens
## the wheel, and saves two PNGs to user://screenshots/. Exits 1 if the focus or
## radial state did not come up, so it can be used as a smoke check.
## Usage: godot --path . --script res://src/test/smoke_focus_radial.gd
const OUT_DIR := "user://screenshots"
const MAX_FRAMES := 1800
const STAGE_DISTANCE := 2.4
var frames := 0
var gm: Node = null
var state := 0
var state_frames := 0
var stage_npc: Node3D = null
var failures: Array[String] = []
func _initialize() -> void:
root.size = Vector2i(1280, 720)
var packed: PackedScene = load("res://scenes/main.tscn")
root.add_child(packed.instantiate())
func _process(_delta: float) -> bool:
frames += 1
if gm == null:
gm = root.find_child("GameManager", true, false)
if gm == null:
if frames > 600:
_fail("GameManager not found")
return false
if frames > MAX_FRAMES:
_fail("timeout before the radial state")
return true
_advance()
return false
func _advance() -> void:
state_frames += 1
match state:
0:
# Wait for the guard to settle on the ground, then grab one NPC.
if state_frames >= 40 and gm.npcs.size() > 0:
print("smoke: spawn guard_y=%.2f z=%.2f" % [
(gm.guard.global_position as Vector3).y,
(gm.guard.global_position as Vector3).z,
])
_shoot("smoke-00-spawn.png")
stage_npc = gm.npcs[0]
_hide_other_npcs(stage_npc)
state = 1
state_frames = 0
1:
# Keep the staged NPC in front of the guard so the ring is visible.
_stage_target()
if state_frames >= 20:
gm.call("_cycle_nearby_target")
if gm.targeted_npc == null:
_fail("no NPC focused")
elif gm.radial == null:
_fail("radial layer missing")
else:
print("smoke: guard_y=%.2f npc_y=%.2f" % [
(gm.guard.global_position as Vector3).y,
(gm.targeted_npc.global_position as Vector3).y,
])
_shoot("smoke-01-focus-ring.png")
gm.call("_open_radial")
state = 2
state_frames = 0
2:
if state_frames >= 20:
if not gm.radial.is_open:
_fail("action wheel did not open")
else:
_shoot("smoke-02-action-wheel.png")
gm.call("_cancel_radial")
state = 3
state_frames = 0
3:
if state_frames >= 5:
if gm.radial.is_open:
_fail("action wheel did not close on cancel")
_finish()
func _stage_target() -> void:
if stage_npc == null or not is_instance_valid(stage_npc):
stage_npc = gm.npcs[0] if gm.npcs.size() > 0 else null
if stage_npc == null:
return
_hide_other_npcs(stage_npc)
var guard_pos: Vector3 = gm.guard.global_position
var forward: Vector3 = -gm.guard.global_transform.basis.z
forward.y = 0.0
forward = forward.normalized()
stage_npc.global_position = guard_pos + forward * STAGE_DISTANCE + Vector3(0, 0.4, 0)
stage_npc.rotation.y = gm.guard.rotation.y + PI
func _hide_other_npcs(keep: Node3D) -> void:
for npc in gm.npcs:
if is_instance_valid(npc) and npc is Node3D and npc != keep:
(npc as Node3D).visible = false
func _shoot(file_name: String) -> void:
var dir := DirAccess.open("user://")
if dir == null or not dir.dir_exists("screenshots"):
dir.make_dir_recursive("screenshots")
var img: Image = root.get_texture().get_image()
var err: int = img.save_png(OUT_DIR.path_join(file_name))
print("%s -> err=%d (%dx%d)" % [file_name, err, img.get_width(), img.get_height()])
func _fail(message: String) -> void:
failures.append(message)
_finish()
func _finish() -> void:
if failures.is_empty():
print("SMOKE OK: focus ring + action wheel opened and cancelled")
quit(0)
else:
for message in failures:
printerr("SMOKE FAIL: " + message)
quit(1)
+134
View File
@@ -0,0 +1,134 @@
extends RefCounted
## Venue-correct access artifacts (override_access, no free recognition on guests).
const Rules := preload("res://src/core/rules.gd")
const AccessLoadout := preload("res://src/core/access_loadout.gd")
const AccessVerifier := preload("res://src/core/access_verifier.gd")
const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd")
var verifier := AccessVerifier.new()
func _guest() -> NpcProfile:
var p := NpcProfile.new()
p.id = "authorized_guest"
p.display_name = "Authorized Guest"
p.npc_type = Rules.NpcType.AUTHORIZED_GUEST
return p
func _staff() -> NpcProfile:
var p := NpcProfile.new()
p.id = "authorized_staff"
p.display_name = "Staff Member"
p.npc_type = Rules.NpcType.AUTHORIZED_STAFF
return p
func _kinds(artifacts: Array) -> Array:
var out: Array = []
for a in artifacts:
out.append(a.kind)
return out
func test_joke_guest_valid_needs_screen_then_grants() -> bool:
var policy = ScenarioFactory.joke_shop().access_policy
var arts: Array = AccessLoadout.build(_guest(), policy, {"has_id": true, "has_ticket": true})
var has_rec := false
for art in arts:
if art.kind == Rules.AccessKind.RECOGNITION:
has_rec = true
var a := TestUtils.check(not has_rec, "no free recognition")
var before := verifier.verify(arts, policy, false)
var after := verifier.verify(arts, policy, true)
var b := TestUtils.check_eq(before["granted"], false, "unscreened valid guest denied")
var c := TestUtils.check_eq(after["granted"], true, "screened valid guest granted")
return a and b and c
func test_joke_guest_no_id_denied_even_when_screened() -> bool:
var policy = ScenarioFactory.joke_shop().access_policy
var arts: Array = AccessLoadout.build(_guest(), policy, {"has_id": false, "has_ticket": true})
var result := verifier.verify(arts, policy, true)
return TestUtils.check_eq(result["granted"], false, "no-id guest denied")
func test_joke_guest_expired_id_denied() -> bool:
var policy = ScenarioFactory.joke_shop().access_policy
var arts: Array = AccessLoadout.build(_guest(), policy, {
"has_id": true, "has_ticket": true, "id_status": Rules.ArtifactStatus.EXPIRED,
})
var result := verifier.verify(arts, policy, true)
return TestUtils.check_eq(result["granted"], false, "expired id denied")
func test_joke_event_payload_matches_factory() -> bool:
var shop = ScenarioFactory.joke_shop()
var found_expired := false
var found_no_id := false
for event in shop.event_pool:
var ov: Dictionary = event.get("override_access", {})
if event.get("id", "") == "guest_expired_id":
found_expired = int(ov.get("id_status", -1)) == Rules.ArtifactStatus.EXPIRED
if event.get("id", "") == "guest_no_id":
found_no_id = ov.get("has_id", true) == false
var a := TestUtils.check(found_expired, "guest_expired_id carries expired status")
var b := TestUtils.check(found_no_id, "guest_no_id has has_id false")
return a and b
func test_joke_staff_recognized_without_override() -> bool:
var policy = ScenarioFactory.joke_shop().access_policy
var arts: Array = AccessLoadout.build(_staff(), policy, {})
var has_rec := false
for a in arts:
if a.kind == Rules.AccessKind.RECOGNITION and a.status == Rules.ArtifactStatus.VALID:
has_rec = true
var screened := verifier.verify(arts, policy, true)
return TestUtils.check(has_rec, "staff are recognized") and TestUtils.check_eq(screened["granted"], true, "recognized staff enter after screen")
func test_building_guest_uses_wristband_not_recognition() -> bool:
var policy = ScenarioFactory.hollywood_building().access_policy
var arts: Array = AccessLoadout.build(_guest(), policy, {})
var has_rec := false
var has_band := false
for a in arts:
if a.kind == Rules.AccessKind.RECOGNITION:
has_rec = true
if a.kind == Rules.AccessKind.WRISTBAND and a.status == Rules.ArtifactStatus.VALID:
has_band = true
var result := verifier.verify(arts, policy)
var a := TestUtils.check(not has_rec, "building guest is not auto-recognized")
var b := TestUtils.check(has_band, "building guest presents a wristband")
var c := TestUtils.check_eq(result["granted"], true, "valid wristband grants")
return a and b and c
func test_studio_ticket_grants_without_recognition() -> bool:
var policy = ScenarioFactory.tv_studio().access_policy
var arts: Array = AccessLoadout.build(_guest(), policy, {"has_ticket": true})
var has_rec := false
for a in arts:
if a.kind == Rules.AccessKind.RECOGNITION:
has_rec = true
var result := verifier.verify(arts, policy)
return TestUtils.check(not has_rec, "audience ticket is not recognition") and TestUtils.check_eq(result["granted"], true, "ticket grants at studio")
func test_studio_counterfeit_denied() -> bool:
var policy = ScenarioFactory.tv_studio().access_policy
var arts: Array = AccessLoadout.build(_guest(), policy, {
"wristband_status": Rules.ArtifactStatus.COUNTERFEIT,
"credential_status": Rules.ArtifactStatus.COUNTERFEIT,
})
var result := verifier.verify(arts, policy)
return TestUtils.check_eq(result["granted"], false, "counterfeit studio artifacts deny")
func test_carried_items_override_and_roll() -> bool:
var forced: Array = AccessLoadout.roll_carried_items(1, {"carried_items": ["weapon"]})
var a := TestUtils.check_eq(forced, ["weapon"], "override items win")
var rolled: Array = AccessLoadout.roll_carried_items(42, {})
var b := TestUtils.check(rolled is Array, "roll returns an array")
var again: Array = AccessLoadout.roll_carried_items(42, {})
var c := TestUtils.check_eq(rolled, again, "same seed same pocket")
return a and b and c
func test_joke_shop_sidewalk_exists() -> bool:
var shop = ScenarioFactory.joke_shop()
var studio = ScenarioFactory.tv_studio()
var a := TestUtils.check(shop.locations.has("sidewalk"), "joke shop sidewalk")
var b := TestUtils.check(studio.locations.has("sidewalk"), "studio sidewalk")
var c := TestUtils.check_eq(shop.waypoint_for_intent("film").z, shop.locations["sidewalk"].z, "film walks to sidewalk")
return a and b and c
+100
View File
@@ -0,0 +1,100 @@
extends RefCounted
## Focus cone math and radial menu wrap/cancel (ADR-0008, ADR-0009).
const NpcFocus := preload("res://src/game/npc_focus.gd")
const ActionRadial := preload("res://src/game/ui/action_radial.gd")
func test_cone_picks_centered_head() -> bool:
var origin := Vector3(0, 1.6, 0)
var forward := Vector3(0, 0, -1)
var heads: Array = [
Vector3(3, 1.6, -4),
Vector3(0.05, 1.6, -4),
Vector3(-2, 1.6, -4),
]
var idx := NpcFocus.pick_index(origin, forward, heads)
return TestUtils.check_eq(idx, 1, "head nearest the look axis wins")
func test_cone_rejects_behind_and_far() -> bool:
var origin := Vector3(0, 1.6, 0)
var forward := Vector3(0, 0, -1)
var behind := NpcFocus.score_head(origin, forward, Vector3(0, 1.6, 4))
var far := NpcFocus.score_head(origin, forward, Vector3(0, 1.6, -40))
var a := TestUtils.check_eq(behind, -1.0, "behind the camera is ignored")
var b := TestUtils.check_eq(far, -1.0, "out of range is ignored")
return a and b
func test_radial_wrap_and_skip_wand() -> bool:
var menu: Node = ActionRadial.new()
menu.open_menu("Guest", false)
var start: int = menu.selected
menu.step(-1)
var a := TestUtils.check(menu.selected != start, "step wrap moves selection")
var b := TestUtils.check(menu.is_slice_enabled(menu.selected), "landed on an enabled slice")
# Walk forward until we would hit wand; it must skip.
menu.selected = 4
menu.step(1)
var c := TestUtils.check_eq(menu.slice_id(menu.selected), "warning", "wand skipped when venue has none")
menu.free()
return a and b and c
func test_radial_cancel_does_not_confirm() -> bool:
var menu: Node = ActionRadial.new()
menu.open_menu("Guest", true)
var got := {"id": ""}
menu.confirmed.connect(func(id): got["id"] = id)
menu.cancelled.connect(func(): got["id"] = "cancelled")
menu.close_menu()
var a := TestUtils.check_eq(got["id"], "", "closing without confirm emits nothing")
var slice: String = menu.confirm_selected()
var b := TestUtils.check_eq(slice, "", "confirm while closed is empty")
menu.free()
return a and b
func test_radial_confirm_returns_id() -> bool:
var menu: Node = ActionRadial.new()
menu.open_menu("Guest", true)
menu.selected = 0
var slice: String = menu.confirm_selected()
menu.free()
return TestUtils.check_eq(slice, "raise_hand", "space-confirm uses highlighted slice")
func test_angle_top_is_first_slice() -> bool:
var menu: Node = ActionRadial.new()
var idx: int = menu.angle_to_index(-PI / 2.0)
menu.free()
return TestUtils.check_eq(idx, 0, "top of the doughnut is slice 0")
func test_wand_not_on_w() -> bool:
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var a := TestUtils.check(not src.contains("KEY_W:"), "W is not a game-manager action key")
var b := TestUtils.check(src.contains("is_action_pressed(\"wand\")"), "wand goes through the named action")
var c := TestUtils.check(not _action_has_key("wand", KEY_W), "wand action is not bound to W")
var d := TestUtils.check(_action_has_key("wand", KEY_C), "wand action is bound to C")
return a and b and c and d
func test_named_input_actions_exist() -> bool:
var menu_ok := _action_has_key("action_menu", KEY_SPACE)
var confirm_ok := _action_has_key("confirm", KEY_SPACE) and _action_has_key("confirm", KEY_ENTER)
var cancel_ok := _action_has_key("cancel", KEY_ESCAPE)
var inspect_ok := _action_has_key("inspect", KEY_E)
return TestUtils.check(menu_ok, "action_menu has Space") \
and TestUtils.check(confirm_ok, "confirm has Space + Enter") \
and TestUtils.check(cancel_ok, "cancel has Esc") \
and TestUtils.check(inspect_ok, "inspect has E")
func test_cancel_has_right_mouse() -> bool:
var events: Array = InputMap.action_get_events("cancel")
var found := false
for event in events:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT:
found = true
return TestUtils.check(found, "cancel action includes right mouse button")
func _action_has_key(action: String, keycode: Key) -> bool:
if not InputMap.has_action(action):
return false
for event in InputMap.action_get_events(action):
if event is InputEventKey and event.physical_keycode == keycode:
return true
return false
+4 -1
View File
@@ -20,10 +20,13 @@ func test_save_round_trip() -> bool:
var last: Dictionary = results[results.size() - 1] var last: Dictionary = results[results.size() - 1]
var c := TestUtils.check_eq(last.get("scenario_id", ""), "hollywood_building", "round-trips scenario id") var c := TestUtils.check_eq(last.get("scenario_id", ""), "hollywood_building", "round-trips scenario id")
var d := TestUtils.check_eq(float(last.get("reputation", {}).get("rating", 0.0)), 82.5, "round-trips rating") var d := TestUtils.check_eq(float(last.get("reputation", {}).get("rating", 0.0)), 82.5, "round-trips rating")
save.free()
return a and b and c and d return a and b and c and d
func test_load_returns_array_when_empty_or_missing() -> bool: func test_load_returns_array_when_empty_or_missing() -> bool:
# The tester home may already contain results; this only asserts shape safety. # The tester home may already contain results; this only asserts shape safety.
var save := _save_instance() var save := _save_instance()
var results: Array = save.load_shift_results() var results: Array = save.load_shift_results()
return TestUtils.check(results is Array, "load_shift_results always returns Array") var ok := TestUtils.check(results is Array, "load_shift_results always returns Array")
save.free()
return ok
+15 -1
View File
@@ -38,7 +38,7 @@ func test_tv_studio_has_all_npc_types() -> bool:
var _spawn_count := 0 var _spawn_count := 0
func _on_spawn(_profile: NpcProfile, _intent: String, _id: String) -> void: func _on_spawn(_profile: NpcProfile, _intent: String, _id: String, _event: Dictionary = {}) -> void:
_spawn_count += 1 _spawn_count += 1
func test_late_phase_reaches_violent_event() -> bool: func test_late_phase_reaches_violent_event() -> bool:
@@ -137,3 +137,17 @@ func test_waypoints_map_intent_to_location() -> bool:
var d := TestUtils.check_eq(scenario.waypoint_for_intent("sit"), steps_end, "sit -> steps_end") var d := TestUtils.check_eq(scenario.waypoint_for_intent("sit"), steps_end, "sit -> steps_end")
var e := TestUtils.check_eq(scenario.waypoint_for_intent("unknown_intent"), sidewalk, "unknown -> sidewalk fallback") var e := TestUtils.check_eq(scenario.waypoint_for_intent("unknown_intent"), sidewalk, "unknown -> sidewalk fallback")
return a and b and c and d and e return a and b and c and d and e
func test_spawn_rate_is_time_based() -> bool:
# Regression: tick() runs at physics rate. Rolling the event scheduler every
# frame spawned >1000 NPCs in seconds. Ten simulated seconds at 60 Hz must
# stay in the normal single-digit-per-second range.
var scenario: VenueScenario = ScenarioFactory.hollywood_building()
var coordinator := ShiftCoordinator.new()
coordinator.setup(scenario, 60.0, 99)
_spawn_count = 0
coordinator.npc_spawn_requested.connect(_on_spawn)
coordinator.start()
for i in 600:
coordinator.tick(1.0 / 60.0)
return TestUtils.check(_spawn_count <= 60, "spawns stay time-based at physics rate (was %d)" % _spawn_count)
+1 -1
View File
@@ -30,7 +30,7 @@ func test_strings_cover_ui_keys() -> bool:
"hud.aim_first", "hud.no_one_nearby", "hud.targeting", "hud.no_wand", "hud.aim_first", "hud.no_one_nearby", "hud.targeting", "hud.no_wand",
"hud.screen_first", "access.granted", "access.denied", "access.policy_satisfied", "hud.screen_first", "access.granted", "access.denied", "access.policy_satisfied",
"access.wrong_grant", "access.wrong_deny", "wand.admitted_prohibited", "access.wrong_grant", "access.wrong_deny", "wand.admitted_prohibited",
"wand.denied_false_alarm", "wand.cleared", "wand.refused", "radio.address_first", "wand.denied_false_alarm", "wand.cleared", "wand.refused", "wand.recharge", "radio.address_first",
"radio.blocked", "radio.inaccurate", "radio.received", "radio.emergency_dispatched", "radio.blocked", "radio.inaccurate", "radio.received", "radio.emergency_dispatched",
"responder.arrived", "react.comply", "react.leave", "react.argue", "react.record", "responder.arrived", "react.comply", "react.leave", "react.argue", "react.record",
"react.ignore", "react.threaten", "react.force_entry", "react.return_later", "react.ignore", "react.threaten", "react.force_entry", "react.return_later",
+451
View File
@@ -0,0 +1,451 @@
#!/usr/bin/env python3
"""Generate, crop, resize, and quality-check game image assets."""
import argparse
import json
import sys
from pathlib import Path
from PIL import Image, ImageChops, ImageOps, ImageStat
import generate_textures
ROOT = Path(__file__).resolve().parent.parent
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"}
DEFAULT_ROLE_IMAGE_MODEL = "openai/gpt-5-image-mini"
API_ASPECTS = {
"texture": "1:1", "tile": "1:1", "icon": "1:1", "app_icon": "1:1",
"overlay": "1:1", "card": "3:2", "banner": "3:2", "backdrop": "3:2",
"background": "3:2",
}
PROFILES = {
"texture": {"target": (512, 512), "min": (256, 256), "max": (1024, 1024),
"aspect": 1.0, "alpha": None, "view": "flat/orthographic"},
"tile": {"target": (512, 512), "min": (256, 256), "max": (1024, 1024),
"aspect": 1.0, "alpha": None, "view": "flat/orthographic",
"power_of_two": True, "seamless": True},
"icon": {"target": (64, 64), "min": (32, 32), "max": (512, 512),
"aspect": 1.0, "alpha": True, "view": "flat, centered glyph"},
"app_icon": {"target": (1024, 1024), "min": (512, 512), "max": (1024, 1024),
"aspect": 1.0, "alpha": None, "view": "square, centered composition"},
"overlay": {"target": (512, 512), "min": (256, 256), "max": (1024, 1024),
"aspect": 1.0, "alpha": None, "view": "screen-space overlay",
"min_contrast": 5.0},
"card": {"target": (256, 128), "min": (128, 64), "max": (1024, 512),
"aspect": 2.0, "alpha": None, "view": "flat, front-facing card"},
"banner": {"target": (1024, 256), "min": (512, 128), "max": (2048, 512),
"aspect": 4.0, "alpha": None, "view": "flat, front-facing banner"},
"backdrop": {"target": (1024, 256), "min": (1024, 256), "max": (8192, 2048),
"aspect": 4.0, "alpha": True,
"view": "straight-on wide backdrop on transparency"},
"background": {"target": (1920, 1080), "min": (1280, 720), "max": (8192, 4320),
"aspect": 16 / 9, "alpha": None, "view": "intended game camera and horizon"},
"screenshot": {"target": (1280, 720), "min": (1280, 720), "max": (3840, 2160),
"aspect": 16 / 9, "alpha": None, "view": "gameplay frame, venue, and UI state",
"min_contrast": 5.0},
}
ROLE_GUIDANCE = {
"texture": "flat-lit game surface texture, straight-on orthographic view, no perspective",
"tile": "seamless tileable surface texture, flat even lighting, orthographic, no perspective or shadows",
"icon": "single centered flat UI glyph, clean silhouette, transparent background, no mockup or perspective",
"app_icon": "square game application icon, centered composition, no perspective",
"overlay": "screen-space overlay texture with transparent edges, no perspective",
"card": "single wide landscape game UI card filling the frame, front-facing and flat, no perspective, no surrounding mockup or whitespace",
"banner": "wide front-facing game banner texture, keep important details in a narrow horizontal band through the image center for final cropping, no perspective, no surrounding scene",
"backdrop": "wide isolated skyline silhouette across a low horizon in a narrow horizontal band through the image center, transparent background, no foreground objects",
"background": "wide 16:9 game background with a coherent horizon and straight camera view, keep important details in the central horizontal band for final cropping",
}
def resolve_path(value):
path = Path(value).expanduser()
return path if path.is_absolute() else ROOT / path
def has_alpha(image):
return image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info)
def has_transparency(image):
if not has_alpha(image):
return False
return image.convert("RGBA").getchannel("A").getextrema()[0] < 255
def parse_color(value):
value = value.strip().lstrip("#")
if len(value) not in (6, 8):
raise argparse.ArgumentTypeError("color must be #RRGGBB or #RRGGBBAA")
try:
channels = tuple(int(value[index:index + 2], 16)
for index in range(0, len(value), 2))
except ValueError as exc:
raise argparse.ArgumentTypeError(
"color must use hexadecimal digits") from exc
return channels + ((255,) if len(channels) == 3 else ())
def save_image(image, path):
path.parent.mkdir(parents=True, exist_ok=True)
suffix = path.suffix.lower()
if suffix in (".jpg", ".jpeg") and image.mode in ("RGBA", "LA"):
background = Image.new("RGB", image.size, (0, 0, 0))
background.paste(image.convert("RGBA"),
mask=image.convert("RGBA").getchannel("A"))
image = background
image.save(path)
print(
f"wrote {path.relative_to(ROOT) if path.is_relative_to(ROOT) else path} {image.size}")
def crop_image(source, output, width, height, x=None, y=None):
with Image.open(source) as image:
image.load()
source_width, source_height = image.size
if width <= 0 or height <= 0 or width > source_width or height > source_height:
raise ValueError(
f"crop {width}x{height} must fit inside source {source_width}x{source_height}")
if (x is None) != (y is None):
raise ValueError("--x and --y must be supplied together")
left = (source_width - width) // 2 if x is None else x
top = (source_height - height) // 2 if y is None else y
if left < 0 or top < 0 or left + width > source_width or top + height > source_height:
raise ValueError(
"crop origin and dimensions extend beyond the source image")
cropped = image.crop((left, top, left + width, top + height))
save_image(cropped, output)
def resize_image(source, output, width, height, fit="contain", background=(0, 0, 0, 0)):
if width <= 0 or height <= 0:
raise ValueError("resize dimensions must be positive")
with Image.open(source) as image:
image.load()
if fit == "stretch":
result = image.resize((width, height), Image.Resampling.LANCZOS)
elif fit == "cover":
result = ImageOps.fit(image, (width, height),
method=Image.Resampling.LANCZOS)
else:
contained = ImageOps.contain(
image, (width, height), method=Image.Resampling.LANCZOS)
use_alpha = has_alpha(image) or background[3] < 255
mode = "RGBA" if use_alpha else "RGB"
fill = background if use_alpha else background[:3]
result = Image.new(mode, (width, height), fill)
contained = contained.convert(mode)
offset = ((width - contained.width) // 2,
(height - contained.height) // 2)
if use_alpha:
result.alpha_composite(contained, offset)
else:
result.paste(contained, offset)
save_image(result, output)
def infer_role(path):
parts = [part.lower() for part in path.parts]
stem = path.stem.lower()
if "screenshots" in parts or "screenshot" in stem:
return "screenshot"
if "icons" in parts:
return "icon"
if "cards" in parts:
return "card"
if stem == "icon":
return "app_icon"
if "vignette" in stem or "overlay" in stem:
return "overlay"
if "skyline" in stem or "backdrop" in parts:
return "backdrop"
if "awning" in stem or "banner" in parts:
return "banner"
if stem in {"brick_facade", "concrete", "asphalt"} or "tiles" in parts:
return "tile"
if "backgrounds" in parts or "background" in parts:
return "background"
return "texture"
def _mean_edge_difference(image):
rgb = image.convert("RGB")
width, height = rgb.size
horizontal = ImageStat.Stat(ImageChops.difference(
rgb.crop((0, 0, 1, height)), rgb.crop((width - 1, 0, width, height))))
vertical = ImageStat.Stat(ImageChops.difference(
rgb.crop((0, 0, width, 1)), rgb.crop((0, height - 1, width, height))))
return max(sum(horizontal.mean) / len(horizontal.mean),
sum(vertical.mean) / len(vertical.mean))
def audit_image(path, role="auto", seam_threshold=18.0):
path = Path(path)
chosen_role = infer_role(path) if role == "auto" else role
profile = PROFILES[chosen_role]
errors = []
warnings = []
try:
with Image.open(path) as image:
image.load()
width, height = image.size
alpha = has_transparency(image)
minimum = profile["min"]
maximum = profile["max"]
if width < minimum[0] or height < minimum[1]:
errors.append(
f"too small: {width}x{height}; minimum is {minimum[0]}x{minimum[1]}")
if width > maximum[0] or height > maximum[1]:
errors.append(
f"too large: {width}x{height}; maximum is {maximum[0]}x{maximum[1]}")
aspect = profile.get("aspect")
actual_aspect = width / height
if aspect and abs(actual_aspect - aspect) / aspect > 0.12:
errors.append(
f"wrong aspect ratio: {actual_aspect:.3f}; expected about {aspect:.3f}")
if profile.get("alpha") is True and not alpha:
errors.append("transparency required but image is opaque")
if alpha and image.convert("RGBA").getchannel("A").getbbox() is None:
errors.append("image is fully transparent")
elif profile.get("alpha") is False and alpha:
warnings.append(
"image has an alpha channel although this role is normally opaque")
if profile.get("power_of_two") and (width & (width - 1) or height & (height - 1)):
warnings.append("dimensions are not powers of two")
edge_difference = None
if profile.get("seamless"):
edge_difference = _mean_edge_difference(image)
if edge_difference > seam_threshold:
errors.append(
f"tile edges differ by {edge_difference:.1f} (limit {seam_threshold:.1f})")
contrast = None
if profile.get("min_contrast") is not None:
contrast = ImageStat.Stat(image.convert("L")).stddev[0]
if contrast < profile["min_contrast"]:
errors.append(
f"near-blank frame: contrast {contrast:.1f} is below {profile['min_contrast']:.1f}")
manual_review = [
f"Confirm {profile.get('view', 'intended')} perspective/projection visually; "
"automated checks cannot infer it reliably."
]
return {
"path": str(path), "role": chosen_role, "size": [width, height],
"alpha": alpha, "edge_difference": edge_difference, "contrast": contrast,
"quality": "bad" if errors else "good",
"errors": errors, "warnings": warnings, "manual_review": manual_review,
}
except (OSError, ValueError) as exc:
return {
"path": str(path), "role": chosen_role, "size": None, "alpha": None,
"edge_difference": None, "contrast": None, "quality": "bad",
"errors": [f"cannot read image: {exc}"],
"warnings": [], "manual_review": [],
}
def image_paths(values):
found = []
for value in values:
path = resolve_path(value)
if path.is_dir():
found.extend(item for item in path.rglob(
"*") if item.is_file() and item.suffix.lower() in IMAGE_EXTENSIONS)
elif path.suffix.lower() in IMAGE_EXTENSIONS:
found.append(path)
else:
raise ValueError(f"not an image file or directory: {path}")
return sorted(set(found))
def _generate(args):
profile = PROFILES[args.role]
if (args.width is None) != (args.height is None):
raise ValueError("--width and --height must be supplied together")
width, height = (
args.width, args.height) if args.width else profile["target"]
transparent = args.transparent if args.transparent is not None else profile.get(
"alpha") is True
prompt = f"{args.prompt.strip()}. {ROLE_GUIDANCE[args.role]}."
image = generate_textures.generate_image(
prompt, args.model, args.request_size, transparent,
aspect_ratio=API_ASPECTS[args.role])
foreground = getattr(args, "foreground", None)
if foreground:
image = colorize_alpha(image, foreground)
if args.role == "icon" and has_alpha(image):
image = resize_image_from_image(
image, width, height, "contain", (0, 0, 0, 0))
else:
image = ImageOps.fit(image, (width, height),
method=Image.Resampling.LANCZOS)
if args.role == "tile":
image = make_seamless(image)
save_image(image, resolve_path(args.output))
def resize_image_from_image(image, width, height, fit, background):
if fit == "cover":
return ImageOps.fit(image, (width, height), method=Image.Resampling.LANCZOS)
contained = ImageOps.contain(
image, (width, height), method=Image.Resampling.LANCZOS)
mode = "RGBA" if has_alpha(image) or background[3] < 255 else "RGB"
result = Image.new(mode, (width, height),
background if mode == "RGBA" else background[:3])
contained = contained.convert(mode)
offset = ((width - contained.width) // 2, (height - contained.height) // 2)
if mode == "RGBA":
result.alpha_composite(contained, offset)
else:
result.paste(contained, offset)
return result
def colorize_alpha(image, color):
if not has_alpha(image):
raise ValueError(
"foreground color requires an image with transparency")
result = Image.new("RGBA", image.size, color[:3])
result.putalpha(image.convert("RGBA").getchannel("A"))
return result
def make_seamless(image, band=32):
original_mode = image.mode
result = image.convert("RGBA")
width, height = result.size
pixels = result.load()
band = min(band, width // 4, height // 4)
def blend_edges(first_index, last_index, length, across_length, horizontal):
for offset in range(band):
weight = 1.0 - offset / band
first = first_index(offset)
last = last_index(offset)
for across in range(across_length):
first_pos = (first, across) if horizontal else (across, first)
last_pos = (last, across) if horizontal else (across, last)
first_color = pixels[first_pos]
last_color = pixels[last_pos]
average = tuple((a + b) / 2 for a,
b in zip(first_color, last_color))
pixels[first_pos] = tuple(round(a * (1 - weight) + b * weight)
for a, b in zip(first_color, average))
pixels[last_pos] = tuple(round(a * (1 - weight) + b * weight)
for a, b in zip(last_color, average))
blend_edges(lambda offset: offset, lambda offset: width - 1 - offset,
width, height, True)
blend_edges(lambda offset: offset, lambda offset: height - 1 - offset,
height, width, False)
return result if has_alpha(image) else result.convert(original_mode)
def seamless_image(source, output):
with Image.open(source) as image:
image.load()
result = make_seamless(image)
save_image(result, output)
def build_parser():
parser = argparse.ArgumentParser(
description="Create and validate game image assets.")
commands = parser.add_subparsers(dest="command", required=True)
generate = commands.add_parser(
"generate", help="generate an image using the existing OpenRouter backend")
generate.add_argument("--prompt", required=True)
generate.add_argument("--output", required=True,
help="output path, relative to the project root or absolute")
generate.add_argument("--role", choices=API_ASPECTS, required=True)
generate.add_argument(
"--model", default=DEFAULT_ROLE_IMAGE_MODEL)
generate.add_argument("--request-size", type=int,
default=generate_textures.GEN_SIZE)
generate.add_argument("--width", type=int)
generate.add_argument("--height", type=int)
generate.add_argument(
"--foreground", type=parse_color,
help="replace visible RGB with this color while preserving alpha")
alpha = generate.add_mutually_exclusive_group()
alpha.add_argument("--transparent", dest="transparent",
action="store_true")
alpha.add_argument("--opaque", dest="transparent", action="store_false")
generate.set_defaults(transparent=None)
generate.set_defaults(handler=_generate)
crop = commands.add_parser(
"crop", help="crop an exact-size region; defaults to the image center")
crop.add_argument("input")
crop.add_argument("output")
crop.add_argument("--width", type=int, required=True)
crop.add_argument("--height", type=int, required=True)
crop.add_argument("--x", type=int)
crop.add_argument("--y", type=int)
crop.set_defaults(handler=lambda args: crop_image(resolve_path(args.input), resolve_path(args.output),
args.width, args.height, args.x, args.y))
resize = commands.add_parser(
"resize", help="resize to exact dimensions while preserving aspect by default")
resize.add_argument("input")
resize.add_argument("output")
resize.add_argument("--width", type=int, required=True)
resize.add_argument("--height", type=int, required=True)
resize.add_argument("--fit", choices=("contain",
"cover", "stretch"), default="contain")
resize.add_argument("--background", type=parse_color, default=(0, 0, 0, 0))
resize.set_defaults(handler=lambda args: resize_image(resolve_path(args.input), resolve_path(args.output),
args.width, args.height, args.fit, args.background))
seamless = commands.add_parser(
"seamless", help="blend opposite edges of a texture for tiling")
seamless.add_argument("input")
seamless.add_argument("output")
seamless.set_defaults(handler=lambda args: seamless_image(
resolve_path(args.input), resolve_path(args.output)))
audit = commands.add_parser(
"audit", help="classify images as good/bad using their game-asset role")
audit.add_argument("paths", nargs="+", help="image files or directories")
audit.add_argument("--role", choices=("auto", *PROFILES), default="auto")
audit.add_argument("--seam-threshold", type=float, default=18.0)
audit.add_argument("--json", action="store_true",
help="emit machine-readable JSON")
audit.set_defaults(handler=_audit)
return parser
def _audit(args):
results = [audit_image(path, args.role, args.seam_threshold)
for path in image_paths(args.paths)]
if args.json:
print(json.dumps(results, indent=2))
else:
good_count = sum(result["quality"] == "good" for result in results)
for result in results:
size = "unreadable" if result["size"] is None else "x".join(
map(str, result["size"]))
print(
f"{result['quality'].upper():4} {result['role']:10} {size:12} {result['path']}")
for message in result["errors"] + result["warnings"]:
print(f" - {message}")
for message in result["manual_review"]:
print(f" - review: {message}")
print(f"Summary: {good_count} good, {len(results) - good_count} bad")
return 1 if any(result["quality"] == "bad" for result in results) else 0
def main(argv=None):
parser = build_parser()
args = parser.parse_args(argv)
try:
result = args.handler(args)
return result if isinstance(result, int) else 0
except (OSError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+2
View File
@@ -16,6 +16,8 @@ set -euo pipefail
GODOT="${GODOT:-godot}" GODOT="${GODOT:-godot}"
VERSION="$(grep -oP 'config/version="\K[^"]+' project.godot)" VERSION="$(grep -oP 'config/version="\K[^"]+' project.godot)"
mkdir -p build mkdir -p build
# Presets now embed the PCK into a single binary; drop stale .pck leftovers.
rm -f build/*.pck
echo "==> Exporting Linux (x86_64)" echo "==> Exporting Linux (x86_64)"
"$GODOT" --headless --export-release "Linux" "build/step-line-linux-${VERSION}.x86_64" "$GODOT" --headless --export-release "Linux" "build/step-line-linux-${VERSION}.x86_64"
+308 -21
View File
@@ -3,17 +3,93 @@
All outputs are deterministic (fixed seeds), <=1024px, power-of-two where All outputs are deterministic (fixed seeds), <=1024px, power-of-two where
possible. Run from the repo root: python3 tools/generate_textures.py possible. Run from the repo root: python3 tools/generate_textures.py
Optional AI mode generates textures through the OpenRouter image API
(https://openrouter.ai/docs/api/api-reference/images/generate-an-image.md).
Requires an API key in the OPENROUTER_API_KEY environment variable:
python3 tools/generate_textures.py --ai
python3 tools/generate_textures.py --ai --prompt "..." --name foo.png
""" """
import argparse
import base64
import io
import json
import math import math
import os import os
import random import random
import stat import stat
import sys
import urllib.error
import urllib.request
from PIL import Image, ImageDraw, ImageFilter, ImageFont from PIL import Image, ImageDraw, ImageFilter, ImageFont
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
ASSETS = os.path.join(ROOT, "assets", "textures") ASSETS = os.path.join(ROOT, "assets", "textures")
# OpenRouter image generation endpoint and default model (from the API docs).
OPENROUTER_IMAGES_URL = "https://openrouter.ai/api/v1/images"
DEFAULT_IMAGE_MODEL = "bytedance-seed/seedream-4.5"
MAX_DIM = 1024 # hard cap on the longest side of any generated texture
# px requested from the API; some models (e.g. seedream) need >= 1920
GEN_SIZE = 2048
# Default batch for `--ai`: one entry per game texture (see test_world.gd).
# Each entry is (output name, prompt, transparent_background).
AI_TEXTURES = [
# --- world surfaces (seamless, flat-lit) ---
("ai/brick_facade.png",
"seamless tileable red brick wall texture, weathered urban facade, "
"flat even lighting, frontal view, photorealistic game texture", False),
("ai/concrete.png",
"seamless tileable grey concrete wall texture, subtle cracks and stains, "
"flat even lighting, frontal view, photorealistic game texture", False),
("ai/asphalt.png",
"seamless tileable dark asphalt road texture, fine grain, "
"flat even lighting, frontal view, photorealistic game texture", False),
# --- storefront dressing ---
("ai/awning.png",
"red and white striped storefront awning with scalloped bottom edge, "
"frontal view, flat even lighting, game texture", False),
("ai/skyline.png",
"night city skyline silhouette, dark building shapes with a few lit "
"windows, flat graphic style, isolated on transparent background", True),
# --- screen-space overlay ---
("ai/vignette.png",
"radial vignette overlay, black edges fading to fully transparent center, "
"grayscale, soft gradient", True),
# --- HUD icons (white glyphs on transparent) ---
("ai/icons/flashlight_on.png",
"simple flat flashlight icon, white glyph on transparent background, "
"minimal UI icon", True),
("ai/icons/door_locked.png",
"simple flat locked door icon with keyhole, white outline glyph on "
"transparent background, minimal UI icon", True),
("ai/icons/wand.png",
"simple flat handheld security wand icon, white glyph on transparent "
"background, minimal UI icon", True),
("ai/icons/rec_dot.png",
"record button icon, red filled dot inside a white circle outline, on "
"transparent background, minimal UI icon", True),
# --- access artifact cards ---
("ai/cards/id_card.png",
"driver license ID card, blue plastic card with photo placeholder and "
"text lines, flat frontal view, game UI asset", False),
("ai/cards/wristband.png",
"red event access wristband with strap, flat frontal view, game UI asset",
False),
("ai/cards/ticket.png",
"green admission ticket stub with perforated edge, flat frontal view, "
"game UI asset", False),
("ai/cards/credential.png",
"dark grey staff credential pass with lanyard clip, flat frontal view, "
"game UI asset", False),
("ai/cards/guest_list.png",
"beige guest list document with ruled lines and checkboxes, flat frontal "
"view, game UI asset", False),
]
FONT_CANDIDATES = [ FONT_CANDIDATES = [
"/usr/share/fonts/liberation-sans-fonts/LiberationSans-Bold.ttf", "/usr/share/fonts/liberation-sans-fonts/LiberationSans-Bold.ttf",
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
@@ -21,12 +97,14 @@ FONT_CANDIDATES = [
"/usr/share/fonts/adwaita-sans-fonts/AdwaitaSans-Bold.ttf", "/usr/share/fonts/adwaita-sans-fonts/AdwaitaSans-Bold.ttf",
] ]
def font(size: int): def font(size: int):
for path in FONT_CANDIDATES: for path in FONT_CANDIDATES:
if os.path.exists(path): if os.path.exists(path):
return ImageFont.truetype(path, size) return ImageFont.truetype(path, size)
return ImageFont.load_default() return ImageFont.load_default()
def save(img: Image.Image, name: str) -> None: def save(img: Image.Image, name: str) -> None:
os.makedirs(ASSETS, exist_ok=True) os.makedirs(ASSETS, exist_ok=True)
path = os.path.join(ASSETS, name) path = os.path.join(ASSETS, name)
@@ -34,6 +112,7 @@ def save(img: Image.Image, name: str) -> None:
img.save(path, optimize=True) img.save(path, optimize=True)
print(f"wrote {os.path.relpath(path, ROOT)} {img.size}") print(f"wrote {os.path.relpath(path, ROOT)} {img.size}")
def grain(size, base, spread, seed, blur=1.0): def grain(size, base, spread, seed, blur=1.0):
rnd = random.Random(seed) rnd = random.Random(seed)
img = Image.new("L", (size, size), base) img = Image.new("L", (size, size), base)
@@ -44,6 +123,7 @@ def grain(size, base, spread, seed, blur=1.0):
px[x, y] = max(0, min(255, v)) px[x, y] = max(0, min(255, v))
return img.filter(ImageFilter.GaussianBlur(blur)) return img.filter(ImageFilter.GaussianBlur(blur))
def brick_facade(): def brick_facade():
size = 512 size = 512
mortar = (118, 110, 104) mortar = (118, 110, 104)
@@ -68,13 +148,14 @@ def brick_facade():
bx = rnd.randint(0, size - 1) bx = rnd.randint(0, size - 1)
by = rnd.randint(0, size - 1) by = rnd.randint(0, size - 1)
d.point((bx, by), fill=(max(0, base[0] - 18 + rnd.randint(0, 30)), d.point((bx, by), fill=(max(0, base[0] - 18 + rnd.randint(0, 30)),
max(0, base[1] - 10 + rnd.randint(0, 22)), max(0, base[1] - 10 + rnd.randint(0, 22)),
max(0, base[2] - 8 + rnd.randint(0, 18)))) max(0, base[2] - 8 + rnd.randint(0, 18))))
# horizontal mortar lines on top of bricks (wraps vertically by drawing at 0 and size) # horizontal mortar lines on top of bricks (wraps vertically by drawing at 0 and size)
for y in [-mortar_t, size - mortar_t]: for y in [-mortar_t, size - mortar_t]:
d.rectangle([0, y, size, y + mortar_t], fill=mortar) d.rectangle([0, y, size, y + mortar_t], fill=mortar)
save(img, "brick_facade.png") save(img, "brick_facade.png")
def seamless_concrete(): def seamless_concrete():
size = 512 size = 512
img = grain(size, 150, 26, 7, blur=2.5).convert("RGBA") img = grain(size, 150, 26, 7, blur=2.5).convert("RGBA")
@@ -83,7 +164,8 @@ def seamless_concrete():
for _ in range(90): for _ in range(90):
x, y = rnd.randint(0, size), rnd.randint(0, size) x, y = rnd.randint(0, size), rnd.randint(0, size)
r = rnd.choice([2, 3, 4]) r = rnd.choice([2, 3, 4])
d.ellipse([x - r, y - r, x + r, y + r], fill=(0, 0, 0, rnd.randint(8, 22))) d.ellipse([x - r, y - r, x + r, y + r],
fill=(0, 0, 0, rnd.randint(8, 22)))
for _ in range(8): # subtle crack segments for _ in range(8): # subtle crack segments
x, y = rnd.randint(0, size), rnd.randint(0, size) x, y = rnd.randint(0, size), rnd.randint(0, size)
pts = [(x, y)] pts = [(x, y)]
@@ -94,6 +176,7 @@ def seamless_concrete():
d.line(pts, fill=(70, 70, 70, 170), width=1) d.line(pts, fill=(70, 70, 70, 170), width=1)
save(img, "concrete.png") save(img, "concrete.png")
def asphalt(): def asphalt():
size = 512 size = 512
img = grain(size, 52, 14, 23, blur=1.2).convert("RGBA") img = grain(size, 52, 14, 23, blur=1.2).convert("RGBA")
@@ -101,9 +184,11 @@ def asphalt():
rnd = random.Random(31) rnd = random.Random(31)
for _ in range(600): for _ in range(600):
x, y = rnd.randint(0, size), rnd.randint(0, size) x, y = rnd.randint(0, size), rnd.randint(0, size)
d.point((x, y), fill=(max(0, 40 - rnd.randint(0, 18)),) * 3 + (rnd.randint(40, 110),)) d.point((x, y), fill=(max(0, 40 - rnd.randint(0, 18)),)
* 3 + (rnd.randint(40, 110),))
save(img, "asphalt.png") save(img, "asphalt.png")
def awning(): def awning():
w, h = 1024, 256 w, h = 1024, 256
img = Image.new("RGB", (w, h), (140, 40, 45)) img = Image.new("RGB", (w, h), (140, 40, 45))
@@ -111,7 +196,8 @@ def awning():
stripe = 64 stripe = 64
for i in range(w // stripe): for i in range(w // stripe):
if i % 2 == 1: if i % 2 == 1:
d.rectangle([i * stripe, 0, (i + 1) * stripe - 2, h], fill=(230, 225, 220)) d.rectangle([i * stripe, 0, (i + 1) * stripe - 2, h],
fill=(230, 225, 220))
# scalloped bottom edge # scalloped bottom edge
for x in range(0, w, 32): for x in range(0, w, 32):
d.arc([x, h - 24, x + 32, h + 40], 0, 180, fill=(80, 20, 24), width=6) d.arc([x, h - 24, x + 32, h + 40], 0, 180, fill=(80, 20, 24), width=6)
@@ -125,6 +211,7 @@ def awning():
img = Image.alpha_composite(img.convert("RGBA"), shade) img = Image.alpha_composite(img.convert("RGBA"), shade)
save(img, "awning.png") save(img, "awning.png")
def skyline(): def skyline():
w, h = 1024, 256 w, h = 1024, 256
img = Image.new("RGBA", (w, h), (0, 0, 0, 0)) img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
@@ -141,11 +228,13 @@ def skyline():
wx = x + rnd.randint(4, max(4, bw - 8)) wx = x + rnd.randint(4, max(4, bw - 8))
wy = h - rnd.randint(4, bh - 4) wy = h - rnd.randint(4, bh - 4)
if rnd.random() < 0.5: if rnd.random() < 0.5:
d.rectangle([wx, wy, wx + 6, wy + 8], fill=(255, 200, 120, 220)) d.rectangle([wx, wy, wx + 6, wy + 8],
fill=(255, 200, 120, 220))
x += bw + rnd.randint(10, 46) x += bw + rnd.randint(10, 46)
d.rectangle([0, h - 6, w, h], fill=(12, 13, 22, 255)) d.rectangle([0, h - 6, w, h], fill=(12, 13, 22, 255))
save(img, "skyline.png") save(img, "skyline.png")
def vignette(): def vignette():
size = 512 size = 512
img = Image.new("L", (size, size), 0) img = Image.new("L", (size, size), 0)
@@ -159,52 +248,115 @@ def vignette():
px[x, y] = int(255 * (t * t)) px[x, y] = int(255 * (t * t))
save(img, "vignette.png") save(img, "vignette.png")
def icon(name, draw_fn):
size = 64 def icon(name, draw_fn, size=64):
img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(img) d = ImageDraw.Draw(img)
draw_fn(d, size) draw_fn(d, size)
save(img, name) save(img, name)
def icons(): def icons():
def flashlight_on(d, s): def flashlight_on(d, s):
d.rounded_rectangle([s*0.30, s*0.30, s*0.62, s*0.62], radius=4, fill=(255,255,255,255)) d.rounded_rectangle([s*0.30, s*0.30, s*0.62, s*0.62],
d.polygon([(s*0.62, s*0.30), (s*0.82, s*0.18), (s*0.82, s*0.74), (s*0.62, s*0.62)], fill=(255,214,120,255)) radius=4, fill=(255, 255, 255, 255))
d.polygon([(s*0.62, s*0.30), (s*0.82, s*0.18), (s*0.82, s *
0.74), (s*0.62, s*0.62)], fill=(255, 214, 120, 255))
def door_locked(d, s): def door_locked(d, s):
d.rectangle([s*0.18, s*0.18, s*0.82, s*0.82], outline=(255,255,255,255), width=5) d.rectangle([s*0.18, s*0.18, s*0.82, s*0.82],
d.rounded_rectangle([s*0.36, s*0.34, s*0.64, s*0.66], radius=3, fill=(255,255,255,255)) outline=(255, 255, 255, 255), width=5)
d.arc([s*0.40, s*0.28, s*0.60, s*0.50], 180, 360, fill=(255,255,255,255), width=5) d.rounded_rectangle([s*0.36, s*0.34, s*0.64, s*0.66],
radius=3, fill=(255, 255, 255, 255))
d.arc([s*0.40, s*0.28, s*0.60, s*0.50], 180,
360, fill=(255, 255, 255, 255), width=5)
def wand(d, s): def wand(d, s):
d.rounded_rectangle([s*0.30, s*0.10, s*0.44, s*0.90], radius=3, fill=(255,255,255,255)) d.rounded_rectangle([s*0.30, s*0.10, s*0.44, s*0.90],
d.ellipse([s*0.24, s*0.06, s*0.50, s*0.32], fill=(120,220,160,255)) radius=3, fill=(255, 255, 255, 255))
d.ellipse([s*0.24, s*0.06, s*0.50, s*0.32], fill=(120, 220, 160, 255))
def rec(d, s): def rec(d, s):
d.ellipse([s*0.14, s*0.14, s*0.86, s*0.86], outline=(255,255,255,255), width=5) d.ellipse([s*0.14, s*0.14, s*0.86, s*0.86],
outline=(255, 255, 255, 255), width=5)
d.ellipse([s*0.30, s*0.30, s*0.70, s*0.70], fill=(230, 60, 60, 255)) d.ellipse([s*0.30, s*0.30, s*0.70, s*0.70], fill=(230, 60, 60, 255))
icon("icons/flashlight_on.png", flashlight_on) icon("icons/flashlight_on.png", flashlight_on)
icon("icons/door_locked.png", door_locked) icon("icons/door_locked.png", door_locked)
icon("icons/wand.png", wand) icon("icons/wand.png", wand)
icon("icons/rec_dot.png", rec) icon("icons/rec_dot.png", rec)
def app_icon():
"""Game app icon: dark badge, gold star, white 'S' — used by desktop/AppImage
(PNG) and the Windows installer (ICO)."""
def badge(d, s):
d.ellipse([2, 2, s-2, s-2], fill=(24, 26, 38, 255))
d.ellipse([int(s*0.10), int(s*0.10), int(s*0.90), int(s*0.90)],
outline=(120, 130, 170, 255), width=max(2, s//64))
# gold star
cx, cy, r = s*0.5, s*0.44, s*0.30
pts = []
for i in range(10):
ang = -90 + i * 36
rad = r if i % 2 == 0 else r * 0.42
import math
pts.append((cx + rad * math.cos(math.radians(ang)),
cy + rad * math.sin(math.radians(ang))))
d.polygon(pts, fill=(255, 205, 80, 255))
icon("icon.png", badge, size=1024)
icon("icon.ico", badge, size=256)
# ICO for Windows. Must be BMP-entry based: PIL's ICO serializer emits
# PNG-compressed entries which NSIS' icon loader rejects. ImageMagick
# writes classic BMP entries; fall back to PIL only if ImageMagick is gone
# (flag it so a human notices).
import subprocess
import sys
ico_path = os.path.join(ASSETS, "icon.ico")
src_png = os.path.join(ASSETS, "icon.png")
ran = False
for cmd in (
["magick", src_png, "-background", "none", "-define",
"icon:auto-resize=48,32,16", ico_path],
["convert", src_png, "-background", "none",
"-define", "icon:auto-resize=48,32,16", ico_path],
):
try:
subprocess.run(cmd, check=True, capture_output=True)
ran = True
break
except (FileNotFoundError, subprocess.CalledProcessError):
continue
if ran:
print("wrote assets/textures/icon.ico (BMP 16/32/48 via ImageMagick)")
else:
print("WARNING: ImageMagick not found; writing PIL ICO (may be PNG-compressed)")
Image.open(src_png).convert("RGBA").save(
ico_path, format="ICO", sizes=[(48, 48), (32, 32), (16, 16)])
def cards(): def cards():
CW, CH = 256, 128 CW, CH = 256, 128
def base(card_name, bg, label, band=(0,0,0,0)):
def base(card_name, bg, label, band=(0, 0, 0, 0)):
img = Image.new("RGBA", (CW, CH), bg + (255,)) img = Image.new("RGBA", (CW, CH), bg + (255,))
d = ImageDraw.Draw(img) d = ImageDraw.Draw(img)
d.rounded_rectangle([6, 6, CW-6, CH-6], radius=10, outline=(0,0,0,90), width=2) d.rounded_rectangle([6, 6, CW-6, CH-6], radius=10,
outline=(0, 0, 0, 90), width=2)
if band and band[3] == 255: if band and band[3] == 255:
d.rectangle([0, CH-34, CW, CH], fill=band) d.rectangle([0, CH-34, CW, CH], fill=band)
f = font(26) f = font(26)
d.text((14, 10), label, font=f, fill=(255,255,255,235)) d.text((14, 10), label, font=f, fill=(255, 255, 255, 235))
# body text lines # body text lines
f2 = font(15) f2 = font(15)
y = 52 y = 52
for _ in range(4): for _ in range(4):
w = 150 - (24 if _ == 3 else 0) w = 150 - (24 if _ == 3 else 0)
d.rectangle([16, y, 16 + w, y + 8], fill=(255,255,255,110)) d.rectangle([16, y, 16 + w, y + 8], fill=(255, 255, 255, 110))
y += 24 y += 24
# photo square for id-like cards # photo square for id-like cards
if "photo" in card_name: if "photo" in card_name:
d.rectangle([CW-84, 14, CW-18, 96], fill=(180,190,205,255)) d.rectangle([CW-84, 14, CW-18, 96], fill=(180, 190, 205, 255))
save(img, f"cards/{card_name}.png") save(img, f"cards/{card_name}.png")
base("id_card", (46, 92, 168), "CA DRIVER LICENSE", band=(20, 36, 70, 255), ) base("id_card", (46, 92, 168), "CA DRIVER LICENSE", band=(20, 36, 70, 255), )
base("wristband", (196, 62, 78), "EVENT ACCESS", band=(150, 35, 50, 255)) base("wristband", (196, 62, 78), "EVENT ACCESS", band=(150, 35, 50, 255))
@@ -212,7 +364,141 @@ def cards():
base("credential", (58, 62, 74), "STAFF PASS", band=(20, 22, 28, 255)) base("credential", (58, 62, 74), "STAFF PASS", band=(20, 22, 28, 255))
base("guest_list", (200, 190, 168), "GUEST LIST", band=(120, 90, 60, 255)) base("guest_list", (200, 190, 168), "GUEST LIST", band=(120, 90, 60, 255))
def _api_key() -> str:
key = os.environ.get("OPENROUTER_API_KEY", "").strip()
if not key:
sys.exit("error: OPENROUTER_API_KEY is not set; export it before using --ai")
return key
def generate_image(prompt: str, model: str, size: int,
transparent: bool = False,
aspect_ratio: str = "1:1") -> Image.Image:
"""Call the OpenRouter image API and return the decoded PIL image.
Requests a square image at `size` px or a role-shaped image at the matching
resolution tier and decodes `data[].b64_json`. Seedream's square requests
retain the existing GEN_SIZE floor. When `transparent` is true, the API is
asked for a transparent PNG background.
"""
size = max(1, int(size))
if model == DEFAULT_IMAGE_MODEL:
size = max(GEN_SIZE, size)
body = {
"model": model,
"prompt": prompt,
"output_format": "png",
"n": 1,
}
if aspect_ratio == "1:1":
body["size"] = f"{size}x{size}"
else:
body["aspect_ratio"] = aspect_ratio
body["resolution"] = next(
(tier for limit, tier in ((512, "512"), (768, "768"), (1024, "1K"),
(1536, "1.5K"), (2048, "2K"), (4096, "4K"))
if size <= limit), "4K")
if transparent:
body["background"] = "transparent"
req = urllib.request.Request(
OPENROUTER_IMAGES_URL,
data=json.dumps(body).encode("utf-8"),
headers={
"Authorization": f"Bearer {_api_key()}",
"Content-Type": "application/json",
"HTTP-Referer": "https://git.allucanget.biz/zwitschi/security-game",
"X-Title": "Step Line",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=300) as resp:
payload = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")
sys.exit(f"error: OpenRouter returned HTTP {exc.code}: {detail}")
except urllib.error.URLError as exc:
sys.exit(f"error: could not reach OpenRouter: {exc.reason}")
data = payload.get("data") or []
if not data or not data[0].get("b64_json"):
sys.exit(f"error: OpenRouter returned no image data: {payload}")
raw = base64.b64decode(data[0]["b64_json"])
try:
img = Image.open(io.BytesIO(raw))
img.load()
except Exception as exc: # noqa: BLE001 - surface any decode failure clearly
sys.exit(f"error: could not decode returned image: {exc}")
return img
def _fit(img: Image.Image, max_dim: int = MAX_DIM) -> Image.Image:
"""Downscale to fit within max_dim on the longest side, preserving aspect."""
w, h = img.size
longest = max(w, h)
if longest <= max_dim:
return img
scale = max_dim / longest
return img.resize((max(1, round(w * scale)), max(1, round(h * scale))),
Image.Resampling.LANCZOS)
def generate_ai_textures(prompt: str, name: str, model: str, size: int,
transparent: bool = False) -> None:
"""Generate one texture via OpenRouter and save it under assets/textures/."""
img = generate_image(prompt, model, size, transparent)
img = _fit(img)
if transparent:
img = img.convert("RGBA")
elif img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
img = img.convert("RGBA")
else:
img = img.convert("RGB")
save(img, name)
def _run_ai(args: argparse.Namespace) -> None:
if args.prompt:
if not args.name:
sys.exit("error: --name is required when --prompt is given")
generate_ai_textures(args.prompt, args.name, args.model, args.size)
return
for name, prompt, transparent in AI_TEXTURES:
print(f"generating {name} ...")
generate_ai_textures(prompt, name, args.model, args.size, transparent)
def _build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Generate Step Line textures (authored or via OpenRouter).")
parser.add_argument(
"--ai", action="store_true",
help="generate textures through the OpenRouter image API "
"(requires OPENROUTER_API_KEY)")
parser.add_argument(
"--prompt", default="",
help="single prompt for --ai (overrides the default batch)")
parser.add_argument(
"--name", default="",
help="output filename under assets/textures/ for a single --prompt")
parser.add_argument(
"--model", default=DEFAULT_IMAGE_MODEL,
help=f"image model slug (default: {DEFAULT_IMAGE_MODEL})")
parser.add_argument(
"--size", type=int, default=GEN_SIZE,
help=f"square size requested from the API in px (min {GEN_SIZE}); "
f"output is downscaled to <= {MAX_DIM} (default: {GEN_SIZE})")
return parser
if __name__ == "__main__": if __name__ == "__main__":
args = _build_arg_parser().parse_args()
if args.ai:
_run_ai(args)
print("done")
sys.exit(0)
brick_facade() brick_facade()
seamless_concrete() seamless_concrete()
asphalt() asphalt()
@@ -220,5 +506,6 @@ if __name__ == "__main__":
skyline() skyline()
vignette() vignette()
icons() icons()
app_icon()
cards() cards()
print("done") print("done")
+48 -23
View File
@@ -2,8 +2,9 @@
# Publish a release to the Gitea/Forgejo host, uploading the built artifacts. # Publish a release to the Gitea/Forgejo host, uploading the built artifacts.
# #
# Requires: # Requires:
# - GITEA_TOKEN (a token with write access to the repo) # - GITEA_TOKEN (token with write access) OR credentials usable for basic auth
# - build artifacts from tools/build_releases.sh (or already exported) # (e.g. the same ones stored in ~/.git-credentials by `git push`)
# - build artifacts from tools/build_releases.sh (+ optional installer/AppImage)
# #
# Usage: # Usage:
# GITEA_TOKEN=xxx ./tools/publish_release.sh [version] # GITEA_TOKEN=xxx ./tools/publish_release.sh [version]
@@ -12,45 +13,69 @@ set -euo pipefail
REPO="${REPO:-zwitschi/security-game}" REPO="${REPO:-zwitschi/security-game}"
HOST="${HOST:-https://git.allucanget.biz}" HOST="${HOST:-https://git.allucanget.biz}"
API="$HOST/api/v1/repos/$REPO/releases"
VERSION="${1:-$(grep -oP 'config/version="\K[^"]+' project.godot)}" VERSION="${1:-$(grep -oP 'config/version="\K[^"]+' project.godot)}"
TAG="v$VERSION" TAG="v$VERSION"
if [[ -z "${GITEA_TOKEN:-}" ]]; then if [[ -n "${GITEA_TOKEN:-}" ]]; then
echo "error: GITEA_TOKEN is not set (see docs/RELEASING.md)" >&2 AUTH=("Authorization: token $GITEA_TOKEN")
elif [[ -f "$HOME/.git-credentials" ]]; then
CRED="$(python3 -c "
import os
for l in open(os.path.expanduser('~/.git-credentials')):
if '$HOST'.split('//')[1] in l:
print(l.split('://',1)[1].split('@')[0]); break")"
if [[ -z "$CRED" ]]; then
echo "error: no credentials for $HOST in ~/.git-credentials" >&2
exit 1
fi
else
echo "error: set GITEA_TOKEN or provide ~/.git-credentials" >&2
exit 1 exit 1
fi fi
# Artifacts must already exist (run tools/build_releases.sh first). # Artifacts that must exist (run tools/build_releases.sh first).
printf -v BS 'build/step-line-linux-%s.x86_64' "$VERSION" portables=(
printf -v BS_WIN 'build/step-line-windows-%s.exe' "$VERSION" "build/step-line-linux-$VERSION.x86_64"
printf -v BS_MAC 'build/step-line-macos-%s.zip' "$VERSION" "build/step-line-windows-$VERSION.exe"
for f in "$BS" "$BS_WIN" "$BS_MAC"; do "build/step-line-macos-$VERSION.zip"
if [[ ! -f "$f" ]]; then )
echo "error: missing artifact $f — run ./tools/build_releases.sh first" >&2 extras=(
exit 1 "build/step-line-setup-$VERSION.exe"
"build/step-line-$VERSION-x86_64.AppImage"
)
all_artifacts=("${portables[@]}")
for f in "${extras[@]}"; do
if [[ -f "$f" ]]; then
all_artifacts+=("$f")
else
echo "note: optional artifact missing (skipping): $f"
fi fi
done done
API="$HOST/api/v1/repos/$REPO/releases"
echo "==> Creating release $TAG on $HOST/$REPO" echo "==> Creating release $TAG on $HOST/$REPO"
RELEASE_JSON="$(curl -s -X POST "$API" \ RELEASE_ID="$(curl -s -X POST "$API" "${AUTH[@]}" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Step Line $VERSION — playable prototype. See README for controls.\",\"draft\":false,\"prerelease\":false}")" -d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Step Line $VERSION — playable prototype. See README for controls.\",\"draft\":false,\"prerelease\":false}" \
| python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)"
RELEASE_ID="$(echo "$RELEASE_JSON" | python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)" # If the release already exists (tag already pushed), reuse it.
if [[ -z "$RELEASE_ID" ]]; then if [[ -z "$RELEASE_ID" ]]; then
echo "error: could not create release; response:" >&2 RELEASE_ID="$(curl -s "${AUTH[@]}" "$API/tags/$TAG" | python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)"
echo "$RELEASE_JSON" >&2 fi
if [[ -z "$RELEASE_ID" ]]; then
echo "error: could not create or find release $TAG" >&2
exit 1 exit 1
fi fi
echo "Release id: $RELEASE_ID" echo "Release id: $RELEASE_ID"
for f in "$BS" "$BS_WIN" "$BS_MAC"; do for f in "${all_artifacts[@]}"; do
if [[ ! -f "$f" ]]; then
echo "error: missing artifact $f — run ./tools/build_releases.sh first" >&2
exit 1
fi
echo "==> Uploading $(basename "$f")" echo "==> Uploading $(basename "$f")"
curl -s -X POST "$API/$RELEASE_ID/assets" \ curl -s -X POST "$API/$RELEASE_ID/assets" "${AUTH[@]}" -F "attachment=@$f" >/dev/null
-H "Authorization: token $GITEA_TOKEN" \
-F "attachment=@$f" >/dev/null
done done
echo "" echo ""
+207
View File
@@ -0,0 +1,207 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from PIL import Image, ImageDraw
import asset_pipeline
class AssetPipelineTests(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.root = Path(self.temp_dir.name)
def tearDown(self):
self.temp_dir.cleanup()
def test_crop_uses_center_and_exact_dimensions(self):
source = self.root / "source.png"
output = self.root / "crop.png"
Image.new("RGB", (80, 60), (20, 40, 60)).save(source)
asset_pipeline.crop_image(source, output, 32, 24)
with Image.open(output) as image:
self.assertEqual(image.size, (32, 24))
def test_resize_contain_preserves_aspect_and_exact_canvas(self):
source = self.root / "source.png"
output = self.root / "resized.png"
Image.new("RGB", (80, 40), (20, 40, 60)).save(source)
asset_pipeline.resize_image(source, output, 64, 64, "contain")
with Image.open(output) as image:
self.assertEqual(image.size, (64, 64))
self.assertEqual(image.getpixel((32, 0)), (0, 0, 0, 0))
def test_texture_audit_accepts_valid_tile_and_rejects_bad_seams(self):
good = self.root / "good.png"
bad = self.root / "bad.png"
Image.new("RGB", (512, 512), (80, 80, 80)).save(good)
mismatch = Image.new("RGB", (512, 512), (80, 80, 80))
for y in range(512):
mismatch.putpixel((0, y), (255, 0, 0))
mismatch.save(bad)
self.assertEqual(asset_pipeline.audit_image(
good, "tile")["quality"], "good")
self.assertEqual(asset_pipeline.audit_image(
bad, "tile")["quality"], "bad")
def test_seamless_transform_makes_tile_edges_continuous(self):
image_path = self.root / "tile.png"
output = self.root / "seamless.png"
image = Image.new("RGB", (512, 512))
for x in range(512):
for y in range(512):
image.putpixel((x, y), (x // 2, y // 2, (x + y) // 4))
image.save(image_path)
asset_pipeline.seamless_image(image_path, output)
self.assertEqual(asset_pipeline.audit_image(
output, "tile")["quality"], "good")
def test_tile_generation_applies_seamless_transform(self):
output = self.root / "generated_tile.png"
args = type("Args", (), {
"prompt": "brick", "role": "tile", "output": str(output),
"width": None, "height": None, "transparent": False,
"model": "test-model", "request_size": 1024,
})()
generated = Image.new("RGB", (512, 512), (80, 80, 80))
for y in range(512):
generated.putpixel((0, y), (255, 0, 0))
with patch.object(asset_pipeline.generate_textures, "generate_image",
return_value=generated):
asset_pipeline._generate(args)
self.assertEqual(asset_pipeline.audit_image(
output, "tile")["quality"], "good")
def test_background_audit_rejects_small_image(self):
image_path = self.root / "small.png"
Image.new("RGB", (640, 360)).save(image_path)
result = asset_pipeline.audit_image(image_path, "background")
self.assertEqual(result["quality"], "bad")
self.assertTrue(
any("too small" in error for error in result["errors"]))
def test_grayscale_vignette_is_valid_without_alpha(self):
image_path = self.root / "vignette.png"
image = Image.new("L", (512, 512), 0)
draw = ImageDraw.Draw(image)
for radius in range(255, -1, -1):
value = int(255 * (1 - radius / 255))
draw.ellipse((256 - radius, 256 - radius,
256 + radius, 256 + radius), fill=value)
image.save(image_path)
self.assertEqual(asset_pipeline.audit_image(
image_path, "overlay")["quality"], "good")
def test_overlay_audit_rejects_blank_transparent_image(self):
image_path = self.root / "blank_overlay.png"
Image.new("RGBA", (512, 512), (0, 0, 0, 0)).save(image_path)
result = asset_pipeline.audit_image(image_path, "overlay")
self.assertEqual(result["quality"], "bad")
self.assertTrue(any("near-blank frame" in error
for error in result["errors"]))
def test_icon_audit_rejects_opaque_rgba_images(self):
image_path = self.root / "opaque_icon.png"
Image.new("RGBA", (64, 64), (255, 255, 255, 255)).save(image_path)
result = asset_pipeline.audit_image(image_path, "icon")
self.assertEqual(result["quality"], "bad")
self.assertTrue(any("transparency required" in error
for error in result["errors"]))
def test_backdrop_audit_requires_transparency(self):
image_path = self.root / "skyline.png"
Image.new("RGB", (1024, 256), (20, 30, 40)).save(image_path)
result = asset_pipeline.audit_image(image_path, "backdrop")
self.assertEqual(result["quality"], "bad")
self.assertTrue(any("transparency required" in error
for error in result["errors"]))
def test_generation_reuses_existing_backend_and_targets_role_dimensions(self):
output = self.root / "background.png"
args = type("Args", (), {
"prompt": "Hollywood at night", "role": "background", "output": str(output),
"width": 1920, "height": 1080, "transparent": None,
"model": "test-model", "request_size": 2048,
})()
generated = Image.new("RGB", (256, 256), (20, 40, 60))
with patch.object(asset_pipeline.generate_textures, "generate_image",
return_value=generated) as backend:
asset_pipeline._generate(args)
self.assertIn("Hollywood at night", backend.call_args.args[0])
self.assertIn("wide 16:9", backend.call_args.args[0])
self.assertEqual(backend.call_args.kwargs["aspect_ratio"], "3:2")
with Image.open(output) as image:
self.assertEqual(image.size, (1920, 1080))
def test_generation_can_recolor_transparent_glyph(self):
output = self.root / "icon.png"
args = type("Args", (), {
"prompt": "flashlight", "role": "icon", "output": str(output),
"width": None, "height": None, "transparent": True,
"model": "test-model", "request_size": 1024,
"foreground": (255, 255, 255, 255),
})()
generated = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
generated.putpixel((32, 32), (20, 20, 20, 255))
with patch.object(asset_pipeline.generate_textures, "generate_image",
return_value=generated):
asset_pipeline._generate(args)
with Image.open(output) as image:
self.assertEqual(image.getpixel((32, 32)), (255, 255, 255, 255))
self.assertEqual(image.getpixel((0, 0))[3], 0)
def test_screenshot_audit_accepts_game_frame_and_rejects_blank_capture(self):
valid = self.root / "frame.png"
blank = self.root / "blank.png"
frame = Image.new("RGB", (1280, 720), (0, 0, 0))
ImageDraw.Draw(frame).rectangle(
(640, 0, 1279, 719), fill=(255, 255, 255))
frame.save(valid)
Image.new("RGB", (1280, 720), (0, 0, 0)).save(blank)
self.assertEqual(asset_pipeline.audit_image(
valid, "screenshot")["quality"], "good")
self.assertEqual(asset_pipeline.audit_image(
blank, "screenshot")["quality"], "bad")
def test_role_inference_uses_game_asset_paths(self):
self.assertEqual(asset_pipeline.infer_role(
Path("assets/textures/icons/wand.png")), "icon")
self.assertEqual(asset_pipeline.infer_role(
Path("assets/textures/cards/ticket.png")), "card")
self.assertEqual(asset_pipeline.infer_role(
Path("assets/textures/skyline.png")), "backdrop")
def test_role_generator_defaults_to_validated_image_model(self):
args = asset_pipeline.build_parser().parse_args([
"generate", "--prompt", "test", "--role", "tile", "--output", "tile.png"])
self.assertEqual(args.model, asset_pipeline.DEFAULT_ROLE_IMAGE_MODEL)
if __name__ == "__main__":
unittest.main()