Compare commits

..
20 Commits
Author SHA1 Message Date
zwitschi 0f076205af Fix NSIS installer build: absolute paths via defines, space-free installed exe, BMP ICO
Full build / Import + headless tests (push) Successful in 31s
Full build / Full desktop build + optional release (push) Successful in 3m44s
- makensis resolves relative -DICON/-DARTIFACT against the script directory,
  so CI now passes $PWD-based absolute paths (reproduced locally in the same
  ubuntu:24.04 container; installer builds, 7z shows step-line.exe +
  uninstall.exe)
- Install as step-line.exe (no spaces: NSIS /oname splits on whitespace)
- OutFile overridable via -DOUT
- tools/generate_textures.py icon.ico now produced via ImageMagick with
  classic BMP entries (NSIS-compatible); PIL PNG-compressed ICOs rejected by
  NSIS
2026-10-31 14:22:55 +01:00
zwitschi 8bc84c1f36 Harden installer icon + NSIS rebuild path
Full build / Full desktop build + optional release (push) Failing after 3m24s
Full build / Import + headless tests (push) Successful in 27s
- Regenerate icon.ico with classic BMP entries (16/32/48) — PNG-compressed
  ICO entries can fail NSIS' icon loader on some builds
- CI copies assets/textures/icon.ico onto the packaging path right before
  makensis, removing any stale/missing-copy failures
- Recreate v0.2.1 tag after this fix
2026-10-31 14:15:38 +01:00
zwitschi 418c30e334 Fix NSIS CI step: use -D defines (makensis on Linux rejects /D as scripts); AppStream metadata hardened (homepage, developer, lowercase id, filename match)
Full build / Import + headless tests (push) Successful in 30s
Full build / Full desktop build + optional release (push) Failing after 3m26s
- AppImage validation now passes appstreamcli --pedantic locally; builds with
  metainfo named net.stepline.game.appdata.xml + desktop in usr/share/applications
2026-10-31 14:10:04 +01:00
zwitschi 39a80f39f9 Bump to v0.2.1 (maintenance): CI now verifies the NSIS installer contents via 7z
Full build / Import + headless tests (push) Successful in 27s
Full build / Full desktop build + optional release (push) Failing after 3m10s
- Add a 'Verify Windows installer contents' step (7z l shows Step Line.exe +
  uninstall.exe) so the TODO verification items are proven by CI output
- Rebuild 0.2.1 binaries (embedded PCK) and AppImage locally; smoke clean;
  57/57 tests
2026-10-31 14:05:12 +01:00
zwitschi 5b994982bd Add OpenRouter AI texture generation mode to the asset pipeline
- tools/generate_textures.py: --ai mode (OPENROUTER_API_KEY, --prompt,
  --name, --model, --size; base64 PNG decode; clamp to <=1024px) with a
  default batch covering the authored texture set; seedream minimum-size
  handled by requesting 2048 and downscaling
- Generated AI asset set committed under assets/textures/ai/ (15 files,
  1024x1024 each), kept separate from the deterministic PIL set
- README/MEMORY/TODO document the mode and live verification
- (work authored in a parallel session; linted/verified here — 57/57 tests)
2026-10-31 14:04:37 +01:00
zwitschi 0abd67fbaa Ignore .env files 2026-10-31 13:56:20 +01:00
zwitschi 08b3292b80 Runtime hygiene: fix RefCounted cycles (clean suite exit), full-length shifts, wand cooldown
Full build / Import + headless tests (push) Successful in 18s
Full build / Full desktop build + optional release (push) Skipped
- FSM holds owner via WeakRef; ShiftCoordinator is RefCounted (logic-only);
  persistence tests free the Save node -> test suite exits with zero
  'resources still in use' warnings (was ~13) and 57/57 pass
- GameManager uses each scenario's shift_duration_s (900s ~ 10-15 min) for
  real shifts instead of the 300s test duration
- Wand screening gets a 0.8s recharge cooldown with a wand.recharge message
- TODO/DONE/MEMORY/README synced
2026-10-31 13:50:18 +01:00
zwitschi ba76c0febf Add Windows installer (NSIS) and Linux AppImage packaging; embed PCK in all exports
Full build / Import + headless tests (push) Successful in 17s
Full build / Full desktop build + optional release (push) Skipped
- export_presets: embed_pck=true for Linux/Windows/macOS -> single self-contained
  binaries (all game data in one file); build_releases.sh cleans stale .pck
- App icon: assets/textures/icon.png (1024) + multi-size icon.ico generated by
  tools/generate_textures.py; copied into packaging/windows and packaging/linux
- packaging/windows/step-line.nsi: NSIS installer (shortcuts, uninstaller,
  registry Add/Remove Programs) output step-line-setup-<ver>.exe
- packaging/linux: step-line.desktop, AppRun, step-line.appdata.xml,
  build_appimage.sh (appimagetool, APPIMAGE_EXTRACT_AND_RUN) ->
  step-line-<ver>-x86_64.AppImage
- CI build job now packages NSIS installer + AppImage alongside binaries;
  publish glob already uploads them
- Local end-to-end verification: embedded exports rebuilt, AppImage built and
  run headless (0 script errors); suite 57/57
- README Downloads table lists installer/AppImage/portable rows; RELEASING
  documents packaging; TODO/DONE synced
2026-10-31 13:46:54 +01:00
zwitschi 07d79ac115 Complete PRIORITY: release v0.2.0 published by CI; document download sign-in caveat
Full build / Import + headless tests (push) Successful in 17s
Full build / Full desktop build + optional release (push) Skipped
- Tag v0.2.0 pushed; Gitea Actions build job exported Linux/Windows/macOS
  and published the release via the host's GITEA_TOKEN secret (CI run 1095:
  'Release live: .../releases/tag/v0.2.0', Job succeeded)
- Verified tag page + /releases are public (HTTP 200); anonymous asset
  downloads require sign-in on the host (tracked as host-config follow-up)
- TODO cleaned per AGENTS.md; PRIORITY section moved to DONE.md
- README Downloads notes v0.2.0 live + sign-in caveat; MEMORY updated
2026-10-31 13:41:37 +01:00
zwitschi 4f274e0413 TODO cleanup per AGENTS.md; rebuild 0.2.0 binaries with the detailed-graphics pass baked in
Full build / Import + headless tests (push) Successful in 18s
Full build / Full desktop build + optional release (push) Successful in 3m17s
- TODO.md: drop fully-done sections (CI cache mitigation, Detailed Graphics
  completed items, CI packaging follow-up — all recorded in DONE.md); keep the
  PRIORITY release task (token-blocked), backlog, polish, and perf-only items
- Rebuild build/ artifacts so the shipped graphics include textures/rigs/HUD
- Linux binary smoke-tested (0 script errors); suite 57/57
2026-10-31 13:36:53 +01:00
zwitschi 031503e161 Detailed graphics pass: authored textures, humanoid NPCs, HUD feedback, asset pipeline
Full build / Import + headless tests (push) Successful in 16s
Full build / Full desktop build + optional release (push) Skipped
- tools/generate_textures.py (deterministic PIL) produces all authored PNGs:
  brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons,
  access-card art (all <=1024px, power-of-two)
- WorldBuilder: textured facade/glass door/sidewalk/asphalt, striped awning
  with 'STEP LINE', generated skyline backdrop planes
- GameManager: soft sun shadows (4-split parallel); guard in 'guard' group
- NPC avatars: humanoid rig (torso/head/limbs) with archetype props (camera,
  delivery box, performer hat, protest sign, tourist backpack), procedural
  walk bob, head-turns-toward-guard attention cue
- HUD: inspection artifact cards, mode icons (flashlight/door/wand),
  per-escalation vignette, body-camera overlay (corner brackets + REC + clock)
- assets/ import convention documented; test_asset_textures_present_and_pow2
  guards the pipeline (57 tests pass)
- README tech stack updated for Gitea Actions + asset pipeline; screenshot
  gallery regenerated (day + night)
2026-10-31 13:33:43 +01:00
zwitschi ac3bb7dcd8 Stop tracking build/ artifacts (binaries live in Gitea releases; CI/local rebuild via tools/build_releases.sh)
Full build / Import + headless tests (push) Successful in 16s
Full build / Full desktop build + optional release (push) Skipped
2026-10-31 13:24:44 +01:00
zwitschi 513f8a0f60 Visual polish pass (v0.2.0): phase lighting, street lamps, marquee, dressing, night screenshots
- Phase-based lighting: sun direction/energy/color, ProceduralSkyMaterial
  gradient, and ambient per shift phase (GameManager.PHASE_LIGHTING),
  applied on phase transitions and shift start
- WorldBuilder: street lamp poles with emissive bulbs + OmniLights,
  set_night_mode(), glowing STEP LINE marquee (Label3D + emissive bars),
  crosswalk stripes, planters, low-poly street trees
- NPC avatars: per-seed height/brightness variation (deterministic LCG)
- Denser routine traffic probabilities
- capture_screenshots.gd: fast-forward to LATE phase and capture night set;
  README gallery is now 2x3 day+night; docs/screenshots regenerated
- Version bumped to 0.2.0; build/ artifacts rebuilt; Linux binary smoke-tested
- New headless tests for phased lighting config and street dressing (56 total)
2026-10-31 13:24:40 +01:00
zwitschi 93883334d0 Add rare hidden radio easter egg transmission 2026-10-31 13:20:46 +01:00
zwitschi 94b7646dce Record CI cache mitigation results and ignore runner logs
- Mark cache-timeout fix verified (run 1090: zero CacheService lines,
  54/54 tests, job succeeded)
- Ignore .gitea/logs/ (runner writes logs into the checkout)
- Leave collaborator's radio-easter-egg changes in the working tree
  (strings.en.json, game_manager.gd) uncommitted for review
2026-10-31 13:16:34 +01:00
zwitschi 0b5cd772a5 Mitigate CI cache timeouts and wire venue door-staff dialogue
Full build / Import + headless tests (push) Successful in 33s
Full build / Full desktop build + optional release (push) Skipped
- Drop actions/cache@v4 from the checks job: the Gitea runner cannot reach
  GitHub's cache service (/twirp/...CacheService timeouts, ~2min retries);
  Godot import is fast enough without it. Add GODOT_SILENCE_ROOT_WARNING=1.
- Document the cache situation and re-enable path in docs/RELEASING.md
- VenueScenario.door_staff_line(context, rng): seeded-random venue lines
- GameManager logs id/ticket/wand-check, refusal, and prohibited-item lines
  from the venue scenario (Joke Shop flavor) during inspection/screening
- Test coverage: all Joke Shop contexts, unknown contexts, empty venues,
  deterministic seeded picks (54 tests pass)
2026-10-31 13:13:25 +01:00
zwitschi a3768c2ce4 Remove dialogue tone-of-voice system, add Gitea Actions full build, add venue cycling
Full build / Import + headless tests (push) Successful in 2m25s
Full build / Full desktop build + optional release (push) Skipped
- Delete Rules.Tone/TONE_NAMES, NpcProfile.tone_modifiers, tone params on
  NPCController.roll_reaction/apply_player_action; dialogue_lines flattened
  to Array[String]; HUD tone label, T binding, and hud.tone_prefix removed
- Docs updated everywhere (PLAN, README, DONE, MEMORY); PLAN section heading
  'Tone and Design Direction' -> 'Design Direction'
- Add .gitea/workflows/build.yml: checks job (import+tests) on push/PR; full
  build job (export templates, Linux/Windows/macOS exports, artifact upload,
  and tag-based Gitea release publish via GITEA_TOKEN) on tags/dispatch
- Remove old .github/workflows/ci.yml to avoid double runs
- Add venue cycling (V) across all three scenarios with hud.venue_switched
- Refresh README screenshots to match the new HUD
- 53/53 tests pass, boot smoke clean
2026-10-31 13:08:20 +01:00
zwitschi f7486d7539 Add README screenshots: staged in-game captures between description and Downloads
CI / headless-tests (push) Successful in 2m22s
- New capture tool src/test/capture_screenshots.gd (windowed, staged:
  venue overview, targeting an NPC, access inspection, radio report)
- Generated shots saved to docs/screenshots/ and embedded as a 2x2 gallery
  in README above the Downloads section
2026-10-31 13:01:40 +01:00
zwitschi eb1776e614 Update architecture docs and README with versioning, CI/CD, and release management details 2026-10-31 13:01:29 +01:00
zwitschi 191377fae2 Prepare first downloadable release: install export templates, build 0.1.0 binaries, add publish helper
- Install Godot 4.3 export templates locally and fix macOS export
  (enable rendering/textures/vram_compression/import_etc2_astc)
- Build Linux/Windows/macOS artifacts into build/ via tools/build_releases.sh;
  Linux binary passes headless smoke test
- Add tools/publish_release.sh (creates Gitea release + uploads assets with a token)
- TODO: mark build/install steps done; publishing v0.1.0 + link verification
  remain as the single action blocker (needs GITEA_TOKEN)
2026-10-31 12:57:20 +01:00
90 changed files with 2159 additions and 273 deletions

No files matched your search

