Compare commits

..
11 Commits
Author SHA1 Message Date
zwitschi 4f274e0413 TODO cleanup per AGENTS.md; rebuild 0.2.0 binaries with the detailed-graphics pass baked in
Full build / Import + headless tests (push) Successful in 18s
Full build / Full desktop build + optional release (push) Successful in 3m17s
- TODO.md: drop fully-done sections (CI cache mitigation, Detailed Graphics
  completed items, CI packaging follow-up — all recorded in DONE.md); keep the
  PRIORITY release task (token-blocked), backlog, polish, and perf-only items
- Rebuild build/ artifacts so the shipped graphics include textures/rigs/HUD
- Linux binary smoke-tested (0 script errors); suite 57/57
2026-10-31 13:36:53 +01:00
zwitschi 031503e161 Detailed graphics pass: authored textures, humanoid NPCs, HUD feedback, asset pipeline
Full build / Import + headless tests (push) Successful in 16s
Full build / Full desktop build + optional release (push) Skipped
- tools/generate_textures.py (deterministic PIL) produces all authored PNGs:
  brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons,
  access-card art (all <=1024px, power-of-two)
- WorldBuilder: textured facade/glass door/sidewalk/asphalt, striped awning
  with 'STEP LINE', generated skyline backdrop planes
- GameManager: soft sun shadows (4-split parallel); guard in 'guard' group
- NPC avatars: humanoid rig (torso/head/limbs) with archetype props (camera,
  delivery box, performer hat, protest sign, tourist backpack), procedural
  walk bob, head-turns-toward-guard attention cue
- HUD: inspection artifact cards, mode icons (flashlight/door/wand),
  per-escalation vignette, body-camera overlay (corner brackets + REC + clock)
- assets/ import convention documented; test_asset_textures_present_and_pow2
  guards the pipeline (57 tests pass)
- README tech stack updated for Gitea Actions + asset pipeline; screenshot
  gallery regenerated (day + night)
2026-10-31 13:33:43 +01:00
zwitschi ac3bb7dcd8 Stop tracking build/ artifacts (binaries live in Gitea releases; CI/local rebuild via tools/build_releases.sh)
Full build / Import + headless tests (push) Successful in 16s
Full build / Full desktop build + optional release (push) Skipped
2026-10-31 13:24:44 +01:00
zwitschi 513f8a0f60 Visual polish pass (v0.2.0): phase lighting, street lamps, marquee, dressing, night screenshots
- Phase-based lighting: sun direction/energy/color, ProceduralSkyMaterial
  gradient, and ambient per shift phase (GameManager.PHASE_LIGHTING),
  applied on phase transitions and shift start
- WorldBuilder: street lamp poles with emissive bulbs + OmniLights,
  set_night_mode(), glowing STEP LINE marquee (Label3D + emissive bars),
  crosswalk stripes, planters, low-poly street trees
- NPC avatars: per-seed height/brightness variation (deterministic LCG)
- Denser routine traffic probabilities
- capture_screenshots.gd: fast-forward to LATE phase and capture night set;
  README gallery is now 2x3 day+night; docs/screenshots regenerated
- Version bumped to 0.2.0; build/ artifacts rebuilt; Linux binary smoke-tested
- New headless tests for phased lighting config and street dressing (56 total)
2026-10-31 13:24:40 +01:00
zwitschi 93883334d0 Add rare hidden radio easter egg transmission 2026-10-31 13:20:46 +01:00
zwitschi 94b7646dce Record CI cache mitigation results and ignore runner logs
- Mark cache-timeout fix verified (run 1090: zero CacheService lines,
  54/54 tests, job succeeded)
- Ignore .gitea/logs/ (runner writes logs into the checkout)
- Leave collaborator's radio-easter-egg changes in the working tree
  (strings.en.json, game_manager.gd) uncommitted for review
2026-10-31 13:16:34 +01:00
zwitschi 0b5cd772a5 Mitigate CI cache timeouts and wire venue door-staff dialogue
Full build / Import + headless tests (push) Successful in 33s
Full build / Full desktop build + optional release (push) Skipped
- Drop actions/cache@v4 from the checks job: the Gitea runner cannot reach
  GitHub's cache service (/twirp/...CacheService timeouts, ~2min retries);
  Godot import is fast enough without it. Add GODOT_SILENCE_ROOT_WARNING=1.
