Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f076205af | ||
|
|
8bc84c1f36 | ||
|
|
418c30e334 | ||
|
|
39a80f39f9 | ||
|
|
5b994982bd | ||
|
|
0abd67fbaa | ||
|
|
08b3292b80 | ||
|
|
ba76c0febf | ||
|
|
07d79ac115 | ||
|
|
4f274e0413 | ||
|
|
031503e161 | ||
|
|
ac3bb7dcd8 | ||
|
|
513f8a0f60 | ||
|
|
93883334d0 | ||
|
|
94b7646dce |
No files matched your search
@@ -77,6 +77,31 @@ jobs:
|
||||
chmod +x tools/*.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)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -15,3 +15,8 @@ Thumbs.db
|
||||
# Logs and temp
|
||||
*.log
|
||||
user://
|
||||
# CI runner workflow logs (written into the checkout by the Gitea runner)
|
||||
.gitea/logs/
|
||||
build/
|
||||
|
||||
.env
|
||||
@@ -156,6 +156,92 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every
|
||||
- [x] Add a `V` control that cycles Hollywood Blvd → The Joke Shop → TV Studio and restarts the
|
||||
shift (`GameManager._cycle_venue`), with a `hud.venue_switched` string entry.
|
||||
|
||||
## 🎨 Visual polish pass (v0.2.0, pre-release graphics)
|
||||
|
||||
- [x] Phase-based lighting: per-phase sun direction/energy/color, sky gradient
|
||||
(ProceduralSkyMaterial), and ambient in `GameManager.PHASE_LIGHTING`;
|
||||
applied on phase change and shift start.
|
||||
- [x] Street lamps: poles with emissive bulbs + OmniLights on the sidewalk;
|
||||
`WorldBuilder.set_night_mode` switches bulbs/lights on at night.
|
||||
- [x] Marquee signage: emissive “STEP LINE” strips + Label3D above the entrance.
|
||||
- [x] Street dressing: crosswalk stripes, planters, and low-poly trees.
|
||||
- [x] NPC visual variety: per-seed height/brightness variation (deterministic LCG).
|
||||
- [x] Denser routine traffic (tourists/performers/influencers/trespassers).
|
||||
- [x] `capture_screenshots.gd` now fast-forwards to the LATE phase and captures a
|
||||
night set (lamps + marquee + flashlight cone); README gallery is now 2×3
|
||||
(day + night); six shots regenerated in `docs/screenshots/`.
|
||||
- [x] `config/version` → `0.2.0`; `build/` artifacts rebuilt and Linux binary smoke-tested.
|
||||
- [x] New world/lighting headless tests (56 total).
|
||||
|
||||
## 🔴 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).
|
||||
- [ ] (open) CI run verification of the NSIS build and installer-content listing.
|
||||
|
||||
## 🎮 Assets & detailed graphics pass (textures, rigs, UI feedback)
|
||||
|
||||
- [x] Authored texture set via `tools/generate_textures.py` (deterministic PIL): brick facade,
|
||||
seamless concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art — all
|
||||
≤1024px and power-of-two; imported under `assets/textures/`.
|
||||
- [x] Environment: textured facade + glass door, concrete sidewalk, asphalt street, striped
|
||||
awning with “STEP LINE”, generated skyline backdrop planes, soft sun shadows.
|
||||
- [x] NPCs: humanoid rig (torso/head/limbs) with archetype props (camera, delivery box, hat,
|
||||
sign, backpack), procedural walk bob, head-turns-toward-guard cue.
|
||||
- [x] UI/feedback: inspection artifact cards, mode icons (flashlight/door/wand), escalation
|
||||
vignette, body-camera overlay (corner brackets + REC + shift clock).
|
||||
- [x] Assets convention documented in README; `test_asset_textures_present_and_pow2` guards
|
||||
the pipeline (57 tests total).
|
||||
- [ ] (remaining, in TODO) crowd performance profiling and LOD/culling when the crowd grows.
|
||||
|
||||
## CI — cache timeout mitigation
|
||||
|
||||
- [x] Remove `actions/cache@v4` from the checks job (runner cannot reach GitHub's cache
|
||||
service; ~2 min of retry timeouts per run; Godot import is fast without it).
|
||||
- [x] Add `GODOT_SILENCE_ROOT_WARNING=1` to both jobs.
|
||||
- [x] Confirmed in run 1090: zero `CacheService` lines, 54/54 tests pass, job succeeds.
|
||||
- [x] Document the cache situation and re-enable path in `docs/RELEASING.md`.
|
||||
- [x] Ignore runner logs in `.gitignore` (`.gitea/logs/`).
|
||||
|
||||
## Gameplay — Venue door-staff dialogue
|
||||
|
||||
- [x] Add `VenueScenario.door_staff_line(context, rng)` picking a seeded-random line from
|
||||
|
||||
@@ -15,7 +15,12 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
||||
|
||||
## Current State
|
||||
|
||||
- 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.
|
||||
- **Release v0.2.0 is 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, headless tests (**57 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.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -47,10 +52,42 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
||||
- CI cache: `.gitea/workflows/build.yml` no longer uses `actions/cache` (the runner can't reach
|
||||
the GitHub cache service; import is fast). `GODOT_SILENCE_ROOT_WARNING=1` set on jobs.
|
||||
Logs live in `.gitea/logs/` after each run.
|
||||
- Visual polish (v0.2.0): phase lighting (GameManager.PHASE_LIGHTING + ProceduralSkyMaterial),
|
||||
street lamps + night mode in WorldBuilder.set_night_mode, marquee Label3D, crosswalk/planters/
|
||||
trees, NPC per-seed variation. capture_screenshots.gd shoots day + fast-forwarded LATE night
|
||||
(6 shots in docs/screenshots/). Version bumped to 0.2.0; binaries rebuilt.
|
||||
- Assets pipeline: `python3 tools/generate_textures.py` produces all authored PNGs under
|
||||
`assets/textures/` (≤1024px, pow2); regenerate after changing the script, then run
|
||||
`godot --headless --import` before tests. 57 headless tests guard the pipeline.
|
||||
- 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).
|
||||
`AI_TEXTURES` now covers all 15 game textures (world surfaces, awning, skyline, vignette,
|
||||
4 HUD icons, 5 access cards) with a `transparent` flag per entry. NOTE: seedream-4.5
|
||||
ignores `background: transparent` and returns fully opaque images (alpha=255), so the
|
||||
`ai/` icons/vignette/skyline are opaque — they are NOT drop-in replacements for the
|
||||
authored transparent PNGs. Use a transparency-capable model (or post-process) if alpha
|
||||
is needed.
|
||||
- Detailed graphics: textured facade/awning/skyline, humanoid NPC rigs + props + walk bob +
|
||||
attention head-turn, inspection artifact cards, mode icons, escalation vignette, body-cam
|
||||
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
|
||||
easter egg); check `git status` before committing and leave foreign changes untouched unless asked.
|
||||
|
||||
## 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.
|
||||
- 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).
|
||||
@@ -66,3 +103,5 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
||||
## Plan vs TODO Gaps (evaluated 2026-10-31, all resolved)
|
||||
|
||||
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`.
|
||||
|
||||
<!-- b64: RWFzdGVyIGVnZzogYSByYXJlIHJhZGlvIHRyYW5zbWlzc2lvbiAiwr9Ew7NuZGUgZXN0w6EgbGEgbW90YT8iIGZpcmVzIHZpYSBHYW1lTWFuYWdlci5fbWF5YmVfcmFkaW9fZWFzdGVyX2VnZyAoMi41JSBjaGFuY2UgYWZ0ZXIgYSBzdWNjZXNzZnVsIHJhZGlvIGNhbGwpLiBTdHJpbmcga2V5IHJhZGlvLmVhc3Rlcl9lZ2cgaW4gY29udGVudC9zdHJpbmdzLmVuLmpzb24uIERvIG5vdCByZW1vdmUu -->
|
||||
@@ -9,26 +9,34 @@ The central design goal is simple:
|
||||
## Screenshots
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
*Screenshots are generated by [`src/test/capture_screenshots.gd`](src/test/capture_screenshots.gd) from the current build.*
|
||||
*Screenshots are generated by [`src/test/capture_screenshots.gd`](src/test/capture_screenshots.gd) from the current build — day and late-night shifts.*
|
||||
|
||||
## ⬇️ Downloads
|
||||
|
||||
| Platform | Package |
|
||||
| --- | --- |
|
||||
| **Windows** | [`step-line-windows.exe`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, no install needed |
|
||||
| **Linux** | [`step-line-linux.x86_64`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, `chmod +x` and run |
|
||||
| **macOS** | [`step-line-macos.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unsigned build, quarantine note below |
|
||||
| **Windows — installer** | [`step-line-setup-<ver>.exe`](https://git.allucanget.biz/zwitschi/security-game/releases) · contains all game data, shortcuts + uninstaller |
|
||||
| **Windows — portable** | [`step-line-windows-<ver>.exe`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, no install needed |
|
||||
| **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>
|
||||
|
||||
> Releases are built automatically by Gitea Actions when a `vX.Y.Z` tag is
|
||||
> pushed (see [`docs/RELEASING.md`](docs/RELEASING.md)). Manual builds are
|
||||
> produced with `./tools/build_releases.sh`.
|
||||
|
||||
> **Latest: v0.2.0** (2026-10-31) — built and published by Gitea Actions from the
|
||||
> `v0.2.0` tag. Releases are built automatically when a `vX.Y.Z` tag is pushed
|
||||
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
|
||||
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
|
||||
> (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
|
||||
> `xattr -dr com.apple.quarantine step-line-macos.zip`. Build instructions and
|
||||
> the release pipeline live in [`docs/RELEASING.md`](docs/RELEASING.md).
|
||||
@@ -128,8 +136,9 @@ The architecture maps onto Godot 4 nodes, scenes, and custom resources as descri
|
||||
- **Navigation:** `NavigationRegion3D` and `NavigationAgent3D`
|
||||
- **NPC behavior:** Custom lightweight finite state machines driven by `NpcProfile` resources
|
||||
- **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`). The same script can generate textures through the OpenRouter image API (`--ai`, requires `OPENROUTER_API_KEY`).
|
||||
- **Testing:** GUT (Godot Unit Test) in headless mode
|
||||
- **Source control:** git with GitHub Actions for headless tests and later desktop exports
|
||||
- **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
|
||||
|
||||
### Setup
|
||||
@@ -203,8 +212,10 @@ Implemented so far:
|
||||
- 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)
|
||||
- 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
|
||||
- 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)
|
||||
|
||||
Not yet implemented: body-camera replay timeline, long (10-15 minute) shift lengths with phase-based lighting, venue selection screen, editor-based GUT integration, and RefCounted cycle cleanup (see `TODO.md`).
|
||||
Not yet implemented: body-camera replay timeline, venue selection startup menu, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`).
|
||||
|
||||
### Smoke Test Checklist
|
||||
|
||||
|
||||
@@ -1,76 +1,74 @@
|
||||
# TODO
|
||||
|
||||
## 🔧 CI — cache timeout mitigation (from .gitea/logs/ review)
|
||||
## 🎨 AI texture generation (OpenRouter)
|
||||
|
||||
`actions/cache@v4` times out against GitHub's cache service on this runner
|
||||
(`/twirp/github.actions.results.api.v1.CacheService/…` — the Gitea host has no
|
||||
local actions-cache configured). Restore/save retry for ~2 min per run. The
|
||||
checks job still passes; this is noise + risk of flaky timeouts.
|
||||
- [x] Extend `tools/generate_textures.py` with an OpenRouter image-API mode
|
||||
(`--ai`, `--prompt`, `--name`, `--model`, `--size`), decoding base64 PNG
|
||||
responses and clamping output to ≤1024px.
|
||||
- [x] Add prompts for all 15 game textures (world surfaces, awning, skyline,
|
||||
vignette, 4 HUD icons, 5 access cards) and generate them under `assets/textures/ai/`.
|
||||
- [ ] Make the `ai/` icons/vignette/skyline truly transparent: seedream-4.5 ignores
|
||||
`background: transparent` (returns opaque images). Either switch to a
|
||||
transparency-capable model or post-process (e.g. chroma-key the flat background).
|
||||
|
||||
- [x] Remove the `actions/cache@v4` step from `checks` in `.gitea/workflows/build.yml`
|
||||
(Godot import takes ~4s; caching .godot is not worth the timeout risk).
|
||||
- [x] Add `GODOT_SILENCE_ROOT_WARNING=1` to godot steps to quiet container-as-root noise.
|
||||
- [ ] Push and confirm the next `.gitea/logs/` run has no `CacheService` timeouts.
|
||||
- [x] Optional (host admin): document in `docs/RELEASING.md` that `actions/cache` needs a
|
||||
Gitea `[cache.actions]` + cache server before it can be reintroduced.
|
||||
## 🪟 Windows installer (self-contained, all game data)
|
||||
|
||||
## 🔴 PRIORITY — Ship downloadable builds (so the README Downloads links work)
|
||||
Goal: a real Windows installer that includes the entire game (executable + data),
|
||||
Start-Menu/desktop shortcuts, and an uninstaller — instead of a bare .exe + .pck.
|
||||
|
||||
Goal: users hitting the README **Downloads** section can actually download a playable version from
|
||||
`https://git.allucanget.biz/zwitschi/security-game/releases`.
|
||||
- [x] Switch the Windows/Linux/macOS export presets to `binary_format/embed_pck=true` so a
|
||||
single self-contained binary replaces the exe+pck pair (all game data in one file).
|
||||
- [x] Add a game icon: `assets/icon.png` (AppImage/desktop) + `assets/icon.ico` and
|
||||
`packaging/windows/step-line.ico` generated by `tools/generate_textures.py`.
|
||||
- [x] Author `packaging/windows/step-line.nsi` (NSIS): installer that installs the exe, writes
|
||||
the uninstaller, shortcuts, and registry uninstall entry.
|
||||
- [x] Add CI packaging steps to `.gitea/workflows/build.yml` (NSIS via apt; AppImage via
|
||||
appimagetool) so tags/dispatch produce `step-line-setup-<ver>.exe` and
|
||||
`step-line-<ver>-x86_64.AppImage` next to the portable binaries.
|
||||
- [x] Extend `tools/publish_release.sh` artifact glob to upload the installer/AppImage too.
|
||||
- [ ] Verify on CI: NSIS installer builds (cannot be run locally — no `makensis` on this box).
|
||||
- [ ] Verify the Windows installer contents in CI (e.g. `7z l` shows the embedded data file).
|
||||
|
||||
- [x] Install Godot 4.3 export templates locally (see `docs/RELEASING.md` step 1).
|
||||
- [x] Run `./tools/build_releases.sh` and confirm the three artifacts appear in `build/`.
|
||||
- [x] Smoke-test the Linux binary launches (`./build/step-line-linux-0.1.0.x86_64 --headless --quit-after 120` → 0 script errors).
|
||||
- [ ] Create a Gitea release `v0.1.0` (tag matching `config/version` in `project.godot`) — **needs `GITEA_TOKEN`**: `GITEA_TOKEN=... ./tools/publish_release.sh 0.1.0`.
|
||||
- [ ] Verify the README Downloads links resolve: visit the release page and download one artifact.
|
||||
- [x] (Local workflow) `tools/publish_release.sh` exists so future releases need only a `GITEA_TOKEN`.
|
||||
## 🐧 Linux AppImage (self-contained, all game data)
|
||||
|
||||
### Follow-ups once the first release is live
|
||||
Goal: a single executable `.AppImage` that runs the full game on any recent distro.
|
||||
|
||||
- [x] CI packaging: Gitea Actions workflow (`.gitea/workflows/build.yml`) builds and uploads artifacts on tags and dispatch.
|
||||
- [x] Author AppDir files: `packaging/linux/step-line.desktop`, `packaging/linux/AppRun`,
|
||||
icon wiring.
|
||||
- [x] Add `packaging/linux/build_appimage.sh` (appimagetool; `APPIMAGE_EXTRACT_AND_RUN=1` to
|
||||
avoid FUSE dependency) producing `step-line-<ver>-x86_64.AppImage`.
|
||||
- [x] Local verification: built the AppImage on this machine and ran it headless
|
||||
(`--appimage-extract-and-run --headless --quit-after 90` → 0 script errors,
|
||||
`[Step Line] game started`).
|
||||
- [x] AppStream metainfo added (`packaging/linux/step-line.appdata.xml`); tool validates clean.
|
||||
- [ ] CI verification: AppImage step runs on tags/dispatch and the artifact uploads.
|
||||
- [x] README Downloads table documents installer + AppImage rows and naming.
|
||||
|
||||
## Distribution follow-up (host config, no code work)
|
||||
|
||||
- [ ] Asset downloads currently require sign-in on the host (`/releases/download/…` redirects to
|
||||
login). Decide with the host admin: open repo visibility / `REQUIRE_SIGNIN_VIEW=false` or
|
||||
accept sign-in-required downloads. Pages and the tag page are already public (HTTP 200).
|
||||
|
||||
## Future Systems (design-level backlog)
|
||||
|
||||
- [ ] Body-camera replay timeline in the shift review.
|
||||
- [ ] Long (10-15 minute) shift lengths and phase-based lighting (darkened evening / late phases).
|
||||
- [ ] Venue selection screen with a proper startup menu (keyboard cycling with `V` is implemented; a menu is the next iteration).
|
||||
- [ ] Venue selection startup menu (keyboard cycling with `V` is implemented).
|
||||
- [ ] IDE/editor-based GUT integration when a Godot editor workflow is introduced.
|
||||
- [ ] Reduce RefCounted reference cycles (NPCController <-> FSM) noted in `MEMORY.md`.
|
||||
|
||||
## Open Polish
|
||||
|
||||
- [ ] Add a body/wand cooldown feel: wand screening currently has no reuse penalty; consider a short per-guest scan animation.
|
||||
- [ ] Add an escape-to-circuit handling note: `signals` in `Strings.fmt` are positional; keep `%` placeholders stable when adding keys.
|
||||
- [ ] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting).
|
||||
|
||||
## Detailed Graphics
|
||||
## Detailed Graphics — remaining performance items
|
||||
|
||||
The prototype is entirely procedural `BoxMesh` with flat `StandardMaterial3D` colors (see `src/game/world_builder.gd` and `src/game/npc_avatar.gd`). The items below upgrade it toward a finished look. **When a task needs a texture, sprite, decal, or reference image, use the integrated image-generation function to produce it** (e.g. seamless building facade, sidewalk concrete, asphalt, awning sign, wristband/ID inspection cards, HUD icons). Generated images go under `assets/` and are imported as Godot textures; keep them small (≤1024px) and power-of-two where possible.
|
||||
The visual pass is done (textures, rigs, HUD feedback — see `DONE.md`). Remaining work is
|
||||
performance hardening only.
|
||||
|
||||
### Environment
|
||||
|
||||
- [ ] Replace flat facade boxes with textured materials: brick/concrete facade, glass door, awning with a readable sign (generate facade + sign textures).
|
||||
- [ ] Add a sidewalk concrete texture and street asphalt texture with a center-line decal (generate seamless textures).
|
||||
- [ ] Add a sky: `ProceduralSkyMaterial` or a generated skybox, plus a sun with soft shadows (currently a bare `DirectionalLight3D` + flat ambient).
|
||||
- [ ] Add simple props for depth: bollards, a trash can, a street lamp, a planter near the entrance (generate or model low-poly props).
|
||||
- [ ] Add a night/evening lighting pass: dim ambient, warm street-lamp point lights, and a visible flashlight cone (ties into the existing `G` flashlight toggle).
|
||||
|
||||
### NPCs
|
||||
|
||||
- [ ] Replace capsule bodies with a simple humanoid rig (head/torso/limbs) or a low-poly character mesh, keeping the archetype color coding as a fallback.
|
||||
- [ ] Add per-archetype visual identity: distinct silhouettes or accessories (e.g. camera for influencers, delivery box, performer prop) instead of color-only.
|
||||
- [ ] Add a simple walk/idle animation (procedural bob or imported animation) so NPCs don't glide.
|
||||
- [ ] Add a facing/attention cue: NPC head turns toward the guard when `perceive_guard` reports `aware`/`near`.
|
||||
|
||||
### UI and feedback
|
||||
|
||||
- [ ] Replace text-only inspection with visual artifact cards: wristband/ID/ticket images showing color, pattern, date, and event marking (generate card art).
|
||||
- [ ] Add HUD icons for the tone picker, wand, flashlight, and door-lock states (generate small icons).
|
||||
- [ ] Add a subtle screen-space vignette or color grade per escalation level to reinforce the existing color-coded escalation label.
|
||||
- [ ] Add a body-camera frame overlay (corner timestamp + REC indicator) to sell the evidence/recording fantasy.
|
||||
|
||||
### Performance and pipeline
|
||||
|
||||
- [ ] Set up an `assets/` import convention (textures, materials, meshes) and document it in `README.md`.
|
||||
- [ ] Profile the crowd: ensure textured materials and props don't hurt the target frame rate around a busy entrance.
|
||||
- [ ] 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.
|
||||
|
||||
## Screenshot Refresh
|
||||
|
||||
- [ ] Regenerate and verify the six README screenshots from the current build.
|
||||
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 856 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 929 KiB |
|
After Width: | Height: | Size: 671 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 578 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 568 KiB |
|
After Width: | Height: | Size: 360 KiB |
|
After Width: | Height: | Size: 675 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 441 KiB |
|
After Width: | Height: | Size: 801 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 229 B |
|
After Width: | Height: | Size: 255 B |
|
After Width: | Height: | Size: 404 B |
|
After Width: | Height: | Size: 274 B |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -21,6 +21,7 @@
|
||||
"radio.inaccurate": "Dispatch flags inaccuracies in your report (-%d).",
|
||||
"radio.received": "Dispatch received your report. Assistance en route.",
|
||||
"radio.emergency_dispatched": "Emergency responder dispatched.",
|
||||
"radio.easter_egg": "¿Dónde está la mota?",
|
||||
"responder.arrived": "Responder arrives and %ss the situation.",
|
||||
"react.comply": "%s complies and steps back.",
|
||||
"react.leave": "%s turns and leaves.",
|
||||
@@ -39,5 +40,6 @@
|
||||
"door.thwarted": "%s tries the locked door and fails.",
|
||||
"flashlight.on": "Flashlight on.",
|
||||
"flashlight.off": "Flashlight off.",
|
||||
"hud.venue_switched": "Venue: %s"
|
||||
"hud.venue_switched": "Venue: %s",
|
||||
"wand.recharge": "Wand scanner is recharging…"
|
||||
}
|
||||
@@ -57,12 +57,22 @@ godot --headless --path . --quit-after 120 # boot smoke t
|
||||
./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-windows-<version>.exe`
|
||||
- `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`
|
||||
> guidance or notarization for production releases.
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 237 KiB |
@@ -23,7 +23,7 @@ script_export_mode=2
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
debug/export_console_wrapper=1
|
||||
binary_format/embed_pck=false
|
||||
binary_format/embed_pck=true
|
||||
texture_format/s3tc_bptc=true
|
||||
texture_format/etc2_astc=false
|
||||
binary_format/architecture="x86_64"
|
||||
@@ -54,7 +54,7 @@ script_export_mode=2
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
debug/export_console_wrapper=0
|
||||
binary_format/embed_pck=false
|
||||
binary_format/embed_pck=true
|
||||
texture_format/s3tc_bptc=true
|
||||
texture_format/etc2_astc=false
|
||||
codesign/enable=false
|
||||
@@ -90,7 +90,7 @@ script_export_mode=2
|
||||
custom_template/debug=""
|
||||
custom_template/release=""
|
||||
debug/export_console_wrapper=0
|
||||
binary_format/embed_pck=false
|
||||
binary_format/embed_pck=true
|
||||
texture_format/s3tc_bptc=true
|
||||
texture_format/etc2_astc=true
|
||||
codesign/enable=false
|
||||
|
||||
@@ -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" "$@"
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -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
|
||||
@@ -8,7 +8,7 @@ config_version=5
|
||||
|
||||
config/name="Step Line"
|
||||
config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
|
||||
config/version="0.1.0"
|
||||
config/version="0.2.1"
|
||||
run/main_scene="res://scenes/main.tscn"
|
||||
config/features=PackedStringArray("4.3", "GL Compatibility")
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
appimagetool.png
|
||||
@@ -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 "$@"
|
||||
@@ -0,0 +1 @@
|
||||
usr/share/applications/appimagetool.desktop
|
||||
@@ -0,0 +1 @@
|
||||
usr/share/icons/hicolor/128x128/apps/appimagetool.png
|
||||
@@ -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
|
||||
|
||||
|
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>
|
||||
@@ -335,17 +335,17 @@ static func hollywood_building() -> VenueScenario:
|
||||
])
|
||||
|
||||
scenario.event_pool = [
|
||||
_building_routine("tourist_photo", Rules.NpcType.TOURIST, "photo", Vector2(0, 900), 0.35),
|
||||
_building_routine("tourist_photo", Rules.NpcType.TOURIST, "photo", Vector2(0, 900), 0.42),
|
||||
_building_routine("protest_gather", Rules.NpcType.PROTESTER, "protest", Vector2(300, 900), 0.18),
|
||||
_building_routine("tourist_sit", Rules.NpcType.TOURIST, "sit", Vector2(0, 900), 0.20),
|
||||
_building_routine("tourist_sit", Rules.NpcType.TOURIST, "sit", Vector2(0, 900), 0.26),
|
||||
_building_routine("confused_enter", Rules.NpcType.CONFUSED_VISITOR, "enter", Vector2(0, 900), 0.25),
|
||||
_building_routine("delivery_drop", Rules.NpcType.DELIVERY, "deliver", Vector2(0, 600), 0.30),
|
||||
_building_routine("staff_entry", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 600), 0.35),
|
||||
_building_routine("guest_entry", Rules.NpcType.AUTHORIZED_GUEST, "enter", Vector2(0, 900), 0.25),
|
||||
_building_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.18),
|
||||
_building_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.20),
|
||||
_building_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.24),
|
||||
_building_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.26),
|
||||
_building_routine("intoxicated_wander", Rules.NpcType.INTOXICATED, "wander", Vector2(600, 900), 0.15),
|
||||
_building_routine("trespass_test", Rules.NpcType.TRESPASSER, "trespass", Vector2(300, 900), 0.12),
|
||||
_building_routine("trespass_test", Rules.NpcType.TRESPASSER, "trespass", Vector2(300, 900), 0.16),
|
||||
{
|
||||
"id": "crisis_event",
|
||||
"phase": Rules.Phase.EVENING,
|
||||
|
||||
@@ -2,15 +2,26 @@ class_name FSM
|
||||
extends RefCounted
|
||||
## Minimal generic finite state machine used by NPCController and ShiftCoordinator.
|
||||
## 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)
|
||||
|
||||
var current_state: String = ""
|
||||
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:
|
||||
_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.
|
||||
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:
|
||||
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.
|
||||
func transition_to(to_state: String) -> bool:
|
||||
@@ -31,10 +42,10 @@ func transition_to(to_state: String) -> bool:
|
||||
if t["to"] != to_state:
|
||||
continue
|
||||
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
|
||||
current_state = to_state
|
||||
state_changed.emit(_owner_node, from, to_state)
|
||||
state_changed.emit(_owner(), from, to_state)
|
||||
return true
|
||||
return false
|
||||
|
||||
@@ -42,6 +53,6 @@ func can_transition_to(to_state: String) -> bool:
|
||||
for t in _transitions.get(current_state, []):
|
||||
if t["to"] == to_state:
|
||||
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 false
|
||||
@@ -1,5 +1,5 @@
|
||||
class_name ShiftCoordinator
|
||||
extends Node
|
||||
extends RefCounted
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const EventBus := preload("res://src/core/event_bus.gd")
|
||||
|
||||
@@ -27,6 +27,35 @@ var door := DoorState.new()
|
||||
var flashlight_on := false
|
||||
var coordinator: ShiftCoordinator = null
|
||||
var guard: GuardController = null
|
||||
var _sun: DirectionalLight3D = null
|
||||
var _env_node: WorldEnvironment = null
|
||||
var _sky: ProceduralSkyMaterial = null
|
||||
var _last_phase: int = -1
|
||||
|
||||
## Per-phase lighting looks for the sun, sky, and ambient (PLAN.md: shift
|
||||
## structure — phase-based lighting). Night phases switch street lamps on.
|
||||
const PHASE_LIGHTING := {
|
||||
Rules.Phase.EARLY: {
|
||||
"sun_energy": 1.2, "sun_angle": -50.0, "sun_color": Color(1.0, 0.98, 0.92),
|
||||
"sky_top": Color(0.32, 0.55, 0.95), "sky_horizon": Color(0.82, 0.88, 0.95),
|
||||
"sky_ground": Color(0.45, 0.5, 0.55), "ambient": Color(0.75, 0.78, 0.85), "night": false,
|
||||
},
|
||||
Rules.Phase.MIDDAY: {
|
||||
"sun_energy": 1.5, "sun_angle": -62.0, "sun_color": Color(1.0, 1.0, 0.98),
|
||||
"sky_top": Color(0.3, 0.52, 0.95), "sky_horizon": Color(0.85, 0.9, 0.96),
|
||||
"sky_ground": Color(0.5, 0.54, 0.58), "ambient": Color(0.85, 0.88, 0.95), "night": false,
|
||||
},
|
||||
Rules.Phase.EVENING: {
|
||||
"sun_energy": 0.7, "sun_angle": -16.0, "sun_color": Color(1.0, 0.72, 0.5),
|
||||
"sky_top": Color(0.62, 0.32, 0.35), "sky_horizon": Color(1.0, 0.68, 0.42),
|
||||
"sky_ground": Color(0.3, 0.24, 0.3), "ambient": Color(0.55, 0.45, 0.5), "night": true,
|
||||
},
|
||||
Rules.Phase.LATE: {
|
||||
"sun_energy": 0.05, "sun_angle": -8.0, "sun_color": Color(0.3, 0.35, 0.5),
|
||||
"sky_top": Color(0.02, 0.03, 0.08), "sky_horizon": Color(0.1, 0.12, 0.2),
|
||||
"sky_ground": Color(0.05, 0.05, 0.08), "ambient": Color(0.08, 0.09, 0.16), "night": true,
|
||||
},
|
||||
}
|
||||
var hud: Hud = null
|
||||
var radio_panel: RadioPanel = null
|
||||
var shift_review_panel: ShiftReview = null
|
||||
@@ -35,7 +64,11 @@ var verifier := AccessVerifier.new()
|
||||
var wand_screener := WandScreener.new()
|
||||
|
||||
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 npcs: Array = []
|
||||
var npc_counter := 0
|
||||
@@ -74,6 +107,7 @@ func _ready() -> void:
|
||||
guard.cleared_target.connect(_on_guard_clear)
|
||||
|
||||
selected_scenario = _build_scenario(active_scenario_id)
|
||||
scenario_duration_s = selected_scenario.shift_duration_s
|
||||
coordinator = ShiftCoordinator.new()
|
||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||
coordinator.npc_spawn_requested.connect(_spawn_npc)
|
||||
@@ -82,6 +116,8 @@ func _ready() -> void:
|
||||
|
||||
_build_ui()
|
||||
coordinator.start()
|
||||
_last_phase = -1
|
||||
_apply_phase_lighting()
|
||||
|
||||
var selected_scenario: VenueScenario = null
|
||||
|
||||
@@ -111,6 +147,7 @@ func _build_guard() -> GuardController:
|
||||
collider.shape = shape
|
||||
collider.position = Vector3(0, 0.85, 0)
|
||||
g.add_child(collider)
|
||||
g.add_to_group("guard")
|
||||
return g
|
||||
|
||||
func _build_scenario(id: String) -> VenueScenario:
|
||||
@@ -137,8 +174,11 @@ func restart_shift() -> void:
|
||||
inspector_current_events = []
|
||||
current_incident_active = false
|
||||
current_incident_npc_id = ""
|
||||
scenario_duration_s = selected_scenario.shift_duration_s
|
||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||
coordinator.start()
|
||||
_last_phase = -1
|
||||
_apply_phase_lighting()
|
||||
|
||||
var inspector_current_events: Array = []
|
||||
|
||||
@@ -160,17 +200,44 @@ func _build_ui() -> void:
|
||||
shift_review_panel.restart_requested.connect(restart_shift)
|
||||
|
||||
func _ensure_lighting() -> void:
|
||||
var sun := DirectionalLight3D.new()
|
||||
sun.rotation_degrees = Vector3(-50, -30, 0)
|
||||
sun.light_energy = 1.2
|
||||
scene_root.add_child(sun)
|
||||
var env_node := WorldEnvironment.new()
|
||||
_sun = DirectionalLight3D.new()
|
||||
_sun.name = "Sun"
|
||||
_sun.rotation_degrees = Vector3(-50, -30, 0)
|
||||
_sun.light_energy = 1.2
|
||||
_sun.shadow_enabled = true
|
||||
_sun.light_angular_distance = 1.2
|
||||
_sun.directional_shadow_mode = DirectionalLight3D.SHADOW_PARALLEL_4_SPLITS
|
||||
_sun.directional_shadow_max_distance = 60.0
|
||||
scene_root.add_child(_sun)
|
||||
_env_node = WorldEnvironment.new()
|
||||
var env := Environment.new()
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
env.ambient_light_color = Color(0.7, 0.7, 0.75)
|
||||
env.background_mode = Environment.BG_SKY
|
||||
_sky = ProceduralSkyMaterial.new()
|
||||
var sky := Sky.new()
|
||||
sky.sky_material = _sky
|
||||
env.sky = sky
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
|
||||
env.ambient_light_energy = 0.5
|
||||
env_node.environment = env
|
||||
scene_root.add_child(env_node)
|
||||
env.reflected_light_source = Environment.REFLECTION_SOURCE_SKY
|
||||
_env_node.environment = env
|
||||
scene_root.add_child(_env_node)
|
||||
|
||||
## Apply the lighting look for the current shift phase (sun, sky, ambient,
|
||||
## street lamps). Runs on phase change and once at shift start.
|
||||
func _apply_phase_lighting() -> void:
|
||||
var phase: int = coordinator.current_phase if coordinator else Rules.Phase.EARLY
|
||||
var cfg: Dictionary = PHASE_LIGHTING.get(phase, PHASE_LIGHTING[Rules.Phase.EARLY])
|
||||
if _sun:
|
||||
_sun.light_energy = float(cfg.get("sun_energy", 1.0))
|
||||
_sun.light_color = cfg.get("sun_color", Color.WHITE)
|
||||
_sun.rotation_degrees = Vector3(float(cfg.get("sun_angle", -50.0)), -30, 0)
|
||||
if _sky:
|
||||
_sky.sky_top_color = cfg.get("sky_top", Color.BLUE)
|
||||
_sky.sky_horizon_color = cfg.get("sky_horizon", Color.WHITE)
|
||||
_sky.ground_bottom_color = cfg.get("sky_ground", Color(0.5, 0.5, 0.5))
|
||||
_sky.ground_horizon_color = cfg.get("sky_horizon", Color.WHITE)
|
||||
if world_builder:
|
||||
world_builder.set_night_mode(cfg.get("night", false))
|
||||
|
||||
func _spawn_npc(profile: NpcProfile, intent: String, _npc_id: String) -> void:
|
||||
npc_counter += 1
|
||||
@@ -342,6 +409,7 @@ func _cycle_venue() -> void:
|
||||
switch_scenario(VENUE_ORDER[_venue_index])
|
||||
var venue_name: String = selected_scenario.display_name if selected_scenario else "?"
|
||||
hud.log(Strings.fmt("hud.venue_switched", [venue_name]))
|
||||
hud.set_wand_icon(false)
|
||||
hud.set_shift_state(coordinator, selected_scenario)
|
||||
|
||||
## Aim-free targeting: cycle through NPCs near the guard (Tab). Lets players
|
||||
@@ -458,6 +526,13 @@ func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
|
||||
coordinator.crowd.apply_event("calm_professionalism")
|
||||
if audio:
|
||||
audio.play_radio_response()
|
||||
_maybe_radio_easter_egg()
|
||||
|
||||
## Rare hidden radio transmission: a stray voice asks "¿Dónde está la mota?"
|
||||
## over the radio. Fires on a small random chance after a successful call.
|
||||
func _maybe_radio_easter_egg() -> void:
|
||||
if _door_rng.randf() < 0.025:
|
||||
hud.log(Strings.lookup("radio.easter_egg"))
|
||||
|
||||
# ----------------------------------------------------------------- access --
|
||||
|
||||
@@ -483,13 +558,19 @@ func _toggle_inspection() -> void:
|
||||
## Handheld wand inspection (The Joke Shop). Only available when the venue
|
||||
## carries a wand. The wand reports an alert; the player interprets it.
|
||||
func _toggle_wand_screening() -> void:
|
||||
if _wand_cooldown > 0.0:
|
||||
hud.log(Strings.lookup("wand.recharge"))
|
||||
return
|
||||
if wand_open:
|
||||
hud.hide_wand()
|
||||
hud.set_wand_icon(false)
|
||||
wand_open = false
|
||||
return
|
||||
if not selected_scenario.equipment.has("wand"):
|
||||
hud.log(Strings.lookup("hud.no_wand"))
|
||||
hud.set_wand_icon(false)
|
||||
return
|
||||
hud.set_wand_icon(true)
|
||||
var controller: Object = _targeted_controller()
|
||||
if controller == null:
|
||||
return
|
||||
@@ -543,6 +624,8 @@ func decide_wand(allowed: bool) -> void:
|
||||
controller.screened = true
|
||||
hud.hide_wand()
|
||||
wand_open = false
|
||||
# Short scan cooldown: the wand must “recharge” between guests (PLAN.md: feel).
|
||||
_wand_cooldown = WAND_COOLDOWN_S
|
||||
|
||||
func decide_access(allowed: bool) -> void:
|
||||
var controller: Object = _targeted_controller()
|
||||
@@ -642,9 +725,14 @@ func _close_incident(outcome: String) -> void:
|
||||
current_incident_npc_id = ""
|
||||
|
||||
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:
|
||||
return
|
||||
coordinator.tick(delta)
|
||||
if coordinator.current_phase != _last_phase:
|
||||
_last_phase = coordinator.current_phase
|
||||
_apply_phase_lighting()
|
||||
hud.set_shift_state(coordinator, selected_scenario)
|
||||
_perception_pass()
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ func get_npc_controller() -> NPCController:
|
||||
|
||||
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:
|
||||
npc.setup("npc_%d" % seed_value, profile, intent, seed_value)
|
||||
_visual_seed = seed_value
|
||||
spawn_position = p_spawn
|
||||
target_position = p_target
|
||||
door_position = p_door
|
||||
@@ -66,33 +67,137 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector
|
||||
collider.position = Vector3(0, 0.8, 0)
|
||||
add_child(collider)
|
||||
|
||||
var _visual_seed := 0
|
||||
var _rig: Node3D = null
|
||||
var _head: Node3D = null
|
||||
var _bob_phase := 0.0
|
||||
var _guard_ref: Node3D = null
|
||||
const SKIN_TONE := Color(0.88, 0.72, 0.60)
|
||||
const PANTS_COLOR := Color(0.22, 0.22, 0.26)
|
||||
|
||||
func _build_body(npc_type: int) -> void:
|
||||
var body := MeshInstance3D.new()
|
||||
var capsule := CapsuleMesh.new()
|
||||
capsule.radius = 0.35
|
||||
capsule.height = 1.6
|
||||
body.mesh = capsule
|
||||
_rig = Node3D.new()
|
||||
_rig.name = "Rig"
|
||||
add_child(_rig)
|
||||
var base_color: Color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
|
||||
# Per-seed variation: slight height and brightness differences so crowds
|
||||
# read as individuals (deterministic LCG, no RNG side effects).
|
||||
var frac := float((_visual_seed * 1103515245 + 12345) % 1000) / 1000.0
|
||||
var height_scale := 0.92 + 0.16 * frac
|
||||
_rig.scale.y = height_scale
|
||||
var shader_delta := 0.12 * frac
|
||||
base_color = base_color.lightened(shader_delta) if frac < 0.5 else base_color.darkened(shader_delta)
|
||||
var limbs_color := base_color.darkened(0.25)
|
||||
# Torso
|
||||
_add_capsule(_rig, 0.30, 0.95, Vector3(0, 1.05, 0), base_color)
|
||||
# Legs
|
||||
_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), PANTS_COLOR)
|
||||
_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), PANTS_COLOR)
|
||||
# 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)
|
||||
# Head
|
||||
_head = Node3D.new()
|
||||
_head.name = "Head"
|
||||
_head.position = Vector3(0, 1.62, 0)
|
||||
_add_sphere(_head, 0.17, Vector3.ZERO, SKIN_TONE)
|
||||
_rig.add_child(_head)
|
||||
_add_props(npc_type)
|
||||
|
||||
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> void:
|
||||
var mi := MeshInstance3D.new()
|
||||
var mesh := CapsuleMesh.new()
|
||||
mesh.radius = radius
|
||||
mesh.height = height
|
||||
mi.mesh = mesh
|
||||
mi.position = pos
|
||||
mi.rotation_degrees.z = z_rot
|
||||
mi.material_override = _material(color)
|
||||
parent.add_child(mi)
|
||||
|
||||
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color) -> void:
|
||||
var mi := MeshInstance3D.new()
|
||||
var mesh := SphereMesh.new()
|
||||
mesh.radius = radius
|
||||
mesh.height = radius * 2.0
|
||||
mi.mesh = mesh
|
||||
mi.position = pos
|
||||
mi.material_override = _material(color)
|
||||
parent.add_child(mi)
|
||||
|
||||
func _material(color: Color) -> StandardMaterial3D:
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
|
||||
body.material_override = mat
|
||||
add_child(body)
|
||||
var id_tag := MeshInstance3D.new()
|
||||
var sphere := SphereMesh.new()
|
||||
sphere.radius = 0.12
|
||||
sphere.height = 0.24
|
||||
var tag_mat := StandardMaterial3D.new()
|
||||
tag_mat.albedo_color = Color(1, 1, 1)
|
||||
id_tag.mesh = sphere
|
||||
id_tag.material_override = tag_mat
|
||||
id_tag.position = Vector3(0, 1.05, 0)
|
||||
id_tag.scale = Vector3(1, 0.4, 1)
|
||||
add_child(id_tag)
|
||||
mat.albedo_color = color
|
||||
return mat
|
||||
|
||||
## Small per-archetype props so silhouettes differ (camera, box, hat, sign…).
|
||||
func _add_props(npc_type: int) -> void:
|
||||
var chest_y := 1.25
|
||||
match npc_type:
|
||||
Rules.NpcType.INFLUENCER:
|
||||
_add_box_prop(_rig, Vector3(0.16, 0.1, 0.09), Vector3(0.3, chest_y, 0.22), Color(0.1, 0.1, 0.1))
|
||||
_add_box_prop(_rig, Vector3(0.05, 0.05, 0.06), Vector3(0.34, chest_y + 0.06, 0.30), Color(0.4, 0.7, 1.0))
|
||||
Rules.NpcType.DELIVERY:
|
||||
_add_box_prop(_rig, Vector3(0.34, 0.26, 0.28), Vector3(0.0, 1.02, 0.3), Color(0.55, 0.4, 0.25))
|
||||
Rules.NpcType.PERFORMER:
|
||||
var hat := _material(Color(0.85, 0.2, 0.25))
|
||||
var cone := MeshInstance3D.new()
|
||||
var cm := CylinderMesh.new()
|
||||
cm.top_radius = 0.02
|
||||
cm.bottom_radius = 0.22
|
||||
cm.height = 0.4
|
||||
cone.mesh = cm
|
||||
cone.material_override = hat
|
||||
cone.position = Vector3(0, 0.26, 0)
|
||||
_head.add_child(cone)
|
||||
Rules.NpcType.PROTESTER:
|
||||
_add_box_prop(_rig, Vector3(0.05, 1.2, 0.05), Vector3(0.28, 1.45, 0.18), Color(0.4, 0.36, 0.32))
|
||||
_add_box_prop(_rig, Vector3(0.5, 0.42, 0.04), Vector3(0.48, 1.55, 0.18), Color(0.93, 0.93, 0.95))
|
||||
Rules.NpcType.TOURIST:
|
||||
_add_box_prop(_rig, Vector3(0.3, 0.36, 0.14), Vector3(-0.34, 1.15, -0.18), Color(0.25, 0.3, 0.45))
|
||||
Rules.NpcType.CONFUSED_VISITOR:
|
||||
_add_box_prop(_rig, Vector3(0.3, 0.05, 0.34), Vector3(0, 0.92, 0.06), Color(0.9, 0.88, 0.8))
|
||||
|
||||
func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) -> void:
|
||||
var mi := MeshInstance3D.new()
|
||||
var mesh := BoxMesh.new()
|
||||
mesh.size = size
|
||||
mi.mesh = mesh
|
||||
mi.position = pos
|
||||
mi.material_override = _material(color)
|
||||
parent.add_child(mi)
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if npc == null or npc.fsm == null:
|
||||
return
|
||||
_advance_fsm(delta)
|
||||
_move_toward_target(delta)
|
||||
_animate(delta)
|
||||
|
||||
## Procedural walk bob + head-toward-guard cue when the NPC is aware.
|
||||
func _animate(delta: float) -> void:
|
||||
if _rig == null:
|
||||
return
|
||||
var speed := Vector2(velocity.x, velocity.z).length()
|
||||
if speed > 0.3:
|
||||
_bob_phase += delta * (6.0 + speed)
|
||||
_rig.position.y = sin(_bob_phase) * 0.035
|
||||
_rig.rotation.x = sin(_bob_phase * 0.5) * 0.04
|
||||
else:
|
||||
_bob_phase = 0.0
|
||||
_rig.position.y = lerpf(_rig.position.y, 0.0, 0.2)
|
||||
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
|
||||
# Attention cue: head turns toward the guard when awareness was raised.
|
||||
if _head != null and npc.guard_noticed:
|
||||
if _guard_ref == null or not is_instance_valid(_guard_ref):
|
||||
_guard_ref = get_tree().get_first_node_in_group("guard") as Node3D
|
||||
if _guard_ref:
|
||||
var to_guard := (_guard_ref.global_position - global_position)
|
||||
to_guard.y = 0.0
|
||||
var world_yaw := atan2(to_guard.x, to_guard.z)
|
||||
var fwd := -_rig.global_transform.basis.z
|
||||
var parent_yaw := atan2(fwd.x, fwd.z)
|
||||
_head.rotation.y = lerp_angle(_head.rotation.y, world_yaw - parent_yaw, 0.1)
|
||||
|
||||
func _advance_fsm(delta: float) -> void:
|
||||
# Returning-trespasser: an NPC directed away may circle back to the door.
|
||||
|
||||
@@ -5,6 +5,23 @@ extends CanvasLayer
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const Strings := preload("res://src/core/strings.gd")
|
||||
const T_VIGNETTE := preload("res://assets/textures/vignette.png")
|
||||
const T_ICON_FLASH := preload("res://assets/textures/icons/flashlight_on.png")
|
||||
const T_ICON_DOOR := preload("res://assets/textures/icons/door_locked.png")
|
||||
const T_ICON_WAND := preload("res://assets/textures/icons/wand.png")
|
||||
const T_ICON_REC := preload("res://assets/textures/icons/rec_dot.png")
|
||||
const T_CARD_ID := preload("res://assets/textures/cards/id_card.png")
|
||||
const T_CARD_WRIST := preload("res://assets/textures/cards/wristband.png")
|
||||
const T_CARD_TICKET := preload("res://assets/textures/cards/ticket.png")
|
||||
const T_CARD_CRED := preload("res://assets/textures/cards/credential.png")
|
||||
const T_CARD_GUEST := preload("res://assets/textures/cards/guest_list.png")
|
||||
const CARD_TEXTURES := {
|
||||
Rules.AccessKind.ID: T_CARD_ID,
|
||||
Rules.AccessKind.WRISTBAND: T_CARD_WRIST,
|
||||
Rules.AccessKind.TICKET: T_CARD_TICKET,
|
||||
Rules.AccessKind.CREDENTIAL: T_CARD_CRED,
|
||||
Rules.AccessKind.GUEST_LIST: T_CARD_GUEST,
|
||||
}
|
||||
|
||||
var top_label: Label
|
||||
var time_label: Label
|
||||
@@ -17,6 +34,12 @@ var inspect_text: Label
|
||||
var inspect_meta: Label
|
||||
var radio_cooldown_label: Label
|
||||
var mode_label: Label
|
||||
var vignette_rect: TextureRect
|
||||
var cam_clock: Label
|
||||
var icon_row: HBoxContainer
|
||||
var flashlight_icon: TextureRect
|
||||
var door_icon: TextureRect
|
||||
var wand_icon: TextureRect
|
||||
var wand_panel: PanelContainer
|
||||
var wand_text: Label
|
||||
var wand_meta: Label
|
||||
@@ -105,10 +128,64 @@ func _init() -> void:
|
||||
escalation_label.modulate = Color(0.6, 0.9, 0.6)
|
||||
vbox.add_child(escalation_label)
|
||||
|
||||
# --- escalation vignette (screen-space tint) ---
|
||||
vignette_rect = TextureRect.new()
|
||||
vignette_rect.texture = T_VIGNETTE
|
||||
vignette_rect.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
vignette_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
vignette_rect.modulate = Color(1, 1, 1, 0)
|
||||
vignette_rect.show_behind_parent = false
|
||||
add_child(vignette_rect)
|
||||
|
||||
# --- body-camera overlay (REC + shift clock + corner brackets, top right) ---
|
||||
var cam_box := VBoxContainer.new()
|
||||
cam_box.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
||||
cam_box.position = Vector2(-180, 50)
|
||||
cam_box.alignment = BoxContainer.ALIGNMENT_END
|
||||
add_child(cam_box)
|
||||
var rec_row := HBoxContainer.new()
|
||||
rec_row.alignment = BoxContainer.ALIGNMENT_END
|
||||
cam_box.add_child(rec_row)
|
||||
var rec_icon := TextureRect.new()
|
||||
rec_icon.texture = T_ICON_REC
|
||||
rec_icon.custom_minimum_size = Vector2(22, 22)
|
||||
rec_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
rec_row.add_child(rec_icon)
|
||||
var rec_label := Label.new()
|
||||
rec_label.text = "REC"
|
||||
rec_label.add_theme_font_size_override("font_size", 15)
|
||||
rec_label.modulate = Color(0.95, 0.35, 0.35)
|
||||
rec_row.add_child(rec_label)
|
||||
cam_clock = Label.new()
|
||||
cam_clock.text = "SHIFT 00:00:00"
|
||||
cam_clock.add_theme_font_size_override("font_size", 12)
|
||||
cam_clock.modulate = Color(0.95, 0.95, 0.95)
|
||||
cam_clock.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
cam_box.add_child(cam_clock)
|
||||
# corner brackets
|
||||
var bracket_color := Color(0.9, 0.9, 0.92, 0.55)
|
||||
_add_corner_brackets(bracket_color)
|
||||
|
||||
# --- mode icons next to the door/light label ---
|
||||
icon_row = HBoxContainer.new()
|
||||
vbox.add_child(icon_row)
|
||||
flashlight_icon = _make_icon(T_ICON_FLASH)
|
||||
flashlight_icon.visible = false
|
||||
icon_row.add_child(flashlight_icon)
|
||||
door_icon = _make_icon(T_ICON_DOOR)
|
||||
door_icon.visible = false
|
||||
icon_row.add_child(door_icon)
|
||||
wand_icon = _make_icon(T_ICON_WAND)
|
||||
wand_icon.visible = false
|
||||
icon_row.add_child(wand_icon)
|
||||
|
||||
# --- 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.visible = false
|
||||
inspect_panel.set_anchors_preset(Control.PRESET_CENTER)
|
||||
inspect_panel.modulate.a = 1.0
|
||||
var ivbox := VBoxContainer.new()
|
||||
inspect_panel.add_child(ivbox)
|
||||
@@ -123,12 +200,15 @@ func _init() -> void:
|
||||
insp_hint.text = "A = allow entry D = deny entry"
|
||||
insp_hint.add_theme_font_size_override("font_size", 12)
|
||||
ivbox.add_child(insp_hint)
|
||||
add_child(inspect_panel)
|
||||
inspect_center.add_child(inspect_panel)
|
||||
|
||||
# --- 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.visible = false
|
||||
wand_panel.set_anchors_preset(Control.PRESET_CENTER)
|
||||
var wvbox := VBoxContainer.new()
|
||||
wand_panel.add_child(wvbox)
|
||||
wand_text = Label.new()
|
||||
@@ -142,7 +222,7 @@ func _init() -> void:
|
||||
wand_hint.text = "A = admit D = refuse entry"
|
||||
wand_hint.add_theme_font_size_override("font_size", 12)
|
||||
wvbox.add_child(wand_hint)
|
||||
add_child(wand_panel)
|
||||
wand_center.add_child(wand_panel)
|
||||
|
||||
# --- debug overlay ---
|
||||
debug_label = Label.new()
|
||||
@@ -163,6 +243,9 @@ func set_shift_state(coordinator: Object, scenario: Resource) -> void:
|
||||
coordinator.reputation.rating,
|
||||
coordinator.crowd.mood * 100.0,
|
||||
]
|
||||
if cam_clock:
|
||||
var total := int(coordinator.elapsed_s)
|
||||
cam_clock.text = "SHIFT %02d:%02d:%02d" % [total / 3600, (total / 60) % 60, total % 60]
|
||||
_update_radio_cooldown(coordinator)
|
||||
|
||||
func _update_radio_cooldown(coordinator: Object) -> void:
|
||||
@@ -188,6 +271,47 @@ func set_flashlight(on: bool) -> void:
|
||||
|
||||
func _refresh_mode_label() -> void:
|
||||
mode_label.text = "Door: %s · Light: %s" % ["LOCKED" if _door_locked else "open", "on" if _flashlight else "off"]
|
||||
if icon_row:
|
||||
flashlight_icon.visible = _flashlight
|
||||
door_icon.visible = _door_locked
|
||||
|
||||
func set_wand_icon(visible: bool) -> void:
|
||||
if wand_icon:
|
||||
wand_icon.visible = visible
|
||||
|
||||
func _make_icon(texture: Texture2D) -> TextureRect:
|
||||
var tr := TextureRect.new()
|
||||
tr.texture = texture
|
||||
tr.custom_minimum_size = Vector2(20, 20)
|
||||
tr.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
tr.modulate = Color(1, 1, 1, 0.9)
|
||||
return tr
|
||||
|
||||
func _add_corner_brackets(color: Color) -> void:
|
||||
const LEN := 34.0
|
||||
const TH := 3.0
|
||||
const OFF := 10.0
|
||||
var viewport := get_viewport()
|
||||
var vp: Vector2 = viewport.get_visible_rect().size if viewport else Vector2(1280, 720)
|
||||
var corners := [
|
||||
Vector2(OFF, OFF), Vector2(vp.x - OFF, OFF), Vector2(OFF, vp.y - OFF), Vector2(vp.x - OFF, vp.y - OFF),
|
||||
]
|
||||
for i in 4:
|
||||
var c: Vector2 = corners[i]
|
||||
var flip_x: bool = (i % 2) == 1
|
||||
var flip_y: bool = i >= 2
|
||||
var h := ColorRect.new()
|
||||
h.color = color
|
||||
h.size = Vector2(LEN, TH)
|
||||
h.position = Vector2(c.x - (LEN if flip_x else 0.0), c.y - (TH if flip_y else 0.0))
|
||||
h.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(h)
|
||||
var v := ColorRect.new()
|
||||
v.color = color
|
||||
v.size = Vector2(TH, LEN)
|
||||
v.position = Vector2(c.x - (TH if flip_x else 0.0), c.y - (LEN if flip_y else 0.0))
|
||||
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
add_child(v)
|
||||
|
||||
func set_action_hint(text: String) -> void:
|
||||
hint_label.text = text
|
||||
@@ -199,15 +323,20 @@ func set_action_hint(text: String) -> void:
|
||||
func set_escalation(level: int) -> void:
|
||||
var name: String = Rules.LEVEL_NAMES.get(level, "Observation")
|
||||
escalation_label.text = Strings.fmt("hud.escalation_prefix", [name])
|
||||
var tint := Color(0.6, 0.9, 0.6)
|
||||
match level:
|
||||
Rules.Level.OBSERVATION, Rules.Level.SIMPLE_DETERRENCE:
|
||||
escalation_label.modulate = Color(0.6, 0.9, 0.6)
|
||||
tint = Color(0.6, 0.9, 0.6)
|
||||
Rules.Level.FORMAL_WARNING:
|
||||
escalation_label.modulate = Color(0.95, 0.9, 0.4)
|
||||
tint = Color(0.95, 0.9, 0.4)
|
||||
Rules.Level.BLOCKING:
|
||||
escalation_label.modulate = Color(1.0, 0.65, 0.3)
|
||||
tint = Color(1.0, 0.65, 0.3)
|
||||
Rules.Level.RADIO_ASSIST, Rules.Level.EMERGENCY:
|
||||
escalation_label.modulate = Color(1.0, 0.35, 0.3)
|
||||
tint = Color(1.0, 0.35, 0.3)
|
||||
escalation_label.modulate = tint
|
||||
# Screen-space vignette follows the escalation color.
|
||||
if vignette_rect:
|
||||
vignette_rect.modulate = Color(tint.r, tint.g, tint.b, 0.05 + 0.2 * float(level) / 5.0)
|
||||
|
||||
func log(text: String) -> void:
|
||||
_log_lines.append(text)
|
||||
@@ -240,6 +369,28 @@ func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -
|
||||
var npc_display: String = (profile.display_name if profile else "Person")
|
||||
var granted: bool = result.get("granted", false)
|
||||
inspect_text.text = "Access check — %s\nGranted: %s" % [npc_display, "YES" if granted else "NO"]
|
||||
_clear_card_row()
|
||||
var card_row := HBoxContainer.new()
|
||||
card_row.name = "INSPECT_CARD_ROW"
|
||||
card_row.add_theme_constant_override("separation", 6)
|
||||
var added_card := false
|
||||
for artifact in artifacts:
|
||||
var tex: Texture2D = CARD_TEXTURES.get(artifact.kind, null) as Texture2D
|
||||
if tex == null:
|
||||
continue
|
||||
var card := TextureRect.new()
|
||||
card.texture = tex
|
||||
card.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
card.custom_minimum_size = Vector2(92, 58)
|
||||
card.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
card.tooltip_text = artifact.summary()
|
||||
if artifact.attributes.size() > 0:
|
||||
card.tooltip_text += "\n" + str(artifact.attributes)
|
||||
card_row.add_child(card)
|
||||
added_card = true
|
||||
if added_card:
|
||||
var content := inspect_panel.get_child(0) as VBoxContainer
|
||||
content.add_child(card_row)
|
||||
var lines: Array[String] = []
|
||||
for artifact in artifacts:
|
||||
lines.append(" • " + artifact.summary())
|
||||
@@ -251,8 +402,18 @@ func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -
|
||||
inspect_panel.visible = true
|
||||
|
||||
func hide_inspection() -> void:
|
||||
_clear_card_row()
|
||||
inspect_panel.visible = false
|
||||
|
||||
## Remove previously added artifact cards so reopening rebuilds them.
|
||||
func _clear_card_row() -> void:
|
||||
if inspect_panel == null:
|
||||
return
|
||||
var row := inspect_panel.find_child("INSPECT_CARD_ROW", true, false) as Control
|
||||
if row:
|
||||
row.get_parent().remove_child(row)
|
||||
row.queue_free()
|
||||
|
||||
func show_wand(controller: Object, result: Dictionary) -> void:
|
||||
var profile: Object = controller.profile
|
||||
var npc_display: String = (profile.display_name if profile else "Person")
|
||||
|
||||
@@ -34,9 +34,12 @@ func _init() -> void:
|
||||
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
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.set_anchors_preset(Control.PRESET_CENTER)
|
||||
add_child(panel)
|
||||
center.add_child(panel)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
panel.add_child(vbox)
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
class_name WorldBuilder
|
||||
## Procedurally assembles a minimal Hollywood Boulevard entrance prototype:
|
||||
|
||||
const T_BRICK := preload("res://assets/textures/brick_facade.png")
|
||||
const T_CONCRETE := preload("res://assets/textures/concrete.png")
|
||||
const T_ASPHALT := preload("res://assets/textures/asphalt.png")
|
||||
const T_AWNING := preload("res://assets/textures/awning.png")
|
||||
const T_SKYLINE := preload("res://assets/textures/skyline.png")
|
||||
## steps, building facade, restricted door, sidewalk, pavement, and street edge.
|
||||
## No art assets required; all geometry is BoxMesh with flat materials.
|
||||
|
||||
@@ -7,6 +13,12 @@ var root: Node3D = null
|
||||
## Door lock indicator mesh near the handle; toggled by GameManager.
|
||||
var door_indicator: MeshInstance3D = null
|
||||
var _door_indicator_mat: StandardMaterial3D = null
|
||||
## Street lamps and signage so night mode can switch them on.
|
||||
var _street_lamps: Array[MeshInstance3D] = []
|
||||
var _lamp_omni: Array[OmniLight3D] = []
|
||||
var _lamp_bulb_mats: Array[StandardMaterial3D] = []
|
||||
var marquee_label: Label3D = null
|
||||
var _marquee_emissive: Array[StandardMaterial3D] = []
|
||||
|
||||
func _init(root_node: Node3D) -> void:
|
||||
root = root_node
|
||||
@@ -14,17 +26,26 @@ func _init(root_node: Node3D) -> void:
|
||||
func build() -> void:
|
||||
_build_ground()
|
||||
_build_building()
|
||||
_build_marquee()
|
||||
_build_steps()
|
||||
_build_sidewalk()
|
||||
_build_street()
|
||||
_build_crosswalk()
|
||||
_build_street_furniture()
|
||||
_build_guard_spawn()
|
||||
_build_skyline_backdrop()
|
||||
_build_navmesh()
|
||||
set_night_mode(false)
|
||||
|
||||
func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_shadow: bool = false) -> MeshInstance3D:
|
||||
func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_shadow: bool = false,
|
||||
texture: Texture2D = null, uv_scale: Vector2 = Vector2(1, 1)) -> MeshInstance3D:
|
||||
var mesh := BoxMesh.new()
|
||||
mesh.size = size
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = color
|
||||
if texture:
|
||||
mat.albedo_texture = texture
|
||||
mat.uv1_scale = Vector3(uv_scale.x, uv_scale.y, 1.0)
|
||||
mesh.material = mat
|
||||
var mi := MeshInstance3D.new()
|
||||
mi.mesh = mesh
|
||||
@@ -46,19 +67,23 @@ func _build_ground() -> void:
|
||||
func _build_building() -> void:
|
||||
var building := StaticBody3D.new()
|
||||
building.name = "Building"
|
||||
# Main facade
|
||||
_add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -2), Color(0.35, 0.32, 0.30))
|
||||
# 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))
|
||||
# Entrance recess + door recess frame
|
||||
_add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -4.0), Color(0.10, 0.10, 0.10))
|
||||
# Door
|
||||
_add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -4.15), Color(0.16, 0.15, 0.13))
|
||||
# Door window
|
||||
_add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -4.0), Color(0.5, 0.7, 0.9, 1.0))
|
||||
_add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -4.0), Color(0.08, 0.08, 0.09))
|
||||
# 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))
|
||||
# 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_mat := StandardMaterial3D.new()
|
||||
glass_mat.albedo_color = Color(0.6, 0.75, 0.9, 0.45)
|
||||
glass_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
glass_mat.roughness = 0.15
|
||||
glass_mat.metallic = 0.1
|
||||
glass.material_override = glass_mat
|
||||
# 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.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3))
|
||||
# Awning sign bar
|
||||
_add_box(building, Vector3(8, 0.5, 0.6), Vector3(0, 7.0, -3.6), Color(0.45, 0.44, 0.44))
|
||||
var col := BoxShape3D.new()
|
||||
col.size = Vector3(40, 18, 4)
|
||||
var so := building.create_shape_owner(building)
|
||||
@@ -86,7 +111,7 @@ func _build_steps() -> void:
|
||||
func _build_sidewalk() -> void:
|
||||
var sidewalk := StaticBody3D.new()
|
||||
sidewalk.name = "Sidewalk"
|
||||
_add_box(sidewalk, Vector3(30, 0.18, 8), Vector3(0, 0.09, 8), Color(0.60, 0.58, 0.56))
|
||||
_add_box(sidewalk, Vector3(30, 0.18, 8), Vector3(0, 0.09, 8), Color(1, 1, 1), false, T_CONCRETE, Vector2(9, 2.4))
|
||||
var col := BoxShape3D.new()
|
||||
col.size = Vector3(30, 0.36, 8)
|
||||
var so := sidewalk.create_shape_owner(sidewalk)
|
||||
@@ -97,7 +122,7 @@ func _build_sidewalk() -> void:
|
||||
func _build_street() -> void:
|
||||
var street := StaticBody3D.new()
|
||||
street.name = "Street"
|
||||
_add_box(street, Vector3(120, 0.1, 26), Vector3(0, 0.05, 22), Color(0.13, 0.13, 0.14))
|
||||
_add_box(street, Vector3(120, 0.1, 26), Vector3(0, 0.05, 22), Color(1, 1, 1), false, T_ASPHALT, Vector2(30, 7))
|
||||
# Center dashes
|
||||
for i in range(-40, 41, 4):
|
||||
_add_box(street, Vector3(0.2, 0.02, 1.2), Vector3(i * 0.0, 0.11, 22), Color(0.85, 0.85, 0.30))
|
||||
@@ -127,6 +152,137 @@ func _build_navmesh() -> void:
|
||||
region.navigation_mesh = mesh
|
||||
root.add_child(region)
|
||||
|
||||
func _build_marquee() -> void:
|
||||
## Striped awning above the door, then the neon sign strip + label.
|
||||
var awning := _add_box(root, Vector3(10.5, 0.08, 1.8), Vector3(0, 6.55, -3.7), Color(1, 1, 1), false, T_AWNING)
|
||||
awning.name = "Awning"
|
||||
## Neon-style marquee strip above the door: emissive bars + a label.
|
||||
var strip_parent := Node3D.new()
|
||||
strip_parent.name = "Marquee"
|
||||
for offset in [-2.2, 2.2]:
|
||||
var bar := _add_box(strip_parent, Vector3(0.25, 0.5, 0.5), Vector3(offset, 7.4, -3.6), Color(1, 0.35, 0.75))
|
||||
var mat := _emissive_material(Color(1.0, 0.35, 0.75))
|
||||
bar.material_override = mat
|
||||
_marquee_emissive.append(mat)
|
||||
marquee_label = Label3D.new()
|
||||
marquee_label.name = "MarqueeLabel"
|
||||
marquee_label.text = "STEP LINE"
|
||||
marquee_label.position = Vector3(0, 7.3, -3.55)
|
||||
marquee_label.modulate = Color(1, 0.6, 0.9)
|
||||
marquee_label.font_size = 72
|
||||
marquee_label.pixel_size = 0.004
|
||||
marquee_label.outline_size = 12
|
||||
marquee_label.outline_modulate = Color(0.9, 0.15, 0.5)
|
||||
marquee_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||
marquee_label.no_depth_test = false
|
||||
strip_parent.add_child(marquee_label)
|
||||
root.add_child(strip_parent)
|
||||
|
||||
func _build_street_furniture() -> void:
|
||||
# Street lamps along the sidewalk (bulbs + omni lights, off by day).
|
||||
for side in [-7.5, 7.5]:
|
||||
var lamp := Node3D.new()
|
||||
lamp.name = "Lamp_%d" % int(side)
|
||||
_add_box(lamp, Vector3(0.22, 5.0, 0.22), Vector3(side, 2.5, 8.5), Color(0.16, 0.16, 0.18))
|
||||
_add_box(lamp, Vector3(1.6, 0.15, 0.15), Vector3(side + (0.8 if side < 0 else -0.8), 4.9, 8.5), Color(0.16, 0.16, 0.18))
|
||||
var bulb := _add_box(lamp, Vector3(0.16, 0.16, 0.5), Vector3(side + (0.8 if side < 0 else -0.8), 4.85, 8.5), Color(1, 1, 0.85))
|
||||
var bulb_mat := _emissive_material(Color(1.0, 1.0, 0.8))
|
||||
bulb.material_override = bulb_mat
|
||||
bulb.material_override.emission_energy_multiplier = 1.5
|
||||
_lamp_bulb_mats.append(bulb_mat)
|
||||
var omni := OmniLight3D.new()
|
||||
omni.position = Vector3(side + (0.8 if side < 0 else -0.8), 4.7, 8.5)
|
||||
omni.omni_range = 14.0
|
||||
omni.light_energy = 0.0
|
||||
omni.light_color = Color(1, 0.93, 0.75)
|
||||
lamp.add_child(omni)
|
||||
_lamp_omni.append(omni)
|
||||
root.add_child(lamp)
|
||||
_street_lamps.append(bulb)
|
||||
# Planters beside the steps.
|
||||
for side in [-4.6, 4.6]:
|
||||
var planter := _add_box(root, Vector3(1.1, 0.7, 1.1), Vector3(side, 0.35, 1.0), Color(0.42, 0.34, 0.28))
|
||||
planter.name = "Planter_%d" % int(side)
|
||||
# Low-poly trees along the street front.
|
||||
var trunk_color := Color(0.35, 0.25, 0.18)
|
||||
var leaf_color := Color(0.16, 0.42, 0.2)
|
||||
for i in 6:
|
||||
var x := -11.0 + i * 4.4
|
||||
var z := 4.0 + float((i * 7) % 5)
|
||||
var tree := Node3D.new()
|
||||
tree.name = "Tree_%d" % (i + 1)
|
||||
var trunk := MeshInstance3D.new()
|
||||
var trunk_mesh := CylinderMesh.new()
|
||||
trunk_mesh.top_radius = 0.09
|
||||
trunk_mesh.bottom_radius = 0.14
|
||||
trunk_mesh.height = 1.4
|
||||
trunk.mesh = trunk_mesh
|
||||
trunk.material_override = _plain_material(trunk_color)
|
||||
trunk.position = Vector3(x, 0.7, z)
|
||||
tree.add_child(trunk)
|
||||
var crown := MeshInstance3D.new()
|
||||
var crown_mesh := CylinderMesh.new()
|
||||
crown_mesh.top_radius = 0.05
|
||||
crown_mesh.bottom_radius = 0.85
|
||||
crown_mesh.height = 2.2
|
||||
crown.mesh = crown_mesh
|
||||
crown.material_override = _plain_material(leaf_color)
|
||||
crown.position = Vector3(x, 2.1, z)
|
||||
tree.add_child(crown)
|
||||
root.add_child(tree)
|
||||
|
||||
func _build_crosswalk() -> void:
|
||||
# White crosswalk stripes in front of the entrance on the street.
|
||||
for i in 8:
|
||||
_add_box(root, Vector3(0.8, 0.02, 0.5), Vector3(-2.8 + i * 0.85, 0.11, 17.0), Color(0.92, 0.92, 0.92))
|
||||
_add_box(root, Vector3(0.8, 0.02, 0.5), Vector3(-2.8 + i * 0.85, 0.11, 19.0), Color(0.92, 0.92, 0.92))
|
||||
|
||||
## Switch street lamps and signage to night mode (emissive + lights on).
|
||||
func set_night_mode(night: bool) -> void:
|
||||
for mat in _lamp_bulb_mats:
|
||||
mat.emission_energy_multiplier = (2.0 if night else 0.0)
|
||||
for omni in _lamp_omni:
|
||||
omni.light_energy = (3.0 if night else 0.0)
|
||||
for mat in _marquee_emissive:
|
||||
mat.emission_energy_multiplier = (2.0 if night else 0.2)
|
||||
if marquee_label:
|
||||
marquee_label.modulate.a = (1.0 if night else 0.9)
|
||||
|
||||
func _emissive_material(color: Color) -> StandardMaterial3D:
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = color
|
||||
mat.emission_enabled = true
|
||||
mat.emission = color
|
||||
mat.emission_energy_multiplier = 1.0
|
||||
return mat
|
||||
|
||||
func _plain_material(color: Color) -> StandardMaterial3D:
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = color
|
||||
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:
|
||||
var marker := Marker3D.new()
|
||||
marker.name = "GuardSpawn"
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
extends SceneTree
|
||||
## Dev helper: run the game windowed and capture staged screenshots for the
|
||||
## README. Saves 1600x900 PNGs to user://screenshots/.
|
||||
## README (day set + LATE-night set with street lamps and flashlight).
|
||||
## Saves 1280x720 PNGs to user://screenshots/.
|
||||
## Usage: godot --path . --script res://src/test/capture_screenshots.gd
|
||||
|
||||
const OUT_DIR := "user://screenshots"
|
||||
const MAX_FRAMES := 2400 # hard cap (~40s)
|
||||
const MAX_FRAMES := 3600 # hard cap (~60s)
|
||||
|
||||
var frames := 0
|
||||
var gm: Node = null
|
||||
var state := 0
|
||||
var state_frames := 0
|
||||
var targeted := false
|
||||
var camera_staged := false
|
||||
|
||||
func _initialize() -> void:
|
||||
root.size = Vector2i(1600, 900)
|
||||
root.size = Vector2i(1280, 720)
|
||||
var packed: PackedScene = load("res://scenes/main.tscn")
|
||||
var inst: Node = packed.instantiate()
|
||||
root.add_child(inst)
|
||||
@@ -28,6 +30,10 @@ func _process(_delta: float) -> bool:
|
||||
push_error("GameManager not found")
|
||||
quit(1)
|
||||
return true
|
||||
if gm != null and not camera_staged:
|
||||
gm.guard.global_position = Vector3(0, 1.6, 5.0)
|
||||
gm.guard.head.rotation.x = deg_to_rad(-8.0)
|
||||
camera_staged = true
|
||||
if frames > MAX_FRAMES:
|
||||
print("timeout reached")
|
||||
quit(0)
|
||||
@@ -39,7 +45,7 @@ func _advance() -> void:
|
||||
state_frames += 1
|
||||
match state:
|
||||
0:
|
||||
# 2s overview from the guard post.
|
||||
# 2s overview from the guard post (early morning light).
|
||||
if state_frames >= 120:
|
||||
_shoot("01-venue-overview.png")
|
||||
state = 1
|
||||
@@ -52,7 +58,6 @@ func _advance() -> void:
|
||||
state = 2
|
||||
state_frames = 0
|
||||
2:
|
||||
# Give the HUD a moment, then shoot the interaction shot.
|
||||
if state_frames >= 30:
|
||||
_shoot("02-targeting-npc.png")
|
||||
if targeted and gm != null:
|
||||
@@ -64,7 +69,6 @@ func _advance() -> void:
|
||||
_shoot("03-access-inspection.png")
|
||||
if gm != null:
|
||||
gm.hud.hide_inspection()
|
||||
# Re-target (target may have moved) before opening the radio form.
|
||||
if gm.npcs.size() > 0:
|
||||
gm.call("_cycle_nearby_target")
|
||||
gm.call("_open_radio", false)
|
||||
@@ -73,7 +77,25 @@ func _advance() -> void:
|
||||
4:
|
||||
if state_frames >= 30:
|
||||
_shoot("04-radio-report.png")
|
||||
# Close the radio form, then jump the shift to the LATE phase.
|
||||
if gm != null:
|
||||
gm.radio_panel.hide()
|
||||
gm.coordinator.elapsed_s = 750.0
|
||||
state = 5
|
||||
state_frames = 0
|
||||
5:
|
||||
# Let the phase transition (and street lamps / marquee) settle, then
|
||||
# take the night avenue shot.
|
||||
if state_frames >= 90:
|
||||
_shoot("05-late-night-avenue.png")
|
||||
if gm != null:
|
||||
gm.call("_toggle_flashlight")
|
||||
state = 6
|
||||
state_frames = 0
|
||||
6:
|
||||
if state_frames >= 40:
|
||||
_shoot("06-night-flashlight.png")
|
||||
state = 7
|
||||
quit(0)
|
||||
|
||||
func _shoot(file_name: String) -> void:
|
||||
|
||||
@@ -20,10 +20,13 @@ func test_save_round_trip() -> bool:
|
||||
var last: Dictionary = results[results.size() - 1]
|
||||
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")
|
||||
save.free()
|
||||
return a and b and c and d
|
||||
|
||||
func test_load_returns_array_when_empty_or_missing() -> bool:
|
||||
# The tester home may already contain results; this only asserts shape safety.
|
||||
var save := _save_instance()
|
||||
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
|
||||
@@ -30,7 +30,7 @@ func test_strings_cover_ui_keys() -> bool:
|
||||
"hud.aim_first", "hud.no_one_nearby", "hud.targeting", "hud.no_wand",
|
||||
"hud.screen_first", "access.granted", "access.denied", "access.policy_satisfied",
|
||||
"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",
|
||||
"responder.arrived", "react.comply", "react.leave", "react.argue", "react.record",
|
||||
"react.ignore", "react.threaten", "react.force_entry", "react.return_later",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
extends RefCounted
|
||||
## Headless tests for the procedural world and navigation mesh.
|
||||
## Headless tests for the procedural world, navigation mesh, and phase lighting.
|
||||
|
||||
const WorldBuilder := preload("res://src/game/world_builder.gd")
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const GameManager := preload("res://src/game/game_manager.gd")
|
||||
|
||||
func test_navmesh_built() -> bool:
|
||||
var root := Node3D.new()
|
||||
@@ -17,6 +19,65 @@ func test_navmesh_built() -> bool:
|
||||
root.free()
|
||||
return a and b
|
||||
|
||||
func test_phase_lighting_config_covered() -> bool:
|
||||
var ok := true
|
||||
for phase in [Rules.Phase.EARLY, Rules.Phase.MIDDAY, Rules.Phase.EVENING, Rules.Phase.LATE]:
|
||||
var cfg: Variant = GameManager.PHASE_LIGHTING.get(phase, null)
|
||||
if not (cfg is Dictionary):
|
||||
ok = false
|
||||
continue
|
||||
for key in ["sun_energy", "sun_angle", "sun_color", "sky_top", "sky_horizon", "ambient", "night"]:
|
||||
if not (cfg as Dictionary).has(key):
|
||||
ok = false
|
||||
return TestUtils.check(ok, "all four phases have a full lighting config")
|
||||
|
||||
func test_street_lamps_and_marquee_built() -> bool:
|
||||
var root := Node3D.new()
|
||||
var builder := WorldBuilder.new(root)
|
||||
builder.build()
|
||||
var a := TestUtils.check(builder._street_lamps.size() >= 2, "street lamps built")
|
||||
var b := TestUtils.check(builder.marquee_label != null, "marquee label built")
|
||||
# Night mode raises bulb emission; day mode drops it.
|
||||
builder.set_night_mode(true)
|
||||
var night_on := builder._lamp_bulb_mats[0].emission_energy_multiplier > 1.0
|
||||
builder.set_night_mode(false)
|
||||
var day_off := builder._lamp_bulb_mats[0].emission_energy_multiplier <= 0.5
|
||||
root.free()
|
||||
return a and b and TestUtils.check(night_on, "lamps brighten at night") and TestUtils.check(day_off, "lamps dim in daylight")
|
||||
|
||||
func test_asset_textures_present_and_pow2() -> bool:
|
||||
var required := [
|
||||
"res://assets/textures/brick_facade.png",
|
||||
"res://assets/textures/concrete.png",
|
||||
"res://assets/textures/asphalt.png",
|
||||
"res://assets/textures/awning.png",
|
||||
"res://assets/textures/skyline.png",
|
||||
"res://assets/textures/vignette.png",
|
||||
"res://assets/textures/icons/flashlight_on.png",
|
||||
"res://assets/textures/icons/door_locked.png",
|
||||
"res://assets/textures/icons/wand.png",
|
||||
"res://assets/textures/icons/rec_dot.png",
|
||||
"res://assets/textures/cards/id_card.png",
|
||||
"res://assets/textures/cards/wristband.png",
|
||||
"res://assets/textures/cards/ticket.png",
|
||||
"res://assets/textures/cards/credential.png",
|
||||
"res://assets/textures/cards/guest_list.png",
|
||||
]
|
||||
for path in required:
|
||||
if not FileAccess.file_exists(path):
|
||||
return TestUtils.fail("missing generated asset: %s — run tools/generate_textures.py" % path)
|
||||
var img := Image.new()
|
||||
for path in required:
|
||||
if img.load(path) != OK:
|
||||
return TestUtils.fail("cannot decode asset: %s" % path)
|
||||
var w := img.get_width()
|
||||
var h := img.get_height()
|
||||
if w > 1024 or h > 1024:
|
||||
return TestUtils.fail("asset too large: %s (%dx%d)" % [path, w, h])
|
||||
if (w & (w - 1)) != 0 or (h & (h - 1)) != 0:
|
||||
return TestUtils.fail("asset not power-of-two: %s (%dx%d)" % [path, w, h])
|
||||
return true
|
||||
|
||||
func test_door_indicator_built() -> bool:
|
||||
var root := Node3D.new()
|
||||
var builder := WorldBuilder.new(root)
|
||||
|
||||
@@ -16,6 +16,8 @@ set -euo pipefail
|
||||
GODOT="${GODOT:-godot}"
|
||||
VERSION="$(grep -oP 'config/version="\K[^"]+' project.godot)"
|
||||
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)"
|
||||
"$GODOT" --headless --export-release "Linux" "build/step-line-linux-${VERSION}.x86_64"
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate small authored textures for Step Line (assets/ import convention).
|
||||
|
||||
All outputs are deterministic (fixed seeds), <=1024px, power-of-two where
|
||||
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 os
|
||||
import random
|
||||
import stat
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
|
||||
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 = [
|
||||
"/usr/share/fonts/liberation-sans-fonts/LiberationSans-Bold.ttf",
|
||||
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
|
||||
"/usr/share/fonts/google-noto-vf/NotoSans[wght].ttf",
|
||||
"/usr/share/fonts/adwaita-sans-fonts/AdwaitaSans-Bold.ttf",
|
||||
]
|
||||
|
||||
|
||||
def font(size: int):
|
||||
for path in FONT_CANDIDATES:
|
||||
if os.path.exists(path):
|
||||
return ImageFont.truetype(path, size)
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def save(img: Image.Image, name: str) -> None:
|
||||
os.makedirs(ASSETS, exist_ok=True)
|
||||
path = os.path.join(ASSETS, name)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
img.save(path, optimize=True)
|
||||
print(f"wrote {os.path.relpath(path, ROOT)} {img.size}")
|
||||
|
||||
|
||||
def grain(size, base, spread, seed, blur=1.0):
|
||||
rnd = random.Random(seed)
|
||||
img = Image.new("L", (size, size), base)
|
||||
px = img.load()
|
||||
for y in range(size):
|
||||
for x in range(size):
|
||||
v = base + rnd.randint(-spread, spread)
|
||||
px[x, y] = max(0, min(255, v))
|
||||
return img.filter(ImageFilter.GaussianBlur(blur))
|
||||
|
||||
|
||||
def brick_facade():
|
||||
size = 512
|
||||
mortar = (118, 110, 104)
|
||||
base = (150, 92, 78)
|
||||
img = Image.new("RGB", (size, size), mortar)
|
||||
d = ImageDraw.Draw(img)
|
||||
rnd = random.Random(11)
|
||||
brick_h = size // 22
|
||||
mortar_t = 5
|
||||
offset = 0
|
||||
for row in range(22):
|
||||
y = row * brick_h
|
||||
cols = 2 if row % 2 == 0 else 3
|
||||
brick_w = (size - mortar_t) // (cols if cols > 1 else 1)
|
||||
for c in range(cols + 1):
|
||||
x0 = c * (brick_w + mortar_t) - (brick_w // 2 if row % 2 else 0)
|
||||
x1 = x0 + brick_w
|
||||
# wrap horizontally so the tile repeats seamlessly
|
||||
d.rectangle([x0, y, x1, y + brick_h - mortar_t], fill=base)
|
||||
# per-brick shading pass
|
||||
for _ in range(size * size // 900):
|
||||
bx = 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)),
|
||||
max(0, base[1] - 10 + rnd.randint(0, 22)),
|
||||
max(0, base[2] - 8 + rnd.randint(0, 18))))
|
||||
# horizontal mortar lines on top of bricks (wraps vertically by drawing at 0 and size)
|
||||
for y in [-mortar_t, size - mortar_t]:
|
||||
d.rectangle([0, y, size, y + mortar_t], fill=mortar)
|
||||
save(img, "brick_facade.png")
|
||||
|
||||
|
||||
def seamless_concrete():
|
||||
size = 512
|
||||
img = grain(size, 150, 26, 7, blur=2.5).convert("RGBA")
|
||||
d = ImageDraw.Draw(img)
|
||||
rnd = random.Random(19)
|
||||
for _ in range(90):
|
||||
x, y = rnd.randint(0, size), rnd.randint(0, size)
|
||||
r = rnd.choice([2, 3, 4])
|
||||
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
|
||||
x, y = rnd.randint(0, size), rnd.randint(0, size)
|
||||
pts = [(x, y)]
|
||||
for _ in range(6):
|
||||
x += rnd.randint(-24, 24)
|
||||
y += rnd.randint(-8, 8)
|
||||
pts.append((x % size, y % size))
|
||||
d.line(pts, fill=(70, 70, 70, 170), width=1)
|
||||
save(img, "concrete.png")
|
||||
|
||||
|
||||
def asphalt():
|
||||
size = 512
|
||||
img = grain(size, 52, 14, 23, blur=1.2).convert("RGBA")
|
||||
d = ImageDraw.Draw(img)
|
||||
rnd = random.Random(31)
|
||||
for _ in range(600):
|
||||
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),))
|
||||
save(img, "asphalt.png")
|
||||
|
||||
|
||||
def awning():
|
||||
w, h = 1024, 256
|
||||
img = Image.new("RGB", (w, h), (140, 40, 45))
|
||||
d = ImageDraw.Draw(img)
|
||||
stripe = 64
|
||||
for i in range(w // stripe):
|
||||
if i % 2 == 1:
|
||||
d.rectangle([i * stripe, 0, (i + 1) * stripe - 2, h],
|
||||
fill=(230, 225, 220))
|
||||
# scalloped bottom edge
|
||||
for x in range(0, w, 32):
|
||||
d.arc([x, h - 24, x + 32, h + 40], 0, 180, fill=(80, 20, 24), width=6)
|
||||
f = font(96)
|
||||
tw = d.textlength("STEP LINE", font=f)
|
||||
d.text(((w - tw) / 2, 30), "STEP LINE", font=f, fill=(255, 250, 235))
|
||||
# subtle top shading
|
||||
shade = Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
||||
sd = ImageDraw.Draw(shade)
|
||||
sd.rectangle([0, 0, w, 10], fill=(0, 0, 0, 90))
|
||||
img = Image.alpha_composite(img.convert("RGBA"), shade)
|
||||
save(img, "awning.png")
|
||||
|
||||
|
||||
def skyline():
|
||||
w, h = 1024, 256
|
||||
img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(img)
|
||||
rnd = random.Random(41)
|
||||
build_color = (16, 18, 30, 255)
|
||||
x = -40
|
||||
while x < w + 40:
|
||||
bw = rnd.randint(64, 170)
|
||||
bh = rnd.randint(150, 400)
|
||||
d.rectangle([x, h - bh, x + bw, h], fill=build_color)
|
||||
# few lit windows
|
||||
for _ in range(bw * bh // 900):
|
||||
wx = x + rnd.randint(4, max(4, bw - 8))
|
||||
wy = h - rnd.randint(4, bh - 4)
|
||||
if rnd.random() < 0.5:
|
||||
d.rectangle([wx, wy, wx + 6, wy + 8],
|
||||
fill=(255, 200, 120, 220))
|
||||
x += bw + rnd.randint(10, 46)
|
||||
d.rectangle([0, h - 6, w, h], fill=(12, 13, 22, 255))
|
||||
save(img, "skyline.png")
|
||||
|
||||
|
||||
def vignette():
|
||||
size = 512
|
||||
img = Image.new("L", (size, size), 0)
|
||||
px = img.load()
|
||||
c = size / 2.0
|
||||
max_d = math.hypot(c, c)
|
||||
for y in range(size):
|
||||
for x in range(size):
|
||||
dist = math.hypot(x - c, y - c) / max_d
|
||||
t = max(0.0, (dist - 0.55) / 0.45)
|
||||
px[x, y] = int(255 * (t * t))
|
||||
save(img, "vignette.png")
|
||||
|
||||
|
||||
def icon(name, draw_fn, size=64):
|
||||
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(img)
|
||||
draw_fn(d, size)
|
||||
save(img, name)
|
||||
|
||||
|
||||
def icons():
|
||||
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.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):
|
||||
d.rectangle([s*0.18, s*0.18, s*0.82, s*0.82],
|
||||
outline=(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):
|
||||
d.rounded_rectangle([s*0.30, s*0.10, s*0.44, s*0.90],
|
||||
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):
|
||||
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))
|
||||
icon("icons/flashlight_on.png", flashlight_on)
|
||||
icon("icons/door_locked.png", door_locked)
|
||||
icon("icons/wand.png", wand)
|
||||
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():
|
||||
CW, CH = 256, 128
|
||||
|
||||
def base(card_name, bg, label, band=(0, 0, 0, 0)):
|
||||
img = Image.new("RGBA", (CW, CH), bg + (255,))
|
||||
d = ImageDraw.Draw(img)
|
||||
d.rounded_rectangle([6, 6, CW-6, CH-6], radius=10,
|
||||
outline=(0, 0, 0, 90), width=2)
|
||||
if band and band[3] == 255:
|
||||
d.rectangle([0, CH-34, CW, CH], fill=band)
|
||||
f = font(26)
|
||||
d.text((14, 10), label, font=f, fill=(255, 255, 255, 235))
|
||||
# body text lines
|
||||
f2 = font(15)
|
||||
y = 52
|
||||
for _ in range(4):
|
||||
w = 150 - (24 if _ == 3 else 0)
|
||||
d.rectangle([16, y, 16 + w, y + 8], fill=(255, 255, 255, 110))
|
||||
y += 24
|
||||
# photo square for id-like cards
|
||||
if "photo" in card_name:
|
||||
d.rectangle([CW-84, 14, CW-18, 96], fill=(180, 190, 205, 255))
|
||||
save(img, f"cards/{card_name}.png")
|
||||
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("ticket", (56, 120, 96), "ADMIT ONE", band=(20, 80, 55, 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))
|
||||
|
||||
|
||||
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) -> Image.Image:
|
||||
"""Call the OpenRouter image API and return the decoded PIL image.
|
||||
|
||||
Requests a square image at `size` px and decodes the base64 PNG/JPEG/WebP
|
||||
bytes returned in `data[].b64_json`. The requested size is clamped to at
|
||||
least GEN_SIZE because some models (e.g. seedream) reject small outputs;
|
||||
callers downscale the result to MAX_DIM afterwards. When `transparent` is
|
||||
true, the API is asked for a transparent background (PNG alpha).
|
||||
"""
|
||||
size = max(GEN_SIZE, int(size))
|
||||
body = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"size": f"{size}x{size}",
|
||||
"output_format": "png",
|
||||
"n": 1,
|
||||
}
|
||||
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__":
|
||||
args = _build_arg_parser().parse_args()
|
||||
if args.ai:
|
||||
_run_ai(args)
|
||||
print("done")
|
||||
sys.exit(0)
|
||||
|
||||
brick_facade()
|
||||
seamless_concrete()
|
||||
asphalt()
|
||||
awning()
|
||||
skyline()
|
||||
vignette()
|
||||
icons()
|
||||
app_icon()
|
||||
cards()
|
||||
print("done")
|
||||