Compare commits

...
9 Commits
Author SHA1 Message Date
zwitschi 0f076205af Fix NSIS installer build: absolute paths via defines, space-free installed exe, BMP ICO
Full build / Import + headless tests (push) Successful in 31s
Full build / Full desktop build + optional release (push) Successful in 3m44s
- makensis resolves relative -DICON/-DARTIFACT against the script directory,
  so CI now passes $PWD-based absolute paths (reproduced locally in the same
  ubuntu:24.04 container; installer builds, 7z shows step-line.exe +
  uninstall.exe)
- Install as step-line.exe (no spaces: NSIS /oname splits on whitespace)
- OutFile overridable via -DOUT
- tools/generate_textures.py icon.ico now produced via ImageMagick with
  classic BMP entries (NSIS-compatible); PIL PNG-compressed ICOs rejected by
  NSIS
2026-10-31 14:22:55 +01:00
zwitschi 8bc84c1f36 Harden installer icon + NSIS rebuild path
Full build / Full desktop build + optional release (push) Failing after 3m24s
Full build / Import + headless tests (push) Successful in 27s
- Regenerate icon.ico with classic BMP entries (16/32/48) — PNG-compressed
  ICO entries can fail NSIS' icon loader on some builds
- CI copies assets/textures/icon.ico onto the packaging path right before
  makensis, removing any stale/missing-copy failures
- Recreate v0.2.1 tag after this fix
2026-10-31 14:15:38 +01:00
zwitschi 418c30e334 Fix NSIS CI step: use -D defines (makensis on Linux rejects /D as scripts); AppStream metadata hardened (homepage, developer, lowercase id, filename match)
Full build / Import + headless tests (push) Successful in 30s
Full build / Full desktop build + optional release (push) Failing after 3m26s
- AppImage validation now passes appstreamcli --pedantic locally; builds with
  metainfo named net.stepline.game.appdata.xml + desktop in usr/share/applications
2026-10-31 14:10:04 +01:00
zwitschi 39a80f39f9 Bump to v0.2.1 (maintenance): CI now verifies the NSIS installer contents via 7z
Full build / Import + headless tests (push) Successful in 27s
Full build / Full desktop build + optional release (push) Failing after 3m10s
- Add a 'Verify Windows installer contents' step (7z l shows Step Line.exe +
  uninstall.exe) so the TODO verification items are proven by CI output
- Rebuild 0.2.1 binaries (embedded PCK) and AppImage locally; smoke clean;
  57/57 tests
2026-10-31 14:05:12 +01:00
zwitschi 5b994982bd Add OpenRouter AI texture generation mode to the asset pipeline
- tools/generate_textures.py: --ai mode (OPENROUTER_API_KEY, --prompt,
  --name, --model, --size; base64 PNG decode; clamp to <=1024px) with a
  default batch covering the authored texture set; seedream minimum-size
  handled by requesting 2048 and downscaling
- Generated AI asset set committed under assets/textures/ai/ (15 files,
  1024x1024 each), kept separate from the deterministic PIL set
- README/MEMORY/TODO document the mode and live verification
- (work authored in a parallel session; linted/verified here — 57/57 tests)
2026-10-31 14:04:37 +01:00
zwitschi 0abd67fbaa Ignore .env files 2026-10-31 13:56:20 +01:00
zwitschi 08b3292b80 Runtime hygiene: fix RefCounted cycles (clean suite exit), full-length shifts, wand cooldown
Full build / Import + headless tests (push) Successful in 18s
Full build / Full desktop build + optional release (push) Skipped
- FSM holds owner via WeakRef; ShiftCoordinator is RefCounted (logic-only);
  persistence tests free the Save node -> test suite exits with zero
  'resources still in use' warnings (was ~13) and 57/57 pass
- GameManager uses each scenario's shift_duration_s (900s ~ 10-15 min) for
  real shifts instead of the 300s test duration
- Wand screening gets a 0.8s recharge cooldown with a wand.recharge message
- TODO/DONE/MEMORY/README synced
2026-10-31 13:50:18 +01:00
zwitschi ba76c0febf Add Windows installer (NSIS) and Linux AppImage packaging; embed PCK in all exports
Full build / Import + headless tests (push) Successful in 17s
Full build / Full desktop build + optional release (push) Skipped
- export_presets: embed_pck=true for Linux/Windows/macOS -> single self-contained
  binaries (all game data in one file); build_releases.sh cleans stale .pck
- App icon: assets/textures/icon.png (1024) + multi-size icon.ico generated by
  tools/generate_textures.py; copied into packaging/windows and packaging/linux
- packaging/windows/step-line.nsi: NSIS installer (shortcuts, uninstaller,
  registry Add/Remove Programs) output step-line-setup-<ver>.exe
- packaging/linux: step-line.desktop, AppRun, step-line.appdata.xml,
  build_appimage.sh (appimagetool, APPIMAGE_EXTRACT_AND_RUN) ->
  step-line-<ver>-x86_64.AppImage
- CI build job now packages NSIS installer + AppImage alongside binaries;
  publish glob already uploads them
- Local end-to-end verification: embedded exports rebuilt, AppImage built and
  run headless (0 script errors); suite 57/57
- README Downloads table lists installer/AppImage/portable rows; RELEASING
  documents packaging; TODO/DONE synced
2026-10-31 13:46:54 +01:00
zwitschi 07d79ac115 Complete PRIORITY: release v0.2.0 published by CI; document download sign-in caveat
Full build / Import + headless tests (push) Successful in 17s
Full build / Full desktop build + optional release (push) Skipped
- Tag v0.2.0 pushed; Gitea Actions build job exported Linux/Windows/macOS
  and published the release via the host's GITEA_TOKEN secret (CI run 1095:
  'Release live: .../releases/tag/v0.2.0', Job succeeded)
- Verified tag page + /releases are public (HTTP 200); anonymous asset
  downloads require sign-in on the host (tracked as host-config follow-up)
- TODO cleaned per AGENTS.md; PRIORITY section moved to DONE.md
- README Downloads notes v0.2.0 live + sign-in caveat; MEMORY updated
2026-10-31 13:41:37 +01:00
61 changed files with 755 additions and 72 deletions

No files matched your search