- Document the cache situation and re-enable path in docs/RELEASING.md
- VenueScenario.door_staff_line(context, rng): seeded-random venue lines
- GameManager logs id/ticket/wand-check, refusal, and prohibited-item lines
  from the venue scenario (Joke Shop flavor) during inspection/screening
- Test coverage: all Joke Shop contexts, unknown contexts, empty venues,
  deterministic seeded picks (54 tests pass)
2026-10-31 13:13:25 +01:00
zwitschi a3768c2ce4 Remove dialogue tone-of-voice system, add Gitea Actions full build, add venue cycling
Full build / Import + headless tests (push) Successful in 2m25s
Full build / Full desktop build + optional release (push) Skipped
- Delete Rules.Tone/TONE_NAMES, NpcProfile.tone_modifiers, tone params on
  NPCController.roll_reaction/apply_player_action; dialogue_lines flattened
  to Array[String]; HUD tone label, T binding, and hud.tone_prefix removed
- Docs updated everywhere (PLAN, README, DONE, MEMORY); PLAN section heading
  'Tone and Design Direction' -> 'Design Direction'
- Add .gitea/workflows/build.yml: checks job (import+tests) on push/PR; full
  build job (export templates, Linux/Windows/macOS exports, artifact upload,
  and tag-based Gitea release publish via GITEA_TOKEN) on tags/dispatch
- Remove old .github/workflows/ci.yml to avoid double runs
- Add venue cycling (V) across all three scenarios with hud.venue_switched
- Refresh README screenshots to match the new HUD
- 53/53 tests pass, boot smoke clean
2026-10-31 13:08:20 +01:00
zwitschi f7486d7539 Add README screenshots: staged in-game captures between description and Downloads
CI / headless-tests (push) Successful in 2m22s
- New capture tool src/test/capture_screenshots.gd (windowed, staged:
  venue overview, targeting an NPC, access inspection, radio report)
- Generated shots saved to docs/screenshots/ and embedded as a 2x2 gallery
  in README above the Downloads section
2026-10-31 13:01:40 +01:00
zwitschi eb1776e614 Update architecture docs and README with versioning, CI/CD, and release management details 2026-10-31 13:01:29 +01:00
zwitschi 191377fae2 Prepare first downloadable release: install export templates, build 0.1.0 binaries, add publish helper
- Install Godot 4.3 export templates locally and fix macOS export
  (enable rendering/textures/vram_compression/import_etc2_astc)
- Build Linux/Windows/macOS artifacts into build/ via tools/build_releases.sh;
  Linux binary passes headless smoke test
- Add tools/publish_release.sh (creates Gitea release + uploads assets with a token)
- TODO: mark build/install steps done; publishing v0.1.0 + link verification
  remain as the single action blocker (needs GITEA_TOKEN)
