Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f076205af | ||
|
|
8bc84c1f36 | ||
|
|
418c30e334 | ||
|
|
39a80f39f9 | ||
|
|
5b994982bd | ||
|
|
0abd67fbaa | ||
|
|
08b3292b80 | ||
|
|
ba76c0febf | ||
|
|
07d79ac115 | ||
|
|
4f274e0413 | ||
|
|
031503e161 | ||
|
|
ac3bb7dcd8 | ||
|
|
513f8a0f60 | ||
|
|
93883334d0 | ||
|
|
94b7646dce | ||
|
|
0b5cd772a5 | ||
|
|
a3768c2ce4 | ||
|
|
f7486d7539 | ||
|
|
eb1776e614 | ||
|
|
191377fae2 |
No files matched your search
@@ -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}"
|
||||||
@@ -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
|
|
||||||
@@ -15,3 +15,8 @@ Thumbs.db
|
|||||||
# Logs and temp
|
# Logs and temp
|
||||||
*.log
|
*.log
|
||||||
user://
|
user://
|
||||||
|
# CI runner workflow logs (written into the checkout by the Gitea runner)
|
||||||
|
.gitea/logs/
|
||||||
|
build/
|
||||||
|
|
||||||
|
.env
|
||||||
@@ -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 a lightweight finite state machine for NPC states (`FSM`).
|
||||||
- [x] Implement the observation phase (Level 0) without forced intervention.
|
- [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 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 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 tone.
|
- [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] Implement blocking and physical positioning (Level 3) without default force.
|
||||||
- [x] Wire typical NPC reactions: leave, argue, ignore, record, move closer, or return later.
|
- [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 `tools/build_releases.sh` for headless release exports.
|
||||||
- [x] Add `docs/RELEASING.md` with template installation, build, and Gitea release steps.
|
- [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 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
|
## Core Simulation — NPC Perception
|
||||||
|
|
||||||
@@ -157,7 +272,8 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every
|
|||||||
|
|
||||||
## Polish and Accessibility
|
## 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 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 visual and audio feedback per escalation level (colors, tones, shake).
|
||||||
- [x] Add audio for crowd ambience, radio responses, and confirmation prompts.
|
- [x] Add audio for crowd ambience, radio responses, and confirmation prompts.
|
||||||
|
|||||||
@@ -15,26 +15,83 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
- Core simulation, three scenarios, headless tests (**52 passing**), and a first-person prototype with interaction HUD, access inspection, radio (assist + emergency hold-to-confirm), debug overlay, and shift review are implemented and green.
|
- **Release v0.2.0 is LIVE**: pushing the `v0.2.0` tag made Gitea Actions run the build job
|
||||||
|
and publish automatically (host has the `GITEA_TOKEN` secret). Tag page and `/releases` are
|
||||||
|
public (HTTP 200). Caveat: anonymous **asset downloads** redirect to login on this host
|
||||||
|
(host setting — tracked in TODO). Future releases = bump `config/version`, `git tag vX.Y.Z`,
|
||||||
|
push. No local token needed.
|
||||||
|
- Core simulation, three scenarios, headless tests (**57 passing**), and a first-person prototype with interaction HUD, access inspection, radio (assist + emergency hold-to-confirm), debug overlay, and shift review are implemented and green.
|
||||||
- NPC avatars now walk to per-intent waypoints (`VenueScenario.waypoint_for_intent`): enter/trespass/deliver/arrive → entrance, photo/film/perform/wander/roam → sidewalk, sit → steps_end. `NpcAvatar.setup` takes an explicit `door_position`.
|
- NPC avatars now walk to per-intent waypoints (`VenueScenario.waypoint_for_intent`): enter/trespass/deliver/arrive → entrance, photo/film/perform/wander/roam → sidewalk, sit → steps_end. `NpcAvatar.setup` takes an explicit `door_position`.
|
||||||
- Returning-trespasser behavior: `NpcProfile.return_probability` (trespasser 0.6, mental-crisis 0.4, others 0). `NPCController._maybe_schedule_return()` moves a directed-away NPC to `LOITERING_DISTANT`; `tick_return(delta)` fires after 20-60s and transitions back to `APPROACH`. `NpcAvatar._advance_fsm` re-targets the door on return.
|
- Returning-trespasser behavior: `NpcProfile.return_probability` (trespasser 0.6, mental-crisis 0.4, others 0). `NPCController._maybe_schedule_return()` moves a directed-away NPC to `LOITERING_DISTANT`; `tick_return(delta)` fires after 20-60s and transitions back to `APPROACH`. `NpcAvatar._advance_fsm` re-targets the door on return.
|
||||||
- Perception pass: `NPCController.perceive_guard(distance)` returns `{noticed, band, reaction}` with bands `unaware` (>6m), `aware` (2-6m), `near` (<2m). Near + temperament ≥0.6 → ARGUE, else COMPLY. `GameManager._perception_pass()` feeds real guard distance each physics tick and logs first-time notice.
|
- Perception pass: `NPCController.perceive_guard(distance)` returns `{noticed, band, reaction}` with bands `unaware` (>6m), `aware` (2-6m), `near` (<2m). Near + temperament ≥0.6 → ARGUE, else COMPLY. `GameManager._perception_pass()` feeds real guard distance each physics tick and logs first-time notice.
|
||||||
- Responder dispatch: `RadioSystem` tracks `pending_calls` and decrements `arrival_delay_s` in `tick`; emits `responder_arrived` when a call's delay elapses. `RadioSystem.responder_outcome(call)` maps danger/weapons → command/restraint/evacuation/arrest. `GameManager._on_responder_arrived` spawns a `RESPONDER` avatar at the entrance and logs the outcome. HUD shows an emergency-radio cooldown label via `_update_radio_cooldown`.
|
- Responder dispatch: `RadioSystem` tracks `pending_calls` and decrements `arrival_delay_s` in `tick`; emits `responder_arrived` when a call's delay elapses. `RadioSystem.responder_outcome(call)` maps danger/weapons → command/restraint/evacuation/arrest. `GameManager._on_responder_arrived` spawns a `RESPONDER` avatar at the entrance and logs the outcome. HUD shows an emergency-radio cooldown label via `_update_radio_cooldown`.
|
||||||
- Wand screening (Joke Shop): `WandScreener` (new `src/core/wand_screener.gd`) classifies carried items as prohibited (`weapon/knife/gun/blade`) vs false-alarm (`keys/belt/phone/coins/watch/buckle`) vs clear. `NpcProfile.carried_items` holds guest items. `GameManager` binds `W` to `_toggle_wand_screening` and `A`/`D` to `decide_wand`; `decide_access` blocks admission at wand venues until `controller.screened` is true. `VenueScenario.door_staff_lines` holds venue-specific door-staff dialogue (Joke Shop has id/ticket/wand/refusal/prohibited lines).
|
- Wand screening (Joke Shop): `WandScreener` (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.
|
- 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`
|
- Run tests: `godot --headless --path . --script res://src/test/test_runner.gd`
|
||||||
- Boot check: `godot --headless --path . --quit-after 120`
|
- Boot check: `godot --headless --path . --quit-after 120`
|
||||||
- Screenshot tool: `godot --path . --script res://src/test/take_screenshot.gd` (writes to `user://screenshot.png`).
|
- 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: `./tools/build_releases.sh` requires export templates (see `docs/RELEASING.md`).
|
- 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
|
## Known Issues / Notes
|
||||||
|
|
||||||
- RefCounted reference cycle: `NPCController` holds an `FSM` that holds a back-reference to its owner, so test suites report "resources still in use at exit". Benign (process exit), but worth breaking later (e.g. `WeakRef` or explicit `dispose()`).
|
- Reference cycles are fixed: `FSM` holds its owner via `WeakRef`, `ShiftCoordinator` is
|
||||||
|
`RefCounted`, and persistence tests free the `Save` node — the suite exits with no
|
||||||
|
"resources still in use" warnings (was ~13). If a future subsystem introduces a cycle,
|
||||||
|
repeat the WeakRef pattern.
|
||||||
|
- Shift length: `GameManager` uses the scenario's `shift_duration_s` (900s ≈ 10-15 min);
|
||||||
|
tests and the screenshot tool override elapsed time directly.
|
||||||
- Headless (dummy renderer) logs "Parameter m is null" from `mesh_get_surface_count`; it does not occur with a real GL driver and does not affect logic.
|
- Headless (dummy renderer) logs "Parameter m is null" from `mesh_get_surface_count`; it does not occur with a real GL driver and does not affect logic.
|
||||||
- Steps are visual-only (no collider) so NPCs can path to the entrance; the guard spawns on the landing so it never needs to climb.
|
- Steps are visual-only (no collider) so NPCs can path to the entrance; the guard spawns on the landing so it never needs to climb.
|
||||||
- Persistence tests write into the real `user://shift_results.json` (harmless local pollution of the demo history).
|
- Persistence tests write into the real `user://shift_results.json` (harmless local pollution of the demo history).
|
||||||
- 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).
|
- Joke Shop `door_staff_lines` authored but not yet surfaced in the prototype UI (tracked in TODO).
|
||||||
|
|
||||||
## Open Questions
|
## 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)
|
## 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`.
|
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 -->
|
||||||
@@ -72,14 +72,7 @@ Example commands:
|
|||||||
- “You can’t remain here.”
|
- “You can’t remain here.”
|
||||||
- “Please move back to the sidewalk.”
|
- “Please move back to the sidewalk.”
|
||||||
|
|
||||||
At this stage, most NPCs comply immediately. The player can choose different tones:
|
At this stage, most NPCs comply immediately.
|
||||||
|
|
||||||
- Professional
|
|
||||||
- Friendly
|
|
||||||
- Firm
|
|
||||||
- Suspicious
|
|
||||||
|
|
||||||
A friendly approach works better with tourists and confused visitors, while a firm approach is more effective against deliberate trespassers.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,37 @@ The central design goal is simple:
|
|||||||
|
|
||||||
> The player wins by choosing the lowest level of escalation that keeps everyone safe.
|
> The player wins by choosing the lowest level of escalation that keeps everyone safe.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
|  |  |
|
||||||
|
|  |  |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
*Screenshots are generated by [`src/test/capture_screenshots.gd`](src/test/capture_screenshots.gd) from the current build — day and late-night shifts.*
|
||||||
|
|
||||||
## ⬇️ Downloads
|
## ⬇️ Downloads
|
||||||
|
|
||||||
| Platform | Package |
|
| Platform | Package |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Windows** | [`step-line-windows.exe`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, no install needed |
|
| **Windows — installer** | [`step-line-setup-<ver>.exe`](https://git.allucanget.biz/zwitschi/security-game/releases) · contains all game data, shortcuts + uninstaller |
|
||||||
| **Linux** | [`step-line-linux.x86_64`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, `chmod +x` and run |
|
| **Windows — portable** | [`step-line-windows-<ver>.exe`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, no install needed |
|
||||||
| **macOS** | [`step-line-macos.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unsigned build, quarantine note below |
|
| **Linux — AppImage** | [`step-line-<ver>-x86_64.AppImage`](https://git.allucanget.biz/zwitschi/security-game/releases) · single file, `chmod +x` and run |
|
||||||
|
| **Linux — portable** | [`step-line-linux-<ver>.x86_64`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, `chmod +x` and run |
|
||||||
|
| **macOS** | [`step-line-macos-<ver>.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unsigned build, quarantine note below |
|
||||||
|
|
||||||
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
|
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
|
||||||
|
|
||||||
|
> **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
|
> macOS builds are unsigned: right-click → Open, or clear the quarantine with
|
||||||
> `xattr -dr com.apple.quarantine step-line-macos.zip`. Build instructions and
|
> `xattr -dr com.apple.quarantine step-line-macos.zip`. Build instructions and
|
||||||
> the release pipeline live in [`docs/RELEASING.md`](docs/RELEASING.md).
|
> 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.
|
- **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.
|
- **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.
|
- **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.
|
- **Escalation:** The player chooses the lowest effective response.
|
||||||
- **Radio assistance:** Calls require useful information and may take time to arrive.
|
- **Radio assistance:** Calls require useful information and may take time to arrive.
|
||||||
- **Evidence:** Body-camera records support shift review and accountability.
|
- **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`
|
- **Navigation:** `NavigationRegion3D` and `NavigationAgent3D`
|
||||||
- **NPC behavior:** Custom lightweight finite state machines driven by `NpcProfile` resources
|
- **NPC behavior:** Custom lightweight finite state machines driven by `NpcProfile` resources
|
||||||
- **UI and audio:** Godot `Control` nodes, themes, audio buses, and `AudioStreamPlayer` nodes
|
- **UI and audio:** Godot `Control` nodes, themes, audio buses, and `AudioStreamPlayer` nodes
|
||||||
|
- **Art assets:** authored PNGs in `assets/textures/` (≤1024px, power-of-two) produced by `tools/generate_textures.py` and imported by Godot; structural geometry stays procedural (`WorldBuilder`). The same script can generate textures through the OpenRouter image API (`--ai`, requires `OPENROUTER_API_KEY`).
|
||||||
- **Testing:** GUT (Godot Unit Test) in headless mode
|
- **Testing:** GUT (Godot Unit Test) in headless mode
|
||||||
- **Source control:** git 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
|
- **Targets:** Windows, Linux, and macOS desktop first; web later
|
||||||
|
|
||||||
### Setup
|
### 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 |
|
| E | Inspect access artifacts / close inspection |
|
||||||
| W | Handheld wand screening (wand venues only) |
|
| W | Handheld wand screening (wand venues only) |
|
||||||
| A / D | Allow / deny entry during inspection or wand check |
|
| A / D | Allow / deny entry during inspection or wand check |
|
||||||
| 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) |
|
| Tab | Cycle target among nearby NPCs (aim-free alternative) |
|
||||||
| G | Toggle flashlight (widens NPC perception at night) |
|
| G | Toggle flashlight (widens NPC perception at night) |
|
||||||
| L | Lock / unlock the restricted door (thwarts forced entry) |
|
| 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
|
## 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
|
## 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
|
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
|
||||||
- Headless unit tests: **52 passing** (access rules, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, navmesh, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings)
|
- Headless unit tests: **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
|
- 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
|
### Smoke Test Checklist
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,74 @@
|
|||||||
# TODO
|
# 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)
|
## Future Systems (design-level backlog)
|
||||||
|
|
||||||
- [ ] Body-camera replay timeline in the shift review.
|
- [ ] Body-camera replay timeline in the shift review.
|
||||||
- [ ] Long (10-15 minute) shift lengths and phase-based lighting (darkened evening / late phases).
|
- [ ] Venue selection startup menu (keyboard cycling with `V` is implemented).
|
||||||
- [ ] Venue selection screen (wire `switch_scenario` to a startup menu).
|
|
||||||
- [ ] CI packaging: upload `build/` artifacts to releases automatically on tags.
|
|
||||||
- [ ] IDE/editor-based GUT integration when a Godot editor workflow is introduced.
|
- [ ] IDE/editor-based GUT integration when a Godot editor workflow is introduced.
|
||||||
- [ ] Reduce RefCounted reference cycles (NPCController <-> FSM) noted in `MEMORY.md`.
|
|
||||||
|
|
||||||
## Open Polish
|
## Open Polish
|
||||||
|
|
||||||
- [ ] Add venue-specific door-staff dialogue usage (scenario `door_staff_lines` is authored but not yet shown in the prototype UI).
|
- [ ] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting).
|
||||||
- [ ] 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.
|
## 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.
|
||||||
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 856 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 929 KiB |
|
After Width: | Height: | Size: 671 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 578 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 568 KiB |
|
After Width: | Height: | Size: 360 KiB |
|
After Width: | Height: | Size: 675 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 441 KiB |
|
After Width: | Height: | Size: 801 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 229 B |
|
After Width: | Height: | Size: 255 B |
|
After Width: | Height: | Size: 404 B |
|
After Width: | Height: | Size: 274 B |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -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.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.tone_prefix": "Tone: %s",
|
|
||||||
"hud.escalation_prefix": "Escalation: %s",
|
"hud.escalation_prefix": "Escalation: %s",
|
||||||
"hud.cooldown": "EMERGENCY RADIO COOLDOWN: %.0fs",
|
"hud.cooldown": "EMERGENCY RADIO COOLDOWN: %.0fs",
|
||||||
"hud.aim_first": "Aim at someone first.",
|
"hud.aim_first": "Aim at someone first.",
|
||||||
@@ -22,6 +21,7 @@
|
|||||||
"radio.inaccurate": "Dispatch flags inaccuracies in your report (-%d).",
|
"radio.inaccurate": "Dispatch flags inaccuracies in your report (-%d).",
|
||||||
"radio.received": "Dispatch received your report. Assistance en route.",
|
"radio.received": "Dispatch received your report. Assistance en route.",
|
||||||
"radio.emergency_dispatched": "Emergency responder dispatched.",
|
"radio.emergency_dispatched": "Emergency responder dispatched.",
|
||||||
|
"radio.easter_egg": "¿Dónde está la mota?",
|
||||||
"responder.arrived": "Responder arrives and %ss the situation.",
|
"responder.arrived": "Responder arrives and %ss the situation.",
|
||||||
"react.comply": "%s complies and steps back.",
|
"react.comply": "%s complies and steps back.",
|
||||||
"react.leave": "%s turns and leaves.",
|
"react.leave": "%s turns and leaves.",
|
||||||
@@ -39,5 +39,7 @@
|
|||||||
"door.unlocked": "The door is now unlocked.",
|
"door.unlocked": "The door is now unlocked.",
|
||||||
"door.thwarted": "%s tries the locked door and fails.",
|
"door.thwarted": "%s tries the locked door and fails.",
|
||||||
"flashlight.on": "Flashlight on.",
|
"flashlight.on": "Flashlight on.",
|
||||||
"flashlight.off": "Flashlight off."
|
"flashlight.off": "Flashlight off.",
|
||||||
|
"hud.venue_switched": "Venue: %s",
|
||||||
|
"wand.recharge": "Wand scanner is recharging…"
|
||||||
}
|
}
|
||||||
@@ -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
|
`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`).
|
(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
|
```bash
|
||||||
godot --headless --path . --script res://src/test/test_runner.gd # all tests
|
godot --headless --path . --script res://src/test/test_runner.gd # all tests
|
||||||
godot --headless --path . --quit-after 120 # boot smoke test
|
godot --headless --path . --quit-after 120 # boot smoke test
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./tools/build_releases.sh
|
./tools/build_releases.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Artifacts are written to `build/`:
|
Artifacts are written to `build/` (all exports embed the PCK — single files):
|
||||||
|
|
||||||
- `build/step-line-linux-<version>.x86_64`
|
- `build/step-line-linux-<version>.x86_64`
|
||||||
- `build/step-line-windows-<version>.exe`
|
- `build/step-line-windows-<version>.exe`
|
||||||
- `build/step-line-macos-<version>.zip`
|
- `build/step-line-macos-<version>.zip`
|
||||||
|
|
||||||
|
When run in CI (tags/dispatch), the build job also packages:
|
||||||
|
|
||||||
|
- `build/step-line-setup-<version>.exe` — Windows installer (NSIS, incl. shortcuts +
|
||||||
|
uninstaller; apt `nsis` on the runner)
|
||||||
|
- `build/step-line-<version>-x86_64.AppImage` — Linux AppImage (`packaging/linux/build_appimage.sh`,
|
||||||
|
appimagetool via `APPIMAGE_EXTRACT_AND_RUN`)
|
||||||
|
|
||||||
|
Locally you can build the AppImage with `bash packaging/linux/build_appimage.sh <version>`;
|
||||||
|
the NSIS installer requires `makensis` (covered by CI).
|
||||||
|
|
||||||
> macOS builds are unsigned and will need `xattr -dr com.apple.quarantine`
|
> macOS builds are unsigned and will need `xattr -dr com.apple.quarantine`
|
||||||
> guidance or notarization for production releases.
|
> guidance or notarization for production releases.
|
||||||
|
|
||||||
@@ -54,6 +80,18 @@ Artifacts are written to `build/`:
|
|||||||
|
|
||||||
The repository lives at `https://git.allucanget.biz/zwitschi/security-game`.
|
The repository lives at `https://git.allucanget.biz/zwitschi/security-game`.
|
||||||
|
|
||||||
|
Preferred path — use the helper script with a token:
|
||||||
|
|
||||||
|
```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
|
### Via the web UI
|
||||||
|
|
||||||
1. Create a **New Release** with tag `v<version>`.
|
1. Create a **New Release** with tag `v<version>`.
|
||||||
|
|||||||
@@ -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.
|
- 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 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 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
|
## 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.
|
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
|
## 8. Cross-Cutting Concepts
|
||||||
|
|
||||||
- **Event-based incident recording:** Important actions emit records consumed by evidence, reputation, and review systems.
|
- **Event-based incident recording:** Important actions emit records consumed by evidence, reputation, and review systems.
|
||||||
|
|||||||
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 237 KiB |
@@ -23,7 +23,7 @@ script_export_mode=2
|
|||||||
custom_template/debug=""
|
custom_template/debug=""
|
||||||
custom_template/release=""
|
custom_template/release=""
|
||||||
debug/export_console_wrapper=1
|
debug/export_console_wrapper=1
|
||||||
binary_format/embed_pck=false
|
binary_format/embed_pck=true
|
||||||
texture_format/s3tc_bptc=true
|
texture_format/s3tc_bptc=true
|
||||||
texture_format/etc2_astc=false
|
texture_format/etc2_astc=false
|
||||||
binary_format/architecture="x86_64"
|
binary_format/architecture="x86_64"
|
||||||
@@ -54,7 +54,7 @@ script_export_mode=2
|
|||||||
custom_template/debug=""
|
custom_template/debug=""
|
||||||
custom_template/release=""
|
custom_template/release=""
|
||||||
debug/export_console_wrapper=0
|
debug/export_console_wrapper=0
|
||||||
binary_format/embed_pck=false
|
binary_format/embed_pck=true
|
||||||
texture_format/s3tc_bptc=true
|
texture_format/s3tc_bptc=true
|
||||||
texture_format/etc2_astc=false
|
texture_format/etc2_astc=false
|
||||||
codesign/enable=false
|
codesign/enable=false
|
||||||
@@ -90,9 +90,9 @@ script_export_mode=2
|
|||||||
custom_template/debug=""
|
custom_template/debug=""
|
||||||
custom_template/release=""
|
custom_template/release=""
|
||||||
debug/export_console_wrapper=0
|
debug/export_console_wrapper=0
|
||||||
binary_format/embed_pck=false
|
binary_format/embed_pck=true
|
||||||
texture_format/s3tc_bptc=true
|
texture_format/s3tc_bptc=true
|
||||||
texture_format/etc2_astc=false
|
texture_format/etc2_astc=true
|
||||||
codesign/enable=false
|
codesign/enable=false
|
||||||
codesign/timestamp=true
|
codesign/timestamp=true
|
||||||
codesign/identity_type=0
|
codesign/identity_type=0
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# AppImage entry point: launch the bundled game binary.
|
||||||
|
SELF="$(readlink -f "$0")"
|
||||||
|
HERE="$(dirname "$SELF")"
|
||||||
|
exec "${HERE}/usr/bin/step-line" "$@"
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build a self-contained Step Line AppImage (all game data included).
|
||||||
|
#
|
||||||
|
# Prereqs: exported Linux binary in build/ (embed_pck=true), curl.
|
||||||
|
# Usage: bash packaging/linux/build_appimage.sh [version]
|
||||||
|
# Output: build/step-line-<version>-x86_64.AppImage
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT="$(cd "$HERE/../.." && pwd)"
|
||||||
|
VERSION="${1:-$(sed -n 's/.*config\/version="\([^"]*\)".*/\1/p' "$ROOT/project.godot")}"
|
||||||
|
SRC_BIN="$ROOT/build/step-line-linux-${VERSION}.x86_64"
|
||||||
|
|
||||||
|
if [[ ! -f "$SRC_BIN" ]]; then
|
||||||
|
echo "error: missing $SRC_BIN — run ./tools/build_releases.sh first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$WORK"' EXIT
|
||||||
|
APPDIR="$WORK/StepLine.AppDir"
|
||||||
|
mkdir -p "$APPDIR/usr/bin"
|
||||||
|
cp "$SRC_BIN" "$APPDIR/usr/bin/step-line"
|
||||||
|
# The export embeds the PCK (embed_pck=true), so no extra data files are needed.
|
||||||
|
|
||||||
|
cp "$HERE/step-line.desktop" "$APPDIR/step-line.desktop"
|
||||||
|
cp "$HERE/AppRun" "$APPDIR/AppRun"
|
||||||
|
chmod +x "$APPDIR/AppRun"
|
||||||
|
cp "$HERE/step-line.png" "$APPDIR/step-line.png"
|
||||||
|
# Desktop entry + metainfo in standard locations (AppStream validation requires
|
||||||
|
# the metainfo filename to match the component id, and the .desktop discoverable).
|
||||||
|
mkdir -p "$APPDIR/usr/share/applications" "$APPDIR/usr/share/metainfo"
|
||||||
|
cp "$HERE/step-line.desktop" "$APPDIR/usr/share/applications/step-line.desktop"
|
||||||
|
cp "$HERE/step-line.appdata.xml" "$APPDIR/usr/share/metainfo/net.stepline.game.appdata.xml"
|
||||||
|
|
||||||
|
TOOL="${APPIMAGE_TOOL:-/tmp/appimagetool-x86_64.AppImage}"
|
||||||
|
if [[ ! -f "$TOOL" ]]; then
|
||||||
|
echo "==> Downloading appimagetool"
|
||||||
|
curl -sL -o "$TOOL" \
|
||||||
|
https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||||
|
chmod +x "$TOOL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
OUT="$ROOT/build/step-line-${VERSION}-x86_64.AppImage"
|
||||||
|
echo "==> Building AppImage -> $OUT"
|
||||||
|
# Extract-and-run avoids a FUSE dependency on the build host.
|
||||||
|
APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" "$APPDIR" "$OUT"
|
||||||
|
echo "AppImage ready: $(ls -la "$OUT" | awk '{print $5}') bytes"
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="desktop-application">
|
||||||
|
<id>net.stepline.game</id>
|
||||||
|
<name>Step Line</name>
|
||||||
|
<summary>Workplace security simulation on Hollywood Boulevard</summary>
|
||||||
|
<url type="homepage">https://git.allucanget.biz/zwitschi/security-game</url>
|
||||||
|
<developer id="net.stepline"><name>Step Line Team</name></developer>
|
||||||
|
<description>
|
||||||
|
<p>
|
||||||
|
Step Line is a workplace security simulation about observation, communication,
|
||||||
|
access verification, and proportionate escalation. Guard the steps and entrance
|
||||||
|
of venues around Hollywood Boulevard: direct tourists, verify wristbands and IDs,
|
||||||
|
screen guests, and escalate only when you have to.
|
||||||
|
</p>
|
||||||
|
</description>
|
||||||
|
<launchable type="desktop-id">step-line.desktop</launchable>
|
||||||
|
<metadata_license>CC0-1.0</metadata_license>
|
||||||
|
<project_license>MIT</project_license>
|
||||||
|
<content_rating type="oars-1.1" />
|
||||||
|
<releases>
|
||||||
|
<release version="0.2.0" date="2026-10-31"/>
|
||||||
|
</releases>
|
||||||
|
</component>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Step Line
|
||||||
|
Comment=Workplace security simulation on Hollywood Boulevard
|
||||||
|
Exec=step-line
|
||||||
|
Icon=step-line
|
||||||
|
Terminal=false
|
||||||
|
Categories=Game;Simulation;
|
||||||
|
Keywords=security;simulation;hollywood;
|
||||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,74 @@
|
|||||||
|
; Step Line Windows installer (NSIS 3).
|
||||||
|
; Build from the repo root with the exported self-contained exe:
|
||||||
|
; makensis /DVERSION=0.2.0 /DARTIFACT=build/step-line-windows-0.2.0.exe \
|
||||||
|
; /DICON=packaging/windows/step-line.ico packaging/windows/step-line.nsi
|
||||||
|
; Outputs: build/step-line-setup-<VERSION>.exe
|
||||||
|
|
||||||
|
!ifndef VERSION
|
||||||
|
!define VERSION "0.0.0"
|
||||||
|
!endif
|
||||||
|
!ifndef ARTIFACT
|
||||||
|
!error "ARTIFACT define required (path to exported windows exe)"
|
||||||
|
!endif
|
||||||
|
!ifndef ICON
|
||||||
|
!define ICON "packaging\windows\step-line.ico"
|
||||||
|
!endif
|
||||||
|
|
||||||
|
; The installed executable uses this clean name (no spaces — NSIS /oname tokens
|
||||||
|
; on whitespace, so a spaced name breaks the File directive).
|
||||||
|
!define INSTALLED_EXE "step-line.exe"
|
||||||
|
!ifndef OUT
|
||||||
|
!define OUT "build\step-line-setup-${VERSION}.exe"
|
||||||
|
!endif
|
||||||
|
!define COMPANY "Step Line Project"
|
||||||
|
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\Step Line"
|
||||||
|
|
||||||
|
Unicode true
|
||||||
|
ManifestDPIAware true
|
||||||
|
Icon "${ICON}"
|
||||||
|
UninstallIcon "${ICON}"
|
||||||
|
|
||||||
|
Name "Step Line ${VERSION}"
|
||||||
|
OutFile "${OUT}"
|
||||||
|
InstallDir "$PROGRAMFILES64\Step Line"
|
||||||
|
RequestExecutionLevel admin
|
||||||
|
|
||||||
|
Page directory
|
||||||
|
Page instfiles
|
||||||
|
UninstPage uninstConfirm
|
||||||
|
UninstPage instfiles
|
||||||
|
|
||||||
|
Section "Install"
|
||||||
|
SetOutPath "$INSTDIR"
|
||||||
|
|
||||||
|
; The self-contained executable contains ALL game data (embedded PCK).
|
||||||
|
File "/oname=${INSTALLED_EXE}" "${ARTIFACT}"
|
||||||
|
|
||||||
|
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||||
|
|
||||||
|
; Shortcuts
|
||||||
|
CreateDirectory "$SMPROGRAMS\Step Line"
|
||||||
|
CreateShortCut "$SMPROGRAMS\Step Line\Step Line.lnk" "$INSTDIR\${INSTALLED_EXE}"
|
||||||
|
CreateShortCut "$SMPROGRAMS\Step Line\Uninstall.lnk" "$INSTDIR\uninstall.exe"
|
||||||
|
CreateShortCut "$DESKTOP\Step Line.lnk" "$INSTDIR\${INSTALLED_EXE}"
|
||||||
|
|
||||||
|
; Registry (Add/Remove Programs)
|
||||||
|
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "Step Line"
|
||||||
|
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${VERSION}"
|
||||||
|
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${COMPANY}"
|
||||||
|
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${INSTALLED_EXE}"
|
||||||
|
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" '"$INSTDIR\uninstall.exe"'
|
||||||
|
WriteRegDWORD HKLM "${UNINST_KEY}" "NoModify" 1
|
||||||
|
WriteRegDWORD HKLM "${UNINST_KEY}" "NoRepair" 1
|
||||||
|
SectionEnd
|
||||||
|
|
||||||
|
Section "Uninstall"
|
||||||
|
Delete "$INSTDIR\${INSTALLED_EXE}"
|
||||||
|
Delete "$INSTDIR\uninstall.exe"
|
||||||
|
RMDir "$INSTDIR"
|
||||||
|
Delete "$SMPROGRAMS\Step Line\Step Line.lnk"
|
||||||
|
Delete "$SMPROGRAMS\Step Line\Uninstall.lnk"
|
||||||
|
RMDir "$SMPROGRAMS\Step Line"
|
||||||
|
Delete "$DESKTOP\Step Line.lnk"
|
||||||
|
DeleteRegKey HKLM "${UNINST_KEY}"
|
||||||
|
SectionEnd
|
||||||
@@ -8,7 +8,7 @@ config_version=5
|
|||||||
|
|
||||||
config/name="Step Line"
|
config/name="Step Line"
|
||||||
config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
|
config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
|
||||||
config/version="0.1.0"
|
config/version="0.2.1"
|
||||||
run/main_scene="res://scenes/main.tscn"
|
run/main_scene="res://scenes/main.tscn"
|
||||||
config/features=PackedStringArray("4.3", "GL Compatibility")
|
config/features=PackedStringArray("4.3", "GL Compatibility")
|
||||||
|
|
||||||
@@ -105,3 +105,4 @@ print_debug={
|
|||||||
|
|
||||||
renderer/rendering_method="gl_compatibility"
|
renderer/rendering_method="gl_compatibility"
|
||||||
renderer/rendering_method.mobile="gl_compatibility"
|
renderer/rendering_method.mobile="gl_compatibility"
|
||||||
|
textures/vram_compression/import_etc2_astc=true
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
appimagetool.png
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#! /bin/sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
this_dir="$(dirname -- "$(readlink -f -- "$0")")"
|
||||||
|
|
||||||
|
# make appimagetool prefer the bundled mksquashfs
|
||||||
|
export PATH="$this_dir"/usr/bin:"$PATH"
|
||||||
|
|
||||||
|
exec "$this_dir"/usr/bin/appimagetool "$@"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
usr/share/applications/appimagetool.desktop
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
usr/share/icons/hicolor/128x128/apps/appimagetool.png
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=appimagetool
|
||||||
|
Exec=appimagetool
|
||||||
|
Comment=Tool to generate AppImages from AppDirs
|
||||||
|
Icon=appimagetool
|
||||||
|
Categories=Development;
|
||||||
|
Terminal=true
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 9.5 KiB |
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<component type="desktop">
|
||||||
|
<id>appimagetool.desktop</id>
|
||||||
|
<metadata_license>MIT</metadata_license>
|
||||||
|
<project_license>MIT</project_license>
|
||||||
|
<name>appimagetool</name>
|
||||||
|
<summary>Create AppImage files</summary>
|
||||||
|
<description>
|
||||||
|
<p>
|
||||||
|
appimagetool is a tool that lets you generate AppImage files. An AppImage
|
||||||
|
is a self-running bundle that contains an application and everything
|
||||||
|
it needs to run that cannot reasonably expected to be part of each target system.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
This can include libraries that are not commonly available on target systems,
|
||||||
|
resources such as translations, icons, fonts, and other auxiliary files.
|
||||||
|
appimagetool makes it easy to take an AppDir (e.g., generated by linuxdeployqt)
|
||||||
|
and turn it into an AppImage.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You can also embed update information so that your users can update the AppImage
|
||||||
|
using binary delta updates with the AppImageUpdate tool.
|
||||||
|
You can also sign the AppImage with GPG.
|
||||||
|
</p>
|
||||||
|
</description>
|
||||||
|
<url type="homepage">https://github.com/AppImage/AppImageKit</url>
|
||||||
|
<project_group>AppImage</project_group>
|
||||||
|
<provides>
|
||||||
|
<binary>appimagetool</binary>
|
||||||
|
</provides>
|
||||||
|
</component>
|
||||||
@@ -21,13 +21,11 @@ static func shared_profiles() -> Array[NpcProfile]:
|
|||||||
tourist.npc_type = Rules.NpcType.TOURIST
|
tourist.npc_type = Rules.NpcType.TOURIST
|
||||||
tourist.temperament = 0.1
|
tourist.temperament = 0.1
|
||||||
tourist.base_compliance = 0.9
|
tourist.base_compliance = 0.9
|
||||||
tourist.dialogue_lines = {
|
tourist.dialogue_lines = [
|
||||||
Rules.Tone.FRIENDLY: [
|
"Oh sure, sorry, just taking a picture.",
|
||||||
"Oh sure, sorry, just taking a picture.",
|
"Is there a better spot to stand?",
|
||||||
"Is there a better spot to stand?",
|
"Understood, moving.",
|
||||||
],
|
]
|
||||||
Rules.Tone.PROFESSIONAL: ["Understood, moving."],
|
|
||||||
}
|
|
||||||
profiles.append(tourist)
|
profiles.append(tourist)
|
||||||
|
|
||||||
var confused := NpcProfile.new()
|
var confused := NpcProfile.new()
|
||||||
@@ -36,12 +34,10 @@ static func shared_profiles() -> Array[NpcProfile]:
|
|||||||
confused.npc_type = Rules.NpcType.CONFUSED_VISITOR
|
confused.npc_type = Rules.NpcType.CONFUSED_VISITOR
|
||||||
confused.temperament = 0.2
|
confused.temperament = 0.2
|
||||||
confused.base_compliance = 0.85
|
confused.base_compliance = 0.85
|
||||||
confused.dialogue_lines = {
|
confused.dialogue_lines = [
|
||||||
Rules.Tone.FRIENDLY: [
|
"Wait, this isn't the entrance?",
|
||||||
"Wait, this isn't the entrance?",
|
"Sorry about that, I'm a bit lost.",
|
||||||
"Sorry about that, I'm a bit lost.",
|
]
|
||||||
],
|
|
||||||
}
|
|
||||||
profiles.append(confused)
|
profiles.append(confused)
|
||||||
|
|
||||||
var delivery := NpcProfile.new()
|
var delivery := NpcProfile.new()
|
||||||
@@ -50,9 +46,7 @@ static func shared_profiles() -> Array[NpcProfile]:
|
|||||||
delivery.npc_type = Rules.NpcType.DELIVERY
|
delivery.npc_type = Rules.NpcType.DELIVERY
|
||||||
delivery.temperament = 0.25
|
delivery.temperament = 0.25
|
||||||
delivery.base_compliance = 0.8
|
delivery.base_compliance = 0.8
|
||||||
delivery.dialogue_lines = {
|
delivery.dialogue_lines = ["Where should I leave this?"]
|
||||||
Rules.Tone.PROFESSIONAL: ["Where should I leave this?"],
|
|
||||||
}
|
|
||||||
profiles.append(delivery)
|
profiles.append(delivery)
|
||||||
|
|
||||||
var performer := NpcProfile.new()
|
var performer := NpcProfile.new()
|
||||||
@@ -97,11 +91,9 @@ static func shared_profiles() -> Array[NpcProfile]:
|
|||||||
Rules.Reaction.COMPLY: 10,
|
Rules.Reaction.COMPLY: 10,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
influencer.dialogue_lines = {
|
influencer.dialogue_lines = [
|
||||||
Rules.Tone.SUSPICIOUS: [
|
"This is public sidewalk, you can't stop me from filming.",
|
||||||
"This is public sidewalk, you can't stop me from filming.",
|
]
|
||||||
],
|
|
||||||
}
|
|
||||||
profiles.append(influencer)
|
profiles.append(influencer)
|
||||||
|
|
||||||
# --- Higher-risk crowd ------------------------------------------------------
|
# --- Higher-risk crowd ------------------------------------------------------
|
||||||
@@ -162,12 +154,10 @@ static func shared_profiles() -> Array[NpcProfile]:
|
|||||||
Rules.Reaction.LEAVE: 30,
|
Rules.Reaction.LEAVE: 30,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
trespasser.dialogue_lines = {
|
trespasser.dialogue_lines = [
|
||||||
Rules.Tone.SUSPICIOUS: [
|
"I'm just here to see a friend, come on.",
|
||||||
"I'm just here to see a friend, come on.",
|
"I'll be inside for ten seconds.",
|
||||||
"I'll be inside for ten seconds.",
|
]
|
||||||
],
|
|
||||||
}
|
|
||||||
profiles.append(trespasser)
|
profiles.append(trespasser)
|
||||||
|
|
||||||
var mental_crisis := NpcProfile.new()
|
var mental_crisis := NpcProfile.new()
|
||||||
@@ -278,12 +268,10 @@ static func shared_profiles() -> Array[NpcProfile]:
|
|||||||
Rules.Reaction.FORCE_ENTRY: 20,
|
Rules.Reaction.FORCE_ENTRY: 20,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
protester.dialogue_lines = {
|
protester.dialogue_lines = [
|
||||||
Rules.Tone.SUSPICIOUS: [
|
"I have every right to be on public sidewalk.",
|
||||||
"I have every right to be on public sidewalk.",
|
"You can't touch me, this is a lawful protest.",
|
||||||
"You can't touch me, this is a lawful protest.",
|
]
|
||||||
],
|
|
||||||
}
|
|
||||||
profiles.append(protester)
|
profiles.append(protester)
|
||||||
|
|
||||||
# --- Authorized crowd ---------------------------------------------------------
|
# --- Authorized crowd ---------------------------------------------------------
|
||||||
@@ -347,17 +335,17 @@ static func hollywood_building() -> VenueScenario:
|
|||||||
])
|
])
|
||||||
|
|
||||||
scenario.event_pool = [
|
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("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("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("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("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("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("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.24),
|
||||||
_building_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.20),
|
_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("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",
|
"id": "crisis_event",
|
||||||
"phase": Rules.Phase.EVENING,
|
"phase": Rules.Phase.EVENING,
|
||||||
|
|||||||
@@ -2,15 +2,26 @@ class_name FSM
|
|||||||
extends RefCounted
|
extends RefCounted
|
||||||
## Minimal generic finite state machine used by NPCController and ShiftCoordinator.
|
## Minimal generic finite state machine used by NPCController and ShiftCoordinator.
|
||||||
## Deterministic and headless-testable.
|
## Deterministic and headless-testable.
|
||||||
|
##
|
||||||
|
## The owner is held through a WeakRef so long-lived owners do not create a
|
||||||
|
## reference cycle (FSM <-> owner), which previously leaked RefCounted objects
|
||||||
|
## at process exit (see MEMORY.md).
|
||||||
|
|
||||||
signal state_changed(node: Object, from_state: String, to_state: String)
|
signal state_changed(node: Object, from_state: String, to_state: String)
|
||||||
|
|
||||||
var current_state: String = ""
|
var current_state: String = ""
|
||||||
var _transitions: Dictionary = {} # state_name -> Array[{to, guard: Callable}]
|
var _transitions: Dictionary = {} # state_name -> Array[{to, guard: Callable}]
|
||||||
var _owner_node: Object = null
|
var _owner_ref: WeakRef = null
|
||||||
|
|
||||||
func _init(owner_node: Object = null) -> void:
|
func _init(owner_node: Object = null) -> void:
|
||||||
_owner_node = owner_node
|
if owner_node != null:
|
||||||
|
_owner_ref = weakref(owner_node)
|
||||||
|
|
||||||
|
## The live owner object, or null if it was freed.
|
||||||
|
func _owner() -> Object:
|
||||||
|
if _owner_ref == null:
|
||||||
|
return null
|
||||||
|
return _owner_ref.get_ref() as Object
|
||||||
|
|
||||||
## Register a transition. guard is an optional Callable(node) -> bool.
|
## Register a transition. guard is an optional Callable(node) -> bool.
|
||||||
func add_transition(from_state: String, to_state: String, guard: Callable = Callable()) -> void:
|
func add_transition(from_state: String, to_state: String, guard: Callable = Callable()) -> void:
|
||||||
@@ -20,7 +31,7 @@ func add_transition(from_state: String, to_state: String, guard: Callable = Call
|
|||||||
|
|
||||||
func start(initial_state: String) -> void:
|
func start(initial_state: String) -> void:
|
||||||
current_state = initial_state
|
current_state = initial_state
|
||||||
state_changed.emit(_owner_node, "", initial_state)
|
state_changed.emit(_owner(), "", initial_state)
|
||||||
|
|
||||||
## Attempt to move to `to_state`. Returns true when executed.
|
## Attempt to move to `to_state`. Returns true when executed.
|
||||||
func transition_to(to_state: String) -> bool:
|
func transition_to(to_state: String) -> bool:
|
||||||
@@ -31,10 +42,10 @@ func transition_to(to_state: String) -> bool:
|
|||||||
if t["to"] != to_state:
|
if t["to"] != to_state:
|
||||||
continue
|
continue
|
||||||
var guard: Callable = t["guard"]
|
var guard: Callable = t["guard"]
|
||||||
if not guard.is_valid() or guard.call(_owner_node):
|
if not guard.is_valid() or guard.call(_owner()):
|
||||||
var from := current_state
|
var from := current_state
|
||||||
current_state = to_state
|
current_state = to_state
|
||||||
state_changed.emit(_owner_node, from, to_state)
|
state_changed.emit(_owner(), from, to_state)
|
||||||
return true
|
return true
|
||||||
return false
|
return false
|
||||||
|
|
||||||
@@ -42,6 +53,6 @@ func can_transition_to(to_state: String) -> bool:
|
|||||||
for t in _transitions.get(current_state, []):
|
for t in _transitions.get(current_state, []):
|
||||||
if t["to"] == to_state:
|
if t["to"] == to_state:
|
||||||
var guard: Callable = t["guard"]
|
var guard: Callable = t["guard"]
|
||||||
if not guard.is_valid() or guard.call(_owner_node):
|
if not guard.is_valid() or guard.call(_owner()):
|
||||||
return true
|
return true
|
||||||
return false
|
return false
|
||||||
@@ -8,7 +8,7 @@ const FSM := preload("res://src/core/fsm.gd")
|
|||||||
## reaction logic. The presentation layer adds a visual avatar and navigation.
|
## reaction logic. The presentation layer adds a visual avatar and navigation.
|
||||||
## RefCounted on purpose: headless tests and CI do not need a scene tree.
|
## 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 npc_id: String = ""
|
||||||
var profile: NpcProfile = null
|
var profile: NpcProfile = null
|
||||||
@@ -61,15 +61,10 @@ func _build_fsm(initial_state: String) -> void:
|
|||||||
fsm.add_transition("THREATENING", "FORCING_ENTRY")
|
fsm.add_transition("THREATENING", "FORCING_ENTRY")
|
||||||
fsm.start(initial_state)
|
fsm.start(initial_state)
|
||||||
|
|
||||||
func roll_reaction(level: int, tone: int) -> int:
|
func roll_reaction(level: int) -> int:
|
||||||
## Weighted pick from profile's reaction table for `level`, adjusted for tone.
|
## Weighted pick from profile's reaction table for `level`.
|
||||||
highest_escalation_received = maxi(highest_escalation_received, level)
|
highest_escalation_received = maxi(highest_escalation_received, level)
|
||||||
var weights: Dictionary = profile.pickup_reactions_per_level(level).duplicate()
|
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 roll_value := rng.randf()
|
||||||
var total := 0.0
|
var total := 0.0
|
||||||
for key in weights.keys():
|
for key in weights.keys():
|
||||||
@@ -83,16 +78,15 @@ func roll_reaction(level: int, tone: int) -> int:
|
|||||||
return key
|
return key
|
||||||
return Rules.Reaction.LEAVE
|
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.
|
## 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 = performed_reaction
|
||||||
reaction_set = true
|
reaction_set = true
|
||||||
_update_state_for_reaction(performed_reaction)
|
_update_state_for_reaction(performed_reaction)
|
||||||
return {
|
return {
|
||||||
"npc_id": npc_id,
|
"npc_id": npc_id,
|
||||||
"level_used": level,
|
"level_used": level,
|
||||||
"tone": tone,
|
|
||||||
"reaction": performed_reaction,
|
"reaction": performed_reaction,
|
||||||
"reaction_name": Rules.Reaction.find_key(performed_reaction),
|
"reaction_name": Rules.Reaction.find_key(performed_reaction),
|
||||||
"new_state": fsm.current_state,
|
"new_state": fsm.current_state,
|
||||||
|
|||||||
@@ -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.
|
## 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
|
@export_range(0.0, 1.0) var temperament: float = 0.25
|
||||||
|
|
||||||
## Base compliance: probability the NPC obeys a level-1 polite direction.
|
## Probability the NPC obeys a level-1 polite direction.
|
||||||
## Venue/scenario recalculates effective compliance from profile + tone + level.
|
|
||||||
@export_range(0.0, 1.0) var base_compliance: float = 0.8
|
@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
|
## 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).
|
## Delay the NPC waits before reacting to the player (frames/seconds scaled).
|
||||||
@export_range(0.1, 5.0) var reaction_delay: float = 0.6
|
@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.
|
## Dialogue lines this NPC uses. Falls back to generic lines when empty.
|
||||||
@export var dialogue_lines: Dictionary = {}
|
@export var dialogue_lines: Array[String] = []
|
||||||
|
|
||||||
## Items the NPC is carrying, used by the handheld wand (The Joke Shop).
|
## Items the NPC is carrying, used by the handheld wand (The Joke Shop).
|
||||||
## Values are item names: "keys", "belt", "phone", "weapon", "knife", etc.
|
## Values are item names: "keys", "belt", "phone", "weapon", "knife", etc.
|
||||||
|
|||||||
@@ -108,21 +108,6 @@ enum Reaction {
|
|||||||
CALL_SOMEONE = 8,
|
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.
|
## Shift phases by time of day.
|
||||||
enum Phase {
|
enum Phase {
|
||||||
EARLY = 0,
|
EARLY = 0,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
class_name ShiftCoordinator
|
class_name ShiftCoordinator
|
||||||
extends Node
|
extends RefCounted
|
||||||
|
|
||||||
const Rules := preload("res://src/core/rules.gd")
|
const Rules := preload("res://src/core/rules.gd")
|
||||||
const EventBus := preload("res://src/core/event_bus.gd")
|
const EventBus := preload("res://src/core/event_bus.gd")
|
||||||
|
|||||||
@@ -68,6 +68,23 @@ func _spot_for_intent(intent: String) -> String:
|
|||||||
_:
|
_:
|
||||||
return "sidewalk"
|
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:
|
func _location_or_default(spot: String, fallback: Vector3) -> Vector3:
|
||||||
if locations.has(spot):
|
if locations.has(spot):
|
||||||
var v: Variant = locations[spot]
|
var v: Variant = locations[spot]
|
||||||
|
|||||||
@@ -27,6 +27,35 @@ var door := DoorState.new()
|
|||||||
var flashlight_on := false
|
var flashlight_on := false
|
||||||
var coordinator: ShiftCoordinator = null
|
var coordinator: ShiftCoordinator = null
|
||||||
var guard: GuardController = 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 hud: Hud = null
|
||||||
var radio_panel: RadioPanel = null
|
var radio_panel: RadioPanel = null
|
||||||
var shift_review_panel: ShiftReview = null
|
var shift_review_panel: ShiftReview = null
|
||||||
@@ -35,7 +64,11 @@ var verifier := AccessVerifier.new()
|
|||||||
var wand_screener := WandScreener.new()
|
var wand_screener := WandScreener.new()
|
||||||
|
|
||||||
var active_scenario_id := "hollywood_building"
|
var active_scenario_id := "hollywood_building"
|
||||||
var scenario_duration_s: float = 300.0 # shortened prototype duration for testing
|
## Shift length mirrors the scenario default (900s / 10-15 min per PLAN.md) unless
|
||||||
|
## overridden for a test or debug build.
|
||||||
|
var scenario_duration_s: float = 900.0
|
||||||
|
var _wand_cooldown := 0.0
|
||||||
|
const WAND_COOLDOWN_S := 0.8
|
||||||
var scene_root: Node3D = null
|
var scene_root: Node3D = null
|
||||||
var npcs: Array = []
|
var npcs: Array = []
|
||||||
var npc_counter := 0
|
var npc_counter := 0
|
||||||
@@ -46,15 +79,19 @@ var current_incident_npc_id := ""
|
|||||||
var current_incident_active := false
|
var current_incident_active := false
|
||||||
var current_npc_truth := {"weapons_seen": false, "danger_level": 0}
|
var current_npc_truth := {"weapons_seen": false, "danger_level": 0}
|
||||||
|
|
||||||
## Dialogue tone picker (PLAN.md: Level 1 tones). Cycled with T.
|
## Venue cycling (V): next venue in the scenario list.
|
||||||
var selected_tone: int = Rules.Tone.PROFESSIONAL
|
const VENUE_ORDER := ["hollywood_building", "joke_shop", "tv_studio"]
|
||||||
const TONE_ORDER := [Rules.Tone.PROFESSIONAL, Rules.Tone.FRIENDLY, Rules.Tone.FIRM, Rules.Tone.SUSPICIOUS]
|
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).
|
## Aim-free targeting: cycle through nearby NPCs with Tab (accessibility).
|
||||||
const NEARBY_RADIUS := 12.0
|
const NEARBY_RADIUS := 12.0
|
||||||
var _nearby_index := -1
|
var _nearby_index := -1
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
_door_rng.randomize()
|
||||||
scene_root = Node3D.new()
|
scene_root = Node3D.new()
|
||||||
scene_root.name = "SceneRoot"
|
scene_root.name = "SceneRoot"
|
||||||
add_child(scene_root)
|
add_child(scene_root)
|
||||||
@@ -70,6 +107,7 @@ func _ready() -> void:
|
|||||||
guard.cleared_target.connect(_on_guard_clear)
|
guard.cleared_target.connect(_on_guard_clear)
|
||||||
|
|
||||||
selected_scenario = _build_scenario(active_scenario_id)
|
selected_scenario = _build_scenario(active_scenario_id)
|
||||||
|
scenario_duration_s = selected_scenario.shift_duration_s
|
||||||
coordinator = ShiftCoordinator.new()
|
coordinator = ShiftCoordinator.new()
|
||||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||||
coordinator.npc_spawn_requested.connect(_spawn_npc)
|
coordinator.npc_spawn_requested.connect(_spawn_npc)
|
||||||
@@ -78,6 +116,8 @@ func _ready() -> void:
|
|||||||
|
|
||||||
_build_ui()
|
_build_ui()
|
||||||
coordinator.start()
|
coordinator.start()
|
||||||
|
_last_phase = -1
|
||||||
|
_apply_phase_lighting()
|
||||||
|
|
||||||
var selected_scenario: VenueScenario = null
|
var selected_scenario: VenueScenario = null
|
||||||
|
|
||||||
@@ -107,6 +147,7 @@ func _build_guard() -> GuardController:
|
|||||||
collider.shape = shape
|
collider.shape = shape
|
||||||
collider.position = Vector3(0, 0.85, 0)
|
collider.position = Vector3(0, 0.85, 0)
|
||||||
g.add_child(collider)
|
g.add_child(collider)
|
||||||
|
g.add_to_group("guard")
|
||||||
return g
|
return g
|
||||||
|
|
||||||
func _build_scenario(id: String) -> VenueScenario:
|
func _build_scenario(id: String) -> VenueScenario:
|
||||||
@@ -133,8 +174,11 @@ func restart_shift() -> void:
|
|||||||
inspector_current_events = []
|
inspector_current_events = []
|
||||||
current_incident_active = false
|
current_incident_active = false
|
||||||
current_incident_npc_id = ""
|
current_incident_npc_id = ""
|
||||||
|
scenario_duration_s = selected_scenario.shift_duration_s
|
||||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||||
coordinator.start()
|
coordinator.start()
|
||||||
|
_last_phase = -1
|
||||||
|
_apply_phase_lighting()
|
||||||
|
|
||||||
var inspector_current_events: Array = []
|
var inspector_current_events: Array = []
|
||||||
|
|
||||||
@@ -156,17 +200,44 @@ func _build_ui() -> void:
|
|||||||
shift_review_panel.restart_requested.connect(restart_shift)
|
shift_review_panel.restart_requested.connect(restart_shift)
|
||||||
|
|
||||||
func _ensure_lighting() -> void:
|
func _ensure_lighting() -> void:
|
||||||
var sun := DirectionalLight3D.new()
|
_sun = DirectionalLight3D.new()
|
||||||
sun.rotation_degrees = Vector3(-50, -30, 0)
|
_sun.name = "Sun"
|
||||||
sun.light_energy = 1.2
|
_sun.rotation_degrees = Vector3(-50, -30, 0)
|
||||||
scene_root.add_child(sun)
|
_sun.light_energy = 1.2
|
||||||
var env_node := WorldEnvironment.new()
|
_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()
|
var env := Environment.new()
|
||||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
env.background_mode = Environment.BG_SKY
|
||||||
env.ambient_light_color = Color(0.7, 0.7, 0.75)
|
_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.ambient_light_energy = 0.5
|
||||||
env_node.environment = env
|
env.reflected_light_source = Environment.REFLECTION_SOURCE_SKY
|
||||||
scene_root.add_child(env_node)
|
_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:
|
func _spawn_npc(profile: NpcProfile, intent: String, _npc_id: String) -> void:
|
||||||
npc_counter += 1
|
npc_counter += 1
|
||||||
@@ -266,12 +337,10 @@ func _handle_key(keycode: Key) -> void:
|
|||||||
_toggle_inspection()
|
_toggle_inspection()
|
||||||
KEY_Q:
|
KEY_Q:
|
||||||
if targeted_npc != null and not inspection_open:
|
if targeted_npc != null and not inspection_open:
|
||||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, selected_tone, "polite direction")
|
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "polite direction")
|
||||||
KEY_W:
|
KEY_W:
|
||||||
if targeted_npc != null and not inspection_open:
|
if targeted_npc != null and not inspection_open:
|
||||||
_toggle_wand_screening()
|
_toggle_wand_screening()
|
||||||
KEY_T:
|
|
||||||
_cycle_tone()
|
|
||||||
KEY_TAB:
|
KEY_TAB:
|
||||||
_cycle_nearby_target()
|
_cycle_nearby_target()
|
||||||
KEY_F:
|
KEY_F:
|
||||||
@@ -280,21 +349,23 @@ func _handle_key(keycode: Key) -> void:
|
|||||||
_toggle_flashlight()
|
_toggle_flashlight()
|
||||||
KEY_L:
|
KEY_L:
|
||||||
_toggle_door_lock()
|
_toggle_door_lock()
|
||||||
|
KEY_V:
|
||||||
|
_cycle_venue()
|
||||||
_:
|
_:
|
||||||
_handle_action_key(keycode)
|
_handle_action_key(keycode)
|
||||||
|
|
||||||
func _handle_action_key(keycode: Key) -> void:
|
func _handle_action_key(keycode: Key) -> void:
|
||||||
match keycode:
|
match keycode:
|
||||||
KEY_1:
|
KEY_1:
|
||||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, selected_tone, "raise hand")
|
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "raise hand")
|
||||||
KEY_2:
|
KEY_2:
|
||||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, selected_tone, "point")
|
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "point")
|
||||||
KEY_3:
|
KEY_3:
|
||||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, selected_tone, "head shake")
|
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "head shake")
|
||||||
KEY_4:
|
KEY_4:
|
||||||
_apply_interaction(Rules.Level.FORMAL_WARNING, selected_tone, "formal warning")
|
_apply_interaction(Rules.Level.FORMAL_WARNING, "formal warning")
|
||||||
KEY_5:
|
KEY_5:
|
||||||
_apply_interaction(Rules.Level.BLOCKING, selected_tone, "blocking")
|
_apply_interaction(Rules.Level.BLOCKING, "blocking")
|
||||||
KEY_6:
|
KEY_6:
|
||||||
_open_radio(false)
|
_open_radio(false)
|
||||||
KEY_R:
|
KEY_R:
|
||||||
@@ -331,13 +402,15 @@ func _toggle_door_lock() -> void:
|
|||||||
hud.set_door(door.locked)
|
hud.set_door(door.locked)
|
||||||
hud.log(Strings.lookup("door.locked") if door.locked else Strings.lookup("door.unlocked"))
|
hud.log(Strings.lookup("door.locked") if door.locked else Strings.lookup("door.unlocked"))
|
||||||
|
|
||||||
## Cycle the dialogue tone (Professional → Friendly → Firm → Suspicious).
|
## Cycle to the next venue (Hollywood Blvd → The Joke Shop → TV Studio).
|
||||||
func _cycle_tone() -> void:
|
## The coordinator is rebuilt with the new scenario and the shift restarts.
|
||||||
var idx := TONE_ORDER.find(selected_tone)
|
func _cycle_venue() -> void:
|
||||||
idx = (idx + 1) % TONE_ORDER.size()
|
_venue_index = (_venue_index + 1) % VENUE_ORDER.size()
|
||||||
selected_tone = TONE_ORDER[idx]
|
switch_scenario(VENUE_ORDER[_venue_index])
|
||||||
hud.set_tone(Rules.TONE_NAMES.get(selected_tone, "?"))
|
var venue_name: String = selected_scenario.display_name if selected_scenario else "?"
|
||||||
hud.log("Tone: %s" % Rules.TONE_NAMES.get(selected_tone, "?"))
|
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
|
## Aim-free targeting: cycle through NPCs near the guard (Tab). Lets players
|
||||||
## interact without precise aiming (accessibility).
|
## interact without precise aiming (accessibility).
|
||||||
@@ -359,20 +432,19 @@ func _cycle_nearby_target() -> void:
|
|||||||
_begin_incident(next_target)
|
_begin_incident(next_target)
|
||||||
hud.log(Strings.fmt("hud.targeting", [next_target.name]))
|
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:
|
if targeted_npc == null:
|
||||||
hud.log(Strings.lookup("hud.aim_first"))
|
hud.log(Strings.lookup("hud.aim_first"))
|
||||||
return
|
return
|
||||||
var controller: Object = _targeted_controller()
|
var controller: Object = _targeted_controller()
|
||||||
if controller == null or coordinator == null:
|
if controller == null or coordinator == null:
|
||||||
return
|
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
|
var npc_id: String = controller.npc_id
|
||||||
coordinator.recorder.add_event(npc_id, {
|
coordinator.recorder.add_event(npc_id, {
|
||||||
"type": "player_action",
|
"type": "player_action",
|
||||||
"action": action_name,
|
"action": action_name,
|
||||||
"level": level,
|
"level": level,
|
||||||
"tone": Rules.TONE_NAMES.get(tone, "?"),
|
|
||||||
})
|
})
|
||||||
coordinator.recorder.add_escalation(npc_id, level, "%s on %s" % [action_name, npc_id])
|
coordinator.recorder.add_escalation(npc_id, level, "%s on %s" % [action_name, npc_id])
|
||||||
if level >= Rules.Level.FORMAL_WARNING:
|
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")
|
coordinator.crowd.apply_event("calm_professionalism")
|
||||||
if audio:
|
if audio:
|
||||||
audio.play_radio_response()
|
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 --
|
# ----------------------------------------------------------------- access --
|
||||||
|
|
||||||
@@ -469,19 +548,29 @@ func _toggle_inspection() -> void:
|
|||||||
var artifacts := _artifacts_for(controller)
|
var artifacts := _artifacts_for(controller)
|
||||||
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy)
|
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy)
|
||||||
hud.show_inspection(controller, artifacts, result)
|
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 --
|
# ------------------------------------------------------------- wand screening --
|
||||||
|
|
||||||
## Handheld wand inspection (The Joke Shop). Only available when the venue
|
## Handheld wand inspection (The Joke Shop). Only available when the venue
|
||||||
## carries a wand. The wand reports an alert; the player interprets it.
|
## carries a wand. The wand reports an alert; the player interprets it.
|
||||||
func _toggle_wand_screening() -> void:
|
func _toggle_wand_screening() -> void:
|
||||||
|
if _wand_cooldown > 0.0:
|
||||||
|
hud.log(Strings.lookup("wand.recharge"))
|
||||||
|
return
|
||||||
if wand_open:
|
if wand_open:
|
||||||
hud.hide_wand()
|
hud.hide_wand()
|
||||||
|
hud.set_wand_icon(false)
|
||||||
wand_open = false
|
wand_open = false
|
||||||
return
|
return
|
||||||
if not selected_scenario.equipment.has("wand"):
|
if not selected_scenario.equipment.has("wand"):
|
||||||
hud.log(Strings.lookup("hud.no_wand"))
|
hud.log(Strings.lookup("hud.no_wand"))
|
||||||
|
hud.set_wand_icon(false)
|
||||||
return
|
return
|
||||||
|
hud.set_wand_icon(true)
|
||||||
var controller: Object = _targeted_controller()
|
var controller: Object = _targeted_controller()
|
||||||
if controller == null:
|
if controller == null:
|
||||||
return
|
return
|
||||||
@@ -490,6 +579,9 @@ func _toggle_wand_screening() -> void:
|
|||||||
var result: Dictionary = wand_screener.screen(items)
|
var result: Dictionary = wand_screener.screen(items)
|
||||||
wand_open = true
|
wand_open = true
|
||||||
hud.show_wand(controller, result)
|
hud.show_wand(controller, result)
|
||||||
|
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.
|
## Resolve a wand screening: the player decides whether to admit the guest.
|
||||||
func decide_wand(allowed: bool) -> void:
|
func decide_wand(allowed: bool) -> void:
|
||||||
@@ -514,6 +606,11 @@ func decide_wand(allowed: bool) -> void:
|
|||||||
if decision.get("prohibited", false) and allowed:
|
if decision.get("prohibited", false) and allowed:
|
||||||
hud.log(Strings.fmt("wand.admitted_prohibited", [name]))
|
hud.log(Strings.fmt("wand.admitted_prohibited", [name]))
|
||||||
coordinator.reputation.apply("security_failure", -5.0, "admitted prohibited item")
|
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:
|
elif decision.get("false_alarm", false) and not allowed:
|
||||||
hud.log(Strings.fmt("wand.denied_false_alarm", [name]))
|
hud.log(Strings.fmt("wand.denied_false_alarm", [name]))
|
||||||
coordinator.reputation.apply("complaint", -2.0, "false-alarm refusal")
|
coordinator.reputation.apply("complaint", -2.0, "false-alarm refusal")
|
||||||
@@ -527,6 +624,8 @@ func decide_wand(allowed: bool) -> void:
|
|||||||
controller.screened = true
|
controller.screened = true
|
||||||
hud.hide_wand()
|
hud.hide_wand()
|
||||||
wand_open = false
|
wand_open = false
|
||||||
|
# Short scan cooldown: the wand must “recharge” between guests (PLAN.md: feel).
|
||||||
|
_wand_cooldown = WAND_COOLDOWN_S
|
||||||
|
|
||||||
func decide_access(allowed: bool) -> void:
|
func decide_access(allowed: bool) -> void:
|
||||||
var controller: Object = _targeted_controller()
|
var controller: Object = _targeted_controller()
|
||||||
@@ -557,6 +656,9 @@ func decide_access(allowed: bool) -> void:
|
|||||||
audio.play_confirm()
|
audio.play_confirm()
|
||||||
else:
|
else:
|
||||||
hud.log(Strings.fmt("access.denied", ["; ".join(result.get("issues", []))]))
|
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")
|
coordinator.reputation.apply("access_judgment", 1.0, "correct refusal")
|
||||||
if audio:
|
if audio:
|
||||||
audio.play_deny()
|
audio.play_deny()
|
||||||
@@ -571,6 +673,17 @@ func decide_access(allowed: bool) -> void:
|
|||||||
hud.hide_inspection()
|
hud.hide_inspection()
|
||||||
inspection_open = false
|
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:
|
func _artifacts_for(controller: Object) -> Array:
|
||||||
var profile: Object = controller.profile
|
var profile: Object = controller.profile
|
||||||
var npc_type: int = profile.npc_type if profile else -1
|
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 = ""
|
current_incident_npc_id = ""
|
||||||
|
|
||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
|
if _wand_cooldown > 0.0:
|
||||||
|
_wand_cooldown = maxf(0.0, _wand_cooldown - delta)
|
||||||
if coordinator == null or not coordinator.running:
|
if coordinator == null or not coordinator.running:
|
||||||
return
|
return
|
||||||
coordinator.tick(delta)
|
coordinator.tick(delta)
|
||||||
|
if coordinator.current_phase != _last_phase:
|
||||||
|
_last_phase = coordinator.current_phase
|
||||||
|
_apply_phase_lighting()
|
||||||
hud.set_shift_state(coordinator, selected_scenario)
|
hud.set_shift_state(coordinator, selected_scenario)
|
||||||
_perception_pass()
|
_perception_pass()
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func get_npc_controller() -> NPCController:
|
|||||||
|
|
||||||
func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3, p_door: Vector3 = Vector3(0, 0.5, 1.2)) -> void:
|
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)
|
npc.setup("npc_%d" % seed_value, profile, intent, seed_value)
|
||||||
|
_visual_seed = seed_value
|
||||||
spawn_position = p_spawn
|
spawn_position = p_spawn
|
||||||
target_position = p_target
|
target_position = p_target
|
||||||
door_position = p_door
|
door_position = p_door
|
||||||
@@ -66,33 +67,137 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector
|
|||||||
collider.position = Vector3(0, 0.8, 0)
|
collider.position = Vector3(0, 0.8, 0)
|
||||||
add_child(collider)
|
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:
|
func _build_body(npc_type: int) -> void:
|
||||||
var body := MeshInstance3D.new()
|
_rig = Node3D.new()
|
||||||
var capsule := CapsuleMesh.new()
|
_rig.name = "Rig"
|
||||||
capsule.radius = 0.35
|
add_child(_rig)
|
||||||
capsule.height = 1.6
|
var base_color: Color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
|
||||||
body.mesh = capsule
|
# 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()
|
var mat := StandardMaterial3D.new()
|
||||||
mat.albedo_color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
|
mat.albedo_color = color
|
||||||
body.material_override = mat
|
return mat
|
||||||
add_child(body)
|
|
||||||
var id_tag := MeshInstance3D.new()
|
## Small per-archetype props so silhouettes differ (camera, box, hat, sign…).
|
||||||
var sphere := SphereMesh.new()
|
func _add_props(npc_type: int) -> void:
|
||||||
sphere.radius = 0.12
|
var chest_y := 1.25
|
||||||
sphere.height = 0.24
|
match npc_type:
|
||||||
var tag_mat := StandardMaterial3D.new()
|
Rules.NpcType.INFLUENCER:
|
||||||
tag_mat.albedo_color = Color(1, 1, 1)
|
_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))
|
||||||
id_tag.mesh = sphere
|
_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))
|
||||||
id_tag.material_override = tag_mat
|
Rules.NpcType.DELIVERY:
|
||||||
id_tag.position = Vector3(0, 1.05, 0)
|
_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))
|
||||||
id_tag.scale = Vector3(1, 0.4, 1)
|
Rules.NpcType.PERFORMER:
|
||||||
add_child(id_tag)
|
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:
|
func _physics_process(delta: float) -> void:
|
||||||
if npc == null or npc.fsm == null:
|
if npc == null or npc.fsm == null:
|
||||||
return
|
return
|
||||||
_advance_fsm(delta)
|
_advance_fsm(delta)
|
||||||
_move_toward_target(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:
|
func _advance_fsm(delta: float) -> void:
|
||||||
# Returning-trespasser: an NPC directed away may circle back to the door.
|
# Returning-trespasser: an NPC directed away may circle back to the door.
|
||||||
|
|||||||
@@ -5,6 +5,23 @@ extends CanvasLayer
|
|||||||
|
|
||||||
const Rules := preload("res://src/core/rules.gd")
|
const Rules := preload("res://src/core/rules.gd")
|
||||||
const Strings := preload("res://src/core/strings.gd")
|
const Strings := preload("res://src/core/strings.gd")
|
||||||
|
const T_VIGNETTE := preload("res://assets/textures/vignette.png")
|
||||||
|
const T_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 top_label: Label
|
||||||
var time_label: Label
|
var time_label: Label
|
||||||
@@ -17,10 +34,15 @@ var inspect_text: Label
|
|||||||
var inspect_meta: Label
|
var inspect_meta: Label
|
||||||
var radio_cooldown_label: Label
|
var radio_cooldown_label: Label
|
||||||
var mode_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_panel: PanelContainer
|
||||||
var wand_text: Label
|
var wand_text: Label
|
||||||
var wand_meta: Label
|
var wand_meta: Label
|
||||||
var tone_label: Label
|
|
||||||
var escalation_label: Label
|
var escalation_label: Label
|
||||||
|
|
||||||
var _debug_enabled := false
|
var _debug_enabled := false
|
||||||
@@ -99,13 +121,6 @@ func _init() -> void:
|
|||||||
mode_label.modulate = Color(0.85, 0.85, 0.9)
|
mode_label.modulate = Color(0.85, 0.85, 0.9)
|
||||||
vbox.add_child(mode_label)
|
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 level indicator (color-coded) ---
|
||||||
escalation_label = Label.new()
|
escalation_label = Label.new()
|
||||||
escalation_label.text = "Escalation: Observation"
|
escalation_label.text = "Escalation: Observation"
|
||||||
@@ -113,10 +128,64 @@ func _init() -> void:
|
|||||||
escalation_label.modulate = Color(0.6, 0.9, 0.6)
|
escalation_label.modulate = Color(0.6, 0.9, 0.6)
|
||||||
vbox.add_child(escalation_label)
|
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) ---
|
# --- inspect panel (popup center) ---
|
||||||
|
var inspect_center := CenterContainer.new()
|
||||||
|
inspect_center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
inspect_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
add_child(inspect_center)
|
||||||
inspect_panel = PanelContainer.new()
|
inspect_panel = PanelContainer.new()
|
||||||
inspect_panel.visible = false
|
inspect_panel.visible = false
|
||||||
inspect_panel.set_anchors_preset(Control.PRESET_CENTER)
|
|
||||||
inspect_panel.modulate.a = 1.0
|
inspect_panel.modulate.a = 1.0
|
||||||
var ivbox := VBoxContainer.new()
|
var ivbox := VBoxContainer.new()
|
||||||
inspect_panel.add_child(ivbox)
|
inspect_panel.add_child(ivbox)
|
||||||
@@ -131,12 +200,15 @@ func _init() -> void:
|
|||||||
insp_hint.text = "A = allow entry D = deny entry"
|
insp_hint.text = "A = allow entry D = deny entry"
|
||||||
insp_hint.add_theme_font_size_override("font_size", 12)
|
insp_hint.add_theme_font_size_override("font_size", 12)
|
||||||
ivbox.add_child(insp_hint)
|
ivbox.add_child(insp_hint)
|
||||||
add_child(inspect_panel)
|
inspect_center.add_child(inspect_panel)
|
||||||
|
|
||||||
# --- wand screening panel (popup center) ---
|
# --- wand screening panel (popup center) ---
|
||||||
|
var wand_center := CenterContainer.new()
|
||||||
|
wand_center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
wand_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
add_child(wand_center)
|
||||||
wand_panel = PanelContainer.new()
|
wand_panel = PanelContainer.new()
|
||||||
wand_panel.visible = false
|
wand_panel.visible = false
|
||||||
wand_panel.set_anchors_preset(Control.PRESET_CENTER)
|
|
||||||
var wvbox := VBoxContainer.new()
|
var wvbox := VBoxContainer.new()
|
||||||
wand_panel.add_child(wvbox)
|
wand_panel.add_child(wvbox)
|
||||||
wand_text = Label.new()
|
wand_text = Label.new()
|
||||||
@@ -150,7 +222,7 @@ func _init() -> void:
|
|||||||
wand_hint.text = "A = admit D = refuse entry"
|
wand_hint.text = "A = admit D = refuse entry"
|
||||||
wand_hint.add_theme_font_size_override("font_size", 12)
|
wand_hint.add_theme_font_size_override("font_size", 12)
|
||||||
wvbox.add_child(wand_hint)
|
wvbox.add_child(wand_hint)
|
||||||
add_child(wand_panel)
|
wand_center.add_child(wand_panel)
|
||||||
|
|
||||||
# --- debug overlay ---
|
# --- debug overlay ---
|
||||||
debug_label = Label.new()
|
debug_label = Label.new()
|
||||||
@@ -171,6 +243,9 @@ func set_shift_state(coordinator: Object, scenario: Resource) -> void:
|
|||||||
coordinator.reputation.rating,
|
coordinator.reputation.rating,
|
||||||
coordinator.crowd.mood * 100.0,
|
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)
|
_update_radio_cooldown(coordinator)
|
||||||
|
|
||||||
func _update_radio_cooldown(coordinator: Object) -> void:
|
func _update_radio_cooldown(coordinator: Object) -> void:
|
||||||
@@ -196,27 +271,72 @@ func set_flashlight(on: bool) -> void:
|
|||||||
|
|
||||||
func _refresh_mode_label() -> void:
|
func _refresh_mode_label() -> void:
|
||||||
mode_label.text = "Door: %s · Light: %s" % ["LOCKED" if _door_locked else "open", "on" if _flashlight else "off"]
|
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:
|
func set_action_hint(text: String) -> void:
|
||||||
hint_label.text = text
|
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).
|
## Color-coded escalation feedback (PLAN.md: Polish and Accessibility).
|
||||||
## Green → yellow → orange → red as the escalation level rises.
|
## Green → yellow → orange → red as the escalation level rises.
|
||||||
func set_escalation(level: int) -> void:
|
func set_escalation(level: int) -> void:
|
||||||
var name: String = Rules.LEVEL_NAMES.get(level, "Observation")
|
var name: String = Rules.LEVEL_NAMES.get(level, "Observation")
|
||||||
escalation_label.text = Strings.fmt("hud.escalation_prefix", [name])
|
escalation_label.text = Strings.fmt("hud.escalation_prefix", [name])
|
||||||
|
var tint := Color(0.6, 0.9, 0.6)
|
||||||
match level:
|
match level:
|
||||||
Rules.Level.OBSERVATION, Rules.Level.SIMPLE_DETERRENCE:
|
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:
|
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:
|
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:
|
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:
|
func log(text: String) -> void:
|
||||||
_log_lines.append(text)
|
_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 npc_display: String = (profile.display_name if profile else "Person")
|
||||||
var granted: bool = result.get("granted", false)
|
var granted: bool = result.get("granted", false)
|
||||||
inspect_text.text = "Access check — %s\nGranted: %s" % [npc_display, "YES" if granted else "NO"]
|
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] = []
|
var lines: Array[String] = []
|
||||||
for artifact in artifacts:
|
for artifact in artifacts:
|
||||||
lines.append(" • " + artifact.summary())
|
lines.append(" • " + artifact.summary())
|
||||||
@@ -260,8 +402,18 @@ func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -
|
|||||||
inspect_panel.visible = true
|
inspect_panel.visible = true
|
||||||
|
|
||||||
func hide_inspection() -> void:
|
func hide_inspection() -> void:
|
||||||
|
_clear_card_row()
|
||||||
inspect_panel.visible = false
|
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:
|
func show_wand(controller: Object, result: Dictionary) -> void:
|
||||||
var profile: Object = controller.profile
|
var profile: Object = controller.profile
|
||||||
var npc_display: String = (profile.display_name if profile else "Person")
|
var npc_display: String = (profile.display_name if profile else "Person")
|
||||||
|
|||||||
@@ -34,9 +34,12 @@ func _init() -> void:
|
|||||||
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
|
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
add_child(dim)
|
add_child(dim)
|
||||||
|
|
||||||
|
var center := CenterContainer.new()
|
||||||
|
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
add_child(center)
|
||||||
panel = PanelContainer.new()
|
panel = PanelContainer.new()
|
||||||
panel.set_anchors_preset(Control.PRESET_CENTER)
|
center.add_child(panel)
|
||||||
add_child(panel)
|
|
||||||
|
|
||||||
var vbox := VBoxContainer.new()
|
var vbox := VBoxContainer.new()
|
||||||
panel.add_child(vbox)
|
panel.add_child(vbox)
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
class_name WorldBuilder
|
class_name WorldBuilder
|
||||||
## Procedurally assembles a minimal Hollywood Boulevard entrance prototype:
|
## Procedurally assembles a minimal Hollywood Boulevard entrance prototype:
|
||||||
|
|
||||||
|
const T_BRICK := preload("res://assets/textures/brick_facade.png")
|
||||||
|
const T_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.
|
## steps, building facade, restricted door, sidewalk, pavement, and street edge.
|
||||||
## No art assets required; all geometry is BoxMesh with flat materials.
|
## No art assets required; all geometry is BoxMesh with flat materials.
|
||||||
|
|
||||||
@@ -7,6 +13,12 @@ var root: Node3D = null
|
|||||||
## Door lock indicator mesh near the handle; toggled by GameManager.
|
## Door lock indicator mesh near the handle; toggled by GameManager.
|
||||||
var door_indicator: MeshInstance3D = null
|
var door_indicator: MeshInstance3D = null
|
||||||
var _door_indicator_mat: StandardMaterial3D = 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:
|
func _init(root_node: Node3D) -> void:
|
||||||
root = root_node
|
root = root_node
|
||||||
@@ -14,17 +26,26 @@ func _init(root_node: Node3D) -> void:
|
|||||||
func build() -> void:
|
func build() -> void:
|
||||||
_build_ground()
|
_build_ground()
|
||||||
_build_building()
|
_build_building()
|
||||||
|
_build_marquee()
|
||||||
_build_steps()
|
_build_steps()
|
||||||
_build_sidewalk()
|
_build_sidewalk()
|
||||||
_build_street()
|
_build_street()
|
||||||
|
_build_crosswalk()
|
||||||
|
_build_street_furniture()
|
||||||
_build_guard_spawn()
|
_build_guard_spawn()
|
||||||
|
_build_skyline_backdrop()
|
||||||
_build_navmesh()
|
_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()
|
var mesh := BoxMesh.new()
|
||||||
mesh.size = size
|
mesh.size = size
|
||||||
var mat := StandardMaterial3D.new()
|
var mat := StandardMaterial3D.new()
|
||||||
mat.albedo_color = color
|
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
|
mesh.material = mat
|
||||||
var mi := MeshInstance3D.new()
|
var mi := MeshInstance3D.new()
|
||||||
mi.mesh = mesh
|
mi.mesh = mesh
|
||||||
@@ -46,19 +67,23 @@ func _build_ground() -> void:
|
|||||||
func _build_building() -> void:
|
func _build_building() -> void:
|
||||||
var building := StaticBody3D.new()
|
var building := StaticBody3D.new()
|
||||||
building.name = "Building"
|
building.name = "Building"
|
||||||
# Main facade
|
# Main facade (textured brick)
|
||||||
_add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -2), Color(0.35, 0.32, 0.30))
|
_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
|
# 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))
|
_add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -4.0), Color(0.08, 0.08, 0.09))
|
||||||
# Door
|
# Door frame and dark door
|
||||||
_add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -4.15), Color(0.16, 0.15, 0.13))
|
_add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -4.15), Color(0.13, 0.12, 0.11))
|
||||||
# Door window
|
# Door glass (semi-transparent)
|
||||||
_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))
|
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 lock indicator (green = open, red = locked), placed beside the handle
|
||||||
door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -4.35), Color(0.1, 0.9, 0.3))
|
door_indicator = _add_box(building, Vector3(0.18, 0.18, 0.24), Vector3(-1.8, 1.7, -4.35), Color(0.1, 0.9, 0.3))
|
||||||
door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3))
|
door_indicator.material_override = _lock_indicator_material(Color(0.1, 0.9, 0.3))
|
||||||
# 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()
|
var col := BoxShape3D.new()
|
||||||
col.size = Vector3(40, 18, 4)
|
col.size = Vector3(40, 18, 4)
|
||||||
var so := building.create_shape_owner(building)
|
var so := building.create_shape_owner(building)
|
||||||
@@ -86,7 +111,7 @@ func _build_steps() -> void:
|
|||||||
func _build_sidewalk() -> void:
|
func _build_sidewalk() -> void:
|
||||||
var sidewalk := StaticBody3D.new()
|
var sidewalk := StaticBody3D.new()
|
||||||
sidewalk.name = "Sidewalk"
|
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()
|
var col := BoxShape3D.new()
|
||||||
col.size = Vector3(30, 0.36, 8)
|
col.size = Vector3(30, 0.36, 8)
|
||||||
var so := sidewalk.create_shape_owner(sidewalk)
|
var so := sidewalk.create_shape_owner(sidewalk)
|
||||||
@@ -97,7 +122,7 @@ func _build_sidewalk() -> void:
|
|||||||
func _build_street() -> void:
|
func _build_street() -> void:
|
||||||
var street := StaticBody3D.new()
|
var street := StaticBody3D.new()
|
||||||
street.name = "Street"
|
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
|
# Center dashes
|
||||||
for i in range(-40, 41, 4):
|
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))
|
_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
|
region.navigation_mesh = mesh
|
||||||
root.add_child(region)
|
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:
|
func _build_guard_spawn() -> void:
|
||||||
var marker := Marker3D.new()
|
var marker := Marker3D.new()
|
||||||
marker.name = "GuardSpawn"
|
marker.name = "GuardSpawn"
|
||||||
|
|||||||
@@ -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()])
|
||||||
@@ -16,7 +16,7 @@ func test_tourist_complies_at_level_1() -> bool:
|
|||||||
var p := _profile_with_defaults("tourist", Rules.NpcType.TOURIST, 0.9)
|
var p := _profile_with_defaults("tourist", Rules.NpcType.TOURIST, 0.9)
|
||||||
var npc := NPCController.new()
|
var npc := NPCController.new()
|
||||||
npc.setup("t1", p, "photo", 1234, "AT_ENTRANCE")
|
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")
|
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")
|
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).
|
## from NpcProfile.default_reactions (mostly COMPLY there).
|
||||||
var outcomes: Array = []
|
var outcomes: Array = []
|
||||||
for i in 5:
|
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.
|
## 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")
|
var ok := TestUtils.check(npc.highest_escalation_received >= Rules.Level.SIMPLE_DETERRENCE, "highest escalation tracked")
|
||||||
return ok
|
return ok
|
||||||
@@ -46,8 +46,8 @@ func test_deterministic_with_seed() -> bool:
|
|||||||
var seq_a: Array = []
|
var seq_a: Array = []
|
||||||
var seq_b: Array = []
|
var seq_b: Array = []
|
||||||
for i in 4:
|
for i in 4:
|
||||||
seq_a.append(n1.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, Rules.Tone.PROFESSIONAL))
|
seq_b.append(n2.roll_reaction(Rules.Level.FORMAL_WARNING))
|
||||||
return TestUtils.check_eq(seq_a, seq_b, "same seed produces identical reaction sequence")
|
return TestUtils.check_eq(seq_a, seq_b, "same seed produces identical reaction sequence")
|
||||||
|
|
||||||
func test_violent_threat_scales_to_threaten() -> bool:
|
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")
|
npc.setup("v1", p, "threat", 777, "AT_ENTRANCE")
|
||||||
var saw_threat := false
|
var saw_threat := false
|
||||||
for i in 20:
|
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
|
saw_threat = true
|
||||||
return TestUtils.check(saw_threat, "violent profile can threaten at emergency level")
|
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()
|
var npc := NPCController.new()
|
||||||
npc.setup("c1", p, "film", 5, "AT_ENTRANCE")
|
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 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")
|
var b := TestUtils.check_eq(npc.fsm.current_state, "ARGUING", "transitions to ARGUING")
|
||||||
return a and b
|
return a and b
|
||||||
@@ -92,7 +92,7 @@ func test_fsm_transitions() -> bool:
|
|||||||
var npc := NPCController.new()
|
var npc := NPCController.new()
|
||||||
npc.setup("g1", p, "enter", 5, "APPROACH")
|
npc.setup("g1", p, "enter", 5, "APPROACH")
|
||||||
var started := TestUtils.check_eq(npc.fsm.current_state, "APPROACH", "starts approaching")
|
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")
|
var left := TestUtils.check_eq(npc.fsm.current_state, "LEAVING", "complies and leaves")
|
||||||
return started and left
|
return started and left
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ func test_returning_trespasser_circles_back() -> bool:
|
|||||||
p.return_probability = 1.0
|
p.return_probability = 1.0
|
||||||
var npc := NPCController.new()
|
var npc := NPCController.new()
|
||||||
npc.setup("rt1", p, "trespass", 4242, "AT_ENTRANCE")
|
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 left := TestUtils.check_eq(npc.fsm.current_state, "LOITERING_DISTANT", "directed away loiters before returning")
|
||||||
var scheduled := TestUtils.check(npc.will_return, "return scheduled")
|
var scheduled := TestUtils.check(npc.will_return, "return scheduled")
|
||||||
# Advance past the return delay (max 60s).
|
# 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 p := _profile_with_defaults("tourist", Rules.NpcType.TOURIST, 0.9)
|
||||||
var npc := NPCController.new()
|
var npc := NPCController.new()
|
||||||
npc.setup("t2", p, "photo", 7, "AT_ENTRANCE")
|
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")
|
return TestUtils.check_eq(npc.will_return, false, "tourist does not schedule a return")
|
||||||
|
|
||||||
func test_perception_pass_bands() -> bool:
|
func test_perception_pass_bands() -> bool:
|
||||||
|
|||||||
@@ -20,10 +20,13 @@ func test_save_round_trip() -> bool:
|
|||||||
var last: Dictionary = results[results.size() - 1]
|
var last: Dictionary = results[results.size() - 1]
|
||||||
var c := TestUtils.check_eq(last.get("scenario_id", ""), "hollywood_building", "round-trips scenario id")
|
var c := TestUtils.check_eq(last.get("scenario_id", ""), "hollywood_building", "round-trips scenario id")
|
||||||
var d := TestUtils.check_eq(float(last.get("reputation", {}).get("rating", 0.0)), 82.5, "round-trips rating")
|
var d := TestUtils.check_eq(float(last.get("reputation", {}).get("rating", 0.0)), 82.5, "round-trips rating")
|
||||||
|
save.free()
|
||||||
return a and b and c and d
|
return a and b and c and d
|
||||||
|
|
||||||
func test_load_returns_array_when_empty_or_missing() -> bool:
|
func test_load_returns_array_when_empty_or_missing() -> bool:
|
||||||
# The tester home may already contain results; this only asserts shape safety.
|
# The tester home may already contain results; this only asserts shape safety.
|
||||||
var save := _save_instance()
|
var save := _save_instance()
|
||||||
var results: Array = save.load_shift_results()
|
var results: Array = save.load_shift_results()
|
||||||
return TestUtils.check(results is Array, "load_shift_results always returns Array")
|
var ok := TestUtils.check(results is Array, "load_shift_results always returns Array")
|
||||||
|
save.free()
|
||||||
|
return ok
|
||||||
@@ -63,6 +63,30 @@ func test_late_phase_reaches_violent_event() -> bool:
|
|||||||
c = false
|
c = false
|
||||||
return a and b and TestUtils.check(c, "all venues declare a LATE phase")
|
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:
|
func test_protester_archetype_present() -> bool:
|
||||||
var profiles := ScenarioFactory.shared_profiles()
|
var profiles := ScenarioFactory.shared_profiles()
|
||||||
var protester_found := false
|
var protester_found := false
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ func test_strings_lookup() -> bool:
|
|||||||
return a and b
|
return a and b
|
||||||
|
|
||||||
func test_strings_format() -> bool:
|
func test_strings_format() -> bool:
|
||||||
var a := TestUtils.check_eq(Strings.fmt("hud.tone_prefix", ["Firm"]), "Tone: Firm", "tone prefix 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("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
|
return a and b
|
||||||
|
|
||||||
func test_strings_json_backed_all_resolve() -> bool:
|
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:
|
func test_strings_cover_ui_keys() -> bool:
|
||||||
## Every key referenced by the HUD and game manager must exist.
|
## Every key referenced by the HUD and game manager must exist.
|
||||||
var required := [
|
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.aim_first", "hud.no_one_nearby", "hud.targeting", "hud.no_wand",
|
||||||
"hud.screen_first", "access.granted", "access.denied", "access.policy_satisfied",
|
"hud.screen_first", "access.granted", "access.denied", "access.policy_satisfied",
|
||||||
"access.wrong_grant", "access.wrong_deny", "wand.admitted_prohibited",
|
"access.wrong_grant", "access.wrong_deny", "wand.admitted_prohibited",
|
||||||
"wand.denied_false_alarm", "wand.cleared", "wand.refused", "radio.address_first",
|
"wand.denied_false_alarm", "wand.cleared", "wand.refused", "wand.recharge", "radio.address_first",
|
||||||
"radio.blocked", "radio.inaccurate", "radio.received", "radio.emergency_dispatched",
|
"radio.blocked", "radio.inaccurate", "radio.received", "radio.emergency_dispatched",
|
||||||
"responder.arrived", "react.comply", "react.leave", "react.argue", "react.record",
|
"responder.arrived", "react.comply", "react.leave", "react.argue", "react.record",
|
||||||
"react.ignore", "react.threaten", "react.force_entry", "react.return_later",
|
"react.ignore", "react.threaten", "react.force_entry", "react.return_later",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
extends RefCounted
|
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 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:
|
func test_navmesh_built() -> bool:
|
||||||
var root := Node3D.new()
|
var root := Node3D.new()
|
||||||
@@ -17,6 +19,65 @@ func test_navmesh_built() -> bool:
|
|||||||
root.free()
|
root.free()
|
||||||
return a and b
|
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:
|
func test_door_indicator_built() -> bool:
|
||||||
var root := Node3D.new()
|
var root := Node3D.new()
|
||||||
var builder := WorldBuilder.new(root)
|
var builder := WorldBuilder.new(root)
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ set -euo pipefail
|
|||||||
GODOT="${GODOT:-godot}"
|
GODOT="${GODOT:-godot}"
|
||||||
VERSION="$(grep -oP 'config/version="\K[^"]+' project.godot)"
|
VERSION="$(grep -oP 'config/version="\K[^"]+' project.godot)"
|
||||||
mkdir -p build
|
mkdir -p build
|
||||||
|
# Presets now embed the PCK into a single binary; drop stale .pck leftovers.
|
||||||
|
rm -f build/*.pck
|
||||||
|
|
||||||
echo "==> Exporting Linux (x86_64)"
|
echo "==> Exporting Linux (x86_64)"
|
||||||
"$GODOT" --headless --export-release "Linux" "build/step-line-linux-${VERSION}.x86_64"
|
"$GODOT" --headless --export-release "Linux" "build/step-line-linux-${VERSION}.x86_64"
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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"
|
||||||