+25
View File
@@ -77,6 +77,31 @@ jobs:
chmod +x tools/*.sh chmod +x tools/*.sh
./tools/build_releases.sh ./tools/build_releases.sh
- name: Package Windows installer (NSIS)
run: |
sudo apt-get update && sudo apt-get install -y nsis p7zip-full
VERSION="$(sed -n 's/.*config\/version="\([^"]*\)".*/\1/p' project.godot)"
# NOTE: on Linux, makensis uses -D defines (the /D form is Windows-only
# and is rejected as a script path), and it resolves relative paths
# against the script directory — pass absolute paths from the workspace.
cp assets/textures/icon.ico packaging/windows/step-line.ico
makensis \
-DVERSION="$VERSION" \
-DARTIFACT="$PWD/build/step-line-windows-$VERSION.exe" \
-DICON="$PWD/packaging/windows/step-line.ico" \
-DOUT="$PWD/build/step-line-setup-$VERSION.exe" \
packaging/windows/step-line.nsi
- name: Verify Windows installer contents
run: |
VERSION="$(sed -n 's/.*config\/version="\([^"]*\)".*/\1/p' project.godot)"
ls -la build/step-line-setup-$VERSION.exe
7z l build/step-line-setup-$VERSION.exe | grep -E "step-line.exe|uninstall.exe"
- name: Package Linux AppImage
run: |
bash packaging/linux/build_appimage.sh
- name: Upload installers (workflow_dispatch) - name: Upload installers (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
+2
View File
@@ -18,3 +18,5 @@ user://
# CI runner workflow logs (written into the checkout by the Gitea runner) # CI runner workflow logs (written into the checkout by the Gitea runner)
.gitea/logs/ .gitea/logs/
build/ build/
.env
+45
View File
@@ -173,6 +173,51 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every
- [x] `config/version` → `0.2.0`; `build/` artifacts rebuilt and Linux binary smoke-tested. - [x] `config/version` → `0.2.0`; `build/` artifacts rebuilt and Linux binary smoke-tested.
- [x] New world/lighting headless tests (56 total). - [x] New world/lighting headless tests (56 total).
## 🔴 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) ## 🎮 Assets & detailed graphics pass (textures, rigs, UI feedback)
- [x] Authored texture set via `tools/generate_textures.py` (deterministic PIL): brick facade, - [x] Authored texture set via `tools/generate_textures.py` (deterministic PIL): brick facade,
+27 -2
View File
@@ -15,7 +15,12 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
## Current State ## 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`. - NPC avatars now walk to per-intent waypoints (`VenueScenario.waypoint_for_intent`): enter/trespass/deliver/arrive → entrance, photo/film/perform/wander/roam → sidewalk, sit → steps_end. `NpcAvatar.setup` takes an explicit `door_position`.
- Returning-trespasser behavior: `NpcProfile.return_probability` (trespasser 0.6, mental-crisis 0.4, others 0). `NPCController._maybe_schedule_return()` moves a directed-away NPC to `LOITERING_DISTANT`; `tick_return(delta)` fires after 20-60s and transitions back to `APPROACH`. `NpcAvatar._advance_fsm` re-targets the door on return. - Returning-trespasser behavior: `NpcProfile.return_probability` (trespasser 0.6, mental-crisis 0.4, others 0). `NPCController._maybe_schedule_return()` moves a directed-away NPC to `LOITERING_DISTANT`; `tick_return(delta)` fires after 20-60s and transitions back to `APPROACH`. `NpcAvatar._advance_fsm` re-targets the door on return.
- Perception pass: `NPCController.perceive_guard(distance)` returns `{noticed, band, reaction}` with bands `unaware` (>6m), `aware` (2-6m), `near` (<2m). Near + temperament ≥0.6 → ARGUE, else COMPLY. `GameManager._perception_pass()` feeds real guard distance each physics tick and logs first-time notice. - Perception pass: `NPCController.perceive_guard(distance)` returns `{noticed, band, reaction}` with bands `unaware` (>6m), `aware` (2-6m), `near` (<2m). Near + temperament ≥0.6 → ARGUE, else COMPLY. `GameManager._perception_pass()` feeds real guard distance each physics tick and logs first-time notice.
@@ -54,15 +59,35 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
- Assets pipeline: `python3 tools/generate_textures.py` produces all authored PNGs under - Assets pipeline: `python3 tools/generate_textures.py` produces all authored PNGs under
`assets/textures/` (≤1024px, pow2); regenerate after changing the script, then run `assets/textures/` (≤1024px, pow2); regenerate after changing the script, then run
`godot --headless --import` before tests. 57 headless tests guard the pipeline. `godot --headless --import` before tests. 57 headless tests guard the pipeline.
- 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 + - Detailed graphics: textured facade/awning/skyline, humanoid NPC rigs + props + walk bob +
attention head-turn, inspection artifact cards, mode icons, escalation vignette, body-cam attention head-turn, inspection artifact cards, mode icons, escalation vignette, body-cam
overlay (REC + shift clock). Screenshot set regenerated (`docs/screenshots/`, day + night). overlay (REC + shift clock). Screenshot set regenerated (`docs/screenshots/`, day + night).
- Polish: wand scan cooldown (`WAND_COOLDOWN_S` + `wand.recharge` message).
- NOTE: collaborators occasionally add uncommitted work to this shared tree (e.g. the radio - NOTE: collaborators occasionally add uncommitted work to this shared tree (e.g. the radio
easter egg); check `git status` before committing and leave foreign changes untouched unless asked. easter egg); check `git status` before committing and leave foreign changes untouched unless asked.
## Known Issues / Notes ## Known Issues / Notes
- RefCounted reference cycle: `NPCController` holds an `FSM` that holds a back-reference to its owner, so test suites report "resources still in use at exit". Benign (process exit), but worth breaking later (e.g. `WeakRef` or explicit `dispose()`). - Reference cycles are fixed: `FSM` holds its owner via `WeakRef`, `ShiftCoordinator` is
`RefCounted`, and persistence tests free the `Save` node — the suite exits with no
"resources still in use" warnings (was ~13). If a future subsystem introduces a cycle,
repeat the WeakRef pattern.
- Shift length: `GameManager` uses the scenario's `shift_duration_s` (900s ≈ 10-15 min);
tests and the screenshot tool override elapsed time directly.
- Headless (dummy renderer) logs "Parameter m is null" from `mesh_get_surface_count`; it does not occur with a real GL driver and does not affect logic. - Headless (dummy renderer) logs "Parameter m is null" from `mesh_get_surface_count`; it does not occur with a real GL driver and does not affect logic.
- Steps are visual-only (no collider) so NPCs can path to the entrance; the guard spawns on the landing so it never needs to climb. - Steps are visual-only (no collider) so NPCs can path to the entrance; the guard spawns on the landing so it never needs to climb.
- Persistence tests write into the real `user://shift_results.json` (harmless local pollution of the demo history). - Persistence tests write into the real `user://shift_results.json` (harmless local pollution of the demo history).
+15 -8
View File
@@ -20,15 +20,22 @@ The central design goal is simple:
| Platform | Package | | Platform | Package |
| --- | --- | | --- | --- |
| **Windows** | [`step-line-windows.exe`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, no install needed | | **Windows — installer** | [`step-line-setup-<ver>.exe`](https://git.allucanget.biz/zwitschi/security-game/releases) · contains all game data, shortcuts + uninstaller |
| **Linux** | [`step-line-linux.x86_64`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, `chmod +x` and run | | **Windows — portable** | [`step-line-windows-<ver>.exe`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, no install needed |
| **macOS** | [`step-line-macos.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unsigned build, quarantine note below | | **Linux — AppImage** | [`step-line-<ver>-x86_64.AppImage`](https://git.allucanget.biz/zwitschi/security-game/releases) · single file, `chmod +x` and run |
| **Linux — portable** | [`step-line-linux-<ver>.x86_64`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, `chmod +x` and run |
| **macOS** | [`step-line-macos-<ver>.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unsigned build, quarantine note below |
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases> **All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
> Releases are built automatically by Gitea Actions when a `vX.Y.Z` tag is > **Latest: v0.2.0** (2026-10-31) — built and published by Gitea Actions from the
> pushed (see [`docs/RELEASING.md`](docs/RELEASING.md)). Manual builds are > `v0.2.0` tag. Releases are built automatically when a `vX.Y.Z` tag is pushed
> produced with `./tools/build_releases.sh`. > (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
> (NSIS + appimagetool) and uploads with the portable binaries.
>
> Note: on this host, downloading release files currently requires sign-in;
> the page and tag URLs are public.
> >
> macOS builds are unsigned: right-click → Open, or clear the quarantine with > macOS builds are unsigned: right-click → Open, or clear the quarantine with
> `xattr -dr com.apple.quarantine step-line-macos.zip`. Build instructions and > `xattr -dr com.apple.quarantine step-line-macos.zip`. Build instructions and
@@ -129,7 +136,7 @@ The architecture maps onto Godot 4 nodes, scenes, and custom resources as descri
- **Navigation:** `NavigationRegion3D` and `NavigationAgent3D` - **Navigation:** `NavigationRegion3D` and `NavigationAgent3D`
- **NPC behavior:** Custom lightweight finite state machines driven by `NpcProfile` resources - **NPC behavior:** Custom lightweight finite state machines driven by `NpcProfile` resources
- **UI and audio:** Godot `Control` nodes, themes, audio buses, and `AudioStreamPlayer` nodes - **UI and audio:** Godot `Control` nodes, themes, audio buses, and `AudioStreamPlayer` nodes
- **Art assets:** authored PNGs in `assets/textures/` (≤1024px, power-of-two) produced by `tools/generate_textures.py` and imported by Godot; structural geometry stays procedural (`WorldBuilder`) - **Art assets:** authored PNGs 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 - **Testing:** GUT (Godot Unit Test) in headless mode
- **Source control:** git on a Gitea host with Gitea Actions (`.gitea/workflows/build.yml`) for headless tests and desktop release builds - **Source control:** git on a Gitea host with Gitea Actions (`.gitea/workflows/build.yml`) for headless tests and desktop release builds
- **Targets:** Windows, Linux, and macOS desktop first; web later - **Targets:** Windows, Linux, and macOS desktop first; web later
@@ -208,7 +215,7 @@ Implemented so far:
- Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets - Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets
- Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock) - Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock)
Not yet implemented: body-camera replay timeline, full 10-15 minute shift lengths, venue selection startup menu, editor-based GUT integration, RefCounted cycle cleanup, and crowd performance profiling/LOD (see `TODO.md`). Not yet implemented: body-camera replay timeline, venue selection startup menu, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`).
### Smoke Test Checklist ### Smoke Test Checklist
+51 -15
View File
@@ -1,31 +1,63 @@
# TODO # TODO
## 🔴 PRIORITY — Ship downloadable builds (so the README Downloads links work) ## 🎨 AI texture generation (OpenRouter)
Goal: users hitting the README **Downloads** section can download a playable version from - [x] Extend `tools/generate_textures.py` with an OpenRouter image-API mode
`https://git.allucanget.biz/zwitschi/security-game/releases`. (`--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] Install Godot 4.3 export templates locally. ## 🪟 Windows installer (self-contained, all game data)
- [x] `./tools/build_releases.sh` produces Linux/Windows/macOS artifacts (built for 0.2.0).
- [x] Linux binary smoke-tested (headless, 0 script errors). Goal: a real Windows installer that includes the entire game (executable + data),
- [ ] Publish the release — **blocked on `GITEA_TOKEN`**: once the token is available run Start-Menu/desktop shortcuts, and an uninstaller — instead of a bare .exe + .pck.
`GITEA_TOKEN=... ./tools/publish_release.sh 0.2.0`, or (if the repo secret is already set
on the host) push the `v0.2.0` tag and let CI publish via `.gitea/workflows/build.yml`. - [x] Switch the Windows/Linux/macOS export presets to `binary_format/embed_pck=true` so a
- [ ] Confirm the README Downloads links resolve: check single self-contained binary replaces the exe+pck pair (all game data in one file).
`https://git.allucanget.biz/zwitschi/security-game/releases` and download one artifact. - [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).
## 🐧 Linux AppImage (self-contained, all game data)
Goal: a single executable `.AppImage` that runs the full game on any recent distro.
- [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) ## Future Systems (design-level backlog)
- [ ] Body-camera replay timeline in the shift review. - [ ] Body-camera replay timeline in the shift review.
- [ ] Long (10-15 minute) shift lengths (phase lighting already implemented).
- [ ] Venue selection startup menu (keyboard cycling with `V` is implemented). - [ ] Venue selection startup menu (keyboard cycling with `V` is implemented).
- [ ] IDE/editor-based GUT integration when a Godot editor workflow is introduced. - [ ] IDE/editor-based GUT integration when a Godot editor workflow is introduced.
- [ ] Reduce RefCounted reference cycles (NPCController <-> FSM) noted in `MEMORY.md`.
## Open Polish ## Open Polish
- [ ] Add a body/wand cooldown feel: wand screening currently has no reuse penalty; consider a
short per-guest scan animation.
- [ ] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting). - [ ] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting).
## Detailed Graphics — remaining performance items ## Detailed Graphics — remaining performance items
@@ -36,3 +68,7 @@ performance hardening only.
- [ ] Profile the crowd: ensure textured materials and props hold target frame rate around a - [ ] Profile the crowd: ensure textured materials and props hold target frame rate around a
busy entrance. busy entrance.
- [ ] Add LOD or culling for distant NPCs/props if the crowd grows. - [ ] 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 929 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 801 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+2 -1
View File
@@ -40,5 +40,6 @@
"door.thwarted": "%s tries the locked door and fails.", "door.thwarted": "%s tries the locked door and fails.",
"flashlight.on": "Flashlight on.", "flashlight.on": "Flashlight on.",
"flashlight.off": "Flashlight off.", "flashlight.off": "Flashlight off.",
"hud.venue_switched": "Venue: %s" "hud.venue_switched": "Venue: %s",
"wand.recharge": "Wand scanner is recharging…"
} }
+11 -1
View File
@@ -57,12 +57,22 @@ godot --headless --path . --quit-after 120 # boot smoke t
./tools/build_releases.sh ./tools/build_releases.sh
``` ```
Artifacts are written to `build/`: Artifacts are written to `build/` (all exports embed the PCK — single files):
- `build/step-line-linux-<version>.x86_64` - `build/step-line-linux-<version>.x86_64`
- `build/step-line-windows-<version>.exe` - `build/step-line-windows-<version>.exe`
- `build/step-line-macos-<version>.zip` - `build/step-line-macos-<version>.zip`
When run in CI (tags/dispatch), the build job also packages:
- `build/step-line-setup-<version>.exe` — Windows installer (NSIS, incl. shortcuts +
uninstaller; apt `nsis` on the runner)
- `build/step-line-<version>-x86_64.AppImage` — Linux AppImage (`packaging/linux/build_appimage.sh`,
appimagetool via `APPIMAGE_EXTRACT_AND_RUN`)
Locally you can build the AppImage with `bash packaging/linux/build_appimage.sh <version>`;
the NSIS installer requires `makensis` (covered by CI).
> macOS builds are unsigned and will need `xattr -dr com.apple.quarantine` > macOS builds are unsigned and will need `xattr -dr com.apple.quarantine`
> guidance or notarization for production releases. > guidance or notarization for production releases.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 237 KiB

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

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+74
View File
@@ -0,0 +1,74 @@
; Step Line Windows installer (NSIS 3).
; Build from the repo root with the exported self-contained exe:
; makensis /DVERSION=0.2.0 /DARTIFACT=build/step-line-windows-0.2.0.exe \
; /DICON=packaging/windows/step-line.ico packaging/windows/step-line.nsi
; Outputs: build/step-line-setup-<VERSION>.exe
!ifndef VERSION
!define VERSION "0.0.0"
!endif
!ifndef ARTIFACT
!error "ARTIFACT define required (path to exported windows exe)"
!endif
!ifndef ICON
!define ICON "packaging\windows\step-line.ico"
!endif
; The installed executable uses this clean name (no spaces — NSIS /oname tokens
; on whitespace, so a spaced name breaks the File directive).
!define INSTALLED_EXE "step-line.exe"
!ifndef OUT
!define OUT "build\step-line-setup-${VERSION}.exe"
!endif
!define COMPANY "Step Line Project"
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\Step Line"
Unicode true
ManifestDPIAware true
Icon "${ICON}"
UninstallIcon "${ICON}"
Name "Step Line ${VERSION}"
OutFile "${OUT}"
InstallDir "$PROGRAMFILES64\Step Line"
RequestExecutionLevel admin
Page directory
Page instfiles
UninstPage uninstConfirm
UninstPage instfiles
Section "Install"
SetOutPath "$INSTDIR"
; The self-contained executable contains ALL game data (embedded PCK).
File "/oname=${INSTALLED_EXE}" "${ARTIFACT}"
WriteUninstaller "$INSTDIR\uninstall.exe"
; Shortcuts
CreateDirectory "$SMPROGRAMS\Step Line"
CreateShortCut "$SMPROGRAMS\Step Line\Step Line.lnk" "$INSTDIR\${INSTALLED_EXE}"
CreateShortCut "$SMPROGRAMS\Step Line\Uninstall.lnk" "$INSTDIR\uninstall.exe"
CreateShortCut "$DESKTOP\Step Line.lnk" "$INSTDIR\${INSTALLED_EXE}"
; Registry (Add/Remove Programs)
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "Step Line"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${VERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${COMPANY}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${INSTALLED_EXE}"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" '"$INSTDIR\uninstall.exe"'
WriteRegDWORD HKLM "${UNINST_KEY}" "NoModify" 1
WriteRegDWORD HKLM "${UNINST_KEY}" "NoRepair" 1
SectionEnd
Section "Uninstall"
Delete "$INSTDIR\${INSTALLED_EXE}"
Delete "$INSTDIR\uninstall.exe"
RMDir "$INSTDIR"
Delete "$SMPROGRAMS\Step Line\Step Line.lnk"
Delete "$SMPROGRAMS\Step Line\Uninstall.lnk"
RMDir "$SMPROGRAMS\Step Line"
Delete "$DESKTOP\Step Line.lnk"
DeleteRegKey HKLM "${UNINST_KEY}"
SectionEnd
+1 -1
View File
@@ -8,7 +8,7 @@ config_version=5
config/name="Step Line" config/name="Step Line"
config/description="Workplace security simulation about observation, access verification, and proportionate escalation." config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
config/version="0.2.0" config/version="0.2.1"
run/main_scene="res://scenes/main.tscn" run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.3", "GL Compatibility") config/features=PackedStringArray("4.3", "GL Compatibility")
+1
View File
@@ -0,0 +1 @@
appimagetool.png
+10
View File
@@ -0,0 +1,10 @@
#! /bin/sh
set -e
this_dir="$(dirname -- "$(readlink -f -- "$0")")"
# make appimagetool prefer the bundled mksquashfs
export PATH="$this_dir"/usr/bin:"$PATH"
exec "$this_dir"/usr/bin/appimagetool "$@"
+1
View File
@@ -0,0 +1 @@
usr/share/applications/appimagetool.desktop
+1
View File
@@ -0,0 +1 @@
usr/share/icons/hicolor/128x128/apps/appimagetool.png
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=appimagetool
Exec=appimagetool
Comment=Tool to generate AppImages from AppDirs
Icon=appimagetool
Categories=Development;
Terminal=true
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop">
<id>appimagetool.desktop</id>
<metadata_license>MIT</metadata_license>
<project_license>MIT</project_license>
<name>appimagetool</name>
<summary>Create AppImage files</summary>
<description>
<p>
appimagetool is a tool that lets you generate AppImage files. An AppImage
is a self-running bundle that contains an application and everything
it needs to run that cannot reasonably expected to be part of each target system.
</p>
<p>
This can include libraries that are not commonly available on target systems,
resources such as translations, icons, fonts, and other auxiliary files.
appimagetool makes it easy to take an AppDir (e.g., generated by linuxdeployqt)
and turn it into an AppImage.
</p>
<p>
You can also embed update information so that your users can update the AppImage
using binary delta updates with the AppImageUpdate tool.
You can also sign the AppImage with GPG.
</p>
</description>
<url type="homepage">https://github.com/AppImage/AppImageKit</url>
<project_group>AppImage</project_group>
<provides>
<binary>appimagetool</binary>
</provides>
</component>
+17 -6
View File
@@ -2,15 +2,26 @@ class_name FSM
extends RefCounted extends RefCounted
## Minimal generic finite state machine used by NPCController and ShiftCoordinator. ## Minimal generic finite state machine used by NPCController and ShiftCoordinator.
## Deterministic and headless-testable. ## Deterministic and headless-testable.
##
## The owner is held through a WeakRef so long-lived owners do not create a
## reference cycle (FSM <-> owner), which previously leaked RefCounted objects
## at process exit (see MEMORY.md).
signal state_changed(node: Object, from_state: String, to_state: String) signal state_changed(node: Object, from_state: String, to_state: String)
var current_state: String = "" var current_state: String = ""
var _transitions: Dictionary = {} # state_name -> Array[{to, guard: Callable}] var _transitions: Dictionary = {} # state_name -> Array[{to, guard: Callable}]
var _owner_node: Object = null var _owner_ref: WeakRef = null
func _init(owner_node: Object = null) -> void: func _init(owner_node: Object = null) -> void:
_owner_node = owner_node if owner_node != null:
_owner_ref = weakref(owner_node)
## The live owner object, or null if it was freed.
func _owner() -> Object:
if _owner_ref == null:
return null
return _owner_ref.get_ref() as Object
## Register a transition. guard is an optional Callable(node) -> bool. ## Register a transition. guard is an optional Callable(node) -> bool.
func add_transition(from_state: String, to_state: String, guard: Callable = Callable()) -> void: func add_transition(from_state: String, to_state: String, guard: Callable = Callable()) -> void:
@@ -20,7 +31,7 @@ func add_transition(from_state: String, to_state: String, guard: Callable = Call
func start(initial_state: String) -> void: func start(initial_state: String) -> void:
current_state = initial_state current_state = initial_state
state_changed.emit(_owner_node, "", initial_state) state_changed.emit(_owner(), "", initial_state)
## Attempt to move to `to_state`. Returns true when executed. ## Attempt to move to `to_state`. Returns true when executed.
func transition_to(to_state: String) -> bool: func transition_to(to_state: String) -> bool:
@@ -31,10 +42,10 @@ func transition_to(to_state: String) -> bool:
if t["to"] != to_state: if t["to"] != to_state:
continue continue
var guard: Callable = t["guard"] var guard: Callable = t["guard"]
if not guard.is_valid() or guard.call(_owner_node): if not guard.is_valid() or guard.call(_owner()):
var from := current_state var from := current_state
current_state = to_state current_state = to_state
state_changed.emit(_owner_node, from, to_state) state_changed.emit(_owner(), from, to_state)
return true return true
return false return false
@@ -42,6 +53,6 @@ func can_transition_to(to_state: String) -> bool:
for t in _transitions.get(current_state, []): for t in _transitions.get(current_state, []):
if t["to"] == to_state: if t["to"] == to_state:
var guard: Callable = t["guard"] var guard: Callable = t["guard"]
if not guard.is_valid() or guard.call(_owner_node): if not guard.is_valid() or guard.call(_owner()):
return true return true
return false return false
+1 -1
View File
@@ -1,5 +1,5 @@
class_name ShiftCoordinator class_name ShiftCoordinator
extends Node extends RefCounted
const Rules := preload("res://src/core/rules.gd") const Rules := preload("res://src/core/rules.gd")
const EventBus := preload("res://src/core/event_bus.gd") const EventBus := preload("res://src/core/event_bus.gd")
+14 -1
View File
@@ -64,7 +64,11 @@ var verifier := AccessVerifier.new()
var wand_screener := WandScreener.new() var wand_screener := WandScreener.new()
var active_scenario_id := "hollywood_building" var active_scenario_id := "hollywood_building"
var scenario_duration_s: float = 300.0 # shortened prototype duration for testing ## Shift length mirrors the scenario default (900s / 10-15 min per PLAN.md) unless
## overridden for a test or debug build.
var scenario_duration_s: float = 900.0
var _wand_cooldown := 0.0
const WAND_COOLDOWN_S := 0.8
var scene_root: Node3D = null var scene_root: Node3D = null
var npcs: Array = [] var npcs: Array = []
var npc_counter := 0 var npc_counter := 0
@@ -103,6 +107,7 @@ func _ready() -> void:
guard.cleared_target.connect(_on_guard_clear) guard.cleared_target.connect(_on_guard_clear)
selected_scenario = _build_scenario(active_scenario_id) selected_scenario = _build_scenario(active_scenario_id)
scenario_duration_s = selected_scenario.shift_duration_s
coordinator = ShiftCoordinator.new() coordinator = ShiftCoordinator.new()
coordinator.setup(selected_scenario, scenario_duration_s, 4242) coordinator.setup(selected_scenario, scenario_duration_s, 4242)
coordinator.npc_spawn_requested.connect(_spawn_npc) coordinator.npc_spawn_requested.connect(_spawn_npc)
@@ -169,6 +174,7 @@ func restart_shift() -> void:
inspector_current_events = [] inspector_current_events = []
current_incident_active = false current_incident_active = false
current_incident_npc_id = "" current_incident_npc_id = ""
scenario_duration_s = selected_scenario.shift_duration_s
coordinator.setup(selected_scenario, scenario_duration_s, 4242) coordinator.setup(selected_scenario, scenario_duration_s, 4242)
coordinator.start() coordinator.start()
_last_phase = -1 _last_phase = -1
@@ -552,6 +558,9 @@ func _toggle_inspection() -> void:
## Handheld wand inspection (The Joke Shop). Only available when the venue ## Handheld wand inspection (The Joke Shop). Only available when the venue
## carries a wand. The wand reports an alert; the player interprets it. ## carries a wand. The wand reports an alert; the player interprets it.
func _toggle_wand_screening() -> void: func _toggle_wand_screening() -> void:
if _wand_cooldown > 0.0:
hud.log(Strings.lookup("wand.recharge"))
return
if wand_open: if wand_open:
hud.hide_wand() hud.hide_wand()
hud.set_wand_icon(false) hud.set_wand_icon(false)
@@ -615,6 +624,8 @@ func decide_wand(allowed: bool) -> void:
controller.screened = true controller.screened = true
hud.hide_wand() hud.hide_wand()
wand_open = false 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: func decide_access(allowed: bool) -> void:
var controller: Object = _targeted_controller() var controller: Object = _targeted_controller()
@@ -714,6 +725,8 @@ func _close_incident(outcome: String) -> void:
current_incident_npc_id = "" current_incident_npc_id = ""
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
if _wand_cooldown > 0.0:
_wand_cooldown = maxf(0.0, _wand_cooldown - delta)
if coordinator == null or not coordinator.running: if coordinator == null or not coordinator.running:
return return
coordinator.tick(delta) coordinator.tick(delta)
+15 -7
View File
@@ -140,7 +140,7 @@ func _init() -> void:
# --- body-camera overlay (REC + shift clock + corner brackets, top right) --- # --- body-camera overlay (REC + shift clock + corner brackets, top right) ---
var cam_box := VBoxContainer.new() var cam_box := VBoxContainer.new()
cam_box.set_anchors_preset(Control.PRESET_TOP_RIGHT) cam_box.set_anchors_preset(Control.PRESET_TOP_RIGHT)
cam_box.position = Vector2(-180, 12) cam_box.position = Vector2(-180, 50)
cam_box.alignment = BoxContainer.ALIGNMENT_END cam_box.alignment = BoxContainer.ALIGNMENT_END
add_child(cam_box) add_child(cam_box)
var rec_row := HBoxContainer.new() var rec_row := HBoxContainer.new()
@@ -180,9 +180,12 @@ func _init() -> void:
icon_row.add_child(wand_icon) icon_row.add_child(wand_icon)
# --- inspect panel (popup center) --- # --- inspect panel (popup center) ---
var inspect_center := CenterContainer.new()
inspect_center.set_anchors_preset(Control.PRESET_FULL_RECT)
inspect_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(inspect_center)
inspect_panel = PanelContainer.new() inspect_panel = PanelContainer.new()
inspect_panel.visible = false inspect_panel.visible = false
inspect_panel.set_anchors_preset(Control.PRESET_CENTER)
inspect_panel.modulate.a = 1.0 inspect_panel.modulate.a = 1.0
var ivbox := VBoxContainer.new() var ivbox := VBoxContainer.new()
inspect_panel.add_child(ivbox) inspect_panel.add_child(ivbox)
@@ -197,12 +200,15 @@ func _init() -> void:
insp_hint.text = "A = allow entry D = deny entry" insp_hint.text = "A = allow entry D = deny entry"
insp_hint.add_theme_font_size_override("font_size", 12) insp_hint.add_theme_font_size_override("font_size", 12)
ivbox.add_child(insp_hint) ivbox.add_child(insp_hint)
add_child(inspect_panel) inspect_center.add_child(inspect_panel)
# --- wand screening panel (popup center) --- # --- wand screening panel (popup center) ---
var wand_center := CenterContainer.new()
wand_center.set_anchors_preset(Control.PRESET_FULL_RECT)
wand_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(wand_center)
wand_panel = PanelContainer.new() wand_panel = PanelContainer.new()
wand_panel.visible = false wand_panel.visible = false
wand_panel.set_anchors_preset(Control.PRESET_CENTER)
var wvbox := VBoxContainer.new() var wvbox := VBoxContainer.new()
wand_panel.add_child(wvbox) wand_panel.add_child(wvbox)
wand_text = Label.new() wand_text = Label.new()
@@ -216,7 +222,7 @@ func _init() -> void:
wand_hint.text = "A = admit D = refuse entry" wand_hint.text = "A = admit D = refuse entry"
wand_hint.add_theme_font_size_override("font_size", 12) wand_hint.add_theme_font_size_override("font_size", 12)
wvbox.add_child(wand_hint) wvbox.add_child(wand_hint)
add_child(wand_panel) wand_center.add_child(wand_panel)
# --- debug overlay --- # --- debug overlay ---
debug_label = Label.new() debug_label = Label.new()
@@ -374,6 +380,7 @@ func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -
continue continue
var card := TextureRect.new() var card := TextureRect.new()
card.texture = tex card.texture = tex
card.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
card.custom_minimum_size = Vector2(92, 58) card.custom_minimum_size = Vector2(92, 58)
card.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED card.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
card.tooltip_text = artifact.summary() card.tooltip_text = artifact.summary()
@@ -382,7 +389,8 @@ func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -
card_row.add_child(card) card_row.add_child(card)
added_card = true added_card = true
if added_card: if added_card:
inspect_panel.add_child(card_row) var content := inspect_panel.get_child(0) as VBoxContainer
content.add_child(card_row)
var lines: Array[String] = [] var lines: Array[String] = []
for artifact in artifacts: for artifact in artifacts:
lines.append(" • " + artifact.summary()) lines.append(" • " + artifact.summary())
@@ -403,7 +411,7 @@ func _clear_card_row() -> void:
return return
var row := inspect_panel.find_child("INSPECT_CARD_ROW", true, false) as Control var row := inspect_panel.find_child("INSPECT_CARD_ROW", true, false) as Control
if row: if row:
inspect_panel.remove_child(row) row.get_parent().remove_child(row)
row.queue_free() row.queue_free()
func show_wand(controller: Object, result: Dictionary) -> void: func show_wand(controller: Object, result: Dictionary) -> void:
+5 -2
View File
@@ -34,9 +34,12 @@ func _init() -> void:
dim.set_anchors_preset(Control.PRESET_FULL_RECT) dim.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(dim) add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(center)
panel = PanelContainer.new() panel = PanelContainer.new()
panel.set_anchors_preset(Control.PRESET_CENTER) center.add_child(panel)
add_child(panel)
var vbox := VBoxContainer.new() var vbox := VBoxContainer.new()
panel.add_child(vbox) panel.add_child(vbox)
+6 -1
View File
@@ -12,9 +12,10 @@ var gm: Node = null
var state := 0 var state := 0
var state_frames := 0 var state_frames := 0
var targeted := false var targeted := false
var camera_staged := false
func _initialize() -> void: func _initialize() -> void:
root.size = Vector2i(1600, 900) root.size = Vector2i(1280, 720)
var packed: PackedScene = load("res://scenes/main.tscn") var packed: PackedScene = load("res://scenes/main.tscn")
var inst: Node = packed.instantiate() var inst: Node = packed.instantiate()
root.add_child(inst) root.add_child(inst)
@@ -29,6 +30,10 @@ func _process(_delta: float) -> bool:
push_error("GameManager not found") push_error("GameManager not found")
quit(1) quit(1)
return true return true
if gm != null and not camera_staged:
gm.guard.global_position = Vector3(0, 1.6, 5.0)
gm.guard.head.rotation.x = deg_to_rad(-8.0)
camera_staged = true
if frames > MAX_FRAMES: if frames > MAX_FRAMES:
print("timeout reached") print("timeout reached")
quit(0) quit(0)
+4 -1
View File
@@ -20,10 +20,13 @@ func test_save_round_trip() -> bool:
var last: Dictionary = results[results.size() - 1] var last: Dictionary = results[results.size() - 1]
var c := TestUtils.check_eq(last.get("scenario_id", ""), "hollywood_building", "round-trips scenario id") var c := TestUtils.check_eq(last.get("scenario_id", ""), "hollywood_building", "round-trips scenario id")
var d := TestUtils.check_eq(float(last.get("reputation", {}).get("rating", 0.0)), 82.5, "round-trips rating") var d := TestUtils.check_eq(float(last.get("reputation", {}).get("rating", 0.0)), 82.5, "round-trips rating")
save.free()
return a and b and c and d return a and b and c and d
func test_load_returns_array_when_empty_or_missing() -> bool: func test_load_returns_array_when_empty_or_missing() -> bool:
# The tester home may already contain results; this only asserts shape safety. # The tester home may already contain results; this only asserts shape safety.
var save := _save_instance() var save := _save_instance()
var results: Array = save.load_shift_results() var results: Array = save.load_shift_results()
return TestUtils.check(results is Array, "load_shift_results always returns Array") var ok := TestUtils.check(results is Array, "load_shift_results always returns Array")
save.free()
return ok
+1 -1
View File
@@ -30,7 +30,7 @@ func test_strings_cover_ui_keys() -> bool:
"hud.aim_first", "hud.no_one_nearby", "hud.targeting", "hud.no_wand", "hud.aim_first", "hud.no_one_nearby", "hud.targeting", "hud.no_wand",
"hud.screen_first", "access.granted", "access.denied", "access.policy_satisfied", "hud.screen_first", "access.granted", "access.denied", "access.policy_satisfied",
"access.wrong_grant", "access.wrong_deny", "wand.admitted_prohibited", "access.wrong_grant", "access.wrong_deny", "wand.admitted_prohibited",
"wand.denied_false_alarm", "wand.cleared", "wand.refused", "radio.address_first", "wand.denied_false_alarm", "wand.cleared", "wand.refused", "wand.recharge", "radio.address_first",
"radio.blocked", "radio.inaccurate", "radio.received", "radio.emergency_dispatched", "radio.blocked", "radio.inaccurate", "radio.received", "radio.emergency_dispatched",
"responder.arrived", "react.comply", "react.leave", "react.argue", "react.record", "responder.arrived", "react.comply", "react.leave", "react.argue", "react.record",
"react.ignore", "react.threaten", "react.force_entry", "react.return_later", "react.ignore", "react.threaten", "react.force_entry", "react.return_later",
+2
View File
@@ -16,6 +16,8 @@ set -euo pipefail
GODOT="${GODOT:-godot}" GODOT="${GODOT:-godot}"
VERSION="$(grep -oP 'config/version="\K[^"]+' project.godot)" VERSION="$(grep -oP 'config/version="\K[^"]+' project.godot)"
mkdir -p build mkdir -p build
# Presets now embed the PCK into a single binary; drop stale .pck leftovers.
rm -f build/*.pck
echo "==> Exporting Linux (x86_64)" echo "==> Exporting Linux (x86_64)"
"$GODOT" --headless --export-release "Linux" "build/step-line-linux-${VERSION}.x86_64" "$GODOT" --headless --export-release "Linux" "build/step-line-linux-${VERSION}.x86_64"
+296 -21
View File
@@ -3,17 +3,93 @@
All outputs are deterministic (fixed seeds), <=1024px, power-of-two where All outputs are deterministic (fixed seeds), <=1024px, power-of-two where
possible. Run from the repo root: python3 tools/generate_textures.py possible. Run from the repo root: python3 tools/generate_textures.py
Optional AI mode generates textures through the OpenRouter image API
(https://openrouter.ai/docs/api/api-reference/images/generate-an-image.md).
Requires an API key in the OPENROUTER_API_KEY environment variable:
python3 tools/generate_textures.py --ai
python3 tools/generate_textures.py --ai --prompt "..." --name foo.png
""" """
import argparse
import base64
import io
import json
import math import math
import os import os
import random import random
import stat import stat
import sys
import urllib.error
import urllib.request
from PIL import Image, ImageDraw, ImageFilter, ImageFont from PIL import Image, ImageDraw, ImageFilter, ImageFont
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
ASSETS = os.path.join(ROOT, "assets", "textures") ASSETS = os.path.join(ROOT, "assets", "textures")
# OpenRouter image generation endpoint and default model (from the API docs).
OPENROUTER_IMAGES_URL = "https://openrouter.ai/api/v1/images"
DEFAULT_IMAGE_MODEL = "bytedance-seed/seedream-4.5"
MAX_DIM = 1024 # hard cap on the longest side of any generated texture
# px requested from the API; some models (e.g. seedream) need >= 1920
GEN_SIZE = 2048
# Default batch for `--ai`: one entry per game texture (see test_world.gd).
# Each entry is (output name, prompt, transparent_background).
AI_TEXTURES = [
# --- world surfaces (seamless, flat-lit) ---
("ai/brick_facade.png",
"seamless tileable red brick wall texture, weathered urban facade, "
"flat even lighting, frontal view, photorealistic game texture", False),
("ai/concrete.png",
"seamless tileable grey concrete wall texture, subtle cracks and stains, "
"flat even lighting, frontal view, photorealistic game texture", False),
("ai/asphalt.png",
"seamless tileable dark asphalt road texture, fine grain, "
"flat even lighting, frontal view, photorealistic game texture", False),
# --- storefront dressing ---
("ai/awning.png",
"red and white striped storefront awning with scalloped bottom edge, "
"frontal view, flat even lighting, game texture", False),
("ai/skyline.png",
"night city skyline silhouette, dark building shapes with a few lit "
"windows, flat graphic style, isolated on transparent background", True),
# --- screen-space overlay ---
("ai/vignette.png",
"radial vignette overlay, black edges fading to fully transparent center, "
"grayscale, soft gradient", True),
# --- HUD icons (white glyphs on transparent) ---
("ai/icons/flashlight_on.png",
"simple flat flashlight icon, white glyph on transparent background, "
"minimal UI icon", True),
("ai/icons/door_locked.png",
"simple flat locked door icon with keyhole, white outline glyph on "
"transparent background, minimal UI icon", True),
("ai/icons/wand.png",
"simple flat handheld security wand icon, white glyph on transparent "
"background, minimal UI icon", True),
("ai/icons/rec_dot.png",
"record button icon, red filled dot inside a white circle outline, on "
"transparent background, minimal UI icon", True),
# --- access artifact cards ---
("ai/cards/id_card.png",
"driver license ID card, blue plastic card with photo placeholder and "
"text lines, flat frontal view, game UI asset", False),
("ai/cards/wristband.png",
"red event access wristband with strap, flat frontal view, game UI asset",
False),
("ai/cards/ticket.png",
"green admission ticket stub with perforated edge, flat frontal view, "
"game UI asset", False),
("ai/cards/credential.png",
"dark grey staff credential pass with lanyard clip, flat frontal view, "
"game UI asset", False),
("ai/cards/guest_list.png",
"beige guest list document with ruled lines and checkboxes, flat frontal "
"view, game UI asset", False),
]
FONT_CANDIDATES = [ FONT_CANDIDATES = [
"/usr/share/fonts/liberation-sans-fonts/LiberationSans-Bold.ttf", "/usr/share/fonts/liberation-sans-fonts/LiberationSans-Bold.ttf",
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
@@ -21,12 +97,14 @@ FONT_CANDIDATES = [
"/usr/share/fonts/adwaita-sans-fonts/AdwaitaSans-Bold.ttf", "/usr/share/fonts/adwaita-sans-fonts/AdwaitaSans-Bold.ttf",
] ]
def font(size: int): def font(size: int):
for path in FONT_CANDIDATES: for path in FONT_CANDIDATES:
if os.path.exists(path): if os.path.exists(path):
return ImageFont.truetype(path, size) return ImageFont.truetype(path, size)
return ImageFont.load_default() return ImageFont.load_default()
def save(img: Image.Image, name: str) -> None: def save(img: Image.Image, name: str) -> None:
os.makedirs(ASSETS, exist_ok=True) os.makedirs(ASSETS, exist_ok=True)
path = os.path.join(ASSETS, name) path = os.path.join(ASSETS, name)
@@ -34,6 +112,7 @@ def save(img: Image.Image, name: str) -> None:
img.save(path, optimize=True) img.save(path, optimize=True)
print(f"wrote {os.path.relpath(path, ROOT)} {img.size}") print(f"wrote {os.path.relpath(path, ROOT)} {img.size}")
def grain(size, base, spread, seed, blur=1.0): def grain(size, base, spread, seed, blur=1.0):
rnd = random.Random(seed) rnd = random.Random(seed)
img = Image.new("L", (size, size), base) img = Image.new("L", (size, size), base)
@@ -44,6 +123,7 @@ def grain(size, base, spread, seed, blur=1.0):
px[x, y] = max(0, min(255, v)) px[x, y] = max(0, min(255, v))
return img.filter(ImageFilter.GaussianBlur(blur)) return img.filter(ImageFilter.GaussianBlur(blur))
def brick_facade(): def brick_facade():
size = 512 size = 512
mortar = (118, 110, 104) mortar = (118, 110, 104)
@@ -68,13 +148,14 @@ def brick_facade():
bx = rnd.randint(0, size - 1) bx = rnd.randint(0, size - 1)
by = rnd.randint(0, size - 1) by = rnd.randint(0, size - 1)
d.point((bx, by), fill=(max(0, base[0] - 18 + rnd.randint(0, 30)), d.point((bx, by), fill=(max(0, base[0] - 18 + rnd.randint(0, 30)),
max(0, base[1] - 10 + rnd.randint(0, 22)), max(0, base[1] - 10 + rnd.randint(0, 22)),
max(0, base[2] - 8 + rnd.randint(0, 18)))) max(0, base[2] - 8 + rnd.randint(0, 18))))
# horizontal mortar lines on top of bricks (wraps vertically by drawing at 0 and size) # horizontal mortar lines on top of bricks (wraps vertically by drawing at 0 and size)
for y in [-mortar_t, size - mortar_t]: for y in [-mortar_t, size - mortar_t]:
d.rectangle([0, y, size, y + mortar_t], fill=mortar) d.rectangle([0, y, size, y + mortar_t], fill=mortar)
save(img, "brick_facade.png") save(img, "brick_facade.png")
def seamless_concrete(): def seamless_concrete():
size = 512 size = 512
img = grain(size, 150, 26, 7, blur=2.5).convert("RGBA") img = grain(size, 150, 26, 7, blur=2.5).convert("RGBA")
@@ -83,7 +164,8 @@ def seamless_concrete():
for _ in range(90): for _ in range(90):
x, y = rnd.randint(0, size), rnd.randint(0, size) x, y = rnd.randint(0, size), rnd.randint(0, size)
r = rnd.choice([2, 3, 4]) r = rnd.choice([2, 3, 4])
d.ellipse([x - r, y - r, x + r, y + r], fill=(0, 0, 0, rnd.randint(8, 22))) d.ellipse([x - r, y - r, x + r, y + r],
fill=(0, 0, 0, rnd.randint(8, 22)))
for _ in range(8): # subtle crack segments for _ in range(8): # subtle crack segments
x, y = rnd.randint(0, size), rnd.randint(0, size) x, y = rnd.randint(0, size), rnd.randint(0, size)
pts = [(x, y)] pts = [(x, y)]
@@ -94,6 +176,7 @@ def seamless_concrete():
d.line(pts, fill=(70, 70, 70, 170), width=1) d.line(pts, fill=(70, 70, 70, 170), width=1)
save(img, "concrete.png") save(img, "concrete.png")
def asphalt(): def asphalt():
size = 512 size = 512
img = grain(size, 52, 14, 23, blur=1.2).convert("RGBA") img = grain(size, 52, 14, 23, blur=1.2).convert("RGBA")
@@ -101,9 +184,11 @@ def asphalt():
rnd = random.Random(31) rnd = random.Random(31)
for _ in range(600): for _ in range(600):
x, y = rnd.randint(0, size), rnd.randint(0, size) x, y = rnd.randint(0, size), rnd.randint(0, size)
d.point((x, y), fill=(max(0, 40 - rnd.randint(0, 18)),) * 3 + (rnd.randint(40, 110),)) d.point((x, y), fill=(max(0, 40 - rnd.randint(0, 18)),)
* 3 + (rnd.randint(40, 110),))
save(img, "asphalt.png") save(img, "asphalt.png")
def awning(): def awning():
w, h = 1024, 256 w, h = 1024, 256
img = Image.new("RGB", (w, h), (140, 40, 45)) img = Image.new("RGB", (w, h), (140, 40, 45))
@@ -111,7 +196,8 @@ def awning():
stripe = 64 stripe = 64
for i in range(w // stripe): for i in range(w // stripe):
if i % 2 == 1: if i % 2 == 1:
d.rectangle([i * stripe, 0, (i + 1) * stripe - 2, h], fill=(230, 225, 220)) d.rectangle([i * stripe, 0, (i + 1) * stripe - 2, h],
fill=(230, 225, 220))
# scalloped bottom edge # scalloped bottom edge
for x in range(0, w, 32): for x in range(0, w, 32):
d.arc([x, h - 24, x + 32, h + 40], 0, 180, fill=(80, 20, 24), width=6) d.arc([x, h - 24, x + 32, h + 40], 0, 180, fill=(80, 20, 24), width=6)
@@ -125,6 +211,7 @@ def awning():
img = Image.alpha_composite(img.convert("RGBA"), shade) img = Image.alpha_composite(img.convert("RGBA"), shade)
save(img, "awning.png") save(img, "awning.png")
def skyline(): def skyline():
w, h = 1024, 256 w, h = 1024, 256
img = Image.new("RGBA", (w, h), (0, 0, 0, 0)) img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
@@ -141,11 +228,13 @@ def skyline():
wx = x + rnd.randint(4, max(4, bw - 8)) wx = x + rnd.randint(4, max(4, bw - 8))
wy = h - rnd.randint(4, bh - 4) wy = h - rnd.randint(4, bh - 4)
if rnd.random() < 0.5: if rnd.random() < 0.5:
d.rectangle([wx, wy, wx + 6, wy + 8], fill=(255, 200, 120, 220)) d.rectangle([wx, wy, wx + 6, wy + 8],
fill=(255, 200, 120, 220))
x += bw + rnd.randint(10, 46) x += bw + rnd.randint(10, 46)
d.rectangle([0, h - 6, w, h], fill=(12, 13, 22, 255)) d.rectangle([0, h - 6, w, h], fill=(12, 13, 22, 255))
save(img, "skyline.png") save(img, "skyline.png")
def vignette(): def vignette():
size = 512 size = 512
img = Image.new("L", (size, size), 0) img = Image.new("L", (size, size), 0)
@@ -159,52 +248,112 @@ def vignette():
px[x, y] = int(255 * (t * t)) px[x, y] = int(255 * (t * t))
save(img, "vignette.png") save(img, "vignette.png")
def icon(name, draw_fn):
size = 64 def icon(name, draw_fn, size=64):
img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(img) d = ImageDraw.Draw(img)
draw_fn(d, size) draw_fn(d, size)
save(img, name) save(img, name)
def icons(): def icons():
def flashlight_on(d, s): def flashlight_on(d, s):
d.rounded_rectangle([s*0.30, s*0.30, s*0.62, s*0.62], radius=4, fill=(255,255,255,255)) d.rounded_rectangle([s*0.30, s*0.30, s*0.62, s*0.62],
d.polygon([(s*0.62, s*0.30), (s*0.82, s*0.18), (s*0.82, s*0.74), (s*0.62, s*0.62)], fill=(255,214,120,255)) radius=4, fill=(255, 255, 255, 255))
d.polygon([(s*0.62, s*0.30), (s*0.82, s*0.18), (s*0.82, s *
0.74), (s*0.62, s*0.62)], fill=(255, 214, 120, 255))
def door_locked(d, s): def door_locked(d, s):
d.rectangle([s*0.18, s*0.18, s*0.82, s*0.82], outline=(255,255,255,255), width=5) d.rectangle([s*0.18, s*0.18, s*0.82, s*0.82],
d.rounded_rectangle([s*0.36, s*0.34, s*0.64, s*0.66], radius=3, fill=(255,255,255,255)) outline=(255, 255, 255, 255), width=5)
d.arc([s*0.40, s*0.28, s*0.60, s*0.50], 180, 360, fill=(255,255,255,255), width=5) d.rounded_rectangle([s*0.36, s*0.34, s*0.64, s*0.66],
radius=3, fill=(255, 255, 255, 255))
d.arc([s*0.40, s*0.28, s*0.60, s*0.50], 180,
360, fill=(255, 255, 255, 255), width=5)
def wand(d, s): def wand(d, s):
d.rounded_rectangle([s*0.30, s*0.10, s*0.44, s*0.90], radius=3, fill=(255,255,255,255)) d.rounded_rectangle([s*0.30, s*0.10, s*0.44, s*0.90],
d.ellipse([s*0.24, s*0.06, s*0.50, s*0.32], fill=(120,220,160,255)) radius=3, fill=(255, 255, 255, 255))
d.ellipse([s*0.24, s*0.06, s*0.50, s*0.32], fill=(120, 220, 160, 255))
def rec(d, s): def rec(d, s):
d.ellipse([s*0.14, s*0.14, s*0.86, s*0.86], outline=(255,255,255,255), width=5) d.ellipse([s*0.14, s*0.14, s*0.86, s*0.86],
outline=(255, 255, 255, 255), width=5)
d.ellipse([s*0.30, s*0.30, s*0.70, s*0.70], fill=(230, 60, 60, 255)) d.ellipse([s*0.30, s*0.30, s*0.70, s*0.70], fill=(230, 60, 60, 255))
icon("icons/flashlight_on.png", flashlight_on) icon("icons/flashlight_on.png", flashlight_on)
icon("icons/door_locked.png", door_locked) icon("icons/door_locked.png", door_locked)
icon("icons/wand.png", wand) icon("icons/wand.png", wand)
icon("icons/rec_dot.png", rec) icon("icons/rec_dot.png", rec)
def app_icon():
"""Game app icon: dark badge, gold star, white 'S' — used by desktop/AppImage
(PNG) and the Windows installer (ICO)."""
def badge(d, s):
d.ellipse([2, 2, s-2, s-2], fill=(24, 26, 38, 255))
d.ellipse([int(s*0.10), int(s*0.10), int(s*0.90), int(s*0.90)],
outline=(120, 130, 170, 255), width=max(2, s//64))
# gold star
cx, cy, r = s*0.5, s*0.44, s*0.30
pts = []
for i in range(10):
ang = -90 + i * 36
rad = r if i % 2 == 0 else r * 0.42
import math
pts.append((cx + rad * math.cos(math.radians(ang)),
cy + rad * math.sin(math.radians(ang))))
d.polygon(pts, fill=(255, 205, 80, 255))
icon("icon.png", badge, size=1024)
icon("icon.ico", badge, size=256)
# ICO for Windows. Must be BMP-entry based: PIL's ICO serializer emits
# PNG-compressed entries which NSIS' icon loader rejects. ImageMagick
# writes classic BMP entries; fall back to PIL only if ImageMagick is gone
# (flag it so a human notices).
import subprocess
import sys
ico_path = os.path.join(ASSETS, "icon.ico")
src_png = os.path.join(ASSETS, "icon.png")
ran = False
for cmd in (
["magick", src_png, "-background", "none", "-define", "icon:auto-resize=48,32,16", ico_path],
["convert", src_png, "-background", "none", "-define", "icon:auto-resize=48,32,16", ico_path],
):
try:
subprocess.run(cmd, check=True, capture_output=True)
ran = True
break
except (FileNotFoundError, subprocess.CalledProcessError):
continue
if ran:
print("wrote assets/textures/icon.ico (BMP 16/32/48 via ImageMagick)")
else:
print("WARNING: ImageMagick not found; writing PIL ICO (may be PNG-compressed)")
Image.open(src_png).convert("RGBA").save(ico_path, format="ICO", sizes=[(48, 48), (32, 32), (16, 16)])
def cards(): def cards():
CW, CH = 256, 128 CW, CH = 256, 128
def base(card_name, bg, label, band=(0,0,0,0)):
def base(card_name, bg, label, band=(0, 0, 0, 0)):
img = Image.new("RGBA", (CW, CH), bg + (255,)) img = Image.new("RGBA", (CW, CH), bg + (255,))
d = ImageDraw.Draw(img) d = ImageDraw.Draw(img)
d.rounded_rectangle([6, 6, CW-6, CH-6], radius=10, outline=(0,0,0,90), width=2) d.rounded_rectangle([6, 6, CW-6, CH-6], radius=10,
outline=(0, 0, 0, 90), width=2)
if band and band[3] == 255: if band and band[3] == 255:
d.rectangle([0, CH-34, CW, CH], fill=band) d.rectangle([0, CH-34, CW, CH], fill=band)
f = font(26) f = font(26)
d.text((14, 10), label, font=f, fill=(255,255,255,235)) d.text((14, 10), label, font=f, fill=(255, 255, 255, 235))
# body text lines # body text lines
f2 = font(15) f2 = font(15)
y = 52 y = 52
for _ in range(4): for _ in range(4):
w = 150 - (24 if _ == 3 else 0) w = 150 - (24 if _ == 3 else 0)
d.rectangle([16, y, 16 + w, y + 8], fill=(255,255,255,110)) d.rectangle([16, y, 16 + w, y + 8], fill=(255, 255, 255, 110))
y += 24 y += 24
# photo square for id-like cards # photo square for id-like cards
if "photo" in card_name: if "photo" in card_name:
d.rectangle([CW-84, 14, CW-18, 96], fill=(180,190,205,255)) d.rectangle([CW-84, 14, CW-18, 96], fill=(180, 190, 205, 255))
save(img, f"cards/{card_name}.png") save(img, f"cards/{card_name}.png")
base("id_card", (46, 92, 168), "CA DRIVER LICENSE", band=(20, 36, 70, 255), ) base("id_card", (46, 92, 168), "CA DRIVER LICENSE", band=(20, 36, 70, 255), )
base("wristband", (196, 62, 78), "EVENT ACCESS", band=(150, 35, 50, 255)) base("wristband", (196, 62, 78), "EVENT ACCESS", band=(150, 35, 50, 255))
@@ -212,7 +361,132 @@ def cards():
base("credential", (58, 62, 74), "STAFF PASS", band=(20, 22, 28, 255)) base("credential", (58, 62, 74), "STAFF PASS", band=(20, 22, 28, 255))
base("guest_list", (200, 190, 168), "GUEST LIST", band=(120, 90, 60, 255)) base("guest_list", (200, 190, 168), "GUEST LIST", band=(120, 90, 60, 255))
def _api_key() -> str:
key = os.environ.get("OPENROUTER_API_KEY", "").strip()
if not key:
sys.exit("error: OPENROUTER_API_KEY is not set; export it before using --ai")
return key
def _generate_image(prompt: str, model: str, size: int,
transparent: bool = False) -> 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__": if __name__ == "__main__":
args = _build_arg_parser().parse_args()
if args.ai:
_run_ai(args)
print("done")
sys.exit(0)
brick_facade() brick_facade()
seamless_concrete() seamless_concrete()
asphalt() asphalt()
@@ -220,5 +494,6 @@ if __name__ == "__main__":
skyline() skyline()
vignette() vignette()
icons() icons()
app_icon()
cards() cards()
print("done") print("done")