2026-10-31 12:57:20 +01:00
51 changed files with 1450 additions and 247 deletions

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
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: Upload installers (workflow_dispatch)
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v4
with:
name: step-line-build
path: build/*
if-no-files-found: error
- name: Publish Gitea release (tags only)
if: startsWith(github.ref, 'refs/tags/')
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
VERSION: ${{ github.ref_name }}
run: |
chmod +x tools/publish_release.sh
./tools/publish_release.sh "${VERSION#v}"
-44
View File
@@ -1,44 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
headless-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Godot 4.3
run: |
curl -sL -o /tmp/godot.zip \
https://github.com/godotengine/godot/releases/download/4.3-stable/Godot_v4.3-stable_linux.x86_64.zip
unzip -o /tmp/godot.zip -d /tmp/godot-bin
sudo install /tmp/godot-bin/Godot_v4.3-stable_linux.x86_64 /usr/local/bin/godot
- name: Cache .godot import artifacts
uses: actions/cache@v4
with:
path: .godot
key: godot-import-${{ hashFiles('project.godot', 'src/**/*.gd') }}
- name: Import project (build class cache)
run: godot --headless --import || true
- name: Run headless test suite
run: |
set +e
output=$(godot --headless --path . --script res://src/test/test_runner.gd 2>&1)
status=$?
echo "$output"
if [ $status -ne 0 ]; then
echo "::error::Headless tests failed"
exit 1
fi
if echo "$output" | grep -q "SCRIPT ERROR"; then
echo "::error::Script errors detected"
exit 1
fi
exit 0
+3
View File
@@ -15,3 +15,6 @@ 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/
+74 -3
View File
@@ -30,8 +30,11 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every
- [x] Implement a lightweight finite state machine for NPC states (`FSM`). - [x] Implement 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,73 @@ 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).
## 🎮 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 +227,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.
+38 -4
View File
@@ -21,12 +21,44 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
- 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.
- 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).
- 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
@@ -34,7 +66,7 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
- 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 +78,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 -->
+2 -9
View File
@@ -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.
+23 -6
View File
@@ -6,6 +6,16 @@ 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
| | |
| --- | --- |
| ![Venue overview: the Hollywood Boulevard entrance with steps, sidewalks, street lamps, and signage](docs/screenshots/01-venue-overview.png) | ![Targeting an approaching NPC with the interaction HUD](docs/screenshots/02-targeting-npc.png) |
| ![Access inspection: wristband/credential check with allow/deny prompt](docs/screenshots/03-access-inspection.png) | ![Radio report form: emergency dispatch panel](docs/screenshots/04-radio-report.png) |
| ![Late night: street lamps lit, marquee glowing, dark Hollywood Boulevard](docs/screenshots/05-late-night-avenue.png) | ![Night shift: guard flashlight cutting through the dark](docs/screenshots/06-night-flashlight.png) |
*Screenshots are generated by [`src/test/capture_screenshots.gd`](src/test/capture_screenshots.gd) from the current build — day and late-night shifts.*
## ⬇️ Downloads ## ⬇️ Downloads
| Platform | Package | | Platform | Package |
@@ -16,6 +26,10 @@ The central design goal is simple:
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases> **All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
> Releases are built automatically by Gitea Actions when a `vX.Y.Z` tag is
> pushed (see [`docs/RELEASING.md`](docs/RELEASING.md)). Manual builds are
> produced with `./tools/build_releases.sh`.
>
> 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 +89,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 +129,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`)
- **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 +175,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 +183,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 +204,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, full 10-15 minute shift lengths, venue selection startup menu, editor-based GUT integration, RefCounted cycle cleanup, and crowd performance profiling/LOD (see `TODO.md`).
### Smoke Test Checklist ### Smoke Test Checklist
+28 -6
View File
@@ -1,16 +1,38 @@
# TODO # TODO
## 🔴 PRIORITY — Ship downloadable builds (so the README Downloads links work)
Goal: users hitting the README **Downloads** section can download a playable version from
`https://git.allucanget.biz/zwitschi/security-game/releases`.
- [x] Install Godot 4.3 export templates locally.
- [x] `./tools/build_releases.sh` produces Linux/Windows/macOS artifacts (built for 0.2.0).
- [x] Linux binary smoke-tested (headless, 0 script errors).
- [ ] Publish the release — **blocked on `GITEA_TOKEN`**: once the token is available run
`GITEA_TOKEN=... ./tools/publish_release.sh 0.2.0`, or (if the repo secret is already set
on the host) push the `v0.2.0` tag and let CI publish via `.gitea/workflows/build.yml`.
- [ ] Confirm the README Downloads links resolve: check
`https://git.allucanget.biz/zwitschi/security-game/releases` and download one artifact.
## 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). - [ ] Long (10-15 minute) shift lengths (phase lighting already implemented).
- [ ] Venue selection screen (wire `switch_scenario` to a startup menu). - [ ] Venue selection startup menu (keyboard cycling with `V` is implemented).
- [ ] 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`. - [ ] 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). - [ ] Add a body/wand cooldown feel: wand screening currently has no reuse penalty; consider a
- [ ] Add a body/wand cooldown feel: wand screening currently has no reuse penalty; consider a short per-guest scan animation. short per-guest scan animation.
- [ ] Add an escape-to-circuit handling note: `signals` in `Strings.fmt` are positional; keep `%` placeholders stable when adding keys. - [ ] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting).
## Detailed Graphics — remaining performance items
The visual pass is done (textures, rigs, HUD feedback — see `DONE.md`). Remaining work is
performance hardening only.
- [ ] Profile the crowd: ensure textured materials and props hold target frame rate around a
busy entrance.
- [ ] Add LOD or culling for distant NPCs/props if the crowd grows.
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+4 -3
View File
@@ -1,6 +1,5 @@
{ {
"hud.controls": "Q polite direction · 1-3 simple gestures · 4 warning · 5 block · 6 assist · R emergency · E inspect / close · W wand · A allow · D deny · T tone · G flashlight · L lock door · F debug", "hud.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,6 @@
"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"
} }
+35 -7
View File
@@ -26,18 +26,34 @@ 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
``` ```
@@ -54,6 +70,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
View File
@@ -28,6 +28,15 @@ This document follows the structure of the arc42 architecture documentation temp
- Venue-specific policies must be configurable rather than hard-coded into individual NPCs. - 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

+1 -1
View File
@@ -92,7 +92,7 @@ custom_template/release=""
debug/export_console_wrapper=0 debug/export_console_wrapper=0
binary_format/embed_pck=false binary_format/embed_pck=false
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
+2 -1
View File
@@ -8,7 +8,7 @@ config_version=5
config/name="Step Line" config/name="Step Line"
config/description="Workplace security simulation about observation, access verification, and proportionate escalation." config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
config/version="0.1.0" config/version="0.2.0"
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
+26 -38
View File
@@ -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,
+5 -11
View File
@@ -8,7 +8,7 @@ const FSM := preload("res://src/core/fsm.gd")
## reaction logic. The presentation layer adds a visual avatar and navigation. ## 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,
+3 -11
View File
@@ -12,8 +12,7 @@ const Rules := preload("res://src/core/rules.gd")
## Affects how likely the NPC is to argue, point, or threaten at each level. ## 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.
-15
View File
@@ -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,
+17
View File
@@ -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]
+135 -30
View File
@@ -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
@@ -46,15 +75,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)
@@ -78,6 +111,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 +142,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:
@@ -135,6 +171,8 @@ func restart_shift() -> void:
current_incident_npc_id = "" current_incident_npc_id = ""
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 +194,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 +331,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 +343,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 +396,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 +426,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 +520,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,6 +542,10 @@ 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 --
@@ -477,11 +554,14 @@ func _toggle_inspection() -> void:
func _toggle_wand_screening() -> void: func _toggle_wand_screening() -> void:
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 +570,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 +597,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")
@@ -557,6 +645,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 +662,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
@@ -615,6 +717,9 @@ func _physics_process(delta: float) -> void:
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()
+124 -19
View File
@@ -41,6 +41,7 @@ func get_npc_controller() -> NPCController:
func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3, p_door: Vector3 = Vector3(0, 0.5, 1.2)) -> void: 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.
+158 -14
View File
@@ -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,6 +128,57 @@ 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, 12)
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) ---
inspect_panel = PanelContainer.new() inspect_panel = PanelContainer.new()
inspect_panel.visible = false inspect_panel.visible = false
@@ -171,6 +237,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 +265,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 +363,26 @@ 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.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:
inspect_panel.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 +394,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:
inspect_panel.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")
+168 -12
View File
@@ -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"
+102
View File
@@ -0,0 +1,102 @@
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
func _initialize() -> void:
root.size = Vector2i(1600, 900)
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 frames > MAX_FRAMES:
print("timeout reached")
quit(0)
return true
_advance()
return false
func _advance() -> void:
state_frames += 1
match state:
0:
# 2s overview from the guard post (early morning light).
if state_frames >= 120:
_shoot("01-venue-overview.png")
state = 1
state_frames = 0
1:
# Wait for a nearby NPC so the interaction HUD has a target.
if gm != null and gm.npcs.size() > 0:
gm.call("_cycle_nearby_target")
targeted = true
state = 2
state_frames = 0
2:
if state_frames >= 30:
_shoot("02-targeting-npc.png")
if targeted and gm != null:
gm.call("_toggle_inspection")
state = 3
state_frames = 0
3:
if state_frames >= 30:
_shoot("03-access-inspection.png")
if gm != null:
gm.hud.hide_inspection()
if gm.npcs.size() > 0:
gm.call("_cycle_nearby_target")
gm.call("_open_radio", false)
state = 4
state_frames = 0
4:
if state_frames >= 30:
_shoot("04-radio-report.png")
# Close the radio form, then jump the shift to the LATE phase.
if gm != null:
gm.radio_panel.hide()
gm.coordinator.elapsed_s = 750.0
state = 5
state_frames = 0
5:
# Let the phase transition (and street lamps / marquee) settle, then
# take the night avenue shot.
if state_frames >= 90:
_shoot("05-late-night-avenue.png")
if gm != null:
gm.call("_toggle_flashlight")
state = 6
state_frames = 0
6:
if state_frames >= 40:
_shoot("06-night-flashlight.png")
state = 7
quit(0)
func _shoot(file_name: String) -> void:
var dir := DirAccess.open("user://")
if dir == null or not dir.dir_exists("screenshots"):
dir.make_dir_recursive("screenshots")
var img: Image = root.get_texture().get_image()
var err := img.save_png(OUT_DIR.path_join(file_name))
print("%s -> err=%d (%dx%d)" % [file_name, err, img.get_width(), img.get_height()])
+9 -9
View File
@@ -16,7 +16,7 @@ func test_tourist_complies_at_level_1() -> bool:
var p := _profile_with_defaults("tourist", Rules.NpcType.TOURIST, 0.9) var 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:
+24
View File
@@ -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
+3 -3
View File
@@ -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,7 +26,7 @@ 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",
+62 -1
View File
@@ -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)
Regular → Executable
View File
File mode changed.
+224
View File
@@ -0,0 +1,224 @@
#!/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
"""
import math
import os
import random
import stat
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")
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 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))
if __name__ == "__main__":
brick_facade()
seamless_concrete()
asphalt()
awning()
skyline()
vignette()
icons()
cards()
print("done")
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Publish a release to the Gitea/Forgejo host, uploading the built artifacts.
#
# Requires:
# - GITEA_TOKEN (a token with write access to the repo)
# - build artifacts from tools/build_releases.sh (or already exported)
#
# Usage:
# GITEA_TOKEN=xxx ./tools/publish_release.sh [version]
# (version defaults to config/version from project.godot)
set -euo pipefail
REPO="${REPO:-zwitschi/security-game}"
HOST="${HOST:-https://git.allucanget.biz}"
API="$HOST/api/v1/repos/$REPO/releases"
VERSION="${1:-$(grep -oP 'config/version="\K[^"]+' project.godot)}"
TAG="v$VERSION"
if [[ -z "${GITEA_TOKEN:-}" ]]; then
echo "error: GITEA_TOKEN is not set (see docs/RELEASING.md)" >&2
exit 1
fi
# Artifacts must already exist (run tools/build_releases.sh first).
printf -v BS 'build/step-line-linux-%s.x86_64' "$VERSION"
printf -v BS_WIN 'build/step-line-windows-%s.exe' "$VERSION"
printf -v BS_MAC 'build/step-line-macos-%s.zip' "$VERSION"
for f in "$BS" "$BS_WIN" "$BS_MAC"; do
if [[ ! -f "$f" ]]; then
echo "error: missing artifact $f — run ./tools/build_releases.sh first" >&2
exit 1
fi
done
echo "==> Creating release $TAG on $HOST/$REPO"
RELEASE_JSON="$(curl -s -X POST "$API" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Step Line $VERSION — playable prototype. See README for controls.\",\"draft\":false,\"prerelease\":false}")"
RELEASE_ID="$(echo "$RELEASE_JSON" | python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)"
if [[ -z "$RELEASE_ID" ]]; then
echo "error: could not create release; response:" >&2
echo "$RELEASE_JSON" >&2
exit 1
fi
echo "Release id: $RELEASE_ID"
for f in "$BS" "$BS_WIN" "$BS_MAC"; do
echo "==> Uploading $(basename "$f")"
curl -s -X POST "$API/$RELEASE_ID/assets" \
-H "Authorization: token $GITEA_TOKEN" \
-F "attachment=@$f" >/dev/null
done
echo ""
echo "Release live: $HOST/$REPO/releases/tag/$TAG"