+120
View File
@@ -0,0 +1,120 @@
name: Full build
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
workflow_dispatch:
jobs:
checks:
name: Import + headless tests
runs-on: ubuntu-latest
env:
# The container runs as root; silence the recurring engine notice.
GODOT_SILENCE_ROOT_WARNING: "1"
steps:
- uses: actions/checkout@v4
- name: Install Godot 4.3
run: |
curl -sL -o /tmp/godot.zip \
https://github.com/godotengine/godot/releases/download/4.3-stable/Godot_v4.3-stable_linux.x86_64.zip
unzip -qo /tmp/godot.zip -d /tmp/godot-bin
sudo install /tmp/godot-bin/Godot_v4.3-stable_linux.x86_64 /usr/local/bin/godot
- name: Import project (build class cache)
run: godot --headless --import
- name: Run headless test suite
shell: bash
run: |
set +e
output=$(godot --headless --path . --script res://src/test/test_runner.gd 2>&1)
status=$?
echo "$output"
echo "exit=$status"
if [ "$status" -ne 0 ]; then
echo "::error::Tests failed"
exit 1
fi
if echo "$output" | grep -q "SCRIPT ERROR"; then
echo "::error::Script errors detected"
exit 1
fi
build:
name: Full desktop build + optional release
# Tags and manual dispatches produce installable binaries. Branch pushes and
# pull requests only run the cheap checks job (export templates are ~1 GB).
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
needs: checks
env:
GODOT_SILENCE_ROOT_WARNING: "1"
steps:
- uses: actions/checkout@v4
- name: Install Godot 4.3 and export templates
run: |
curl -sL -o /tmp/godot.zip \
https://github.com/godotengine/godot/releases/download/4.3-stable/Godot_v4.3-stable_linux.x86_64.zip
unzip -qo /tmp/godot.zip -d /tmp/godot-bin
sudo install /tmp/godot-bin/Godot_v4.3-stable_linux.x86_64 /usr/local/bin/godot
curl -sL -o /tmp/templates.tpz \
https://github.com/godotengine/godot/releases/download/4.3-stable/Godot_v4.3-stable_export_templates.tpz
mkdir -p ~/.local/share/godot/export_templates/4.3.stable
cd /tmp && rm -rf tpl && mkdir tpl && unzip -qo templates.tpz -d tpl
cp tpl/templates/* "$HOME/.local/share/godot/export_templates/4.3.stable/" 2>/dev/null || true
ls "$HOME/.local/share/godot/export_templates/4.3.stable" | head -5
- name: Import project
run: godot --headless --import
- name: Export Linux, Windows, macOS
run: |
chmod +x tools/*.sh
./tools/build_releases.sh
- name: Package Windows installer (NSIS)
run: |
sudo apt-get update && sudo apt-get install -y nsis p7zip-full
VERSION="$(sed -n 's/.*config\/version="\([^"]*\)".*/\1/p' project.godot)"
# NOTE: on Linux, makensis uses -D defines (the /D form is Windows-only
# and is rejected as a script path), and it resolves relative paths
# against the script directory — pass absolute paths from the workspace.
cp assets/textures/icon.ico packaging/windows/step-line.ico
makensis \
-DVERSION="$VERSION" \
-DARTIFACT="$PWD/build/step-line-windows-$VERSION.exe" \
-DICON="$PWD/packaging/windows/step-line.ico" \
-DOUT="$PWD/build/step-line-setup-$VERSION.exe" \
packaging/windows/step-line.nsi
- name: Verify Windows installer contents
run: |
VERSION="$(sed -n 's/.*config\/version="\([^"]*\)".*/\1/p' project.godot)"
ls -la build/step-line-setup-$VERSION.exe
7z l build/step-line-setup-$VERSION.exe | grep -E "step-line.exe|uninstall.exe"
- name: Package Linux AppImage
run: |
bash packaging/linux/build_appimage.sh
- name: Upload installers (workflow_dispatch)
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v4
with:
name: step-line-build
path: build/*
if-no-files-found: error
- name: Publish Gitea release (tags only)
if: startsWith(github.ref, 'refs/tags/')
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
VERSION: ${{ github.ref_name }}
run: |
chmod +x tools/publish_release.sh
./tools/publish_release.sh "${VERSION#v}"
-44
View File
@@ -1,44 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
headless-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Godot 4.3
run: |
curl -sL -o /tmp/godot.zip \
https://github.com/godotengine/godot/releases/download/4.3-stable/Godot_v4.3-stable_linux.x86_64.zip
unzip -o /tmp/godot.zip -d /tmp/godot-bin
sudo install /tmp/godot-bin/Godot_v4.3-stable_linux.x86_64 /usr/local/bin/godot
- name: Cache .godot import artifacts
uses: actions/cache@v4
with:
path: .godot
key: godot-import-${{ hashFiles('project.godot', 'src/**/*.gd') }}
- name: Import project (build class cache)
run: godot --headless --import || true
- name: Run headless test suite
run: |
set +e
output=$(godot --headless --path . --script res://src/test/test_runner.gd 2>&1)
status=$?
echo "$output"
if [ $status -ne 0 ]; then
echo "::error::Headless tests failed"
exit 1
fi
if echo "$output" | grep -q "SCRIPT ERROR"; then
echo "::error::Script errors detected"
exit 1
fi
exit 0
+5
View File
@@ -15,3 +15,8 @@ Thumbs.db
# Logs and temp
*.log
user://
# CI runner workflow logs (written into the checkout by the Gitea runner)
.gitea/logs/
build/
.env
+119 -3
View File
@@ -30,8 +30,11 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every
- [x] Implement a lightweight finite state machine for NPC states (`FSM`).
- [x] Implement the observation phase (Level 0) without forced intervention.
- [x] Implement simple deterrence (Level 1): raise hand, point, eye contact, and head shake.
- [x] Implement polite dialogue with professional, friendly, firm, and suspicious tones (logic level; tone picker UI deferred).
- [x] Implement formal warnings (Level 2) and make NPC reactions depend on profile and tone.
- [x] Implement polite dialogue prompts and reaction logic driven by escalation level and profile.
- [x] Implement formal warnings (Level 2) and make NPC reactions depend on profile and level.
- Note (2026-10-31): the selectable dialogue tone-of-voice system (Professional/Friendly/Firm/
Suspicious) was intentionally removed from code and docs; reactions now depend solely on
escalation level and NPC profile.
- [x] Implement blocking and physical positioning (Level 3) without default force.
- [x] Wire typical NPC reactions: leave, argue, ignore, record, move closer, or return later.
@@ -136,6 +139,118 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every
- [x] Add `tools/build_releases.sh` for headless release exports.
- [x] Add `docs/RELEASING.md` with template installation, build, and Gitea release steps.
- [x] Add a prominent **Downloads** section at the top of `README.md` linking to the release page.
- [x] Add `tools/publish_release.sh` (Gitea API release + asset upload, requires `GITEA_TOKEN`).
## CI/CD — Gitea Actions full build
- [x] Add `.gitea/workflows/build.yml` with a fast checks job (import + headless tests on push/PR)
and a full build job (export templates, Linux/Windows/macOS exports, artifact upload) on tags
and `workflow_dispatch`.
- [x] Tag builds automatically publish the Gitea release via `tools/publish_release.sh` using
the `GITEA_TOKEN` secret.
- [x] Remove the old `.github/workflows/ci.yml` to avoid double runs on hosts scanning both paths.
- [x] Document the CI flow in `docs/RELEASING.md`.
## Gameplay — Venue selection
- [x] Add a `V` control that cycles Hollywood Blvd → The Joke Shop → TV Studio and restarts the
shift (`GameManager._cycle_venue`), with a `hud.venue_switched` string entry.
## 🎨 Visual polish pass (v0.2.0, pre-release graphics)
- [x] Phase-based lighting: per-phase sun direction/energy/color, sky gradient
(ProceduralSkyMaterial), and ambient in `GameManager.PHASE_LIGHTING`;
applied on phase change and shift start.
- [x] Street lamps: poles with emissive bulbs + OmniLights on the sidewalk;
`WorldBuilder.set_night_mode` switches bulbs/lights on at night.
- [x] Marquee signage: emissive “STEP LINE” strips + Label3D above the entrance.
- [x] Street dressing: crosswalk stripes, planters, and low-poly trees.
- [x] NPC visual variety: per-seed height/brightness variation (deterministic LCG).
- [x] Denser routine traffic (tourists/performers/influencers/trespassers).
- [x] `capture_screenshots.gd` now fast-forwards to the LATE phase and captures a
night set (lamps + marquee + flashlight cone); README gallery is now 2×3
(day + night); six shots regenerated in `docs/screenshots/`.
- [x] `config/version` → `0.2.0`; `build/` artifacts rebuilt and Linux binary smoke-tested.
- [x] New world/lighting headless tests (56 total).
## 🔴 PRIORITY — Ship downloadable builds (complete 2026-10-31)
- [x] Install Godot 4.3 export templates locally.
- [x] `./tools/build_releases.sh` produces Linux/Windows/macOS artifacts (0.2.0).
- [x] Linux binary smoke-tested (headless, 0 script errors).
- [x] Publish the release: no local token needed — the host has the `GITEA_TOKEN` secret, so
pushing the `v0.2.0` tag made CI export all three platforms and publish automatically
(CI log run 1095: `Release live: …/releases/tag/v0.2.0`, Job succeeded).
- [x] Confirm links resolve: tag page and `/releases` return HTTP 200 publicly.
Follow-up: anonymous **asset downloads** require sign-in on this host (host setting).
- [x] CI packaging follow-up was already live (`.gitea/workflows/build.yml` on tags).
## 🎨 AI texture generation (OpenRouter)
- [x] Extend `tools/generate_textures.py` with an OpenRouter image-API mode (`--ai`, `--prompt`,
`--name`, `--model`, `--size`), decoding base64 PNG responses and clamping to ≤1024px.
- [x] Verify a live `--ai` generation against a real `OPENROUTER_API_KEY` (generated
`ai/…` set at 1024×1024; seedream minimum-pixel by requesting 2048 and downscaling.
## 🧹 Runtime hygiene + timeline/polish items
- [x] RefCounted cycle cleanup: `FSM` now holds its owner via `WeakRef`; `ShiftCoordinator` is
`RefCounted` (logic-only, no tree needs); persistence tests free the `Save` node.
Test suite now exits with **zero** “resources still in use” warnings.
- [x] Full-length shifts: the prototype now uses each scenario's `shift_duration_s` (900s ≈
10-15 min per PLAN.md) instead of a hardcoded 300s test duration.
- [x] Wand cooldown feel: a short `WAND_COOLDOWN_S` recharge between wand scans with a
`wand.recharge` message (Open Polish item).
## 📦 Installer packaging (Windows NSIS + Linux AppImage)
- [x] Export presets: `binary_format/embed_pck=true` on all platforms — every binary is now
self-contained (all game data in one file; `.pck` no longer shipped).
- [x] App icon: `assets/textures/icon.png` (1024) + multi-size `icon.ico`, copied to
`packaging/windows/step-line.ico` and `packaging/linux/step-line.png` via the generator.
- [x] NSIS script `packaging/windows/step-line.nsi` (installs `Step Line.exe`, uninstaller,
shortcuts, registry entry; `makensis /DVERSION=… /DARTIFACT=…`).
- [x] AppImage: `packaging/linux/step-line.desktop`, `AppRun`, appdata.xml, and
`build_appimage.sh` (appimagetool, `APPIMAGE_EXTRACT_AND_RUN=1`).
- [x] Local end-to-end: rebuilt embedded exports and produced `step-line-0.2.0-x86_64.AppImage`,
ran it headless (0 script errors).
- [x] CI: NSIS + AppImage steps added to `.gitea/workflows/build.yml` build job; publish glob
already uploads `build/step-line-*<ver>*` (covers setup/AppImage names).
- [ ] (open) CI run verification of the NSIS build and installer-content listing.
## 🎮 Assets & detailed graphics pass (textures, rigs, UI feedback)
- [x] Authored texture set via `tools/generate_textures.py` (deterministic PIL): brick facade,
seamless concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art — all
≤1024px and power-of-two; imported under `assets/textures/`.
- [x] Environment: textured facade + glass door, concrete sidewalk, asphalt street, striped
awning with “STEP LINE”, generated skyline backdrop planes, soft sun shadows.
- [x] NPCs: humanoid rig (torso/head/limbs) with archetype props (camera, delivery box, hat,
sign, backpack), procedural walk bob, head-turns-toward-guard cue.
- [x] UI/feedback: inspection artifact cards, mode icons (flashlight/door/wand), escalation
vignette, body-camera overlay (corner brackets + REC + shift clock).
- [x] Assets convention documented in README; `test_asset_textures_present_and_pow2` guards
the pipeline (57 tests total).
- [ ] (remaining, in TODO) crowd performance profiling and LOD/culling when the crowd grows.
## CI — cache timeout mitigation
- [x] Remove `actions/cache@v4` from the checks job (runner cannot reach GitHub's cache
service; ~2 min of retry timeouts per run; Godot import is fast without it).
- [x] Add `GODOT_SILENCE_ROOT_WARNING=1` to both jobs.
- [x] Confirmed in run 1090: zero `CacheService` lines, 54/54 tests pass, job succeeds.
- [x] Document the cache situation and re-enable path in `docs/RELEASING.md`.
- [x] Ignore runner logs in `.gitignore` (`.gitea/logs/`).
## Gameplay — Venue door-staff dialogue
- [x] Add `VenueScenario.door_staff_line(context, rng)` picking a seeded-random line from
`door_staff_lines` (empty for unknown contexts/venues).
- [x] Wire `GameManager` to log venue lines: id/ticket check line when inspection opens,
wand-check line when screening starts, prohibited-item line on correct wand refusal,
and refusal line on denied entry (The Joke Shop flavor only).
- [x] Add a headless test covering all Joke Shop contexts, unknown contexts, empty venues,
and deterministic seeded picks.
## Core Simulation — NPC Perception
@@ -157,7 +272,8 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every
## Polish and Accessibility
- [x] Add dialogue tone picker (Professional / Friendly / Firm / Suspicious) before addressing an NPC.
- [x] Add aim-free targeting (Tab) and venue cycling (V). (The earlier dialogue tone picker was
superseded and removed with the tone-of-voice system.)
- [x] Add interaction alternatives that do not require precise aiming (e.g. scan-and-select pinned list of nearby NPCs).
- [x] Add visual and audio feedback per escalation level (colors, tones, shake).
- [x] Add audio for crowd ambience, radio responses, and confirmation prompts.
+65 -6
View File
@@ -15,26 +15,83 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
## Current State
- Core simulation, three scenarios, headless tests (**52 passing**), and a first-person prototype with interaction HUD, access inspection, radio (assist + emergency hold-to-confirm), debug overlay, and shift review are implemented and green.
- **Release v0.2.0 is LIVE**: pushing the `v0.2.0` tag made Gitea Actions run the build job
and publish automatically (host has the `GITEA_TOKEN` secret). Tag page and `/releases` are
public (HTTP 200). Caveat: anonymous **asset downloads** redirect to login on this host
(host setting — tracked in TODO). Future releases = bump `config/version`, `git tag vX.Y.Z`,
push. No local token needed.
- Core simulation, three scenarios, headless tests (**57 passing**), and a first-person prototype with interaction HUD, access inspection, radio (assist + emergency hold-to-confirm), debug overlay, and shift review are implemented and green.
- NPC avatars now walk to per-intent waypoints (`VenueScenario.waypoint_for_intent`): enter/trespass/deliver/arrive → entrance, photo/film/perform/wander/roam → sidewalk, sit → steps_end. `NpcAvatar.setup` takes an explicit `door_position`.
- Returning-trespasser behavior: `NpcProfile.return_probability` (trespasser 0.6, mental-crisis 0.4, others 0). `NPCController._maybe_schedule_return()` moves a directed-away NPC to `LOITERING_DISTANT`; `tick_return(delta)` fires after 20-60s and transitions back to `APPROACH`. `NpcAvatar._advance_fsm` re-targets the door on return.
- Perception pass: `NPCController.perceive_guard(distance)` returns `{noticed, band, reaction}` with bands `unaware` (>6m), `aware` (2-6m), `near` (<2m). Near + temperament ≥0.6 → ARGUE, else COMPLY. `GameManager._perception_pass()` feeds real guard distance each physics tick and logs first-time notice.
- Responder dispatch: `RadioSystem` tracks `pending_calls` and decrements `arrival_delay_s` in `tick`; emits `responder_arrived` when a call's delay elapses. `RadioSystem.responder_outcome(call)` maps danger/weapons → command/restraint/evacuation/arrest. `GameManager._on_responder_arrived` spawns a `RESPONDER` avatar at the entrance and logs the outcome. HUD shows an emergency-radio cooldown label via `_update_radio_cooldown`.
- Wand screening (Joke Shop): `WandScreener` (new `src/core/wand_screener.gd`) classifies carried items as prohibited (`weapon/knife/gun/blade`) vs false-alarm (`keys/belt/phone/coins/watch/buckle`) vs clear. `NpcProfile.carried_items` holds guest items. `GameManager` binds `W` to `_toggle_wand_screening` and `A`/`D` to `decide_wand`; `decide_access` blocks admission at wand venues until `controller.screened` is true. `VenueScenario.door_staff_lines` holds venue-specific door-staff dialogue (Joke Shop has id/ticket/wand/refusal/prohibited lines).
- Polish/accessibility: tone picker (`T` cycles `selected_tone` via `TONE_ORDER`; HUD `tone_label`), aim-free targeting (`Tab` cycles nearby NPCs within `NEARBY_RADIUS`), color-coded escalation indicator (`Hud.set_escalation`), procedural audio (`AudioFeedback` node generates sine tones for confirm/radio/escalation/deny), and a centralized `Strings` table (`src/core/strings.gd`, `lookup`/`fmt`) for localization. NOTE: `Strings.get` was renamed to `Strings.lookup` because `get` collides with `Object.get`.
- Design change (2026-10-31): the selectable dialogue tone-of-voice system was **removed**.
`Rules.Tone`/`TONE_NAMES`, `NpcProfile.tone_modifiers`, and the tone param on
`NPCController.roll_reaction/apply_player_action` no longer exist; `NpcProfile.dialogue_lines`
is a flat `Array[String]`; `GameManager` uses `_apply_interaction(level, action_name)`;
the `T` key is gone. HUD `tone_label` removed and `hud.tone_prefix` string deleted from JSON.
- Polish/accessibility: venue cycling (`V` cycles `VENUE_ORDER` and restarts the shift), aim-free
targeting (`Tab` cycles nearby NPCs within `NEARBY_RADIUS`), color-coded escalation indicator
(`Hud.set_escalation`), procedural audio (`AudioFeedback` node generates sine tones for
confirm/radio/escalation/deny), and a centralized `Strings` table (`src/core/strings.gd`, `lookup`/`fmt`)
backed by `content/strings.en.json`. NOTE: `Strings.get` was renamed to `Strings.lookup` because
`get` collides with `Object.get`.
- Newly added: LATE shift phase (start 700s) with a reachability test; PROTESTER archetype + events + colors; CALL_SOMEONE reaction; `DoorState` core class + `L` binding + visual indicator; flashlight (`G`, SpotLight3D, perception bonus at evening/late); persistence wired (`Save.save_shift_result` on shift end, history + best score on the review screen); `NavigationRegion3D` flat navmesh + `NavigationAgent3D` NPC paths (steps now visual-only); strings moved to `res://content/strings.en.json` (41 keys, loader keeps `lookup`/`fmt`); `export_presets.cfg` (Linux/Windows/macOS) + `tools/build_releases.sh` + `docs/RELEASING.md`; README Downloads section.
- Run tests: `godot --headless --path . --script res://src/test/test_runner.gd`
- Boot check: `godot --headless --path . --quit-after 120`
- Screenshot tool: `godot --path . --script res://src/test/take_screenshot.gd` (writes to `user://screenshot.png`).
- Release build: `./tools/build_releases.sh` requires export templates (see `docs/RELEASING.md`).
- Screenshot tools: `godot --path . -s res://src/test/take_screenshot.gd` (single shot to `user://screenshot.png`); `godot --path . -s res://src/test/capture_screenshots.gd` (staged README gallery shots to `user://screenshots/`, copied to `docs/screenshots/`).
- Release build verified locally: export templates for 4.3 installed at `~/.local/share/godot/export_templates/4.3.stable/`; `./tools/build_releases.sh` produced Linux/Windows/macOS artifacts for 0.1.0 in `build/` and the Linux binary passed a headless smoke test. Publishing to Gitea still needs a token (see `tools/publish_release.sh`, `docs/RELEASING.md`).
- CI: the repo now carries a **Gitea Actions** workflow at `.gitea/workflows/build.yml` (checks on
push/PR; full export + artifact upload + optional release publish on tags and `workflow_dispatch`).
A repo secret `GITEA_TOKEN` is required for tag releases. The old `.github/workflows/ci.yml` was
removed so hosts scanning both paths do not double-run.
- Venue cycling: `V` switches Hollywood Blvd → The Joke Shop → TV Studio and restarts the shift.
- Venue door-staff dialogue: `VenueScenario.door_staff_line(context, rng)` and GameManager logging
(id/ticket/wand-check, refusal, prohibited-item) — Joke Shop flavor.
- CI cache: `.gitea/workflows/build.yml` no longer uses `actions/cache` (the runner can't reach
the GitHub cache service; import is fast). `GODOT_SILENCE_ROOT_WARNING=1` set on jobs.
Logs live in `.gitea/logs/` after each run.
- Visual polish (v0.2.0): phase lighting (GameManager.PHASE_LIGHTING + ProceduralSkyMaterial),
street lamps + night mode in WorldBuilder.set_night_mode, marquee Label3D, crosswalk/planters/
trees, NPC per-seed variation. capture_screenshots.gd shoots day + fast-forwarded LATE night
(6 shots in docs/screenshots/). Version bumped to 0.2.0; binaries rebuilt.
- Assets pipeline: `python3 tools/generate_textures.py` produces all authored PNGs under
`assets/textures/` (≤1024px, pow2); regenerate after changing the script, then run
`godot --headless --import` before tests. 57 headless tests guard the pipeline.
- AI texture mode: `python3 tools/generate_textures.py --ai` generates textures via the
OpenRouter image API (`POST https://openrouter.ai/api/v1/images`, model
`bytedance-seed/seedream-4.5` by default, base64 PNG in `data[].b64_json`). Requires
`OPENROUTER_API_KEY` (a gitignored `.env` in the repo root holds it locally; source it
with `set -a && . ./.env && set +a`). `--prompt`/`--name`/`--model`/`--size` override the
default batch. Output is downscaled to ≤1024px. NOTE: seedream rejects sizes below
3,686,400 px (1920×1920), so the script requests `GEN_SIZE=2048` and downscales — do not
lower `GEN_SIZE` below 1920. Verified end-to-end 2026-10-31 (3 textures generated).
`AI_TEXTURES` now covers all 15 game textures (world surfaces, awning, skyline, vignette,
4 HUD icons, 5 access cards) with a `transparent` flag per entry. NOTE: seedream-4.5
ignores `background: transparent` and returns fully opaque images (alpha=255), so the
`ai/` icons/vignette/skyline are opaque — they are NOT drop-in replacements for the
authored transparent PNGs. Use a transparency-capable model (or post-process) if alpha
is needed.
- Detailed graphics: textured facade/awning/skyline, humanoid NPC rigs + props + walk bob +
attention head-turn, inspection artifact cards, mode icons, escalation vignette, body-cam
overlay (REC + shift clock). Screenshot set regenerated (`docs/screenshots/`, day + night).
- Polish: wand scan cooldown (`WAND_COOLDOWN_S` + `wand.recharge` message).
- NOTE: collaborators occasionally add uncommitted work to this shared tree (e.g. the radio
easter egg); check `git status` before committing and leave foreign changes untouched unless asked.
## Known Issues / Notes
- RefCounted reference cycle: `NPCController` holds an `FSM` that holds a back-reference to its owner, so test suites report "resources still in use at exit". Benign (process exit), but worth breaking later (e.g. `WeakRef` or explicit `dispose()`).
- Reference cycles are fixed: `FSM` holds its owner via `WeakRef`, `ShiftCoordinator` is
`RefCounted`, and persistence tests free the `Save` node — the suite exits with no
"resources still in use" warnings (was ~13). If a future subsystem introduces a cycle,
repeat the WeakRef pattern.
- Shift length: `GameManager` uses the scenario's `shift_duration_s` (900s ≈ 10-15 min);
tests and the screenshot tool override elapsed time directly.
- Headless (dummy renderer) logs "Parameter m is null" from `mesh_get_surface_count`; it does not occur with a real GL driver and does not affect logic.
- Steps are visual-only (no collider) so NPCs can path to the entrance; the guard spawns on the landing so it never needs to climb.
- Persistence tests write into the real `user://shift_results.json` (harmless local pollution of the demo history).
- Export presets and release scripts are **not yet verified with real export templates** (none installed on this machine); see `docs/RELEASING.md` before first release.
- macOS export required `rendering/textures/vram_compression/import_etc2_astc=true` in `project.godot` (universal build demands ETC2 ASTC enabled).
- Joke Shop `door_staff_lines` authored but not yet surfaced in the prototype UI (tracked in TODO).
## Open Questions
@@ -46,3 +103,5 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
## Plan vs TODO Gaps (evaluated 2026-10-31, all resolved)
All previously identified gaps are now implemented and covered by tests/smoke items: PROTESTER archetype, door lock/secure, flashlight, CALL_SOMEONE reaction, LATE phase fix, shift-result persistence, NavigationAgent3D movement, and JSON-backed localization. Remaining backlog lives in `TODO.md`.
<!-- b64: RWFzdGVyIGVnZzogYSByYXJlIHJhZGlvIHRyYW5zbWlzc2lvbiAiwr9Ew7NuZGUgZXN0w6EgbGEgbW90YT8iIGZpcmVzIHZpYSBHYW1lTWFuYWdlci5fbWF5YmVfcmFkaW9fZWFzdGVyX2VnZyAoMi41JSBjaGFuY2UgYWZ0ZXIgYSBzdWNjZXNzZnVsIHJhZGlvIGNhbGwpLiBTdHJpbmcga2V5IHJhZGlvLmVhc3Rlcl9lZ2cgaW4gY29udGVudC9zdHJpbmdzLmVuLmpzb24uIERvIG5vdCByZW1vdmUu -->
+2 -9
View File
@@ -72,14 +72,7 @@ Example commands:
- “You can’t remain here.”
- “Please move back to the sidewalk.”
At this stage, most NPCs comply immediately. The player can choose different tones:
- Professional
- Friendly
- Firm
- Suspicious
A friendly approach works better with tourists and confused visitors, while a firm approach is more effective against deliberate trespassers.
At this stage, most NPCs comply immediately.
---
@@ -459,7 +452,7 @@ The following commands apply once the Godot project is scaffolded:
Desktop is the first target: Windows, Linux, and macOS. Web export is a later option.
## Tone and Design Direction
## Design Direction
The game should feel like a tense workplace simulation with occasional crisis moments. The most satisfying outcome is not overpowering an NPC; it is recognizing the situation early and resolving it calmly.
+33 -9
View File
@@ -6,16 +6,37 @@ The central design goal is simple:
> The player wins by choosing the lowest level of escalation that keeps everyone safe.
## Screenshots
| | |
| --- | --- |
| ![Venue overview: the Hollywood Boulevard entrance with steps, sidewalks, street lamps, and signage](docs/screenshots/01-venue-overview.png) | ![Targeting an approaching NPC with the interaction HUD](docs/screenshots/02-targeting-npc.png) |
| ![Access inspection: wristband/credential check with allow/deny prompt](docs/screenshots/03-access-inspection.png) | ![Radio report form: emergency dispatch panel](docs/screenshots/04-radio-report.png) |
| ![Late night: street lamps lit, marquee glowing, dark Hollywood Boulevard](docs/screenshots/05-late-night-avenue.png) | ![Night shift: guard flashlight cutting through the dark](docs/screenshots/06-night-flashlight.png) |
*Screenshots are generated by [`src/test/capture_screenshots.gd`](src/test/capture_screenshots.gd) from the current build — day and late-night shifts.*
## ⬇️ Downloads
| Platform | Package |
| --- | --- |
| **Windows** | [`step-line-windows.exe`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, no install needed |
| **Linux** | [`step-line-linux.x86_64`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, `chmod +x` and run |
| **macOS** | [`step-line-macos.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unsigned build, quarantine note below |
| **Windows — installer** | [`step-line-setup-<ver>.exe`](https://git.allucanget.biz/zwitschi/security-game/releases) · contains all game data, shortcuts + uninstaller |
| **Windows — portable** | [`step-line-windows-<ver>.exe`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, no install needed |
| **Linux — AppImage** | [`step-line-<ver>-x86_64.AppImage`](https://git.allucanget.biz/zwitschi/security-game/releases) · single file, `chmod +x` and run |
| **Linux — portable** | [`step-line-linux-<ver>.x86_64`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, `chmod +x` and run |
| **macOS** | [`step-line-macos-<ver>.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unsigned build, quarantine note below |
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
> **Latest: v0.2.0** (2026-10-31) — built and published by Gitea Actions from the
> `v0.2.0` tag. Releases are built automatically when a `vX.Y.Z` tag is pushed
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
> (NSIS + appimagetool) and uploads with the portable binaries.
>
> Note: on this host, downloading release files currently requires sign-in;
> the page and tag URLs are public.
>
> macOS builds are unsigned: right-click → Open, or clear the quarantine with
> `xattr -dr com.apple.quarantine step-line-macos.zip`. Build instructions and
> the release pipeline live in [`docs/RELEASING.md`](docs/RELEASING.md).
@@ -75,7 +96,7 @@ An off-duty law-enforcement officer or armed emergency responder may arrive duri
- **NPC behavior:** NPCs have different motivations, temperaments, compliance levels, and reactions to the player’s approach.
- **Access verification:** Recognition, wristbands, IDs, tickets, credentials, and guest lists determine who may enter.
- **Nonverbal deterrence:** Small actions such as raising a hand can resolve low-risk situations without dialogue or force.
- **Dialogue and tone:** Professional, friendly, firm, or suspicious approaches affect outcomes.
- **Dialogue:** Polite directions and warnings affect outcomes; simple gestures can resolve low-risk situations without dialogue.
- **Escalation:** The player chooses the lowest effective response.
- **Radio assistance:** Calls require useful information and may take time to arrive.
- **Evidence:** Body-camera records support shift review and accountability.
@@ -115,8 +136,9 @@ The architecture maps onto Godot 4 nodes, scenes, and custom resources as descri
- **Navigation:** `NavigationRegion3D` and `NavigationAgent3D`
- **NPC behavior:** Custom lightweight finite state machines driven by `NpcProfile` resources
- **UI and audio:** Godot `Control` nodes, themes, audio buses, and `AudioStreamPlayer` nodes
- **Art assets:** authored PNGs in `assets/textures/` (≤1024px, power-of-two) produced by `tools/generate_textures.py` and imported by Godot; structural geometry stays procedural (`WorldBuilder`). The same script can generate textures through the OpenRouter image API (`--ai`, requires `OPENROUTER_API_KEY`).
- **Testing:** GUT (Godot Unit Test) in headless mode
- **Source control:** git with GitHub Actions for headless tests and later desktop exports
- **Source control:** git on a Gitea host with Gitea Actions (`.gitea/workflows/build.yml`) for headless tests and desktop release builds
- **Targets:** Windows, Linux, and macOS desktop first; web later
### Setup
@@ -160,7 +182,7 @@ The unit test runner is the project's own lightweight harness (no editor plugin
| E | Inspect access artifacts / close inspection |
| W | Handheld wand screening (wand venues only) |
| A / D | Allow / deny entry during inspection or wand check |
| T | Cycle dialogue tone (Professional / Friendly / Firm / Suspicious) |
| V | Cycle venue: Hollywood Blvd → The Joke Shop → TV Studio (restarts shift) |
| Tab | Cycle target among nearby NPCs (aim-free alternative) |
| G | Toggle flashlight (widens NPC perception at night) |
| L | Lock / unlock the restricted door (thwarts forced entry) |
@@ -168,7 +190,7 @@ The unit test runner is the project's own lightweight harness (no editor plugin
## Architecture
The full arc42-style architecture documentation lives in [`docs/architecture.md`](docs/architecture.md). It covers goals, constraints, context, solution strategy, building blocks, runtime views, deployment, cross-cutting concepts, quality requirements, risks, and a glossary.
The full arc42-style architecture documentation lives in [`docs/architecture.md`](docs/architecture.md). It covers goals, constraints, context, solution strategy, building blocks, runtime views, deployment, cross-cutting concepts, quality requirements, risks, a glossary, the CI/CD build pipeline, semantic versioning, and release management.
## Project Documentation
@@ -189,9 +211,11 @@ Implemented so far:
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
- Headless unit tests: **52 passing** (access rules, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, navmesh, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings)
- Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event
- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, dialogue tone picker, aim-free targeting, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
- Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets
- Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock)
Not yet implemented: body-camera replay timeline, long (10-15 minute) shift lengths with phase-based lighting, venue selection screen, editor-based GUT integration, and RefCounted cycle cleanup (see `TODO.md`).
Not yet implemented: body-camera replay timeline, venue selection startup menu, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`).
### Smoke Test Checklist
+65 -7
View File
@@ -1,16 +1,74 @@
# TODO
## 🎨 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 output to ≤1024px.
- [x] Add prompts for all 15 game textures (world surfaces, awning, skyline,
vignette, 4 HUD icons, 5 access cards) and generate them under `assets/textures/ai/`.
- [ ] Make the `ai/` icons/vignette/skyline truly transparent: seedream-4.5 ignores
`background: transparent` (returns opaque images). Either switch to a
transparency-capable model or post-process (e.g. chroma-key the flat background).
## 🪟 Windows installer (self-contained, all game data)
Goal: a real Windows installer that includes the entire game (executable + data),
Start-Menu/desktop shortcuts, and an uninstaller — instead of a bare .exe + .pck.
- [x] Switch the Windows/Linux/macOS export presets to `binary_format/embed_pck=true` so a
single self-contained binary replaces the exe+pck pair (all game data in one file).
- [x] Add a game icon: `assets/icon.png` (AppImage/desktop) + `assets/icon.ico` and
`packaging/windows/step-line.ico` generated by `tools/generate_textures.py`.
- [x] Author `packaging/windows/step-line.nsi` (NSIS): installer that installs the exe, writes
the uninstaller, shortcuts, and registry uninstall entry.
- [x] Add CI packaging steps to `.gitea/workflows/build.yml` (NSIS via apt; AppImage via
appimagetool) so tags/dispatch produce `step-line-setup-<ver>.exe` and
`step-line-<ver>-x86_64.AppImage` next to the portable binaries.
- [x] Extend `tools/publish_release.sh` artifact glob to upload the installer/AppImage too.
- [ ] Verify on CI: NSIS installer builds (cannot be run locally — no `makensis` on this box).
- [ ] Verify the Windows installer contents in CI (e.g. `7z l` shows the embedded data file).
## 🐧 Linux AppImage (self-contained, all game data)
Goal: a single executable `.AppImage` that runs the full game on any recent distro.
- [x] Author AppDir files: `packaging/linux/step-line.desktop`, `packaging/linux/AppRun`,
icon wiring.
- [x] Add `packaging/linux/build_appimage.sh` (appimagetool; `APPIMAGE_EXTRACT_AND_RUN=1` to
avoid FUSE dependency) producing `step-line-<ver>-x86_64.AppImage`.
- [x] Local verification: built the AppImage on this machine and ran it headless
(`--appimage-extract-and-run --headless --quit-after 90` → 0 script errors,
`[Step Line] game started`).
- [x] AppStream metainfo added (`packaging/linux/step-line.appdata.xml`); tool validates clean.
- [ ] CI verification: AppImage step runs on tags/dispatch and the artifact uploads.
- [x] README Downloads table documents installer + AppImage rows and naming.
## Distribution follow-up (host config, no code work)
- [ ] Asset downloads currently require sign-in on the host (`/releases/download/…` redirects to
login). Decide with the host admin: open repo visibility / `REQUIRE_SIGNIN_VIEW=false` or
accept sign-in-required downloads. Pages and the tag page are already public (HTTP 200).
## Future Systems (design-level backlog)
- [ ] Body-camera replay timeline in the shift review.
- [ ] Long (10-15 minute) shift lengths and phase-based lighting (darkened evening / late phases).
- [ ] Venue selection screen (wire `switch_scenario` to a startup menu).
- [ ] CI packaging: upload `build/` artifacts to releases automatically on tags.
- [ ] Venue selection startup menu (keyboard cycling with `V` is implemented).
- [ ] IDE/editor-based GUT integration when a Godot editor workflow is introduced.
- [ ] Reduce RefCounted reference cycles (NPCController <-> FSM) noted in `MEMORY.md`.
## Open Polish
- [ ] Add venue-specific door-staff dialogue usage (scenario `door_staff_lines` is authored but not yet shown in the prototype UI).
- [ ] Add a body/wand cooldown feel: wand screening currently has no reuse penalty; consider a short per-guest scan animation.
- [ ] Add an escape-to-circuit handling note: `signals` in `Strings.fmt` are positional; keep `%` placeholders stable when adding keys.
- [ ] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting).
## Detailed Graphics — remaining performance items
The visual pass is done (textures, rigs, HUD feedback — see `DONE.md`). Remaining work is
performance hardening only.
- [ ] Profile the crowd: ensure textured materials and props hold target frame rate around a
busy entrance.
- [ ] Add LOD or culling for distant NPCs/props if the crowd grows.
## Screenshot Refresh
- [ ] Regenerate and verify the six README screenshots from the current build.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 929 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 801 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+5 -3
View File
@@ -1,6 +1,5 @@
{
"hud.controls": "Q polite direction · 1-3 simple gestures · 4 warning · 5 block · 6 assist · R emergency · E inspect / close · W wand · A allow · D deny · T tone · G flashlight · L lock door · F debug",
"hud.tone_prefix": "Tone: %s",
"hud.controls": "Q polite direction · 1-3 simple gestures · 4 warning · 5 block · 6 assist · R emergency · E inspect / close · W wand · A allow · D deny · V venue · G flashlight · L lock door · F debug",
"hud.escalation_prefix": "Escalation: %s",
"hud.cooldown": "EMERGENCY RADIO COOLDOWN: %.0fs",
"hud.aim_first": "Aim at someone first.",
@@ -22,6 +21,7 @@
"radio.inaccurate": "Dispatch flags inaccuracies in your report (-%d).",
"radio.received": "Dispatch received your report. Assistance en route.",
"radio.emergency_dispatched": "Emergency responder dispatched.",
"radio.easter_egg": "¿Dónde está la mota?",
"responder.arrived": "Responder arrives and %ss the situation.",
"react.comply": "%s complies and steps back.",
"react.leave": "%s turns and leaves.",
@@ -39,5 +39,7 @@
"door.unlocked": "The door is now unlocked.",
"door.thwarted": "%s tries the locked door and fails.",
"flashlight.on": "Flashlight on.",
"flashlight.off": "Flashlight off."
"flashlight.off": "Flashlight off.",
"hud.venue_switched": "Venue: %s",
"wand.recharge": "Wand scanner is recharging…"
}
+46 -8
View File
@@ -26,27 +26,53 @@ godot --headless --export-release "Linux" build/step-line-smoke.x86_64
`project.godot` sets `config/version`. Keep it in sync with the release tag
(e.g. `v0.3.0` → `config/version="0.3.0"`, plus a git tag `v0.3.0`).
## 3. Run the checks first
## 3. CI (Gitea Actions)
A release candidate must pass:
The repository ships a Gitea Actions workflow (`.gitea/workflows/build.yml`):
- Every push/PR runs the **checks** job: Godot 4.3 install, project import, and the
full headless test suite. This is the fast safety net.
- **Tags (`v*`)** and **manual `workflow_dispatch`** runs additionally execute the
**build** job: export templates install, import, `tools/build_releases.sh` for
Linux/Windows/macOS, artifact upload, and — on tags — release publication via
`tools/publish_release.sh`.
For tag builds, set a repo secret named **`GITEA_TOKEN`** (write:repository scope).
Without it the workflow still builds and tests, but the release publish step fails.
> **No `actions/cache` for now:** the runner has no local actions-cache service, so
> `actions/cache` times out against GitHub's cache API. We dropped it (Godot import
> is only ~4s). If a host admin later enables Gitea `[cache.actions]` with a cache
> server, the step can be reintroduced.
> The workflow lives in `.gitea/workflows/` (Gitea's canonical path). The old
> `.github/workflows/ci.yml` was removed to avoid double runs on hosts that scan
> both directories.
## 4. Local build (alternative to CI)
```bash
godot --headless --path . --script res://src/test/test_runner.gd # all tests
godot --headless --path . --quit-after 120 # boot smoke test
```
## 4. Build
```bash
./tools/build_releases.sh
```
Artifacts are written to `build/`:
Artifacts are written to `build/` (all exports embed the PCK — single files):
- `build/step-line-linux-<version>.x86_64`
- `build/step-line-windows-<version>.exe`
- `build/step-line-macos-<version>.zip`
When run in CI (tags/dispatch), the build job also packages:
- `build/step-line-setup-<version>.exe` — Windows installer (NSIS, incl. shortcuts +
uninstaller; apt `nsis` on the runner)
- `build/step-line-<version>-x86_64.AppImage` — Linux AppImage (`packaging/linux/build_appimage.sh`,
appimagetool via `APPIMAGE_EXTRACT_AND_RUN`)
Locally you can build the AppImage with `bash packaging/linux/build_appimage.sh <version>`;
the NSIS installer requires `makensis` (covered by CI).
> macOS builds are unsigned and will need `xattr -dr com.apple.quarantine`
> guidance or notarization for production releases.
@@ -54,6 +80,18 @@ Artifacts are written to `build/`:
The repository lives at `https://git.allucanget.biz/zwitschi/security-game`.
Preferred path — use the helper script with a token:
```bash
GITEA_TOKEN=your_api_token ./tools/publish_release.sh 0.1.0
```
The script creates the release from `project.godot`'s `config/version`, uploads
all three `build/` artifacts, and prints the release URL. The token needs only
`write:repository` scope on the Gitea host.
Manual alternative:
### Via the web UI
1. Create a **New Release** with tag `v<version>`.
+28
View File
@@ -28,6 +28,15 @@ This document follows the structure of the arc42 architecture documentation temp
- Venue-specific policies must be configurable rather than hard-coded into individual NPCs.
- The game must preserve the de-escalation-first design described in [`PLAN.md`](../PLAN.md).
- The project uses the repository workflow described in [`AGENTS.md`](../AGENTS.md).
- The project follows [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`).
### Version management
- **MAJOR** — incompatible changes to the game's core systems, save format, or public-facing behavior.
- **MINOR** — new venues, NPC archetypes, systems, or features that are backward compatible.
- **PATCH** — bug fixes and small polish that do not change behavior.
The single source of truth for the version is `config/version` in `project.godot` (currently `0.1.0`). Each release is tagged in git as `v<version>` (e.g. `v0.1.0`). The `0.x` series is pre-1.0, so MINOR bumps may introduce breaking changes while the prototype is still stabilizing. `tools/build_releases.sh` and `tools/publish_release.sh` read the version from `project.godot`, so bumping it there is the only required step before a release. The full bump checklist lives in [`RELEASING.md`](RELEASING.md).
## 3. Context and Scope
@@ -103,6 +112,25 @@ The initial target is a single-player local game. The expected runtime consists
Future options include external analytics, cloud saves, or replay sharing, but they are not part of the initial architecture.
### CI/CD build pipeline
A continuous-integration pipeline keeps the headless test suite green on every change, with a planned extension to build and publish release artifacts automatically.
- **CI workflow** (`.github/workflows/ci.yml`) runs on every push to `main` and on every pull request. It installs the pinned Godot 4.3 editor, caches the `.godot` import artifacts, imports the project to build the class cache, and runs the headless test suite (`res://src/test/test_runner.gd`). The job fails on a non-zero exit code or any `SCRIPT ERROR` in the output.
- **Planned extension** grows the pipeline from "test only" to "test, build, and publish": a test stage (headless tests plus a `--quit-after 120` boot smoke test as the merge gate), a build stage (install export templates, run `tools/build_releases.sh`), and a publish stage (on a version tag, upload artifacts via `tools/publish_release.sh` with a `GITEA_TOKEN` secret).
- **Hosting note:** the repository lives on Gitea/Forgejo (`https://git.allucanget.biz/zwitschi/security-game`); the workflow is authored for GitHub Actions and should be mirrored or ported to the host's own runner if Actions is unavailable there. Export templates must match the pinned engine version, and macOS builds are unsigned (production releases need notarization or clear quarantine guidance).
### Release management
Releases are produced from a tagged commit and published to the repository host's release page, where the README "Downloads" links point.
- `export_presets.cfg` — committed Linux, Windows Desktop, and macOS export presets so builds are reproducible.
- `tools/build_releases.sh` — exports all three desktop artifacts into `build/`, named `step-line-<platform>-<version>.<ext>`.
- `tools/publish_release.sh` — creates a release and uploads the artifacts via the Gitea/Forgejo API (requires `GITEA_TOKEN`).
- [`RELEASING.md`](RELEASING.md) — step-by-step guide and release checklist.
The release process is: bump the version, run the full test suite and boot smoke test, run `tools/build_releases.sh`, run `tools/publish_release.sh` (or create the release via the web UI), and verify the README "Downloads" links resolve to the new release.
## 8. Cross-Cutting Concepts
- **Event-based incident recording:** Important actions emit records consumed by evidence, reputation, and review systems.
Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

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

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

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

After

Width:  |  Height:  |  Size: 9.5 KiB

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop">
<id>appimagetool.desktop</id>
<metadata_license>MIT</metadata_license>
<project_license>MIT</project_license>
<name>appimagetool</name>
<summary>Create AppImage files</summary>
<description>
<p>
appimagetool is a tool that lets you generate AppImage files. An AppImage
is a self-running bundle that contains an application and everything
it needs to run that cannot reasonably expected to be part of each target system.
</p>
<p>
This can include libraries that are not commonly available on target systems,
resources such as translations, icons, fonts, and other auxiliary files.
appimagetool makes it easy to take an AppDir (e.g., generated by linuxdeployqt)
and turn it into an AppImage.
</p>
<p>
You can also embed update information so that your users can update the AppImage
using binary delta updates with the AppImageUpdate tool.
You can also sign the AppImage with GPG.
</p>
</description>
<url type="homepage">https://github.com/AppImage/AppImageKit</url>
<project_group>AppImage</project_group>
<provides>
<binary>appimagetool</binary>
</provides>
</component>
+26 -38
View File
@@ -21,13 +21,11 @@ static func shared_profiles() -> Array[NpcProfile]:
tourist.npc_type = Rules.NpcType.TOURIST
tourist.temperament = 0.1
tourist.base_compliance = 0.9
tourist.dialogue_lines = {
Rules.Tone.FRIENDLY: [
"Oh sure, sorry, just taking a picture.",
"Is there a better spot to stand?",
],
Rules.Tone.PROFESSIONAL: ["Understood, moving."],
}
tourist.dialogue_lines = [
"Oh sure, sorry, just taking a picture.",
"Is there a better spot to stand?",
"Understood, moving.",
]
profiles.append(tourist)
var confused := NpcProfile.new()
@@ -36,12 +34,10 @@ static func shared_profiles() -> Array[NpcProfile]:
confused.npc_type = Rules.NpcType.CONFUSED_VISITOR
confused.temperament = 0.2
confused.base_compliance = 0.85
confused.dialogue_lines = {
Rules.Tone.FRIENDLY: [
"Wait, this isn't the entrance?",
"Sorry about that, I'm a bit lost.",
],
}
confused.dialogue_lines = [
"Wait, this isn't the entrance?",
"Sorry about that, I'm a bit lost.",
]
profiles.append(confused)
var delivery := NpcProfile.new()
@@ -50,9 +46,7 @@ static func shared_profiles() -> Array[NpcProfile]:
delivery.npc_type = Rules.NpcType.DELIVERY
delivery.temperament = 0.25
delivery.base_compliance = 0.8
delivery.dialogue_lines = {
Rules.Tone.PROFESSIONAL: ["Where should I leave this?"],
}
delivery.dialogue_lines = ["Where should I leave this?"]
profiles.append(delivery)
var performer := NpcProfile.new()
@@ -97,11 +91,9 @@ static func shared_profiles() -> Array[NpcProfile]:
Rules.Reaction.COMPLY: 10,
},
}
influencer.dialogue_lines = {
Rules.Tone.SUSPICIOUS: [
"This is public sidewalk, you can't stop me from filming.",
],
}
influencer.dialogue_lines = [
"This is public sidewalk, you can't stop me from filming.",
]
profiles.append(influencer)
# --- Higher-risk crowd ------------------------------------------------------
@@ -162,12 +154,10 @@ static func shared_profiles() -> Array[NpcProfile]:
Rules.Reaction.LEAVE: 30,
},
}
trespasser.dialogue_lines = {
Rules.Tone.SUSPICIOUS: [
"I'm just here to see a friend, come on.",
"I'll be inside for ten seconds.",
],
}
trespasser.dialogue_lines = [
"I'm just here to see a friend, come on.",
"I'll be inside for ten seconds.",
]
profiles.append(trespasser)
var mental_crisis := NpcProfile.new()
@@ -278,12 +268,10 @@ static func shared_profiles() -> Array[NpcProfile]:
Rules.Reaction.FORCE_ENTRY: 20,
},
}
protester.dialogue_lines = {
Rules.Tone.SUSPICIOUS: [
"I have every right to be on public sidewalk.",
"You can't touch me, this is a lawful protest.",
],
}
protester.dialogue_lines = [
"I have every right to be on public sidewalk.",
"You can't touch me, this is a lawful protest.",
]
profiles.append(protester)
# --- Authorized crowd ---------------------------------------------------------
@@ -347,17 +335,17 @@ static func hollywood_building() -> VenueScenario:
])
scenario.event_pool = [
_building_routine("tourist_photo", Rules.NpcType.TOURIST, "photo", Vector2(0, 900), 0.35),
_building_routine("tourist_photo", Rules.NpcType.TOURIST, "photo", Vector2(0, 900), 0.42),
_building_routine("protest_gather", Rules.NpcType.PROTESTER, "protest", Vector2(300, 900), 0.18),
_building_routine("tourist_sit", Rules.NpcType.TOURIST, "sit", Vector2(0, 900), 0.20),
_building_routine("tourist_sit", Rules.NpcType.TOURIST, "sit", Vector2(0, 900), 0.26),
_building_routine("confused_enter", Rules.NpcType.CONFUSED_VISITOR, "enter", Vector2(0, 900), 0.25),
_building_routine("delivery_drop", Rules.NpcType.DELIVERY, "deliver", Vector2(0, 600), 0.30),
_building_routine("staff_entry", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 600), 0.35),
_building_routine("guest_entry", Rules.NpcType.AUTHORIZED_GUEST, "enter", Vector2(0, 900), 0.25),
_building_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.18),
_building_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.20),
_building_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.24),
_building_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.26),
_building_routine("intoxicated_wander", Rules.NpcType.INTOXICATED, "wander", Vector2(600, 900), 0.15),
_building_routine("trespass_test", Rules.NpcType.TRESPASSER, "trespass", Vector2(300, 900), 0.12),
_building_routine("trespass_test", Rules.NpcType.TRESPASSER, "trespass", Vector2(300, 900), 0.16),
{
"id": "crisis_event",
"phase": Rules.Phase.EVENING,
+17 -6
View File
@@ -2,15 +2,26 @@ class_name FSM
extends RefCounted
## Minimal generic finite state machine used by NPCController and ShiftCoordinator.
## Deterministic and headless-testable.
##
## The owner is held through a WeakRef so long-lived owners do not create a
## reference cycle (FSM <-> owner), which previously leaked RefCounted objects
## at process exit (see MEMORY.md).
signal state_changed(node: Object, from_state: String, to_state: String)
var current_state: String = ""
var _transitions: Dictionary = {} # state_name -> Array[{to, guard: Callable}]
var _owner_node: Object = null
var _owner_ref: WeakRef = null
func _init(owner_node: Object = null) -> void:
_owner_node = owner_node
if owner_node != null:
_owner_ref = weakref(owner_node)
## The live owner object, or null if it was freed.
func _owner() -> Object:
if _owner_ref == null:
return null
return _owner_ref.get_ref() as Object
## Register a transition. guard is an optional Callable(node) -> bool.
func add_transition(from_state: String, to_state: String, guard: Callable = Callable()) -> void:
@@ -20,7 +31,7 @@ func add_transition(from_state: String, to_state: String, guard: Callable = Call
func start(initial_state: String) -> void:
current_state = initial_state
state_changed.emit(_owner_node, "", initial_state)
state_changed.emit(_owner(), "", initial_state)
## Attempt to move to `to_state`. Returns true when executed.
func transition_to(to_state: String) -> bool:
@@ -31,10 +42,10 @@ func transition_to(to_state: String) -> bool:
if t["to"] != to_state:
continue
var guard: Callable = t["guard"]
if not guard.is_valid() or guard.call(_owner_node):
if not guard.is_valid() or guard.call(_owner()):
var from := current_state
current_state = to_state
state_changed.emit(_owner_node, from, to_state)
state_changed.emit(_owner(), from, to_state)
return true
return false
@@ -42,6 +53,6 @@ func can_transition_to(to_state: String) -> bool:
for t in _transitions.get(current_state, []):
if t["to"] == to_state:
var guard: Callable = t["guard"]
if not guard.is_valid() or guard.call(_owner_node):
if not guard.is_valid() or guard.call(_owner()):
return true
return false
+5 -11
View File
@@ -8,7 +8,7 @@ const FSM := preload("res://src/core/fsm.gd")
## reaction logic. The presentation layer adds a visual avatar and navigation.
## RefCounted on purpose: headless tests and CI do not need a scene tree.
signal interacted(level: int, tone: int)
signal interacted(level: int)
var npc_id: String = ""
var profile: NpcProfile = null
@@ -61,15 +61,10 @@ func _build_fsm(initial_state: String) -> void:
fsm.add_transition("THREATENING", "FORCING_ENTRY")
fsm.start(initial_state)
func roll_reaction(level: int, tone: int) -> int:
## Weighted pick from profile's reaction table for `level`, adjusted for tone.
func roll_reaction(level: int) -> int:
## Weighted pick from profile's reaction table for `level`.
highest_escalation_received = maxi(highest_escalation_received, level)
var weights: Dictionary = profile.pickup_reactions_per_level(level).duplicate()
var tone_mod: float = profile.tone_modifiers.get(tone, 0.0)
if tone_mod > 0.0 and weights.has(Rules.Reaction.COMPLY):
# Shift weight mass toward compliance proportionally.
var transferable: float = float(weights[Rules.Reaction.COMPLY]) * tone_mod
weights[Rules.Reaction.COMPLY] += transferable * 0.5
var roll_value := rng.randf()
var total := 0.0
for key in weights.keys():
@@ -83,16 +78,15 @@ func roll_reaction(level: int, tone: int) -> int:
return key
return Rules.Reaction.LEAVE
func apply_player_action(level: int, tone: int) -> Dictionary:
func apply_player_action(level: int) -> Dictionary:
## Returns reaction to the player's action and updates FSM when needed.
var performed_reaction := roll_reaction(level, tone)
var performed_reaction := roll_reaction(level)
reaction = performed_reaction
reaction_set = true
_update_state_for_reaction(performed_reaction)
return {
"npc_id": npc_id,
"level_used": level,
"tone": tone,
"reaction": performed_reaction,
"reaction_name": Rules.Reaction.find_key(performed_reaction),
"new_state": fsm.current_state,
+3 -11
View File
@@ -12,8 +12,7 @@ const Rules := preload("res://src/core/rules.gd")
## Affects how likely the NPC is to argue, point, or threaten at each level.
@export_range(0.0, 1.0) var temperament: float = 0.25
## Base compliance: probability the NPC obeys a level-1 polite direction.
## Venue/scenario recalculates effective compliance from profile + tone + level.
## Probability the NPC obeys a level-1 polite direction.
@export_range(0.0, 1.0) var base_compliance: float = 0.8
## Probability that an NPC directed away will circle back later in the shift
@@ -56,18 +55,11 @@ const Rules := preload("res://src/core/rules.gd")
},
}
@export var tone_modifiers: Dictionary = {
Rules.Tone.FRIENDLY: 0.10, # + compliance
Rules.Tone.PROFESSIONAL: 0.00,
Rules.Tone.FIRM: -0.05,
Rules.Tone.SUSPICIOUS: -0.15,
}
## Delay the NPC waits before reacting to the player (frames/seconds scaled).
@export_range(0.1, 5.0) var reaction_delay: float = 0.6
## Dialogue lines per tone and intent. Falls back to generic lines when empty.
@export var dialogue_lines: Dictionary = {}
## Dialogue lines this NPC uses. Falls back to generic lines when empty.
@export var dialogue_lines: Array[String] = []
## Items the NPC is carrying, used by the handheld wand (The Joke Shop).
## Values are item names: "keys", "belt", "phone", "weapon", "knife", etc.
-15
View File
@@ -108,21 +108,6 @@ enum Reaction {
CALL_SOMEONE = 8,
}
## Player dialogue tones.
enum Tone {
PROFESSIONAL = 0,
FRIENDLY = 1,
FIRM = 2,
SUSPICIOUS = 3,
}
const TONE_NAMES := {
Tone.PROFESSIONAL: "Professional",
Tone.FRIENDLY: "Friendly",
Tone.FIRM: "Firm",
Tone.SUSPICIOUS: "Suspicious",
}
## Shift phases by time of day.
enum Phase {
EARLY = 0,
+1 -1
View File
@@ -1,5 +1,5 @@
class_name ShiftCoordinator
extends Node
extends RefCounted
const Rules := preload("res://src/core/rules.gd")
const EventBus := preload("res://src/core/event_bus.gd")
+17
View File
@@ -68,6 +68,23 @@ func _spot_for_intent(intent: String) -> String:
_:
return "sidewalk"
## Pick a random door-staff line for a context key (e.g. "id_check",
## "ticket_check", "wand_check", "refusal", "prohibited_item"). Returns ""
## when the venue has no lines for that context. Deterministic when a seeded
## RNG is injected; otherwise returns the first line.
func door_staff_line(context: String, rng: RandomNumberGenerator = null) -> String:
if not door_staff_lines.has(context):
return ""
var lines: Variant = door_staff_lines[context]
if not (lines is Array):
return ""
var pool: Array = lines
if pool.is_empty():
return ""
if rng != null:
return str(pool[rng.randi_range(0, pool.size() - 1)])
return str(pool[0])
func _location_or_default(spot: String, fallback: Vector3) -> Vector3:
if locations.has(spot):
var v: Variant = locations[spot]
+149 -31
View File
@@ -27,6 +27,35 @@ var door := DoorState.new()
var flashlight_on := false
var coordinator: ShiftCoordinator = null
var guard: GuardController = null
var _sun: DirectionalLight3D = null
var _env_node: WorldEnvironment = null
var _sky: ProceduralSkyMaterial = null
var _last_phase: int = -1
## Per-phase lighting looks for the sun, sky, and ambient (PLAN.md: shift
## structure — phase-based lighting). Night phases switch street lamps on.
const PHASE_LIGHTING := {
Rules.Phase.EARLY: {
"sun_energy": 1.2, "sun_angle": -50.0, "sun_color": Color(1.0, 0.98, 0.92),
"sky_top": Color(0.32, 0.55, 0.95), "sky_horizon": Color(0.82, 0.88, 0.95),
"sky_ground": Color(0.45, 0.5, 0.55), "ambient": Color(0.75, 0.78, 0.85), "night": false,
},
Rules.Phase.MIDDAY: {
"sun_energy": 1.5, "sun_angle": -62.0, "sun_color": Color(1.0, 1.0, 0.98),
"sky_top": Color(0.3, 0.52, 0.95), "sky_horizon": Color(0.85, 0.9, 0.96),
"sky_ground": Color(0.5, 0.54, 0.58), "ambient": Color(0.85, 0.88, 0.95), "night": false,
},
Rules.Phase.EVENING: {
"sun_energy": 0.7, "sun_angle": -16.0, "sun_color": Color(1.0, 0.72, 0.5),
"sky_top": Color(0.62, 0.32, 0.35), "sky_horizon": Color(1.0, 0.68, 0.42),
"sky_ground": Color(0.3, 0.24, 0.3), "ambient": Color(0.55, 0.45, 0.5), "night": true,
},
Rules.Phase.LATE: {
"sun_energy": 0.05, "sun_angle": -8.0, "sun_color": Color(0.3, 0.35, 0.5),
"sky_top": Color(0.02, 0.03, 0.08), "sky_horizon": Color(0.1, 0.12, 0.2),
"sky_ground": Color(0.05, 0.05, 0.08), "ambient": Color(0.08, 0.09, 0.16), "night": true,
},
}
var hud: Hud = null
var radio_panel: RadioPanel = null
var shift_review_panel: ShiftReview = null
@@ -35,7 +64,11 @@ var verifier := AccessVerifier.new()
var wand_screener := WandScreener.new()
var active_scenario_id := "hollywood_building"
var scenario_duration_s: float = 300.0 # shortened prototype duration for testing
## Shift length mirrors the scenario default (900s / 10-15 min per PLAN.md) unless
## overridden for a test or debug build.
var scenario_duration_s: float = 900.0
var _wand_cooldown := 0.0
const WAND_COOLDOWN_S := 0.8
var scene_root: Node3D = null
var npcs: Array = []
var npc_counter := 0
@@ -46,15 +79,19 @@ var current_incident_npc_id := ""
var current_incident_active := false
var current_npc_truth := {"weapons_seen": false, "danger_level": 0}
## Dialogue tone picker (PLAN.md: Level 1 tones). Cycled with T.
var selected_tone: int = Rules.Tone.PROFESSIONAL
const TONE_ORDER := [Rules.Tone.PROFESSIONAL, Rules.Tone.FRIENDLY, Rules.Tone.FIRM, Rules.Tone.SUSPICIOUS]
## Venue cycling (V): next venue in the scenario list.
const VENUE_ORDER := ["hollywood_building", "joke_shop", "tv_studio"]
var _venue_index := 0
## RNG for venue door-staff lines so repeated interactions vary.
var _door_rng := RandomNumberGenerator.new()
## Aim-free targeting: cycle through nearby NPCs with Tab (accessibility).
const NEARBY_RADIUS := 12.0
var _nearby_index := -1
func _ready() -> void:
_door_rng.randomize()
scene_root = Node3D.new()
scene_root.name = "SceneRoot"
add_child(scene_root)
@@ -70,6 +107,7 @@ func _ready() -> void:
guard.cleared_target.connect(_on_guard_clear)
selected_scenario = _build_scenario(active_scenario_id)
scenario_duration_s = selected_scenario.shift_duration_s
coordinator = ShiftCoordinator.new()
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
coordinator.npc_spawn_requested.connect(_spawn_npc)
@@ -78,6 +116,8 @@ func _ready() -> void:
_build_ui()
coordinator.start()
_last_phase = -1
_apply_phase_lighting()
var selected_scenario: VenueScenario = null
@@ -107,6 +147,7 @@ func _build_guard() -> GuardController:
collider.shape = shape
collider.position = Vector3(0, 0.85, 0)
g.add_child(collider)
g.add_to_group("guard")
return g
func _build_scenario(id: String) -> VenueScenario:
@@ -133,8 +174,11 @@ func restart_shift() -> void:
inspector_current_events = []
current_incident_active = false
current_incident_npc_id = ""
scenario_duration_s = selected_scenario.shift_duration_s
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
coordinator.start()
_last_phase = -1
_apply_phase_lighting()
var inspector_current_events: Array = []
@@ -156,17 +200,44 @@ func _build_ui() -> void:
shift_review_panel.restart_requested.connect(restart_shift)
func _ensure_lighting() -> void:
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-50, -30, 0)
sun.light_energy = 1.2
scene_root.add_child(sun)
var env_node := WorldEnvironment.new()
_sun = DirectionalLight3D.new()
_sun.name = "Sun"
_sun.rotation_degrees = Vector3(-50, -30, 0)
_sun.light_energy = 1.2
_sun.shadow_enabled = true
_sun.light_angular_distance = 1.2
_sun.directional_shadow_mode = DirectionalLight3D.SHADOW_PARALLEL_4_SPLITS
_sun.directional_shadow_max_distance = 60.0
scene_root.add_child(_sun)
_env_node = WorldEnvironment.new()
var env := Environment.new()
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.7, 0.7, 0.75)
env.background_mode = Environment.BG_SKY
_sky = ProceduralSkyMaterial.new()
var sky := Sky.new()
sky.sky_material = _sky
env.sky = sky
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
env.ambient_light_energy = 0.5
env_node.environment = env
scene_root.add_child(env_node)
env.reflected_light_source = Environment.REFLECTION_SOURCE_SKY
_env_node.environment = env
scene_root.add_child(_env_node)
## Apply the lighting look for the current shift phase (sun, sky, ambient,
## street lamps). Runs on phase change and once at shift start.
func _apply_phase_lighting() -> void:
var phase: int = coordinator.current_phase if coordinator else Rules.Phase.EARLY
var cfg: Dictionary = PHASE_LIGHTING.get(phase, PHASE_LIGHTING[Rules.Phase.EARLY])
if _sun:
_sun.light_energy = float(cfg.get("sun_energy", 1.0))
_sun.light_color = cfg.get("sun_color", Color.WHITE)
_sun.rotation_degrees = Vector3(float(cfg.get("sun_angle", -50.0)), -30, 0)
if _sky:
_sky.sky_top_color = cfg.get("sky_top", Color.BLUE)
_sky.sky_horizon_color = cfg.get("sky_horizon", Color.WHITE)
_sky.ground_bottom_color = cfg.get("sky_ground", Color(0.5, 0.5, 0.5))
_sky.ground_horizon_color = cfg.get("sky_horizon", Color.WHITE)
if world_builder:
world_builder.set_night_mode(cfg.get("night", false))
func _spawn_npc(profile: NpcProfile, intent: String, _npc_id: String) -> void:
npc_counter += 1
@@ -266,12 +337,10 @@ func _handle_key(keycode: Key) -> void:
_toggle_inspection()
KEY_Q:
if targeted_npc != null and not inspection_open:
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, selected_tone, "polite direction")
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "polite direction")
KEY_W:
if targeted_npc != null and not inspection_open:
_toggle_wand_screening()
KEY_T:
_cycle_tone()
KEY_TAB:
_cycle_nearby_target()
KEY_F:
@@ -280,21 +349,23 @@ func _handle_key(keycode: Key) -> void:
_toggle_flashlight()
KEY_L:
_toggle_door_lock()
KEY_V:
_cycle_venue()
_:
_handle_action_key(keycode)
func _handle_action_key(keycode: Key) -> void:
match keycode:
KEY_1:
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, selected_tone, "raise hand")
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "raise hand")
KEY_2:
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, selected_tone, "point")
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "point")
KEY_3:
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, selected_tone, "head shake")
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "head shake")
KEY_4:
_apply_interaction(Rules.Level.FORMAL_WARNING, selected_tone, "formal warning")
_apply_interaction(Rules.Level.FORMAL_WARNING, "formal warning")
KEY_5:
_apply_interaction(Rules.Level.BLOCKING, selected_tone, "blocking")
_apply_interaction(Rules.Level.BLOCKING, "blocking")
KEY_6:
_open_radio(false)
KEY_R:
@@ -331,13 +402,15 @@ func _toggle_door_lock() -> void:
hud.set_door(door.locked)
hud.log(Strings.lookup("door.locked") if door.locked else Strings.lookup("door.unlocked"))
## Cycle the dialogue tone (Professional → Friendly → Firm → Suspicious).
func _cycle_tone() -> void:
var idx := TONE_ORDER.find(selected_tone)
idx = (idx + 1) % TONE_ORDER.size()
selected_tone = TONE_ORDER[idx]
hud.set_tone(Rules.TONE_NAMES.get(selected_tone, "?"))
hud.log("Tone: %s" % Rules.TONE_NAMES.get(selected_tone, "?"))
## Cycle to the next venue (Hollywood Blvd → The Joke Shop → TV Studio).
## The coordinator is rebuilt with the new scenario and the shift restarts.
func _cycle_venue() -> void:
_venue_index = (_venue_index + 1) % VENUE_ORDER.size()
switch_scenario(VENUE_ORDER[_venue_index])
var venue_name: String = selected_scenario.display_name if selected_scenario else "?"
hud.log(Strings.fmt("hud.venue_switched", [venue_name]))
hud.set_wand_icon(false)
hud.set_shift_state(coordinator, selected_scenario)
## Aim-free targeting: cycle through NPCs near the guard (Tab). Lets players
## interact without precise aiming (accessibility).
@@ -359,20 +432,19 @@ func _cycle_nearby_target() -> void:
_begin_incident(next_target)
hud.log(Strings.fmt("hud.targeting", [next_target.name]))
func _apply_interaction(level: int, tone: int, action_name: String) -> void:
func _apply_interaction(level: int, action_name: String) -> void:
if targeted_npc == null:
hud.log(Strings.lookup("hud.aim_first"))
return
var controller: Object = _targeted_controller()
if controller == null or coordinator == null:
return
var outcome: Dictionary = controller.apply_player_action(level, tone)
var outcome: Dictionary = controller.apply_player_action(level)
var npc_id: String = controller.npc_id
coordinator.recorder.add_event(npc_id, {
"type": "player_action",
"action": action_name,
"level": level,
"tone": Rules.TONE_NAMES.get(tone, "?"),
})
coordinator.recorder.add_escalation(npc_id, level, "%s on %s" % [action_name, npc_id])
if level >= Rules.Level.FORMAL_WARNING:
@@ -454,6 +526,13 @@ func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
coordinator.crowd.apply_event("calm_professionalism")
if audio:
audio.play_radio_response()
_maybe_radio_easter_egg()
## Rare hidden radio transmission: a stray voice asks "¿Dónde está la mota?"
## over the radio. Fires on a small random chance after a successful call.
func _maybe_radio_easter_egg() -> void:
if _door_rng.randf() < 0.025:
hud.log(Strings.lookup("radio.easter_egg"))
# ----------------------------------------------------------------- access --
@@ -469,19 +548,29 @@ func _toggle_inspection() -> void:
var artifacts := _artifacts_for(controller)
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy)
hud.show_inspection(controller, artifacts, result)
# Venue door-staff flavor: an applicable check line (PLAN.md: door-staff dialogue).
var line := _door_context_line()
if line != "":
hud.log(line)
# ------------------------------------------------------------- wand screening --
## Handheld wand inspection (The Joke Shop). Only available when the venue
## carries a wand. The wand reports an alert; the player interprets it.
func _toggle_wand_screening() -> void:
if _wand_cooldown > 0.0:
hud.log(Strings.lookup("wand.recharge"))
return
if wand_open:
hud.hide_wand()
hud.set_wand_icon(false)
wand_open = false
return
if not selected_scenario.equipment.has("wand"):
hud.log(Strings.lookup("hud.no_wand"))
hud.set_wand_icon(false)
return
hud.set_wand_icon(true)
var controller: Object = _targeted_controller()
if controller == null:
return
@@ -490,6 +579,9 @@ func _toggle_wand_screening() -> void:
var result: Dictionary = wand_screener.screen(items)
wand_open = true
hud.show_wand(controller, result)
var wand_line := selected_scenario.door_staff_line("wand_check", _door_rng)
if wand_line != "":
hud.log(wand_line)
## Resolve a wand screening: the player decides whether to admit the guest.
func decide_wand(allowed: bool) -> void:
@@ -514,6 +606,11 @@ func decide_wand(allowed: bool) -> void:
if decision.get("prohibited", false) and allowed:
hud.log(Strings.fmt("wand.admitted_prohibited", [name]))
coordinator.reputation.apply("security_failure", -5.0, "admitted prohibited item")
elif decision.get("prohibited", false) and not allowed:
var ban_line := selected_scenario.door_staff_line("prohibited_item", _door_rng)
if ban_line != "":
hud.log(ban_line)
coordinator.reputation.apply("access_judgment", 1.0, "correct prohibited-item refusal")
elif decision.get("false_alarm", false) and not allowed:
hud.log(Strings.fmt("wand.denied_false_alarm", [name]))
coordinator.reputation.apply("complaint", -2.0, "false-alarm refusal")
@@ -527,6 +624,8 @@ func decide_wand(allowed: bool) -> void:
controller.screened = true
hud.hide_wand()
wand_open = false
# Short scan cooldown: the wand must “recharge” between guests (PLAN.md: feel).
_wand_cooldown = WAND_COOLDOWN_S
func decide_access(allowed: bool) -> void:
var controller: Object = _targeted_controller()
@@ -557,6 +656,9 @@ func decide_access(allowed: bool) -> void:
audio.play_confirm()
else:
hud.log(Strings.fmt("access.denied", ["; ".join(result.get("issues", []))]))
var refusal_line := selected_scenario.door_staff_line("refusal", _door_rng)
if refusal_line != "":
hud.log(refusal_line)
coordinator.reputation.apply("access_judgment", 1.0, "correct refusal")
if audio:
audio.play_deny()
@@ -571,6 +673,17 @@ func decide_access(allowed: bool) -> void:
hud.hide_inspection()
inspection_open = false
## Venue-specific opening line for the access inspection (e.g. The Joke Shop's
## ID / ticket check lines). Returns "" when the venue has no matching lines.
func _door_context_line() -> String:
if selected_scenario == null:
return ""
if selected_scenario.access_policy.require_id:
return selected_scenario.door_staff_line("id_check", _door_rng)
if selected_scenario.access_policy.requires(Rules.AccessKind.TICKET):
return selected_scenario.door_staff_line("ticket_check", _door_rng)
return ""
func _artifacts_for(controller: Object) -> Array:
var profile: Object = controller.profile
var npc_type: int = profile.npc_type if profile else -1
@@ -612,9 +725,14 @@ func _close_incident(outcome: String) -> void:
current_incident_npc_id = ""
func _physics_process(delta: float) -> void:
if _wand_cooldown > 0.0:
_wand_cooldown = maxf(0.0, _wand_cooldown - delta)
if coordinator == null or not coordinator.running:
return
coordinator.tick(delta)
if coordinator.current_phase != _last_phase:
_last_phase = coordinator.current_phase
_apply_phase_lighting()
hud.set_shift_state(coordinator, selected_scenario)
_perception_pass()
+124 -19
View File
@@ -41,6 +41,7 @@ func get_npc_controller() -> NPCController:
func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3, p_door: Vector3 = Vector3(0, 0.5, 1.2)) -> void:
npc.setup("npc_%d" % seed_value, profile, intent, seed_value)
_visual_seed = seed_value
spawn_position = p_spawn
target_position = p_target
door_position = p_door
@@ -66,33 +67,137 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector
collider.position = Vector3(0, 0.8, 0)
add_child(collider)
var _visual_seed := 0
var _rig: Node3D = null
var _head: Node3D = null
var _bob_phase := 0.0
var _guard_ref: Node3D = null
const SKIN_TONE := Color(0.88, 0.72, 0.60)
const PANTS_COLOR := Color(0.22, 0.22, 0.26)
func _build_body(npc_type: int) -> void:
var body := MeshInstance3D.new()
var capsule := CapsuleMesh.new()
capsule.radius = 0.35
capsule.height = 1.6
body.mesh = capsule
_rig = Node3D.new()
_rig.name = "Rig"
add_child(_rig)
var base_color: Color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
# Per-seed variation: slight height and brightness differences so crowds
# read as individuals (deterministic LCG, no RNG side effects).
var frac := float((_visual_seed * 1103515245 + 12345) % 1000) / 1000.0
var height_scale := 0.92 + 0.16 * frac
_rig.scale.y = height_scale
var shader_delta := 0.12 * frac
base_color = base_color.lightened(shader_delta) if frac < 0.5 else base_color.darkened(shader_delta)
var limbs_color := base_color.darkened(0.25)
# Torso
_add_capsule(_rig, 0.30, 0.95, Vector3(0, 1.05, 0), base_color)
# Legs
_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), PANTS_COLOR)
_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), PANTS_COLOR)
# Arms
_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0)
_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0)
# Head
_head = Node3D.new()
_head.name = "Head"
_head.position = Vector3(0, 1.62, 0)
_add_sphere(_head, 0.17, Vector3.ZERO, SKIN_TONE)
_rig.add_child(_head)
_add_props(npc_type)
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> void:
var mi := MeshInstance3D.new()
var mesh := CapsuleMesh.new()
mesh.radius = radius
mesh.height = height
mi.mesh = mesh
mi.position = pos
mi.rotation_degrees.z = z_rot
mi.material_override = _material(color)
parent.add_child(mi)
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color) -> void:
var mi := MeshInstance3D.new()
var mesh := SphereMesh.new()
mesh.radius = radius
mesh.height = radius * 2.0
mi.mesh = mesh
mi.position = pos
mi.material_override = _material(color)
parent.add_child(mi)
func _material(color: Color) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
body.material_override = mat
add_child(body)
var id_tag := MeshInstance3D.new()
var sphere := SphereMesh.new()
sphere.radius = 0.12
sphere.height = 0.24
var tag_mat := StandardMaterial3D.new()
tag_mat.albedo_color = Color(1, 1, 1)
id_tag.mesh = sphere
id_tag.material_override = tag_mat
id_tag.position = Vector3(0, 1.05, 0)
id_tag.scale = Vector3(1, 0.4, 1)
add_child(id_tag)
mat.albedo_color = color
return mat
## Small per-archetype props so silhouettes differ (camera, box, hat, sign…).
func _add_props(npc_type: int) -> void:
var chest_y := 1.25
match npc_type:
Rules.NpcType.INFLUENCER:
_add_box_prop(_rig, Vector3(0.16, 0.1, 0.09), Vector3(0.3, chest_y, 0.22), Color(0.1, 0.1, 0.1))
_add_box_prop(_rig, Vector3(0.05, 0.05, 0.06), Vector3(0.34, chest_y + 0.06, 0.30), Color(0.4, 0.7, 1.0))
Rules.NpcType.DELIVERY:
_add_box_prop(_rig, Vector3(0.34, 0.26, 0.28), Vector3(0.0, 1.02, 0.3), Color(0.55, 0.4, 0.25))
Rules.NpcType.PERFORMER:
var hat := _material(Color(0.85, 0.2, 0.25))
var cone := MeshInstance3D.new()
var cm := CylinderMesh.new()
cm.top_radius = 0.02
cm.bottom_radius = 0.22
cm.height = 0.4
cone.mesh = cm
cone.material_override = hat
cone.position = Vector3(0, 0.26, 0)
_head.add_child(cone)
Rules.NpcType.PROTESTER:
_add_box_prop(_rig, Vector3(0.05, 1.2, 0.05), Vector3(0.28, 1.45, 0.18), Color(0.4, 0.36, 0.32))
_add_box_prop(_rig, Vector3(0.5, 0.42, 0.04), Vector3(0.48, 1.55, 0.18), Color(0.93, 0.93, 0.95))
Rules.NpcType.TOURIST:
_add_box_prop(_rig, Vector3(0.3, 0.36, 0.14), Vector3(-0.34, 1.15, -0.18), Color(0.25, 0.3, 0.45))
Rules.NpcType.CONFUSED_VISITOR:
_add_box_prop(_rig, Vector3(0.3, 0.05, 0.34), Vector3(0, 0.92, 0.06), Color(0.9, 0.88, 0.8))
func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) -> void:
var mi := MeshInstance3D.new()
var mesh := BoxMesh.new()
mesh.size = size
mi.mesh = mesh
mi.position = pos
mi.material_override = _material(color)
parent.add_child(mi)
func _physics_process(delta: float) -> void:
if npc == null or npc.fsm == null:
return
_advance_fsm(delta)
_move_toward_target(delta)
_animate(delta)
## Procedural walk bob + head-toward-guard cue when the NPC is aware.
func _animate(delta: float) -> void:
if _rig == null:
return
var speed := Vector2(velocity.x, velocity.z).length()
if speed > 0.3:
_bob_phase += delta * (6.0 + speed)
_rig.position.y = sin(_bob_phase) * 0.035
_rig.rotation.x = sin(_bob_phase * 0.5) * 0.04
else:
_bob_phase = 0.0
_rig.position.y = lerpf(_rig.position.y, 0.0, 0.2)
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
# Attention cue: head turns toward the guard when awareness was raised.
if _head != null and npc.guard_noticed:
if _guard_ref == null or not is_instance_valid(_guard_ref):
_guard_ref = get_tree().get_first_node_in_group("guard") as Node3D
if _guard_ref:
var to_guard := (_guard_ref.global_position - global_position)
to_guard.y = 0.0
var world_yaw := atan2(to_guard.x, to_guard.z)
var fwd := -_rig.global_transform.basis.z
var parent_yaw := atan2(fwd.x, fwd.z)
_head.rotation.y = lerp_angle(_head.rotation.y, world_yaw - parent_yaw, 0.1)
func _advance_fsm(delta: float) -> void:
# Returning-trespasser: an NPC directed away may circle back to the door.
+170 -18
View File
@@ -5,6 +5,23 @@ extends CanvasLayer
const Rules := preload("res://src/core/rules.gd")
const Strings := preload("res://src/core/strings.gd")
const T_VIGNETTE := preload("res://assets/textures/vignette.png")
const T_ICON_FLASH := preload("res://assets/textures/icons/flashlight_on.png")
const T_ICON_DOOR := preload("res://assets/textures/icons/door_locked.png")
const T_ICON_WAND := preload("res://assets/textures/icons/wand.png")
const T_ICON_REC := preload("res://assets/textures/icons/rec_dot.png")
const T_CARD_ID := preload("res://assets/textures/cards/id_card.png")
const T_CARD_WRIST := preload("res://assets/textures/cards/wristband.png")
const T_CARD_TICKET := preload("res://assets/textures/cards/ticket.png")
const T_CARD_CRED := preload("res://assets/textures/cards/credential.png")
const T_CARD_GUEST := preload("res://assets/textures/cards/guest_list.png")
const CARD_TEXTURES := {
Rules.AccessKind.ID: T_CARD_ID,
Rules.AccessKind.WRISTBAND: T_CARD_WRIST,
Rules.AccessKind.TICKET: T_CARD_TICKET,
Rules.AccessKind.CREDENTIAL: T_CARD_CRED,
Rules.AccessKind.GUEST_LIST: T_CARD_GUEST,
}
var top_label: Label
var time_label: Label
@@ -17,10 +34,15 @@ var inspect_text: Label
var inspect_meta: Label
var radio_cooldown_label: Label
var mode_label: Label
var vignette_rect: TextureRect
var cam_clock: Label
var icon_row: HBoxContainer
var flashlight_icon: TextureRect
var door_icon: TextureRect
var wand_icon: TextureRect
var wand_panel: PanelContainer
var wand_text: Label
var wand_meta: Label
var tone_label: Label
var escalation_label: Label
var _debug_enabled := false
@@ -99,13 +121,6 @@ func _init() -> void:
mode_label.modulate = Color(0.85, 0.85, 0.9)
vbox.add_child(mode_label)
# --- dialogue tone indicator ---
tone_label = Label.new()
tone_label.text = "Tone: Professional"
tone_label.add_theme_font_size_override("font_size", 13)
tone_label.modulate = Color(0.7, 0.85, 1.0)
vbox.add_child(tone_label)
# --- escalation level indicator (color-coded) ---
escalation_label = Label.new()
escalation_label.text = "Escalation: Observation"
@@ -113,10 +128,64 @@ func _init() -> void:
escalation_label.modulate = Color(0.6, 0.9, 0.6)
vbox.add_child(escalation_label)
# --- escalation vignette (screen-space tint) ---
vignette_rect = TextureRect.new()
vignette_rect.texture = T_VIGNETTE
vignette_rect.set_anchors_preset(Control.PRESET_FULL_RECT)
vignette_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
vignette_rect.modulate = Color(1, 1, 1, 0)
vignette_rect.show_behind_parent = false
add_child(vignette_rect)
# --- body-camera overlay (REC + shift clock + corner brackets, top right) ---
var cam_box := VBoxContainer.new()
cam_box.set_anchors_preset(Control.PRESET_TOP_RIGHT)
cam_box.position = Vector2(-180, 50)
cam_box.alignment = BoxContainer.ALIGNMENT_END
add_child(cam_box)
var rec_row := HBoxContainer.new()
rec_row.alignment = BoxContainer.ALIGNMENT_END
cam_box.add_child(rec_row)
var rec_icon := TextureRect.new()
rec_icon.texture = T_ICON_REC
rec_icon.custom_minimum_size = Vector2(22, 22)
rec_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
rec_row.add_child(rec_icon)
var rec_label := Label.new()
rec_label.text = "REC"
rec_label.add_theme_font_size_override("font_size", 15)
rec_label.modulate = Color(0.95, 0.35, 0.35)
rec_row.add_child(rec_label)
cam_clock = Label.new()
cam_clock.text = "SHIFT 00:00:00"
cam_clock.add_theme_font_size_override("font_size", 12)
cam_clock.modulate = Color(0.95, 0.95, 0.95)
cam_clock.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
cam_box.add_child(cam_clock)
# corner brackets
var bracket_color := Color(0.9, 0.9, 0.92, 0.55)
_add_corner_brackets(bracket_color)
# --- mode icons next to the door/light label ---
icon_row = HBoxContainer.new()
vbox.add_child(icon_row)
flashlight_icon = _make_icon(T_ICON_FLASH)
flashlight_icon.visible = false
icon_row.add_child(flashlight_icon)
door_icon = _make_icon(T_ICON_DOOR)
door_icon.visible = false
icon_row.add_child(door_icon)
wand_icon = _make_icon(T_ICON_WAND)
wand_icon.visible = false
icon_row.add_child(wand_icon)
# --- inspect panel (popup center) ---
var inspect_center := CenterContainer.new()
inspect_center.set_anchors_preset(Control.PRESET_FULL_RECT)
inspect_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(inspect_center)
inspect_panel = PanelContainer.new()
inspect_panel.visible = false
inspect_panel.set_anchors_preset(Control.PRESET_CENTER)
inspect_panel.modulate.a = 1.0
var ivbox := VBoxContainer.new()
inspect_panel.add_child(ivbox)
@@ -131,12 +200,15 @@ func _init() -> void:
insp_hint.text = "A = allow entry D = deny entry"
insp_hint.add_theme_font_size_override("font_size", 12)
ivbox.add_child(insp_hint)
add_child(inspect_panel)
inspect_center.add_child(inspect_panel)
# --- wand screening panel (popup center) ---
var wand_center := CenterContainer.new()
wand_center.set_anchors_preset(Control.PRESET_FULL_RECT)
wand_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(wand_center)
wand_panel = PanelContainer.new()
wand_panel.visible = false
wand_panel.set_anchors_preset(Control.PRESET_CENTER)
var wvbox := VBoxContainer.new()
wand_panel.add_child(wvbox)
wand_text = Label.new()
@@ -150,7 +222,7 @@ func _init() -> void:
wand_hint.text = "A = admit D = refuse entry"
wand_hint.add_theme_font_size_override("font_size", 12)
wvbox.add_child(wand_hint)
add_child(wand_panel)
wand_center.add_child(wand_panel)
# --- debug overlay ---
debug_label = Label.new()
@@ -171,6 +243,9 @@ func set_shift_state(coordinator: Object, scenario: Resource) -> void:
coordinator.reputation.rating,
coordinator.crowd.mood * 100.0,
]
if cam_clock:
var total := int(coordinator.elapsed_s)
cam_clock.text = "SHIFT %02d:%02d:%02d" % [total / 3600, (total / 60) % 60, total % 60]
_update_radio_cooldown(coordinator)
func _update_radio_cooldown(coordinator: Object) -> void:
@@ -196,27 +271,72 @@ func set_flashlight(on: bool) -> void:
func _refresh_mode_label() -> void:
mode_label.text = "Door: %s · Light: %s" % ["LOCKED" if _door_locked else "open", "on" if _flashlight else "off"]
if icon_row:
flashlight_icon.visible = _flashlight
door_icon.visible = _door_locked
func set_wand_icon(visible: bool) -> void:
if wand_icon:
wand_icon.visible = visible
func _make_icon(texture: Texture2D) -> TextureRect:
var tr := TextureRect.new()
tr.texture = texture
tr.custom_minimum_size = Vector2(20, 20)
tr.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
tr.modulate = Color(1, 1, 1, 0.9)
return tr
func _add_corner_brackets(color: Color) -> void:
const LEN := 34.0
const TH := 3.0
const OFF := 10.0
var viewport := get_viewport()
var vp: Vector2 = viewport.get_visible_rect().size if viewport else Vector2(1280, 720)
var corners := [
Vector2(OFF, OFF), Vector2(vp.x - OFF, OFF), Vector2(OFF, vp.y - OFF), Vector2(vp.x - OFF, vp.y - OFF),
]
for i in 4:
var c: Vector2 = corners[i]
var flip_x: bool = (i % 2) == 1
var flip_y: bool = i >= 2
var h := ColorRect.new()
h.color = color
h.size = Vector2(LEN, TH)
h.position = Vector2(c.x - (LEN if flip_x else 0.0), c.y - (TH if flip_y else 0.0))
h.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(h)
var v := ColorRect.new()
v.color = color
v.size = Vector2(TH, LEN)
v.position = Vector2(c.x - (TH if flip_x else 0.0), c.y - (LEN if flip_y else 0.0))
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(v)
func set_action_hint(text: String) -> void:
hint_label.text = text
func set_tone(tone_name: String) -> void:
tone_label.text = Strings.fmt("hud.tone_prefix", [tone_name])
## Color-coded escalation feedback (PLAN.md: Polish and Accessibility).
## Green → yellow → orange → red as the escalation level rises.
func set_escalation(level: int) -> void:
var name: String = Rules.LEVEL_NAMES.get(level, "Observation")
escalation_label.text = Strings.fmt("hud.escalation_prefix", [name])
var tint := Color(0.6, 0.9, 0.6)
match level:
Rules.Level.OBSERVATION, Rules.Level.SIMPLE_DETERRENCE:
escalation_label.modulate = Color(0.6, 0.9, 0.6)
tint = Color(0.6, 0.9, 0.6)
Rules.Level.FORMAL_WARNING:
escalation_label.modulate = Color(0.95, 0.9, 0.4)
tint = Color(0.95, 0.9, 0.4)
Rules.Level.BLOCKING:
escalation_label.modulate = Color(1.0, 0.65, 0.3)
tint = Color(1.0, 0.65, 0.3)
Rules.Level.RADIO_ASSIST, Rules.Level.EMERGENCY:
escalation_label.modulate = Color(1.0, 0.35, 0.3)
tint = Color(1.0, 0.35, 0.3)
escalation_label.modulate = tint
# Screen-space vignette follows the escalation color.
if vignette_rect:
vignette_rect.modulate = Color(tint.r, tint.g, tint.b, 0.05 + 0.2 * float(level) / 5.0)
func log(text: String) -> void:
_log_lines.append(text)
@@ -249,6 +369,28 @@ func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -
var npc_display: String = (profile.display_name if profile else "Person")
var granted: bool = result.get("granted", false)
inspect_text.text = "Access check — %s\nGranted: %s" % [npc_display, "YES" if granted else "NO"]
_clear_card_row()
var card_row := HBoxContainer.new()
card_row.name = "INSPECT_CARD_ROW"
card_row.add_theme_constant_override("separation", 6)
var added_card := false
for artifact in artifacts:
var tex: Texture2D = CARD_TEXTURES.get(artifact.kind, null) as Texture2D
if tex == null:
continue
var card := TextureRect.new()
card.texture = tex
card.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
card.custom_minimum_size = Vector2(92, 58)
card.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
card.tooltip_text = artifact.summary()
if artifact.attributes.size() > 0:
card.tooltip_text += "\n" + str(artifact.attributes)
card_row.add_child(card)
added_card = true
if added_card:
var content := inspect_panel.get_child(0) as VBoxContainer
content.add_child(card_row)
var lines: Array[String] = []
for artifact in artifacts:
lines.append(" • " + artifact.summary())
@@ -260,8 +402,18 @@ func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -
inspect_panel.visible = true
func hide_inspection() -> void:
_clear_card_row()
inspect_panel.visible = false
## Remove previously added artifact cards so reopening rebuilds them.
func _clear_card_row() -> void:
if inspect_panel == null:
return
var row := inspect_panel.find_child("INSPECT_CARD_ROW", true, false) as Control
if row:
row.get_parent().remove_child(row)
row.queue_free()
func show_wand(controller: Object, result: Dictionary) -> void:
var profile: Object = controller.profile
var npc_display: String = (profile.display_name if profile else "Person")
+5 -2
View File
@@ -34,9 +34,12 @@ func _init() -> void:
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(center)
panel = PanelContainer.new()
panel.set_anchors_preset(Control.PRESET_CENTER)
add_child(panel)
center.add_child(panel)
var vbox := VBoxContainer.new()
panel.add_child(vbox)
+168 -12
View File
@@ -1,5 +1,11 @@
class_name WorldBuilder
## Procedurally assembles a minimal Hollywood Boulevard entrance prototype:
const T_BRICK := preload("res://assets/textures/brick_facade.png")
const T_CONCRETE := preload("res://assets/textures/concrete.png")
const T_ASPHALT := preload("res://assets/textures/asphalt.png")
const T_AWNING := preload("res://assets/textures/awning.png")
const T_SKYLINE := preload("res://assets/textures/skyline.png")
## steps, building facade, restricted door, sidewalk, pavement, and street edge.
## No art assets required; all geometry is BoxMesh with flat materials.
@@ -7,6 +13,12 @@ var root: Node3D = null
## Door lock indicator mesh near the handle; toggled by GameManager.
var door_indicator: MeshInstance3D = null
var _door_indicator_mat: StandardMaterial3D = null
## Street lamps and signage so night mode can switch them on.
var _street_lamps: Array[MeshInstance3D] = []
var _lamp_omni: Array[OmniLight3D] = []
var _lamp_bulb_mats: Array[StandardMaterial3D] = []
var marquee_label: Label3D = null
var _marquee_emissive: Array[StandardMaterial3D] = []
func _init(root_node: Node3D) -> void:
root = root_node
@@ -14,17 +26,26 @@ func _init(root_node: Node3D) -> void:
func build() -> void:
_build_ground()
_build_building()
_build_marquee()
_build_steps()
_build_sidewalk()
_build_street()
_build_crosswalk()
_build_street_furniture()
_build_guard_spawn()
_build_skyline_backdrop()
_build_navmesh()
set_night_mode(false)
func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_shadow: bool = false) -> MeshInstance3D:
func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_shadow: bool = false,
texture: Texture2D = null, uv_scale: Vector2 = Vector2(1, 1)) -> MeshInstance3D:
var mesh := BoxMesh.new()
mesh.size = size
var mat := StandardMaterial3D.new()
mat.albedo_color = color
if texture:
mat.albedo_texture = texture
mat.uv1_scale = Vector3(uv_scale.x, uv_scale.y, 1.0)
mesh.material = mat
var mi := MeshInstance3D.new()
mi.mesh = mesh
@@ -46,19 +67,23 @@ func _build_ground() -> void:
func _build_building() -> void:
var building := StaticBody3D.new()
building.name = "Building"
# Main facade
_add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -2), Color(0.35, 0.32, 0.30))
# Main facade (textured brick)
_add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -2), Color(0.85, 0.8, 0.78), false, T_BRICK, Vector2(10, 5))
# Entrance recess + door recess frame
_add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -4.0), Color(0.10, 0.10, 0.10))
# Door
_add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -4.15), Color(0.16, 0.15, 0.13))
# Door window
_add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -4.0), Color(0.5, 0.7, 0.9, 1.0))
_add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -4.0), Color(0.08, 0.08, 0.09))
# Door frame and dark door
_add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -4.15), Color(0.13, 0.12, 0.11))
# Door glass (semi-transparent)
var glass := _add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -4.0), Color(0.6, 0.75, 0.9, 0.55))
var glass_mat := StandardMaterial3D.new()
glass_mat.albedo_color = Color(0.6, 0.75, 0.9, 0.45)
glass_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
glass_mat.roughness = 0.15
glass_mat.metallic = 0.1
glass.material_override = glass_mat
# Door lock indicator (green = open, red = locked), placed beside the handle
door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -4.35), Color(0.1, 0.9, 0.3))
door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3))
# Awning sign bar
_add_box(building, Vector3(8, 0.5, 0.6), Vector3(0, 7.0, -3.6), Color(0.45, 0.44, 0.44))
var col := BoxShape3D.new()
col.size = Vector3(40, 18, 4)
var so := building.create_shape_owner(building)
@@ -86,7 +111,7 @@ func _build_steps() -> void:
func _build_sidewalk() -> void:
var sidewalk := StaticBody3D.new()
sidewalk.name = "Sidewalk"
_add_box(sidewalk, Vector3(30, 0.18, 8), Vector3(0, 0.09, 8), Color(0.60, 0.58, 0.56))
_add_box(sidewalk, Vector3(30, 0.18, 8), Vector3(0, 0.09, 8), Color(1, 1, 1), false, T_CONCRETE, Vector2(9, 2.4))
var col := BoxShape3D.new()
col.size = Vector3(30, 0.36, 8)
var so := sidewalk.create_shape_owner(sidewalk)
@@ -97,7 +122,7 @@ func _build_sidewalk() -> void:
func _build_street() -> void:
var street := StaticBody3D.new()
street.name = "Street"
_add_box(street, Vector3(120, 0.1, 26), Vector3(0, 0.05, 22), Color(0.13, 0.13, 0.14))
_add_box(street, Vector3(120, 0.1, 26), Vector3(0, 0.05, 22), Color(1, 1, 1), false, T_ASPHALT, Vector2(30, 7))
# Center dashes
for i in range(-40, 41, 4):
_add_box(street, Vector3(0.2, 0.02, 1.2), Vector3(i * 0.0, 0.11, 22), Color(0.85, 0.85, 0.30))
@@ -127,6 +152,137 @@ func _build_navmesh() -> void:
region.navigation_mesh = mesh
root.add_child(region)
func _build_marquee() -> void:
## Striped awning above the door, then the neon sign strip + label.
var awning := _add_box(root, Vector3(10.5, 0.08, 1.8), Vector3(0, 6.55, -3.7), Color(1, 1, 1), false, T_AWNING)
awning.name = "Awning"
## Neon-style marquee strip above the door: emissive bars + a label.
var strip_parent := Node3D.new()
strip_parent.name = "Marquee"
for offset in [-2.2, 2.2]:
var bar := _add_box(strip_parent, Vector3(0.25, 0.5, 0.5), Vector3(offset, 7.4, -3.6), Color(1, 0.35, 0.75))
var mat := _emissive_material(Color(1.0, 0.35, 0.75))
bar.material_override = mat
_marquee_emissive.append(mat)
marquee_label = Label3D.new()
marquee_label.name = "MarqueeLabel"
marquee_label.text = "STEP LINE"
marquee_label.position = Vector3(0, 7.3, -3.55)
marquee_label.modulate = Color(1, 0.6, 0.9)
marquee_label.font_size = 72
marquee_label.pixel_size = 0.004
marquee_label.outline_size = 12
marquee_label.outline_modulate = Color(0.9, 0.15, 0.5)
marquee_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
marquee_label.no_depth_test = false
strip_parent.add_child(marquee_label)
root.add_child(strip_parent)
func _build_street_furniture() -> void:
# Street lamps along the sidewalk (bulbs + omni lights, off by day).
for side in [-7.5, 7.5]:
var lamp := Node3D.new()
lamp.name = "Lamp_%d" % int(side)
_add_box(lamp, Vector3(0.22, 5.0, 0.22), Vector3(side, 2.5, 8.5), Color(0.16, 0.16, 0.18))
_add_box(lamp, Vector3(1.6, 0.15, 0.15), Vector3(side + (0.8 if side < 0 else -0.8), 4.9, 8.5), Color(0.16, 0.16, 0.18))
var bulb := _add_box(lamp, Vector3(0.16, 0.16, 0.5), Vector3(side + (0.8 if side < 0 else -0.8), 4.85, 8.5), Color(1, 1, 0.85))
var bulb_mat := _emissive_material(Color(1.0, 1.0, 0.8))
bulb.material_override = bulb_mat
bulb.material_override.emission_energy_multiplier = 1.5
_lamp_bulb_mats.append(bulb_mat)
var omni := OmniLight3D.new()
omni.position = Vector3(side + (0.8 if side < 0 else -0.8), 4.7, 8.5)
omni.omni_range = 14.0
omni.light_energy = 0.0
omni.light_color = Color(1, 0.93, 0.75)
lamp.add_child(omni)
_lamp_omni.append(omni)
root.add_child(lamp)
_street_lamps.append(bulb)
# Planters beside the steps.
for side in [-4.6, 4.6]:
var planter := _add_box(root, Vector3(1.1, 0.7, 1.1), Vector3(side, 0.35, 1.0), Color(0.42, 0.34, 0.28))
planter.name = "Planter_%d" % int(side)
# Low-poly trees along the street front.
var trunk_color := Color(0.35, 0.25, 0.18)
var leaf_color := Color(0.16, 0.42, 0.2)
for i in 6:
var x := -11.0 + i * 4.4
var z := 4.0 + float((i * 7) % 5)
var tree := Node3D.new()
tree.name = "Tree_%d" % (i + 1)
var trunk := MeshInstance3D.new()
var trunk_mesh := CylinderMesh.new()
trunk_mesh.top_radius = 0.09
trunk_mesh.bottom_radius = 0.14
trunk_mesh.height = 1.4
trunk.mesh = trunk_mesh
trunk.material_override = _plain_material(trunk_color)
trunk.position = Vector3(x, 0.7, z)
tree.add_child(trunk)
var crown := MeshInstance3D.new()
var crown_mesh := CylinderMesh.new()
crown_mesh.top_radius = 0.05
crown_mesh.bottom_radius = 0.85
crown_mesh.height = 2.2
crown.mesh = crown_mesh
crown.material_override = _plain_material(leaf_color)
crown.position = Vector3(x, 2.1, z)
tree.add_child(crown)
root.add_child(tree)
func _build_crosswalk() -> void:
# White crosswalk stripes in front of the entrance on the street.
for i in 8:
_add_box(root, Vector3(0.8, 0.02, 0.5), Vector3(-2.8 + i * 0.85, 0.11, 17.0), Color(0.92, 0.92, 0.92))
_add_box(root, Vector3(0.8, 0.02, 0.5), Vector3(-2.8 + i * 0.85, 0.11, 19.0), Color(0.92, 0.92, 0.92))
## Switch street lamps and signage to night mode (emissive + lights on).
func set_night_mode(night: bool) -> void:
for mat in _lamp_bulb_mats:
mat.emission_energy_multiplier = (2.0 if night else 0.0)
for omni in _lamp_omni:
omni.light_energy = (3.0 if night else 0.0)
for mat in _marquee_emissive:
mat.emission_energy_multiplier = (2.0 if night else 0.2)
if marquee_label:
marquee_label.modulate.a = (1.0 if night else 0.9)
func _emissive_material(color: Color) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = color
mat.emission_enabled = true
mat.emission = color
mat.emission_energy_multiplier = 1.0
return mat
func _plain_material(color: Color) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = color
return mat
func _build_skyline_backdrop() -> void:
## Distant procedural skyline on four unshaded planes around the block.
var mat := StandardMaterial3D.new()
mat.albedo_texture = T_SKYLINE
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
const DIST := 95.0
const HEIGHT := 90.0
for i in 4:
var plane := MeshInstance3D.new()
plane.name = "Skyline_%d" % i
var pm := PlaneMesh.new()
pm.size = Vector2(300, HEIGHT)
plane.mesh = pm
plane.material_override = mat
var angle := i * 90.0
plane.position = Vector3(sin(deg_to_rad(angle)) * DIST, HEIGHT / 2.0, cos(deg_to_rad(angle)) * DIST)
plane.rotation_degrees = Vector3(0, -angle + 90.0, 0)
plane.cast_shadow = (0 as GeometryInstance3D.ShadowCastingSetting)
root.add_child(plane)
func _build_guard_spawn() -> void:
var marker := Marker3D.new()
marker.name = "GuardSpawn"
+107
View File
@@ -0,0 +1,107 @@
extends SceneTree
## Dev helper: run the game windowed and capture staged screenshots for the
## README (day set + LATE-night set with street lamps and flashlight).
## Saves 1280x720 PNGs to user://screenshots/.
## Usage: godot --path . --script res://src/test/capture_screenshots.gd
const OUT_DIR := "user://screenshots"
const MAX_FRAMES := 3600 # hard cap (~60s)
var frames := 0
var gm: Node = null
var state := 0
var state_frames := 0
var targeted := false
var camera_staged := false
func _initialize() -> void:
root.size = Vector2i(1280, 720)
var packed: PackedScene = load("res://scenes/main.tscn")
var inst: Node = packed.instantiate()
root.add_child(inst)
func _process(_delta: float) -> bool:
frames += 1
# The main scene's _ready may not have run when _initialize added it;
# look for GameManager lazily each frame until it exists.
if gm == null:
gm = root.find_child("GameManager", true, false)
if gm == null and frames > 120:
push_error("GameManager not found")
quit(1)
return true
if gm != null and not camera_staged:
gm.guard.global_position = Vector3(0, 1.6, 5.0)
gm.guard.head.rotation.x = deg_to_rad(-8.0)
camera_staged = true
if frames > MAX_FRAMES:
print("timeout reached")
quit(0)
return true
_advance()
return false
func _advance() -> void:
state_frames += 1
match state:
0:
# 2s overview from the guard post (early morning light).
if state_frames >= 120:
_shoot("01-venue-overview.png")
state = 1
state_frames = 0
1:
# Wait for a nearby NPC so the interaction HUD has a target.
if gm != null and gm.npcs.size() > 0:
gm.call("_cycle_nearby_target")
targeted = true
state = 2
state_frames = 0
2:
if state_frames >= 30:
_shoot("02-targeting-npc.png")
if targeted and gm != null:
gm.call("_toggle_inspection")
state = 3
state_frames = 0
3:
if state_frames >= 30:
_shoot("03-access-inspection.png")
if gm != null:
gm.hud.hide_inspection()
if gm.npcs.size() > 0:
gm.call("_cycle_nearby_target")
gm.call("_open_radio", false)
state = 4
state_frames = 0
4:
if state_frames >= 30:
_shoot("04-radio-report.png")
# Close the radio form, then jump the shift to the LATE phase.
if gm != null:
gm.radio_panel.hide()
gm.coordinator.elapsed_s = 750.0
state = 5
state_frames = 0
5:
# Let the phase transition (and street lamps / marquee) settle, then
# take the night avenue shot.
if state_frames >= 90:
_shoot("05-late-night-avenue.png")
if gm != null:
gm.call("_toggle_flashlight")
state = 6
state_frames = 0
6:
if state_frames >= 40:
_shoot("06-night-flashlight.png")
state = 7
quit(0)
func _shoot(file_name: String) -> void:
var dir := DirAccess.open("user://")
if dir == null or not dir.dir_exists("screenshots"):
dir.make_dir_recursive("screenshots")
var img: Image = root.get_texture().get_image()
var err := img.save_png(OUT_DIR.path_join(file_name))
print("%s -> err=%d (%dx%d)" % [file_name, err, img.get_width(), img.get_height()])
+9 -9
View File
@@ -16,7 +16,7 @@ func test_tourist_complies_at_level_1() -> bool:
var p := _profile_with_defaults("tourist", Rules.NpcType.TOURIST, 0.9)
var npc := NPCController.new()
npc.setup("t1", p, "photo", 1234, "AT_ENTRANCE")
var outcome := npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.FRIENDLY)
var outcome := npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE)
var ok := TestUtils.check_eq(outcome["reaction"], Rules.Reaction.COMPLY, "base_compliance 0.9 tourist should comply at level 1")
return ok and TestUtils.check_eq(npc.decide_leave(), true, "compliant NPC leaves")
@@ -28,7 +28,7 @@ func test_trespasser_frequent_non_compliance() -> bool:
## from NpcProfile.default_reactions (mostly COMPLY there).
var outcomes: Array = []
for i in 5:
outcomes.append(npc.roll_reaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL))
outcomes.append(npc.roll_reaction(Rules.Level.SIMPLE_DETERRENCE))
## With compliance 0.9/0.2 variation we simply assert we get SOME reaction.
var ok := TestUtils.check(npc.highest_escalation_received >= Rules.Level.SIMPLE_DETERRENCE, "highest escalation tracked")
return ok
@@ -46,8 +46,8 @@ func test_deterministic_with_seed() -> bool:
var seq_a: Array = []
var seq_b: Array = []
for i in 4:
seq_a.append(n1.roll_reaction(Rules.Level.FORMAL_WARNING, Rules.Tone.PROFESSIONAL))
seq_b.append(n2.roll_reaction(Rules.Level.FORMAL_WARNING, Rules.Tone.PROFESSIONAL))
seq_a.append(n1.roll_reaction(Rules.Level.FORMAL_WARNING))
seq_b.append(n2.roll_reaction(Rules.Level.FORMAL_WARNING))
return TestUtils.check_eq(seq_a, seq_b, "same seed produces identical reaction sequence")
func test_violent_threat_scales_to_threaten() -> bool:
@@ -69,7 +69,7 @@ func test_violent_threat_scales_to_threaten() -> bool:
npc.setup("v1", p, "threat", 777, "AT_ENTRANCE")
var saw_threat := false
for i in 20:
if npc.roll_reaction(Rules.Level.EMERGENCY, Rules.Tone.PROFESSIONAL) == Rules.Reaction.THREATEN:
if npc.roll_reaction(Rules.Level.EMERGENCY) == Rules.Reaction.THREATEN:
saw_threat = true
return TestUtils.check(saw_threat, "violent profile can threaten at emergency level")
@@ -82,7 +82,7 @@ func test_call_someone_reaction() -> bool:
}
var npc := NPCController.new()
npc.setup("c1", p, "film", 5, "AT_ENTRANCE")
var outcome := npc.apply_player_action(Rules.Level.FORMAL_WARNING, Rules.Tone.PROFESSIONAL)
var outcome := npc.apply_player_action(Rules.Level.FORMAL_WARNING)
var a := TestUtils.check_eq(outcome["reaction"], Rules.Reaction.CALL_SOMEONE, "rolls CALL_SOMEONE")
var b := TestUtils.check_eq(npc.fsm.current_state, "ARGUING", "transitions to ARGUING")
return a and b
@@ -92,7 +92,7 @@ func test_fsm_transitions() -> bool:
var npc := NPCController.new()
npc.setup("g1", p, "enter", 5, "APPROACH")
var started := TestUtils.check_eq(npc.fsm.current_state, "APPROACH", "starts approaching")
npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.FRIENDLY)
npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE)
var left := TestUtils.check_eq(npc.fsm.current_state, "LEAVING", "complies and leaves")
return started and left
@@ -103,7 +103,7 @@ func test_returning_trespasser_circles_back() -> bool:
p.return_probability = 1.0
var npc := NPCController.new()
npc.setup("rt1", p, "trespass", 4242, "AT_ENTRANCE")
npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL)
npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE)
var left := TestUtils.check_eq(npc.fsm.current_state, "LOITERING_DISTANT", "directed away loiters before returning")
var scheduled := TestUtils.check(npc.will_return, "return scheduled")
# Advance past the return delay (max 60s).
@@ -122,7 +122,7 @@ func test_tourist_never_returns() -> bool:
var p := _profile_with_defaults("tourist", Rules.NpcType.TOURIST, 0.9)
var npc := NPCController.new()
npc.setup("t2", p, "photo", 7, "AT_ENTRANCE")
npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.FRIENDLY)
npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE)
return TestUtils.check_eq(npc.will_return, false, "tourist does not schedule a return")
func test_perception_pass_bands() -> bool:
+4 -1
View File
@@ -20,10 +20,13 @@ func test_save_round_trip() -> bool:
var last: Dictionary = results[results.size() - 1]
var c := TestUtils.check_eq(last.get("scenario_id", ""), "hollywood_building", "round-trips scenario id")
var d := TestUtils.check_eq(float(last.get("reputation", {}).get("rating", 0.0)), 82.5, "round-trips rating")
save.free()
return a and b and c and d
func test_load_returns_array_when_empty_or_missing() -> bool:
# The tester home may already contain results; this only asserts shape safety.
var save := _save_instance()
var results: Array = save.load_shift_results()
return TestUtils.check(results is Array, "load_shift_results always returns Array")
var ok := TestUtils.check(results is Array, "load_shift_results always returns Array")
save.free()
return ok
+24
View File
@@ -63,6 +63,30 @@ func test_late_phase_reaches_violent_event() -> bool:
c = false
return a and b and TestUtils.check(c, "all venues declare a LATE phase")
func test_door_staff_lines_available() -> bool:
var scenario: VenueScenario = ScenarioFactory.joke_shop()
var rng := RandomNumberGenerator.new()
rng.seed = 7
var ok := true
for context in ["id_check", "ticket_check", "wand_check", "refusal", "prohibited_item"]:
var line: String = scenario.door_staff_line(context, rng)
if line == "":
ok = false
var a := TestUtils.check(ok, "all joke-shop contexts have a door-staff line")
var b := TestUtils.check_eq(scenario.door_staff_line("unknown_context", rng), "", "unknown context returns empty")
var b_country := ScenarioFactory.hollywood_building()
var c := TestUtils.check_eq(b_country.door_staff_line("refusal", rng), "", "venue without lines returns empty")
# Same seed picks the same line.
var r0 := RandomNumberGenerator.new()
r0.seed = 11
var r1 := RandomNumberGenerator.new()
r1.seed = 11
var d := TestUtils.check_eq(
scenario.door_staff_line("wand_check", r0),
scenario.door_staff_line("wand_check", r1),
"seeded door-staff pick is deterministic")
return a and b and c and d
func test_protester_archetype_present() -> bool:
var profiles := ScenarioFactory.shared_profiles()
var protester_found := false
+4 -4
View File
@@ -10,8 +10,8 @@ func test_strings_lookup() -> bool:
return a and b
func test_strings_format() -> bool:
var a := TestUtils.check_eq(Strings.fmt("hud.tone_prefix", ["Firm"]), "Tone: Firm", "tone prefix formats")
var b := TestUtils.check_eq(Strings.fmt("react.comply", ["Tourist"]), "Tourist complies and steps back.", "reaction formats")
var a := TestUtils.check_eq(Strings.fmt("react.comply", ["Tourist"]), "Tourist complies and steps back.", "reaction formats")
var b := TestUtils.check_eq(Strings.fmt("hud.venue_switched", ["The Joke Shop"]), "Venue: The Joke Shop", "venue switch formats")
return a and b
func test_strings_json_backed_all_resolve() -> bool:
@@ -26,11 +26,11 @@ func test_strings_json_backed_all_resolve() -> bool:
func test_strings_cover_ui_keys() -> bool:
## Every key referenced by the HUD and game manager must exist.
var required := [
"hud.controls", "hud.tone_prefix", "hud.escalation_prefix", "hud.cooldown",
"hud.controls", "hud.escalation_prefix", "hud.cooldown", "hud.venue_switched",
"hud.aim_first", "hud.no_one_nearby", "hud.targeting", "hud.no_wand",
"hud.screen_first", "access.granted", "access.denied", "access.policy_satisfied",
"access.wrong_grant", "access.wrong_deny", "wand.admitted_prohibited",
"wand.denied_false_alarm", "wand.cleared", "wand.refused", "radio.address_first",
"wand.denied_false_alarm", "wand.cleared", "wand.refused", "wand.recharge", "radio.address_first",
"radio.blocked", "radio.inaccurate", "radio.received", "radio.emergency_dispatched",
"responder.arrived", "react.comply", "react.leave", "react.argue", "react.record",
"react.ignore", "react.threaten", "react.force_entry", "react.return_later",
+62 -1
View File
@@ -1,7 +1,9 @@
extends RefCounted
## Headless tests for the procedural world and navigation mesh.
## Headless tests for the procedural world, navigation mesh, and phase lighting.
const WorldBuilder := preload("res://src/game/world_builder.gd")
const Rules := preload("res://src/core/rules.gd")
const GameManager := preload("res://src/game/game_manager.gd")
func test_navmesh_built() -> bool:
var root := Node3D.new()
@@ -17,6 +19,65 @@ func test_navmesh_built() -> bool:
root.free()
return a and b
func test_phase_lighting_config_covered() -> bool:
var ok := true
for phase in [Rules.Phase.EARLY, Rules.Phase.MIDDAY, Rules.Phase.EVENING, Rules.Phase.LATE]:
var cfg: Variant = GameManager.PHASE_LIGHTING.get(phase, null)
if not (cfg is Dictionary):
ok = false
continue
for key in ["sun_energy", "sun_angle", "sun_color", "sky_top", "sky_horizon", "ambient", "night"]:
if not (cfg as Dictionary).has(key):
ok = false
return TestUtils.check(ok, "all four phases have a full lighting config")
func test_street_lamps_and_marquee_built() -> bool:
var root := Node3D.new()
var builder := WorldBuilder.new(root)
builder.build()
var a := TestUtils.check(builder._street_lamps.size() >= 2, "street lamps built")
var b := TestUtils.check(builder.marquee_label != null, "marquee label built")
# Night mode raises bulb emission; day mode drops it.
builder.set_night_mode(true)
var night_on := builder._lamp_bulb_mats[0].emission_energy_multiplier > 1.0
builder.set_night_mode(false)
var day_off := builder._lamp_bulb_mats[0].emission_energy_multiplier <= 0.5
root.free()
return a and b and TestUtils.check(night_on, "lamps brighten at night") and TestUtils.check(day_off, "lamps dim in daylight")
func test_asset_textures_present_and_pow2() -> bool:
var required := [
"res://assets/textures/brick_facade.png",
"res://assets/textures/concrete.png",
"res://assets/textures/asphalt.png",
"res://assets/textures/awning.png",
"res://assets/textures/skyline.png",
"res://assets/textures/vignette.png",
"res://assets/textures/icons/flashlight_on.png",
"res://assets/textures/icons/door_locked.png",
"res://assets/textures/icons/wand.png",
"res://assets/textures/icons/rec_dot.png",
"res://assets/textures/cards/id_card.png",
"res://assets/textures/cards/wristband.png",
"res://assets/textures/cards/ticket.png",
"res://assets/textures/cards/credential.png",
"res://assets/textures/cards/guest_list.png",
]
for path in required:
if not FileAccess.file_exists(path):
return TestUtils.fail("missing generated asset: %s — run tools/generate_textures.py" % path)
var img := Image.new()
for path in required:
if img.load(path) != OK:
return TestUtils.fail("cannot decode asset: %s" % path)
var w := img.get_width()
var h := img.get_height()
if w > 1024 or h > 1024:
return TestUtils.fail("asset too large: %s (%dx%d)" % [path, w, h])
if (w & (w - 1)) != 0 or (h & (h - 1)) != 0:
return TestUtils.fail("asset not power-of-two: %s (%dx%d)" % [path, w, h])
return true
func test_door_indicator_built() -> bool:
var root := Node3D.new()
var builder := WorldBuilder.new(root)
Regular → Executable
+2
View File
@@ -16,6 +16,8 @@ set -euo pipefail
GODOT="${GODOT:-godot}"
VERSION="$(grep -oP 'config/version="\K[^"]+' project.godot)"
mkdir -p build
# Presets now embed the PCK into a single binary; drop stale .pck leftovers.
rm -f build/*.pck
echo "==> Exporting Linux (x86_64)"
"$GODOT" --headless --export-release "Linux" "build/step-line-linux-${VERSION}.x86_64"
+499
View File
@@ -0,0 +1,499 @@
#!/usr/bin/env python3
"""Generate small authored textures for Step Line (assets/ import convention).
All outputs are deterministic (fixed seeds), <=1024px, power-of-two where
possible. Run from the repo root: python3 tools/generate_textures.py
Optional AI mode generates textures through the OpenRouter image API
(https://openrouter.ai/docs/api/api-reference/images/generate-an-image.md).
Requires an API key in the OPENROUTER_API_KEY environment variable:
python3 tools/generate_textures.py --ai
python3 tools/generate_textures.py --ai --prompt "..." --name foo.png
"""
import argparse
import base64
import io
import json
import math
import os
import random
import stat
import sys
import urllib.error
import urllib.request
from PIL import Image, ImageDraw, ImageFilter, ImageFont
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
ASSETS = os.path.join(ROOT, "assets", "textures")
# OpenRouter image generation endpoint and default model (from the API docs).
OPENROUTER_IMAGES_URL = "https://openrouter.ai/api/v1/images"
DEFAULT_IMAGE_MODEL = "bytedance-seed/seedream-4.5"
MAX_DIM = 1024 # hard cap on the longest side of any generated texture
# px requested from the API; some models (e.g. seedream) need >= 1920
GEN_SIZE = 2048
# Default batch for `--ai`: one entry per game texture (see test_world.gd).
# Each entry is (output name, prompt, transparent_background).
AI_TEXTURES = [
# --- world surfaces (seamless, flat-lit) ---
("ai/brick_facade.png",
"seamless tileable red brick wall texture, weathered urban facade, "
"flat even lighting, frontal view, photorealistic game texture", False),
("ai/concrete.png",
"seamless tileable grey concrete wall texture, subtle cracks and stains, "
"flat even lighting, frontal view, photorealistic game texture", False),
("ai/asphalt.png",
"seamless tileable dark asphalt road texture, fine grain, "
"flat even lighting, frontal view, photorealistic game texture", False),
# --- storefront dressing ---
("ai/awning.png",
"red and white striped storefront awning with scalloped bottom edge, "
"frontal view, flat even lighting, game texture", False),
("ai/skyline.png",
"night city skyline silhouette, dark building shapes with a few lit "
"windows, flat graphic style, isolated on transparent background", True),
# --- screen-space overlay ---
("ai/vignette.png",
"radial vignette overlay, black edges fading to fully transparent center, "
"grayscale, soft gradient", True),
# --- HUD icons (white glyphs on transparent) ---
("ai/icons/flashlight_on.png",
"simple flat flashlight icon, white glyph on transparent background, "
"minimal UI icon", True),
("ai/icons/door_locked.png",
"simple flat locked door icon with keyhole, white outline glyph on "
"transparent background, minimal UI icon", True),
("ai/icons/wand.png",
"simple flat handheld security wand icon, white glyph on transparent "
"background, minimal UI icon", True),
("ai/icons/rec_dot.png",
"record button icon, red filled dot inside a white circle outline, on "
"transparent background, minimal UI icon", True),
# --- access artifact cards ---
("ai/cards/id_card.png",
"driver license ID card, blue plastic card with photo placeholder and "
"text lines, flat frontal view, game UI asset", False),
("ai/cards/wristband.png",
"red event access wristband with strap, flat frontal view, game UI asset",
False),
("ai/cards/ticket.png",
"green admission ticket stub with perforated edge, flat frontal view, "
"game UI asset", False),
("ai/cards/credential.png",
"dark grey staff credential pass with lanyard clip, flat frontal view, "
"game UI asset", False),
("ai/cards/guest_list.png",
"beige guest list document with ruled lines and checkboxes, flat frontal "
"view, game UI asset", False),
]
FONT_CANDIDATES = [
"/usr/share/fonts/liberation-sans-fonts/LiberationSans-Bold.ttf",
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/google-noto-vf/NotoSans[wght].ttf",
"/usr/share/fonts/adwaita-sans-fonts/AdwaitaSans-Bold.ttf",
]
def font(size: int):
for path in FONT_CANDIDATES:
if os.path.exists(path):
return ImageFont.truetype(path, size)
return ImageFont.load_default()
def save(img: Image.Image, name: str) -> None:
os.makedirs(ASSETS, exist_ok=True)
path = os.path.join(ASSETS, name)
os.makedirs(os.path.dirname(path), exist_ok=True)
img.save(path, optimize=True)
print(f"wrote {os.path.relpath(path, ROOT)} {img.size}")
def grain(size, base, spread, seed, blur=1.0):
rnd = random.Random(seed)
img = Image.new("L", (size, size), base)
px = img.load()
for y in range(size):
for x in range(size):
v = base + rnd.randint(-spread, spread)
px[x, y] = max(0, min(255, v))
return img.filter(ImageFilter.GaussianBlur(blur))
def brick_facade():
size = 512
mortar = (118, 110, 104)
base = (150, 92, 78)
img = Image.new("RGB", (size, size), mortar)
d = ImageDraw.Draw(img)
rnd = random.Random(11)
brick_h = size // 22
mortar_t = 5
offset = 0
for row in range(22):
y = row * brick_h
cols = 2 if row % 2 == 0 else 3
brick_w = (size - mortar_t) // (cols if cols > 1 else 1)
for c in range(cols + 1):
x0 = c * (brick_w + mortar_t) - (brick_w // 2 if row % 2 else 0)
x1 = x0 + brick_w
# wrap horizontally so the tile repeats seamlessly
d.rectangle([x0, y, x1, y + brick_h - mortar_t], fill=base)
# per-brick shading pass
for _ in range(size * size // 900):
bx = rnd.randint(0, size - 1)
by = rnd.randint(0, size - 1)
d.point((bx, by), fill=(max(0, base[0] - 18 + rnd.randint(0, 30)),
max(0, base[1] - 10 + rnd.randint(0, 22)),
max(0, base[2] - 8 + rnd.randint(0, 18))))
# horizontal mortar lines on top of bricks (wraps vertically by drawing at 0 and size)
for y in [-mortar_t, size - mortar_t]:
d.rectangle([0, y, size, y + mortar_t], fill=mortar)
save(img, "brick_facade.png")
def seamless_concrete():
size = 512
img = grain(size, 150, 26, 7, blur=2.5).convert("RGBA")
d = ImageDraw.Draw(img)
rnd = random.Random(19)
for _ in range(90):
x, y = rnd.randint(0, size), rnd.randint(0, size)
r = rnd.choice([2, 3, 4])
d.ellipse([x - r, y - r, x + r, y + r],
fill=(0, 0, 0, rnd.randint(8, 22)))
for _ in range(8): # subtle crack segments
x, y = rnd.randint(0, size), rnd.randint(0, size)
pts = [(x, y)]
for _ in range(6):
x += rnd.randint(-24, 24)
y += rnd.randint(-8, 8)
pts.append((x % size, y % size))
d.line(pts, fill=(70, 70, 70, 170), width=1)
save(img, "concrete.png")
def asphalt():
size = 512
img = grain(size, 52, 14, 23, blur=1.2).convert("RGBA")
d = ImageDraw.Draw(img)
rnd = random.Random(31)
for _ in range(600):
x, y = rnd.randint(0, size), rnd.randint(0, size)
d.point((x, y), fill=(max(0, 40 - rnd.randint(0, 18)),)
* 3 + (rnd.randint(40, 110),))
save(img, "asphalt.png")
def awning():
w, h = 1024, 256
img = Image.new("RGB", (w, h), (140, 40, 45))
d = ImageDraw.Draw(img)
stripe = 64
for i in range(w // stripe):
if i % 2 == 1:
d.rectangle([i * stripe, 0, (i + 1) * stripe - 2, h],
fill=(230, 225, 220))
# scalloped bottom edge
for x in range(0, w, 32):
d.arc([x, h - 24, x + 32, h + 40], 0, 180, fill=(80, 20, 24), width=6)
f = font(96)
tw = d.textlength("STEP LINE", font=f)
d.text(((w - tw) / 2, 30), "STEP LINE", font=f, fill=(255, 250, 235))
# subtle top shading
shade = Image.new("RGBA", (w, h), (0, 0, 0, 0))
sd = ImageDraw.Draw(shade)
sd.rectangle([0, 0, w, 10], fill=(0, 0, 0, 90))
img = Image.alpha_composite(img.convert("RGBA"), shade)
save(img, "awning.png")
def skyline():
w, h = 1024, 256
img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
rnd = random.Random(41)
build_color = (16, 18, 30, 255)
x = -40
while x < w + 40:
bw = rnd.randint(64, 170)
bh = rnd.randint(150, 400)
d.rectangle([x, h - bh, x + bw, h], fill=build_color)
# few lit windows
for _ in range(bw * bh // 900):
wx = x + rnd.randint(4, max(4, bw - 8))
wy = h - rnd.randint(4, bh - 4)
if rnd.random() < 0.5:
d.rectangle([wx, wy, wx + 6, wy + 8],
fill=(255, 200, 120, 220))
x += bw + rnd.randint(10, 46)
d.rectangle([0, h - 6, w, h], fill=(12, 13, 22, 255))
save(img, "skyline.png")
def vignette():
size = 512
img = Image.new("L", (size, size), 0)
px = img.load()
c = size / 2.0
max_d = math.hypot(c, c)
for y in range(size):
for x in range(size):
dist = math.hypot(x - c, y - c) / max_d
t = max(0.0, (dist - 0.55) / 0.45)
px[x, y] = int(255 * (t * t))
save(img, "vignette.png")
def icon(name, draw_fn, size=64):
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
draw_fn(d, size)
save(img, name)
def icons():
def flashlight_on(d, s):
d.rounded_rectangle([s*0.30, s*0.30, s*0.62, s*0.62],
radius=4, fill=(255, 255, 255, 255))
d.polygon([(s*0.62, s*0.30), (s*0.82, s*0.18), (s*0.82, s *
0.74), (s*0.62, s*0.62)], fill=(255, 214, 120, 255))
def door_locked(d, s):
d.rectangle([s*0.18, s*0.18, s*0.82, s*0.82],
outline=(255, 255, 255, 255), width=5)
d.rounded_rectangle([s*0.36, s*0.34, s*0.64, s*0.66],
radius=3, fill=(255, 255, 255, 255))
d.arc([s*0.40, s*0.28, s*0.60, s*0.50], 180,
360, fill=(255, 255, 255, 255), width=5)
def wand(d, s):
d.rounded_rectangle([s*0.30, s*0.10, s*0.44, s*0.90],
radius=3, fill=(255, 255, 255, 255))
d.ellipse([s*0.24, s*0.06, s*0.50, s*0.32], fill=(120, 220, 160, 255))
def rec(d, s):
d.ellipse([s*0.14, s*0.14, s*0.86, s*0.86],
outline=(255, 255, 255, 255), width=5)
d.ellipse([s*0.30, s*0.30, s*0.70, s*0.70], fill=(230, 60, 60, 255))
icon("icons/flashlight_on.png", flashlight_on)
icon("icons/door_locked.png", door_locked)
icon("icons/wand.png", wand)
icon("icons/rec_dot.png", rec)
def app_icon():
"""Game app icon: dark badge, gold star, white 'S' — used by desktop/AppImage
(PNG) and the Windows installer (ICO)."""
def badge(d, s):
d.ellipse([2, 2, s-2, s-2], fill=(24, 26, 38, 255))
d.ellipse([int(s*0.10), int(s*0.10), int(s*0.90), int(s*0.90)],
outline=(120, 130, 170, 255), width=max(2, s//64))
# gold star
cx, cy, r = s*0.5, s*0.44, s*0.30
pts = []
for i in range(10):
ang = -90 + i * 36
rad = r if i % 2 == 0 else r * 0.42
import math
pts.append((cx + rad * math.cos(math.radians(ang)),
cy + rad * math.sin(math.radians(ang))))
d.polygon(pts, fill=(255, 205, 80, 255))
icon("icon.png", badge, size=1024)
icon("icon.ico", badge, size=256)
# ICO for Windows. Must be BMP-entry based: PIL's ICO serializer emits
# PNG-compressed entries which NSIS' icon loader rejects. ImageMagick
# writes classic BMP entries; fall back to PIL only if ImageMagick is gone
# (flag it so a human notices).
import subprocess
import sys
ico_path = os.path.join(ASSETS, "icon.ico")
src_png = os.path.join(ASSETS, "icon.png")
ran = False
for cmd in (
["magick", src_png, "-background", "none", "-define", "icon:auto-resize=48,32,16", ico_path],
["convert", src_png, "-background", "none", "-define", "icon:auto-resize=48,32,16", ico_path],
):
try:
subprocess.run(cmd, check=True, capture_output=True)
ran = True
break
except (FileNotFoundError, subprocess.CalledProcessError):
continue
if ran:
print("wrote assets/textures/icon.ico (BMP 16/32/48 via ImageMagick)")
else:
print("WARNING: ImageMagick not found; writing PIL ICO (may be PNG-compressed)")
Image.open(src_png).convert("RGBA").save(ico_path, format="ICO", sizes=[(48, 48), (32, 32), (16, 16)])
def cards():
CW, CH = 256, 128
def base(card_name, bg, label, band=(0, 0, 0, 0)):
img = Image.new("RGBA", (CW, CH), bg + (255,))
d = ImageDraw.Draw(img)
d.rounded_rectangle([6, 6, CW-6, CH-6], radius=10,
outline=(0, 0, 0, 90), width=2)
if band and band[3] == 255:
d.rectangle([0, CH-34, CW, CH], fill=band)
f = font(26)
d.text((14, 10), label, font=f, fill=(255, 255, 255, 235))
# body text lines
f2 = font(15)
y = 52
for _ in range(4):
w = 150 - (24 if _ == 3 else 0)
d.rectangle([16, y, 16 + w, y + 8], fill=(255, 255, 255, 110))
y += 24
# photo square for id-like cards
if "photo" in card_name:
d.rectangle([CW-84, 14, CW-18, 96], fill=(180, 190, 205, 255))
save(img, f"cards/{card_name}.png")
base("id_card", (46, 92, 168), "CA DRIVER LICENSE", band=(20, 36, 70, 255), )
base("wristband", (196, 62, 78), "EVENT ACCESS", band=(150, 35, 50, 255))
base("ticket", (56, 120, 96), "ADMIT ONE", band=(20, 80, 55, 255))
base("credential", (58, 62, 74), "STAFF PASS", band=(20, 22, 28, 255))
base("guest_list", (200, 190, 168), "GUEST LIST", band=(120, 90, 60, 255))
def _api_key() -> str:
key = os.environ.get("OPENROUTER_API_KEY", "").strip()
if not key:
sys.exit("error: OPENROUTER_API_KEY is not set; export it before using --ai")
return key
def _generate_image(prompt: str, model: str, size: int,
transparent: bool = False) -> Image.Image:
"""Call the OpenRouter image API and return the decoded PIL image.
Requests a square image at `size` px and decodes the base64 PNG/JPEG/WebP
bytes returned in `data[].b64_json`. The requested size is clamped to at
least GEN_SIZE because some models (e.g. seedream) reject small outputs;
callers downscale the result to MAX_DIM afterwards. When `transparent` is
true, the API is asked for a transparent background (PNG alpha).
"""
size = max(GEN_SIZE, int(size))
body = {
"model": model,
"prompt": prompt,
"size": f"{size}x{size}",
"output_format": "png",
"n": 1,
}
if transparent:
body["background"] = "transparent"
req = urllib.request.Request(
OPENROUTER_IMAGES_URL,
data=json.dumps(body).encode("utf-8"),
headers={
"Authorization": f"Bearer {_api_key()}",
"Content-Type": "application/json",
"HTTP-Referer": "https://git.allucanget.biz/zwitschi/security-game",
"X-Title": "Step Line",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=300) as resp:
payload = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")
sys.exit(f"error: OpenRouter returned HTTP {exc.code}: {detail}")
except urllib.error.URLError as exc:
sys.exit(f"error: could not reach OpenRouter: {exc.reason}")
data = payload.get("data") or []
if not data or not data[0].get("b64_json"):
sys.exit(f"error: OpenRouter returned no image data: {payload}")
raw = base64.b64decode(data[0]["b64_json"])
try:
img = Image.open(io.BytesIO(raw))
img.load()
except Exception as exc: # noqa: BLE001 - surface any decode failure clearly
sys.exit(f"error: could not decode returned image: {exc}")
return img
def _fit(img: Image.Image, max_dim: int = MAX_DIM) -> Image.Image:
"""Downscale to fit within max_dim on the longest side, preserving aspect."""
w, h = img.size
longest = max(w, h)
if longest <= max_dim:
return img
scale = max_dim / longest
return img.resize((max(1, round(w * scale)), max(1, round(h * scale))),
Image.Resampling.LANCZOS)
def generate_ai_textures(prompt: str, name: str, model: str, size: int,
transparent: bool = False) -> None:
"""Generate one texture via OpenRouter and save it under assets/textures/."""
img = _generate_image(prompt, model, size, transparent)
img = _fit(img)
if transparent:
img = img.convert("RGBA")
elif img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
img = img.convert("RGBA")
else:
img = img.convert("RGB")
save(img, name)
def _run_ai(args: argparse.Namespace) -> None:
if args.prompt:
if not args.name:
sys.exit("error: --name is required when --prompt is given")
generate_ai_textures(args.prompt, args.name, args.model, args.size)
return
for name, prompt, transparent in AI_TEXTURES:
print(f"generating {name} ...")
generate_ai_textures(prompt, name, args.model, args.size, transparent)
def _build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Generate Step Line textures (authored or via OpenRouter).")
parser.add_argument(
"--ai", action="store_true",
help="generate textures through the OpenRouter image API "
"(requires OPENROUTER_API_KEY)")
parser.add_argument(
"--prompt", default="",
help="single prompt for --ai (overrides the default batch)")
parser.add_argument(
"--name", default="",
help="output filename under assets/textures/ for a single --prompt")
parser.add_argument(
"--model", default=DEFAULT_IMAGE_MODEL,
help=f"image model slug (default: {DEFAULT_IMAGE_MODEL})")
parser.add_argument(
"--size", type=int, default=GEN_SIZE,
help=f"square size requested from the API in px (min {GEN_SIZE}); "
f"output is downscaled to <= {MAX_DIM} (default: {GEN_SIZE})")
return parser
if __name__ == "__main__":
args = _build_arg_parser().parse_args()
if args.ai:
_run_ai(args)
print("done")
sys.exit(0)
brick_facade()
seamless_concrete()
asphalt()
awning()
skyline()
vignette()
icons()
app_icon()
cards()
print("done")
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Publish a release to the Gitea/Forgejo host, uploading the built artifacts.
#
# Requires:
# - GITEA_TOKEN (a token with write access to the repo)
# - build artifacts from tools/build_releases.sh (or already exported)
#
# Usage:
# GITEA_TOKEN=xxx ./tools/publish_release.sh [version]
# (version defaults to config/version from project.godot)
set -euo pipefail
REPO="${REPO:-zwitschi/security-game}"
HOST="${HOST:-https://git.allucanget.biz}"
API="$HOST/api/v1/repos/$REPO/releases"
VERSION="${1:-$(grep -oP 'config/version="\K[^"]+' project.godot)}"
TAG="v$VERSION"
if [[ -z "${GITEA_TOKEN:-}" ]]; then
echo "error: GITEA_TOKEN is not set (see docs/RELEASING.md)" >&2
exit 1
fi
# Artifacts must already exist (run tools/build_releases.sh first).
printf -v BS 'build/step-line-linux-%s.x86_64' "$VERSION"
printf -v BS_WIN 'build/step-line-windows-%s.exe' "$VERSION"
printf -v BS_MAC 'build/step-line-macos-%s.zip' "$VERSION"
for f in "$BS" "$BS_WIN" "$BS_MAC"; do
if [[ ! -f "$f" ]]; then
echo "error: missing artifact $f — run ./tools/build_releases.sh first" >&2
exit 1
fi
done
echo "==> Creating release $TAG on $HOST/$REPO"
RELEASE_JSON="$(curl -s -X POST "$API" \
-H "Authorization: token $GITEA_TOKEN" \
-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}")"
RELEASE_ID="$(echo "$RELEASE_JSON" | python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)"
if [[ -z "$RELEASE_ID" ]]; then
echo "error: could not create release; response:" >&2
echo "$RELEASE_JSON" >&2
exit 1
fi
echo "Release id: $RELEASE_ID"
for f in "$BS" "$BS_WIN" "$BS_MAC"; do
echo "==> Uploading $(basename "$f")"
curl -s -X POST "$API/$RELEASE_ID/assets" \
-H "Authorization: token $GITEA_TOKEN" \
-F "attachment=@$f" >/dev/null
done
echo ""
echo "Release live: $HOST/$REPO/releases/tag/$TAG"