Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f274e0413 | ||
|
|
031503e161 | ||
|
|
ac3bb7dcd8 | ||
|
|
513f8a0f60 | ||
|
|
93883334d0 | ||
|
|
94b7646dce | ||
|
|
0b5cd772a5 | ||
|
|
a3768c2ce4 |
No files matched your search
@@ -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}"
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
headless-tests:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Godot 4.3
|
|
||||||
run: |
|
|
||||||
curl -sL -o /tmp/godot.zip \
|
|
||||||
https://github.com/godotengine/godot/releases/download/4.3-stable/Godot_v4.3-stable_linux.x86_64.zip
|
|
||||||
unzip -o /tmp/godot.zip -d /tmp/godot-bin
|
|
||||||
sudo install /tmp/godot-bin/Godot_v4.3-stable_linux.x86_64 /usr/local/bin/godot
|
|
||||||
|
|
||||||
- name: Cache .godot import artifacts
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: .godot
|
|
||||||
key: godot-import-${{ hashFiles('project.godot', 'src/**/*.gd') }}
|
|
||||||
|
|
||||||
- name: Import project (build class cache)
|
|
||||||
run: godot --headless --import || true
|
|
||||||
|
|
||||||
- name: Run headless test suite
|
|
||||||
run: |
|
|
||||||
set +e
|
|
||||||
output=$(godot --headless --path . --script res://src/test/test_runner.gd 2>&1)
|
|
||||||
status=$?
|
|
||||||
echo "$output"
|
|
||||||
if [ $status -ne 0 ]; then
|
|
||||||
echo "::error::Headless tests failed"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if echo "$output" | grep -q "SCRIPT ERROR"; then
|
|
||||||
echo "::error::Script errors detected"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
@@ -15,3 +15,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/
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 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/`).
|
- Screenshot tools: `godot --path . -s res://src/test/take_screenshot.gd` (single shot to `user://screenshot.png`); `godot --path . -s res://src/test/capture_screenshots.gd` (staged README gallery shots to `user://screenshots/`, copied to `docs/screenshots/`).
|
||||||
- Release build verified locally: export templates for 4.3 installed at `~/.local/share/godot/export_templates/4.3.stable/`; `./tools/build_releases.sh` produced Linux/Windows/macOS artifacts for 0.1.0 in `build/` and the Linux binary passed a headless smoke test. Publishing to Gitea still needs a token (see `tools/publish_release.sh`, `docs/RELEASING.md`).
|
- 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
|
||||||
|
|
||||||
@@ -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 -->
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ The central design goal is simple:
|
|||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
|  |  |
|
|  |  |
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
*Screenshots are generated by [`src/test/capture_screenshots.gd`](src/test/capture_screenshots.gd) from the current build.*
|
*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
|
||||||
|
|
||||||
@@ -25,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).
|
||||||
@@ -84,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.
|
||||||
@@ -124,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
|
||||||
@@ -169,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) |
|
||||||
@@ -198,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
|
||||||
|
|
||||||
|
|||||||
@@ -2,30 +2,37 @@
|
|||||||
|
|
||||||
## 🔴 PRIORITY — Ship downloadable builds (so the README Downloads links work)
|
## 🔴 PRIORITY — Ship downloadable builds (so the README Downloads links work)
|
||||||
|
|
||||||
Goal: users hitting the README **Downloads** section can actually download a playable version from
|
Goal: users hitting the README **Downloads** section can download a playable version from
|
||||||
`https://git.allucanget.biz/zwitschi/security-game/releases`.
|
`https://git.allucanget.biz/zwitschi/security-game/releases`.
|
||||||
|
|
||||||
- [x] Install Godot 4.3 export templates locally (see `docs/RELEASING.md` step 1).
|
- [x] Install Godot 4.3 export templates locally.
|
||||||
- [x] Run `./tools/build_releases.sh` and confirm the three artifacts appear in `build/`.
|
- [x] `./tools/build_releases.sh` produces Linux/Windows/macOS artifacts (built for 0.2.0).
|
||||||
- [x] Smoke-test the Linux binary launches (`./build/step-line-linux-0.1.0.x86_64 --headless --quit-after 120` → 0 script errors).
|
- [x] Linux binary smoke-tested (headless, 0 script errors).
|
||||||
- [ ] Create a Gitea release `v0.1.0` (tag matching `config/version` in `project.godot`) — **needs `GITEA_TOKEN`**: `GITEA_TOKEN=... ./tools/publish_release.sh 0.1.0`.
|
- [ ] Publish the release — **blocked on `GITEA_TOKEN`**: once the token is available run
|
||||||
- [ ] Verify the README Downloads links resolve: visit the release page and download one artifact.
|
`GITEA_TOKEN=... ./tools/publish_release.sh 0.2.0`, or (if the repo secret is already set
|
||||||
- [x] (Local workflow) `tools/publish_release.sh` exists so future releases need only a `GITEA_TOKEN`.
|
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
|
||||||
### Follow-ups once the first release is live
|
`https://git.allucanget.biz/zwitschi/security-game/releases` and download one artifact.
|
||||||
|
|
||||||
- [ ] CI packaging: build and upload artifacts automatically on tags.
|
|
||||||
|
|
||||||
## 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).
|
||||||
- [ ] 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.
|
||||||
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 229 B |
|
After Width: | Height: | Size: 255 B |
|
After Width: | Height: | Size: 404 B |
|
After Width: | Height: | Size: 274 B |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"hud.controls": "Q polite direction · 1-3 simple gestures · 4 warning · 5 block · 6 assist · R emergency · E inspect / close · W wand · A allow · D deny · T tone · G flashlight · L lock door · F debug",
|
"hud.controls": "Q polite direction · 1-3 simple gestures · 4 warning · 5 block · 6 assist · R emergency · E inspect / close · W wand · A allow · D deny · V venue · G flashlight · L lock door · F debug",
|
||||||
"hud.tone_prefix": "Tone: %s",
|
|
||||||
"hud.escalation_prefix": "Escalation: %s",
|
"hud.escalation_prefix": "Escalation: %s",
|
||||||
"hud.cooldown": "EMERGENCY RADIO COOLDOWN: %.0fs",
|
"hud.cooldown": "EMERGENCY RADIO COOLDOWN: %.0fs",
|
||||||
"hud.aim_first": "Aim at someone first.",
|
"hud.aim_first": "Aim at someone first.",
|
||||||
@@ -22,6 +21,7 @@
|
|||||||
"radio.inaccurate": "Dispatch flags inaccuracies in your report (-%d).",
|
"radio.inaccurate": "Dispatch flags inaccuracies in your report (-%d).",
|
||||||
"radio.received": "Dispatch received your report. Assistance en route.",
|
"radio.received": "Dispatch received your report. Assistance en route.",
|
||||||
"radio.emergency_dispatched": "Emergency responder dispatched.",
|
"radio.emergency_dispatched": "Emergency responder dispatched.",
|
||||||
|
"radio.easter_egg": "¿Dónde está la mota?",
|
||||||
"responder.arrived": "Responder arrives and %ss the situation.",
|
"responder.arrived": "Responder arrives and %ss the situation.",
|
||||||
"react.comply": "%s complies and steps back.",
|
"react.comply": "%s complies and steps back.",
|
||||||
"react.leave": "%s turns and leaves.",
|
"react.leave": "%s turns and leaves.",
|
||||||
@@ -39,5 +39,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"
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 242 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 85 KiB |
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const FSM := preload("res://src/core/fsm.gd")
|
|||||||
## reaction logic. The presentation layer adds a visual avatar and navigation.
|
## reaction logic. The presentation layer adds a visual avatar and navigation.
|
||||||
## RefCounted on purpose: headless tests and CI do not need a scene tree.
|
## RefCounted on purpose: headless tests and CI do not need a scene tree.
|
||||||
|
|
||||||
signal interacted(level: int, tone: int)
|
signal interacted(level: int)
|
||||||
|
|
||||||
var npc_id: String = ""
|
var npc_id: String = ""
|
||||||
var profile: NpcProfile = null
|
var profile: NpcProfile = null
|
||||||
@@ -61,15 +61,10 @@ func _build_fsm(initial_state: String) -> void:
|
|||||||
fsm.add_transition("THREATENING", "FORCING_ENTRY")
|
fsm.add_transition("THREATENING", "FORCING_ENTRY")
|
||||||
fsm.start(initial_state)
|
fsm.start(initial_state)
|
||||||
|
|
||||||
func roll_reaction(level: int, tone: int) -> int:
|
func roll_reaction(level: int) -> int:
|
||||||
## Weighted pick from profile's reaction table for `level`, adjusted for tone.
|
## Weighted pick from profile's reaction table for `level`.
|
||||||
highest_escalation_received = maxi(highest_escalation_received, level)
|
highest_escalation_received = maxi(highest_escalation_received, level)
|
||||||
var weights: Dictionary = profile.pickup_reactions_per_level(level).duplicate()
|
var weights: Dictionary = profile.pickup_reactions_per_level(level).duplicate()
|
||||||
var tone_mod: float = profile.tone_modifiers.get(tone, 0.0)
|
|
||||||
if tone_mod > 0.0 and weights.has(Rules.Reaction.COMPLY):
|
|
||||||
# Shift weight mass toward compliance proportionally.
|
|
||||||
var transferable: float = float(weights[Rules.Reaction.COMPLY]) * tone_mod
|
|
||||||
weights[Rules.Reaction.COMPLY] += transferable * 0.5
|
|
||||||
var roll_value := rng.randf()
|
var roll_value := rng.randf()
|
||||||
var total := 0.0
|
var total := 0.0
|
||||||
for key in weights.keys():
|
for key in weights.keys():
|
||||||
@@ -83,16 +78,15 @@ func roll_reaction(level: int, tone: int) -> int:
|
|||||||
return key
|
return key
|
||||||
return Rules.Reaction.LEAVE
|
return Rules.Reaction.LEAVE
|
||||||
|
|
||||||
func apply_player_action(level: int, tone: int) -> Dictionary:
|
func apply_player_action(level: int) -> Dictionary:
|
||||||
## Returns reaction to the player's action and updates FSM when needed.
|
## Returns reaction to the player's action and updates FSM when needed.
|
||||||
var performed_reaction := roll_reaction(level, tone)
|
var performed_reaction := roll_reaction(level)
|
||||||
reaction = performed_reaction
|
reaction = performed_reaction
|
||||||
reaction_set = true
|
reaction_set = true
|
||||||
_update_state_for_reaction(performed_reaction)
|
_update_state_for_reaction(performed_reaction)
|
||||||
return {
|
return {
|
||||||
"npc_id": npc_id,
|
"npc_id": npc_id,
|
||||||
"level_used": level,
|
"level_used": level,
|
||||||
"tone": tone,
|
|
||||||
"reaction": performed_reaction,
|
"reaction": performed_reaction,
|
||||||
"reaction_name": Rules.Reaction.find_key(performed_reaction),
|
"reaction_name": Rules.Reaction.find_key(performed_reaction),
|
||||||
"new_state": fsm.current_state,
|
"new_state": fsm.current_state,
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ const Rules := preload("res://src/core/rules.gd")
|
|||||||
## Affects how likely the NPC is to argue, point, or threaten at each level.
|
## Affects how likely the NPC is to argue, point, or threaten at each level.
|
||||||
@export_range(0.0, 1.0) var temperament: float = 0.25
|
@export_range(0.0, 1.0) var temperament: float = 0.25
|
||||||
|
|
||||||
## Base compliance: probability the NPC obeys a level-1 polite direction.
|
## Probability the NPC obeys a level-1 polite direction.
|
||||||
## Venue/scenario recalculates effective compliance from profile + tone + level.
|
|
||||||
@export_range(0.0, 1.0) var base_compliance: float = 0.8
|
@export_range(0.0, 1.0) var base_compliance: float = 0.8
|
||||||
|
|
||||||
## Probability that an NPC directed away will circle back later in the shift
|
## Probability that an NPC directed away will circle back later in the shift
|
||||||
@@ -56,18 +55,11 @@ const Rules := preload("res://src/core/rules.gd")
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@export var tone_modifiers: Dictionary = {
|
|
||||||
Rules.Tone.FRIENDLY: 0.10, # + compliance
|
|
||||||
Rules.Tone.PROFESSIONAL: 0.00,
|
|
||||||
Rules.Tone.FIRM: -0.05,
|
|
||||||
Rules.Tone.SUSPICIOUS: -0.15,
|
|
||||||
}
|
|
||||||
|
|
||||||
## Delay the NPC waits before reacting to the player (frames/seconds scaled).
|
## Delay the NPC waits before reacting to the player (frames/seconds scaled).
|
||||||
@export_range(0.1, 5.0) var reaction_delay: float = 0.6
|
@export_range(0.1, 5.0) var reaction_delay: float = 0.6
|
||||||
|
|
||||||
## Dialogue lines per tone and intent. Falls back to generic lines when empty.
|
## Dialogue lines this NPC uses. Falls back to generic lines when empty.
|
||||||
@export var dialogue_lines: Dictionary = {}
|
@export var dialogue_lines: Array[String] = []
|
||||||
|
|
||||||
## Items the NPC is carrying, used by the handheld wand (The Joke Shop).
|
## Items the NPC is carrying, used by the handheld wand (The Joke Shop).
|
||||||
## Values are item names: "keys", "belt", "phone", "weapon", "knife", etc.
|
## Values are item names: "keys", "belt", "phone", "weapon", "knife", etc.
|
||||||
|
|||||||
@@ -108,21 +108,6 @@ enum Reaction {
|
|||||||
CALL_SOMEONE = 8,
|
CALL_SOMEONE = 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
## Player dialogue tones.
|
|
||||||
enum Tone {
|
|
||||||
PROFESSIONAL = 0,
|
|
||||||
FRIENDLY = 1,
|
|
||||||
FIRM = 2,
|
|
||||||
SUSPICIOUS = 3,
|
|
||||||
}
|
|
||||||
|
|
||||||
const TONE_NAMES := {
|
|
||||||
Tone.PROFESSIONAL: "Professional",
|
|
||||||
Tone.FRIENDLY: "Friendly",
|
|
||||||
Tone.FIRM: "Firm",
|
|
||||||
Tone.SUSPICIOUS: "Suspicious",
|
|
||||||
}
|
|
||||||
|
|
||||||
## Shift phases by time of day.
|
## Shift phases by time of day.
|
||||||
enum Phase {
|
enum Phase {
|
||||||
EARLY = 0,
|
EARLY = 0,
|
||||||
|
|||||||
@@ -68,6 +68,23 @@ func _spot_for_intent(intent: String) -> String:
|
|||||||
_:
|
_:
|
||||||
return "sidewalk"
|
return "sidewalk"
|
||||||
|
|
||||||
|
## Pick a random door-staff line for a context key (e.g. "id_check",
|
||||||
|
## "ticket_check", "wand_check", "refusal", "prohibited_item"). Returns ""
|
||||||
|
## when the venue has no lines for that context. Deterministic when a seeded
|
||||||
|
## RNG is injected; otherwise returns the first line.
|
||||||
|
func door_staff_line(context: String, rng: RandomNumberGenerator = null) -> String:
|
||||||
|
if not door_staff_lines.has(context):
|
||||||
|
return ""
|
||||||
|
var lines: Variant = door_staff_lines[context]
|
||||||
|
if not (lines is Array):
|
||||||
|
return ""
|
||||||
|
var pool: Array = lines
|
||||||
|
if pool.is_empty():
|
||||||
|
return ""
|
||||||
|
if rng != null:
|
||||||
|
return str(pool[rng.randi_range(0, pool.size() - 1)])
|
||||||
|
return str(pool[0])
|
||||||
|
|
||||||
func _location_or_default(spot: String, fallback: Vector3) -> Vector3:
|
func _location_or_default(spot: String, fallback: Vector3) -> Vector3:
|
||||||
if locations.has(spot):
|
if locations.has(spot):
|
||||||
var v: Variant = locations[spot]
|
var v: Variant = locations[spot]
|
||||||
|
|||||||
@@ -27,6 +27,35 @@ var door := DoorState.new()
|
|||||||
var flashlight_on := false
|
var flashlight_on := false
|
||||||
var coordinator: ShiftCoordinator = null
|
var coordinator: ShiftCoordinator = null
|
||||||
var guard: GuardController = null
|
var guard: GuardController = null
|
||||||
|
var _sun: DirectionalLight3D = null
|
||||||
|
var _env_node: WorldEnvironment = null
|
||||||
|
var _sky: ProceduralSkyMaterial = null
|
||||||
|
var _last_phase: int = -1
|
||||||
|
|
||||||
|
## Per-phase lighting looks for the sun, sky, and ambient (PLAN.md: shift
|
||||||
|
## structure — phase-based lighting). Night phases switch street lamps on.
|
||||||
|
const PHASE_LIGHTING := {
|
||||||
|
Rules.Phase.EARLY: {
|
||||||
|
"sun_energy": 1.2, "sun_angle": -50.0, "sun_color": Color(1.0, 0.98, 0.92),
|
||||||
|
"sky_top": Color(0.32, 0.55, 0.95), "sky_horizon": Color(0.82, 0.88, 0.95),
|
||||||
|
"sky_ground": Color(0.45, 0.5, 0.55), "ambient": Color(0.75, 0.78, 0.85), "night": false,
|
||||||
|
},
|
||||||
|
Rules.Phase.MIDDAY: {
|
||||||
|
"sun_energy": 1.5, "sun_angle": -62.0, "sun_color": Color(1.0, 1.0, 0.98),
|
||||||
|
"sky_top": Color(0.3, 0.52, 0.95), "sky_horizon": Color(0.85, 0.9, 0.96),
|
||||||
|
"sky_ground": Color(0.5, 0.54, 0.58), "ambient": Color(0.85, 0.88, 0.95), "night": false,
|
||||||
|
},
|
||||||
|
Rules.Phase.EVENING: {
|
||||||
|
"sun_energy": 0.7, "sun_angle": -16.0, "sun_color": Color(1.0, 0.72, 0.5),
|
||||||
|
"sky_top": Color(0.62, 0.32, 0.35), "sky_horizon": Color(1.0, 0.68, 0.42),
|
||||||
|
"sky_ground": Color(0.3, 0.24, 0.3), "ambient": Color(0.55, 0.45, 0.5), "night": true,
|
||||||
|
},
|
||||||
|
Rules.Phase.LATE: {
|
||||||
|
"sun_energy": 0.05, "sun_angle": -8.0, "sun_color": Color(0.3, 0.35, 0.5),
|
||||||
|
"sky_top": Color(0.02, 0.03, 0.08), "sky_horizon": Color(0.1, 0.12, 0.2),
|
||||||
|
"sky_ground": Color(0.05, 0.05, 0.08), "ambient": Color(0.08, 0.09, 0.16), "night": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
var hud: Hud = null
|
var hud: Hud = null
|
||||||
var radio_panel: RadioPanel = null
|
var radio_panel: RadioPanel = null
|
||||||
var shift_review_panel: ShiftReview = null
|
var shift_review_panel: ShiftReview = null
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func get_npc_controller() -> NPCController:
|
|||||||
|
|
||||||
func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3, p_door: Vector3 = Vector3(0, 0.5, 1.2)) -> void:
|
func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3, p_door: Vector3 = Vector3(0, 0.5, 1.2)) -> void:
|
||||||
npc.setup("npc_%d" % seed_value, profile, intent, seed_value)
|
npc.setup("npc_%d" % seed_value, profile, intent, seed_value)
|
||||||
|
_visual_seed = seed_value
|
||||||
spawn_position = p_spawn
|
spawn_position = p_spawn
|
||||||
target_position = p_target
|
target_position = p_target
|
||||||
door_position = p_door
|
door_position = p_door
|
||||||
@@ -66,33 +67,137 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector
|
|||||||
collider.position = Vector3(0, 0.8, 0)
|
collider.position = Vector3(0, 0.8, 0)
|
||||||
add_child(collider)
|
add_child(collider)
|
||||||
|
|
||||||
|
var _visual_seed := 0
|
||||||
|
var _rig: Node3D = null
|
||||||
|
var _head: Node3D = null
|
||||||
|
var _bob_phase := 0.0
|
||||||
|
var _guard_ref: Node3D = null
|
||||||
|
const SKIN_TONE := Color(0.88, 0.72, 0.60)
|
||||||
|
const PANTS_COLOR := Color(0.22, 0.22, 0.26)
|
||||||
|
|
||||||
func _build_body(npc_type: int) -> void:
|
func _build_body(npc_type: int) -> void:
|
||||||
var body := MeshInstance3D.new()
|
_rig = Node3D.new()
|
||||||
var capsule := CapsuleMesh.new()
|
_rig.name = "Rig"
|
||||||
capsule.radius = 0.35
|
add_child(_rig)
|
||||||
capsule.height = 1.6
|
var base_color: Color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
|
||||||
body.mesh = capsule
|
# Per-seed variation: slight height and brightness differences so crowds
|
||||||
|
# read as individuals (deterministic LCG, no RNG side effects).
|
||||||
|
var frac := float((_visual_seed * 1103515245 + 12345) % 1000) / 1000.0
|
||||||
|
var height_scale := 0.92 + 0.16 * frac
|
||||||
|
_rig.scale.y = height_scale
|
||||||
|
var shader_delta := 0.12 * frac
|
||||||
|
base_color = base_color.lightened(shader_delta) if frac < 0.5 else base_color.darkened(shader_delta)
|
||||||
|
var limbs_color := base_color.darkened(0.25)
|
||||||
|
# Torso
|
||||||
|
_add_capsule(_rig, 0.30, 0.95, Vector3(0, 1.05, 0), base_color)
|
||||||
|
# Legs
|
||||||
|
_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), PANTS_COLOR)
|
||||||
|
_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), PANTS_COLOR)
|
||||||
|
# Arms
|
||||||
|
_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0)
|
||||||
|
_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0)
|
||||||
|
# Head
|
||||||
|
_head = Node3D.new()
|
||||||
|
_head.name = "Head"
|
||||||
|
_head.position = Vector3(0, 1.62, 0)
|
||||||
|
_add_sphere(_head, 0.17, Vector3.ZERO, SKIN_TONE)
|
||||||
|
_rig.add_child(_head)
|
||||||
|
_add_props(npc_type)
|
||||||
|
|
||||||
|
func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3, color: Color, z_rot: float = 0.0) -> void:
|
||||||
|
var mi := MeshInstance3D.new()
|
||||||
|
var mesh := CapsuleMesh.new()
|
||||||
|
mesh.radius = radius
|
||||||
|
mesh.height = height
|
||||||
|
mi.mesh = mesh
|
||||||
|
mi.position = pos
|
||||||
|
mi.rotation_degrees.z = z_rot
|
||||||
|
mi.material_override = _material(color)
|
||||||
|
parent.add_child(mi)
|
||||||
|
|
||||||
|
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color) -> void:
|
||||||
|
var mi := MeshInstance3D.new()
|
||||||
|
var mesh := SphereMesh.new()
|
||||||
|
mesh.radius = radius
|
||||||
|
mesh.height = radius * 2.0
|
||||||
|
mi.mesh = mesh
|
||||||
|
mi.position = pos
|
||||||
|
mi.material_override = _material(color)
|
||||||
|
parent.add_child(mi)
|
||||||
|
|
||||||
|
func _material(color: Color) -> StandardMaterial3D:
|
||||||
var mat := StandardMaterial3D.new()
|
var mat := StandardMaterial3D.new()
|
||||||
mat.albedo_color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
|
mat.albedo_color = color
|
||||||
body.material_override = mat
|
return mat
|
||||||
add_child(body)
|
|
||||||
var id_tag := MeshInstance3D.new()
|
## Small per-archetype props so silhouettes differ (camera, box, hat, sign…).
|
||||||
var sphere := SphereMesh.new()
|
func _add_props(npc_type: int) -> void:
|
||||||
sphere.radius = 0.12
|
var chest_y := 1.25
|
||||||
sphere.height = 0.24
|
match npc_type:
|
||||||
var tag_mat := StandardMaterial3D.new()
|
Rules.NpcType.INFLUENCER:
|
||||||
tag_mat.albedo_color = Color(1, 1, 1)
|
_add_box_prop(_rig, Vector3(0.16, 0.1, 0.09), Vector3(0.3, chest_y, 0.22), Color(0.1, 0.1, 0.1))
|
||||||
id_tag.mesh = sphere
|
_add_box_prop(_rig, Vector3(0.05, 0.05, 0.06), Vector3(0.34, chest_y + 0.06, 0.30), Color(0.4, 0.7, 1.0))
|
||||||
id_tag.material_override = tag_mat
|
Rules.NpcType.DELIVERY:
|
||||||
id_tag.position = Vector3(0, 1.05, 0)
|
_add_box_prop(_rig, Vector3(0.34, 0.26, 0.28), Vector3(0.0, 1.02, 0.3), Color(0.55, 0.4, 0.25))
|
||||||
id_tag.scale = Vector3(1, 0.4, 1)
|
Rules.NpcType.PERFORMER:
|
||||||
add_child(id_tag)
|
var hat := _material(Color(0.85, 0.2, 0.25))
|
||||||
|
var cone := MeshInstance3D.new()
|
||||||
|
var cm := CylinderMesh.new()
|
||||||
|
cm.top_radius = 0.02
|
||||||
|
cm.bottom_radius = 0.22
|
||||||
|
cm.height = 0.4
|
||||||
|
cone.mesh = cm
|
||||||
|
cone.material_override = hat
|
||||||
|
cone.position = Vector3(0, 0.26, 0)
|
||||||
|
_head.add_child(cone)
|
||||||
|
Rules.NpcType.PROTESTER:
|
||||||
|
_add_box_prop(_rig, Vector3(0.05, 1.2, 0.05), Vector3(0.28, 1.45, 0.18), Color(0.4, 0.36, 0.32))
|
||||||
|
_add_box_prop(_rig, Vector3(0.5, 0.42, 0.04), Vector3(0.48, 1.55, 0.18), Color(0.93, 0.93, 0.95))
|
||||||
|
Rules.NpcType.TOURIST:
|
||||||
|
_add_box_prop(_rig, Vector3(0.3, 0.36, 0.14), Vector3(-0.34, 1.15, -0.18), Color(0.25, 0.3, 0.45))
|
||||||
|
Rules.NpcType.CONFUSED_VISITOR:
|
||||||
|
_add_box_prop(_rig, Vector3(0.3, 0.05, 0.34), Vector3(0, 0.92, 0.06), Color(0.9, 0.88, 0.8))
|
||||||
|
|
||||||
|
func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) -> void:
|
||||||
|
var mi := MeshInstance3D.new()
|
||||||
|
var mesh := BoxMesh.new()
|
||||||
|
mesh.size = size
|
||||||
|
mi.mesh = mesh
|
||||||
|
mi.position = pos
|
||||||
|
mi.material_override = _material(color)
|
||||||
|
parent.add_child(mi)
|
||||||
|
|
||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
if npc == null or npc.fsm == null:
|
if npc == null or npc.fsm == null:
|
||||||
return
|
return
|
||||||
_advance_fsm(delta)
|
_advance_fsm(delta)
|
||||||
_move_toward_target(delta)
|
_move_toward_target(delta)
|
||||||
|
_animate(delta)
|
||||||
|
|
||||||
|
## Procedural walk bob + head-toward-guard cue when the NPC is aware.
|
||||||
|
func _animate(delta: float) -> void:
|
||||||
|
if _rig == null:
|
||||||
|
return
|
||||||
|
var speed := Vector2(velocity.x, velocity.z).length()
|
||||||
|
if speed > 0.3:
|
||||||
|
_bob_phase += delta * (6.0 + speed)
|
||||||
|
_rig.position.y = sin(_bob_phase) * 0.035
|
||||||
|
_rig.rotation.x = sin(_bob_phase * 0.5) * 0.04
|
||||||
|
else:
|
||||||
|
_bob_phase = 0.0
|
||||||
|
_rig.position.y = lerpf(_rig.position.y, 0.0, 0.2)
|
||||||
|
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
|
||||||
|
# Attention cue: head turns toward the guard when awareness was raised.
|
||||||
|
if _head != null and npc.guard_noticed:
|
||||||
|
if _guard_ref == null or not is_instance_valid(_guard_ref):
|
||||||
|
_guard_ref = get_tree().get_first_node_in_group("guard") as Node3D
|
||||||
|
if _guard_ref:
|
||||||
|
var to_guard := (_guard_ref.global_position - global_position)
|
||||||
|
to_guard.y = 0.0
|
||||||
|
var world_yaw := atan2(to_guard.x, to_guard.z)
|
||||||
|
var fwd := -_rig.global_transform.basis.z
|
||||||
|
var parent_yaw := atan2(fwd.x, fwd.z)
|
||||||
|
_head.rotation.y = lerp_angle(_head.rotation.y, world_yaw - parent_yaw, 0.1)
|
||||||
|
|
||||||
func _advance_fsm(delta: float) -> void:
|
func _advance_fsm(delta: float) -> void:
|
||||||
# Returning-trespasser: an NPC directed away may circle back to the door.
|
# Returning-trespasser: an NPC directed away may circle back to the door.
|
||||||
|
|||||||
@@ -5,6 +5,23 @@ extends CanvasLayer
|
|||||||
|
|
||||||
const Rules := preload("res://src/core/rules.gd")
|
const Rules := preload("res://src/core/rules.gd")
|
||||||
const Strings := preload("res://src/core/strings.gd")
|
const Strings := preload("res://src/core/strings.gd")
|
||||||
|
const T_VIGNETTE := preload("res://assets/textures/vignette.png")
|
||||||
|
const T_ICON_FLASH := preload("res://assets/textures/icons/flashlight_on.png")
|
||||||
|
const T_ICON_DOOR := preload("res://assets/textures/icons/door_locked.png")
|
||||||
|
const T_ICON_WAND := preload("res://assets/textures/icons/wand.png")
|
||||||
|
const T_ICON_REC := preload("res://assets/textures/icons/rec_dot.png")
|
||||||
|
const T_CARD_ID := preload("res://assets/textures/cards/id_card.png")
|
||||||
|
const T_CARD_WRIST := preload("res://assets/textures/cards/wristband.png")
|
||||||
|
const T_CARD_TICKET := preload("res://assets/textures/cards/ticket.png")
|
||||||
|
const T_CARD_CRED := preload("res://assets/textures/cards/credential.png")
|
||||||
|
const T_CARD_GUEST := preload("res://assets/textures/cards/guest_list.png")
|
||||||
|
const CARD_TEXTURES := {
|
||||||
|
Rules.AccessKind.ID: T_CARD_ID,
|
||||||
|
Rules.AccessKind.WRISTBAND: T_CARD_WRIST,
|
||||||
|
Rules.AccessKind.TICKET: T_CARD_TICKET,
|
||||||
|
Rules.AccessKind.CREDENTIAL: T_CARD_CRED,
|
||||||
|
Rules.AccessKind.GUEST_LIST: T_CARD_GUEST,
|
||||||
|
}
|
||||||
|
|
||||||
var top_label: Label
|
var top_label: Label
|
||||||
var time_label: Label
|
var time_label: Label
|
||||||
@@ -17,10 +34,15 @@ var inspect_text: Label
|
|||||||
var inspect_meta: Label
|
var inspect_meta: Label
|
||||||
var radio_cooldown_label: Label
|
var radio_cooldown_label: Label
|
||||||
var mode_label: Label
|
var mode_label: Label
|
||||||
|
var vignette_rect: TextureRect
|
||||||
|
var cam_clock: Label
|
||||||
|
var icon_row: HBoxContainer
|
||||||
|
var flashlight_icon: TextureRect
|
||||||
|
var door_icon: TextureRect
|
||||||
|
var wand_icon: TextureRect
|
||||||
var wand_panel: PanelContainer
|
var wand_panel: PanelContainer
|
||||||
var wand_text: Label
|
var wand_text: Label
|
||||||
var wand_meta: Label
|
var wand_meta: Label
|
||||||
var tone_label: Label
|
|
||||||
var escalation_label: Label
|
var escalation_label: Label
|
||||||
|
|
||||||
var _debug_enabled := false
|
var _debug_enabled := false
|
||||||
@@ -99,13 +121,6 @@ func _init() -> void:
|
|||||||
mode_label.modulate = Color(0.85, 0.85, 0.9)
|
mode_label.modulate = Color(0.85, 0.85, 0.9)
|
||||||
vbox.add_child(mode_label)
|
vbox.add_child(mode_label)
|
||||||
|
|
||||||
# --- dialogue tone indicator ---
|
|
||||||
tone_label = Label.new()
|
|
||||||
tone_label.text = "Tone: Professional"
|
|
||||||
tone_label.add_theme_font_size_override("font_size", 13)
|
|
||||||
tone_label.modulate = Color(0.7, 0.85, 1.0)
|
|
||||||
vbox.add_child(tone_label)
|
|
||||||
|
|
||||||
# --- escalation level indicator (color-coded) ---
|
# --- escalation level indicator (color-coded) ---
|
||||||
escalation_label = Label.new()
|
escalation_label = Label.new()
|
||||||
escalation_label.text = "Escalation: Observation"
|
escalation_label.text = "Escalation: Observation"
|
||||||
@@ -113,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")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
extends SceneTree
|
extends SceneTree
|
||||||
## Dev helper: run the game windowed and capture staged screenshots for the
|
## Dev helper: run the game windowed and capture staged screenshots for the
|
||||||
## README. Saves 1600x900 PNGs to user://screenshots/.
|
## 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
|
## Usage: godot --path . --script res://src/test/capture_screenshots.gd
|
||||||
|
|
||||||
const OUT_DIR := "user://screenshots"
|
const OUT_DIR := "user://screenshots"
|
||||||
const MAX_FRAMES := 2400 # hard cap (~40s)
|
const MAX_FRAMES := 3600 # hard cap (~60s)
|
||||||
|
|
||||||
var frames := 0
|
var frames := 0
|
||||||
var gm: Node = null
|
var gm: Node = null
|
||||||
@@ -39,7 +40,7 @@ func _advance() -> void:
|
|||||||
state_frames += 1
|
state_frames += 1
|
||||||
match state:
|
match state:
|
||||||
0:
|
0:
|
||||||
# 2s overview from the guard post.
|
# 2s overview from the guard post (early morning light).
|
||||||
if state_frames >= 120:
|
if state_frames >= 120:
|
||||||
_shoot("01-venue-overview.png")
|
_shoot("01-venue-overview.png")
|
||||||
state = 1
|
state = 1
|
||||||
@@ -52,7 +53,6 @@ func _advance() -> void:
|
|||||||
state = 2
|
state = 2
|
||||||
state_frames = 0
|
state_frames = 0
|
||||||
2:
|
2:
|
||||||
# Give the HUD a moment, then shoot the interaction shot.
|
|
||||||
if state_frames >= 30:
|
if state_frames >= 30:
|
||||||
_shoot("02-targeting-npc.png")
|
_shoot("02-targeting-npc.png")
|
||||||
if targeted and gm != null:
|
if targeted and gm != null:
|
||||||
@@ -64,7 +64,6 @@ func _advance() -> void:
|
|||||||
_shoot("03-access-inspection.png")
|
_shoot("03-access-inspection.png")
|
||||||
if gm != null:
|
if gm != null:
|
||||||
gm.hud.hide_inspection()
|
gm.hud.hide_inspection()
|
||||||
# Re-target (target may have moved) before opening the radio form.
|
|
||||||
if gm.npcs.size() > 0:
|
if gm.npcs.size() > 0:
|
||||||
gm.call("_cycle_nearby_target")
|
gm.call("_cycle_nearby_target")
|
||||||
gm.call("_open_radio", false)
|
gm.call("_open_radio", false)
|
||||||
@@ -73,7 +72,25 @@ func _advance() -> void:
|
|||||||
4:
|
4:
|
||||||
if state_frames >= 30:
|
if state_frames >= 30:
|
||||||
_shoot("04-radio-report.png")
|
_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 = 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)
|
quit(0)
|
||||||
|
|
||||||
func _shoot(file_name: String) -> void:
|
func _shoot(file_name: String) -> void:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -63,6 +63,30 @@ func test_late_phase_reaches_violent_event() -> bool:
|
|||||||
c = false
|
c = false
|
||||||
return a and b and TestUtils.check(c, "all venues declare a LATE phase")
|
return a and b and TestUtils.check(c, "all venues declare a LATE phase")
|
||||||
|
|
||||||
|
func test_door_staff_lines_available() -> bool:
|
||||||
|
var scenario: VenueScenario = ScenarioFactory.joke_shop()
|
||||||
|
var rng := RandomNumberGenerator.new()
|
||||||
|
rng.seed = 7
|
||||||
|
var ok := true
|
||||||
|
for context in ["id_check", "ticket_check", "wand_check", "refusal", "prohibited_item"]:
|
||||||
|
var line: String = scenario.door_staff_line(context, rng)
|
||||||
|
if line == "":
|
||||||
|
ok = false
|
||||||
|
var a := TestUtils.check(ok, "all joke-shop contexts have a door-staff line")
|
||||||
|
var b := TestUtils.check_eq(scenario.door_staff_line("unknown_context", rng), "", "unknown context returns empty")
|
||||||
|
var b_country := ScenarioFactory.hollywood_building()
|
||||||
|
var c := TestUtils.check_eq(b_country.door_staff_line("refusal", rng), "", "venue without lines returns empty")
|
||||||
|
# Same seed picks the same line.
|
||||||
|
var r0 := RandomNumberGenerator.new()
|
||||||
|
r0.seed = 11
|
||||||
|
var r1 := RandomNumberGenerator.new()
|
||||||
|
r1.seed = 11
|
||||||
|
var d := TestUtils.check_eq(
|
||||||
|
scenario.door_staff_line("wand_check", r0),
|
||||||
|
scenario.door_staff_line("wand_check", r1),
|
||||||
|
"seeded door-staff pick is deterministic")
|
||||||
|
return a and b and c and d
|
||||||
|
|
||||||
func test_protester_archetype_present() -> bool:
|
func test_protester_archetype_present() -> bool:
|
||||||
var profiles := ScenarioFactory.shared_profiles()
|
var profiles := ScenarioFactory.shared_profiles()
|
||||||
var protester_found := false
|
var protester_found := false
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ func test_strings_lookup() -> bool:
|
|||||||
return a and b
|
return a and b
|
||||||
|
|
||||||
func test_strings_format() -> bool:
|
func test_strings_format() -> bool:
|
||||||
var a := TestUtils.check_eq(Strings.fmt("hud.tone_prefix", ["Firm"]), "Tone: Firm", "tone prefix formats")
|
var a := TestUtils.check_eq(Strings.fmt("react.comply", ["Tourist"]), "Tourist complies and steps back.", "reaction formats")
|
||||||
var b := TestUtils.check_eq(Strings.fmt("react.comply", ["Tourist"]), "Tourist complies and steps back.", "reaction formats")
|
var b := TestUtils.check_eq(Strings.fmt("hud.venue_switched", ["The Joke Shop"]), "Venue: The Joke Shop", "venue switch formats")
|
||||||
return a and b
|
return a and b
|
||||||
|
|
||||||
func test_strings_json_backed_all_resolve() -> bool:
|
func test_strings_json_backed_all_resolve() -> bool:
|
||||||
@@ -26,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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||