Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2d218cc27 | ||
|
|
3377545a3e | ||
|
|
7ff0c28c03 | ||
|
|
2ca0e18018 | ||
|
|
0f076205af | ||
|
|
8bc84c1f36 | ||
|
|
418c30e334 | ||
|
|
39a80f39f9 | ||
|
|
5b994982bd | ||
|
|
0abd67fbaa | ||
|
|
08b3292b80 | ||
|
|
ba76c0febf | ||
|
|
07d79ac115 |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -1,3 +1,27 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
*.pycache
|
||||||
|
*.pytest_cache
|
||||||
|
*.mypy_cache
|
||||||
|
*.coverage
|
||||||
|
coverage.xml
|
||||||
|
htmlcov/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
eggs/
|
||||||
|
*.egg-info/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
*.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
# Godot 4
|
# Godot 4
|
||||||
.godot/
|
.godot/
|
||||||
*.import
|
*.import
|
||||||
@@ -18,3 +42,5 @@ user://
|
|||||||
# CI runner workflow logs (written into the checkout by the Gitea runner)
|
# CI runner workflow logs (written into the checkout by the Gitea runner)
|
||||||
.gitea/logs/
|
.gitea/logs/
|
||||||
build/
|
build/
|
||||||
|
|
||||||
|
.env
|
||||||
@@ -173,6 +173,61 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every
|
|||||||
- [x] `config/version` → `0.2.0`; `build/` artifacts rebuilt and Linux binary smoke-tested.
|
- [x] `config/version` → `0.2.0`; `build/` artifacts rebuilt and Linux binary smoke-tested.
|
||||||
- [x] New world/lighting headless tests (56 total).
|
- [x] New world/lighting headless tests (56 total).
|
||||||
|
|
||||||
|
## Screenshot Refresh
|
||||||
|
|
||||||
|
- [x] Regenerate and verify the six README screenshots from the current build.
|
||||||
|
|
||||||
|
## 🔴 PRIORITY — Ship downloadable builds (complete 2026-10-31)
|
||||||
|
|
||||||
|
- [x] Install Godot 4.3 export templates locally.
|
||||||
|
- [x] `./tools/build_releases.sh` produces Linux/Windows/macOS artifacts (0.2.0).
|
||||||
|
- [x] Linux binary smoke-tested (headless, 0 script errors).
|
||||||
|
- [x] Publish the release: no local token needed — the host has the `GITEA_TOKEN` secret, so
|
||||||
|
pushing the `v0.2.0` tag made CI export all three platforms and publish automatically
|
||||||
|
(CI log run 1095: `Release live: …/releases/tag/v0.2.0`, Job succeeded).
|
||||||
|
- [x] Confirm links resolve: tag page and `/releases` return HTTP 200 publicly.
|
||||||
|
Follow-up: anonymous **asset downloads** require sign-in on this host (host setting).
|
||||||
|
- [x] CI packaging follow-up was already live (`.gitea/workflows/build.yml` on tags).
|
||||||
|
|
||||||
|
## 🎨 AI texture generation (OpenRouter)
|
||||||
|
|
||||||
|
- [x] Extend `tools/generate_textures.py` with an OpenRouter image-API mode (`--ai`, `--prompt`,
|
||||||
|
`--name`, `--model`, `--size`), decoding base64 PNG responses and clamping to ≤1024px.
|
||||||
|
- [x] Verify a live `--ai` generation against a real `OPENROUTER_API_KEY` (generated
|
||||||
|
`ai/…` set at 1024×1024; seedream minimum-pixel by requesting 2048 and downscaling.
|
||||||
|
|
||||||
|
## 🧹 Runtime hygiene + timeline/polish items
|
||||||
|
|
||||||
|
- [x] RefCounted cycle cleanup: `FSM` now holds its owner via `WeakRef`; `ShiftCoordinator` is
|
||||||
|
`RefCounted` (logic-only, no tree needs); persistence tests free the `Save` node.
|
||||||
|
Test suite now exits with **zero** “resources still in use” warnings.
|
||||||
|
- [x] Full-length shifts: the prototype now uses each scenario's `shift_duration_s` (900s ≈
|
||||||
|
10-15 min per PLAN.md) instead of a hardcoded 300s test duration.
|
||||||
|
- [x] Wand cooldown feel: a short `WAND_COOLDOWN_S` recharge between wand scans with a
|
||||||
|
`wand.recharge` message (Open Polish item).
|
||||||
|
|
||||||
|
## 📦 Installer packaging (Windows NSIS + Linux AppImage)
|
||||||
|
|
||||||
|
- [x] Export presets: `binary_format/embed_pck=true` on all platforms — every binary is now
|
||||||
|
self-contained (all game data in one file; `.pck` no longer shipped).
|
||||||
|
- [x] App icon: `assets/textures/icon.png` (1024) + multi-size `icon.ico`, copied to
|
||||||
|
`packaging/windows/step-line.ico` and `packaging/linux/step-line.png` via the generator.
|
||||||
|
- [x] NSIS script `packaging/windows/step-line.nsi` (installs `Step Line.exe`, uninstaller,
|
||||||
|
shortcuts, registry entry; `makensis /DVERSION=… /DARTIFACT=…`).
|
||||||
|
- [x] AppImage: `packaging/linux/step-line.desktop`, `AppRun`, appdata.xml, and
|
||||||
|
`build_appimage.sh` (appimagetool, `APPIMAGE_EXTRACT_AND_RUN=1`).
|
||||||
|
- [x] Local end-to-end: rebuilt embedded exports and produced `step-line-0.2.0-x86_64.AppImage`,
|
||||||
|
ran it headless (0 script errors).
|
||||||
|
- [x] CI: NSIS + AppImage steps added to `.gitea/workflows/build.yml` build job; publish glob
|
||||||
|
already uploads `build/step-line-*<ver>*` (covers setup/AppImage names).
|
||||||
|
- [x] CI run 1110 verified end-to-end (v0.2.1): NSIS installer `step-line-setup-0.2.1.exe`
|
||||||
|
built, `7z l` shows `step-line.exe` + `uninstall.exe`, AppImage built (33.9 MB),
|
||||||
|
release `v0.2.1` published, `Job succeeded`.
|
||||||
|
- Gotchas fixed on the way: makensis needs `-D` defines (not `/D`) and absolute paths
|
||||||
|
(it resolves relatives against the script dir); installed exe must be space-free
|
||||||
|
(`step-line.exe`); icon.ico must use classic BMP entries (ImageMagick; PIL emits
|
||||||
|
PNG-compressed ICOs NSIS rejects).
|
||||||
|
|
||||||
## 🎮 Assets & detailed graphics pass (textures, rigs, UI feedback)
|
## 🎮 Assets & detailed graphics pass (textures, rigs, UI feedback)
|
||||||
|
|
||||||
- [x] Authored texture set via `tools/generate_textures.py` (deterministic PIL): brick facade,
|
- [x] Authored texture set via `tools/generate_textures.py` (deterministic PIL): brick facade,
|
||||||
|
|||||||
@@ -15,7 +15,28 @@ 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.1 is LIVE** (2026-10-31): CI run 1110 built the NSIS installer + AppImage
|
||||||
|
and published — installers/AppImage are on the releases page. Releasing = bump version → tag → push.
|
||||||
|
- NSIS gotchas (validated in a local ubuntu:24.04 docker repro): makensis uses `-D` defines
|
||||||
|
(never `/D`), resolves relative `-DICON`/`-DARTIFACT` against the **script directory** so CI
|
||||||
|
passes `$PWD`-absolute paths; installed exe must be space-free (`step-line.exe`); ICO must be
|
||||||
|
classic BMP entries (ImageMagick `icon:auto-resize=48,32,16`; PIL emits PNG-compressed ICOs NSIS
|
||||||
|
cannot load).
|
||||||
|
- appimagetool runs system `appstreamcli` if present (strict, exit 3 on warnings) — project
|
||||||
|
metadata now passes `--pedantic`.
|
||||||
|
- Distribution facts (verified via Gitea API + host creds): repo is **public**, account is
|
||||||
|
admin; `v0.2.1` has all 5 assets; the installers were initially missing from the release
|
||||||
|
because `publish_release.sh` uploaded only portables — now fixed (uploads installer +
|
||||||
|
AppImage; falls back to `~/.git-credentials` when no `GITEA_TOKEN`).
|
||||||
|
- Host sign-in: instance `[service] REQUIRE_SIGNIN_VIEW=true` blocks anonymous browse/download
|
||||||
|
(not a repo/API setting). `~/.git-credentials` holds the Gitea creds (basic-auth usable);
|
||||||
|
`.env` only holds `OPENROUTER_API_KEY`.
|
||||||
|
- **Release v0.2.0 was LIVE**: pushing the `v0.2.0` tag made Gitea Actions run the build job
|
||||||
|
and publish automatically (host has the `GITEA_TOKEN` secret). Tag page and `/releases` are
|
||||||
|
public (HTTP 200). Caveat: anonymous **asset downloads** redirect to login on this host
|
||||||
|
(host setting — tracked in TODO). Future releases = bump `config/version`, `git tag vX.Y.Z`,
|
||||||
|
push. No local token needed.
|
||||||
|
- Core simulation, three scenarios, 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 +75,39 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
- Assets pipeline: `python3 tools/generate_textures.py` produces all authored PNGs under
|
- Assets pipeline: `python3 tools/generate_textures.py` produces all authored PNGs under
|
||||||
`assets/textures/` (≤1024px, pow2); regenerate after changing the script, then run
|
`assets/textures/` (≤1024px, pow2); regenerate after changing the script, then run
|
||||||
`godot --headless --import` before tests. 57 headless tests guard the pipeline.
|
`godot --headless --import` before tests. 57 headless tests guard the pipeline.
|
||||||
|
- General image pipeline: `python3 tools/asset_pipeline.py` reuses the OpenRouter image backend
|
||||||
|
for role-sized generation and provides exact crop/resize plus role-aware audits. Audit checks
|
||||||
|
dimensions, aspect, required alpha, and tile-edge continuity; perspective/projection stays a
|
||||||
|
manual visual check. Python tools require Pillow from `requirements-tools.txt`.
|
||||||
|
- AI texture mode: `python3 tools/generate_textures.py --ai` generates textures via the
|
||||||
|
OpenRouter image API (`POST https://openrouter.ai/api/v1/images`, model
|
||||||
|
`bytedance-seed/seedream-4.5` by default, base64 PNG in `data[].b64_json`). Requires
|
||||||
|
`OPENROUTER_API_KEY` (a gitignored `.env` in the repo root holds it locally; source it
|
||||||
|
with `set -a && . ./.env && set +a`). `--prompt`/`--name`/`--model`/`--size` override the
|
||||||
|
default batch. Output is downscaled to ≤1024px. NOTE: seedream rejects sizes below
|
||||||
|
3,686,400 px (1920×1920), so the script requests `GEN_SIZE=2048` and downscales — do not
|
||||||
|
lower `GEN_SIZE` below 1920. Verified end-to-end 2026-10-31 (3 textures generated).
|
||||||
|
`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).
|
||||||
|
|||||||
@@ -20,15 +20,22 @@ The central design goal is simple:
|
|||||||
|
|
||||||
| Platform | Package |
|
| Platform | Package |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Windows** | [`step-line-windows.exe`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, no install needed |
|
| **Windows — installer** | [`step-line-setup-<ver>.exe`](https://git.allucanget.biz/zwitschi/security-game/releases) · contains all game data, shortcuts + uninstaller |
|
||||||
| **Linux** | [`step-line-linux.x86_64`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, `chmod +x` and run |
|
| **Windows — portable** | [`step-line-windows-<ver>.exe`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, no install needed |
|
||||||
| **macOS** | [`step-line-macos.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unsigned build, quarantine note below |
|
| **Linux — AppImage** | [`step-line-<ver>-x86_64.AppImage`](https://git.allucanget.biz/zwitschi/security-game/releases) · single file, `chmod +x` and run |
|
||||||
|
| **Linux — portable** | [`step-line-linux-<ver>.x86_64`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, `chmod +x` and run |
|
||||||
|
| **macOS** | [`step-line-macos-<ver>.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unsigned build, quarantine note below |
|
||||||
|
|
||||||
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
|
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
|
||||||
|
|
||||||
> Releases are built automatically by Gitea Actions when a `vX.Y.Z` tag is
|
> **Latest: v0.2.1** (2026-10-31) — built and published by Gitea Actions from the
|
||||||
> pushed (see [`docs/RELEASING.md`](docs/RELEASING.md)). Manual builds are
|
> `v0.2.1` tag, including the Windows installer and Linux AppImage. Releases are built automatically when a `vX.Y.Z` tag is pushed
|
||||||
> produced with `./tools/build_releases.sh`.
|
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
|
||||||
|
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
|
||||||
|
> (NSIS + appimagetool) and uploads with the portable binaries.
|
||||||
|
>
|
||||||
|
> Note: on this host, downloading release files currently requires sign-in;
|
||||||
|
> the page and tag URLs are public.
|
||||||
>
|
>
|
||||||
> macOS builds are unsigned: right-click → Open, or clear the quarantine with
|
> macOS builds are unsigned: right-click → Open, or clear the quarantine with
|
||||||
> `xattr -dr com.apple.quarantine step-line-macos.zip`. Build instructions and
|
> `xattr -dr com.apple.quarantine step-line-macos.zip`. Build instructions and
|
||||||
@@ -129,11 +136,43 @@ 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`).
|
||||||
|
- **Asset tooling:** `tools/asset_pipeline.py` reuses the OpenRouter generator for role-aware assets and provides crop, resize, and image-audit commands.
|
||||||
- **Testing:** GUT (Godot Unit Test) in headless mode
|
- **Testing:** GUT (Godot Unit Test) in headless mode
|
||||||
- **Source control:** git on a Gitea host with Gitea Actions (`.gitea/workflows/build.yml`) for headless tests and desktop release builds
|
- **Source control:** git on a Gitea host with Gitea Actions (`.gitea/workflows/build.yml`) for headless tests and desktop release builds
|
||||||
- **Targets:** Windows, Linux, and macOS desktop first; web later
|
- **Targets:** Windows, Linux, and macOS desktop first; web later
|
||||||
|
|
||||||
|
### Image Asset Tools
|
||||||
|
|
||||||
|
Install the Pillow dependency used by the existing texture generator and the asset tools:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m pip install -r requirements-tools.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate a role-sized image (generation requires `OPENROUTER_API_KEY`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/asset_pipeline.py generate --prompt "weathered red brick" --role tile --output assets/textures/brick_extra.png
|
||||||
|
```
|
||||||
|
|
||||||
|
Crop uses the image center by default; `--x` and `--y` select an explicit crop origin. Resize defaults to aspect-preserving `contain`; use `--fit cover` to crop or `--fit stretch` to distort to the exact dimensions.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/asset_pipeline.py crop assets/textures/source.png assets/textures/crop.png --width 256 --height 128
|
||||||
|
python3 tools/asset_pipeline.py resize assets/textures/source.png assets/images/backgrounds/scene.png --width 1920 --height 1080 --fit cover
|
||||||
|
```
|
||||||
|
|
||||||
|
Audit accepts image files or directories. `--role auto` infers common roles from paths and filenames; explicit roles include `tile`, `texture`, `icon`, `card`, `banner`, `backdrop`, `background`, `overlay`, and `app_icon`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/asset_pipeline.py audit assets/textures
|
||||||
|
python3 tools/asset_pipeline.py audit assets/images/backgrounds --role background --json
|
||||||
|
python3 -m unittest discover -s tools -p 'test_*.py'
|
||||||
|
```
|
||||||
|
|
||||||
|
The audit classifies measurable fit issues as `good` or `bad` based on role-specific dimensions, aspect ratio, alpha requirements, and tile-edge continuity. It lists perspective/projection as a visual review item because image semantics cannot be judged reliably by these deterministic checks.
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Download the Godot 4.3 editor (official build) and put `godot` on your PATH.
|
1. Download the Godot 4.3 editor (official build) and put `godot` on your PATH.
|
||||||
@@ -208,7 +247,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
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,29 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
## 🔴 PRIORITY — Ship downloadable builds (so the README Downloads links work)
|
## Distribution follow-up (findings + host config)
|
||||||
|
|
||||||
Goal: users hitting the README **Downloads** section can download a playable version from
|
Verified via API+host credentials (2026-10-31):
|
||||||
`https://git.allucanget.biz/zwitschi/security-game/releases`.
|
|
||||||
|
|
||||||
- [x] Install Godot 4.3 export templates locally.
|
- [x] Repo is **public** (`private:false`) and account has admin rights.
|
||||||
- [x] `./tools/build_releases.sh` produces Linux/Windows/macOS artifacts (built for 0.2.0).
|
- [x] Release `v0.2.1` now carries all five artifacts (portable binaries + NSIS installer +
|
||||||
- [x] Linux binary smoke-tested (headless, 0 script errors).
|
AppImage) — the two installers were missing after CI publish (script uploaded only the
|
||||||
- [ ] Publish the release — **blocked on `GITEA_TOKEN`**: once the token is available run
|
portables); `tools/publish_release.sh` fixed to include them and re-uploaded via API.
|
||||||
`GITEA_TOKEN=... ./tools/publish_release.sh 0.2.0`, or (if the repo secret is already set
|
- [ ] **Remaining host setting**: instance-wide `[service] REQUIRE_SIGNIN_VIEW=true` makes
|
||||||
on the host) push the `v0.2.0` tag and let CI publish via `.gitea/workflows/build.yml`.
|
anonymous browsing/downloads redirect to login (API-confirmed repo is public; this is a
|
||||||
- [ ] Confirm the README Downloads links resolve: check
|
server `app.ini` setting, not an API or repo option).
|
||||||
`https://git.allucanget.biz/zwitschi/security-game/releases` and download one artifact.
|
Advised path for the host admin: set `REQUIRE_SIGNIN_VIEW = false` in `app.ini` under
|
||||||
|
`[service]`, restart Gitea, then verify `curl -sI https://git.allucanget.biz/zwitschi/security-game/releases/tag/v0.2.1`
|
||||||
|
returns 200 (not a login redirect) and an anonymous asset download succeeds. Alternative:
|
||||||
|
mirror downloads (Itch.io / GitHub Releases) for anonymous users.
|
||||||
|
|
||||||
## 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 +34,11 @@ 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.
|
||||||
|
|
||||||
|
## Generalized Game Asset Tooling
|
||||||
|
|
||||||
|
- [ ] Reuse the OpenRouter image backend for role-aware arbitrary asset generation.
|
||||||
|
- [ ] Add exact-dimension image crop and aspect-preserving resize commands.
|
||||||
|
- [ ] Audit existing assets for role-specific dimensions, aspect, alpha, and tile seams;
|
||||||
|
report projection/perspective checks for visual review.
|
||||||
|
- [ ] Add automated tests and document asset-pipeline usage.
|
||||||
|
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: 15 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -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…"
|
||||||
}
|
}
|
||||||
@@ -57,12 +57,22 @@ godot --headless --path . --quit-after 120 # boot smoke t
|
|||||||
./tools/build_releases.sh
|
./tools/build_releases.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Artifacts are written to `build/`:
|
Artifacts are written to `build/` (all exports embed the PCK — single files):
|
||||||
|
|
||||||
- `build/step-line-linux-<version>.x86_64`
|
- `build/step-line-linux-<version>.x86_64`
|
||||||
- `build/step-line-windows-<version>.exe`
|
- `build/step-line-windows-<version>.exe`
|
||||||
- `build/step-line-macos-<version>.zip`
|
- `build/step-line-macos-<version>.zip`
|
||||||
|
|
||||||
|
When run in CI (tags/dispatch), the build job also packages:
|
||||||
|
|
||||||
|
- `build/step-line-setup-<version>.exe` — Windows installer (NSIS, incl. shortcuts +
|
||||||
|
uninstaller; apt `nsis` on the runner)
|
||||||
|
- `build/step-line-<version>-x86_64.AppImage` — Linux AppImage (`packaging/linux/build_appimage.sh`,
|
||||||
|
appimagetool via `APPIMAGE_EXTRACT_AND_RUN`)
|
||||||
|
|
||||||
|
Locally you can build the AppImage with `bash packaging/linux/build_appimage.sh <version>`;
|
||||||
|
the NSIS installer requires `makensis` (covered by CI).
|
||||||
|
|
||||||
> macOS builds are unsigned and will need `xattr -dr com.apple.quarantine`
|
> macOS builds are unsigned and will need `xattr -dr com.apple.quarantine`
|
||||||
> guidance or notarization for production releases.
|
> guidance or notarization for production releases.
|
||||||
|
|
||||||
@@ -70,15 +80,22 @@ Artifacts are written to `build/`:
|
|||||||
|
|
||||||
The repository lives at `https://git.allucanget.biz/zwitschi/security-game`.
|
The repository lives at `https://git.allucanget.biz/zwitschi/security-game`.
|
||||||
|
|
||||||
Preferred path — use the helper script with a token:
|
Preferred path — use the helper script. It needs either a token or the same
|
||||||
|
credentials used by `git push` (it falls back to `~/.git-credentials`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
GITEA_TOKEN=your_api_token ./tools/publish_release.sh 0.1.0
|
GITEA_TOKEN=your_api_token ./tools/publish_release.sh 0.2.1
|
||||||
|
# or simply: ./tools/publish_release.sh 0.2.1 (uses ~/.git-credentials)
|
||||||
```
|
```
|
||||||
|
|
||||||
The script creates the release from `project.godot`'s `config/version`, uploads
|
The script creates (or reuses) the release from `project.godot`'s
|
||||||
all three `build/` artifacts, and prints the release URL. The token needs only
|
`config/version`, uploads the three portable artifacts **plus** the NSIS
|
||||||
`write:repository` scope on the Gitea host.
|
installer and AppImage when present, and prints the release URL. If CI already
|
||||||
|
published the release, re-running the script only adds missing assets.
|
||||||
|
|
||||||
|
> **Host note**: the release is fully built by CI, but anonymous users can only
|
||||||
|
> browse and download when the instance sets `[service] REQUIRE_SIGNIN_VIEW=false`
|
||||||
|
> in `app.ini` (the repo itself is public). See the TODO for the exact steps.
|
||||||
|
|
||||||
Manual alternative:
|
Manual alternative:
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 237 KiB |
@@ -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
|
||||||
|
|||||||
@@ -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/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")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Pillow>=9.2
|
||||||
@@ -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>
|
||||||
@@ -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,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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate, crop, resize, and quality-check game image assets."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image, ImageChops, ImageOps, ImageStat
|
||||||
|
|
||||||
|
import generate_textures
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"}
|
||||||
|
|
||||||
|
PROFILES = {
|
||||||
|
"texture": {"target": (512, 512), "min": (256, 256), "max": (1024, 1024),
|
||||||
|
"aspect": 1.0, "alpha": None, "view": "flat/orthographic"},
|
||||||
|
"tile": {"target": (512, 512), "min": (256, 256), "max": (1024, 1024),
|
||||||
|
"aspect": 1.0, "alpha": None, "view": "flat/orthographic",
|
||||||
|
"power_of_two": True, "seamless": True},
|
||||||
|
"icon": {"target": (64, 64), "min": (32, 32), "max": (512, 512),
|
||||||
|
"aspect": 1.0, "alpha": True, "view": "flat, centered glyph"},
|
||||||
|
"app_icon": {"target": (1024, 1024), "min": (512, 512), "max": (1024, 1024),
|
||||||
|
"aspect": 1.0, "alpha": None, "view": "square, centered composition"},
|
||||||
|
"overlay": {"target": (512, 512), "min": (256, 256), "max": (1024, 1024),
|
||||||
|
"aspect": 1.0, "alpha": None, "view": "screen-space overlay"},
|
||||||
|
"card": {"target": (256, 128), "min": (128, 64), "max": (1024, 512),
|
||||||
|
"aspect": 2.0, "alpha": None, "view": "flat, front-facing card"},
|
||||||
|
"banner": {"target": (1024, 256), "min": (512, 128), "max": (2048, 512),
|
||||||
|
"aspect": 4.0, "alpha": None, "view": "flat, front-facing banner"},
|
||||||
|
"backdrop": {"target": (1024, 256), "min": (1024, 256), "max": (8192, 2048),
|
||||||
|
"aspect": 4.0, "alpha": None, "view": "straight-on wide backdrop"},
|
||||||
|
"background": {"target": (1920, 1080), "min": (1280, 720), "max": (8192, 4320),
|
||||||
|
"aspect": 16 / 9, "alpha": None, "view": "intended game camera and horizon"},
|
||||||
|
}
|
||||||
|
|
||||||
|
ROLE_GUIDANCE = {
|
||||||
|
"texture": "flat-lit game surface texture, straight-on orthographic view, no perspective",
|
||||||
|
"tile": "seamless tileable surface texture, flat even lighting, orthographic, no perspective or shadows",
|
||||||
|
"icon": "single centered flat UI glyph, clean silhouette, transparent background, no mockup or perspective",
|
||||||
|
"app_icon": "square game application icon, centered composition, no perspective",
|
||||||
|
"overlay": "screen-space overlay texture with transparent edges, no perspective",
|
||||||
|
"card": "single front-facing flat game UI card, no perspective, no surrounding mockup",
|
||||||
|
"banner": "wide front-facing game banner texture, no perspective, no surrounding scene",
|
||||||
|
"backdrop": "wide seamless-looking game environment backdrop, straight-on view, no foreground objects",
|
||||||
|
"background": "wide 16:9 game background environment with a coherent horizon and straight camera view",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_path(value):
|
||||||
|
path = Path(value).expanduser()
|
||||||
|
return path if path.is_absolute() else ROOT / path
|
||||||
|
|
||||||
|
|
||||||
|
def has_alpha(image):
|
||||||
|
return image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info)
|
||||||
|
|
||||||
|
|
||||||
|
def has_transparency(image):
|
||||||
|
if not has_alpha(image):
|
||||||
|
return False
|
||||||
|
return image.convert("RGBA").getchannel("A").getextrema()[0] < 255
|
||||||
|
|
||||||
|
|
||||||
|
def parse_color(value):
|
||||||
|
value = value.strip().lstrip("#")
|
||||||
|
if len(value) not in (6, 8):
|
||||||
|
raise argparse.ArgumentTypeError("color must be #RRGGBB or #RRGGBBAA")
|
||||||
|
try:
|
||||||
|
channels = tuple(int(value[index:index + 2], 16)
|
||||||
|
for index in range(0, len(value), 2))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
"color must use hexadecimal digits") from exc
|
||||||
|
return channels + ((255,) if len(channels) == 3 else ())
|
||||||
|
|
||||||
|
|
||||||
|
def save_image(image, path):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
if suffix in (".jpg", ".jpeg") and image.mode in ("RGBA", "LA"):
|
||||||
|
background = Image.new("RGB", image.size, (0, 0, 0))
|
||||||
|
background.paste(image.convert("RGBA"),
|
||||||
|
mask=image.convert("RGBA").getchannel("A"))
|
||||||
|
image = background
|
||||||
|
image.save(path)
|
||||||
|
print(
|
||||||
|
f"wrote {path.relative_to(ROOT) if path.is_relative_to(ROOT) else path} {image.size}")
|
||||||
|
|
||||||
|
|
||||||
|
def crop_image(source, output, width, height, x=None, y=None):
|
||||||
|
with Image.open(source) as image:
|
||||||
|
image.load()
|
||||||
|
source_width, source_height = image.size
|
||||||
|
if width <= 0 or height <= 0 or width > source_width or height > source_height:
|
||||||
|
raise ValueError(
|
||||||
|
f"crop {width}x{height} must fit inside source {source_width}x{source_height}")
|
||||||
|
if (x is None) != (y is None):
|
||||||
|
raise ValueError("--x and --y must be supplied together")
|
||||||
|
left = (source_width - width) // 2 if x is None else x
|
||||||
|
top = (source_height - height) // 2 if y is None else y
|
||||||
|
if left < 0 or top < 0 or left + width > source_width or top + height > source_height:
|
||||||
|
raise ValueError(
|
||||||
|
"crop origin and dimensions extend beyond the source image")
|
||||||
|
cropped = image.crop((left, top, left + width, top + height))
|
||||||
|
save_image(cropped, output)
|
||||||
|
|
||||||
|
|
||||||
|
def resize_image(source, output, width, height, fit="contain", background=(0, 0, 0, 0)):
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
raise ValueError("resize dimensions must be positive")
|
||||||
|
with Image.open(source) as image:
|
||||||
|
image.load()
|
||||||
|
if fit == "stretch":
|
||||||
|
result = image.resize((width, height), Image.Resampling.LANCZOS)
|
||||||
|
elif fit == "cover":
|
||||||
|
result = ImageOps.fit(image, (width, height),
|
||||||
|
method=Image.Resampling.LANCZOS)
|
||||||
|
else:
|
||||||
|
contained = ImageOps.contain(
|
||||||
|
image, (width, height), method=Image.Resampling.LANCZOS)
|
||||||
|
use_alpha = has_alpha(image) or background[3] < 255
|
||||||
|
mode = "RGBA" if use_alpha else "RGB"
|
||||||
|
fill = background if use_alpha else background[:3]
|
||||||
|
result = Image.new(mode, (width, height), fill)
|
||||||
|
contained = contained.convert(mode)
|
||||||
|
offset = ((width - contained.width) // 2,
|
||||||
|
(height - contained.height) // 2)
|
||||||
|
if use_alpha:
|
||||||
|
result.alpha_composite(contained, offset)
|
||||||
|
else:
|
||||||
|
result.paste(contained, offset)
|
||||||
|
save_image(result, output)
|
||||||
|
|
||||||
|
|
||||||
|
def infer_role(path):
|
||||||
|
parts = [part.lower() for part in path.parts]
|
||||||
|
stem = path.stem.lower()
|
||||||
|
if "icons" in parts:
|
||||||
|
return "icon"
|
||||||
|
if "cards" in parts:
|
||||||
|
return "card"
|
||||||
|
if stem == "icon":
|
||||||
|
return "app_icon"
|
||||||
|
if "vignette" in stem or "overlay" in stem:
|
||||||
|
return "overlay"
|
||||||
|
if "skyline" in stem or "backdrop" in parts:
|
||||||
|
return "backdrop"
|
||||||
|
if "awning" in stem or "banner" in parts:
|
||||||
|
return "banner"
|
||||||
|
if stem in {"brick_facade", "concrete", "asphalt"} or "tiles" in parts:
|
||||||
|
return "tile"
|
||||||
|
if "backgrounds" in parts or "background" in parts:
|
||||||
|
return "background"
|
||||||
|
return "texture"
|
||||||
|
|
||||||
|
|
||||||
|
def _mean_edge_difference(image):
|
||||||
|
rgb = image.convert("RGB")
|
||||||
|
width, height = rgb.size
|
||||||
|
horizontal = ImageStat.Stat(ImageChops.difference(
|
||||||
|
rgb.crop((0, 0, 1, height)), rgb.crop((width - 1, 0, width, height))))
|
||||||
|
vertical = ImageStat.Stat(ImageChops.difference(
|
||||||
|
rgb.crop((0, 0, width, 1)), rgb.crop((0, height - 1, width, height))))
|
||||||
|
return max(sum(horizontal.mean) / len(horizontal.mean),
|
||||||
|
sum(vertical.mean) / len(vertical.mean))
|
||||||
|
|
||||||
|
|
||||||
|
def audit_image(path, role="auto", seam_threshold=18.0):
|
||||||
|
path = Path(path)
|
||||||
|
chosen_role = infer_role(path) if role == "auto" else role
|
||||||
|
profile = PROFILES[chosen_role]
|
||||||
|
errors = []
|
||||||
|
warnings = []
|
||||||
|
try:
|
||||||
|
with Image.open(path) as image:
|
||||||
|
image.load()
|
||||||
|
width, height = image.size
|
||||||
|
alpha = has_transparency(image)
|
||||||
|
minimum = profile["min"]
|
||||||
|
maximum = profile["max"]
|
||||||
|
if width < minimum[0] or height < minimum[1]:
|
||||||
|
errors.append(
|
||||||
|
f"too small: {width}x{height}; minimum is {minimum[0]}x{minimum[1]}")
|
||||||
|
if width > maximum[0] or height > maximum[1]:
|
||||||
|
errors.append(
|
||||||
|
f"too large: {width}x{height}; maximum is {maximum[0]}x{maximum[1]}")
|
||||||
|
aspect = profile.get("aspect")
|
||||||
|
actual_aspect = width / height
|
||||||
|
if aspect and abs(actual_aspect - aspect) / aspect > 0.12:
|
||||||
|
errors.append(
|
||||||
|
f"wrong aspect ratio: {actual_aspect:.3f}; expected about {aspect:.3f}")
|
||||||
|
if profile.get("alpha") is True and not alpha:
|
||||||
|
errors.append("transparency required but image is opaque")
|
||||||
|
elif profile.get("alpha") is False and alpha:
|
||||||
|
warnings.append(
|
||||||
|
"image has an alpha channel although this role is normally opaque")
|
||||||
|
if profile.get("power_of_two") and (width & (width - 1) or height & (height - 1)):
|
||||||
|
warnings.append("dimensions are not powers of two")
|
||||||
|
edge_difference = None
|
||||||
|
if profile.get("seamless"):
|
||||||
|
edge_difference = _mean_edge_difference(image)
|
||||||
|
if edge_difference > seam_threshold:
|
||||||
|
errors.append(
|
||||||
|
f"tile edges differ by {edge_difference:.1f} (limit {seam_threshold:.1f})")
|
||||||
|
manual_review = [
|
||||||
|
f"Confirm {profile.get('view', 'intended')} perspective/projection visually; "
|
||||||
|
"automated checks cannot infer it reliably."
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"path": str(path), "role": chosen_role, "size": [width, height],
|
||||||
|
"alpha": alpha, "edge_difference": edge_difference,
|
||||||
|
"quality": "bad" if errors else "good",
|
||||||
|
"errors": errors, "warnings": warnings, "manual_review": manual_review,
|
||||||
|
}
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
return {
|
||||||
|
"path": str(path), "role": chosen_role, "size": None, "alpha": None,
|
||||||
|
"edge_difference": None, "quality": "bad", "errors": [f"cannot read image: {exc}"],
|
||||||
|
"warnings": [], "manual_review": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def image_paths(values):
|
||||||
|
found = []
|
||||||
|
for value in values:
|
||||||
|
path = resolve_path(value)
|
||||||
|
if path.is_dir():
|
||||||
|
found.extend(item for item in path.rglob(
|
||||||
|
"*") if item.is_file() and item.suffix.lower() in IMAGE_EXTENSIONS)
|
||||||
|
elif path.suffix.lower() in IMAGE_EXTENSIONS:
|
||||||
|
found.append(path)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"not an image file or directory: {path}")
|
||||||
|
return sorted(set(found))
|
||||||
|
|
||||||
|
|
||||||
|
def _generate(args):
|
||||||
|
profile = PROFILES[args.role]
|
||||||
|
if (args.width is None) != (args.height is None):
|
||||||
|
raise ValueError("--width and --height must be supplied together")
|
||||||
|
width, height = (
|
||||||
|
args.width, args.height) if args.width else profile["target"]
|
||||||
|
transparent = args.transparent if args.transparent is not None else profile.get(
|
||||||
|
"alpha") is True
|
||||||
|
prompt = f"{args.prompt.strip()}. {ROLE_GUIDANCE[args.role]}."
|
||||||
|
image = generate_textures.generate_image(
|
||||||
|
prompt, args.model, args.request_size, transparent)
|
||||||
|
if args.role == "icon" and has_alpha(image):
|
||||||
|
image = resize_image_from_image(
|
||||||
|
image, width, height, "contain", (0, 0, 0, 0))
|
||||||
|
else:
|
||||||
|
image = ImageOps.fit(image, (width, height),
|
||||||
|
method=Image.Resampling.LANCZOS)
|
||||||
|
save_image(image, resolve_path(args.output))
|
||||||
|
|
||||||
|
|
||||||
|
def resize_image_from_image(image, width, height, fit, background):
|
||||||
|
if fit == "cover":
|
||||||
|
return ImageOps.fit(image, (width, height), method=Image.Resampling.LANCZOS)
|
||||||
|
contained = ImageOps.contain(
|
||||||
|
image, (width, height), method=Image.Resampling.LANCZOS)
|
||||||
|
mode = "RGBA" if has_alpha(image) or background[3] < 255 else "RGB"
|
||||||
|
result = Image.new(mode, (width, height),
|
||||||
|
background if mode == "RGBA" else background[:3])
|
||||||
|
contained = contained.convert(mode)
|
||||||
|
offset = ((width - contained.width) // 2, (height - contained.height) // 2)
|
||||||
|
if mode == "RGBA":
|
||||||
|
result.alpha_composite(contained, offset)
|
||||||
|
else:
|
||||||
|
result.paste(contained, offset)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Create and validate game image assets.")
|
||||||
|
commands = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
generate = commands.add_parser(
|
||||||
|
"generate", help="generate an image using the existing OpenRouter backend")
|
||||||
|
generate.add_argument("--prompt", required=True)
|
||||||
|
generate.add_argument("--output", required=True,
|
||||||
|
help="output path, relative to the project root or absolute")
|
||||||
|
generate.add_argument("--role", choices=PROFILES, required=True)
|
||||||
|
generate.add_argument(
|
||||||
|
"--model", default=generate_textures.DEFAULT_IMAGE_MODEL)
|
||||||
|
generate.add_argument("--request-size", type=int,
|
||||||
|
default=generate_textures.GEN_SIZE)
|
||||||
|
generate.add_argument("--width", type=int)
|
||||||
|
generate.add_argument("--height", type=int)
|
||||||
|
alpha = generate.add_mutually_exclusive_group()
|
||||||
|
alpha.add_argument("--transparent", dest="transparent",
|
||||||
|
action="store_true")
|
||||||
|
alpha.add_argument("--opaque", dest="transparent", action="store_false")
|
||||||
|
generate.set_defaults(transparent=None)
|
||||||
|
generate.set_defaults(handler=_generate)
|
||||||
|
|
||||||
|
crop = commands.add_parser(
|
||||||
|
"crop", help="crop an exact-size region; defaults to the image center")
|
||||||
|
crop.add_argument("input")
|
||||||
|
crop.add_argument("output")
|
||||||
|
crop.add_argument("--width", type=int, required=True)
|
||||||
|
crop.add_argument("--height", type=int, required=True)
|
||||||
|
crop.add_argument("--x", type=int)
|
||||||
|
crop.add_argument("--y", type=int)
|
||||||
|
crop.set_defaults(handler=lambda args: crop_image(resolve_path(args.input), resolve_path(args.output),
|
||||||
|
args.width, args.height, args.x, args.y))
|
||||||
|
|
||||||
|
resize = commands.add_parser(
|
||||||
|
"resize", help="resize to exact dimensions while preserving aspect by default")
|
||||||
|
resize.add_argument("input")
|
||||||
|
resize.add_argument("output")
|
||||||
|
resize.add_argument("--width", type=int, required=True)
|
||||||
|
resize.add_argument("--height", type=int, required=True)
|
||||||
|
resize.add_argument("--fit", choices=("contain",
|
||||||
|
"cover", "stretch"), default="contain")
|
||||||
|
resize.add_argument("--background", type=parse_color, default=(0, 0, 0, 0))
|
||||||
|
resize.set_defaults(handler=lambda args: resize_image(resolve_path(args.input), resolve_path(args.output),
|
||||||
|
args.width, args.height, args.fit, args.background))
|
||||||
|
|
||||||
|
audit = commands.add_parser(
|
||||||
|
"audit", help="classify images as good/bad using their game-asset role")
|
||||||
|
audit.add_argument("paths", nargs="+", help="image files or directories")
|
||||||
|
audit.add_argument("--role", choices=("auto", *PROFILES), default="auto")
|
||||||
|
audit.add_argument("--seam-threshold", type=float, default=18.0)
|
||||||
|
audit.add_argument("--json", action="store_true",
|
||||||
|
help="emit machine-readable JSON")
|
||||||
|
audit.set_defaults(handler=_audit)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def _audit(args):
|
||||||
|
results = [audit_image(path, args.role, args.seam_threshold)
|
||||||
|
for path in image_paths(args.paths)]
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(results, indent=2))
|
||||||
|
else:
|
||||||
|
good_count = sum(result["quality"] == "good" for result in results)
|
||||||
|
for result in results:
|
||||||
|
size = "unreadable" if result["size"] is None else "x".join(
|
||||||
|
map(str, result["size"]))
|
||||||
|
print(
|
||||||
|
f"{result['quality'].upper():4} {result['role']:10} {size:12} {result['path']}")
|
||||||
|
for message in result["errors"] + result["warnings"]:
|
||||||
|
print(f" - {message}")
|
||||||
|
for message in result["manual_review"]:
|
||||||
|
print(f" - review: {message}")
|
||||||
|
print(f"Summary: {good_count} good, {len(results) - good_count} bad")
|
||||||
|
return 1 if any(result["quality"] == "bad" for result in results) else 0
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
try:
|
||||||
|
result = args.handler(args)
|
||||||
|
return result if isinstance(result, int) else 0
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -75,6 +155,7 @@ def brick_facade():
|
|||||||
d.rectangle([0, y, size, y + mortar_t], fill=mortar)
|
d.rectangle([0, y, size, y + mortar_t], fill=mortar)
|
||||||
save(img, "brick_facade.png")
|
save(img, "brick_facade.png")
|
||||||
|
|
||||||
|
|
||||||
def seamless_concrete():
|
def seamless_concrete():
|
||||||
size = 512
|
size = 512
|
||||||
img = grain(size, 150, 26, 7, blur=2.5).convert("RGBA")
|
img = grain(size, 150, 26, 7, blur=2.5).convert("RGBA")
|
||||||
@@ -83,7 +164,8 @@ def seamless_concrete():
|
|||||||
for _ in range(90):
|
for _ in range(90):
|
||||||
x, y = rnd.randint(0, size), rnd.randint(0, size)
|
x, y = rnd.randint(0, size), rnd.randint(0, size)
|
||||||
r = rnd.choice([2, 3, 4])
|
r = rnd.choice([2, 3, 4])
|
||||||
d.ellipse([x - r, y - r, x + r, y + r], fill=(0, 0, 0, rnd.randint(8, 22)))
|
d.ellipse([x - r, y - r, x + r, y + r],
|
||||||
|
fill=(0, 0, 0, rnd.randint(8, 22)))
|
||||||
for _ in range(8): # subtle crack segments
|
for _ in range(8): # subtle crack segments
|
||||||
x, y = rnd.randint(0, size), rnd.randint(0, size)
|
x, y = rnd.randint(0, size), rnd.randint(0, size)
|
||||||
pts = [(x, y)]
|
pts = [(x, y)]
|
||||||
@@ -94,6 +176,7 @@ def seamless_concrete():
|
|||||||
d.line(pts, fill=(70, 70, 70, 170), width=1)
|
d.line(pts, fill=(70, 70, 70, 170), width=1)
|
||||||
save(img, "concrete.png")
|
save(img, "concrete.png")
|
||||||
|
|
||||||
|
|
||||||
def asphalt():
|
def asphalt():
|
||||||
size = 512
|
size = 512
|
||||||
img = grain(size, 52, 14, 23, blur=1.2).convert("RGBA")
|
img = grain(size, 52, 14, 23, blur=1.2).convert("RGBA")
|
||||||
@@ -101,9 +184,11 @@ def asphalt():
|
|||||||
rnd = random.Random(31)
|
rnd = random.Random(31)
|
||||||
for _ in range(600):
|
for _ in range(600):
|
||||||
x, y = rnd.randint(0, size), rnd.randint(0, size)
|
x, y = rnd.randint(0, size), rnd.randint(0, size)
|
||||||
d.point((x, y), fill=(max(0, 40 - rnd.randint(0, 18)),) * 3 + (rnd.randint(40, 110),))
|
d.point((x, y), fill=(max(0, 40 - rnd.randint(0, 18)),)
|
||||||
|
* 3 + (rnd.randint(40, 110),))
|
||||||
save(img, "asphalt.png")
|
save(img, "asphalt.png")
|
||||||
|
|
||||||
|
|
||||||
def awning():
|
def awning():
|
||||||
w, h = 1024, 256
|
w, h = 1024, 256
|
||||||
img = Image.new("RGB", (w, h), (140, 40, 45))
|
img = Image.new("RGB", (w, h), (140, 40, 45))
|
||||||
@@ -111,7 +196,8 @@ def awning():
|
|||||||
stripe = 64
|
stripe = 64
|
||||||
for i in range(w // stripe):
|
for i in range(w // stripe):
|
||||||
if i % 2 == 1:
|
if i % 2 == 1:
|
||||||
d.rectangle([i * stripe, 0, (i + 1) * stripe - 2, h], fill=(230, 225, 220))
|
d.rectangle([i * stripe, 0, (i + 1) * stripe - 2, h],
|
||||||
|
fill=(230, 225, 220))
|
||||||
# scalloped bottom edge
|
# scalloped bottom edge
|
||||||
for x in range(0, w, 32):
|
for x in range(0, w, 32):
|
||||||
d.arc([x, h - 24, x + 32, h + 40], 0, 180, fill=(80, 20, 24), width=6)
|
d.arc([x, h - 24, x + 32, h + 40], 0, 180, fill=(80, 20, 24), width=6)
|
||||||
@@ -125,6 +211,7 @@ def awning():
|
|||||||
img = Image.alpha_composite(img.convert("RGBA"), shade)
|
img = Image.alpha_composite(img.convert("RGBA"), shade)
|
||||||
save(img, "awning.png")
|
save(img, "awning.png")
|
||||||
|
|
||||||
|
|
||||||
def skyline():
|
def skyline():
|
||||||
w, h = 1024, 256
|
w, h = 1024, 256
|
||||||
img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
||||||
@@ -141,11 +228,13 @@ def skyline():
|
|||||||
wx = x + rnd.randint(4, max(4, bw - 8))
|
wx = x + rnd.randint(4, max(4, bw - 8))
|
||||||
wy = h - rnd.randint(4, bh - 4)
|
wy = h - rnd.randint(4, bh - 4)
|
||||||
if rnd.random() < 0.5:
|
if rnd.random() < 0.5:
|
||||||
d.rectangle([wx, wy, wx + 6, wy + 8], fill=(255, 200, 120, 220))
|
d.rectangle([wx, wy, wx + 6, wy + 8],
|
||||||
|
fill=(255, 200, 120, 220))
|
||||||
x += bw + rnd.randint(10, 46)
|
x += bw + rnd.randint(10, 46)
|
||||||
d.rectangle([0, h - 6, w, h], fill=(12, 13, 22, 255))
|
d.rectangle([0, h - 6, w, h], fill=(12, 13, 22, 255))
|
||||||
save(img, "skyline.png")
|
save(img, "skyline.png")
|
||||||
|
|
||||||
|
|
||||||
def vignette():
|
def vignette():
|
||||||
size = 512
|
size = 512
|
||||||
img = Image.new("L", (size, size), 0)
|
img = Image.new("L", (size, size), 0)
|
||||||
@@ -159,52 +248,115 @@ def vignette():
|
|||||||
px[x, y] = int(255 * (t * t))
|
px[x, y] = int(255 * (t * t))
|
||||||
save(img, "vignette.png")
|
save(img, "vignette.png")
|
||||||
|
|
||||||
def icon(name, draw_fn):
|
|
||||||
size = 64
|
def icon(name, draw_fn, size=64):
|
||||||
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||||
d = ImageDraw.Draw(img)
|
d = ImageDraw.Draw(img)
|
||||||
draw_fn(d, size)
|
draw_fn(d, size)
|
||||||
save(img, name)
|
save(img, name)
|
||||||
|
|
||||||
|
|
||||||
def icons():
|
def icons():
|
||||||
def flashlight_on(d, s):
|
def flashlight_on(d, s):
|
||||||
d.rounded_rectangle([s*0.30, s*0.30, s*0.62, s*0.62], radius=4, fill=(255,255,255,255))
|
d.rounded_rectangle([s*0.30, s*0.30, s*0.62, s*0.62],
|
||||||
d.polygon([(s*0.62, s*0.30), (s*0.82, s*0.18), (s*0.82, s*0.74), (s*0.62, s*0.62)], fill=(255,214,120,255))
|
radius=4, fill=(255, 255, 255, 255))
|
||||||
|
d.polygon([(s*0.62, s*0.30), (s*0.82, s*0.18), (s*0.82, s *
|
||||||
|
0.74), (s*0.62, s*0.62)], fill=(255, 214, 120, 255))
|
||||||
|
|
||||||
def door_locked(d, s):
|
def door_locked(d, s):
|
||||||
d.rectangle([s*0.18, s*0.18, s*0.82, s*0.82], outline=(255,255,255,255), width=5)
|
d.rectangle([s*0.18, s*0.18, s*0.82, s*0.82],
|
||||||
d.rounded_rectangle([s*0.36, s*0.34, s*0.64, s*0.66], radius=3, fill=(255,255,255,255))
|
outline=(255, 255, 255, 255), width=5)
|
||||||
d.arc([s*0.40, s*0.28, s*0.60, s*0.50], 180, 360, fill=(255,255,255,255), width=5)
|
d.rounded_rectangle([s*0.36, s*0.34, s*0.64, s*0.66],
|
||||||
|
radius=3, fill=(255, 255, 255, 255))
|
||||||
|
d.arc([s*0.40, s*0.28, s*0.60, s*0.50], 180,
|
||||||
|
360, fill=(255, 255, 255, 255), width=5)
|
||||||
|
|
||||||
def wand(d, s):
|
def wand(d, s):
|
||||||
d.rounded_rectangle([s*0.30, s*0.10, s*0.44, s*0.90], radius=3, fill=(255,255,255,255))
|
d.rounded_rectangle([s*0.30, s*0.10, s*0.44, s*0.90],
|
||||||
d.ellipse([s*0.24, s*0.06, s*0.50, s*0.32], fill=(120,220,160,255))
|
radius=3, fill=(255, 255, 255, 255))
|
||||||
|
d.ellipse([s*0.24, s*0.06, s*0.50, s*0.32], fill=(120, 220, 160, 255))
|
||||||
|
|
||||||
def rec(d, s):
|
def rec(d, s):
|
||||||
d.ellipse([s*0.14, s*0.14, s*0.86, s*0.86], outline=(255,255,255,255), width=5)
|
d.ellipse([s*0.14, s*0.14, s*0.86, s*0.86],
|
||||||
|
outline=(255, 255, 255, 255), width=5)
|
||||||
d.ellipse([s*0.30, s*0.30, s*0.70, s*0.70], fill=(230, 60, 60, 255))
|
d.ellipse([s*0.30, s*0.30, s*0.70, s*0.70], fill=(230, 60, 60, 255))
|
||||||
icon("icons/flashlight_on.png", flashlight_on)
|
icon("icons/flashlight_on.png", flashlight_on)
|
||||||
icon("icons/door_locked.png", door_locked)
|
icon("icons/door_locked.png", door_locked)
|
||||||
icon("icons/wand.png", wand)
|
icon("icons/wand.png", wand)
|
||||||
icon("icons/rec_dot.png", rec)
|
icon("icons/rec_dot.png", rec)
|
||||||
|
|
||||||
|
|
||||||
|
def app_icon():
|
||||||
|
"""Game app icon: dark badge, gold star, white 'S' — used by desktop/AppImage
|
||||||
|
(PNG) and the Windows installer (ICO)."""
|
||||||
|
def badge(d, s):
|
||||||
|
d.ellipse([2, 2, s-2, s-2], fill=(24, 26, 38, 255))
|
||||||
|
d.ellipse([int(s*0.10), int(s*0.10), int(s*0.90), int(s*0.90)],
|
||||||
|
outline=(120, 130, 170, 255), width=max(2, s//64))
|
||||||
|
# gold star
|
||||||
|
cx, cy, r = s*0.5, s*0.44, s*0.30
|
||||||
|
pts = []
|
||||||
|
for i in range(10):
|
||||||
|
ang = -90 + i * 36
|
||||||
|
rad = r if i % 2 == 0 else r * 0.42
|
||||||
|
import math
|
||||||
|
pts.append((cx + rad * math.cos(math.radians(ang)),
|
||||||
|
cy + rad * math.sin(math.radians(ang))))
|
||||||
|
d.polygon(pts, fill=(255, 205, 80, 255))
|
||||||
|
icon("icon.png", badge, size=1024)
|
||||||
|
icon("icon.ico", badge, size=256)
|
||||||
|
|
||||||
|
# ICO for Windows. Must be BMP-entry based: PIL's ICO serializer emits
|
||||||
|
# PNG-compressed entries which NSIS' icon loader rejects. ImageMagick
|
||||||
|
# writes classic BMP entries; fall back to PIL only if ImageMagick is gone
|
||||||
|
# (flag it so a human notices).
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
ico_path = os.path.join(ASSETS, "icon.ico")
|
||||||
|
src_png = os.path.join(ASSETS, "icon.png")
|
||||||
|
ran = False
|
||||||
|
for cmd in (
|
||||||
|
["magick", src_png, "-background", "none", "-define",
|
||||||
|
"icon:auto-resize=48,32,16", ico_path],
|
||||||
|
["convert", src_png, "-background", "none",
|
||||||
|
"-define", "icon:auto-resize=48,32,16", ico_path],
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, check=True, capture_output=True)
|
||||||
|
ran = True
|
||||||
|
break
|
||||||
|
except (FileNotFoundError, subprocess.CalledProcessError):
|
||||||
|
continue
|
||||||
|
if ran:
|
||||||
|
print("wrote assets/textures/icon.ico (BMP 16/32/48 via ImageMagick)")
|
||||||
|
else:
|
||||||
|
print("WARNING: ImageMagick not found; writing PIL ICO (may be PNG-compressed)")
|
||||||
|
Image.open(src_png).convert("RGBA").save(
|
||||||
|
ico_path, format="ICO", sizes=[(48, 48), (32, 32), (16, 16)])
|
||||||
|
|
||||||
|
|
||||||
def cards():
|
def cards():
|
||||||
CW, CH = 256, 128
|
CW, CH = 256, 128
|
||||||
def base(card_name, bg, label, band=(0,0,0,0)):
|
|
||||||
|
def base(card_name, bg, label, band=(0, 0, 0, 0)):
|
||||||
img = Image.new("RGBA", (CW, CH), bg + (255,))
|
img = Image.new("RGBA", (CW, CH), bg + (255,))
|
||||||
d = ImageDraw.Draw(img)
|
d = ImageDraw.Draw(img)
|
||||||
d.rounded_rectangle([6, 6, CW-6, CH-6], radius=10, outline=(0,0,0,90), width=2)
|
d.rounded_rectangle([6, 6, CW-6, CH-6], radius=10,
|
||||||
|
outline=(0, 0, 0, 90), width=2)
|
||||||
if band and band[3] == 255:
|
if band and band[3] == 255:
|
||||||
d.rectangle([0, CH-34, CW, CH], fill=band)
|
d.rectangle([0, CH-34, CW, CH], fill=band)
|
||||||
f = font(26)
|
f = font(26)
|
||||||
d.text((14, 10), label, font=f, fill=(255,255,255,235))
|
d.text((14, 10), label, font=f, fill=(255, 255, 255, 235))
|
||||||
# body text lines
|
# body text lines
|
||||||
f2 = font(15)
|
f2 = font(15)
|
||||||
y = 52
|
y = 52
|
||||||
for _ in range(4):
|
for _ in range(4):
|
||||||
w = 150 - (24 if _ == 3 else 0)
|
w = 150 - (24 if _ == 3 else 0)
|
||||||
d.rectangle([16, y, 16 + w, y + 8], fill=(255,255,255,110))
|
d.rectangle([16, y, 16 + w, y + 8], fill=(255, 255, 255, 110))
|
||||||
y += 24
|
y += 24
|
||||||
# photo square for id-like cards
|
# photo square for id-like cards
|
||||||
if "photo" in card_name:
|
if "photo" in card_name:
|
||||||
d.rectangle([CW-84, 14, CW-18, 96], fill=(180,190,205,255))
|
d.rectangle([CW-84, 14, CW-18, 96], fill=(180, 190, 205, 255))
|
||||||
save(img, f"cards/{card_name}.png")
|
save(img, f"cards/{card_name}.png")
|
||||||
base("id_card", (46, 92, 168), "CA DRIVER LICENSE", band=(20, 36, 70, 255), )
|
base("id_card", (46, 92, 168), "CA DRIVER LICENSE", band=(20, 36, 70, 255), )
|
||||||
base("wristband", (196, 62, 78), "EVENT ACCESS", band=(150, 35, 50, 255))
|
base("wristband", (196, 62, 78), "EVENT ACCESS", band=(150, 35, 50, 255))
|
||||||
@@ -212,7 +364,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 +497,6 @@ if __name__ == "__main__":
|
|||||||
skyline()
|
skyline()
|
||||||
vignette()
|
vignette()
|
||||||
icons()
|
icons()
|
||||||
|
app_icon()
|
||||||
cards()
|
cards()
|
||||||
print("done")
|
print("done")
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
# Publish a release to the Gitea/Forgejo host, uploading the built artifacts.
|
# Publish a release to the Gitea/Forgejo host, uploading the built artifacts.
|
||||||
#
|
#
|
||||||
# Requires:
|
# Requires:
|
||||||
# - GITEA_TOKEN (a token with write access to the repo)
|
# - GITEA_TOKEN (token with write access) OR credentials usable for basic auth
|
||||||
# - build artifacts from tools/build_releases.sh (or already exported)
|
# (e.g. the same ones stored in ~/.git-credentials by `git push`)
|
||||||
|
# - build artifacts from tools/build_releases.sh (+ optional installer/AppImage)
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# GITEA_TOKEN=xxx ./tools/publish_release.sh [version]
|
# GITEA_TOKEN=xxx ./tools/publish_release.sh [version]
|
||||||
@@ -12,45 +13,69 @@ set -euo pipefail
|
|||||||
|
|
||||||
REPO="${REPO:-zwitschi/security-game}"
|
REPO="${REPO:-zwitschi/security-game}"
|
||||||
HOST="${HOST:-https://git.allucanget.biz}"
|
HOST="${HOST:-https://git.allucanget.biz}"
|
||||||
API="$HOST/api/v1/repos/$REPO/releases"
|
|
||||||
VERSION="${1:-$(grep -oP 'config/version="\K[^"]+' project.godot)}"
|
VERSION="${1:-$(grep -oP 'config/version="\K[^"]+' project.godot)}"
|
||||||
TAG="v$VERSION"
|
TAG="v$VERSION"
|
||||||
|
|
||||||
if [[ -z "${GITEA_TOKEN:-}" ]]; then
|
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
||||||
echo "error: GITEA_TOKEN is not set (see docs/RELEASING.md)" >&2
|
AUTH=("Authorization: token $GITEA_TOKEN")
|
||||||
|
elif [[ -f "$HOME/.git-credentials" ]]; then
|
||||||
|
CRED="$(python3 -c "
|
||||||
|
import os
|
||||||
|
for l in open(os.path.expanduser('~/.git-credentials')):
|
||||||
|
if '$HOST'.split('//')[1] in l:
|
||||||
|
print(l.split('://',1)[1].split('@')[0]); break")"
|
||||||
|
if [[ -z "$CRED" ]]; then
|
||||||
|
echo "error: no credentials for $HOST in ~/.git-credentials" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "error: set GITEA_TOKEN or provide ~/.git-credentials" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Artifacts must already exist (run tools/build_releases.sh first).
|
# Artifacts that must exist (run tools/build_releases.sh first).
|
||||||
printf -v BS 'build/step-line-linux-%s.x86_64' "$VERSION"
|
portables=(
|
||||||
printf -v BS_WIN 'build/step-line-windows-%s.exe' "$VERSION"
|
"build/step-line-linux-$VERSION.x86_64"
|
||||||
printf -v BS_MAC 'build/step-line-macos-%s.zip' "$VERSION"
|
"build/step-line-windows-$VERSION.exe"
|
||||||
for f in "$BS" "$BS_WIN" "$BS_MAC"; do
|
"build/step-line-macos-$VERSION.zip"
|
||||||
if [[ ! -f "$f" ]]; then
|
)
|
||||||
echo "error: missing artifact $f — run ./tools/build_releases.sh first" >&2
|
extras=(
|
||||||
exit 1
|
"build/step-line-setup-$VERSION.exe"
|
||||||
|
"build/step-line-$VERSION-x86_64.AppImage"
|
||||||
|
)
|
||||||
|
all_artifacts=("${portables[@]}")
|
||||||
|
for f in "${extras[@]}"; do
|
||||||
|
if [[ -f "$f" ]]; then
|
||||||
|
all_artifacts+=("$f")
|
||||||
|
else
|
||||||
|
echo "note: optional artifact missing (skipping): $f"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
API="$HOST/api/v1/repos/$REPO/releases"
|
||||||
echo "==> Creating release $TAG on $HOST/$REPO"
|
echo "==> Creating release $TAG on $HOST/$REPO"
|
||||||
RELEASE_JSON="$(curl -s -X POST "$API" \
|
RELEASE_ID="$(curl -s -X POST "$API" "${AUTH[@]}" \
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Step Line $VERSION — playable prototype. See README for controls.\",\"draft\":false,\"prerelease\":false}")"
|
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Step Line $VERSION — playable prototype. See README for controls.\",\"draft\":false,\"prerelease\":false}" \
|
||||||
|
| python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)"
|
||||||
|
|
||||||
RELEASE_ID="$(echo "$RELEASE_JSON" | python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)"
|
# If the release already exists (tag already pushed), reuse it.
|
||||||
if [[ -z "$RELEASE_ID" ]]; then
|
if [[ -z "$RELEASE_ID" ]]; then
|
||||||
echo "error: could not create release; response:" >&2
|
RELEASE_ID="$(curl -s "${AUTH[@]}" "$API/tags/$TAG" | python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)"
|
||||||
echo "$RELEASE_JSON" >&2
|
fi
|
||||||
|
if [[ -z "$RELEASE_ID" ]]; then
|
||||||
|
echo "error: could not create or find release $TAG" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Release id: $RELEASE_ID"
|
echo "Release id: $RELEASE_ID"
|
||||||
|
|
||||||
for f in "$BS" "$BS_WIN" "$BS_MAC"; do
|
for f in "${all_artifacts[@]}"; do
|
||||||
|
if [[ ! -f "$f" ]]; then
|
||||||
|
echo "error: missing artifact $f — run ./tools/build_releases.sh first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
echo "==> Uploading $(basename "$f")"
|
echo "==> Uploading $(basename "$f")"
|
||||||
curl -s -X POST "$API/$RELEASE_ID/assets" \
|
curl -s -X POST "$API/$RELEASE_ID/assets" "${AUTH[@]}" -F "attachment=@$f" >/dev/null
|
||||||
-H "Authorization: token $GITEA_TOKEN" \
|
|
||||||
-F "attachment=@$f" >/dev/null
|
|
||||||
done
|
done
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
import asset_pipeline
|
||||||
|
|
||||||
|
|
||||||
|
class AssetPipelineTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temp_dir.name)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
def test_crop_uses_center_and_exact_dimensions(self):
|
||||||
|
source = self.root / "source.png"
|
||||||
|
output = self.root / "crop.png"
|
||||||
|
Image.new("RGB", (80, 60), (20, 40, 60)).save(source)
|
||||||
|
|
||||||
|
asset_pipeline.crop_image(source, output, 32, 24)
|
||||||
|
|
||||||
|
with Image.open(output) as image:
|
||||||
|
self.assertEqual(image.size, (32, 24))
|
||||||
|
|
||||||
|
def test_resize_contain_preserves_aspect_and_exact_canvas(self):
|
||||||
|
source = self.root / "source.png"
|
||||||
|
output = self.root / "resized.png"
|
||||||
|
Image.new("RGB", (80, 40), (20, 40, 60)).save(source)
|
||||||
|
|
||||||
|
asset_pipeline.resize_image(source, output, 64, 64, "contain")
|
||||||
|
|
||||||
|
with Image.open(output) as image:
|
||||||
|
self.assertEqual(image.size, (64, 64))
|
||||||
|
self.assertEqual(image.getpixel((32, 0)), (0, 0, 0, 0))
|
||||||
|
|
||||||
|
def test_texture_audit_accepts_valid_tile_and_rejects_bad_seams(self):
|
||||||
|
good = self.root / "good.png"
|
||||||
|
bad = self.root / "bad.png"
|
||||||
|
Image.new("RGB", (512, 512), (80, 80, 80)).save(good)
|
||||||
|
mismatch = Image.new("RGB", (512, 512), (80, 80, 80))
|
||||||
|
for y in range(512):
|
||||||
|
mismatch.putpixel((0, y), (255, 0, 0))
|
||||||
|
mismatch.save(bad)
|
||||||
|
|
||||||
|
self.assertEqual(asset_pipeline.audit_image(
|
||||||
|
good, "tile")["quality"], "good")
|
||||||
|
self.assertEqual(asset_pipeline.audit_image(
|
||||||
|
bad, "tile")["quality"], "bad")
|
||||||
|
|
||||||
|
def test_background_audit_rejects_small_image(self):
|
||||||
|
image_path = self.root / "small.png"
|
||||||
|
Image.new("RGB", (640, 360)).save(image_path)
|
||||||
|
|
||||||
|
result = asset_pipeline.audit_image(image_path, "background")
|
||||||
|
|
||||||
|
self.assertEqual(result["quality"], "bad")
|
||||||
|
self.assertTrue(
|
||||||
|
any("too small" in error for error in result["errors"]))
|
||||||
|
|
||||||
|
def test_grayscale_vignette_is_valid_without_alpha(self):
|
||||||
|
image_path = self.root / "vignette.png"
|
||||||
|
Image.new("L", (512, 512), 0).save(image_path)
|
||||||
|
|
||||||
|
self.assertEqual(asset_pipeline.audit_image(
|
||||||
|
image_path, "overlay")["quality"], "good")
|
||||||
|
|
||||||
|
def test_icon_audit_rejects_opaque_rgba_images(self):
|
||||||
|
image_path = self.root / "opaque_icon.png"
|
||||||
|
Image.new("RGBA", (64, 64), (255, 255, 255, 255)).save(image_path)
|
||||||
|
|
||||||
|
result = asset_pipeline.audit_image(image_path, "icon")
|
||||||
|
|
||||||
|
self.assertEqual(result["quality"], "bad")
|
||||||
|
self.assertTrue(any("transparency required" in error
|
||||||
|
for error in result["errors"]))
|
||||||
|
|
||||||
|
def test_generation_reuses_existing_backend_and_targets_role_dimensions(self):
|
||||||
|
output = self.root / "background.png"
|
||||||
|
args = type("Args", (), {
|
||||||
|
"prompt": "Hollywood at night", "role": "background", "output": str(output),
|
||||||
|
"width": 1920, "height": 1080, "transparent": None,
|
||||||
|
"model": "test-model", "request_size": 2048,
|
||||||
|
})()
|
||||||
|
generated = Image.new("RGB", (256, 256), (20, 40, 60))
|
||||||
|
|
||||||
|
with patch.object(asset_pipeline.generate_textures, "generate_image",
|
||||||
|
return_value=generated) as backend:
|
||||||
|
asset_pipeline._generate(args)
|
||||||
|
|
||||||
|
self.assertIn("Hollywood at night", backend.call_args.args[0])
|
||||||
|
self.assertIn("wide 16:9", backend.call_args.args[0])
|
||||||
|
with Image.open(output) as image:
|
||||||
|
self.assertEqual(image.size, (1920, 1080))
|
||||||
|
|
||||||
|
def test_role_inference_uses_game_asset_paths(self):
|
||||||
|
self.assertEqual(asset_pipeline.infer_role(
|
||||||
|
Path("assets/textures/icons/wand.png")), "icon")
|
||||||
|
self.assertEqual(asset_pipeline.infer_role(
|
||||||
|
Path("assets/textures/cards/ticket.png")), "card")
|
||||||
|
self.assertEqual(asset_pipeline.infer_role(
|
||||||
|
Path("assets/textures/skyline.png")), "backdrop")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||