Compare commits
370
Commits
30f6666d7d
...
v0.1.13
@@ -114,7 +114,7 @@ Synthesize findings.
|
||||
|
||||
### Qatux (Documenter & Librarian)
|
||||
- Core team member — participates in discussion rounds as documenter
|
||||
- Manages document search via `/search-docs` skill
|
||||
- Manages document search via `/docs-search` skill
|
||||
- Maintains DECISIONS.md, DISCUSSION.md, briefings, and Qdrant search index
|
||||
- Answers "did we discuss this?" with citations
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ Named after Qatux, the Raiel with perfect memory who helped Paula Myo by recalli
|
||||
- Provide "state of the project" summaries when asked
|
||||
|
||||
### Knowledge management
|
||||
- Maintain the Qdrant document index via /search-docs skill
|
||||
- Maintain the Qdrant document index via /docs-search skill
|
||||
- Update briefing files when decisions change
|
||||
- Answer retrieval questions: "did we discuss X?", "what did we decide about Y?"
|
||||
- Catch staleness in briefings and flag for update
|
||||
|
||||
+67
-1
@@ -2,5 +2,71 @@
|
||||
"env": {
|
||||
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
|
||||
},
|
||||
"teammateMode": "in-process"
|
||||
"teammateMode": "in-process",
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit *)",
|
||||
"Bash(git push *)",
|
||||
"Bash(git fetch *)",
|
||||
"Bash(git merge *)",
|
||||
"Bash(git pull *)",
|
||||
"Bash(git status *)",
|
||||
"Bash(git log *)",
|
||||
"Bash(git diff *)",
|
||||
"Bash(git show *)",
|
||||
"Bash(git checkout *)",
|
||||
"Bash(git stash *)",
|
||||
"Bash(git branch *)",
|
||||
"Bash(git worktree *)",
|
||||
"Bash(git config *)",
|
||||
"Bash(git mv *)",
|
||||
"Bash(git rm *)",
|
||||
"Bash(git ls-tree *)",
|
||||
"Bash(git rev-parse --show-toplevel)",
|
||||
|
||||
"Bash(db/connectors/ticket *)",
|
||||
"Bash(db/connectors/sprint *)",
|
||||
"Bash(db/connectors/sqlite-query *)",
|
||||
"Bash(db/connectors/sqlite-exec *)",
|
||||
"Bash(db/connectors/qdrant-search *)",
|
||||
"Bash(db/connectors/qdrant-index *)",
|
||||
"Bash(db/connectors/qdrant-health)",
|
||||
"Bash(db/connectors/qdrant-count)",
|
||||
"Bash(db/connectors/sqlite-init)",
|
||||
"Bash(db/connectors/decisions-sync)",
|
||||
|
||||
"Bash(db/connectors/audio-generate *)",
|
||||
"Bash(db/connectors/audio-health)",
|
||||
"Bash(db/connectors/audio-post *)",
|
||||
|
||||
"Bash(make *)",
|
||||
"Bash(make)",
|
||||
|
||||
"Bash(tea *)",
|
||||
|
||||
"Bash(chmod *)",
|
||||
"Bash(ls *)",
|
||||
"Bash(find *)",
|
||||
"Bash(list *)",
|
||||
"Bash(tree *)",
|
||||
"Bash(sed -n *)",
|
||||
|
||||
"Skill(git-commit)",
|
||||
"Skill(worktree-update)",
|
||||
"Skill(sprint-start)",
|
||||
"Skill(sprint-plan)",
|
||||
"Skill(pr-push)",
|
||||
"Skill(pr-review)",
|
||||
"Skill(ticket)",
|
||||
"Skill(docs-search)",
|
||||
"Skill(workshop-start)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(git push --force *)",
|
||||
"Bash(git reset --hard *)",
|
||||
"Bash(git clean -f *)",
|
||||
"Bash(rm -rf *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
---
|
||||
name: audio-gen
|
||||
description: >
|
||||
Generate audio assets for The Settled Reach using the Stable Audio Open API
|
||||
(self-hosted Gradio app at tower-of-joy:11500). Use when generating any game
|
||||
audio: ambient loops, SFX, UI sounds, monologue chimes, footsteps, or any
|
||||
sound asset from docs/assets/audio/. Also use when the user asks about audio
|
||||
generation, sound design pipeline, or audio asset iteration. Triggers on:
|
||||
"generate audio", "make sounds", "create ambient", "audio pipeline",
|
||||
"generate sfx", "stable audio", "gen audio", "sound design".
|
||||
---
|
||||
|
||||
# Audio Generation — The Settled Reach
|
||||
|
||||
Generate sonically consistent audio assets using the Stable Audio Open API via
|
||||
wrapper scripts at `db/connectors/audio-*`.
|
||||
|
||||
Asset descriptions, filenames, bus routing, and design intent are documented in
|
||||
`docs/assets/audio/`. This skill provides the prompt system, generation
|
||||
workflow, and quality validation.
|
||||
|
||||
## API Access
|
||||
|
||||
**Never call the API directly.** Use the wrapper scripts:
|
||||
|
||||
```bash
|
||||
# Check API health
|
||||
db/connectors/audio-health
|
||||
|
||||
# Generate a single asset (WAV only)
|
||||
db/connectors/audio-generate "prompt text" \
|
||||
--duration 10 --steps 100 --cfg 7 \
|
||||
--output path/to/output.wav
|
||||
|
||||
# Generate + post-process in one command (WAV → trim → normalize → OGG)
|
||||
db/connectors/audio-generate "prompt text" \
|
||||
--duration 10 --steps 100 --cfg 7 \
|
||||
--output path/to/gen/intermediate.wav \
|
||||
--output-ogg client/assets/audio/final.ogg
|
||||
|
||||
# Batch-generate from a manifest (preferred for multiple assets)
|
||||
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Parameter | Default | Range | Notes |
|
||||
|-----------|---------|-------|-------|
|
||||
| `--duration` | 10 | 0-47s | Max 47s per generation. For longer loops, generate 45s with crossfade overlap. |
|
||||
| `--steps` | 100 | 10-200 | More steps = better quality, slower. Use 50 for quick previews, 100-150 for final. |
|
||||
| `--cfg` | 7 | 1-15 | Classifier-free guidance. Higher = more prompt-adherent but less natural. 5-9 is the sweet spot. |
|
||||
| `--output` | auto | — | Output WAV file path. Auto-names from prompt if omitted. |
|
||||
| `--post` | off | — | Run trim + normalize + convert after generation. |
|
||||
| `--output-ogg` | auto | — | OGG output path (implies `--post`). Defaults to same basename as WAV. |
|
||||
| `--timeout` | 600 | — | Max wait in seconds. Generation can take 2-5 minutes on 11GB VRAM. |
|
||||
|
||||
### Critical Constraints
|
||||
|
||||
- **NEVER parallelize requests.** The server has 11GB VRAM and runs one
|
||||
generation at a time. Always wait for a generation to complete before
|
||||
starting the next. Sequential only.
|
||||
- **Generation takes 2-5 minutes** per clip depending on duration and steps.
|
||||
Be patient. The timeout default (600s) is generous.
|
||||
- **Max 47 seconds** per generation. For 60-90s ambient loops, generate 45s
|
||||
clips and crossfade-stitch in post-processing.
|
||||
|
||||
## Prompt Assembly
|
||||
|
||||
Every generation uses three parts:
|
||||
|
||||
```
|
||||
[SONIC FAMILY PREFIX] + [CATEGORY TEMPLATE] + [ASSET DESCRIPTION from docs/assets/audio/]
|
||||
```
|
||||
|
||||
Never call the API with just the asset description. Always prepend the sonic
|
||||
family prefix and matching category template.
|
||||
|
||||
- **Sonic palette and families:** Read `references/sonic-palette.md`
|
||||
- **Category templates:** Read `references/category-templates.md` and match by
|
||||
asset type (ambient, sfx, ui)
|
||||
- **Asset description:** Look up the specific asset in `docs/assets/audio/{category}.md`
|
||||
|
||||
## Batch Workflow (Preferred)
|
||||
|
||||
For generating multiple assets, use a manifest file. This reduces prompt
|
||||
approvals to 2: one Write (manifest) + one Bash (batch run).
|
||||
|
||||
### 1. Create the manifest
|
||||
|
||||
Write a JSON manifest to `docs/assets/audio/batch-{sprint}-{ticket}.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"description": "Sprint 10 ambient + world SFX batch",
|
||||
"output_dir": "client/assets/audio",
|
||||
"gen_dir": "client/assets/audio/gen",
|
||||
"defaults": {
|
||||
"steps": 100,
|
||||
"cfg": 7,
|
||||
"lufs": -16,
|
||||
"quality": 6
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"id": "AMB-001",
|
||||
"filename": "amb_station_base.ogg",
|
||||
"method": "sao",
|
||||
"duration": 45,
|
||||
"steps": 150,
|
||||
"cfg": 5,
|
||||
"prompt": "[sonic family prefix] + [template] + [description]"
|
||||
},
|
||||
{
|
||||
"id": "UI-005",
|
||||
"filename": "sfx_monologue_chime.ogg",
|
||||
"method": "synth",
|
||||
"synth": {
|
||||
"type": "harmonic",
|
||||
"duration": 0.8,
|
||||
"fundamental": 1200,
|
||||
"harmonics": [
|
||||
{"freq": 2400, "db": -12},
|
||||
{"freq": 3600, "db": -24}
|
||||
],
|
||||
"attack_ms": 15,
|
||||
"sustain_ratio": 0.2,
|
||||
"decay": "exponential"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Asset `id` values must match IDs in `docs/assets/audio/{category}.md` (e.g.,
|
||||
AMB-001, SFX-002, UI-005). This couples the manifest to the asset inventory.
|
||||
|
||||
### 2. Run the batch
|
||||
|
||||
```bash
|
||||
# Full run
|
||||
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json
|
||||
|
||||
# Dry run — preview what would be generated
|
||||
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --dry-run
|
||||
|
||||
# Generate only specific assets
|
||||
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --only AMB-001,AMB-002
|
||||
|
||||
# Skip assets that already have OGG files
|
||||
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --skip-existing
|
||||
```
|
||||
|
||||
### 3. Update asset docs with prompts
|
||||
|
||||
After the batch completes, write the exact prompts used back into the
|
||||
Prompt/Notes column of `docs/assets/audio/{category}.md`. The manifest records
|
||||
what was generated; the asset docs record what we have.
|
||||
|
||||
### Manifest fields
|
||||
|
||||
| Field | Required | Notes |
|
||||
|-------|----------|-------|
|
||||
| `id` | yes | Asset ID from docs (AMB-001, SFX-002, UI-005) |
|
||||
| `filename` | yes | Output filename (must match asset doc) |
|
||||
| `method` | yes | `sao` (Stable Audio Open) or `synth` (harmonic synthesis) |
|
||||
| `duration` | SAO only | Duration in seconds |
|
||||
| `prompt` | SAO only | Full assembled prompt |
|
||||
| `steps` | no | Override default steps |
|
||||
| `cfg` | no | Override default CFG |
|
||||
| `synth` | synth only | Synthesis parameters (see below) |
|
||||
|
||||
### Synth parameters
|
||||
|
||||
| Field | Default | Notes |
|
||||
|-------|---------|-------|
|
||||
| `type` | harmonic | Only `harmonic` supported currently |
|
||||
| `duration` | — | Duration in seconds |
|
||||
| `fundamental` | — | Fundamental frequency in Hz |
|
||||
| `harmonics` | [] | List of `{"freq": Hz, "db": dB}` objects |
|
||||
| `attack_ms` | 10 | Attack time in milliseconds |
|
||||
| `sustain_ratio` | 0.2 | Fraction of duration at full level before decay |
|
||||
| `decay` | exponential | `exponential` or `linear` |
|
||||
|
||||
## Single Asset Workflow
|
||||
|
||||
For one-off generation or iteration on a specific asset:
|
||||
|
||||
1. Find the asset in `docs/assets/audio/{ambient,sfx,ui}.md` — note filename,
|
||||
duration, bus, method, and design intent.
|
||||
2. Read `references/sonic-palette.md` for the sonic family prefix.
|
||||
3. Read `references/category-templates.md` for the matching template.
|
||||
4. Assemble the full prompt.
|
||||
5. Run `db/connectors/audio-health` to verify the API is up.
|
||||
6. Run `db/connectors/audio-generate` with `--post` or `--output-ogg` to
|
||||
generate and post-process in one step.
|
||||
7. Verify the output (file size, duration).
|
||||
8. Update the asset status and prompt in `docs/assets/audio/{category}.md`.
|
||||
|
||||
## Iteration Workflow
|
||||
|
||||
For each asset, generate 4-6 candidates:
|
||||
|
||||
1. **Generate candidates** — vary the prompt slightly (add/remove descriptors,
|
||||
adjust CFG between 5-9). Run each generation sequentially — never in
|
||||
parallel.
|
||||
2. **Solo test** — does each candidate sound right alone?
|
||||
3. **Stack test** — play the candidate alongside other layers. Does it mask or
|
||||
clash?
|
||||
4. **Fatigue test** (loops only) — can you listen for 5+ minutes without a
|
||||
jarring repeat?
|
||||
5. **Close-your-eyes test** — does it create a mental image or sensation?
|
||||
6. Select the best candidate (post-processing is already done if `--post` was
|
||||
used).
|
||||
|
||||
## Post-Processing (Standalone)
|
||||
|
||||
If you need to post-process separately (e.g., re-normalizing an existing file):
|
||||
|
||||
```bash
|
||||
# Full pipeline: trim → normalize → convert
|
||||
db/connectors/audio-post pipeline input.wav --output output.ogg
|
||||
|
||||
# Individual steps
|
||||
db/connectors/audio-post trim input.wav
|
||||
db/connectors/audio-post normalize input.wav --lufs -16
|
||||
db/connectors/audio-post convert input.wav --output output.ogg
|
||||
```
|
||||
|
||||
## Manual Synthesis (Insert-Tech Sounds)
|
||||
|
||||
For sounds under 200ms (cursor hover, weapon aim), Stable Audio Open cannot
|
||||
produce meaningful output. Use manual synthesis via `tooling/synth_ui_sounds.py`
|
||||
or the batch manifest's `method: "synth"` with harmonic parameters.
|
||||
|
||||
For complex synthesis beyond the `harmonic` type (FM, filtered noise, bandpass
|
||||
impulse), write a custom script in `tooling/` following the pattern in
|
||||
`tooling/synth_ui_sounds.py`.
|
||||
|
||||
## Quality Checklist
|
||||
|
||||
After generating, verify:
|
||||
- Sound matches the sonic family (insert-tech = synthetic/precise, organic = warm/natural)
|
||||
- Frequency range doesn't mask other layers (check docs/assets/audio/)
|
||||
- Duration matches spec
|
||||
- No unwanted artifacts (clicks, pops, digital noise at start/end)
|
||||
- Loop point is clean (ambient loops only)
|
||||
- Volume sits well relative to other assets (LUFS normalized)
|
||||
- Passes the close-your-eyes test
|
||||
|
||||
## File Placement
|
||||
|
||||
Generated assets go to `client/assets/audio/` with exact filenames from the
|
||||
asset docs. Intermediates go to `client/assets/audio/gen/` (gitignored).
|
||||
|
||||
AudioManager discovers assets by directory scan — filenames must match exactly.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Category Templates — Audio Generation
|
||||
|
||||
Match the asset's category to the right template. Append after the sonic family
|
||||
prefix and before the specific asset description.
|
||||
|
||||
## Ambient Loops
|
||||
|
||||
**Template:**
|
||||
> Continuous ambient soundscape loop, seamless looping audio, no distinct
|
||||
> beginning or end, steady background atmosphere, [DURATION]s duration
|
||||
|
||||
**Notes:**
|
||||
- Generate at 45s (the SAO sweet spot below the 47s ceiling)
|
||||
- Add 3-5s of overlap material for crossfade looping in post
|
||||
- These play continuously — they must be boring enough to fade into the
|
||||
background but rich enough to reward attention
|
||||
- Station baseline prefix is required (see sonic-palette.md)
|
||||
|
||||
**Steps:** 100-150 (higher quality for loops — artifacts are more noticeable)
|
||||
**CFG:** 5-7 (lower guidance = more natural variation, less mechanical)
|
||||
|
||||
## Sound Effects (SFX)
|
||||
|
||||
**Template:**
|
||||
> Single isolated sound effect, clean recording, [DURATION] duration,
|
||||
> clear attack and natural decay, no background noise, no reverb tail
|
||||
> beyond natural
|
||||
|
||||
**Notes:**
|
||||
- For footsteps: specify surface material, pace, weight
|
||||
- For environmental: specify the physical mechanism (door hinge, cargo latch)
|
||||
- Keep short and punchy — these are event-driven, not continuous
|
||||
|
||||
**Steps:** 80-100 (shorter sounds need fewer steps)
|
||||
**CFG:** 7-9 (more guidance = more precise sound matching)
|
||||
|
||||
## UI Sounds
|
||||
|
||||
**Template:**
|
||||
> Interface feedback sound, digital UI element, [DURATION] duration,
|
||||
> immediate attack, clean decay, isolated sound with no background
|
||||
|
||||
**Notes:**
|
||||
- Most UI sounds are under 200ms — use manual synthesis, not SAO
|
||||
- For SAO-generated UI sounds (>200ms like fog_recognition, chimes):
|
||||
generate at 3-5s duration, then trim to the best portion
|
||||
- Insert-tech prefix is required for lattice/interface sounds
|
||||
- Organic prefix for fog_recognition (cognitive, not technological)
|
||||
|
||||
**Steps:** 80-100
|
||||
**CFG:** 7-9 (precision matters for UI sounds)
|
||||
|
||||
## Chimes / Notification Sounds
|
||||
|
||||
**Template:**
|
||||
> Single musical tone, crystalline quality, [DURATION] duration, gentle
|
||||
> attack, resonant sustain, natural fade out, no accompaniment, no rhythm
|
||||
|
||||
**Notes:**
|
||||
- Generate at 3-5s to give SAO enough temporal context
|
||||
- Trim to the best 0.5-1.0s section
|
||||
- The attack transient is where the character lives — select for that
|
||||
- Normal vs urgent variants: same base prompt, vary "gentle/soft" vs
|
||||
"brighter/sharper/more present"
|
||||
|
||||
**Steps:** 100 (tonal quality matters)
|
||||
**CFG:** 6-8 (some freedom for natural harmonic content)
|
||||
@@ -0,0 +1,45 @@
|
||||
# Sonic Palette — Prompt Prefixes
|
||||
|
||||
Two sonic families. Every generated sound uses one of these as a prompt prefix.
|
||||
|
||||
## Insert-Tech (Synthetic)
|
||||
|
||||
For UI sounds, neural lattice interface, augmented cognition.
|
||||
|
||||
**Prompt prefix:**
|
||||
> Clean digital audio, synthetic electronic tone, precise and clinical sound
|
||||
> design, no reverb, no room ambience, studio-dry recording, futuristic
|
||||
> interface sound
|
||||
|
||||
**Character:** Mathematical precision. The sound of well-designed technology.
|
||||
No organic texture, no room reflections. Exists "inside the head," not in
|
||||
physical space.
|
||||
|
||||
**Frequency range:** 800Hz-4kHz primary. Sharp attack, controlled decay.
|
||||
|
||||
**Used for:** cursor_hover, implant_open, weapon_aim, sfx_monologue_chime,
|
||||
sfx_monologue_chime_urgent
|
||||
|
||||
## Organic (Biological/Environmental)
|
||||
|
||||
For environmental sounds, human cognition, physical world.
|
||||
|
||||
**Prompt prefix:**
|
||||
> Warm organic audio, natural sound recording, slight room ambience,
|
||||
> lived-in industrial space, realistic acoustic properties, authentic
|
||||
> environmental sound
|
||||
|
||||
**Character:** Human warmth. Natural attack and decay. Reverberant — exists
|
||||
in physical space. The sound of a real place with real materials.
|
||||
|
||||
**Frequency range:** 60Hz-3kHz primary. Soft or natural attack, room tail.
|
||||
|
||||
**Used for:** fog_recognition, all amb_* ambient loops, all sfx_footstep_*
|
||||
|
||||
## Station Baseline
|
||||
|
||||
For ambient loops specifically, add the station context:
|
||||
|
||||
> Interior of a large industrial space station, metal and composite
|
||||
> construction, background mechanical hum from power systems, air circulation
|
||||
> audible, no music, no prominent voices unless specified
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: search-docs
|
||||
name: docs-search
|
||||
description: >
|
||||
Search project documents using semantic search (Qdrant + ollama) or grep fallback.
|
||||
Use when the user asks "did we discuss X?", "find references to Y", "search docs",
|
||||
or invokes /search-docs. Wraps the qdrant_connector.py for semantic document search.
|
||||
or invokes /docs-search. Wraps the qdrant_connector.py for semantic document search.
|
||||
user-invocable: true
|
||||
allowed-tools: Bash, Read, Grep, Glob
|
||||
---
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
name: commit
|
||||
name: git-commit
|
||||
description: >
|
||||
Commit changes with clean, structured messages. Use when the user says
|
||||
"commit", "save my work", "commit changes", or invokes /commit. Enforces
|
||||
"commit", "save my work", "commit changes", or invokes /git-commit. Enforces
|
||||
conventional commit format, groups changes into logical commits, and maintains
|
||||
CHANGELOG.md. Never squash unrelated changes into one commit.
|
||||
user-invocable: true
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: asset-gen
|
||||
name: image-gen
|
||||
description: >
|
||||
Generate themed visual assets for the Lords of Ash CK3 total conversion mod
|
||||
using the generate_image MCP tool (Nano Banana / Gemini 2.5 Flash Image).
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
name: pr-push
|
||||
description: >
|
||||
Push commits and create or update a pull request. Use when the user says
|
||||
"push pr", "push and create pr", "update pr", "create a pr", "open a pr",
|
||||
or invokes /pr-push. NOT triggered by plain "push" (that's just git push).
|
||||
Pushes the current branch, creates a PR if none exists, or confirms the
|
||||
existing PR was updated. NEVER merges the PR into main — this skill only
|
||||
pushes to the branch and manages the PR lifecycle.
|
||||
user-invocable: true
|
||||
allowed-tools: Bash, Read, Grep, Glob, AskUserQuestion, Skill
|
||||
---
|
||||
|
||||
# Push PR Skill
|
||||
|
||||
Push commits to remote and create or update a PR. Operates exclusively on the
|
||||
current branch — never touches main.
|
||||
|
||||
## Safety Rules (NON-NEGOTIABLE)
|
||||
|
||||
- **NEVER merge a PR into main.** No `tea pr merge`, no `git merge` into main.
|
||||
- **NEVER checkout or push to main.**
|
||||
- **NEVER force-push** unless the user explicitly requests it.
|
||||
- **NEVER use `--no-verify` or skip hooks.**
|
||||
- Only push to the current working branch.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Validate branch
|
||||
|
||||
```bash
|
||||
git branch --show-current
|
||||
```
|
||||
|
||||
If on `main`, stop: "You're on main. Switch to a team branch first."
|
||||
|
||||
### 2. Commit uncommitted changes
|
||||
|
||||
```bash
|
||||
git status
|
||||
```
|
||||
|
||||
If there are uncommitted changes (staged or unstaged), run the **commit skill**
|
||||
first. Use the `/git-commit` skill to group changes into logical commits with
|
||||
proper conventional commit messages. Wait for commit to complete before
|
||||
proceeding.
|
||||
|
||||
If the working tree is clean (no uncommitted changes), skip to step 3.
|
||||
|
||||
### 3. Check for unpushed commits
|
||||
|
||||
```bash
|
||||
git fetch --all
|
||||
git log --oneline origin/<branch>..<branch>
|
||||
```
|
||||
|
||||
If no unpushed commits, skip to step 5 (PR check).
|
||||
|
||||
### 4. Check for conflicts with main
|
||||
|
||||
```bash
|
||||
git merge-tree --write-tree origin/main HEAD 2>&1
|
||||
```
|
||||
|
||||
If conflicts reported, merge main into current branch:
|
||||
|
||||
```bash
|
||||
git merge origin/main --no-edit
|
||||
```
|
||||
|
||||
If merge conflicts, **stop and report** — let the user resolve.
|
||||
If clean, continue.
|
||||
|
||||
### 5. Push
|
||||
|
||||
```bash
|
||||
git push origin <branch>
|
||||
```
|
||||
|
||||
If push fails, stop and report. Never force-push without explicit request.
|
||||
|
||||
### 6. Check for existing PR
|
||||
|
||||
```bash
|
||||
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||
```
|
||||
|
||||
Match current branch name in PR list.
|
||||
|
||||
- **PR exists**: Report "Pushed N commits to `<branch>`. PR #X updated." Done.
|
||||
- **No PR**: Continue to step 7.
|
||||
|
||||
### 7. Create a new PR
|
||||
|
||||
```bash
|
||||
git log --oneline main..<branch>
|
||||
git diff --stat main...<branch>
|
||||
```
|
||||
|
||||
Draft title (`<type>(<scope>): <summary>`, max 70 chars) and description.
|
||||
|
||||
```bash
|
||||
cat > /tmp/pr-body.md << 'EOF'
|
||||
## Summary
|
||||
...
|
||||
EOF
|
||||
tea pr create \
|
||||
--repo jpmschweitzer/settled-reach \
|
||||
--login schweitz \
|
||||
--title "<title>" \
|
||||
--description "$(cat /tmp/pr-body.md)" \
|
||||
--base main \
|
||||
--head <branch>
|
||||
```
|
||||
|
||||
Report PR URL when done.
|
||||
|
||||
### 8. Update ticket status to review
|
||||
|
||||
Scan all commit messages in the PR for ticket references (`#NNN`):
|
||||
|
||||
```bash
|
||||
git log --oneline main..<branch>
|
||||
```
|
||||
|
||||
Extract ticket IDs from `#NNN` patterns. For each ticket that is
|
||||
currently `in_progress`, update it to `review`:
|
||||
|
||||
```bash
|
||||
db/connectors/ticket status <id> review
|
||||
```
|
||||
|
||||
Report which tickets were moved to review. Skip tickets that are
|
||||
already `done`, `review`, `cancelled`, or `backlog` (only transition
|
||||
`in_progress` → `review`).
|
||||
|
||||
## Arguments
|
||||
|
||||
If the user passes arguments (e.g., `/pr-push "my title"`), use them as the
|
||||
PR title instead of generating one.
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: review-pr
|
||||
name: pr-review
|
||||
description: >
|
||||
Review a branch diff with team-appropriate agents before merge. Use when the
|
||||
user says "review-pr", "review this PR", "review this branch", or invokes
|
||||
/review-pr. Spawns reviewers matched to the branch type (code, copy, visual,
|
||||
/pr-review. Spawns reviewers matched to the branch type (code, copy, visual,
|
||||
audio) in parallel. Reports approve/reject with inline comments.
|
||||
user-invocable: true
|
||||
allowed-tools: Bash, Read, Grep, Glob, Task
|
||||
@@ -140,33 +140,32 @@ Respond with:
|
||||
- File path and approximate location
|
||||
- Severity: critical / warning / suggestion
|
||||
- Description of the issue
|
||||
|
||||
Verdict rules:
|
||||
- Any **critical** or **warning** issue → REQUEST_CHANGES
|
||||
- Only **suggestion**-level issues (or none) → APPROVE
|
||||
If no issues found, say APPROVE with a brief positive summary.
|
||||
```
|
||||
|
||||
## 6. Posting results to Gitea
|
||||
|
||||
After presenting results to the user, post the review as a PR comment:
|
||||
|
||||
```bash
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER> "<review markdown>"
|
||||
```
|
||||
|
||||
Use a heredoc for multi-line review bodies:
|
||||
```bash
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER> "$(cat <<'REVIEW'
|
||||
## Review: <branch> -> main
|
||||
...review content...
|
||||
REVIEW
|
||||
)"
|
||||
```
|
||||
After presenting results to the user, post the review as a PR comment.
|
||||
|
||||
Note: `tea pr reject` does not work on your own PRs. Use `tea comment` instead.
|
||||
|
||||
**Heredoc bodies hang** in `tea comment`. Write review to a temp file instead:
|
||||
```bash
|
||||
# Write review to temp file, then pass via cat
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER> "$(cat /tmp/review.md)"
|
||||
**IMPORTANT — `tea comment` hangs with inline heredocs and multi-line strings.**
|
||||
Always use a two-step approach: write to a temp file first, then pass via `$(cat)`:
|
||||
|
||||
```
|
||||
# Step 1: Write review to .tmp/ using the Write tool (no permission prompt)
|
||||
Write(file_path: "<repo_root>/.tmp/review-<branch>.md", content: "...review content...")
|
||||
|
||||
# Step 2: Post to Gitea (separate Bash call)
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER> "$(cat .tmp/review-<branch>.md)"
|
||||
```
|
||||
|
||||
Use the Write tool for step 1 (avoids Bash permission prompts). The `.tmp/`
|
||||
directory is gitignored and exists in the repo root for this purpose.
|
||||
|
||||
## 7. Merging approved PRs
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: create-skill
|
||||
name: skill-create
|
||||
description: >
|
||||
Guidance for creating effective Claude Code skills (.skill packages).
|
||||
Use when the user wants to create, build, design, or iterate on a skill —
|
||||
@@ -178,7 +178,11 @@ For design patterns, consult:
|
||||
**Writing guidelines:** Always use imperative/infinitive form.
|
||||
|
||||
**Frontmatter:**
|
||||
- `name`: The skill name
|
||||
- `name`: The skill name — use **domain-action** naming: `{domain}-{action}`.
|
||||
The domain is the system/area the skill operates on, the action is what it does.
|
||||
Examples: `pr-review`, `sprint-plan`, `docs-search`, `git-commit`, `debt-scan`.
|
||||
Multi-action wrappers (like `ticket`) can use the domain name alone.
|
||||
The directory name must match the `name` field.
|
||||
- `description`: Primary triggering mechanism. Include what the skill does AND
|
||||
specific triggers/contexts. All "when to use" info goes here (not in body).
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: plan-sprint
|
||||
name: sprint-plan
|
||||
description: >
|
||||
Plan the next sprint and generate team briefing files. Use when the user says
|
||||
"plan sprint", "prep sprint briefing", "plan next sprint", "sprint planning",
|
||||
or invokes /plan-sprint. Gathers current sprint status, scans the backlog,
|
||||
or invokes /sprint-plan. Gathers current sprint status, scans the backlog,
|
||||
proposes ticket selection, and writes briefing files per team to
|
||||
docs/sprints/sprint-N/.
|
||||
user-invocable: true
|
||||
@@ -19,8 +19,8 @@ When this skill is invoked, spawn SI using the Task tool:
|
||||
```
|
||||
Task(
|
||||
subagent_type: "si",
|
||||
prompt: "Run /plan-sprint for Sprint N. Read the skill at
|
||||
.claude/skills/plan-sprint/SKILL.md for the full workflow,
|
||||
prompt: "Run /sprint-plan for Sprint N. Read the skill at
|
||||
.claude/skills/sprint-plan/SKILL.md for the full workflow,
|
||||
then execute it. Use the arguments provided: {args}",
|
||||
description: "Plan sprint N"
|
||||
)
|
||||
@@ -53,34 +53,24 @@ file so the team knows who to spawn.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Determine sprint number
|
||||
### 1. Run sprint prepare
|
||||
|
||||
Get carry-overs, backlog candidates, and decision gaps in one shot:
|
||||
|
||||
```bash
|
||||
db/connectors/ticket sprint --active
|
||||
db/connectors/sprint prepare
|
||||
```
|
||||
|
||||
Next sprint = active sprint ID + 1. If no active sprint, ask the user.
|
||||
This auto-detects the next sprint number (max ID + 1), creates the sprint
|
||||
record in `planning` status if needed, and outputs:
|
||||
- Previous sprint status and carry-over candidates
|
||||
- Backlog candidates grouped by team
|
||||
- Decision coverage gaps
|
||||
- Already-assigned tickets (if any)
|
||||
|
||||
### 2. Gather current sprint state
|
||||
### 2. Deepen the scan
|
||||
|
||||
Review the active sprint for carry-overs:
|
||||
|
||||
```bash
|
||||
db/connectors/ticket list --sprint <current_id> --status in_progress
|
||||
db/connectors/ticket list --sprint <current_id> --status ready
|
||||
```
|
||||
|
||||
Any ticket not `done` is a potential carry-over. Note these for the briefing.
|
||||
|
||||
### 3. Scan the backlog
|
||||
|
||||
Pull candidate tickets by priority:
|
||||
|
||||
```bash
|
||||
db/connectors/ticket epics --status backlog
|
||||
```
|
||||
|
||||
For critical epics, check their children:
|
||||
For critical epics, check their children for granular candidates:
|
||||
|
||||
```bash
|
||||
db/connectors/ticket children <epic_id>
|
||||
@@ -88,7 +78,7 @@ db/connectors/ticket children <epic_id>
|
||||
|
||||
Use `db/connectors/ticket show --brief <id> [<id>...]` to quickly scan multiple tickets.
|
||||
|
||||
### 4. Read existing code state
|
||||
### 3. Read existing code state
|
||||
|
||||
Scan what's already built to write accurate "what exists" notes:
|
||||
|
||||
@@ -103,7 +93,7 @@ ls client/scripts/ client/scripts/*/
|
||||
Read key files that sprint tickets will build on (bridge types, existing
|
||||
renderers, etc.) to reference specific integration points in the briefing.
|
||||
|
||||
### 5. Select tickets — propose to user
|
||||
### 4. Select tickets — propose to user
|
||||
|
||||
Based on the backlog scan, propose a sprint with:
|
||||
|
||||
@@ -122,22 +112,30 @@ Selection heuristics:
|
||||
- Aim for 3-6 tickets per team, with parallel tracks where possible
|
||||
- Check `decisions/questions.md` for open Q-NNN items that block candidates
|
||||
|
||||
### 6. Read relevant decisions
|
||||
### 5. Read relevant decisions
|
||||
|
||||
For the selected tickets, identify which `decisions/*.md` files are relevant.
|
||||
Read them to provide accurate cross-references in the briefing.
|
||||
|
||||
### 7. Write briefing files
|
||||
### 6. Write briefing files
|
||||
|
||||
Create `docs/sprints/sprint-N/` and write one file per team.
|
||||
|
||||
Read the template at `references/briefing-template.md` in this skill directory
|
||||
for the exact file structure.
|
||||
|
||||
**IMPORTANT — worktree-relative paths:** This project uses git worktrees.
|
||||
Each team branch is checked out in its own worktree, and each worktree
|
||||
contains the full repo (`server/`, `client/`, `docs/`, etc.). All file
|
||||
paths in briefings must be relative to the worktree/git root. Example:
|
||||
`server/src/bridge/types.rs`, not `/absolute/path/to/server/src/...` or
|
||||
paths that navigate outside the git root (`../sibling-worktree/...`).
|
||||
Agents must stay within the git root they are running in.
|
||||
|
||||
Key requirements per file:
|
||||
- **server.md**: Carry-overs, new tickets, dependency chain, key decisions, notes
|
||||
referencing existing Rust modules by path
|
||||
- **client.md**: Same structure, notes referencing existing GDScript files by path
|
||||
referencing existing Rust modules by path from worktree root (e.g. `server/src/simulation/movement.rs`)
|
||||
- **client.md**: Same structure, notes referencing existing GDScript files by path from worktree root (e.g. `client/scripts/rendering/fog.gd`)
|
||||
- **copy.md**: In-game text tasks — dialogue, UI copy, tooltips, flavor text, lore
|
||||
- **audio.md**: Sound design, music, audio integration tasks
|
||||
- **visual.md**: Art direction, asset creation, visual consistency tasks
|
||||
@@ -148,19 +146,24 @@ Key requirements per file:
|
||||
Only generate briefing files for teams that have tickets assigned in the sprint.
|
||||
Not every sprint will have work for every team.
|
||||
|
||||
### 8. Assign tickets to sprint in DB
|
||||
### 7. Assign tickets to sprint in DB
|
||||
|
||||
After the user approves, assign all selected tickets to the new sprint:
|
||||
After the user approves, assign all selected tickets. The sprint record
|
||||
was already created by `sprint prepare` in step 1 (status: `planning`).
|
||||
Update it with the theme and goal, then assign tickets:
|
||||
|
||||
```bash
|
||||
# Create the sprint
|
||||
db/connectors/sqlite-exec "INSERT INTO sprints (name, goal, status) VALUES ('Sprint N: Theme', 'goal', 'planned')"
|
||||
# Update the sprint with theme and goal
|
||||
db/connectors/sqlite-exec "UPDATE sprints SET name='Sprint N: Theme', goal='goal' WHERE id=N"
|
||||
|
||||
# Assign tickets
|
||||
db/connectors/ticket sprint assign <ticket_id> <sprint_id>
|
||||
```
|
||||
|
||||
### 9. Present summary
|
||||
The sprint stays in `planning` status until explicitly activated via
|
||||
`db/connectors/sprint start`. This prevents starting an unplanned sprint.
|
||||
|
||||
### 8. Present summary
|
||||
|
||||
Output:
|
||||
- Sprint number, theme, and goal
|
||||
@@ -0,0 +1,327 @@
|
||||
---
|
||||
name: sprint-start
|
||||
description: >
|
||||
Manage the sprint lifecycle from main, or start sprint work on a team
|
||||
branch. Use when the user says "start sprint", "start working on the
|
||||
server/client/copy", "begin sprint", or invokes /sprint-start. On main:
|
||||
assesses sprint state and does the next right thing (close, activate, or
|
||||
guide). On a team branch: merges main, loads the briefing, presents the
|
||||
work plan.
|
||||
user-invocable: true
|
||||
allowed-tools: Bash, Read, Grep, Glob, TeamCreate, Task, TaskCreate, TaskUpdate, TaskList, SendMessage, AskUserQuestion
|
||||
---
|
||||
|
||||
# Start Sprint Skill
|
||||
|
||||
Prepare a team branch for sprint work: sync with main, load the sprint
|
||||
briefing, and present actionable next steps.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Determine the team
|
||||
|
||||
The current branch IS the team. Read it with:
|
||||
```bash
|
||||
git branch --show-current
|
||||
```
|
||||
|
||||
Valid team branches: `server`, `client`, `copy`, `audio`, `visual`, `ci`.
|
||||
|
||||
If on `main`, follow the **Main branch workflow** below instead of
|
||||
the team branch workflow (steps 2–8).
|
||||
|
||||
---
|
||||
|
||||
## Main branch workflow (sprint lifecycle management)
|
||||
|
||||
When `/sprint-start` is run on `main`, assess the current sprint state
|
||||
and do the next right thing. Query the database to determine the state:
|
||||
|
||||
```bash
|
||||
db/connectors/sqlite-query "SELECT id, name, status FROM sprints ORDER BY id DESC LIMIT 3"
|
||||
```
|
||||
|
||||
Then follow the **first matching case**:
|
||||
|
||||
### Case A: An active sprint exists
|
||||
|
||||
First, check whether the sprint's work is actually done:
|
||||
|
||||
```bash
|
||||
db/connectors/sprint status
|
||||
```
|
||||
|
||||
This shows ticket counts by status (done, in_progress, backlog).
|
||||
|
||||
Also check for open PRs that may contain completed work waiting for
|
||||
review or merge:
|
||||
|
||||
```bash
|
||||
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||
```
|
||||
|
||||
Report the full picture to the user:
|
||||
- Ticket progress (done / in_progress / backlog counts)
|
||||
- Open PRs (if any — these represent work that's done but not merged)
|
||||
|
||||
**If tickets remain unfinished** (in_progress or backlog) **or open PRs
|
||||
exist**, do NOT close the sprint. Instead, report the current progress
|
||||
and ask the user what they want to do:
|
||||
|
||||
- **Continue working** — switch to a team branch and run `/sprint-start`
|
||||
there to resume work
|
||||
- **Review & merge PRs first** — (only if open PRs exist) merge pending
|
||||
work before deciding whether to close
|
||||
- **Close anyway** — proceed with the close workflow below (carries over
|
||||
unfinished tickets)
|
||||
|
||||
Use `AskUserQuestion` to confirm. Do not proceed to A1 unless the user
|
||||
explicitly chooses to close.
|
||||
|
||||
#### A1. Close the active sprint
|
||||
|
||||
```bash
|
||||
db/connectors/sprint stop
|
||||
```
|
||||
|
||||
This marks the active sprint as completed and lists carry-over candidates.
|
||||
Note the sprint number (N) from the output.
|
||||
|
||||
#### A2. Bump the version
|
||||
|
||||
The project version scheme is `v0.1.{sprint_number}`. After closing
|
||||
sprint N, the version is `v0.1.N`.
|
||||
|
||||
Update `project.yaml`:
|
||||
- Set the `version` field to `0.1.N` (this is the source of truth).
|
||||
|
||||
Update `server/Cargo.toml`:
|
||||
- Set `version = "0.1.N"` in `[package]`.
|
||||
|
||||
Update `CHANGELOG.md`:
|
||||
- Move all entries under `## [Unreleased]` into a new section
|
||||
`## [v0.1.N] — YYYY-MM-DD` (using today's date).
|
||||
- Leave `## [Unreleased]` as an empty section above the new version.
|
||||
- Keep the existing sub-headings (Added, Fixed, Changed, Removed) —
|
||||
only move entries that have content.
|
||||
|
||||
#### A3. Commit the release
|
||||
|
||||
Stage and commit `project.yaml`, `server/Cargo.toml`, and `CHANGELOG.md`:
|
||||
```
|
||||
chore(meta): release v0.1.N
|
||||
```
|
||||
|
||||
#### A4. Tag the release
|
||||
|
||||
```bash
|
||||
git tag v0.1.N
|
||||
```
|
||||
|
||||
#### A5. Push
|
||||
|
||||
```bash
|
||||
git push && git push --tags
|
||||
```
|
||||
|
||||
#### A6. Check for a planned sprint
|
||||
|
||||
After closing, re-query the database. If a sprint in `planning` status
|
||||
exists, continue to **Case B**. Otherwise, report the close and suggest
|
||||
running `/sprint-plan`.
|
||||
|
||||
---
|
||||
|
||||
### Case B: No active sprint, but a planned sprint exists
|
||||
|
||||
A sprint is ready to activate. Verify it looks complete:
|
||||
|
||||
1. Check that briefing files exist at `docs/sprints/sprint-N/`:
|
||||
```bash
|
||||
ls docs/sprints/sprint-N/
|
||||
```
|
||||
2. Check the ticket count:
|
||||
```bash
|
||||
db/connectors/sprint status --sprint N
|
||||
```
|
||||
|
||||
If briefings are missing or the sprint has 0 tickets, report the gap
|
||||
and suggest running `/sprint-plan` to complete planning.
|
||||
|
||||
If everything looks ready, activate the sprint:
|
||||
|
||||
```bash
|
||||
db/connectors/sprint start
|
||||
```
|
||||
|
||||
Then report:
|
||||
- Sprint activated (name, ticket count per team)
|
||||
- Remind the user to switch to a team branch and run `/sprint-start`
|
||||
there (or `cd` into the relevant worktree)
|
||||
|
||||
---
|
||||
|
||||
### Case C: No active sprint and no planned sprint
|
||||
|
||||
Nothing is ready. Report the state and suggest running `/sprint-plan`
|
||||
to plan the next sprint.
|
||||
|
||||
---
|
||||
|
||||
## Team branch workflow
|
||||
|
||||
### 2. Sync with main
|
||||
|
||||
```bash
|
||||
git fetch --all
|
||||
git merge origin/main --no-edit
|
||||
```
|
||||
|
||||
If the merge has conflicts, report them and stop — do not force-resolve.
|
||||
|
||||
### 3. Load sprint context
|
||||
|
||||
Run the sprint CLI to get the full context dump in one shot:
|
||||
|
||||
```bash
|
||||
db/connectors/sprint start-work
|
||||
```
|
||||
|
||||
This auto-detects the active sprint and current team from the branch.
|
||||
It outputs: sprint metadata, briefing paths, decision refs, actionable
|
||||
tickets, blocked tickets, and done tickets.
|
||||
|
||||
If no active sprint is found, report that and stop.
|
||||
|
||||
### 4. Read the sprint briefing
|
||||
|
||||
Read the briefing file(s) listed in the `start-work` output
|
||||
(e.g. `docs/sprints/sprint-6/server.md` and `joint.md`).
|
||||
If no matching briefing exists for the team, suggest running
|
||||
`/sprint-plan` to generate one.
|
||||
|
||||
### 5. Load ticket details
|
||||
|
||||
For tickets that need more detail than the `start-work` summary provides:
|
||||
```bash
|
||||
db/connectors/ticket show <id>
|
||||
```
|
||||
|
||||
### 6. Read key decisions
|
||||
|
||||
Read the decision files referenced in the sprint briefing so the agent has
|
||||
full architectural context before starting work.
|
||||
|
||||
### 7. Mark tickets in progress and present the work plan
|
||||
|
||||
Mark all actionable (unblocked, non-done) tickets in the sprint as
|
||||
`in_progress`:
|
||||
|
||||
```bash
|
||||
db/connectors/ticket status <id> in_progress
|
||||
```
|
||||
|
||||
Then output a summary:
|
||||
- Sprint name and goal
|
||||
- Branch status (clean merge or conflicts)
|
||||
- Tickets marked in_progress (list with IDs)
|
||||
- Blocked tickets (and what blocks them)
|
||||
- Key decisions loaded
|
||||
- Suggested first task (lowest ID unblocked ticket)
|
||||
|
||||
### 8. Confirm and spawn the team
|
||||
|
||||
Before spawning agents, use `AskUserQuestion` to confirm the work plan and
|
||||
agent lineup with the user. If declined, stop.
|
||||
|
||||
Once confirmed:
|
||||
|
||||
#### 8a. Parse agents from the briefing
|
||||
|
||||
Extract agent names from the `**Agents:**` line. Format:
|
||||
```
|
||||
**Agents:** Name (role), Name (role), ...
|
||||
```
|
||||
|
||||
Map each name to its `subagent_type` (lowercase):
|
||||
- "Dudley (simulation)" → `dudley`
|
||||
- "Stig (UI)" → `stig`
|
||||
- "Tyre (architecture)" → `tyre`
|
||||
- "Hoshe (QA)" → `hoshe`
|
||||
- "Mellanie (author)" → `mellanie`
|
||||
- etc. (see `.claude/agents/` for full roster)
|
||||
|
||||
#### 8b. Create the team
|
||||
|
||||
```
|
||||
TeamCreate(team_name: "sprint-{N}-{team}")
|
||||
```
|
||||
|
||||
This makes you the team lead.
|
||||
|
||||
#### 8c. Create tasks from tickets
|
||||
|
||||
For each ticket in the briefing, create a task:
|
||||
|
||||
```
|
||||
TaskCreate(
|
||||
subject: "#{id}: {title}",
|
||||
description: "Full ticket details from step 5, plus briefing notes
|
||||
and integration points for this ticket.",
|
||||
activeForm: "Working on #{id}: {short_title}"
|
||||
)
|
||||
```
|
||||
|
||||
After creating all tasks, mirror the dependency chain from the briefing
|
||||
using `TaskUpdate` with `addBlockedBy`.
|
||||
|
||||
#### 8d. Spawn agents
|
||||
|
||||
For each agent from the `**Agents:**` line, spawn a teammate in the
|
||||
background. Spawn all agents in parallel (one message, multiple Task calls):
|
||||
|
||||
```
|
||||
Task(
|
||||
subagent_type: "{name_lowercase}",
|
||||
team_name: "sprint-{N}-{team}",
|
||||
name: "{name_lowercase}",
|
||||
prompt: "You are on the {team} team for Sprint {N}.
|
||||
Branch: `{team}`
|
||||
|
||||
RULES:
|
||||
- GIT: Do NOT run any git commands (commit, push, pull, merge,
|
||||
checkout, branch, stash, tag, etc.). All git operations are
|
||||
handled by the team lead.
|
||||
- DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use
|
||||
the exact command with no wrappers or chaining. Examples:
|
||||
db/connectors/ticket show 528
|
||||
db/connectors/ticket list --sprint {N}
|
||||
Do NOT prepend python3, do NOT chain with && or ;, do NOT
|
||||
add cleanup commands. Just the bare command.
|
||||
|
||||
1. Read the sprint briefing: docs/sprints/sprint-{N}/{team}.md
|
||||
2. Read the decision files referenced in the briefing.
|
||||
3. Check TaskList for available work.
|
||||
4. Claim an unblocked task (TaskUpdate with owner: your name),
|
||||
mark it in_progress, and implement it.
|
||||
5. When done, mark the task completed and check TaskList for
|
||||
the next available task.
|
||||
|
||||
Use `db/connectors/ticket show <id>` for full ticket specs.",
|
||||
description: "Sprint {N} {team}: {name}",
|
||||
run_in_background: true
|
||||
)
|
||||
```
|
||||
|
||||
#### 8e. Report
|
||||
|
||||
Output to the user:
|
||||
- Team name: `sprint-{N}-{team}`
|
||||
- Agents spawned (names and roles)
|
||||
- Tasks created (count actionable vs blocked)
|
||||
- How to interact: `SendMessage` to talk to agents, `TaskList` to
|
||||
check progress
|
||||
|
||||
You are now the team lead. Agents work autonomously — monitor via
|
||||
`TaskList`, communicate via `SendMessage`, and handle blockers as
|
||||
they arise.
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: render-sprite
|
||||
name: sprite-gen
|
||||
description: >
|
||||
Render a 3D model to 2D sprites via the Godot render pipeline. Produces
|
||||
sprites at 3 resolutions (1024, 256, 64) from 4 cardinal directions (north,
|
||||
east, south, west) with outline applied at working resolution. Use when the
|
||||
user says "render sprite", "render model", "run the render pipeline",
|
||||
"test the pipeline", "/render-sprite", or asks to render a specific model
|
||||
"test the pipeline", "/sprite-gen", or asks to render a specific model
|
||||
(e.g., "render wall_structural"). Output: 12 PNG files in renderer/output/.
|
||||
---
|
||||
|
||||
@@ -14,7 +14,7 @@ description: >
|
||||
Run the render script with the model name:
|
||||
|
||||
```bash
|
||||
.claude/skills/render-sprite/scripts/render.sh <model_name>
|
||||
.claude/skills/sprite-gen/scripts/render.sh <model_name>
|
||||
```
|
||||
|
||||
### Available Models
|
||||
@@ -52,4 +52,4 @@ ls renderer/models/*.tscn | xargs -I{} basename {} .tscn
|
||||
|
||||
1. Create model scene at `renderer/models/<name>.tscn`
|
||||
2. Apply texture from `renderer/textures/` via StandardMaterial3D
|
||||
3. Run: `.claude/skills/render-sprite/scripts/render.sh <name>`
|
||||
3. Run: `.claude/skills/sprite-gen/scripts/render.sh <name>`
|
||||
@@ -53,6 +53,9 @@ db/connectors/ticket sprint [--active]
|
||||
db/connectors/ticket sprint assign <id> <sprint_id>
|
||||
```
|
||||
|
||||
For sprint-scoped operations (status overview, context dumps, lifecycle),
|
||||
use the dedicated sprint CLI instead: `db/connectors/sprint --help`
|
||||
|
||||
### Dependencies
|
||||
```bash
|
||||
db/connectors/ticket deps <id>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
name: workshop-start
|
||||
description: >
|
||||
Start a multi-agent design workshop from a workshop brief. Use when the user says
|
||||
"start workshop", "run workshop", "let's start the workshop", or invokes /workshop-start.
|
||||
Parses the workshop brief to extract participants, questions, and round format.
|
||||
Creates a team, tasks, and spawns agents as teammates via the Task tool.
|
||||
---
|
||||
|
||||
# Start Workshop
|
||||
|
||||
## Prerequisites
|
||||
|
||||
A workshop brief must exist at `docs/workshops/{name}/{name}-workshop-brief.md` containing:
|
||||
- **Participants** line (comma-separated agent names)
|
||||
- **Questions for participants** sections with numbered questions tagged by agent name
|
||||
- **Workshop Format** section defining number of rounds and their purpose
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Parse the Brief
|
||||
|
||||
Read the workshop brief. Extract:
|
||||
- Workshop name (from directory name)
|
||||
- Participant list (from `**Participants:**` line)
|
||||
- Per-participant questions (scan for `**{AgentName}**:` patterns in questions sections)
|
||||
- Round count and round descriptions (from `**Workshop Format**` section)
|
||||
|
||||
### 2. Create Team
|
||||
|
||||
```
|
||||
TeamCreate: team_name = "{workshop-name}", description from brief title
|
||||
```
|
||||
|
||||
### 3. Always-Present Agents
|
||||
|
||||
Two agents join every workshop regardless of the participant list:
|
||||
|
||||
| Agent | Role | Task | Participates in discussion? |
|
||||
|-------|------|------|-----------------------------|
|
||||
| **Qatux** | Documenter | Captures all decisions, questions, dissent, consensus. Writes `workshop-notes.md` per round, produces final `workshop-outcomes.md`. | No — observes and records only |
|
||||
| **SI** | Sprint prep | Suggest adding when outputs include tickets. Creates tickets from decisions, links to sprint backlog. | No — execution prep only |
|
||||
|
||||
Qatux and SI are **never dismissed early.** If the user reduces the team mid-workshop, keep qatux and si (if added). Documenting everything prevents loss of valuable information.
|
||||
|
||||
### 4. Create Tasks (Round 1)
|
||||
|
||||
One task per participant containing:
|
||||
- Instruction to read the full brief at its path
|
||||
- The specific questions assigned to that participant (extracted from all layers)
|
||||
- The output format from the brief's round description
|
||||
- **IMPORTANT — file output requirement:** Instruct each agent to write their full output to `docs/workshops/{name}/{agent}-round{N}.md` (e.g., `docs/workshops/test-architecture/gestalt-round1.md`). Agents must write to disk, not just send messages. This ensures Qatux and other agents can read all outputs directly without relying on message forwarding.
|
||||
|
||||
One task for Qatux: "Document Round N — read all agent output files at `docs/workshops/{name}/*-round{N}.md` and capture decisions, questions, and dissent."
|
||||
|
||||
Assign all tasks using TaskUpdate with `owner` = agent name.
|
||||
|
||||
### 5. Spawn Agents
|
||||
|
||||
Use the Task tool to spawn each agent as a teammate. Each call should:
|
||||
- Set `team_name` to the workshop team name
|
||||
- Set `name` to the agent name (e.g., "gestalt")
|
||||
- Set `subagent_type` to the matching agent type (same as name — see reference table)
|
||||
- Provide a prompt telling the agent to check TaskList for their assigned task
|
||||
|
||||
Spawn all agents in parallel (one Task call per agent in a single message). Agents will appear as teammates in the Claude Code UI and pick up their tasks from the shared task list.
|
||||
|
||||
For large workshops (>6 agents), spawn participants in batches to avoid overwhelming the system. Always-present agents (Qatux, SI) can run in background via `run_in_background: true`.
|
||||
|
||||
### 6. Monitor
|
||||
|
||||
- TaskList to check progress
|
||||
- SendMessage to nudge idle agents or provide clarification
|
||||
- Agents work autonomously — claim tasks, read the brief, produce responses
|
||||
|
||||
### 7. Between Rounds
|
||||
|
||||
When all Round N tasks are complete:
|
||||
1. Verify all agents wrote output files to `docs/workshops/{name}/`. If any are missing, nudge the agent or extract from their message and write the file yourself.
|
||||
2. Qatux reads all `*-round{N}.md` files and produces round summary in `round-{N}-notes.md`
|
||||
3. Create Round N+1 tasks (integration pass, synthesis, etc.) — include the same file output requirement
|
||||
4. Assign to agents with TaskUpdate
|
||||
5. Agents continue working
|
||||
|
||||
### 8. Wrap Up
|
||||
|
||||
**Always ask the user before wrapping up.** There may be more to discuss or additional rounds needed. Only proceed to wrap-up when the user confirms.
|
||||
|
||||
Wrap-up sequence:
|
||||
1. Qatux produces final `workshop-outcomes.md` from accumulated notes
|
||||
2. If SI is present, SI creates tickets from decided items
|
||||
3. Send shutdown_request to all agents (qatux and si last, after they finish their output tasks)
|
||||
4. TeamDelete to clean up
|
||||
|
||||
## Agent Type Reference
|
||||
|
||||
Agent name maps directly to subagent_type:
|
||||
|
||||
| Name | Domain | Typical workshop role |
|
||||
|------|--------|-----------------------|
|
||||
| gestalt | Systems design | Mechanics, fun factor, system interactions |
|
||||
| ozzie | Player experience | Wow moments, feel, emotional response |
|
||||
| tyre | Technical architect | Feasibility, performance, architecture |
|
||||
| stig | UI developer | Interface patterns, layout, interaction flows |
|
||||
| dudley | Server developer | Server-side systems, data flow, ECS |
|
||||
| paula | Narrative | Story, dialogue, character, political depth |
|
||||
| araminta | Visual design | Art direction, visual treatment, aesthetics |
|
||||
| nigel | Replayability | Emergent stories, second-playthrough, sandbox |
|
||||
| gore | Themes/endgame | What the game is about, philosophical questions |
|
||||
| mellanie | Copywriter | In-game text, voice, tone |
|
||||
| miri | Worldbuilder | Setting, factions, lore, consistency |
|
||||
| inigo | Sound design | Audio, soundscape, spatial audio |
|
||||
| troblum | Tech consultant | Second opinion on architecture/tech choices |
|
||||
| hoshe | QA/testing | Test plans, verification, quality |
|
||||
@@ -44,10 +44,16 @@ Branch determines the mode: `main` → outbound sync, anything else → inbound
|
||||
git fetch --all
|
||||
```
|
||||
|
||||
Discover all worktree branches (excluding `main` itself):
|
||||
|
||||
```bash
|
||||
git worktree list | grep -v '\[main\]' | sed 's/.*\[//;s/\]//'
|
||||
```
|
||||
|
||||
For each worktree branch, check if it has commits ahead of main:
|
||||
|
||||
```bash
|
||||
git rev-list --count main..<branch>
|
||||
git rev-list --count main..origin/<branch>
|
||||
```
|
||||
|
||||
Skip branches with 0 commits ahead. For branches that ARE ahead, collect:
|
||||
@@ -67,7 +73,7 @@ Cross-reference open PR head branches with the ahead-of-main branches.
|
||||
|
||||
Show a summary table of branches ahead of main. For each branch, indicate:
|
||||
- `[PR]` if it has an open pull request — warn that it should go through
|
||||
normal review channels (use `/review-pr` instead)
|
||||
normal review channels (use `/pr-review` instead)
|
||||
- Commit count and summary
|
||||
|
||||
Use `AskUserQuestion` to let the user pick which branches to merge.
|
||||
@@ -110,12 +116,10 @@ git merge origin/main --no-edit
|
||||
If clean, report the result (fast-forward or merge commit, files changed).
|
||||
If conflicts, report them and stop.
|
||||
|
||||
### 3. Push prompt
|
||||
### 3. Push
|
||||
|
||||
After a successful merge, ask the user if they want to push:
|
||||
After a successful merge, push the branch:
|
||||
|
||||
```bash
|
||||
git push origin <current-branch>
|
||||
```
|
||||
|
||||
Never push without explicit confirmation.
|
||||
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pre-commit hook dispatcher. Runs modular checks from tooling/.
|
||||
# Installed via: git config core.hooksPath .config/hooks
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
ERRORS=0
|
||||
|
||||
run_check() {
|
||||
local script="$1"
|
||||
local label="$2"
|
||||
if [ -x "$REPO_ROOT/$script" ]; then
|
||||
if ! "$REPO_ROOT/$script"; then
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
else
|
||||
echo "pre-commit: WARNING — $label skipped ($script not found or not executable)"
|
||||
echo " Run 'make setup' or check that $script exists and is executable."
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Checks ---
|
||||
run_check "tooling/check-fact-ids" "fact_id validation"
|
||||
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "pre-commit: $ERRORS check(s) failed. Commit aborted."
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,6 +1,11 @@
|
||||
# Build and cache
|
||||
.cache/
|
||||
.tmp/
|
||||
server/target/
|
||||
tooling/content-converter/target/
|
||||
tooling/line-previewer/target/
|
||||
tooling/test-client/target/
|
||||
content-ron/
|
||||
|
||||
# Godot client (further ignores managed by client team)
|
||||
client/.godot/
|
||||
|
||||
+446
@@ -6,16 +6,462 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.1.13] — 2026-02-20
|
||||
|
||||
### Added
|
||||
- D-078: Overheard NPC conversation — passive dialogue panel with server-authoritative stochastic word occlusion
|
||||
- Sprint 14 "Live" briefings — 22 tickets across server (7), client (3), copy (6), visual (6)
|
||||
|
||||
## [v0.1.12] — 2026-02-19
|
||||
|
||||
### Added
|
||||
- Tier marker components (#93), active tier simulation (#94), tier transition logic (#99)
|
||||
- Information tag schema (#138), component-level access control (#139)
|
||||
- Line previewer CLI (#193)
|
||||
- Sound event system — server pipeline (#124)
|
||||
- Close-range stereo audio — client positional 2D (#125)
|
||||
- Medium-range visual indicators — fog-edge directional arrows (#126)
|
||||
- HashMap ban in simulation crate via clippy (#343)
|
||||
- Tracing crate infrastructure — JSON format, tick duration logging (#344)
|
||||
- System dependency graph debug command — `--dump-schedule` CLI flag (#346)
|
||||
- rng_seed field on ObserverSnapshot for deterministic replay (#527)
|
||||
- v0.1 Visual Grammar Document (#303)
|
||||
- Placeholder art specification (#252)
|
||||
- Spatial layouts: Logistics Hub (#311), Bar (#312), Smuggling corridors (#313)
|
||||
- Cultural generation guide — 5-dimension framework for Sova Transit District cultural voice (#189)
|
||||
- Sova Texture Appendix — 20-term slang glossary, sensory profile, Meridian self-censorship rules (#302)
|
||||
- Contraband specification — unlicensed lattice components, supply chain, street terminology (#321)
|
||||
- Sova Station Profile — 6 districts, governance, off-station references (#320)
|
||||
- Span Gate Transit Schedule — hourly schedule, maintenance windows, ring operational calendar (#336)
|
||||
- Meridian Coverage Map — 10 named zones from Commission-grade to dead air (#335)
|
||||
- Character definition schema and both character builds — smuggler + detective (#179, #180, #181)
|
||||
- Divergent starting knowledge and relationships per character (#182, #183)
|
||||
- Detective institutional chain of command (#322)
|
||||
- Contradiction arc design document — reusable FRIEND pattern (#332)
|
||||
- Mirror moment design document — 7 core dual-perspective observation triggers (#329)
|
||||
- First 5 minutes experience design — systemic opening per character (#259)
|
||||
- Opening hook content per character (#260)
|
||||
- Knowledge vocabulary for v0.1 content — entity/world categories, prerequisite format (#368)
|
||||
- Knowledge state vocabulary — author-facing quick reference (#309)
|
||||
- Knowledge fact catalogs — 10 YAML files in content/global/knowledge/, 73 canonical facts
|
||||
- D-075 endorsement — archetype dimension review recorded in decisions/content.md
|
||||
- Flat NPC memorable trait pass — Pael, Ren, Tev with noise-floor profiles (#307)
|
||||
- Environmental text content — 20 items across Terminal, Bar, and Corridors with dual-lens notes (#262)
|
||||
- Diegetic insert flavor text — per-character labels and notification strings (#331)
|
||||
- News ticker / Meridian feed — 30 lines including batch 44xx recall dual-lens moment (#306)
|
||||
- Workplace content pack — The Terminal: 5 NPC dialogue files (#190)
|
||||
- Bar content pack — The Last Shift: 3 NPC dialogue files (#191)
|
||||
- Smuggling ring content pack — maintenance corridors: coded vocabulary, dual registers (#192)
|
||||
- Generation pass expansion — 80 ambient variant lines across all 9 dialogue files (#194)
|
||||
- Sprint 13 "Sound" briefings — 9 tickets across server, client, audio, visual teams; full audio architecture + gauntlet expansion + monologue display spec
|
||||
|
||||
### Fixed
|
||||
- Entity renderer field name bug — `id` vs `entity_id` (#345)
|
||||
- Dialogue max-width pixel value — 640px per D-076 (#447)
|
||||
- Routine tests missing ActiveSim — 3 of 5 tests passed trivially without the required tier marker
|
||||
- `_observer_pos` misleading unused prefix renamed to `observer_pos` (used for sound event filtering)
|
||||
- Stale protocol version doc comment "Current: 9" corrected to 10
|
||||
- FactionOnly non-numeric `faction_id` attribute now logs a tracing::warn instead of silently denying
|
||||
- SOUND_EVENT_ASSETS walk-speed key mismatch — `sfx_footstep_metal` corrected to `sfx_footstep_metal_walk`
|
||||
|
||||
### Changed
|
||||
- Removed orphaned `SimulationTier`/`LastInteraction`/`ScopeTag`/`ScopeKind` types from tier.rs (unused outside own tests)
|
||||
- Sound pipeline documented as intentionally empty in v0.1 (no producers yet, full pipeline wired)
|
||||
- Observer test setup now inserts SoundEventQueue resource for integration coverage
|
||||
- Added FactionOnly positive test case and Medium-range occlusion TODO
|
||||
- Sound indicator colors sourced from Constants instead of duplicated hex literals
|
||||
- play_loop() null guard on stream.duplicate()
|
||||
- Camera zoom fallback uses Constants.CAMERA_DEFAULT_ZOOM
|
||||
|
||||
## [v0.1.11] — 2026-02-19
|
||||
|
||||
### Added
|
||||
- Sprint 12 "Build" briefings — 50 tickets across server, client, copy, visual, ci teams; production-layer foundations + all v0.1 copy authoring
|
||||
- `.tmp/` gitignored repo directory for agent temp files — avoids Bash permission prompts during PR review comment posting
|
||||
- `sed -n` blanket permission in shared settings
|
||||
|
||||
### Changed
|
||||
- All skills renamed to domain-action convention (e.g. `commit`→`git-commit`, `review-pr`→`pr-review`, `gen-audio`→`audio-gen`, `render-sprite`→`sprite-gen`) — 12 renames total
|
||||
- `pr-review` skill uses Write tool into `.tmp/` instead of Bash heredocs to `/tmp/`
|
||||
|
||||
## [v0.1.10] — 2026-02-19
|
||||
|
||||
### Added
|
||||
- `project.yaml` — technical project descriptor with version, architecture, simulation, and content model as the canonical version source of truth
|
||||
- Scratchpad: asset generation pipeline idea (registry, status tracking, prompt versioning, pre-sprint cohesion)
|
||||
- Scratchpad: remote terminal proxy idea for mobile monitoring of Claude Code permission prompts and interactive elements
|
||||
- `make perf-baseline` — full plugin stack tick benchmark (50 measured ticks, 5 warmup) capturing per-tick timing, entity counts, process RSS, and shadowcast benchmarks; outputs structured JSON to `tests/perf/baseline.json` with `--compare` mode for regression detection (>20% threshold, D-026 budget check)
|
||||
- Michroma font integration (#517) — Michroma-Regular.ttf as game font with +1px tracking FontVariation, global Theme with cyan-white (#E0F7FA) implant text color, IMPLANT_TEXT_COLOR/DIM/PULSE constants
|
||||
- Mouse-relative facing and movement (#526, D-054) — mouse position determines facing direction (client-side float), WASD remapped to cursor-relative (W=toward, S=away, A/D=strafe), SET_FACING action sends octant to server, smooth facing indicator rotation
|
||||
- Room reset client UX (#502) — amber reset_plate tile type, 0.15s screen flash on room reset, 'Reset Room' interaction verb
|
||||
- Auto-checklist progress tracking (#503) — ChecklistEvaluator parses room YAML and evaluates 7 condition types against GameState with latching, ChecklistOverlay renders progress in gauntlet mode only, 48 new tests
|
||||
- 4 ambient zone loops: station base, workplace, bar, corridor — SAO-generated organic soundscape with crossfade loop points (#327)
|
||||
- 2 footstep SFX: metal walk and run — SAO hybrid with best-transient extraction (#327)
|
||||
- `audio-batch` command — batch audio generation from JSON manifests, supports SAO and harmonic synthesis, with `--dry-run`, `--only`, and `--skip-existing` flags
|
||||
- `--post` and `--output-ogg` flags on `audio-generate` — chain post-processing (trim, normalize, convert) into a single command
|
||||
|
||||
### Changed
|
||||
- `push-pr` skill now runs `/commit` first when uncommitted changes are detected
|
||||
- Insert open/close now sends explicit PauseSimulation/ResumeSimulation (#518, D-058) — replaces toggle-style pause with idempotent pair
|
||||
- Interaction list colors reference Constants.IMPLANT_TEXT_COLOR instead of hardcoded values
|
||||
- World radial menu uses theme font instead of ThemeDB.fallback_font
|
||||
- Monologue chimes replaced with production-quality manual synthesis — insert-tech aesthetic per D-074, pure sine harmonics with mathematical envelopes (#327)
|
||||
|
||||
### Fixed
|
||||
- Bidirectional relationship check (#515) — Check 9 tested `target in npc_rels` which missed NPCs with no relationship entries; changed to `target in self.npcs`
|
||||
|
||||
## [v0.1.9] — 2026-02-18
|
||||
|
||||
### Fixed
|
||||
- `make game` now builds client before launching — was missing `build-client` dependency, causing class_name registration failures after `make clean`
|
||||
- `build-client` uses `--import --quit` instead of just `--quit` — ensures `.godot/` cache and `global_script_class_cache.cfg` are created from scratch
|
||||
- `make clean` preserves `client/.godot/` directory (clears contents only) to avoid Godot startup issues
|
||||
|
||||
### Added
|
||||
- `--description TEXT` flag for `ticket create` CLI — previously required raw SQL workaround to set ticket descriptions
|
||||
|
||||
### Changed
|
||||
- UI audio assets revised — monologue chimes re-generated (0.8s, insert-tech aesthetic), fog_recognition re-generated (was silent), all 8 assets normalized to 44.1kHz stereo LUFS -16 (#453)
|
||||
- review-pr skill: explicit verdict rules — critical/warning → REQUEST_CHANGES, suggestion-only → APPROVE
|
||||
|
||||
### Added
|
||||
- `make golden-diff` + `make golden-update` targets (#486) — developer workflow for golden file comparison and regeneration; safe restore on cargo failure
|
||||
- Gauntlet checklist YAML schema (#497) — 7 condition types evaluable from ObserverSnapshot, per-room checklists for 3 rooms, `make checklist-validate` and `make checklist-generate` targets, wired into `pre-pr-content` gate
|
||||
- Gauntlet room timer + personal bests (#496) — GauntletHUD shows TIMER: MM:SS (PB: MM:SS), starts on room entry, resets on room change, persists stats to user://dev/gauntlet-stats.json, session summary on disconnect, hidden in non-gauntlet mode
|
||||
- WRONG button F12 MVP (#495) — bug report capture: pause sim, show modal prompt, save snapshot.json + render.txt + description.txt to user://bug-reports/, Esc to cancel
|
||||
- GameState.room_id and gauntlet_mode fields — parsed from ObserverSnapshot, enabling gauntlet UI
|
||||
- BUG_REPORT action in InputMapper (F12 binding) with SimBridge wire guard (client-only)
|
||||
- 24 new anti-tedium tests — GauntletHUD lifecycle (16: timer, PB, visibility, room change, session tracking) + BugReportDialog (8: pause/unpause, wire guard, text render, edge cases)
|
||||
- whatsinagame starter kit — reusable multi-agent team bootstrap for any project (3-tier profiles, 18 skills, 16 agent archetypes, stakeholder personas, ticketing DB, decision tracking)
|
||||
- Domain-action naming convention for skills documented in create-skill guide
|
||||
- `gauntlet` feature flag (default-on) — allows stripping Gauntlet test world from release builds with `--no-default-features`
|
||||
- `EXPECTED_ENTITY_COUNT` and `RESET_PLATE_STABLE_IDS` constants — entity counts derived from StableId ranges instead of hardcoded values
|
||||
- Debounce exact-boundary test for room reset (tick 9 rejected, tick 10 accepted)
|
||||
- Reset plate StableId verification in `stable_id_ranges_match_spec`
|
||||
- Runtime content test (`content_runtime.rs`) separated from structural loading tests
|
||||
- Client P2 tests (#492) — 16 gdUnit4 tests for camera (smoothing, zoom, viewport, follow, no-pan), entity alpha/color (peripheral, forward, NPC color, player constant), UI (monologue, interaction, inventory, dialogue, pause, fog blob, fog z_index)
|
||||
- Client P3 tests (#493) — 12 gdUnit4 tests for z-layer ordering (floor/ysort/fog/UI), entity lerp (snap, converge, LERP_SPEED=12.0), Tyre additions (recognition, facing, delta scaling, blob removal)
|
||||
- Anti-tedium regression tests (#494) — 5 tests: F12 no-crash guard, no queued input, gauntlet UI hidden in default/normal snapshot/multi-tick modes
|
||||
|
||||
### Changed
|
||||
- Room reset API consolidated to `plan_reset` only — `execute_reset` removed (was a maintenance trap; production uses Commands via `plan_reset`)
|
||||
- `room_at()` documented with z-range and corridor overlap assumptions
|
||||
- TCP runtime test now has 10s read timeout and registers player in EntityRegistry
|
||||
|
||||
### Removed
|
||||
- Dead `RoomMember` component from reset.rs (defined but never used)
|
||||
- Protocol v8: dialogue_response field decoding (DialogueResponseEvent with line_id, text, speaker_entity_id) from server #305/D-028
|
||||
- Test client binary scaffolding (#480) — standalone crate at `tooling/test-client/` with CLI (--connect, --replay, --text, --json, --quiet, --golden, --ticks), exit codes (0/1/2), golden file JSON diff, JSONL replay loader
|
||||
- Snapshot text renderer (#481) — `format_snapshot_text(&ObserverSnapshot)` pub-exported from server crate, entity labels as kind:entity_id sorted by distance, room name stub, 10 unit tests
|
||||
- Sprint 9 (Gauntlet) briefing files — server, client, CI, audio, joint — 23 tickets across 4 teams
|
||||
- Weapon aim lock audio (`sfx_weapon_aim_lock.ogg`) — clinical targeting confirmation tone for weapon aim state (#440)
|
||||
- Stance change audio (`sfx_stance_change.ogg`) — subtle mechanical click for stance toggle feedback (#440)
|
||||
|
||||
### Fixed
|
||||
- MessagePack int_64 encoder dead code branch (#516) — `-(1 << 63)` overflowed making int_64 branch unreachable; negative values beyond int_32 now correctly encode as 0xd3 instead of 0xcf
|
||||
- Cross-encoder fixture pipeline hardened — encode failures now exit non-zero instead of writing empty .msgpack files (#475 review)
|
||||
- GDScript fixture test no longer silently skips on missing/empty fixture dir — asserts instead (#475 review)
|
||||
- GDScript fixture generator covers all PlayerAction variants (added MoveSouth, MoveEast, MoveWest, Unpause, ToggleStanceDown, WalkAway)
|
||||
- `make pre-pr` now checks GDScript fixture staleness alongside Rust fixtures
|
||||
- Protocol version bumped from 7 to 8 to match server — fixes 5 test failures from version mismatch
|
||||
- Interact action encoding changed from unit variant to struct variant to match server's PlayerAction::Interact { target_entity_id, verb }
|
||||
- Monologue duplication test (test_monologue_not_duplicated_after_consumption) fixed — was using poll_snapshot() which doesn't consume _last_snapshot in test mode
|
||||
- `make pre-pr` target — full pre-PR verification chain: lint → build → test → content validation → fixture staleness (#460, #465)
|
||||
- Branch-specific pre-PR variants: `make pre-pr-server`, `make pre-pr-client`, `make pre-pr-content`
|
||||
- Content cross-reference validation (9 checks) — canonical_id uniqueness, relationship targets, location slugs, dialogue locations, fact_ids, triangle membership, npc_count, dialogue line_ids, bidirectional relationships (#464)
|
||||
|
||||
### Fixed
|
||||
- Dialogue schema missing `focused` (mood) and `greeting` (situation) values added in Sprint 7-8 content
|
||||
- Speaker wire ID silent fallback — dialogue now warns and skips when target entity missing from registry (was silently using 0)
|
||||
- Cross-plugin system ordering — trigger_recognition_monologue now runs after detect_anomalies (latent determinism bug)
|
||||
- Walk-away ordering — process_walk_away now runs after process_talk_interaction (prevents same-tick race)
|
||||
- ActiveDialogue overwrite — new Talk while in existing dialogue now emits IncompleteInteraction before replacing
|
||||
- Server-side Talk range check — handle_talk now enforces CLOSE_RANGE before setting TalkRequest (was client-only)
|
||||
- ExamineNpc label collision — VerbKind::ExamineNpc now uses "Examine NPC" label (was "Observe", same as generic Observe)
|
||||
- Dead conditional in main.rs collapsed (both branches were identical)
|
||||
- WalkAway variant added to all_player_action_variants_roundtrip serialization test
|
||||
|
||||
### Changed
|
||||
- DialogueCooldownTracker.used changed from Vec to BTreeMap for O(log n) lookup (D-041 compliance)
|
||||
- MonologueState.shown_ids changed from Vec to HashSet for O(1) contains check (was O(n) per tick)
|
||||
- Secret trust tier documented as unreachable with TODO for Phase 2 KG-gated unlock
|
||||
|
||||
### Added
|
||||
- Determinism test: different_seed_produces_different_replay — exercises SimRng via dialogue weighted selection
|
||||
- Dialogue selection pipeline (#305, D-028) — 4-layer filtering engine: access tier from KG relationship, situation derivation from game state, trust tier, weighted topic+mood scoring via SimRng. Full Talk verb → selected line → ObserverSnapshot pipeline with cooldown tracking
|
||||
- ContentSlug component (#452) — stable content identity from YAML (e.g. "kael-davan") independent of runtime Entity handles, for knowledge graph interaction memory across save/load
|
||||
- Walk-away KG recording (#427, D-064) — IncompleteInteraction knowledge events with Talk/Confront type, ActiveDialogue tracking, walk-away detection clears dialogue and records in KG
|
||||
- Anomaly detection for urgent recognition (#450, D-060) — AnomalyMarker flags PersonOfInterest/Contradicted entities for 0.3s cognitive delay instead of 0.6s normal
|
||||
- Recognition monologue during cognitive delay (#451, D-060) — monologue fires at delay START (grey blob phase), not completion. v0.1 fallback lines, anomaly prioritization, cooldown tracking
|
||||
- Server --test-mode, --port, --seed CLI flags (#459) — LISTENING:{port} stdout signal, OS-assigned ports, deterministic seed override, stderr-only tracing
|
||||
- Determinism gauntlet test (#466) — 20-tick replay determinism regression test with movement, stance, pause/unpause exercise
|
||||
- Pause guard test suite (#461-463, #468) — 7 tests covering movement, unpause, roundtrip, stance, interact, batch, tick_rate during pause
|
||||
- EntityRegistry lifecycle tests (#469) — stale mapping, re-register, unknown unregister edge cases
|
||||
- Boundary value encode/roundtrip tests (#471) — 41 values across all MessagePack integer format boundaries
|
||||
- Encoding asymmetry tests (#473) — Rust decoder accepts GDScript-style signed encodings for unsigned fields
|
||||
- Boundary fixture generation (#472) — 14 raw + 5 snapshot fixtures at integer format boundaries
|
||||
- Malformed batch rejection test (#479) — truncated, garbage, and mixed payloads rejected atomically
|
||||
- Per-fix determinism unit tests (#467) — equidistant NPC ordering, visible tile sorting, same-tile mover resolution
|
||||
|
||||
### Fixed
|
||||
- Determinism: visible_ids HashSet → BTreeSet for stable iteration order (#456)
|
||||
- Determinism: visible entities in snapshot sorted by entity_id (#457)
|
||||
- Determinism: movers sorted by Entity bits in validate_movement (#458)
|
||||
- Pause guard blocks all actions except Pause/Unpause while paused (previously only blocked movement)
|
||||
|
||||
### Changed
|
||||
- Protocol version bumped from v7 to v8 (dialogue_response field in ObserverSnapshot)
|
||||
|
||||
### Added
|
||||
- AudioManager autoload (#255, D-068/D-069/D-073) — 5-bus architecture (Music, Ambient, WorldSFX, PlayerActions, UISounds), directory-scan asset registry, spatial/non-spatial playback, audio dip profiles (dialogue, confrontation, listening_focus) with low-pass filter sweep, zone crossfade stub
|
||||
- Dialogue response selection (#435, D-061/D-062) — structured options with response_id, priority sorting, max 3 visible, invisible locked options, RichTextLabel for BBCode support
|
||||
- Walk-away mechanic (#437, D-064) — WASD triggers WalkAway input during dialogue, 300ms fade, dialogue_active flag gates movement, re-show on server interrupt
|
||||
- Confrontation text styling (#436, D-063) — italic first-person options, 1.5s monologue beat with dialogue dim to 70%, audio dip via AudioManager, walk-away cancels in-flight beat
|
||||
- MessagePack boundary value tests (#470) — 41 values (25 positive, 16 negative), encode-only header verification, roundtrip, Rust-style unsigned decode overlap tests
|
||||
- Client P0 regression tests (#477) — monologue carry-forward (Bug #5), camera stability during pause (Bug #2)
|
||||
- Client P1 tests (#478) — fog shader state (4), entity lifecycle (2), pending recognition blob (1)
|
||||
|
||||
### Changed
|
||||
- Fog byte magic numbers replaced with named constants (#476) — VIS_HIDDEN/PERIPHERAL/FORWARD, EXP_UNEXPLORED/EXPLORED/VISIBLE in FogState
|
||||
- Dialogue options now carry structured {text, response_id, priority, confrontation} instead of plain strings
|
||||
- Protocol v7 dialogue decode validates and skips malformed options
|
||||
|
||||
### Added
|
||||
- QA test architecture workshop complete — 3-round, 7-agent workshop producing 60 tickets (epic #455): Gauntlet test world (7 rooms, 48 entities), test client binary (tooling/test-client/), determinism fixes, content validation, make pre-pr pipeline, 38 client tests, anti-tedium features, human tester workflow
|
||||
- Workshop skill updated — agents now write output files to disk instead of sending messages, fixing documenter access
|
||||
- Camera anchor test suite — 10 gdUnit4 tests verifying camera init, smoothing cycle, and player tracking
|
||||
- Entity position lerping — framerate-independent exponential smoothing so entities slide between tiles instead of snapping
|
||||
|
||||
### Fixed
|
||||
- Server never sends snapshots — blocking TCP read in receive_bridge_inputs stalled the entire bevy Update schedule; switched to non-blocking I/O with WouldBlock handling
|
||||
- Camera doesn't center on player at startup — Camera2D smoothed_camera_pos starts at (0,0); now disable smoothing during init, snap to player, re-enable after first anchored frame
|
||||
- Player moves while game is paused — movement commands now discarded when TickRate is Paused (pause/unpause still process)
|
||||
- Spacebar only pauses, doesn't toggle — added UNPAUSE action with toggle logic based on tick_rate state
|
||||
- MessagePack encodes tick 128 as -128 — off-by-one in signed int boundary checks (<=128 instead of <128) across int8/16/32/64 branches
|
||||
- Monologue/dialogue lost on snapshot overwrite — one-shot events now carried forward when a newer snapshot replaces an unconsumed one
|
||||
- Fog shader white screen on load failure — ColorRect defaults to transparent, shader load failure logged instead of crashing
|
||||
- Fog desync during camera smooth pan — fog rect now tracks camera position instead of player position
|
||||
|
||||
### Changed
|
||||
- Server game loop throttled to ~20 ticks/sec (50ms frames) — non-blocking TCP loop no longer spins; remaining frame budget available for NPC AI
|
||||
- Hold-to-move input model — movement polled each frame with stance-based throttle (Sprint=200ms, Walk=400ms, Careful=600ms, Crouch=800ms) and composite diagonals (W+D → northeast)
|
||||
- D-053 updated with client throttle rates and input model documentation
|
||||
|
||||
### Added
|
||||
- Dialogue box UI skeleton (#434, D-061) — bottom screen, max 20% height, ~65% width, NPC speech + max 3 response options, insert-styled colors, WASD walk-away with 300ms fade, no close button, diegetic on InsertOverlay z-layer 6
|
||||
- Fog entity visualization (#431, D-059/D-060) — cognitive delay rendering: sonar-style sound pings (3 concentric rings, 1.5s fade), unrecognized grey blobs with 0.8s breathing pulse, D-033 color transition at 50% recognition progress, ±0.5 tile position drift, FogEntities node at z:950
|
||||
|
||||
### Changed
|
||||
- Client protocol version bumped from 6 to 7 (pending_recognitions decode for cognitive delay)
|
||||
- GameState: current_dialogue and pending_recognitions fields wired from ObserverSnapshot v7
|
||||
- Scene tree: DialogueBox added to InsertOverlay, FogEntities at z:950 between fog shader and InsertOverlay
|
||||
- Test mode: mock dialogue (Kael NPC, 3 options) and mock cognitive delay entity (6-tick recognition cycle)
|
||||
|
||||
### Added
|
||||
- Archetype evidence presentation spec (#443, D-065/D-034/D-033) — detective case file vs smuggler notebook design document: item definitions, knowledge graph presentation, contradiction markers, THE FRIEND arc walkthroughs, systems interaction map, authoring guidelines
|
||||
- Cognitive delay system (#423, D-060) — CognitiveDelay component buffers perception events before emitting KnowledgeEvents (0.6s base / 0.3s urgent at 10 tps), pending_recognitions in ObserverSnapshot v7 for client fog entity visualization, cancellation on entity LOS exit
|
||||
- ListeningFocus eavesdrop system (#426, D-053) — stationary_ticks tracking for eavesdrop positioning bonus, 30-tick threshold (20 for Careful stance), Sprint blocks accumulation, registered after validate_movement
|
||||
- YAML content loader with hot-reload (#326, D-028) — LinePool system parsing dialogue/monologue YAML into BTreeMap-indexed pools, 4-layer query filtering (access > situation > trust > topic+mood), timestamp-polling hot-reload (dev-only), graceful failure preserves previous content
|
||||
- Line pool format specification (#308) — formal spec at docs/architecture/line-pool-format.md defining YAML structure, tag enums, 4-layer filtering pipeline, prerequisite-to-KG mapping, ID format, and Rust loader interface
|
||||
- InteractionMemory KG schema design (#442, D-064) — design doc at docs/architecture/interaction-memory-schema.md extending FactKnowledge with interaction tracking, 5-state InteractionState enum, monologue prerequisite extension, NpcTolerance reconciliation
|
||||
- Audio discussion decisions D-067 through D-074: recognition chime timing, 5-bus architecture, audio dip profiles, confrontation as cognitive vulnerability, monologue chime placeholder strategy, universal conversation murmur, zone crossfade, hybrid audio generation
|
||||
- Asset pipeline documentation system (docs/assets/) with category-based index, sonic palette, and generation templates for audio, visual, and video pipelines
|
||||
- Stable Audio Open connector and post-processing wrappers (audio-generate, audio-health, audio-post) with timeout handling for 11GB VRAM constraint
|
||||
- gen-audio skill with prompt assembly system (sonic palette prefixes + category templates + asset descriptions)
|
||||
- 6 interaction UI audio assets (#440): cursor_hover, weapon_aim, implant_open, fog_recognition, sfx_monologue_chime, sfx_monologue_chime_urgent — generated via SAO, needs duration trimming (#453)
|
||||
- Dialogue/confrontation ambient dip implementation spec with full Godot AudioBus tween code
|
||||
- Synthesis tooling (tooling/synth_ui_sounds.py) for programmatic insert-tech sound generation
|
||||
|
||||
### Changed
|
||||
- Protocol version bumped from 6 to 7 (pending_recognitions field in ObserverSnapshot)
|
||||
- MessagePack fixtures regenerated for protocol v7
|
||||
- Monologue trigger system uses .values() iterator (clippy fix)
|
||||
- Monologue schema: relationship prerequisite now requires target and state fields
|
||||
- 389 tests total (70 new) — cognitive delay pipeline, line pool loader, ListeningFocus, content watching, serialization
|
||||
- Renamed asset-gen skill to gen-image for consistent gen-* naming pattern
|
||||
- Client protocol v6 bridge — decode player_stance (4 variants) and player_inventory from ObserverSnapshot, TOGGLE_STANCE_UP/DOWN input actions, 25 gdUnit4 tests
|
||||
- Three-scope z-layer rendering pipeline (D-049) — world z:0-900 inside CanvasGroup, insert overlay CanvasLayer 10, UI CanvasLayer 20, modal CanvasLayer 30. Y-sort contract enforced, reserved VFX/airborne/lower-floor ranges documented
|
||||
- Cursor state machine (#429, D-056) — 4 states (Default/EntityHover/ObjectHover/WeaponAim), 150ms transitions, insert-styled geometric shapes
|
||||
- Fog shader rebuild (#430, D-059) — 5-layer fragment shader with animated Perlin noise, CanvasGroup compositing, FogState autoload for visibility/exploration textures
|
||||
- Entity interaction list (#432, D-057) — vertical multi-verb menu, insert-styled, sprint suppression, diegetic toggle
|
||||
- World radial menu (#433, D-058) — 2 spokes (Observe + Insert), drag-release and click-click input, 60-degree acceptance zones
|
||||
- Inventory UI (#438, D-065) — 3x3 grid, 40x40px slots, 1-9 hotkey selection
|
||||
- Stance indicator (#439, D-053) — color-coded HUD text, C/X keybinds
|
||||
- Architecture docs: z-layer gap analysis, fog shader spec, flying taxi feasibility analysis
|
||||
|
||||
### Changed
|
||||
- Scene tree restructured: Entities z_index 3->0 (critical y-sort fix), FloorObjects->10, YSortGroup->100, Overhead->300, FogOverlay->900, ModalLayer added
|
||||
- constants.gd rewritten with three-scope z numbering and full reserved range documentation
|
||||
- Fog renderer replaced: TileMapLayer-based fog_renderer.gd deleted, replaced by shader-based fog_shader.gd + fog.gdshader
|
||||
|
||||
### Added
|
||||
- ObserverSnapshot v6 wire protocol (#449) — player_stance (MovementStance) and player_inventory (Vec\<InventoryItem\>) fields with serde defaults for backward compatibility
|
||||
- Stance system (#417) — Sprint/Walk/Careful/Crouch movement stance with tick-based speed (1/2/3/4 ticks per move), monologue rate multipliers (40%/100%/150%/100%), PlayerMoveCooldown component, ToggleStanceUp/Down player actions
|
||||
- TilePresence posture layers (#420) — Standing/Prone/Seated/Fixture occupancy layers enabling same-tile coexistence (e.g. seated NPC + standing player), layer-based collision in validate_movement
|
||||
- ObjectType component (#421) — Readable/Container/Terminal/Door/Pickup/Furniture types with Phase 1 verb sets computed from type + proximity range
|
||||
- Phase 2 verb filter (#422) — KG-gated observer-side verb processing: POI priority flips (D-060), Confront injection at KnowsDetails+ confidence, contradiction marking, archetype-specific label relabeling (Smuggler sees Move/Stash, Detective sees Scan/Flag on containers)
|
||||
- CharacterArchetype component — Smuggler/Detective archetype for Phase 2 verb label differentiation (D-057)
|
||||
- VerbKind::Confront — Phase 2 only verb injected when observer has KnowsDetails+ on an NPC at close range
|
||||
- Smuggler inventory system (#424) — CarriedBy(StableId) component, Take/Place verbs, 9-slot (3x3 grid) capacity, auto-slot assignment, info boundary enforcement (carried items invisible to other observers)
|
||||
- MovementProfile component (#418) — per-archetype default stance (smuggler=Walk, detective=Walk), applied on spawn, factory methods for future archetypes
|
||||
- Sprint interaction buffer suppression (#419, D-055) — sprint stance explicitly clears interaction buffer, no verbs computed or sent during sprint, anomaly monologue pipeline unaffected
|
||||
- Sprint anomaly double-take monologue (#428, D-055) — SprintAnomalyQueue component detects Contradicted entities during sprint, fires delayed retroactive monologue after ~1.5s ("Wait — something wasn't right back there"), first-in-wins queue semantics, 3 hardcoded v0.1 lines
|
||||
|
||||
### Changed
|
||||
- Protocol version bumped from 5 to 6 (stance, inventory, ObjectType, verb system fields)
|
||||
- MessagePack fixtures regenerated for protocol v6
|
||||
- Input processing queries expanded for stance and cooldown components with backward-compatible Option wrapping
|
||||
- Observer pipeline queries expanded for Stance and CharacterArchetype components
|
||||
- NearbyInteraction carries object_type and contradicted fields for Phase 2 context
|
||||
- BridgePlugin system ordering: process_sprint_anomaly_monologue runs after trigger_monologue, compute_observer_snapshot runs after anomaly processing
|
||||
- Player spawn includes MovementProfile, Stance, PlayerMoveCooldown, and SprintAnomalyQueue components
|
||||
- 331 tests total (131 new) — comprehensive QA coverage across stance, occupancy, Phase 2 verbs, sprint suppression, inventory, anomaly monologue, and wire format
|
||||
|
||||
### Added
|
||||
- D-066: Dual-scale grid — 0.5m simulation tiles for stealth granularity, 1m visual tiles for proportional art (2x retina factor). All world geometry 2x2 sim tile minimum so cover/LOS maps 1:1 with visuals. Amends OQ-01.
|
||||
- Sprint CLI (`db/connectors/sprint`) — unified sprint lifecycle management with 5 subcommands: status, start, stop, start-work, prepare. Auto-detects sprint from DB state and team from git branch. Guards prevent activating unplanned sprints.
|
||||
- Shared permission settings in `.claude/settings.json` — git, ticket/sprint CLI, make, tea, and core skills pre-approved across all worktrees. Deny rules block destructive operations.
|
||||
- Decisions D-053 through D-065 from Control & Interaction Workshop — formalized interaction verb system, contextual actions, NPC awareness model, and related design decisions
|
||||
- Sprint 6 Touch briefings for server, client, copy, and joint teams
|
||||
- Control & Interaction Workshop outputs — full workshop notes and outcomes
|
||||
- Smuggler inventory item specs for transit district (#441)
|
||||
- Start-workshop skill for multi-agent design workshops
|
||||
|
||||
### Fixed
|
||||
- Added worktree boundary rules to CLAUDE.md — agents must stay within the git root, no navigating to sibling worktrees or above the repo
|
||||
- Plan-sprint skill now enforces worktree-relative paths in generated briefings
|
||||
- Worktree-update skill now discovers branches dynamically via `git worktree list` instead of relying on hardcoded branch names — fixes missed branches like `planning`
|
||||
|
||||
### Changed
|
||||
- Start-sprint and plan-sprint skills updated to use sprint CLI instead of manual multi-query workflows
|
||||
- Permission syntax migrated from deprecated `:*` suffix to modern space-wildcard format across all worktrees
|
||||
- Internal monologue trigger system (#414) — enter_location fires on first tick, time_idle fires after 100 ticks of no movement, 300-tick cooldown, dedup within session, random line selection from content pools via ChaCha20 RNG
|
||||
- MonologueEvent in ObserverSnapshot v5 — current_monologue field carries id, text, and display duration across the IPC bridge
|
||||
- Client monologue display wiring — protocol v5 decoding, GameState extraction, HUD display pass-through
|
||||
|
||||
### Fixed
|
||||
- NPC spawn missing Interactable component (#413) — NPCs spawned from content and proof room now have Interactable, enabling E-prompt detection
|
||||
- PlayerAction::Interact was a no-op (#415) — changed from unit to struct variant with target_entity_id and verb fields, server logs interaction data
|
||||
|
||||
### Changed
|
||||
- Protocol version bumped from 4 to 5 (MonologueEvent field, Interact variant change)
|
||||
- MessagePack fixtures regenerated for protocol v5
|
||||
|
||||
### Changed
|
||||
- UIStrings YAML parser now handles arbitrary nesting depth and inline comments — adapts to copy team's restructured ui-strings.yaml with multi-level sections (relationship_states, health_values)
|
||||
- HUD uses new YAML keys: `hud.perception_mode_prefix`, `hud.time_prefix`, `hud.health` (empty prefixes display values directly)
|
||||
- Interaction prompt keybind hint ("E") hardcoded instead of loaded from YAML — keybinding is not copywriter text
|
||||
|
||||
### Added
|
||||
- Pre-commit FactId validation hook (#393) — grep-based check validates fact_id references in content YAML against canonical knowledge catalogs; advisory mode when catalogs are stubs, enforcing mode when populated
|
||||
- Pre-commit hook infrastructure — `.config/hooks/` with modular dispatcher, `make setup-hooks` target, `core.hooksPath` config for worktree-safe hook installation
|
||||
- `make check-fact-ids` target for manual fact_id validation
|
||||
- UIStrings autoload with YAML-based UI string loading (#409) — minimal YAML parser, `get_text()` lookup with fallback-to-key
|
||||
- HUD and interaction prompt labels now loaded from `client/data/ui-strings.yaml` instead of hardcoded strings
|
||||
- Character voice speech patterns (#310) — sentence-level execution spec for smuggler and detective covering contractions, punctuation, stress markers, vocabulary, verbal tics, and authoring checklist
|
||||
- NPC authoring style guide (#379) — 954-line handbook: tier budgets, 9 NPC patterns, dialogue/monologue rules, tag taxonomy, dual-lens coordination, Krenn/Sova culture, FRIEND phase mapping, validation checklist
|
||||
- UI microcopy (#409) — 72 YAML strings for client integration: interaction verbs, relationship states, HUD labels, perception modes, notifications, knowledge panel, tutorial prompts
|
||||
- Kael Davan FRIEND pack (#297) — 86 hand-authored lines across 3 locations, 5-phase relationship arc with contradiction scene, dual-lens notes
|
||||
- Sera Venn FRIEND pack (#298) — 75 hand-authored lines at The Last Shift, trust-gated gossip, avoidance contradiction, contaminated trust arc
|
||||
- PC-as-NPC content (#401) — 70 authored items enabling second-playthrough recognition (D-039 wow moment #4)
|
||||
|
||||
### Changed
|
||||
- Moved ui-strings.yaml from content/campaigns/_meta/ to client/data/ for direct Godot client loading
|
||||
|
||||
### Fixed
|
||||
- Fact ID format collision across FRIEND content — normalized 73 flat IDs to dotted category.topic format, fixing broken detective evidence chain from Sera to Kael observations
|
||||
- Entity ref format inconsistency — normalized underscore format to npc:hyphenated across ~15 monologue references
|
||||
- Phase tag inconsistency between FRIEND packs — standardized to phase-N format, added missing phase-2 tags to Kael ring ops lines
|
||||
- Detective monologue gap for Kael — added 7 observation lines (7 → 15 total), added mood tags and bar_evening situation to Kael bar content
|
||||
- Hoshe QA briefing updated with integration test coverage priorities — test harnesses, dedicated client-server test map, edge case focus
|
||||
- Sprint 5 "Live" team briefings — copy (6 tickets), client (2), CI (1), joint coordination for content-at-scale sprint targeting FRIEND packs, voice patterns, NPC style guide, PC-as-NPC authoring, UI microcopy, FactId validation
|
||||
- Live server mode (`make game`) — single command builds server, launches client with TCP connection, auto-kills server on exit; `make stop` helper for manual cleanup
|
||||
- `SR_LIVE=1` environment variable switches SimBridge from test mode to real TCP server connection
|
||||
- TileKind in wire protocol (#412) — server sends Floor/Wall/Door/Object per visible tile, client renders walls and floors in live mode
|
||||
- Input roundtrip integration test (#411) — spawns real server, connects via TCP, validates full movement and interact pipeline
|
||||
- Debug logging for received player inputs on server (visible with `RUST_LOG=debug`)
|
||||
|
||||
### Fixed
|
||||
- Interaction prompt target+verb data now attached to Interact action in game loop (#405) — was TODO stub, server receives `{target_entity_id, verb}` payload
|
||||
- Player entity detection uses `kind.variant == "Player"` instead of hardcoded `entity_id == 1` — fixes "player not found" warnings when connected to real server (which assigns different IDs)
|
||||
- Movement keys now work in live mode — client was sending millisecond timestamps as input tick, server only processes ticks <= current frame counter; now uses server tick from latest snapshot
|
||||
- Entity-to-tile alignment in live mode — server sends tile-center render coords (tile 16 → 16.5), entity renderer now floors to tile index before positioning
|
||||
|
||||
### Added
|
||||
- Content loader Phase 2 (#408) — 2-phase spawn pipeline loads real YAML content into ECS entities: enums, entity attributes, pools, templates, triangles, NPC profiles with Want/Tolerance/Contentment/Personality/Tells/Skills axes plus cross-reference resolution for Secrets, Relationships, Information, and DailyRoutine
|
||||
- Global enum YAML files (#387) — 9 enum definitions (situations, topics, moods, triggers, access-tiers, trust-tiers, activities, patterns, motivations) from D-035 taxonomy
|
||||
- Entity attributes YAML (#388) — 16 canonical knowledge graph attribute keys from D-024 with A7 workshop updates
|
||||
- Seed-time pools (#389) — 5 single-candidate pools for deterministic v0.1 testing
|
||||
- Social site templates (#390) — 3 templates (logistics-hub, bar, smuggling-ring) with role slot definitions per D-025
|
||||
- Triangle YAML files (#391) — 5 v0.1 triangles (3 active fork, 2 passive) per D-024 workshop synthesis
|
||||
- Seed configuration schema design (#394) — design document defining game-start randomization: FRIEND selections, pool draws, template assignments, entanglement config, ChaCha20 RNG protocol
|
||||
- YAML to RON converter tool (#403) — build-time converter in tooling/content-converter/, runs via `make content-ron`
|
||||
- Line previewer CLI (#407) — 4 subcommands (dialogue, monologue, coverage, sequence) for content authors to test line selection without running the full game
|
||||
- Happiness added to WantKind enum — Harek remapped from Safety to Happiness
|
||||
|
||||
### Fixed
|
||||
- Dialogue-pool schema corrected to use arrays for situation/topic/mood per D-035 (were incorrectly single strings)
|
||||
- NPC want.primary changed from narrative strings to WantKind enum keywords — fixes silent Want component drop at spawn time
|
||||
- Schema enum constraint added to npc-profile.schema.json for want.primary validation
|
||||
|
||||
### Added
|
||||
- Sprint 4 "Feel" team briefings — copy (8 tickets), server (9), client (1 carry-over), CI (1), joint coordination
|
||||
- Interaction prompt system (#405) — server-driven "E - Talk" prompt decoding v4 nearby_interactions with nested VerbOption structs, fade animation, extensible get_interaction_target/get_selected_verb interface for future radial verb menu
|
||||
- Art direction & mood board workshop (3 rounds + closing) — 4-agent team establishes visual identity, 16 art direction principles, 9 mood board images, 10 candidate decisions (D-042–D-051)
|
||||
- 3D-to-2D sprite render pipeline (`client/tooling/sprite_renderer/`) — Godot @tool scene renders textured 3D models at "the angle" (-72.5deg ortho) from 4 cardinal directions at 1024/256/64 resolutions with outline applied at working resolution
|
||||
- `/render-sprite` skill — CLI wrapper for the render pipeline with headless import step
|
||||
- Pipeline POC: Era 1 institutional wall + bar green wall textures generated via Nano Banana and rendered through full pipeline
|
||||
- PerceptionQuery trait and ActivePerceptionMode resource — abstraction layer for D-017 perception mode swapping (NaturalVision default implementation)
|
||||
- VisibilityGeometry intermediate resource decoupling FOV computation from entity filtering
|
||||
- Client-side PROTOCOL_VERSION enforcement — snapshot decoder rejects version mismatches with error log
|
||||
- POI verb priority test in observer pipeline — asserts both verb kind and priority values end-to-end
|
||||
|
||||
### Changed
|
||||
- Observer pipeline decomposed into two-stage system: compute_visibility_geometry (geometry) → compute_observer_snapshot (entity filtering + assembly)
|
||||
- POI verb priority adjustment moved from simulation phase (interaction.rs) to perception phase (observer) — fixes D-010 information boundary violation
|
||||
- compute_nearby_interactions no longer reads KnowledgeGraph — determines verb availability by proximity only, verb priority adjusted by observer
|
||||
- compute_nearby_interactions scheduling moved from SimulationPlugin to BridgePlugin for explicit ordering with geometry and observer systems
|
||||
- Client test snapshot updated to v4 format (Protocol.PROTOCOL_VERSION, tick_rate replaces paused)
|
||||
|
||||
### Added
|
||||
- Content validation tooling — `make validate-content` validates campaign YAML files against JSON schemas, maps files by directory context
|
||||
- Entity::to_bits() roundtrip test — guards against bevy version changes silently breaking wire IDs
|
||||
- TickRate switch mid-accumulation test — verifies Half→Full→Paused→Half transitions preserve accumulator state
|
||||
- Tick rate scaling system (#406, D-052) — Full/Half/Paused rates with fractional accumulation, SetTickRate player action, replaces binary pause flag
|
||||
- Proximity detection and interaction verbs (#404, D-060) — compute_nearby_interactions system with Manhattan distance ranges (close ≤2, mid ≤5), context-sensitive verb computation (Talk, Observe, Examine), PersonOfInterest priority flip, NearbyInteraction in ObserverSnapshot v4
|
||||
- Content directory skeleton (#385, D-057) — district-as-atomic-pack layout with Sova Transit first district, 17 NPC stubs, 3 locations, 5 triangles, dialogue/monologue pools, factions, knowledge catalogs, enum definitions
|
||||
- Content schema definitions (#386) — 8 JSON Schema files (draft 2020-12) for district, location, npc-profile, dialogue-pool, monologue-pool, triangle, routine, fact-catalog validation
|
||||
- PROTOCOL_VERSION constant in bridge types — versioning strategy documented (subprocess IPC, serde defaults for field evolution)
|
||||
- Campaign, system, station JSON schemas for hierarchical content validation
|
||||
|
||||
### Fixed
|
||||
- Test suite aligned with server v4 protocol enforcement — all hand-built snapshots include version field, verb priorities 1-indexed, ExamineNpc label corrected to "Observe"
|
||||
- E2E proof tests resilient to entity ordering — player found by kind instead of array position, wall-hides test checks specific NPC position instead of total count, supports 3-NPC proof room layout
|
||||
- Wire entity_id now uses StableId consistently across observer, observation, interpretation, and interaction systems (was Entity::to_bits() in some paths)
|
||||
- Restored system/station/district hierarchical fields in district metadata (incorrectly removed during canonical_id cleanup)
|
||||
- Unregistered entities in observer/interaction now log tracing::error instead of silently falling back to Entity::to_bits()
|
||||
|
||||
### Changed
|
||||
- NearbyInteractionBuffer refactored from global Resource to per-entity Component on PlayerCharacter — multiplayer-ready (D-009)
|
||||
- Observer module split into mod.rs (244 lines) + tests.rs (480 lines) — reduces module complexity
|
||||
- Content directory restructured from flat districts/ to hierarchical campaigns/main/systems/krenn/stations/sova/districts/transit/ — path mirrors canonical IDs, glob-based discovery, multi-campaign/DLC ready
|
||||
- Content manifest (content.yaml) rewritten for glob-based district discovery
|
||||
- District identity fields (system, station, district) now derived from directory path — removed from district.yaml required fields
|
||||
- NPC canonical_id schema accepts district-scoped IDs (npc:transit.kael-davan) for cross-district uniqueness
|
||||
- ObserverSnapshot protocol bumped to v4 — adds nearby_interactions field, tick_rate replaces paused field in GameTime
|
||||
- NearbyInteractionBuffer.interactions is now private with take() accessor (no per-frame clone)
|
||||
- NearbyInteraction.distance changed from f32 to u32 (matches manhattan distance)
|
||||
- Missing PlayerCharacter in input processing now panics instead of silent no-op
|
||||
- Verb sort uses (priority, kind) tuple for deterministic ordering at equal priority
|
||||
- Unregistered entity in knowledge events triggers debug_assert + error (was warn)
|
||||
- District schema: canonical_id is now optional (derived from directory path at load time)
|
||||
- Skills trimmed for CLAUDE.md deduplication — search-docs (-48%), ticket (-17%), review-pr (-35%) now reference CLAUDE.md for basics instead of repeating them
|
||||
- Review-pr reviewer profiles extracted to `references/reviewer-profiles.md` for progressive disclosure
|
||||
|
||||
### Removed
|
||||
- Dead generate_snapshot function in bridge/mod.rs — superseded by compute_observer_snapshot
|
||||
- content/global/regions/ directory — region data absorbed into system.yaml metadata
|
||||
|
||||
### Added
|
||||
- Dual Lens Authoring Guide — 7-chapter reference for writing content that works for both smuggler and detective perspectives (D-027, D-028, D-032, D-034, D-035)
|
||||
- THE MIRROR pattern spec — transparency-as-contrast NPC design with Naia Tamm reference implementation and generator template
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
A top-down immersive sim — occlusion-based detective game with combat elements, set in an original science fiction universe. Single-character perspective, asymmetric information as core mechanic, Rimworld-style storyteller. Godot 4 client + Rust/bevy_ecs simulation server via subprocess/IPC (D-020).
|
||||
|
||||
**Official Title:** The Settled Reach (D-021)
|
||||
**Repository name:** commonwealth (historical code name, retained for path stability)
|
||||
**Repository name:** settled-reach (formerly commonwealth, renamed for clarity)
|
||||
**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.1.{sprint_number}`)
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -33,10 +34,10 @@ db/
|
||||
skills/ # Skill definitions
|
||||
decisions/ # Decision domain files (source of truth)
|
||||
README.md # Domain index and query examples
|
||||
architecture.md # D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031
|
||||
perception.md # D-011, D-015, D-016, D-017, D-018, D-019
|
||||
content.md # D-023, D-024, D-025, D-028, D-029
|
||||
scope.md # D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027
|
||||
architecture.md # D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066
|
||||
perception.md # D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043-D-049, D-052, D-056-D-061, D-067, D-069-D-072, D-076-D-078
|
||||
content.md # D-023, D-024, D-025, D-028, D-029, D-032, D-034-D-037, D-050, D-062-D-064
|
||||
scope.md # D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065
|
||||
process.md # D-004, D-021, D-022
|
||||
questions.md # Q-001 through Q-011
|
||||
rejected.md # R-001 through R-010
|
||||
@@ -50,6 +51,18 @@ See [docs/DEVOPS.md](docs/DEVOPS.md) for build, test, lint, and CI procedures. A
|
||||
|
||||
## Agent Instructions
|
||||
|
||||
### Worktree boundaries
|
||||
|
||||
This project uses **git worktrees** in a shared parent directory (`settled-reach/`). Each team branch (`server`, `client`, `copy`, `audio`, `visual`, `ci`) is checked out in its own worktree under that parent. The parent directory also contains shared resources like the ticketing database.
|
||||
|
||||
Each worktree contains the full repository: `server/` (Rust backend), `client/` (Godot client), `docs/`, `decisions/`, etc. The worktree root IS the git root — use `git rev-parse --show-toplevel` if in doubt.
|
||||
|
||||
Unless there is a direct instruction or a functional need (e.g. accessing the shared database in the parent directory), **all work must remain within the scope of the git root Claude is running in.**
|
||||
|
||||
- All file paths are relative to the worktree/git root (e.g. `server/src/bridge/types.rs`, `client/scripts/rendering/fog.gd`).
|
||||
- Do not navigate to or access sibling worktrees in the parent directory (`../client/`, `../copy/`, etc.) unless explicitly instructed.
|
||||
- Do not navigate above the git root unless explicitly instructed.
|
||||
|
||||
### Database
|
||||
|
||||
The ticketing database (`settledreach.db`) lives in the **parent directory** shared across all worktrees — it is not tracked in git. A backup is committed to `docs/backups/settledreach.db.backup` via main only.
|
||||
@@ -68,6 +81,18 @@ db/connectors/ticket show 78
|
||||
db/connectors/ticket sprint --active
|
||||
```
|
||||
|
||||
### Sprint CLI
|
||||
**Use the sprint CLI for sprint-scoped operations.** It batches ticket queries and formats output for agent consumption:
|
||||
```bash
|
||||
db/connectors/sprint status # Current sprint progress
|
||||
db/connectors/sprint status --team server # Team-scoped view
|
||||
db/connectors/sprint start-work --team client # Full context dump for starting work
|
||||
db/connectors/sprint prepare # Prepare next sprint (candidates + gaps)
|
||||
db/connectors/sprint start # Activate a planned sprint
|
||||
db/connectors/sprint stop # Complete an active sprint
|
||||
```
|
||||
Team is auto-detected from the current git branch (if not `main`). Sprint is auto-detected from DB state.
|
||||
|
||||
Only fall back to raw SQL for queries the CLI doesn't support. **Never use the `sqlite3` CLI** — it crashes in Claude Code due to a known std::bad_alloc bug. Use the wrapper scripts instead:
|
||||
```bash
|
||||
db/connectors/sqlite-query "SELECT * FROM tickets WHERE status='in_progress'"
|
||||
@@ -107,6 +132,12 @@ tea issue list --login schweitz --repo jpmschweitzer/settled-reach --state open
|
||||
Key rules:
|
||||
- **All flags must be explicit** — omitting `--login` or `--repo` triggers interactive prompts that crash in Claude Code (no TTY)
|
||||
- **Use `--output simple`** for machine-readable output (no table borders)
|
||||
- **`tea comment` hangs with inline heredocs and multi-line strings.** Always write the comment body to a temp file first, then pass it via `$(cat)`:
|
||||
```bash
|
||||
# Step 1: Write content to .tmp/ (gitignored) using the Write tool
|
||||
# Step 2: Post via cat
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <NUMBER> "$(cat .tmp/review-branch.md)"
|
||||
```
|
||||
- **`tea pr reject` does not work on your own PRs** — use `tea comment` instead
|
||||
- **Never delete protected branches:** `main`, `maintenance`, `server`, `client`, `copy`, `audio`, `visual`, `ci` are protected on Gitea. Do not use `tea pr clean`, `git push --delete`, or `git branch -D` on these branches.
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
|
||||
|
||||
.PHONY: help setup build client server test lint ci ci-client ci-server clean \
|
||||
.PHONY: help setup build client server game stop test lint ci ci-client ci-server clean \
|
||||
decisions-sync decisions-coverage decisions-active decisions-orphan \
|
||||
db-backup db-install
|
||||
db-backup db-install validate-content content-ron check-fact-ids setup-hooks \
|
||||
pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
|
||||
pre-pr-server pre-pr-client pre-pr-content \
|
||||
fixtures-client golden-diff golden-update \
|
||||
checklist-validate checklist-generate \
|
||||
perf-baseline debug-schedule
|
||||
|
||||
# --- Configuration ---
|
||||
|
||||
@@ -14,7 +19,9 @@ help:
|
||||
@echo ""
|
||||
@echo " make setup Install dev dependencies (Rust, Godot, tooling)"
|
||||
@echo " make build Build client and server"
|
||||
@echo " make client Run the Godot client"
|
||||
@echo " make game Build and run the full game (server + client)"
|
||||
@echo " make stop Stop any running server instance"
|
||||
@echo " make client Run the Godot client (test mode)"
|
||||
@echo " make server Run the Rust simulation server"
|
||||
@echo " make test Run all tests"
|
||||
@echo " make lint Run all linters"
|
||||
@@ -30,12 +37,29 @@ help:
|
||||
@echo " make decisions-coverage Decision-to-ticket coverage by domain"
|
||||
@echo " make decisions-active List active decisions"
|
||||
@echo " make decisions-orphan Decisions without implementing tickets"
|
||||
@echo " make validate-content Validate content YAML against schemas"
|
||||
@echo " make check-fact-ids Check fact_id references against knowledge catalogs"
|
||||
@echo " make content-ron Convert content YAML to RON (build-time)"
|
||||
@echo " make fixtures-client Generate GDScript->Rust cross-encoder fixtures (#475)"
|
||||
@echo " make golden-diff Show diff if golden file output has changed"
|
||||
@echo " make golden-update Regenerate golden file and stage for commit"
|
||||
@echo " make checklist-validate Validate checklist YAML against schema"
|
||||
@echo " make checklist-generate Validate checklists + print condition summary"
|
||||
@echo " make perf-baseline Run performance benchmarks and save baseline"
|
||||
@echo ""
|
||||
@echo " make pre-pr Run all pre-PR checks (lint, build, test, validate, fixtures)"
|
||||
@echo " make pre-pr-server Server-scoped pre-PR (lint, build, test, fixtures)"
|
||||
@echo " make pre-pr-client Client-scoped pre-PR (lint, build, test)"
|
||||
@echo " make pre-pr-content Content-scoped pre-PR (schema + cross-ref validation)"
|
||||
@echo ""
|
||||
@echo " make setup-hooks Install pre-commit hooks (included in setup)"
|
||||
@echo " make debug-schedule Print bevy_ecs schedule graph (diff for PR artifacts)"
|
||||
@echo ""
|
||||
@echo " GODOT_VERSION=4.6 make setup Override Godot version"
|
||||
|
||||
# --- Setup ---
|
||||
|
||||
setup: setup-rust setup-godot setup-tooling decisions-sync
|
||||
setup: setup-rust setup-godot setup-tooling setup-hooks decisions-sync
|
||||
@echo "Dev environment ready."
|
||||
|
||||
setup-rust:
|
||||
@@ -49,6 +73,12 @@ setup-tooling:
|
||||
@command -v python3 >/dev/null 2>&1 || { echo "Install Python 3"; exit 1; }
|
||||
@command -v curl >/dev/null 2>&1 || { echo "Install curl (required for Godot download)"; exit 1; }
|
||||
@command -v unzip >/dev/null 2>&1 || { echo "Install unzip (required for Godot download)"; exit 1; }
|
||||
@python3 -c "import yaml" 2>/dev/null || { echo "Install PyYAML: pip install pyyaml"; exit 1; }
|
||||
@python3 -c "import jsonschema" 2>/dev/null || { echo "Install jsonschema: pip install jsonschema"; exit 1; }
|
||||
|
||||
setup-hooks:
|
||||
@git config core.hooksPath .config/hooks
|
||||
@echo "Git hooks path set to .config/hooks"
|
||||
|
||||
# --- Build ---
|
||||
|
||||
@@ -59,7 +89,7 @@ build-server:
|
||||
|
||||
build-client:
|
||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||
$(GODOT) --headless --path client --quit
|
||||
$(GODOT) --headless --path client --import --quit
|
||||
|
||||
# --- Run ---
|
||||
|
||||
@@ -70,6 +100,19 @@ client:
|
||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||
$(GODOT) --path client
|
||||
|
||||
game: stop build
|
||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||
@echo "Starting server..."
|
||||
@cd server && cargo run &
|
||||
@sleep 2
|
||||
@echo "Starting client..."
|
||||
@SR_LIVE=1 $(GODOT) --path client
|
||||
@$(MAKE) stop
|
||||
|
||||
stop:
|
||||
@lsof -ti :9876 | xargs -r kill 2>/dev/null || true
|
||||
@echo "Stopped any running server on port 9876"
|
||||
|
||||
# --- Test ---
|
||||
|
||||
test: test-server test-client
|
||||
@@ -80,6 +123,41 @@ test-server:
|
||||
fixtures:
|
||||
cd server && cargo test --test gen_fixtures -- --ignored
|
||||
|
||||
fixtures-client:
|
||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||
@echo "Generating GDScript fixtures for Rust decoder..."
|
||||
$(GODOT) --headless --path client -s res://tests/gen_client_fixtures.gd
|
||||
@echo "Verifying Rust can decode GDScript fixtures..."
|
||||
cd server && cargo test --test serialization gdscript_generated_fixtures
|
||||
@echo "--- Fixtures-client: PASS ---"
|
||||
|
||||
golden-diff:
|
||||
@echo "Regenerating golden output for comparison..."
|
||||
@mkdir -p .cache/golden
|
||||
@cp server/tests/golden/proof_room_tick_10.json .cache/golden/before.json
|
||||
@cd server && UPDATE_GOLDEN=1 cargo test --test golden_suite -- proof_room_tick_10_matches_golden; \
|
||||
rc=$$?; \
|
||||
cp ../server/tests/golden/proof_room_tick_10.json ../.cache/golden/after.json 2>/dev/null; \
|
||||
cp ../.cache/golden/before.json ../server/tests/golden/proof_room_tick_10.json; \
|
||||
if [ $$rc -ne 0 ]; then echo "Cargo test failed (golden file restored)."; exit $$rc; fi
|
||||
@if diff -q .cache/golden/before.json .cache/golden/after.json >/dev/null 2>&1; then \
|
||||
echo "--- Golden file: UP TO DATE ---"; \
|
||||
else \
|
||||
echo "--- Golden file has CHANGED ---"; \
|
||||
echo ""; \
|
||||
diff --color -u .cache/golden/before.json .cache/golden/after.json || true; \
|
||||
echo ""; \
|
||||
echo "Run 'make golden-update' to accept changes."; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
golden-update:
|
||||
@echo "Regenerating golden file..."
|
||||
@cd server && UPDATE_GOLDEN=1 cargo test --test golden_suite -- proof_room_tick_10_matches_golden
|
||||
@git add server/tests/golden/
|
||||
@echo "--- Golden file updated and staged ---"
|
||||
@echo "Review with: git diff --cached -- server/tests/golden/"
|
||||
|
||||
test-client:
|
||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||
$(GODOT) --headless --path client -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://tests/
|
||||
@@ -97,6 +175,70 @@ lint-client:
|
||||
@echo "Checking GDScript for errors..."
|
||||
@$(GODOT) --headless --path client --quit 2>&1 | grep -i "SCRIPT ERROR" && { echo "GDScript errors found"; exit 1; } || echo "No script errors found"
|
||||
|
||||
# --- Pre-PR verification ---
|
||||
|
||||
pre-pr: pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures
|
||||
@echo ""
|
||||
@echo "=== PRE-PR: ALL CHECKS PASSED ==="
|
||||
@echo "Safe to create PR."
|
||||
|
||||
pre-pr-lint: lint-server lint-client
|
||||
@echo "--- Lint: PASS ---"
|
||||
|
||||
pre-pr-build: build-server build-client
|
||||
@echo "--- Build: PASS ---"
|
||||
|
||||
pre-pr-test: test-server test-client
|
||||
@echo "--- Tests: PASS ---"
|
||||
|
||||
pre-pr-validate: validate-content check-fact-ids
|
||||
@echo "--- Content validation: PASS ---"
|
||||
|
||||
pre-pr-fixtures:
|
||||
@echo "Checking Rust->GDScript fixture staleness..."
|
||||
@cd server && cargo test --test gen_fixtures -- --ignored
|
||||
@if git diff --quiet client/tests/fixtures/; then \
|
||||
echo "--- Rust fixtures: UP TO DATE ---"; \
|
||||
else \
|
||||
echo ""; \
|
||||
echo "--- RUST FIXTURES STALE ---"; \
|
||||
echo " Protocol changed but fixtures not regenerated."; \
|
||||
echo " Stale fixtures make all client tests FALSE POSITIVES."; \
|
||||
echo ""; \
|
||||
echo " Changed files:"; \
|
||||
git diff --stat client/tests/fixtures/; \
|
||||
echo ""; \
|
||||
echo " Fix: commit the updated fixtures with your protocol change."; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Checking GDScript->Rust fixture staleness..."
|
||||
@$(MAKE) fixtures-client
|
||||
@if git diff --quiet server/tests/fixtures/gdscript/; then \
|
||||
echo "--- GDScript fixtures: UP TO DATE ---"; \
|
||||
else \
|
||||
echo ""; \
|
||||
echo "--- GDSCRIPT FIXTURES STALE ---"; \
|
||||
echo " Protocol changed but GDScript fixtures not regenerated."; \
|
||||
echo " Stale fixtures make cross-encoder tests FALSE POSITIVES."; \
|
||||
echo ""; \
|
||||
echo " Changed files:"; \
|
||||
git diff --stat server/tests/fixtures/gdscript/; \
|
||||
echo ""; \
|
||||
echo " Fix: commit the updated fixtures with your protocol change."; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# Branch-specific variants (faster, scope-appropriate)
|
||||
|
||||
pre-pr-server: lint-server build-server test-server pre-pr-fixtures
|
||||
@echo "=== Server pre-PR: PASSED ==="
|
||||
|
||||
pre-pr-client: lint-client build-client test-client
|
||||
@echo "=== Client pre-PR: PASSED ==="
|
||||
|
||||
pre-pr-content: validate-content check-fact-ids checklist-validate
|
||||
@echo "=== Content pre-PR: PASSED ==="
|
||||
|
||||
# --- CI (run locally) ---
|
||||
|
||||
ci: ci-server ci-client
|
||||
@@ -127,10 +269,37 @@ decisions-active:
|
||||
decisions-orphan:
|
||||
@db/connectors/sqlite-query "SELECT id, title FROM decisions WHERE type='confirmed' AND status='active' AND id NOT IN (SELECT DISTINCT decision_ref FROM tickets WHERE decision_ref IS NOT NULL)"
|
||||
|
||||
# --- Content Validation ---
|
||||
|
||||
validate-content:
|
||||
@tooling/validate-content
|
||||
|
||||
check-fact-ids:
|
||||
@tooling/check-fact-ids
|
||||
|
||||
checklist-validate:
|
||||
@tooling/validate-checklist --check
|
||||
|
||||
checklist-generate:
|
||||
@tooling/validate-checklist
|
||||
|
||||
perf-baseline:
|
||||
@tooling/perf-baseline
|
||||
|
||||
# --- Schedule debug (#346) ---
|
||||
|
||||
debug-schedule:
|
||||
@echo "Dumping bevy_ecs schedule graph..."
|
||||
@cd server && cargo run -- --dump-schedule
|
||||
|
||||
content-ron:
|
||||
cd tooling/content-converter && cargo build --release
|
||||
tooling/content-converter/target/release/content-converter --input content --output content-ron --verbose
|
||||
|
||||
# --- Clean ---
|
||||
|
||||
clean:
|
||||
cd server && cargo clean || true
|
||||
rm -rf .cache/*
|
||||
rm -rf client/.godot client/reports
|
||||
rm -rf client/.godot/* client/reports
|
||||
@echo "Clean complete."
|
||||
|
||||
@@ -69,25 +69,26 @@ static func _encode_message(buffer: StreamPeerBuffer, value):
|
||||
TYPE_INT:
|
||||
if - (1 << 5) <= value and value <= (1 << 7) - 1:
|
||||
buffer.put_8(value)
|
||||
elif - (1 << 7) <= value and value <= (1 << 7):
|
||||
elif - (1 << 7) <= value and value < (1 << 7):
|
||||
buffer.put_u8(types["int_8"])
|
||||
buffer.put_8(value)
|
||||
elif 0 <= value and value <= (1 << 8) - 1:
|
||||
buffer.put_u8(types["uint_8"])
|
||||
buffer.put_u8(value)
|
||||
elif - (1 << 15) <= value and value <= (1 << 15):
|
||||
elif - (1 << 15) <= value and value < (1 << 15):
|
||||
buffer.put_u8(types["int_16"])
|
||||
buffer.put_16(value)
|
||||
elif 0 <= value and value <= (1 << 16) - 1:
|
||||
buffer.put_u8(types["uint_16"])
|
||||
buffer.put_u16(value)
|
||||
elif - (1 << 31) <= value and value <= (1 << 31):
|
||||
elif - (1 << 31) <= value and value < (1 << 31):
|
||||
buffer.put_u8(types["int_32"])
|
||||
buffer.put_32(value)
|
||||
elif 0 <= value and value <= (1 << 32) - 1:
|
||||
buffer.put_u8(types["uint_32"])
|
||||
buffer.put_u32(value)
|
||||
elif - (1 << 63) <= value and value <= (1 << 63):
|
||||
elif value < 0:
|
||||
# Negative beyond int_32 range — encode as int_64 (0xd3)
|
||||
buffer.put_u8(types["int_64"])
|
||||
buffer.put_64(value)
|
||||
else:
|
||||
@@ -133,7 +134,9 @@ static func _encode_message(buffer: StreamPeerBuffer, value):
|
||||
return ERR_INVALID_DATA
|
||||
|
||||
for obj in value:
|
||||
_encode_message(buffer, obj)
|
||||
var inner_err = _encode_message(buffer, obj)
|
||||
if inner_err:
|
||||
return inner_err
|
||||
|
||||
TYPE_DICTIONARY:
|
||||
var size = value.size()
|
||||
@@ -150,8 +153,12 @@ static func _encode_message(buffer: StreamPeerBuffer, value):
|
||||
return ERR_INVALID_DATA
|
||||
|
||||
for key in value:
|
||||
_encode_message(buffer, key)
|
||||
_encode_message(buffer, value[key])
|
||||
var key_err = _encode_message(buffer, key)
|
||||
if key_err:
|
||||
return key_err
|
||||
var val_err = _encode_message(buffer, value[key])
|
||||
if val_err:
|
||||
return val_err
|
||||
|
||||
TYPE_PACKED_BYTE_ARRAY:
|
||||
var size = value.size()
|
||||
@@ -225,11 +232,9 @@ static func _decode_message(buffer: StreamPeerBuffer, err: Dictionary):
|
||||
return buffer.get_utf8_string(size)
|
||||
|
||||
elif first_byte == types["nil"]: # nil
|
||||
print("null size:%s"%buffer_size)
|
||||
return null
|
||||
|
||||
|
||||
elif first_byte == types["false"]: # false
|
||||
print("false size:%s"%buffer_size)
|
||||
return false
|
||||
|
||||
elif first_byte == types["true"]: # true
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://uflbvtm1aquk"
|
||||
path="res://.godot/imported/cursor_hover.ogg-f20fa5adecb73cc9163569968f5867a3.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/cursor_hover.ogg"
|
||||
dest_files=["res://.godot/imported/cursor_hover.ogg-f20fa5adecb73cc9163569968f5867a3.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://bw0mfil2343po"
|
||||
path="res://.godot/imported/fog_recognition.ogg-fd0dc7f57c3509774a533bc71a7687e7.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/fog_recognition.ogg"
|
||||
dest_files=["res://.godot/imported/fog_recognition.ogg-fd0dc7f57c3509774a533bc71a7687e7.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://dei8ikwaqyn67"
|
||||
path="res://.godot/imported/implant_open.ogg-46eadc4ed252693e3a936e75ddfdf4bc.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/implant_open.ogg"
|
||||
dest_files=["res://.godot/imported/implant_open.ogg-46eadc4ed252693e3a936e75ddfdf4bc.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://b7iopmg6ftp4k"
|
||||
path="res://.godot/imported/sfx_monologue_chime.ogg-5981f28716fbe14273c052560195d890.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/sfx_monologue_chime.ogg"
|
||||
dest_files=["res://.godot/imported/sfx_monologue_chime.ogg-5981f28716fbe14273c052560195d890.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://4m87qby6qdhc"
|
||||
path="res://.godot/imported/sfx_monologue_chime_urgent.ogg-696a4dbc297417724610e01fa44fa575.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/sfx_monologue_chime_urgent.ogg"
|
||||
dest_files=["res://.godot/imported/sfx_monologue_chime_urgent.ogg-696a4dbc297417724610e01fa44fa575.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://caeuztw9ah5h5l"
|
||||
path="res://.godot/imported/sfx_npc_murmur.ogg-bfd7592cfea1b592d89f107b6cd33838.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/sfx_npc_murmur.ogg"
|
||||
dest_files=["res://.godot/imported/sfx_npc_murmur.ogg-bfd7592cfea1b592d89f107b6cd33838.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://dbu2lr4scswyn"
|
||||
path="res://.godot/imported/sfx_stance_change.ogg-042fce976b2bd1b0806c72aa5dd64755.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/sfx_stance_change.ogg"
|
||||
dest_files=["res://.godot/imported/sfx_stance_change.ogg-042fce976b2bd1b0806c72aa5dd64755.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://c5lduxqgx0vme"
|
||||
path="res://.godot/imported/sfx_weapon_aim_lock.ogg-649a54b41e9bf6dd8ab414c03bb48cfa.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/sfx_weapon_aim_lock.ogg"
|
||||
dest_files=["res://.godot/imported/sfx_weapon_aim_lock.ogg-649a54b41e9bf6dd8ab414c03bb48cfa.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://c2bqso5hkljq1"
|
||||
path="res://.godot/imported/weapon_aim.ogg-0bff40b0820b0f9db4c2b0ae2542bfa0.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/weapon_aim.ogg"
|
||||
dest_files=["res://.godot/imported/weapon_aim.ogg-0bff40b0820b0f9db4c2b0ae2542bfa0.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2011 The Michroma Project Authors (https://github.com/googlefonts/Michroma-font)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,8 @@
|
||||
[gd_resource type="FontVariation" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="FontFile" path="res://assets/fonts/Michroma-Regular.ttf" id="1_base"]
|
||||
|
||||
[resource]
|
||||
base_font = ExtResource("1_base")
|
||||
spacing_glyph = 1
|
||||
spacing_space = 1
|
||||
@@ -0,0 +1,20 @@
|
||||
[gd_resource type="Theme" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="FontVariation" path="res://assets/fonts/michroma_ui.tres" id="1_font"]
|
||||
|
||||
[resource]
|
||||
default_font = ExtResource("1_font")
|
||||
default_font_size = 14
|
||||
|
||||
Label/colors/font_color = Color(0.878, 0.969, 0.98, 1)
|
||||
Label/font_sizes/font_size = 14
|
||||
|
||||
RichTextLabel/colors/default_color = Color(0.878, 0.969, 0.98, 1)
|
||||
RichTextLabel/font_sizes/normal_font_size = 14
|
||||
|
||||
Button/colors/font_color = Color(0.878, 0.969, 0.98, 1)
|
||||
Button/colors/font_hover_color = Color(0.91, 0.773, 0.278, 1)
|
||||
Button/font_sizes/font_size = 14
|
||||
|
||||
LineEdit/colors/font_color = Color(0.878, 0.969, 0.98, 1)
|
||||
LineEdit/font_sizes/font_size = 14
|
||||
@@ -0,0 +1,187 @@
|
||||
# UI Microcopy — The Settled Reach v0.1
|
||||
#
|
||||
# Ticket: #409 | Sprint: 5
|
||||
# Author: Mellanie (Copywriter)
|
||||
# Date: 2026-02-13
|
||||
#
|
||||
# All player-facing UI text. Organized by category.
|
||||
# Voice: Sova Transit District (D-036) — working-class pragmatic, quotidian-with-undertow.
|
||||
# Diegetic frame: most UI elements are neural insert overlays, not game chrome.
|
||||
#
|
||||
# Integration: Stig imports these into client/scripts/constants/ or reads at runtime.
|
||||
# Keys are stable identifiers. Values are display strings.
|
||||
|
||||
# ============================================================
|
||||
# INTERACTION PROMPTS
|
||||
# Displayed as "E - {label}" when player is near an interactable.
|
||||
# Server sends verb kind + label. These are the canonical labels.
|
||||
# Keep short: max 12 characters. Must read instantly during gameplay.
|
||||
# ============================================================
|
||||
interaction_verbs:
|
||||
talk: "Talk"
|
||||
examine_npc: "Look"
|
||||
examine_object: "Examine"
|
||||
read: "Read"
|
||||
pick_up: "Pick up"
|
||||
use: "Use"
|
||||
open: "Open"
|
||||
listen: "Listen"
|
||||
|
||||
# ============================================================
|
||||
# RELATIONSHIP STATE DESCRIPTORS
|
||||
# Shown in knowledge panel when player inspects an entity.
|
||||
# Maps to RelationshipState enum (server/src/knowledge/types.rs).
|
||||
# These should feel like insert-generated assessments, not game labels.
|
||||
# ============================================================
|
||||
relationship_states:
|
||||
unknown:
|
||||
label: "Unknown"
|
||||
description: "No prior contact."
|
||||
known:
|
||||
label: "Recognized"
|
||||
description: "Seen before. No relationship established."
|
||||
friendly:
|
||||
label: "Trusted"
|
||||
description: "Known contact. Cooperative."
|
||||
person_of_interest:
|
||||
label: "Flagged"
|
||||
description: "Behavioral anomaly noted."
|
||||
hostile:
|
||||
label: "Hostile"
|
||||
description: "Known threat."
|
||||
|
||||
# ============================================================
|
||||
# HUD LABELS
|
||||
# Top-level HUD elements. Diegetic: these are the insert's status readouts.
|
||||
# Format mirrors a low-key neural overlay — terse, functional.
|
||||
# ============================================================
|
||||
hud:
|
||||
health: "Condition"
|
||||
health_values:
|
||||
full: "Normal"
|
||||
wounded: "Injured"
|
||||
critical: "Critical"
|
||||
time_prefix: "" # No prefix — time displays as raw value (e.g., "14:30")
|
||||
perception_mode_prefix: "" # No prefix — mode name displays directly
|
||||
|
||||
# ============================================================
|
||||
# PERCEPTION MODE LABELS
|
||||
# The insert's active sensing mode. Per D-017.
|
||||
# Smuggler has Baseline. Detective has Standard Professional.
|
||||
# Labels should feel like the insert's own status readout.
|
||||
# ============================================================
|
||||
perception_modes:
|
||||
standard: "Standard"
|
||||
analytical: "Analytical"
|
||||
social: "Social"
|
||||
surveillance: "Surveillance"
|
||||
|
||||
# ============================================================
|
||||
# NOTIFICATION TEXT
|
||||
# Brief messages that appear when game state changes.
|
||||
# Max 40 characters. Must read in under 2 seconds.
|
||||
# Diegetic: these are insert alerts, not achievement popups.
|
||||
# ============================================================
|
||||
notifications:
|
||||
# Location discovery
|
||||
location_discovered: "New location logged."
|
||||
location_revisit: "Returning to {location}."
|
||||
|
||||
# NPC knowledge
|
||||
contact_new: "New contact: {name}."
|
||||
contact_updated: "Contact updated: {name}."
|
||||
contact_flagged: "{name} flagged."
|
||||
|
||||
# Relationship shifts
|
||||
relationship_improved: "{name} — trust increased."
|
||||
relationship_degraded: "{name} — trust decreased."
|
||||
relationship_hostile: "{name} — hostile."
|
||||
|
||||
# Evidence / observation
|
||||
evidence_found: "Evidence logged."
|
||||
anomaly_detected: "Anomaly noted."
|
||||
pattern_recognized: "Pattern flagged."
|
||||
|
||||
# System
|
||||
save_complete: "Progress saved."
|
||||
connection_lost: "Signal interrupted."
|
||||
connection_restored: "Signal restored."
|
||||
|
||||
# ============================================================
|
||||
# KNOWLEDGE PANEL LABELS
|
||||
# When the player opens the knowledge view to review what they know.
|
||||
# Insert-flavored: reads like a personal filing system, not a game menu.
|
||||
# ============================================================
|
||||
knowledge_panel:
|
||||
tab_contacts: "Contacts"
|
||||
tab_locations: "Locations"
|
||||
tab_evidence: "Notes"
|
||||
section_known_attributes: "Known"
|
||||
section_last_seen: "Last seen"
|
||||
section_relationship: "Status"
|
||||
section_observations: "Observations"
|
||||
empty_state: "Nothing logged yet."
|
||||
confidence_high: "Confirmed"
|
||||
confidence_medium: "Likely"
|
||||
confidence_low: "Unconfirmed"
|
||||
confidence_rumor: "Hearsay"
|
||||
|
||||
# ============================================================
|
||||
# TUTORIAL TEXT (DIEGETIC)
|
||||
# Delivered via internal monologue (D-016), not UI overlay.
|
||||
# These are NOT traditional tutorial prompts. They're the character
|
||||
# thinking about what they can do, filtered through their voice.
|
||||
# Smuggler and detective variants are in the monologue pools.
|
||||
# These are the GENERIC fallback strings if monologue doesn't fire.
|
||||
# ============================================================
|
||||
tutorial_prompts:
|
||||
move_hint: "Use the directional keys to move."
|
||||
interact_hint: "Press E near someone to interact."
|
||||
examine_hint: "Press E to take a closer look."
|
||||
listen_hint: "Move closer to overhear conversations."
|
||||
monologue_hint: "Your thoughts appear as text on screen."
|
||||
perception_hint: "Switch perception modes to see differently."
|
||||
minimap_hint: "The overlay shows the area around you."
|
||||
knowledge_hint: "Open your notes to review what you know."
|
||||
|
||||
# ============================================================
|
||||
# DIALOGUE
|
||||
# Text displayed during dialogue interactions.
|
||||
# D-063: Confrontation beat monologue — character's internal hesitation.
|
||||
# ============================================================
|
||||
dialogue:
|
||||
confrontation_beat: "This changes things. No taking it back."
|
||||
|
||||
# ============================================================
|
||||
# MENU AND SYSTEM TEXT
|
||||
# Non-diegetic. Standard game UI. Clean, no flavor text.
|
||||
# ============================================================
|
||||
menu:
|
||||
pause_title: "Paused"
|
||||
resume: "Resume"
|
||||
settings: "Settings"
|
||||
save_game: "Save"
|
||||
load_game: "Load"
|
||||
quit_to_menu: "Quit to Menu"
|
||||
quit_game: "Quit"
|
||||
confirm_quit: "Unsaved progress will be lost."
|
||||
confirm_yes: "Yes"
|
||||
confirm_no: "No"
|
||||
|
||||
settings:
|
||||
audio_volume: "Volume"
|
||||
text_size: "Text Size"
|
||||
text_speed: "Text Speed"
|
||||
fullscreen: "Fullscreen"
|
||||
language: "Language"
|
||||
|
||||
# ============================================================
|
||||
# CHARACTER SELECTION (if applicable in v0.1)
|
||||
# ============================================================
|
||||
character_select:
|
||||
title: "Choose your perspective."
|
||||
smuggler_name: "Logistics Operator"
|
||||
smuggler_tagline: "You know these people. You know the work. You know what's at stake."
|
||||
detective_name: "Commission Investigator"
|
||||
detective_tagline: "The manifests don't add up. Someone in this district knows why."
|
||||
confirm: "Begin"
|
||||
@@ -0,0 +1,43 @@
|
||||
[gd_resource type="AudioBusLayout" format=3]
|
||||
|
||||
; D-068: 5-bus audio architecture — Music, Ambient, WorldSFX, PlayerActions, UISounds.
|
||||
; All buses route to Master. Volumes managed at runtime by AudioManager autoload.
|
||||
; AudioManager._setup_buses() creates any missing buses on startup (no-op if present).
|
||||
|
||||
[resource]
|
||||
bus/0/name = "Master"
|
||||
bus/0/solo = false
|
||||
bus/0/mute = false
|
||||
bus/0/bypass_fx = false
|
||||
bus/0/volume_db = 0.0
|
||||
bus/0/send = &""
|
||||
bus/1/name = "Music"
|
||||
bus/1/solo = false
|
||||
bus/1/mute = false
|
||||
bus/1/bypass_fx = false
|
||||
bus/1/volume_db = 0.0
|
||||
bus/1/send = &"Master"
|
||||
bus/2/name = "Ambient"
|
||||
bus/2/solo = false
|
||||
bus/2/mute = false
|
||||
bus/2/bypass_fx = false
|
||||
bus/2/volume_db = 0.0
|
||||
bus/2/send = &"Master"
|
||||
bus/3/name = "WorldSFX"
|
||||
bus/3/solo = false
|
||||
bus/3/mute = false
|
||||
bus/3/bypass_fx = false
|
||||
bus/3/volume_db = 0.0
|
||||
bus/3/send = &"Master"
|
||||
bus/4/name = "PlayerActions"
|
||||
bus/4/solo = false
|
||||
bus/4/mute = false
|
||||
bus/4/bypass_fx = false
|
||||
bus/4/volume_db = 0.0
|
||||
bus/4/send = &"Master"
|
||||
bus/5/name = "UISounds"
|
||||
bus/5/solo = false
|
||||
bus/5/mute = false
|
||||
bus/5/bypass_fx = false
|
||||
bus/5/volume_db = 0.0
|
||||
bus/5/send = &"Master"
|
||||
@@ -20,6 +20,18 @@ config/icon="res://icon.svg"
|
||||
SimBridge="*res://scripts/autoloads/sim_bridge.gd"
|
||||
GameState="*res://scripts/autoloads/game_state.gd"
|
||||
InputMapper="*res://scripts/autoloads/input_mapper.gd"
|
||||
UIStrings="*res://scripts/autoloads/ui_strings.gd"
|
||||
FogState="*res://scripts/autoloads/fog_state.gd"
|
||||
AudioManager="*res://scripts/autoloads/audio_manager.gd"
|
||||
|
||||
[audio]
|
||||
|
||||
buses/default_bus_layout="res://default_bus_layout.tres"
|
||||
|
||||
[gui]
|
||||
|
||||
theme/custom="res://assets/theme/game_theme.tres"
|
||||
theme/custom_font="res://assets/fonts/michroma_ui.tres"
|
||||
|
||||
[display]
|
||||
|
||||
@@ -93,6 +105,26 @@ pause={
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
stance_up={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":67,"key_label":0,"unicode":99,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
stance_down={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":88,"key_label":0,"unicode":120,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
bug_report={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194343,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
teleport_hub={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194317,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
|
||||
[rendering]
|
||||
|
||||
|
||||
+136
-6
@@ -1,38 +1,168 @@
|
||||
[gd_scene load_steps=9 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
[gd_scene load_steps=22 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/entity_renderer.gd" id="3_entity"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/fog_renderer.gd" id="4_fog"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/fog_shader.gd" id="4_fog"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/tile_renderer.gd" id="5_tile"]
|
||||
[ext_resource type="PackedScene" path="res://ui/hud.tscn" id="6_hud"]
|
||||
[ext_resource type="PackedScene" path="res://ui/minimap.tscn" id="7_minimap"]
|
||||
[ext_resource type="PackedScene" path="res://ui/monologue_display.tscn" id="8_monologue"]
|
||||
[ext_resource type="PackedScene" path="res://ui/interaction_prompt.tscn" id="9_prompt"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/cursor_renderer.gd" id="10_cursor"]
|
||||
[ext_resource type="PackedScene" path="res://ui/interaction_list.tscn" id="11_ilist"]
|
||||
[ext_resource type="PackedScene" path="res://ui/inventory_grid.tscn" id="12_inv"]
|
||||
[ext_resource type="PackedScene" path="res://ui/stance_indicator.tscn" id="13_stance"]
|
||||
[ext_resource type="PackedScene" path="res://ui/world_radial.tscn" id="14_radial"]
|
||||
[ext_resource type="PackedScene" path="res://ui/dialogue_box.tscn" id="15_dialogue"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/fog_entities.gd" id="16_fogent"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/sound_indicator_renderer.gd" id="20_soundind"]
|
||||
[ext_resource type="PackedScene" path="res://ui/gauntlet_hud.tscn" id="17_gauntlet"]
|
||||
[ext_resource type="PackedScene" path="res://ui/checklist_overlay.tscn" id="18_checklist"]
|
||||
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"]
|
||||
[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"]
|
||||
|
||||
[node name="Game" type="Node2D"]
|
||||
script = ExtResource("1_main")
|
||||
|
||||
; D-049 Z-level rendering pipeline (z-layer-gap-analysis.md)
|
||||
;
|
||||
; World scope (z:-200 to z:900, inside FogGroup CanvasGroup):
|
||||
; z:0 FloorTiles — ground plane
|
||||
; z:10 FloorObjects — cosmetic floor detail, ground shadows
|
||||
; z:100 YSortGroup — y-sorted: furniture, entities, wall faces (all z:0 relative)
|
||||
; z:200 [future] — airborne (projectiles, low-flying objects)
|
||||
; z:300 Overhead — ceiling edges, semi-transparent occlusion
|
||||
; z:350 [future] — high airborne (above ceiling, scale > 1.0)
|
||||
; z:400 [future] — upper floor content (rare with fixed camera)
|
||||
; z:900 FogOverlay — OUTSIDE FogGroup, fog shader
|
||||
;
|
||||
; Y-sort contract: ALL children of YSortGroup that participate in positional
|
||||
; occlusion MUST have z_index = 0. z_index is PRIMARY sort, y is SECONDARY.
|
||||
; Entities node is AFTER Furniture node — D-044 entity-wins-ties on same y.
|
||||
|
||||
[node name="World" type="Node2D" parent="."]
|
||||
script = ExtResource("2_world")
|
||||
|
||||
[node name="TileMapLayer" type="TileMapLayer" parent="World"]
|
||||
; --- World content inside CanvasGroup for fog compositing ---
|
||||
; CanvasGroup captures everything beneath it into one texture.
|
||||
; FogOverlay (outside) draws the fog shader over this composited texture.
|
||||
|
||||
[node name="FogGroup" type="CanvasGroup" parent="World"]
|
||||
|
||||
; z:0 — Floor tiles: zone identity, movement surface
|
||||
[node name="FloorTiles" type="TileMapLayer" parent="World/FogGroup"]
|
||||
z_index = 0
|
||||
script = ExtResource("5_tile")
|
||||
|
||||
[node name="FogOverlay" type="TileMapLayer" parent="World"]
|
||||
script = ExtResource("4_fog")
|
||||
; z:10 — Floor objects: cosmetic detail, walked over, ground shadows from airborne
|
||||
; (placeholder — populated when floor object art is added)
|
||||
[node name="FloorObjects" type="Node2D" parent="World/FogGroup"]
|
||||
z_index = 10
|
||||
|
||||
[node name="Entities" type="Node2D" parent="World"]
|
||||
; z:100 — Y-sorted group: furniture + entities + wall faces interleave by y-position
|
||||
; ALL children MUST use z_index = 0 (y-sort contract, Godot #62715)
|
||||
[node name="YSortGroup" type="Node2D" parent="World/FogGroup"]
|
||||
y_sort_enabled = true
|
||||
z_index = 100
|
||||
|
||||
; Furniture (z:0 relative) — tables, chairs, placed objects
|
||||
; Placeholder — before Entities in tree order so entities win visual ties (D-044)
|
||||
[node name="Furniture" type="Node2D" parent="World/FogGroup/YSortGroup"]
|
||||
y_sort_enabled = true
|
||||
z_index = 0
|
||||
|
||||
; Entities (z:0 relative) — D-033 colored sprites, y-sorted with furniture
|
||||
; MUST be z_index = 0 for correct y-sort interleaving
|
||||
[node name="Entities" type="Node2D" parent="World/FogGroup/YSortGroup"]
|
||||
y_sort_enabled = true
|
||||
z_index = 0
|
||||
script = ExtResource("3_entity")
|
||||
|
||||
; z:300 — Overhead: ceiling edges, upper floor structure, semi-transparent occlusion
|
||||
; (placeholder — populated when overhead art is added)
|
||||
[node name="Overhead" type="Node2D" parent="World/FogGroup"]
|
||||
z_index = 300
|
||||
|
||||
; --- z:900 — Fog of perception (D-059 shader-based) ---
|
||||
; OUTSIDE FogGroup. ColorRect child with fragment shader composites
|
||||
; 5-layer fog over the world. fog_shader.gd manages uniforms.
|
||||
[node name="FogOverlay" type="Node2D" parent="World"]
|
||||
z_index = 900
|
||||
script = ExtResource("4_fog")
|
||||
|
||||
; --- z:950 — Fog entities (D-059/D-060 cognitive delay visualization) ---
|
||||
; Between fog (z:900) and InsertOverlay (CanvasLayer 10).
|
||||
; Sound pings, grey blobs, recognized entity glow + silhouette.
|
||||
[node name="FogEntities" type="Node2D" parent="World"]
|
||||
z_index = 950
|
||||
script = ExtResource("16_fogent")
|
||||
|
||||
; --- z:951 — Medium-range sound indicators (#126, D-018) ---
|
||||
; Directional arrows at fog boundary for sounds outside LOS.
|
||||
; Above FogEntities (z:950), below InsertOverlay (CanvasLayer 10).
|
||||
[node name="SoundIndicators" type="Node2D" parent="World"]
|
||||
z_index = 951
|
||||
script = ExtResource("20_soundind")
|
||||
|
||||
; --- Camera ---
|
||||
[node name="Camera2D" type="Camera2D" parent="."]
|
||||
position_smoothing_enabled = true
|
||||
position_smoothing_speed = 6.0
|
||||
zoom = Vector2(2, 2)
|
||||
|
||||
; --- Insert overlay (CanvasLayer 10) ---
|
||||
; Bloom-rendered, NOT affected by fog or camera transform.
|
||||
; World-anchored elements convert world→screen coords in scripts.
|
||||
[node name="InsertOverlay" type="CanvasLayer" parent="."]
|
||||
layer = 10
|
||||
|
||||
; InteractionPrompt — v0.1 fallback single-line "E - Talk" display
|
||||
[node name="InteractionPrompt" parent="InsertOverlay" instance=ExtResource("9_prompt")]
|
||||
|
||||
; D-057: Entity interaction vertical list — multi-verb, insert-styled
|
||||
[node name="InteractionList" parent="InsertOverlay" instance=ExtResource("11_ilist")]
|
||||
|
||||
; D-058: World radial menu — right-click, 2 spokes (Observe + Insert)
|
||||
[node name="WorldRadial" parent="InsertOverlay" instance=ExtResource("14_radial")]
|
||||
|
||||
; D-061: Dialogue box — bottom screen, max 20% height, diegetic insert UI
|
||||
[node name="DialogueBox" parent="InsertOverlay" instance=ExtResource("15_dialogue")]
|
||||
|
||||
; --- UI layer (CanvasLayer 20) ---
|
||||
; HUD, monologue, cursor — always visible, not affected by fog or camera.
|
||||
[node name="UILayer" type="CanvasLayer" parent="."]
|
||||
layer = 20
|
||||
|
||||
[node name="HUD" parent="UILayer" instance=ExtResource("6_hud")]
|
||||
|
||||
[node name="Minimap" parent="UILayer" instance=ExtResource("7_minimap")]
|
||||
|
||||
[node name="MonologueDisplay" parent="UILayer" instance=ExtResource("8_monologue")]
|
||||
|
||||
; D-053: Stance indicator — top-right, color-coded
|
||||
[node name="StanceIndicator" parent="UILayer" instance=ExtResource("13_stance")]
|
||||
|
||||
; #496: Gauntlet HUD — room timer + personal bests, hidden in non-gauntlet mode
|
||||
[node name="GauntletHUD" parent="UILayer" instance=ExtResource("17_gauntlet")]
|
||||
|
||||
; #503: Auto-checklist overlay — condition progress in gauntlet mode
|
||||
[node name="ChecklistOverlay" parent="UILayer" instance=ExtResource("18_checklist")]
|
||||
|
||||
; D-065: Inventory grid — 3x3, bottom-right, 40x40px, 1-9 hotkeys
|
||||
[node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")]
|
||||
|
||||
; D-056: Cursor state machine — insert-styled geometric cursor, topmost in UILayer
|
||||
[node name="CursorRenderer" type="Node2D" parent="UILayer"]
|
||||
script = ExtResource("10_cursor")
|
||||
|
||||
; --- Modal layer (CanvasLayer 30) ---
|
||||
; Full-screen overlays: pause menu, inventory modal, death screen.
|
||||
[node name="ModalLayer" type="CanvasLayer" parent="."]
|
||||
layer = 30
|
||||
|
||||
; #495: WRONG button (F12) — bug report capture dialog
|
||||
[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("19_bugreport")]
|
||||
|
||||
; #528: Audio settings dialog — 5-bus volume sliders, ESC/OPEN_MENU to toggle
|
||||
[node name="SettingsDialog" parent="ModalLayer" instance=ExtResource("21_settings")]
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
extends Node
|
||||
|
||||
## AudioManager autoload singleton (D-068, D-069, D-073).
|
||||
## 5-bus architecture with directory-scan registry pattern.
|
||||
## No-op fallback when audio assets absent (D-038).
|
||||
## Spatial audio positioning for close-range sounds (D-018).
|
||||
|
||||
# --- D-067: Recognition chime asset key ---
|
||||
# Fires on first fog recognition (cognitive delay onset). UISounds bus (not WorldSFX).
|
||||
# Matches sfx_monologue_chime.ogg from D-038 — "neural lattice firing" feel.
|
||||
const CHIME_RECOGNITION := "sfx_monologue_chime"
|
||||
|
||||
# --- Bus names (D-068) ---
|
||||
const BUS_MUSIC := "Music"
|
||||
const BUS_AMBIENT := "Ambient"
|
||||
const BUS_WORLD_SFX := "WorldSFX"
|
||||
const BUS_PLAYER_ACTIONS := "PlayerActions"
|
||||
const BUS_UI_SOUNDS := "UISounds"
|
||||
const BUSES := [BUS_MUSIC, BUS_AMBIENT, BUS_WORLD_SFX, BUS_PLAYER_ACTIONS, BUS_UI_SOUNDS]
|
||||
|
||||
# D-069: Audio dip profiles — dB offsets relative to player slider setting.
|
||||
# Mid-range of spec values used (e.g. -6 to -8 → -7).
|
||||
# NOTE: listening_focus requires 30+ stationary ticks before activation (D-069/D-071).
|
||||
# The tick gate is the caller's responsibility — AudioManager only manages bus volumes.
|
||||
const DIP_SPECS := {
|
||||
"dialogue": {
|
||||
"ease_in": 0.3, "ease_out": 0.5,
|
||||
"buses": { "Ambient": -7.0 },
|
||||
},
|
||||
"confrontation": {
|
||||
"ease_in": 0.5, "ease_out": 1.0,
|
||||
"buses": { "Ambient": -11.0, "WorldSFX": -5.0 },
|
||||
"filter_hz": 800.0,
|
||||
},
|
||||
"listening_focus": {
|
||||
"ease_in": 0.5, "ease_out": 0.5,
|
||||
"buses": { "WorldSFX": 2.5 },
|
||||
},
|
||||
}
|
||||
|
||||
# Low-pass filter default cutoff — effectively bypassed at this value.
|
||||
const FILTER_CUTOFF_DEFAULT := 20500.0
|
||||
|
||||
# --- D-073: Zone crossfade constants ---
|
||||
const CROSSFADE_DURATION := 1.8 # D-073: 1.5-2s spec, mid-range
|
||||
|
||||
# Maps server zone_id strings to ambient asset keys (filenames in res://audio/).
|
||||
# Hub and Workplace intentionally share the same ambient layer (amb_hub_layer) —
|
||||
# they are the same location type, so hub→workplace transition is a same-asset no-op
|
||||
# (old_asset != new_asset guard skips the fade-out). Sprint brief consolidates
|
||||
# D-038's "amb_workplace_layer" to "amb_hub_layer" for v0.1.
|
||||
# Note: amb_station_base (D-038 global base hum) plays globally via play_loop()
|
||||
# at startup — it is not zone-dependent and has no ZONE_ASSETS entry.
|
||||
const ZONE_ASSETS: Dictionary = {
|
||||
"hub": "amb_hub_layer",
|
||||
"workplace": "amb_hub_layer",
|
||||
"bar": "amb_bar_layer",
|
||||
"corridor": "amb_corridor_layer",
|
||||
}
|
||||
|
||||
# Asset registry: filename stem (e.g. "amb_station_base") → AudioStream
|
||||
var _registry: Dictionary = {}
|
||||
|
||||
# Player volume settings per bus (dB). Base for proportional dip calculation.
|
||||
var _bus_volumes: Dictionary = {}
|
||||
|
||||
# Active dip state (D-069)
|
||||
var _active_dip: String = ""
|
||||
var _dip_tweens: Array = []
|
||||
|
||||
# Low-pass filter on Ambient bus (D-069 confrontation sweep)
|
||||
var _ambient_filter: AudioEffectLowPassFilter = null
|
||||
|
||||
# Ambient loop players keyed by asset_key (D-073 zone crossfade)
|
||||
var _ambient_players: Dictionary = {}
|
||||
|
||||
# D-073: Zone crossfade state
|
||||
var _current_zone_id: String = ""
|
||||
var _zone_tweens: Array = []
|
||||
|
||||
signal dip_changed(profile: String)
|
||||
|
||||
|
||||
const PREFS_PATH := "user://audio_prefs.cfg"
|
||||
|
||||
func _ready() -> void:
|
||||
_setup_buses()
|
||||
_scan_registry()
|
||||
_load_prefs()
|
||||
|
||||
|
||||
# --- Bus setup ---
|
||||
|
||||
func _setup_buses() -> void:
|
||||
for bus_name in BUSES:
|
||||
if AudioServer.get_bus_index(bus_name) < 0:
|
||||
AudioServer.add_bus()
|
||||
var idx := AudioServer.bus_count - 1
|
||||
AudioServer.set_bus_name(idx, bus_name)
|
||||
AudioServer.set_bus_send(idx, "Master")
|
||||
_bus_volumes[bus_name] = 0.0
|
||||
# Low-pass filter on Ambient bus — cutoff starts high (bypassed).
|
||||
# Confrontation dip sweeps it down to 800 Hz (D-069).
|
||||
# Guard: skip if filter already exists (e.g. duplicate _ready in tests).
|
||||
var amb_idx := AudioServer.get_bus_index(BUS_AMBIENT)
|
||||
if amb_idx >= 0 and _ambient_filter == null:
|
||||
_ambient_filter = AudioEffectLowPassFilter.new()
|
||||
_ambient_filter.cutoff_hz = FILTER_CUTOFF_DEFAULT
|
||||
AudioServer.add_bus_effect(amb_idx, _ambient_filter)
|
||||
|
||||
|
||||
# --- Asset registry (D-068 directory-scan pattern) ---
|
||||
|
||||
func _scan_registry() -> void:
|
||||
_scan_dir("res://audio/")
|
||||
print("AudioManager: %d assets registered" % _registry.size())
|
||||
|
||||
|
||||
func _scan_dir(path: String) -> void:
|
||||
var dir := DirAccess.open(path)
|
||||
if dir == null:
|
||||
if path == "res://audio/":
|
||||
print("AudioManager: res://audio/ not found — all play methods no-op")
|
||||
return
|
||||
dir.list_dir_begin()
|
||||
var file_name := dir.get_next()
|
||||
while file_name != "":
|
||||
var full_path := path.path_join(file_name)
|
||||
if dir.current_is_dir():
|
||||
_scan_dir(full_path)
|
||||
elif not file_name.ends_with(".import"):
|
||||
if file_name.get_extension().to_lower() in ["ogg", "wav", "mp3"]:
|
||||
var stream := load(full_path) as AudioStream
|
||||
if stream:
|
||||
_registry[file_name.get_basename()] = stream
|
||||
file_name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
|
||||
|
||||
func has_asset(asset_key: String) -> bool:
|
||||
return _registry.has(asset_key)
|
||||
|
||||
|
||||
func get_registry_size() -> int:
|
||||
return _registry.size()
|
||||
|
||||
|
||||
# --- Playback: non-spatial ---
|
||||
|
||||
## Play a one-shot sound on the given bus. No-ops if asset not in registry.
|
||||
func play(asset_key: String, bus: String = BUS_UI_SOUNDS) -> void:
|
||||
var stream := _get_stream(asset_key)
|
||||
if stream == null:
|
||||
return
|
||||
var player := AudioStreamPlayer.new()
|
||||
player.stream = stream
|
||||
player.bus = bus
|
||||
add_child(player)
|
||||
player.finished.connect(player.queue_free)
|
||||
player.play()
|
||||
|
||||
|
||||
## Start or replace a looping non-spatial sound. Returns the player or null.
|
||||
func play_loop(asset_key: String, bus: String = BUS_AMBIENT) -> AudioStreamPlayer:
|
||||
var stream := _get_stream(asset_key)
|
||||
if stream == null:
|
||||
return null
|
||||
stop_loop(asset_key)
|
||||
var loop_stream := stream.duplicate() as AudioStream
|
||||
if loop_stream == null:
|
||||
return null
|
||||
_enable_loop(loop_stream)
|
||||
var player := AudioStreamPlayer.new()
|
||||
player.stream = loop_stream
|
||||
player.bus = bus
|
||||
add_child(player)
|
||||
player.play()
|
||||
_ambient_players[asset_key] = player
|
||||
return player
|
||||
|
||||
|
||||
## Stop a looping sound by asset key.
|
||||
func stop_loop(asset_key: String) -> void:
|
||||
if _ambient_players.has(asset_key):
|
||||
var player: AudioStreamPlayer = _ambient_players[asset_key]
|
||||
if is_instance_valid(player):
|
||||
player.stop()
|
||||
player.queue_free()
|
||||
_ambient_players.erase(asset_key)
|
||||
|
||||
|
||||
## Stop all looping sounds.
|
||||
func stop_all_loops() -> void:
|
||||
for key in _ambient_players.keys():
|
||||
stop_loop(key)
|
||||
|
||||
|
||||
# --- Audio asset registry: event type → asset key (D-018, #125) ---
|
||||
# Maps server-sent sound event_type strings to audio asset keys.
|
||||
# Keys match filename stems in res://audio/ (scanned by _scan_registry).
|
||||
# Audio assets per D-038: footstep variants (walk / run), NPC murmur (D-072, #532).
|
||||
# Missing assets no-op gracefully (D-038 fallback pattern).
|
||||
const SOUND_EVENT_ASSETS: Dictionary = {
|
||||
"Footstep": "sfx_footstep_metal_walk",
|
||||
"FootstepWalk": "sfx_footstep_metal_walk",
|
||||
"FootstepCareful":"sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
|
||||
"FootstepCrouch": "sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
|
||||
"FootstepSprint": "sfx_footstep_metal_run",
|
||||
"FootstepRun": "sfx_footstep_metal_run",
|
||||
}
|
||||
|
||||
|
||||
## Play a close-range sound event at a world tile position (D-018, #125).
|
||||
## event_type: server RangeCategory::Close event type string (e.g. "Footstep").
|
||||
## world_tile_pos: server tile coordinates — converted to world pixels internally.
|
||||
## No-ops if event_type has no registered asset or asset file is absent.
|
||||
func play_sound_event(event_type: String, world_tile_pos: Vector2) -> void:
|
||||
var asset_key: String = SOUND_EVENT_ASSETS.get(event_type, "")
|
||||
if asset_key.is_empty():
|
||||
return
|
||||
play_at(asset_key, world_tile_pos * Constants.TILE_SIZE)
|
||||
|
||||
|
||||
# --- Playback: spatial (D-018 close-range) ---
|
||||
|
||||
## Play a one-shot spatial sound at a world position (pixels).
|
||||
## Added to current_scene for correct AudioListener2D positioning.
|
||||
func play_at(asset_key: String, world_position: Vector2, bus: String = BUS_WORLD_SFX) -> void:
|
||||
var stream := _get_stream(asset_key)
|
||||
if stream == null:
|
||||
return
|
||||
var player := AudioStreamPlayer2D.new()
|
||||
player.stream = stream
|
||||
player.bus = bus
|
||||
player.global_position = world_position
|
||||
var scene := get_tree().current_scene
|
||||
if scene:
|
||||
scene.add_child(player)
|
||||
else:
|
||||
add_child(player)
|
||||
player.finished.connect(player.queue_free)
|
||||
player.play()
|
||||
|
||||
|
||||
# --- Audio dip profiles (D-069) ---
|
||||
|
||||
## Apply a dip profile. Interrupts any active dip, restoring unaffected buses.
|
||||
## Profiles: "dialogue", "confrontation", "listening_focus"
|
||||
func apply_dip(profile: String) -> void:
|
||||
if not DIP_SPECS.has(profile):
|
||||
push_warning("AudioManager: unknown dip profile '%s'" % profile)
|
||||
return
|
||||
_kill_tweens()
|
||||
_active_dip = profile
|
||||
var spec: Dictionary = DIP_SPECS[profile]
|
||||
var buses: Dictionary = spec.get("buses", {})
|
||||
var ease_in: float = spec.get("ease_in", 0.3)
|
||||
# Tween ALL buses — affected ones get offset, unaffected ones restore to base.
|
||||
for bus_name in BUSES:
|
||||
var offset_db: float = buses.get(bus_name, 0.0)
|
||||
var target_db: float = _bus_volumes.get(bus_name, 0.0) + offset_db
|
||||
var idx := AudioServer.get_bus_index(bus_name)
|
||||
if idx < 0:
|
||||
continue
|
||||
var current_db := AudioServer.get_bus_volume_db(idx)
|
||||
if absf(current_db - target_db) < 0.1:
|
||||
continue
|
||||
var tween := create_tween()
|
||||
tween.tween_method(_make_bus_setter(bus_name), current_db, target_db, ease_in)
|
||||
_dip_tweens.append(tween)
|
||||
# Confrontation: sweep low-pass filter on Ambient (D-069)
|
||||
if spec.has("filter_hz") and _ambient_filter:
|
||||
var tween := create_tween()
|
||||
tween.tween_property(_ambient_filter, "cutoff_hz", spec["filter_hz"], ease_in)
|
||||
_dip_tweens.append(tween)
|
||||
elif _ambient_filter and _ambient_filter.cutoff_hz < FILTER_CUTOFF_DEFAULT - 100.0:
|
||||
# Leaving confrontation for another dip — restore filter
|
||||
var tween := create_tween()
|
||||
tween.tween_property(_ambient_filter, "cutoff_hz", FILTER_CUTOFF_DEFAULT, ease_in)
|
||||
_dip_tweens.append(tween)
|
||||
dip_changed.emit(profile)
|
||||
|
||||
|
||||
## Clear the active dip, restoring all bus volumes to player slider settings.
|
||||
func clear_dip() -> void:
|
||||
if _active_dip.is_empty():
|
||||
return
|
||||
var spec: Dictionary = DIP_SPECS.get(_active_dip, {})
|
||||
var ease_out: float = spec.get("ease_out", 0.5)
|
||||
_kill_tweens()
|
||||
_active_dip = ""
|
||||
for bus_name in BUSES:
|
||||
var target_db: float = _bus_volumes.get(bus_name, 0.0)
|
||||
var idx := AudioServer.get_bus_index(bus_name)
|
||||
if idx < 0:
|
||||
continue
|
||||
var current_db := AudioServer.get_bus_volume_db(idx)
|
||||
if absf(current_db - target_db) < 0.1:
|
||||
continue
|
||||
var tween := create_tween()
|
||||
tween.tween_method(_make_bus_setter(bus_name), current_db, target_db, ease_out)
|
||||
_dip_tweens.append(tween)
|
||||
# Restore low-pass filter
|
||||
if _ambient_filter and _ambient_filter.cutoff_hz < FILTER_CUTOFF_DEFAULT - 100.0:
|
||||
var tween := create_tween()
|
||||
tween.tween_property(_ambient_filter, "cutoff_hz", FILTER_CUTOFF_DEFAULT, ease_out)
|
||||
_dip_tweens.append(tween)
|
||||
dip_changed.emit("")
|
||||
|
||||
|
||||
func get_active_dip() -> String:
|
||||
return _active_dip
|
||||
|
||||
|
||||
# --- Volume control (player sliders, D-069 proportional base) ---
|
||||
|
||||
## Set the player's volume preference for a bus. During active dip, immediately
|
||||
## recalculates effective volume (proportional to new base).
|
||||
func set_volume(bus: String, volume_db: float) -> void:
|
||||
_bus_volumes[bus] = volume_db
|
||||
var idx := AudioServer.get_bus_index(bus)
|
||||
if idx < 0:
|
||||
return
|
||||
if _active_dip.is_empty():
|
||||
AudioServer.set_bus_volume_db(idx, volume_db)
|
||||
else:
|
||||
var spec: Dictionary = DIP_SPECS.get(_active_dip, {})
|
||||
var buses: Dictionary = spec.get("buses", {})
|
||||
var offset_db: float = buses.get(bus, 0.0)
|
||||
AudioServer.set_bus_volume_db(idx, volume_db + offset_db)
|
||||
_save_prefs()
|
||||
|
||||
|
||||
func get_volume(bus: String) -> float:
|
||||
return _bus_volumes.get(bus, 0.0)
|
||||
|
||||
|
||||
# --- Volume persistence (user://audio_prefs.cfg) ---
|
||||
|
||||
func _load_prefs() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
if cfg.load(PREFS_PATH) != OK:
|
||||
return
|
||||
for bus_name in BUSES:
|
||||
if cfg.has_section_key("audio", bus_name):
|
||||
var db: float = cfg.get_value("audio", bus_name, 0.0)
|
||||
# Apply directly: bypass _save_prefs() on initial load.
|
||||
_bus_volumes[bus_name] = db
|
||||
var idx := AudioServer.get_bus_index(bus_name)
|
||||
if idx >= 0:
|
||||
AudioServer.set_bus_volume_db(idx, db)
|
||||
|
||||
|
||||
func _save_prefs() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
for bus_name in BUSES:
|
||||
cfg.set_value("audio", bus_name, _bus_volumes.get(bus_name, 0.0))
|
||||
var err := cfg.save(PREFS_PATH)
|
||||
if err != OK:
|
||||
push_warning("AudioManager: failed to save prefs to %s (error %d)" % [PREFS_PATH, err])
|
||||
|
||||
|
||||
# --- Zone crossfade (D-073) ---
|
||||
|
||||
## Handle zone transition. Server sends zone_id per tile in ObserverSnapshot.
|
||||
## Hard boundary trigger with 1.5-2s audio crossfade between ambient layers.
|
||||
## Interruptible — mid-crossfade zone change tweens from current position.
|
||||
## No-op if assets absent (D-038) or same zone.
|
||||
func set_zone(zone_id: String) -> void:
|
||||
if zone_id == _current_zone_id:
|
||||
return
|
||||
var new_asset: String = ZONE_ASSETS.get(zone_id, "")
|
||||
var old_asset: String = ZONE_ASSETS.get(_current_zone_id, "")
|
||||
_current_zone_id = zone_id
|
||||
_kill_zone_tweens()
|
||||
|
||||
# Fade out old ambient layer (if different asset from incoming zone)
|
||||
if not old_asset.is_empty() and old_asset != new_asset:
|
||||
if _ambient_players.has(old_asset):
|
||||
var old_player: AudioStreamPlayer = _ambient_players[old_asset]
|
||||
if is_instance_valid(old_player):
|
||||
var tween := create_tween()
|
||||
tween.tween_property(old_player, "volume_db", -80.0, CROSSFADE_DURATION)
|
||||
tween.tween_callback(stop_loop.bind(old_asset))
|
||||
_zone_tweens.append(tween)
|
||||
|
||||
# Fade in new ambient layer
|
||||
if not new_asset.is_empty():
|
||||
if _ambient_players.has(new_asset):
|
||||
# Already playing (interrupted reverse crossfade) — tween from current volume
|
||||
var existing: AudioStreamPlayer = _ambient_players[new_asset]
|
||||
if is_instance_valid(existing):
|
||||
var tween := create_tween()
|
||||
tween.tween_property(existing, "volume_db", 0.0, CROSSFADE_DURATION)
|
||||
_zone_tweens.append(tween)
|
||||
elif has_asset(new_asset):
|
||||
var new_player := play_loop(new_asset, BUS_AMBIENT)
|
||||
if new_player:
|
||||
new_player.volume_db = -80.0
|
||||
var tween := create_tween()
|
||||
tween.tween_property(new_player, "volume_db", 0.0, CROSSFADE_DURATION)
|
||||
_zone_tweens.append(tween)
|
||||
|
||||
|
||||
func _kill_zone_tweens() -> void:
|
||||
for tween in _zone_tweens:
|
||||
if tween != null and tween.is_valid():
|
||||
tween.kill()
|
||||
_zone_tweens.clear()
|
||||
|
||||
|
||||
# --- Internal helpers ---
|
||||
|
||||
func _get_stream(asset_key: String) -> AudioStream:
|
||||
return _registry.get(asset_key) as AudioStream
|
||||
|
||||
|
||||
func _make_bus_setter(bus_name: String) -> Callable:
|
||||
return func(db: float) -> void:
|
||||
var idx := AudioServer.get_bus_index(bus_name)
|
||||
if idx >= 0:
|
||||
AudioServer.set_bus_volume_db(idx, db)
|
||||
|
||||
|
||||
func _kill_tweens() -> void:
|
||||
for tween in _dip_tweens:
|
||||
if tween != null and tween.is_valid():
|
||||
tween.kill()
|
||||
_dip_tweens.clear()
|
||||
|
||||
|
||||
static func _enable_loop(stream: AudioStream) -> void:
|
||||
if stream is AudioStreamOggVorbis:
|
||||
stream.loop = true
|
||||
elif stream is AudioStreamWAV:
|
||||
stream.loop_mode = AudioStreamWAV.LOOP_FORWARD
|
||||
elif stream is AudioStreamMP3:
|
||||
stream.loop = true
|
||||
@@ -0,0 +1 @@
|
||||
uid://cdkut8u7sxwmb
|
||||
@@ -0,0 +1,129 @@
|
||||
extends Node
|
||||
|
||||
## Fog texture state — visibility/exploration/zone tint images updated from GameState.
|
||||
## Read by fog_shader.gd for shader uniforms. Not a renderer — pure data.
|
||||
## Architecture: docs/architecture/fog-shader-spec.md | D-059
|
||||
|
||||
# Fog texture byte values — visibility and exploration layers.
|
||||
# Used by fog shader to distinguish visual treatment per tile.
|
||||
# Test assertions reference these: assert_that(byte).is_equal(FogState.VIS_FORWARD)
|
||||
const VIS_HIDDEN: int = 0 # Not in LOS — fully fogged
|
||||
const VIS_PERIPHERAL: int = 180 # In LOS, peripheral sector — light fog dimming
|
||||
const VIS_FORWARD: int = 255 # In LOS, forward sector — clear vision
|
||||
|
||||
const EXP_UNEXPLORED: int = 0 # Never seen — total darkness
|
||||
const EXP_EXPLORED: int = 128 # Previously seen, now out of LOS — deep fog
|
||||
const EXP_VISIBLE: int = 255 # Currently in LOS — clear (written each frame)
|
||||
|
||||
var map_bounds: Rect2i = Rect2i(0, 0, 1, 1)
|
||||
var visibility_texture: ImageTexture
|
||||
var exploration_texture: ImageTexture
|
||||
var zone_tint_texture: ImageTexture
|
||||
|
||||
var _vis_bytes: PackedByteArray
|
||||
var _exp_bytes: PackedByteArray
|
||||
var _vis_image: Image
|
||||
var _exp_image: Image
|
||||
var _tint_image: Image
|
||||
var _width: int = 1
|
||||
var _height: int = 1
|
||||
var _prev_visible: Dictionary = {} # Tiles visible last frame (for incremental decay)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_resize(Rect2i(0, 0, 64, 64))
|
||||
|
||||
|
||||
func _resize(bounds: Rect2i) -> void:
|
||||
map_bounds = bounds
|
||||
_width = maxi(bounds.size.x, 1)
|
||||
_height = maxi(bounds.size.y, 1)
|
||||
var sz := _width * _height
|
||||
|
||||
_vis_bytes = PackedByteArray()
|
||||
_vis_bytes.resize(sz)
|
||||
_vis_bytes.fill(VIS_HIDDEN)
|
||||
_vis_image = Image.create_from_data(_width, _height, false, Image.FORMAT_R8, _vis_bytes)
|
||||
visibility_texture = ImageTexture.create_from_image(_vis_image)
|
||||
|
||||
_exp_bytes = PackedByteArray()
|
||||
_exp_bytes.resize(sz)
|
||||
_exp_bytes.fill(EXP_UNEXPLORED)
|
||||
_exp_image = Image.create_from_data(_width, _height, false, Image.FORMAT_R8, _exp_bytes)
|
||||
exploration_texture = ImageTexture.create_from_image(_exp_image)
|
||||
|
||||
# Zone tint — neutral dark for Sprint 6 (zone metadata deferred)
|
||||
_tint_image = Image.create(_width, _height, false, Image.FORMAT_RGB8)
|
||||
_tint_image.fill(Color(0.05, 0.05, 0.08))
|
||||
zone_tint_texture = ImageTexture.create_from_image(_tint_image)
|
||||
|
||||
_prev_visible.clear()
|
||||
|
||||
|
||||
func update_from_state() -> void:
|
||||
# Resize if map bounds changed
|
||||
var tiles := GameState.visible_tiles
|
||||
if tiles.size() > 0:
|
||||
var new_bounds := _compute_bounds(tiles)
|
||||
if new_bounds != map_bounds:
|
||||
_resize(new_bounds)
|
||||
|
||||
var ox: int = map_bounds.position.x
|
||||
var oy: int = map_bounds.position.y
|
||||
var positions: Dictionary = GameState.visible_positions
|
||||
var sectors: Dictionary = GameState.visibility_sectors
|
||||
|
||||
# TODO(v0.2): gradual decay over game-time instead of immediate EXP_VISIBLE→EXP_EXPLORED
|
||||
|
||||
# 1. Clear visibility, then write current LOS
|
||||
_vis_bytes.fill(VIS_HIDDEN)
|
||||
for pos in positions:
|
||||
var px: int = pos.x - ox
|
||||
var py: int = pos.y - oy
|
||||
if px < 0 or py < 0 or px >= _width or py >= _height:
|
||||
continue
|
||||
var sector: String = sectors.get(pos, "Forward")
|
||||
_vis_bytes[py * _width + px] = VIS_FORWARD if sector == "Forward" else VIS_PERIPHERAL
|
||||
_vis_image.set_data(_width, _height, false, Image.FORMAT_R8, _vis_bytes)
|
||||
visibility_texture.update(_vis_image)
|
||||
|
||||
# 2. Exploration: tiles leaving LOS decay to EXP_EXPLORED, visible tiles stay EXP_VISIBLE
|
||||
# Only touch tiles that changed (O(visible) not O(map_size))
|
||||
for pos in _prev_visible:
|
||||
if not positions.has(pos):
|
||||
var px: int = pos.x - ox
|
||||
var py: int = pos.y - oy
|
||||
if px >= 0 and py >= 0 and px < _width and py < _height:
|
||||
var idx: int = py * _width + px
|
||||
if _exp_bytes[idx] > EXP_EXPLORED:
|
||||
_exp_bytes[idx] = EXP_EXPLORED
|
||||
|
||||
for pos in positions:
|
||||
var px: int = pos.x - ox
|
||||
var py: int = pos.y - oy
|
||||
if px >= 0 and py >= 0 and px < _width and py < _height:
|
||||
_exp_bytes[py * _width + px] = EXP_VISIBLE
|
||||
_exp_image.set_data(_width, _height, false, Image.FORMAT_R8, _exp_bytes)
|
||||
exploration_texture.update(_exp_image)
|
||||
|
||||
# Shallow copy — correct for Dictionary<Vector2i, bool/String> values
|
||||
_prev_visible = positions.duplicate()
|
||||
|
||||
|
||||
func _compute_bounds(tiles: Array) -> Rect2i:
|
||||
var min_x := 999999
|
||||
var min_y := 999999
|
||||
var max_x := -999999
|
||||
var max_y := -999999
|
||||
for tile in tiles:
|
||||
if not tile is Dictionary or not tile.has("x") or not tile.has("y"):
|
||||
continue
|
||||
min_x = mini(min_x, int(tile.x))
|
||||
min_y = mini(min_y, int(tile.y))
|
||||
max_x = maxi(max_x, int(tile.x))
|
||||
max_y = maxi(max_y, int(tile.y))
|
||||
# Guard: all tiles invalid (no x/y) — sentinels would produce negative Rect2i
|
||||
if min_x > max_x:
|
||||
return Rect2i(0, 0, 1, 1)
|
||||
# Margin for fog gradient bleed at edges
|
||||
return Rect2i(min_x - 4, min_y - 4, max_x - min_x + 9, max_y - min_y + 9)
|
||||
@@ -0,0 +1 @@
|
||||
uid://de816lfn5xeqs
|
||||
@@ -11,7 +11,7 @@ var visible_tiles: Array = []
|
||||
var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups
|
||||
|
||||
# v2 fields (D-015, D-031)
|
||||
var game_time: Dictionary = {} # {day, time_of_day, day_phase, paused} or empty
|
||||
var game_time: Dictionary = {} # {day, time_of_day, day_phase, tick_rate} or empty
|
||||
var player_facing: String = "North" # 8-directional facing direction
|
||||
var visibility_sectors: Dictionary = {} # Vector2i -> "Forward"/"Peripheral"
|
||||
|
||||
@@ -19,6 +19,60 @@ var visibility_sectors: Dictionary = {} # Vector2i -> "Forward"/"Peripheral"
|
||||
# refined when the server assigns explicit player entity IDs).
|
||||
var player_entity_id: int = 1
|
||||
|
||||
# v4 fields (#404/#405)
|
||||
var nearby_interactions: Array = [] # [{entity_id, entity_type, distance, verbs: [{kind, label, priority, available}]}]
|
||||
|
||||
# v5 fields (#414)
|
||||
var current_monologue: Variant = null # {id, text, duration_seconds} or null
|
||||
|
||||
# v6 fields (#449, D-053, D-065)
|
||||
var player_stance: String = "Walk" # Sprint/Walk/Careful/Crouch
|
||||
var player_inventory: Array = [] # [{item_id, name, slot}]
|
||||
|
||||
# v7 fields (#435, D-061/D-062)
|
||||
var current_dialogue: Variant = null # {npc_name, npc_entity_id, speech, options: [{text, response_id, priority}]} or null
|
||||
|
||||
# D-064: true while dialogue box is visible or fading out (300ms).
|
||||
# InputMapper suppresses movement when this is true.
|
||||
var dialogue_active: bool = false
|
||||
|
||||
# v8 fields (#496): Gauntlet mode — room timer + personal bests
|
||||
var room_id: Variant = null # String room_id from snapshot, null in non-gauntlet mode
|
||||
var gauntlet_mode: bool = false # true when snapshot includes gauntlet_mode flag
|
||||
|
||||
# OQ-07 (#522): Insert active state — false suppresses verb labels (z-layer 6).
|
||||
# Cursor shape changes still fire when false (D-056 option a).
|
||||
# v0.1 assumption: always true — both playable characters (detective and smuggler)
|
||||
# have neural inserts. Future characters without inserts would receive false from
|
||||
# the server's "insert_active" snapshot field, disabling all z-layer-6 UI.
|
||||
var insert_active: bool = true
|
||||
|
||||
# #507: RNG seed for replay determinism — populated from snapshot "rng_seed" field.
|
||||
# Null in v0.1 (server does not yet send this field; protocol change required).
|
||||
var rng_seed: Variant = null
|
||||
|
||||
# v7 fields (#431, D-059/D-060)
|
||||
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
|
||||
|
||||
# #126, D-018: Medium-range sound events for fog-edge directional indicators.
|
||||
# Format: [{x, y, event_type, range_category}] — server sends current medium events per tick.
|
||||
var medium_sound_events: Array = []
|
||||
|
||||
# #125, D-018: Close-range sound events for positional 2D audio.
|
||||
# Format: [{x, y, event_type, range_category}] — consumed once per tick in main.gd.
|
||||
var close_sound_events: Array = []
|
||||
|
||||
# D-071 (#530): Consecutive ticks without player position change.
|
||||
# Incremented per snapshot in apply_snapshot(). Reset to 0 on movement.
|
||||
# ListeningFocus boost activates at 30+ ticks (main.gd manages the dip).
|
||||
var stationary_ticks: int = 0
|
||||
var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previous position
|
||||
|
||||
# D-073 (#529): Server-authoritative zone_id from the player's current tile.
|
||||
# Extracted in apply_snapshot() — avoids O(N) tile scan in main.gd per Tyre review.
|
||||
# Empty string when zone_id field absent (server hasn't shipped OQ-09 yet).
|
||||
var current_zone_id: String = ""
|
||||
|
||||
func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
current_snapshot = snapshot
|
||||
|
||||
@@ -27,19 +81,37 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
|
||||
if snapshot.has("entities"):
|
||||
visible_entities = snapshot.entities
|
||||
# Derive player position from the player entity
|
||||
# Derive player position from the entity with kind.variant == "Player"
|
||||
var found_player := false
|
||||
for entity in visible_entities:
|
||||
if entity.has("entity_id") and entity.entity_id == player_entity_id:
|
||||
if entity.has("kind") and entity.kind is Dictionary and entity.kind.get("variant") == "Player":
|
||||
player_position = Vector2(entity.x, entity.y)
|
||||
if entity.has("entity_id"):
|
||||
player_entity_id = entity.entity_id
|
||||
found_player = true
|
||||
break
|
||||
if not found_player and visible_entities.size() > 0:
|
||||
push_warning("GameState: player entity_id %d not found in %d entities" % [
|
||||
player_entity_id, visible_entities.size()])
|
||||
push_warning("GameState: no Player entity found in %d entities" % [
|
||||
visible_entities.size()])
|
||||
|
||||
# D-071 (#530): Track consecutive stationary ticks for ListeningFocus boost.
|
||||
# Compares current player_position against previous snapshot's position.
|
||||
if player_position == _prev_player_position:
|
||||
stationary_ticks += 1
|
||||
else:
|
||||
stationary_ticks = 0
|
||||
_prev_player_position = player_position
|
||||
|
||||
# Tiles for rendering: test mode sends "tiles", live server sends tile data in "visible_tiles"
|
||||
if snapshot.has("tiles"):
|
||||
visible_tiles = snapshot.tiles
|
||||
elif snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
|
||||
# Live server: visible_tiles now includes type from tile_kind field
|
||||
var has_type := false
|
||||
if snapshot.visible_tiles.size() > 0 and snapshot.visible_tiles[0] is Dictionary:
|
||||
has_type = snapshot.visible_tiles[0].has("type")
|
||||
if has_type:
|
||||
visible_tiles = snapshot.visible_tiles
|
||||
|
||||
if snapshot.has("visible_positions"):
|
||||
visible_positions.clear()
|
||||
@@ -54,6 +126,93 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
if snapshot.has("player_facing") and snapshot.player_facing is String:
|
||||
player_facing = snapshot.player_facing
|
||||
|
||||
# v4: nearby_interactions (#404/#405)
|
||||
if snapshot.has("nearby_interactions") and snapshot.nearby_interactions is Array:
|
||||
nearby_interactions = snapshot.nearby_interactions
|
||||
else:
|
||||
nearby_interactions = []
|
||||
|
||||
# v5: current_monologue (#414)
|
||||
if snapshot.has("current_monologue") and snapshot.current_monologue is Dictionary:
|
||||
current_monologue = snapshot.current_monologue
|
||||
else:
|
||||
current_monologue = null
|
||||
|
||||
# v6: player_stance (#449, D-053)
|
||||
if snapshot.has("player_stance") and snapshot.player_stance is String:
|
||||
player_stance = snapshot.player_stance
|
||||
|
||||
# v6: player_inventory (#449, D-065)
|
||||
if snapshot.has("player_inventory") and snapshot.player_inventory is Array:
|
||||
player_inventory = snapshot.player_inventory
|
||||
else:
|
||||
player_inventory = []
|
||||
|
||||
# v7: current_dialogue (#434, D-061)
|
||||
if snapshot.has("current_dialogue") and snapshot.current_dialogue is Dictionary:
|
||||
current_dialogue = snapshot.current_dialogue
|
||||
else:
|
||||
current_dialogue = null
|
||||
|
||||
# v7: pending_recognitions (#431, D-059/D-060)
|
||||
if snapshot.has("pending_recognitions") and snapshot.pending_recognitions is Array:
|
||||
pending_recognitions = snapshot.pending_recognitions
|
||||
else:
|
||||
pending_recognitions = []
|
||||
|
||||
# v8: gauntlet mode (#496) — room_id and gauntlet_mode
|
||||
if snapshot.has("gauntlet_mode") and snapshot.gauntlet_mode == true:
|
||||
gauntlet_mode = true
|
||||
else:
|
||||
gauntlet_mode = false
|
||||
if snapshot.has("room_id") and snapshot.room_id is String:
|
||||
room_id = snapshot.room_id
|
||||
else:
|
||||
room_id = null
|
||||
|
||||
# OQ-07 (#522): insert_active — defaults true (v0.1 always has insert).
|
||||
# Server may send false for characters without an insert in future sprints.
|
||||
if snapshot.has("insert_active") and snapshot.insert_active is bool:
|
||||
insert_active = snapshot.insert_active
|
||||
else:
|
||||
insert_active = true
|
||||
|
||||
# #507: rng_seed — server sends current RNG seed for replay determinism.
|
||||
# Field: "rng_seed" (u64 as integer). Null if server does not include it.
|
||||
if snapshot.has("rng_seed"):
|
||||
rng_seed = snapshot.rng_seed
|
||||
else:
|
||||
rng_seed = null
|
||||
|
||||
# D-018: Sound events from server — partition by range_category.
|
||||
# #126: Medium → fog-edge directional indicators.
|
||||
# #125: Close → positional 2D audio via AudioManager.
|
||||
if snapshot.has("sound_events") and snapshot.sound_events is Array:
|
||||
medium_sound_events = []
|
||||
close_sound_events = []
|
||||
for se in snapshot.sound_events:
|
||||
if not se is Dictionary:
|
||||
continue
|
||||
var rc: String = se.get("range_category", "")
|
||||
if rc == "Medium":
|
||||
medium_sound_events.append(se)
|
||||
elif rc == "Close":
|
||||
close_sound_events.append(se)
|
||||
else:
|
||||
medium_sound_events = []
|
||||
close_sound_events = []
|
||||
|
||||
# D-073 (#529): Extract zone_id from the player's current tile (server-authoritative).
|
||||
# O(1) via visible_positions dict would be ideal, but tiles are arrays without
|
||||
# positional indexing — use the same tile iteration below instead.
|
||||
current_zone_id = ""
|
||||
var _px := int(player_position.x)
|
||||
var _py := int(player_position.y)
|
||||
for _ztile in visible_tiles:
|
||||
if _ztile is Dictionary and _ztile.get("x") == _px and _ztile.get("y") == _py:
|
||||
current_zone_id = _ztile.get("zone_id", "")
|
||||
break
|
||||
|
||||
# v2: visible_tiles with visibility sectors
|
||||
# Derives visible_positions when not explicitly provided (real server mode)
|
||||
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
|
||||
|
||||
@@ -1,55 +1,218 @@
|
||||
extends Node
|
||||
|
||||
# Semantic actions — NO raw key codes cross the bridge
|
||||
# Diagonal directions registered in project.godot with empty event arrays (intentional).
|
||||
# Keybindings deferred until input design is finalized — likely numpad or composite WASD.
|
||||
# Movement uses hold-to-move (polled each frame in _process).
|
||||
# Discrete actions (interact, stance, etc.) use press events (_unhandled_input).
|
||||
#
|
||||
# D-054: Mouse-relative facing and movement.
|
||||
# Mouse position determines facing direction (client-side float).
|
||||
# WASD is relative to facing: W = toward cursor, S = away, A/D = strafe.
|
||||
# Server receives facing octant only — the full float stays client-side.
|
||||
#
|
||||
# Movement throttle: client-side rate limit per stance (D-053).
|
||||
# Sprint=5/s, Walk=2.5/s, Careful=1.7/s, Crouch=1.25/s.
|
||||
# Server cooldown is authoritative, but the client throttle prevents
|
||||
# flooding and gives correct movement feel in test mode.
|
||||
enum Action {
|
||||
MOVE_NORTH, MOVE_NORTHEAST, MOVE_EAST, MOVE_SOUTHEAST,
|
||||
MOVE_SOUTH, MOVE_SOUTHWEST, MOVE_WEST, MOVE_NORTHWEST,
|
||||
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE
|
||||
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE,
|
||||
TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN,
|
||||
BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server
|
||||
SET_FACING, # D-054: facing octant update (no movement)
|
||||
TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel)
|
||||
}
|
||||
|
||||
var input_queue: Array[Dictionary] = []
|
||||
|
||||
# D-054: Client-side facing angle (radians). 0=East, -PI/2=North, PI/2=South.
|
||||
# Updated every frame from mouse position. EntityRenderer reads this for indicator.
|
||||
var facing_angle: float = -PI / 2.0 # Default: North
|
||||
var facing_octant: String = "North" # Derived from facing_angle
|
||||
var _last_sent_octant: String = "North" # Track to avoid redundant sends
|
||||
|
||||
# Minimum milliseconds between movement commands, per stance.
|
||||
# Tuned so Walk feels like walking, Sprint feels fast but readable.
|
||||
const MOVE_INTERVAL_MS := {
|
||||
"Sprint": 200, # 5/sec — fast but trackable
|
||||
"Walk": 400, # 2.5/sec — comfortable walking pace
|
||||
"Careful": 600, # ~1.7/sec — deliberate, scanning
|
||||
"Crouch": 800, # 1.25/sec — creeping
|
||||
}
|
||||
var _last_move_msec: int = 0
|
||||
|
||||
|
||||
# Hold-to-move: poll held direction keys each frame, throttled by stance.
|
||||
# D-054: WASD is now mouse-relative. W = toward cursor, A/D = strafe.
|
||||
# Server-side cooldown (D-053) is authoritative; this prevents client flooding.
|
||||
# D-064: movement suppressed during dialogue (walk-away handled by dialogue_box).
|
||||
func _process(_delta: float) -> void:
|
||||
# D-054: Update facing angle from mouse position every frame
|
||||
_update_facing_from_mouse()
|
||||
|
||||
if GameState.dialogue_active:
|
||||
return
|
||||
|
||||
# D-054: Send facing octant to server when it changes (even without movement)
|
||||
if facing_octant != _last_sent_octant:
|
||||
_last_sent_octant = facing_octant
|
||||
input_queue.append({
|
||||
"action": Action.SET_FACING,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {"facing": facing_octant},
|
||||
})
|
||||
|
||||
# Poll held WASD keys
|
||||
var raw_dir := Vector2i.ZERO
|
||||
if Input.is_action_pressed("move_north"):
|
||||
raw_dir.y -= 1
|
||||
if Input.is_action_pressed("move_south"):
|
||||
raw_dir.y += 1
|
||||
if Input.is_action_pressed("move_east"):
|
||||
raw_dir.x += 1
|
||||
if Input.is_action_pressed("move_west"):
|
||||
raw_dir.x -= 1
|
||||
|
||||
if raw_dir != Vector2i.ZERO:
|
||||
var now := Time.get_ticks_msec()
|
||||
var interval: int = MOVE_INTERVAL_MS.get(GameState.player_stance, 200)
|
||||
if now - _last_move_msec >= interval:
|
||||
_last_move_msec = now
|
||||
# D-054: Transform WASD input relative to mouse facing
|
||||
var world_dir := _wasd_to_world_dir(raw_dir)
|
||||
var action: Action = _dir_to_action(world_dir)
|
||||
input_queue.append({
|
||||
"action": action,
|
||||
"timestamp_msec": now,
|
||||
})
|
||||
|
||||
|
||||
# Discrete actions: fire once on key press (not held).
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
var action: Action = -1
|
||||
|
||||
# Map input actions to semantic Action enum
|
||||
# is_action_pressed handles press detection for all input types (key, gamepad, etc.)
|
||||
if event.is_action_pressed("move_north"):
|
||||
action = Action.MOVE_NORTH
|
||||
elif event.is_action_pressed("move_northeast"):
|
||||
action = Action.MOVE_NORTHEAST
|
||||
elif event.is_action_pressed("move_east"):
|
||||
action = Action.MOVE_EAST
|
||||
elif event.is_action_pressed("move_southeast"):
|
||||
action = Action.MOVE_SOUTHEAST
|
||||
elif event.is_action_pressed("move_south"):
|
||||
action = Action.MOVE_SOUTH
|
||||
elif event.is_action_pressed("move_southwest"):
|
||||
action = Action.MOVE_SOUTHWEST
|
||||
elif event.is_action_pressed("move_west"):
|
||||
action = Action.MOVE_WEST
|
||||
elif event.is_action_pressed("move_northwest"):
|
||||
action = Action.MOVE_NORTHWEST
|
||||
elif event.is_action_pressed("interact"):
|
||||
if event.is_action_pressed("interact"):
|
||||
action = Action.INTERACT
|
||||
elif event.is_action_pressed("perception_mode"):
|
||||
action = Action.USE_PERCEPTION_MODE
|
||||
elif event.is_action_pressed("open_menu"):
|
||||
action = Action.OPEN_MENU
|
||||
elif event.is_action_pressed("pause"):
|
||||
action = Action.PAUSE
|
||||
var tick_rate = GameState.game_time.get("tick_rate", "Full")
|
||||
action = Action.UNPAUSE if tick_rate == "Paused" else Action.PAUSE
|
||||
elif event.is_action_pressed("stance_up"):
|
||||
action = Action.TOGGLE_STANCE_UP
|
||||
elif event.is_action_pressed("stance_down"):
|
||||
action = Action.TOGGLE_STANCE_DOWN
|
||||
elif event.is_action_pressed("bug_report"):
|
||||
action = Action.BUG_REPORT
|
||||
elif event.is_action_pressed("teleport_hub"):
|
||||
if GameState.gauntlet_mode:
|
||||
action = Action.TELEPORT_HUB
|
||||
|
||||
# Queue the action if valid
|
||||
if action != -1:
|
||||
input_queue.append({
|
||||
"action": action,
|
||||
"timestamp_msec": Time.get_ticks_msec()
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func flush_queue() -> Array[Dictionary]:
|
||||
var queue = input_queue.duplicate()
|
||||
input_queue.clear()
|
||||
return queue
|
||||
|
||||
|
||||
## Reset facing state to default (North). Use in tests per D-030 testability.
|
||||
func reset_facing_state() -> void:
|
||||
facing_angle = -PI / 2.0
|
||||
facing_octant = "North"
|
||||
_last_sent_octant = "North"
|
||||
|
||||
|
||||
# D-054: Compute facing angle from mouse position relative to player screen position.
|
||||
# Uses viewport canvas transform to convert world coords to screen coords.
|
||||
# Intentional coupling: reads GameState.player_position directly — InputMapper is an
|
||||
# autoload that runs before game loop rendering, so position is always current-tick.
|
||||
func _update_facing_from_mouse() -> void:
|
||||
var vp := get_viewport()
|
||||
if vp == null:
|
||||
return
|
||||
var canvas_xf := vp.get_canvas_transform()
|
||||
var player_world_px := GameState.player_position * Constants.TILE_SIZE
|
||||
var player_screen := canvas_xf * player_world_px
|
||||
var mouse_screen := vp.get_mouse_position()
|
||||
var delta := mouse_screen - player_screen
|
||||
# Only update if mouse is meaningfully distant from player (avoid jitter at center)
|
||||
if delta.length_squared() > 4.0:
|
||||
facing_angle = delta.angle()
|
||||
facing_octant = _angle_to_octant(facing_angle)
|
||||
|
||||
|
||||
# D-054: Transform raw WASD input (screen-space) to world direction relative to mouse facing.
|
||||
# W (+Y up in input, mapped to forward), S (backward), A (strafe left), D (strafe right).
|
||||
# Raw input: W=(-Y), S=(+Y), A=(-X), D=(+X) in screen coords.
|
||||
# Forward = facing_angle direction. Output: nearest octant direction vector.
|
||||
func _wasd_to_world_dir(raw_dir: Vector2i) -> Vector2i:
|
||||
# Build a continuous direction vector relative to facing.
|
||||
# raw_dir.y: -1 = W (forward), +1 = S (backward)
|
||||
# raw_dir.x: -1 = A (strafe left), +1 = D (strafe right)
|
||||
var forward := Vector2(cos(facing_angle), sin(facing_angle))
|
||||
var right := Vector2(-forward.y, forward.x) # 90° clockwise
|
||||
|
||||
# Combine: forward/back from W/S, strafe from A/D
|
||||
var world_float := forward * float(-raw_dir.y) + right * float(raw_dir.x)
|
||||
|
||||
# Snap to nearest octant direction
|
||||
return _snap_to_octant_dir(world_float)
|
||||
|
||||
|
||||
# Snap a floating-point direction vector to the nearest of 8 cardinal/diagonal directions.
|
||||
static func _snap_to_octant_dir(dir: Vector2) -> Vector2i:
|
||||
if dir.length_squared() < 0.001:
|
||||
return Vector2i.ZERO
|
||||
var angle := dir.angle()
|
||||
# Quantize to nearest 45° (PI/4)
|
||||
var octant := roundi(angle / (PI / 4.0))
|
||||
match octant:
|
||||
0: return Vector2i(1, 0) # East
|
||||
1: return Vector2i(1, 1) # Southeast
|
||||
2, -6: return Vector2i(0, 1) # South
|
||||
3, -5: return Vector2i(-1, 1) # Southwest
|
||||
4, -4: return Vector2i(-1, 0) # West
|
||||
-3, 5: return Vector2i(-1, -1) # Northwest
|
||||
-2: return Vector2i(0, -1) # North
|
||||
-1: return Vector2i(1, -1) # Northeast
|
||||
_: return Vector2i.ZERO
|
||||
|
||||
|
||||
# D-054: Convert a facing angle (radians) to the nearest octant name.
|
||||
# Godot 2D: 0=East, PI/2=South, -PI/2=North.
|
||||
static func _angle_to_octant(angle: float) -> String:
|
||||
var octant := roundi(angle / (PI / 4.0))
|
||||
match octant:
|
||||
0: return "East"
|
||||
1: return "Southeast"
|
||||
2, -6: return "South"
|
||||
3, -5: return "Southwest"
|
||||
4, -4: return "West"
|
||||
-3, 5: return "Northwest"
|
||||
-2: return "North"
|
||||
-1: return "Northeast"
|
||||
_: return "East"
|
||||
|
||||
|
||||
# Map a direction vector to the corresponding movement Action.
|
||||
# Handles all 8 directions via composite W+D, W+A, etc.
|
||||
static func _dir_to_action(dir: Vector2i) -> Action:
|
||||
match dir:
|
||||
Vector2i(0, -1): return Action.MOVE_NORTH
|
||||
Vector2i(1, -1): return Action.MOVE_NORTHEAST
|
||||
Vector2i(1, 0): return Action.MOVE_EAST
|
||||
Vector2i(1, 1): return Action.MOVE_SOUTHEAST
|
||||
Vector2i(0, 1): return Action.MOVE_SOUTH
|
||||
Vector2i(-1, 1): return Action.MOVE_SOUTHWEST
|
||||
Vector2i(-1, 0): return Action.MOVE_WEST
|
||||
Vector2i(-1, -1): return Action.MOVE_NORTHWEST
|
||||
_: return Action.MOVE_NORTH
|
||||
|
||||
@@ -4,11 +4,14 @@ extends Node
|
||||
enum ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, ERROR }
|
||||
|
||||
var state: ConnectionState = ConnectionState.DISCONNECTED
|
||||
var test_mode: bool = true # Enable test mode for development without Rust server
|
||||
var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects to real server
|
||||
var _test_tick: int = 0
|
||||
var _test_player_pos: Vector2i = Vector2i(10, 10)
|
||||
var _test_facing: String = "North"
|
||||
var _test_input_queue: Array = [] # Queued actions for test mode
|
||||
var _test_in_dialogue: bool = false # Mock dialogue state (#434)
|
||||
var _test_gauntlet_mode: bool = false # #501: Gauntlet mode for dev teleport guard
|
||||
var _test_npc_relationship: String = "Unknown" # #521: NPC relationship for D-033 color
|
||||
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
|
||||
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
|
||||
|
||||
@@ -38,6 +41,9 @@ func reset_test_state() -> void:
|
||||
_test_player_pos = Vector2i(10, 10)
|
||||
_test_facing = "North"
|
||||
_test_input_queue.clear()
|
||||
_test_in_dialogue = false
|
||||
_test_gauntlet_mode = false
|
||||
_test_npc_relationship = "Unknown"
|
||||
|
||||
# Change connection state and emit signal
|
||||
func _set_state(new_state: ConnectionState) -> void:
|
||||
@@ -173,15 +179,26 @@ func send_input(player_input: Dictionary) -> Error:
|
||||
return ERR_CONNECTION_ERROR
|
||||
if test_mode:
|
||||
var action: int = player_input.get("action", -1)
|
||||
var wire_name: String = _action_enum_to_wire(action)
|
||||
var wire_name: String = action_enum_to_wire(action)
|
||||
if not wire_name.is_empty():
|
||||
_test_input_queue.append(wire_name)
|
||||
if wire_name == "SetFacing":
|
||||
# D-054: Use action_data.facing from the input dict, not InputMapper global
|
||||
var facing: String = ""
|
||||
var action_data: Variant = player_input.get("action_data")
|
||||
if action_data is Dictionary:
|
||||
facing = str(action_data.get("facing", ""))
|
||||
if not facing.is_empty():
|
||||
_test_facing = facing
|
||||
else:
|
||||
_test_input_queue.append(wire_name)
|
||||
return OK
|
||||
var action_name := _action_enum_to_wire(player_input.get("action", -1))
|
||||
var action_name := action_enum_to_wire(player_input.get("action", -1))
|
||||
if action_name.is_empty():
|
||||
# _action_enum_to_wire already emits push_warning for invalid actions
|
||||
# action_enum_to_wire already emits push_warning for invalid actions
|
||||
return ERR_INVALID_PARAMETER
|
||||
var tick: int = player_input.get("timestamp_msec", 0)
|
||||
# Use the server's current tick so drain_for_tick processes this input immediately.
|
||||
# The client-side timestamp_msec is only useful for ordering within a frame.
|
||||
var tick: int = GameState.current_tick
|
||||
var entry: Dictionary = { "tick": tick, "action_name": action_name }
|
||||
# Data variants (e.g. UsePerceptionMode) carry payload
|
||||
var action_data: Variant = player_input.get("action_data")
|
||||
@@ -210,14 +227,21 @@ func poll_snapshot() -> Variant:
|
||||
return null
|
||||
|
||||
# Called by transport layer when raw bytes arrive from the server.
|
||||
# Latest-wins semantics: newer snapshots replace unconsumed ones. This is correct
|
||||
# for real-time rendering (stale frames are worthless). Upgrade to queue if needed.
|
||||
# Latest-wins for positional state (stale frames are worthless), but one-shot
|
||||
# events (monologue, dialogue) are carried forward from overwritten snapshots
|
||||
# so they aren't silently dropped when server ticks faster than client consumes.
|
||||
func receive_bytes(bytes: PackedByteArray) -> void:
|
||||
var snapshot = Protocol.decode_snapshot(bytes)
|
||||
if snapshot != null:
|
||||
if _last_snapshot != null:
|
||||
push_warning("SimBridge: overwriting unconsumed snapshot (tick %s replaced by %s)" % [_last_snapshot.tick, snapshot.tick])
|
||||
_last_snapshot = snapshot
|
||||
if snapshot == null:
|
||||
push_warning("SimBridge: decode_snapshot returned null for %d bytes" % bytes.size())
|
||||
return
|
||||
if _last_snapshot != null:
|
||||
# Carry forward one-shot events the client hasn't consumed yet.
|
||||
if snapshot.get("current_monologue") == null and _last_snapshot.get("current_monologue") != null:
|
||||
snapshot["current_monologue"] = _last_snapshot["current_monologue"]
|
||||
if snapshot.get("current_dialogue") == null and _last_snapshot.get("current_dialogue") != null:
|
||||
snapshot["current_dialogue"] = _last_snapshot["current_dialogue"]
|
||||
_last_snapshot = snapshot
|
||||
|
||||
# Drain the outbound buffer. Returns raw input entries for batch encoding.
|
||||
func drain_outbound() -> Array[Dictionary]:
|
||||
@@ -227,7 +251,7 @@ func drain_outbound() -> Array[Dictionary]:
|
||||
|
||||
# Map InputMapper.Action enum values to wire-format action names (matching Rust PlayerAction).
|
||||
# OPEN_MENU is client-only — no Rust equivalent, not sent over the wire.
|
||||
static func _action_enum_to_wire(action: int) -> String:
|
||||
static func action_enum_to_wire(action: int) -> String:
|
||||
match action:
|
||||
InputMapper.Action.MOVE_NORTH: return "MoveNorth"
|
||||
InputMapper.Action.MOVE_NORTHEAST: return "MoveNortheast"
|
||||
@@ -240,10 +264,17 @@ static func _action_enum_to_wire(action: int) -> String:
|
||||
InputMapper.Action.INTERACT: return "Interact"
|
||||
InputMapper.Action.USE_PERCEPTION_MODE: return "UsePerceptionMode"
|
||||
InputMapper.Action.PAUSE: return "Pause"
|
||||
InputMapper.Action.UNPAUSE: return "Unpause"
|
||||
InputMapper.Action.TOGGLE_STANCE_UP: return "ToggleStanceUp"
|
||||
InputMapper.Action.TOGGLE_STANCE_DOWN: return "ToggleStanceDown"
|
||||
InputMapper.Action.OPEN_MENU:
|
||||
# Client-only action, not part of wire protocol
|
||||
push_warning("SimBridge: OPEN_MENU is client-only, not sent to server")
|
||||
return ""
|
||||
return "" # Client-only action, not part of wire protocol
|
||||
InputMapper.Action.BUG_REPORT:
|
||||
return "" # Client-only action (#495), not part of wire protocol
|
||||
InputMapper.Action.SET_FACING:
|
||||
return "SetFacing" # D-054: facing octant update (no movement)
|
||||
InputMapper.Action.TELEPORT_HUB:
|
||||
return "TeleportToHub" # #501: Gauntlet dev teleport (not production fast-travel)
|
||||
_:
|
||||
push_warning("SimBridge: unknown action enum %s" % action)
|
||||
return ""
|
||||
@@ -259,12 +290,26 @@ func _test_snapshot() -> Dictionary:
|
||||
|
||||
# Process queued inputs
|
||||
for action_name in _test_input_queue:
|
||||
if action_name == "TeleportToHub":
|
||||
# #501: Reset to hub spawn position, clear dialogue
|
||||
_test_player_pos = Vector2i(10, 10)
|
||||
_test_in_dialogue = false
|
||||
continue
|
||||
if action_name == "Interact":
|
||||
# Mock dialogue trigger (#434): if near NPC, start dialogue
|
||||
var npc_pos := Vector2i(12, 9)
|
||||
var dist := absi(_test_player_pos.x - npc_pos.x) + absi(_test_player_pos.y - npc_pos.y)
|
||||
if dist <= 2 and _test_has_los(_test_player_pos, npc_pos):
|
||||
_test_in_dialogue = true
|
||||
continue
|
||||
var delta := _action_to_delta(action_name)
|
||||
var new_pos := _test_player_pos + delta
|
||||
if _test_is_walkable(new_pos):
|
||||
_test_player_pos = new_pos
|
||||
if delta != Vector2i.ZERO:
|
||||
_test_facing = _delta_to_facing(delta)
|
||||
# Walk-away dismisses dialogue (D-064)
|
||||
if _test_in_dialogue:
|
||||
_test_in_dialogue = false
|
||||
_test_input_queue.clear()
|
||||
|
||||
var px := _test_player_pos.x
|
||||
@@ -292,22 +337,86 @@ func _test_snapshot() -> Dictionary:
|
||||
"z": 0,
|
||||
"kind": { "variant": "Npc", "data": null },
|
||||
"visibility": sector,
|
||||
"relationship": _test_npc_relationship,
|
||||
})
|
||||
|
||||
# v4: nearby_interactions when NPC is nearby and visible (#404/#405)
|
||||
var nearby: Array = []
|
||||
if npc_dist <= 2 and _test_has_los(Vector2i(px, py), npc_pos):
|
||||
nearby.append({
|
||||
"entity_id": 2,
|
||||
"entity_type": "Npc",
|
||||
"distance": npc_dist,
|
||||
"verbs": [
|
||||
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
|
||||
{"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true},
|
||||
],
|
||||
})
|
||||
|
||||
# v5: monologue on first tick (#414)
|
||||
var monologue: Variant = null
|
||||
if _test_tick == 1:
|
||||
monologue = {
|
||||
"id": "test_enter_001",
|
||||
"text": "Sova Transit District. Population twelve thousand and change.",
|
||||
"duration_seconds": 5.0,
|
||||
}
|
||||
|
||||
# v7: mock dialogue (#435, D-061/D-062) — triggered by Interact near NPC
|
||||
# Sustained: dialogue persists across ticks while _test_in_dialogue is true.
|
||||
# Movement (walk-away) clears it. Client consume-once guards against re-show.
|
||||
# Options: structured {text, response_id, priority} per #435.
|
||||
var dialogue: Variant = null
|
||||
if _test_in_dialogue:
|
||||
dialogue = {
|
||||
"npc_name": "Kael",
|
||||
"npc_entity_id": 2,
|
||||
"speech": "Haven't seen you around the transit hub before. You new to Sova, or just passing through?",
|
||||
"options": [
|
||||
{"text": "Just arrived. Still getting my bearings.", "response_id": "kael_greet_01", "priority": 1, "confrontation": false},
|
||||
{"text": "Passing through. Know where I can find work?", "response_id": "kael_greet_02", "priority": 2, "confrontation": false},
|
||||
{"text": "I saw you near the cargo bay last night.", "response_id": "kael_confront_01", "priority": 3, "confrontation": true},
|
||||
],
|
||||
}
|
||||
|
||||
# v7: mock pending_recognitions (#431, D-059/D-060) — cognitive delay fog entity
|
||||
# Entity at (13, 12) in fog: starts as grey blob, transitions to recognized over 6 ticks.
|
||||
# Cycles every 12 ticks: 6 ticks recognizing, 6 ticks off (simulates repeat encounters).
|
||||
var pending_recs: Array = []
|
||||
var cycle_pos := _test_tick % 12
|
||||
if cycle_pos < 6:
|
||||
var total_delay := 6
|
||||
var remaining := total_delay - cycle_pos
|
||||
pending_recs.append({
|
||||
"entity_id": 100,
|
||||
"x": 13.5,
|
||||
"y": 12.5,
|
||||
"z": 0,
|
||||
"remaining_ticks": remaining,
|
||||
"total_delay_ticks": total_delay,
|
||||
})
|
||||
|
||||
return {
|
||||
"tick": _test_tick,
|
||||
"version": 2,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"game_time": {
|
||||
"day": 0,
|
||||
"time_of_day": _test_tick * 10,
|
||||
"day_phase": "Morning",
|
||||
"paused": false,
|
||||
"tick_rate": "Full",
|
||||
},
|
||||
"player_facing": _test_facing,
|
||||
"player_stance": "Walk",
|
||||
"player_inventory": [],
|
||||
"entities": entities,
|
||||
"tiles": _test_tiles(),
|
||||
"visible_tiles": _test_visible_tiles(),
|
||||
"visible_positions": _test_visible_positions(),
|
||||
"nearby_interactions": nearby,
|
||||
"current_monologue": monologue,
|
||||
"current_dialogue": dialogue,
|
||||
"pending_recognitions": pending_recs,
|
||||
"gauntlet_mode": _test_gauntlet_mode,
|
||||
}
|
||||
|
||||
# Generate a small test room: 8x6 room with walls, a door, and floor
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
extends Node
|
||||
|
||||
# UIStrings — loads UI display text from data/ui-strings.yaml.
|
||||
# Access: UIStrings.get_text("hud.mode_label") -> "Mode"
|
||||
# Falls back to the key itself if not found, with a push_warning.
|
||||
|
||||
const STRINGS_PATH: String = "res://data/ui-strings.yaml"
|
||||
|
||||
var _strings: Dictionary = {}
|
||||
var _loaded: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
_load_strings()
|
||||
|
||||
func _load_strings() -> void:
|
||||
if not FileAccess.file_exists(STRINGS_PATH):
|
||||
push_warning("UIStrings: file not found: %s" % STRINGS_PATH)
|
||||
return
|
||||
var file := FileAccess.open(STRINGS_PATH, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("UIStrings: failed to open: %s" % STRINGS_PATH)
|
||||
return
|
||||
var text := file.get_as_text()
|
||||
file.close()
|
||||
_strings = _parse_yaml(text)
|
||||
_loaded = true
|
||||
print("UIStrings: loaded %d strings from %s" % [_strings.size(), STRINGS_PATH])
|
||||
|
||||
## Get a UI string by dotted key. Returns the key itself if not found.
|
||||
func get_text(key: String) -> String:
|
||||
if _strings.has(key):
|
||||
return _strings[key]
|
||||
push_warning("UIStrings: missing key '%s'" % key)
|
||||
return key
|
||||
|
||||
## Check if a key exists.
|
||||
func has_key(key: String) -> bool:
|
||||
return _strings.has(key)
|
||||
|
||||
## Get all keys.
|
||||
func get_all_keys() -> PackedStringArray:
|
||||
return PackedStringArray(_strings.keys())
|
||||
|
||||
## Reload from disk (useful for hot-reload during development).
|
||||
func reload() -> void:
|
||||
_strings.clear()
|
||||
_loaded = false
|
||||
_load_strings()
|
||||
|
||||
## Parse YAML with arbitrary nesting depth.
|
||||
## Returns flat Dictionary with dotted keys: { "section.sub.key": "value" }.
|
||||
static func _parse_yaml(text: String) -> Dictionary:
|
||||
var strings := {}
|
||||
var stack: Array = [] # [[indent, key], ...]
|
||||
for line in text.split("\n"):
|
||||
var stripped := line.strip_edges(false, true)
|
||||
if stripped.is_empty() or stripped.begins_with("#"):
|
||||
continue
|
||||
var indent := line.length() - line.lstrip(" ").length()
|
||||
var content := stripped.strip_edges()
|
||||
var colon_pos := content.find(":")
|
||||
if colon_pos < 0:
|
||||
continue
|
||||
var key := content.substr(0, colon_pos).strip_edges()
|
||||
var val := content.substr(colon_pos + 1).strip_edges()
|
||||
# Trailing comment without a value — treat as section header
|
||||
if val.begins_with("#"):
|
||||
val = ""
|
||||
# Pop sections at same or deeper indent
|
||||
while stack.size() > 0 and stack.back()[0] >= indent:
|
||||
stack.pop_back()
|
||||
if val.is_empty():
|
||||
# Section header — push onto stack
|
||||
stack.push_back([indent, key])
|
||||
else:
|
||||
# Leaf value — extract from quotes or strip inline comment
|
||||
if val.begins_with("\""):
|
||||
var end_quote := val.find("\"", 1)
|
||||
if end_quote > 0:
|
||||
val = val.substr(1, end_quote - 1)
|
||||
else:
|
||||
val = val.substr(1)
|
||||
else:
|
||||
var comment_pos := val.find(" #")
|
||||
if comment_pos >= 0:
|
||||
val = val.substr(0, comment_pos).strip_edges()
|
||||
var dotted_key := ""
|
||||
for entry in stack:
|
||||
dotted_key += entry[1] + "."
|
||||
dotted_key += key
|
||||
strings[dotted_key] = val
|
||||
return strings
|
||||
@@ -0,0 +1 @@
|
||||
uid://xm2hswjm3pss
|
||||
@@ -0,0 +1,354 @@
|
||||
extends RefCounted
|
||||
|
||||
## #503: Auto-checklist progress tracking — evaluates ObserverSnapshot against
|
||||
## checklist YAML conditions and latches satisfied conditions.
|
||||
##
|
||||
## Usage:
|
||||
## var evaluator := ChecklistEvaluator.new()
|
||||
## evaluator.load_room("inventory_warehouse")
|
||||
## evaluator.evaluate() # call each tick
|
||||
## var results := evaluator.get_results()
|
||||
##
|
||||
## Condition types (per checklist.schema.json):
|
||||
## player_near, player_facing, entity_present, entity_absent,
|
||||
## expected_monologue, expected_dialogue, expected_interaction_verb
|
||||
##
|
||||
## Spec ref: D-030 (testability), checklist.schema.json (#497).
|
||||
|
||||
var _room_conditions: Array = [] # Conditions from per-room checklist
|
||||
var _cross_conditions: Array = [] # Conditions from cross_room_checks.yaml
|
||||
var _latched: Dictionary = {} # condition_id -> true (once met, stays met)
|
||||
var _current_room_id: String = ""
|
||||
var _content_base: String = "" # Absolute path to content/ directory
|
||||
var _loaded: bool = false
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
# Content directory lives at repo root (content/), one level above the Godot
|
||||
# project (client/). In editor/dev mode we resolve via the project path.
|
||||
# In exported builds, content is expected at res://content/ (copied by export
|
||||
# preset) — the globalize fallback won't exist, so check res:// first.
|
||||
if DirAccess.dir_exists_absolute("res://content"):
|
||||
_content_base = ProjectSettings.globalize_path("res://content")
|
||||
else:
|
||||
var project_path := ProjectSettings.globalize_path("res://")
|
||||
_content_base = project_path.path_join("../content")
|
||||
|
||||
|
||||
## Load checklist for a room. Clears per-room latches; cross-room latches persist.
|
||||
func load_room(room_id: String) -> void:
|
||||
if room_id == _current_room_id and _loaded:
|
||||
return
|
||||
|
||||
_current_room_id = room_id
|
||||
_room_conditions.clear()
|
||||
|
||||
# Clear per-room latches (keep cross-room latches)
|
||||
var cross_ids := {}
|
||||
for cond in _cross_conditions:
|
||||
cross_ids[cond.get("id", "")] = true
|
||||
var kept := {}
|
||||
for cid in _latched:
|
||||
if cross_ids.has(cid):
|
||||
kept[cid] = true
|
||||
_latched = kept
|
||||
|
||||
# Load per-room checklist
|
||||
var room_path := _content_base.path_join(
|
||||
"gauntlet/rooms/%s/checklist.yaml" % room_id)
|
||||
var room_data := _load_checklist_file(room_path)
|
||||
if room_data.has("conditions"):
|
||||
_room_conditions = room_data["conditions"]
|
||||
_warn_empty_ids(_room_conditions, room_path)
|
||||
|
||||
# Load cross-room checks (only on first load)
|
||||
if _cross_conditions.is_empty():
|
||||
var cross_path := _content_base.path_join("gauntlet/cross_room_checks.yaml")
|
||||
var cross_data := _load_checklist_file(cross_path)
|
||||
if cross_data.has("conditions"):
|
||||
_cross_conditions = cross_data["conditions"]
|
||||
_warn_empty_ids(_cross_conditions, cross_path)
|
||||
|
||||
_loaded = true
|
||||
|
||||
|
||||
## Evaluate all conditions against current GameState. Latches newly met conditions.
|
||||
func evaluate() -> void:
|
||||
for cond in _room_conditions + _cross_conditions:
|
||||
var cid: String = cond.get("id", "")
|
||||
if cid.is_empty() or _latched.has(cid):
|
||||
continue
|
||||
if _evaluate_condition(cond):
|
||||
_latched[cid] = true
|
||||
|
||||
|
||||
## Returns array of {id, description, met} for all loaded conditions.
|
||||
## Conditions with empty id are excluded (invalid, cannot be latched).
|
||||
func get_results() -> Array:
|
||||
var results: Array = []
|
||||
for cond in _room_conditions + _cross_conditions:
|
||||
var cid: String = cond.get("id", "")
|
||||
if cid.is_empty():
|
||||
continue
|
||||
results.append({
|
||||
"id": cid,
|
||||
"description": cond.get("description", ""),
|
||||
"condition_type": cond.get("condition_type", ""),
|
||||
"met": _latched.has(cid),
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
## Total number of loaded conditions (excludes conditions with empty id).
|
||||
func get_total_count() -> int:
|
||||
var count: int = 0
|
||||
for cond in _room_conditions + _cross_conditions:
|
||||
if not cond.get("id", "").is_empty():
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
## Number of latched (met) conditions.
|
||||
func get_met_count() -> int:
|
||||
return _latched.size()
|
||||
|
||||
|
||||
## Whether all conditions are met.
|
||||
func is_complete() -> bool:
|
||||
return get_met_count() >= get_total_count() and get_total_count() > 0
|
||||
|
||||
|
||||
## Whether any checklist is loaded.
|
||||
func is_loaded() -> bool:
|
||||
return _loaded
|
||||
|
||||
|
||||
## Reset all state (room change to null, or disconnect).
|
||||
func reset() -> void:
|
||||
_room_conditions.clear()
|
||||
_cross_conditions.clear()
|
||||
_latched.clear()
|
||||
_current_room_id = ""
|
||||
_loaded = false
|
||||
|
||||
|
||||
static func _warn_empty_ids(conditions: Array, path: String) -> void:
|
||||
for i in conditions.size():
|
||||
if conditions[i].get("id", "").is_empty():
|
||||
push_warning("ChecklistEvaluator: condition at index %d in %s has empty id — will be excluded from results" % [i, path])
|
||||
|
||||
|
||||
# -- Condition evaluation ------------------------------------------------------
|
||||
|
||||
func _evaluate_condition(cond: Dictionary) -> bool:
|
||||
match cond.get("condition_type", ""):
|
||||
"player_near":
|
||||
return _eval_player_near(cond)
|
||||
"player_facing":
|
||||
return _eval_player_facing(cond)
|
||||
"entity_present":
|
||||
return _eval_entity_present(cond)
|
||||
"entity_absent":
|
||||
return _eval_entity_absent(cond)
|
||||
"expected_monologue":
|
||||
return _eval_expected_monologue(cond)
|
||||
"expected_dialogue":
|
||||
return _eval_expected_dialogue(cond)
|
||||
"expected_interaction_verb":
|
||||
return _eval_expected_interaction_verb(cond)
|
||||
push_warning("ChecklistEvaluator: unknown condition_type '%s'" % cond.get("condition_type", ""))
|
||||
return false
|
||||
|
||||
|
||||
## x/y and radius are in tile coordinates (matching GameState.player_position),
|
||||
## not pixels. D-066 dual-scale: YAML authors write tile coords, pixel conversion
|
||||
## happens only at render time.
|
||||
func _eval_player_near(cond: Dictionary) -> bool:
|
||||
var tx: float = float(cond.get("x", 0))
|
||||
var ty: float = float(cond.get("y", 0))
|
||||
var radius: float = float(cond.get("radius", 0.0))
|
||||
var target := Vector2(tx, ty)
|
||||
return GameState.player_position.distance_to(target) <= radius
|
||||
|
||||
|
||||
func _eval_player_facing(cond: Dictionary) -> bool:
|
||||
var direction: String = str(cond.get("direction", ""))
|
||||
# Schema uses 4-cardinal (North/South/East/West).
|
||||
# GameState uses 8-directional. Exact match only.
|
||||
return GameState.player_facing == direction
|
||||
|
||||
|
||||
func _eval_entity_present(cond: Dictionary) -> bool:
|
||||
var entity_id: int = int(cond.get("entity_id", -1))
|
||||
return _find_entity(entity_id)
|
||||
|
||||
|
||||
func _eval_entity_absent(cond: Dictionary) -> bool:
|
||||
var entity_id: int = int(cond.get("entity_id", -1))
|
||||
return not _find_entity(entity_id)
|
||||
|
||||
|
||||
func _eval_expected_monologue(cond: Dictionary) -> bool:
|
||||
var contains: String = str(cond.get("contains", ""))
|
||||
if GameState.current_monologue == null:
|
||||
return false
|
||||
var text: String = str(GameState.current_monologue.get("text", ""))
|
||||
return text.find(contains) >= 0
|
||||
|
||||
|
||||
func _eval_expected_dialogue(cond: Dictionary) -> bool:
|
||||
var contains: String = str(cond.get("contains", ""))
|
||||
if GameState.current_dialogue == null:
|
||||
return false
|
||||
var text: String = str(GameState.current_dialogue.get("speech", ""))
|
||||
return text.find(contains) >= 0
|
||||
|
||||
|
||||
func _eval_expected_interaction_verb(cond: Dictionary) -> bool:
|
||||
var entity_id: int = int(cond.get("entity_id", -1))
|
||||
var verb: String = str(cond.get("verb", ""))
|
||||
for interaction in GameState.nearby_interactions:
|
||||
if not interaction is Dictionary:
|
||||
continue
|
||||
if int(interaction.get("entity_id", -1)) != entity_id:
|
||||
continue
|
||||
var verbs: Array = interaction.get("verbs", [])
|
||||
for v in verbs:
|
||||
if not v is Dictionary:
|
||||
continue
|
||||
if str(v.get("label", "")) == verb or str(v.get("kind", "")) == verb:
|
||||
if v.get("available", true):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
func _find_entity(entity_id: int) -> bool:
|
||||
for entity in GameState.visible_entities:
|
||||
if not entity is Dictionary:
|
||||
continue
|
||||
if int(entity.get("entity_id", -1)) == entity_id:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
# -- YAML parsing (checklist-specific) -----------------------------------------
|
||||
# Handles the constrained checklist YAML format: top-level key:value pairs,
|
||||
# a conditions array of flat dictionaries. No nested arrays or anchors.
|
||||
#
|
||||
# Limitation: unquoted values containing " #" are truncated at the comment marker.
|
||||
# Use quoted strings ("value # with hash") if values must contain literal hashes.
|
||||
|
||||
func _load_checklist_file(path: String) -> Dictionary:
|
||||
if not FileAccess.file_exists(path):
|
||||
return {}
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_warning("ChecklistEvaluator: cannot open %s" % path)
|
||||
return {}
|
||||
var text := file.get_as_text()
|
||||
file.close()
|
||||
return parse_checklist_yaml(text)
|
||||
|
||||
|
||||
static func parse_checklist_yaml(text: String) -> Dictionary:
|
||||
var result := {}
|
||||
var conditions: Array = []
|
||||
var current_item: Dictionary = {}
|
||||
var in_conditions := false
|
||||
|
||||
for line in text.split("\n"):
|
||||
var stripped := line.strip_edges(false, true)
|
||||
if stripped.is_empty() or stripped.strip_edges().begins_with("#"):
|
||||
continue
|
||||
|
||||
var indent := line.length() - line.lstrip(" ").length()
|
||||
var content := stripped.strip_edges()
|
||||
|
||||
# Detect conditions: array header
|
||||
if content == "conditions:":
|
||||
in_conditions = true
|
||||
continue
|
||||
|
||||
if not in_conditions:
|
||||
# Top-level key: value
|
||||
var colon := content.find(":")
|
||||
if colon >= 0:
|
||||
var key := content.substr(0, colon).strip_edges()
|
||||
var val_str := content.substr(colon + 1).strip_edges()
|
||||
result[key] = _parse_value(val_str)
|
||||
else:
|
||||
if content.begins_with("- "):
|
||||
# New array item — flush previous
|
||||
if not current_item.is_empty():
|
||||
conditions.append(current_item)
|
||||
current_item = {}
|
||||
var rest := content.substr(2).strip_edges()
|
||||
var colon := rest.find(":")
|
||||
if colon >= 0:
|
||||
var key := rest.substr(0, colon).strip_edges()
|
||||
var val_str := rest.substr(colon + 1).strip_edges()
|
||||
current_item[key] = _parse_value(val_str)
|
||||
elif indent >= 2 and not current_item.is_empty():
|
||||
# Continuation of current array item
|
||||
var colon := content.find(":")
|
||||
if colon >= 0:
|
||||
var key := content.substr(0, colon).strip_edges()
|
||||
var val_str := content.substr(colon + 1).strip_edges()
|
||||
current_item[key] = _parse_value(val_str)
|
||||
elif indent == 0:
|
||||
# Back to top level — shouldn't happen in valid checklist YAML
|
||||
in_conditions = false
|
||||
if not current_item.is_empty():
|
||||
conditions.append(current_item)
|
||||
current_item = {}
|
||||
var colon := content.find(":")
|
||||
if colon >= 0:
|
||||
var key := content.substr(0, colon).strip_edges()
|
||||
var val_str := content.substr(colon + 1).strip_edges()
|
||||
result[key] = _parse_value(val_str)
|
||||
|
||||
# Flush last item
|
||||
if not current_item.is_empty():
|
||||
conditions.append(current_item)
|
||||
|
||||
if not conditions.is_empty():
|
||||
result["conditions"] = conditions
|
||||
|
||||
return result
|
||||
|
||||
|
||||
static func _parse_value(val: String) -> Variant:
|
||||
if val.is_empty():
|
||||
return ""
|
||||
|
||||
# Strip inline comments (not inside quotes)
|
||||
if not val.begins_with("\""):
|
||||
var comment_pos := val.find(" #")
|
||||
if comment_pos >= 0:
|
||||
val = val.substr(0, comment_pos).strip_edges()
|
||||
|
||||
# Quoted string
|
||||
if val.begins_with("\""):
|
||||
var end_quote := val.find("\"", 1)
|
||||
if end_quote > 0:
|
||||
return val.substr(1, end_quote - 1)
|
||||
return val.substr(1)
|
||||
|
||||
# Boolean
|
||||
if val == "true":
|
||||
return true
|
||||
if val == "false":
|
||||
return false
|
||||
|
||||
# Float (contains decimal point)
|
||||
if val.contains(".") and val.is_valid_float():
|
||||
return val.to_float()
|
||||
|
||||
# Integer
|
||||
if val.is_valid_int():
|
||||
return val.to_int()
|
||||
|
||||
# Plain string
|
||||
return val
|
||||
@@ -5,6 +5,49 @@ class_name Constants
|
||||
## Tile size in pixels — all renderers and coordinate conversions use this.
|
||||
const TILE_SIZE: int = 32
|
||||
|
||||
# D-049 Z-level rendering pipeline — three-scope architecture.
|
||||
# Full spec: docs/architecture/z-layer-gap-analysis.md
|
||||
#
|
||||
# WORLD SCOPE (z:-200 to z:900, inside FogGroup CanvasGroup)
|
||||
# All world content composited into one texture, then fog drawn over it.
|
||||
# Y-sort contract: all children of YSortGroup MUST use z_index = 0.
|
||||
# z_index is PRIMARY sort, y-position is SECONDARY (Godot #62715).
|
||||
const Z_FLOOR: int = 0 # Floor tiles — ground plane
|
||||
const Z_FLOOR_OBJECTS: int = 10 # Floor objects — cosmetic, ground shadows
|
||||
# z:20-99 reserved: liquid surface (z:110), surface effects
|
||||
const Z_YSORT: int = 100 # YSortGroup — furniture + entities + walls, all z:0 relative
|
||||
# z:110 reserved: liquid surface occlusion (alpha by depth)
|
||||
# z:150-199 reserved: ground VFX (smoke origins, gas pools, sparks)
|
||||
const Z_AIRBORNE: int = 200 # Projectiles, low-flying objects (scale ~1.0)
|
||||
# z:250-299 reserved: mid-air VFX (rising smoke, floating particles)
|
||||
const Z_OVERHEAD: int = 300 # Ceiling edges, upper structure, semi-transparent
|
||||
# z:325-349 reserved: ceiling VFX (smoke through ceiling)
|
||||
const Z_HIGH_AIRBORNE: int = 350 # Above-ceiling flying (scale 1.03-1.30, alpha fades)
|
||||
const Z_UPPER_CONTENT: int = 400 # Upper-floor entities (rare with fixed camera)
|
||||
# z:500-899 reserved: edge cases
|
||||
const Z_FOG: int = 900 # FogOverlay — OUTSIDE FogGroup, fog shader
|
||||
const Z_FOG_ENTITIES: int = 950 # FogEntities — cognitive delay blobs/pings (D-059/D-060)
|
||||
# Lower floors: z:-100 per floor (floor-1: z:-100 to z:-1, floor-2: z:-200 to z:-101)
|
||||
# z:-75 to z:-51 reserved: lower floor VFX
|
||||
#
|
||||
# INSERT SCOPE (CanvasLayer 10)
|
||||
# Bloom-rendered, not affected by fog or camera transform.
|
||||
const CANVAS_INSERT: int = 10 # CanvasLayer number for InsertOverlay
|
||||
#
|
||||
# UI SCOPE (CanvasLayer 20)
|
||||
# HUD, monologue, cursor — always visible.
|
||||
const CANVAS_UI: int = 20 # CanvasLayer number for UILayer
|
||||
#
|
||||
# MODAL SCOPE (CanvasLayer 30)
|
||||
# Full-screen overlays: pause, inventory modal, death screen.
|
||||
const CANVAS_MODAL: int = 30 # CanvasLayer number for ModalLayer
|
||||
#
|
||||
# Rendering ceiling: 10 floors (25m) above current floor.
|
||||
# Above this: no sprites, ground shadows + environmental effects only.
|
||||
const RENDER_CEILING_FLOORS: int = 10
|
||||
# Visible floor window (looking down): current - 2 floors (detailed + parallax).
|
||||
const VISIBLE_FLOOR_DEPTH: int = 2
|
||||
|
||||
# D-033: Entity relationship color palette
|
||||
# Color represents the player's RELATIONSHIP to the entity, not an objective property.
|
||||
# Phase 1: default colors mapped by entity kind (Player/Npc/Object/Terrain).
|
||||
@@ -16,9 +59,49 @@ const ENTITY_COLOR_HOSTILE: Color = Color("#d45d5d") # Hostile/Dangerous —
|
||||
const ENTITY_COLOR_OBJECT: Color = Color("#8b8ba0") # Static objects — muted grey
|
||||
const ENTITY_COLOR_PLAYER: Color = Color("#e0e8ff") # Player character (detective)
|
||||
|
||||
# D-033 color lookup by relationship string (#521)
|
||||
static func color_for_relationship(relationship: String) -> Color:
|
||||
match relationship:
|
||||
"Friendly": return ENTITY_COLOR_FRIENDLY
|
||||
"PersonOfInterest": return ENTITY_COLOR_POI
|
||||
"Hostile": return ENTITY_COLOR_HOSTILE
|
||||
"Unknown": return ENTITY_COLOR_UNKNOWN
|
||||
_: return ENTITY_COLOR_UNKNOWN
|
||||
|
||||
# D-033 color lookup by entity data — uses relationship for NPCs (#521)
|
||||
static func color_for_entity_kind(entity_data: Dictionary) -> Color:
|
||||
var kind_variant: String = entity_data.get("kind", {}).get("variant", "")
|
||||
match kind_variant:
|
||||
"Player": return ENTITY_COLOR_PLAYER
|
||||
"Object", "Terrain": return ENTITY_COLOR_OBJECT
|
||||
"Npc": return color_for_relationship(entity_data.get("relationship", "Unknown"))
|
||||
_: return ENTITY_COLOR_OBJECT
|
||||
|
||||
# D-048/D-056: Insert-styled UI color palette
|
||||
# Used by dialogue box, interaction list, radial menu, and other diegetic insert UI.
|
||||
const INSERT_COLOR_TEXT: Color = Color("#c8d0e0") # Default insert text — white-blue
|
||||
const INSERT_COLOR_HOVER: Color = Color("#e8c547") # Hover/highlight — amber POI
|
||||
const INSERT_COLOR_ACTIVE: Color = Color("#6bc9a6") # Active/pressed — friendly green
|
||||
|
||||
# D-015: Peripheral vision dimming
|
||||
const PERIPHERAL_ALPHA: float = 0.5
|
||||
|
||||
# Facing direction indicator
|
||||
const FACING_INDICATOR_SIZE: float = 6.0
|
||||
const FACING_INDICATOR_OFFSET: float = 14.0
|
||||
|
||||
# D-076 (OQ-29 resolution): Dialogue box max-width in pixels.
|
||||
# 640px = 20 × TILE_SIZE (32px) — grid-aligned, ~33% of 1920px viewport.
|
||||
# Tyre architecture review 2026-02-19: readability over max-width; fits
|
||||
# two columns of text comfortably, leaves world game visible alongside.
|
||||
const DIALOGUE_MAX_WIDTH: int = 640
|
||||
|
||||
# Default camera zoom — used as fallback when get_camera_2d() returns null
|
||||
const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0)
|
||||
|
||||
# #517: Implant UI font color grading — avoid pure white, project through a lens
|
||||
const IMPLANT_TEXT_COLOR: Color = Color("#E0F7FA") # Cyan-white — primary text
|
||||
const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — secondary text
|
||||
const IMPLANT_PULSE_MIN: float = 0.85 # Alpha pulse floor
|
||||
const IMPLANT_PULSE_MAX: float = 1.0 # Alpha pulse ceiling
|
||||
const IMPLANT_PULSE_PERIOD: float = 2.5 # Seconds per pulse cycle
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://wmbu7ivynu5d
|
||||
+381
-4
@@ -1,31 +1,408 @@
|
||||
extends Node2D
|
||||
|
||||
@onready var world_renderer = $World
|
||||
@onready var fog_entities = $World/FogEntities # D-059/D-060: cognitive delay fog visualization
|
||||
@onready var camera = $Camera2D
|
||||
@onready var hud = $UILayer/HUD
|
||||
@onready var monologue_display = $UILayer/MonologueDisplay
|
||||
@onready var interaction_prompt = $InsertOverlay/InteractionPrompt # v0.1 single-line fallback
|
||||
@onready var interaction_list = $InsertOverlay/InteractionList # D-057: z-layer 6
|
||||
@onready var world_radial = $InsertOverlay/WorldRadial # D-058: z-layer 6
|
||||
@onready var dialogue_box = $InsertOverlay/DialogueBox # D-061: z-layer 6
|
||||
@onready var inventory_grid = $UILayer/InventoryGrid # D-065: z-layer 7
|
||||
@onready var stance_indicator = $UILayer/StanceIndicator # D-053: z-layer 7
|
||||
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
|
||||
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
|
||||
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
|
||||
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
||||
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
|
||||
|
||||
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
||||
var _camera_anchored: bool = false
|
||||
var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice
|
||||
var _last_dialogue_tick: int = -1
|
||||
var _known_recognition_ids: Dictionary = {} # D-067: entity_ids that have already chimed
|
||||
var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber)
|
||||
var _teleport_in_progress: bool = false # #501: defer smoothing re-enable by one frame after teleport
|
||||
var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival
|
||||
var _current_zone: String = "" # D-073 (#529): zone tracking for ambient crossfades
|
||||
|
||||
const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost activates
|
||||
|
||||
func _ready() -> void:
|
||||
print("The Settled Reach — client initialized")
|
||||
|
||||
# Connect to simulation (will use test mode initially)
|
||||
# Disable camera smoothing during init. Camera2D's position_smoothing
|
||||
# lerps an internal smoothed_camera_pos toward global_position each frame.
|
||||
# That smoothed position initializes at (0,0) — the Camera2D's default in
|
||||
# the .tscn. Even after we set global_position to the player coords,
|
||||
# smoothing causes the viewport to still show (0,0) on the first rendered
|
||||
# frame because the lerp hasn't converged. With smoothing OFF, the viewport
|
||||
# uses global_position directly. Re-enabled in _process() after anchor.
|
||||
camera.position_smoothing_enabled = false
|
||||
|
||||
# Connect to simulation (test mode sets CONNECTED immediately)
|
||||
SimBridge.connect_to_sim()
|
||||
|
||||
# Camera anchor: snap to player position before the first frame renders.
|
||||
# In test mode poll_snapshot() returns synchronously — position is set
|
||||
# immediately. In live mode the snapshot isn't available yet — _process
|
||||
# handles it. No reset_smoothing() needed: smoothing is OFF.
|
||||
var first_snapshot: Variant = SimBridge.poll_snapshot()
|
||||
if first_snapshot != null:
|
||||
GameState.apply_snapshot(first_snapshot)
|
||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
||||
_camera_anchored = true
|
||||
|
||||
# D-061: Connect dialogue box signals
|
||||
if dialogue_box:
|
||||
dialogue_box.option_selected.connect(_on_dialogue_option_selected)
|
||||
dialogue_box.dialogue_dismissed.connect(_on_dialogue_dismissed)
|
||||
dialogue_box.confrontation_monologue.connect(_on_confrontation_monologue)
|
||||
|
||||
# #496: Print gauntlet session summary on disconnect
|
||||
if gauntlet_hud:
|
||||
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
# Main game loop: poll snapshot, apply state, flush input
|
||||
var snapshot = SimBridge.poll_snapshot()
|
||||
var snapshot: Variant = SimBridge.poll_snapshot()
|
||||
if snapshot != null:
|
||||
var old_pos := GameState.player_position
|
||||
GameState.apply_snapshot(snapshot)
|
||||
|
||||
# #501: Detect teleport (large position jump > 5 tiles) and trigger fade
|
||||
if _camera_anchored and _detect_teleport(old_pos, GameState.player_position):
|
||||
_teleport_transition()
|
||||
|
||||
# Late anchor: live mode — first snapshot arrives during _process.
|
||||
# Smoothing is already OFF (disabled in _ready), so setting
|
||||
# global_position takes effect immediately with no lerp.
|
||||
if not _camera_anchored:
|
||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
||||
_camera_anchored = true
|
||||
|
||||
# Update renderers with new state
|
||||
if world_renderer and world_renderer.has_method("update_from_state"):
|
||||
world_renderer.update_from_state()
|
||||
|
||||
# OQ-07 (#522): propagate insert state to all z-layer-6 display nodes.
|
||||
# Cursor shape still fires (D-056 option a) — only verb labels suppressed.
|
||||
var insert_state := GameState.insert_active
|
||||
if cursor_renderer and cursor_renderer.has_method("set_insert_active"):
|
||||
cursor_renderer.set_insert_active(insert_state)
|
||||
if interaction_list and interaction_list.has_method("set_insert_active"):
|
||||
interaction_list.set_insert_active(insert_state)
|
||||
if interaction_prompt and interaction_prompt.has_method("set_insert_active"):
|
||||
interaction_prompt.set_insert_active(insert_state)
|
||||
|
||||
# D-057: Update interaction list from game state
|
||||
# Suppress during dialogue — player is in conversation, verb list is noise
|
||||
if interaction_list and interaction_list.has_method("update_from_state"):
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
if interaction_list.is_showing():
|
||||
interaction_list._hide()
|
||||
else:
|
||||
interaction_list.update_from_state()
|
||||
|
||||
# D-065: Update inventory grid
|
||||
if inventory_grid and inventory_grid.has_method("update_from_state"):
|
||||
inventory_grid.update_from_state()
|
||||
|
||||
# D-053: Update stance indicator
|
||||
if stance_indicator and stance_indicator.has_method("update_from_state"):
|
||||
stance_indicator.update_from_state()
|
||||
|
||||
# D-059/D-060: Update fog entity visualization (#431)
|
||||
if fog_entities and fog_entities.has_method("update_from_state"):
|
||||
fog_entities.update_from_state()
|
||||
|
||||
# D-067: Recognition chime — fire sfx_monologue_chime on first fog recognition
|
||||
_play_recognition_chimes()
|
||||
|
||||
# #496: Update gauntlet HUD (room timer + personal bests)
|
||||
if gauntlet_hud and gauntlet_hud.has_method("update_from_state"):
|
||||
gauntlet_hud.update_from_state()
|
||||
|
||||
# #503: Update checklist overlay (auto-checklist progress tracking)
|
||||
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
|
||||
checklist_overlay.update_from_state()
|
||||
|
||||
# D-018 #125: Play close-range sound events via positional 2D audio
|
||||
_play_close_sound_events()
|
||||
|
||||
# D-073 (#529): Zone ambient crossfade — detect player tile zone, trigger set_zone on change.
|
||||
_update_zone()
|
||||
|
||||
# D-071 (#530): ListeningFocus boost — stationary 30+ ticks boosts WorldSFX.
|
||||
# Only activates when no dialogue/confrontation dip is active (D-070).
|
||||
_update_listening_focus()
|
||||
|
||||
# Show monologue if server sent one this tick (#414)
|
||||
_consume_monologue()
|
||||
|
||||
# D-061: Show dialogue if server sent one this tick (#434)
|
||||
_consume_dialogue()
|
||||
|
||||
# Track camera to player position every frame (D-015: locked, no panning)
|
||||
# Camera2D smoothing handles interpolation — we just set the target
|
||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
||||
if _camera_anchored:
|
||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
||||
|
||||
# Re-enable smoothing after the first anchored frame. The frame that just
|
||||
# rendered used smoothing=OFF (correct viewport from frame one). Now we
|
||||
# turn smoothing back on and sync its internal state so subsequent frames
|
||||
# get smooth camera tracking during gameplay.
|
||||
# #501: Skip re-enable during teleport — _teleport_transition() disables
|
||||
# smoothing for a clean camera snap. Defer by one frame to avoid the
|
||||
# re-enable block in the same _process() call undoing the snap.
|
||||
if _camera_anchored and not camera.position_smoothing_enabled:
|
||||
if _teleport_in_progress:
|
||||
_teleport_in_progress = false
|
||||
else:
|
||||
camera.position_smoothing_enabled = true
|
||||
camera.reset_smoothing()
|
||||
|
||||
# Send queued input to simulation
|
||||
# #507: Server-bound inputs are accumulated into _pending_record_inputs across frames.
|
||||
# At 60fps/10tps, inputs on non-snapshot frames must not be lost from the ring buffer.
|
||||
var inputs = InputMapper.flush_queue()
|
||||
for input in inputs:
|
||||
# #495: F12 WRONG button — client-only, trigger bug report capture
|
||||
if input.action == InputMapper.Action.BUG_REPORT:
|
||||
if bug_report_dialog and not bug_report_dialog.is_active():
|
||||
bug_report_dialog.start_capture()
|
||||
continue
|
||||
# #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog
|
||||
if input.action == InputMapper.Action.OPEN_MENU:
|
||||
if settings_dialog:
|
||||
if settings_dialog.is_open():
|
||||
settings_dialog.close()
|
||||
else:
|
||||
settings_dialog.open()
|
||||
continue
|
||||
if input.action == InputMapper.Action.INTERACT:
|
||||
# D-057: prefer interaction list (multi-verb), fall back to prompt (v0.1)
|
||||
var target_id: int = -1
|
||||
var verb: String = ""
|
||||
if interaction_list and interaction_list.has_method("get_interaction_target"):
|
||||
target_id = interaction_list.get_interaction_target()
|
||||
verb = interaction_list.get_selected_verb()
|
||||
if target_id < 0 and interaction_prompt:
|
||||
target_id = interaction_prompt.get_interaction_target()
|
||||
verb = interaction_prompt.get_selected_verb()
|
||||
# Always send struct form for Interact (#415) — server expects named fields
|
||||
if target_id >= 0:
|
||||
input["action_data"] = {
|
||||
"target_entity_id": target_id,
|
||||
"verb": verb,
|
||||
}
|
||||
else:
|
||||
input["action_data"] = {
|
||||
"target_entity_id": null,
|
||||
"verb": null,
|
||||
}
|
||||
SimBridge.send_input(input)
|
||||
_pending_record_inputs.append(input)
|
||||
|
||||
# #507: Record tick data to ring buffer — once per server tick (snapshot arrival).
|
||||
# Flushes all inputs accumulated since the last snapshot (across multiple display frames),
|
||||
# then clears the accumulator for the next tick.
|
||||
if snapshot != null and bug_report_dialog and bug_report_dialog.has_method("record_tick"):
|
||||
bug_report_dialog.record_tick(
|
||||
GameState.current_tick,
|
||||
JSON.stringify(GameState.current_snapshot),
|
||||
_pending_record_inputs
|
||||
)
|
||||
_pending_record_inputs.clear()
|
||||
|
||||
|
||||
# D-018 #125: Play close-range sound events — fired once per snapshot tick.
|
||||
# Each event is passed to AudioManager.play_sound_event() for 2D positional playback
|
||||
# on the WorldSFX bus. Events with no registered asset are silently skipped (D-038).
|
||||
# Consume-once: events are cleared after processing so they don't replay if
|
||||
# _process runs again before the next server tick (D-009 multiplayer-safe pattern).
|
||||
func _play_close_sound_events() -> void:
|
||||
for evt in GameState.close_sound_events:
|
||||
if not evt is Dictionary or not evt.has("x") or not evt.has("y"):
|
||||
continue
|
||||
AudioManager.play_sound_event(
|
||||
evt.get("event_type", ""),
|
||||
Vector2(float(evt.x), float(evt.y))
|
||||
)
|
||||
GameState.close_sound_events = []
|
||||
|
||||
|
||||
# D-067: Recognition chime — fires sfx_monologue_chime when a fog entity
|
||||
# enters the cognitive delay recognition queue for the first time.
|
||||
# "The chime marks the character's attention shifting" (D-067).
|
||||
# Entities that complete recognition (leave pending_recognitions) are removed
|
||||
# from _known_recognition_ids so they can chime again if re-encountered.
|
||||
func _play_recognition_chimes() -> void:
|
||||
var active_ids: Dictionary = {}
|
||||
for rec in GameState.pending_recognitions:
|
||||
if not rec is Dictionary or not rec.has("entity_id"):
|
||||
continue
|
||||
var eid: int = rec.entity_id
|
||||
active_ids[eid] = true
|
||||
if not _known_recognition_ids.has(eid):
|
||||
_known_recognition_ids[eid] = true
|
||||
AudioManager.play(AudioManager.CHIME_RECOGNITION)
|
||||
# Expire IDs no longer in the recognition queue
|
||||
for eid in _known_recognition_ids.keys():
|
||||
if not active_ids.has(eid):
|
||||
_known_recognition_ids.erase(eid)
|
||||
|
||||
|
||||
# D-073 (#529): Zone ambient crossfade — reads zone_id from GameState.current_zone_id
|
||||
# (extracted in apply_snapshot(), server-authoritative per D-020).
|
||||
# Calls AudioManager.set_zone() when zone changes (AudioManager handles crossfade).
|
||||
func _update_zone() -> void:
|
||||
var zone := GameState.current_zone_id
|
||||
if zone != _current_zone:
|
||||
_current_zone = zone
|
||||
AudioManager.set_zone(zone)
|
||||
|
||||
|
||||
# D-071 (#530): ListeningFocus boost — World SFX +2.5dB when stationary 30+ ticks.
|
||||
# Uses AudioManager.get_active_dip() as single source of truth (no separate flag).
|
||||
# Only activates when no other dip (dialogue/confrontation) is running.
|
||||
# Only deactivates its own dip — never touches dialogue/confrontation.
|
||||
# D-070: no UI indicator — the boost is "felt, not computed."
|
||||
func _update_listening_focus() -> void:
|
||||
var current_dip := AudioManager.get_active_dip()
|
||||
var threshold_met := GameState.stationary_ticks >= LISTENING_FOCUS_TICKS
|
||||
if threshold_met and current_dip == "":
|
||||
AudioManager.apply_dip("listening_focus")
|
||||
elif not threshold_met and current_dip == "listening_focus":
|
||||
AudioManager.clear_dip()
|
||||
|
||||
|
||||
# Consume-once per tick: show monologue text, then clear.
|
||||
# Tick guard prevents re-triggering when the same tick is polled multiple
|
||||
# times (client FPS > sim tick rate).
|
||||
func _consume_monologue() -> void:
|
||||
if GameState.current_monologue == null or not monologue_display:
|
||||
return
|
||||
if GameState.current_tick == _last_monologue_tick:
|
||||
return
|
||||
_last_monologue_tick = GameState.current_tick
|
||||
var mono: Dictionary = GameState.current_monologue
|
||||
monologue_display.show_monologue(mono.get("text", ""), mono.get("duration_seconds", 5.0))
|
||||
# #502: Amber flash on room reset
|
||||
var mono_id: String = mono.get("id", "")
|
||||
if mono_id.begins_with("room_reset"):
|
||||
_screen_flash(Constants.ENTITY_COLOR_POI, 0.15)
|
||||
GameState.current_monologue = null
|
||||
|
||||
|
||||
# Consume-once per tick with ID tracking: show dialogue, then clear.
|
||||
# Tick guard + is_dialogue_active check prevent re-triggering.
|
||||
func _consume_dialogue() -> void:
|
||||
if GameState.current_dialogue == null or not dialogue_box:
|
||||
return
|
||||
if GameState.current_tick == _last_dialogue_tick:
|
||||
return
|
||||
if dialogue_box.is_dialogue_active():
|
||||
GameState.current_dialogue = null
|
||||
return
|
||||
_last_dialogue_tick = GameState.current_tick
|
||||
var dlg: Dictionary = GameState.current_dialogue
|
||||
_last_dialogue_npc_id = dlg.get("npc_entity_id", -1)
|
||||
dialogue_box.show_dialogue(
|
||||
dlg.get("npc_name", ""),
|
||||
dlg.get("speech", ""),
|
||||
dlg.get("options", [])
|
||||
)
|
||||
GameState.current_dialogue = null
|
||||
|
||||
|
||||
# D-061: Handle dialogue option selection → send to server
|
||||
func _on_dialogue_option_selected(response_id: String, text: String) -> void:
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.INTERACT,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {
|
||||
"target_entity_id": null,
|
||||
"verb": "DialogueResponse",
|
||||
"response_id": response_id,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
# D-063: Handle confrontation beat monologue → show on monologue display (layer 7)
|
||||
func _on_confrontation_monologue(text: String, duration: float) -> void:
|
||||
if monologue_display:
|
||||
monologue_display.show_monologue(text, duration)
|
||||
|
||||
|
||||
# D-064: Handle walk-away → send WalkAway{npc_id} to server
|
||||
func _on_dialogue_dismissed() -> void:
|
||||
SimBridge.send_input({
|
||||
"action": InputMapper.Action.INTERACT,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
"action_data": {
|
||||
"target_entity_id": _last_dialogue_npc_id if _last_dialogue_npc_id >= 0 else null,
|
||||
"verb": "WalkAway",
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
# #496: Finalize gauntlet stats on disconnect
|
||||
func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void:
|
||||
if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud:
|
||||
gauntlet_hud.finalize()
|
||||
|
||||
|
||||
# #501: Detect large position jump indicating a teleport (not normal movement).
|
||||
const TELEPORT_DISTANCE_THRESHOLD: float = 5.0
|
||||
|
||||
func _detect_teleport(old_pos: Vector2, new_pos: Vector2) -> bool:
|
||||
return old_pos.distance_to(new_pos) > TELEPORT_DISTANCE_THRESHOLD
|
||||
|
||||
|
||||
# #501: Gauntlet dev teleport transition — snap camera + 0.3s fade-from-black.
|
||||
# Clears dialogue/monologue/interaction state (server clears its side too).
|
||||
# Scoped to Gauntlet testing only — production fast-travel uses diegetic gates.
|
||||
func _teleport_transition() -> void:
|
||||
# Snap camera: disable smoothing, force re-anchor.
|
||||
# _teleport_in_progress defers smoothing re-enable by one frame so the
|
||||
# re-enable block at the bottom of _process() doesn't undo the snap.
|
||||
camera.position_smoothing_enabled = false
|
||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
||||
_camera_anchored = true
|
||||
_teleport_in_progress = true
|
||||
|
||||
# Clear client-side buffers
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
dialogue_box.hide_dialogue()
|
||||
|
||||
# Fade from black: instant black overlay, fades to transparent over 0.3s
|
||||
if _flash_rect and is_instance_valid(_flash_rect):
|
||||
_flash_rect.queue_free()
|
||||
_flash_rect = ColorRect.new()
|
||||
_flash_rect.color = Color(0, 0, 0, 1.0)
|
||||
_flash_rect.anchors_preset = Control.PRESET_FULL_RECT
|
||||
_flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
$UILayer.add_child(_flash_rect)
|
||||
var tween := create_tween()
|
||||
tween.tween_property(_flash_rect, "color:a", 0.0, 0.3)
|
||||
tween.tween_callback(_flash_rect.queue_free)
|
||||
|
||||
|
||||
# #502: Full-screen color flash — fades from color to transparent over duration.
|
||||
# Used for room reset amber flash. Creates ephemeral ColorRect on UILayer.
|
||||
func _screen_flash(color: Color, duration: float) -> void:
|
||||
if _flash_rect and is_instance_valid(_flash_rect):
|
||||
_flash_rect.queue_free()
|
||||
_flash_rect = ColorRect.new()
|
||||
_flash_rect.color = Color(color.r, color.g, color.b, 0.4)
|
||||
_flash_rect.anchors_preset = Control.PRESET_FULL_RECT
|
||||
_flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
$UILayer.add_child(_flash_rect)
|
||||
var tween := create_tween()
|
||||
tween.tween_property(_flash_rect, "color:a", 0.0, duration)
|
||||
tween.tween_callback(_flash_rect.queue_free)
|
||||
|
||||
@@ -9,6 +9,10 @@ class_name Protocol
|
||||
## Unit enum variants (no data) → bare strings ("MoveNorth", "Npc")
|
||||
## Data enum variants → single-element maps ({"UsePerceptionMode": "thermal"})
|
||||
|
||||
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
|
||||
## Reject snapshots where version != this value.
|
||||
const PROTOCOL_VERSION: int = 8
|
||||
|
||||
|
||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||
|
||||
@@ -27,6 +31,12 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
push_error("Protocol: snapshot missing required fields")
|
||||
return null
|
||||
|
||||
# Version check: reject snapshots from incompatible server
|
||||
var version: Variant = raw.get("version")
|
||||
if version != PROTOCOL_VERSION:
|
||||
push_error("Protocol: version mismatch (got %s, expected %s). Server and client are out of sync." % [version, PROTOCOL_VERSION])
|
||||
return null
|
||||
|
||||
var entities: Array[Dictionary] = []
|
||||
var raw_entities: Array = raw["entities"]
|
||||
var dropped := 0
|
||||
@@ -44,8 +54,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
# in any realistic scenario (would require ~29 billion years at 10 ticks/game-minute per D-031).
|
||||
var tick: int = raw["tick"]
|
||||
|
||||
# v2 fields — optional for backward compatibility
|
||||
var version: Variant = raw.get("version")
|
||||
# version already checked above; game_time for HUD display
|
||||
var game_time: Variant = raw.get("game_time")
|
||||
|
||||
# player_facing: FacingDirection is a unit enum → bare string in rmp_serde
|
||||
@@ -54,7 +63,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
if raw_facing is String:
|
||||
player_facing = raw_facing
|
||||
|
||||
# visible_tiles: Array of {x, y, z, visibility}
|
||||
# visible_tiles: Array of {x, y, z, visibility, tile_kind}
|
||||
var visible_tiles: Array = []
|
||||
var raw_vtiles: Variant = raw.get("visible_tiles")
|
||||
if raw_vtiles is Array:
|
||||
@@ -68,8 +77,109 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
var vis: Variant = raw_tile.get("visibility")
|
||||
if vis is String:
|
||||
tile_entry["visibility"] = vis
|
||||
# tile_kind: Floor/Wall/Door/Object — map to client tile type strings
|
||||
var kind: Variant = raw_tile.get("tile_kind")
|
||||
if kind is String:
|
||||
tile_entry["type"] = kind.to_lower()
|
||||
else:
|
||||
tile_entry["type"] = "floor"
|
||||
visible_tiles.append(tile_entry)
|
||||
|
||||
# v4: nearby_interactions (#404/#405)
|
||||
var nearby_interactions: Array = []
|
||||
var raw_interactions: Variant = raw.get("nearby_interactions")
|
||||
if raw_interactions is Array:
|
||||
for raw_ni in raw_interactions:
|
||||
var ni = _decode_nearby_interaction(raw_ni)
|
||||
if ni != null:
|
||||
nearby_interactions.append(ni)
|
||||
|
||||
# v5: current_monologue (#414)
|
||||
var current_monologue: Variant = null
|
||||
var raw_monologue: Variant = raw.get("current_monologue")
|
||||
if raw_monologue is Dictionary and raw_monologue.has("text"):
|
||||
current_monologue = {
|
||||
"id": str(raw_monologue.get("id", "")),
|
||||
"text": str(raw_monologue["text"]),
|
||||
"duration_seconds": float(raw_monologue.get("duration_seconds", 5.0)),
|
||||
}
|
||||
|
||||
# v6: player_stance (#449, D-053) — unit enum → bare string
|
||||
var player_stance: String = "Walk"
|
||||
var raw_stance: Variant = raw.get("player_stance")
|
||||
if raw_stance is String:
|
||||
player_stance = raw_stance
|
||||
|
||||
# v6: player_inventory (#449, D-065) — array of {item_id, name, slot}
|
||||
var player_inventory: Array = []
|
||||
var raw_inventory: Variant = raw.get("player_inventory")
|
||||
if raw_inventory is Array:
|
||||
for raw_item in raw_inventory:
|
||||
if raw_item is Dictionary and raw_item.has("item_id") and raw_item.has("name"):
|
||||
player_inventory.append({
|
||||
"item_id": int(raw_item["item_id"]),
|
||||
"name": str(raw_item["name"]),
|
||||
"slot": int(raw_item.get("slot", 0)),
|
||||
})
|
||||
|
||||
# v7: pending_recognitions (#431, D-059/D-060) — cognitive delay fog entities
|
||||
# Bounded: server sends at most ~50 pending recognitions per snapshot (practical limit
|
||||
# given perception range). Excessive arrays are truncated to prevent allocation abuse.
|
||||
const MAX_PENDING_RECOGNITIONS: int = 64
|
||||
var pending_recognitions: Array = []
|
||||
var raw_recognitions: Variant = raw.get("pending_recognitions")
|
||||
if raw_recognitions is Array:
|
||||
var count := 0
|
||||
for raw_pr in raw_recognitions:
|
||||
if count >= MAX_PENDING_RECOGNITIONS:
|
||||
push_warning("Protocol: pending_recognitions truncated at %d entries" % MAX_PENDING_RECOGNITIONS)
|
||||
break
|
||||
if raw_pr is Dictionary and raw_pr.has("entity_id") and raw_pr.has("x") and raw_pr.has("y"):
|
||||
pending_recognitions.append({
|
||||
"entity_id": int(raw_pr["entity_id"]),
|
||||
"x": float(raw_pr["x"]),
|
||||
"y": float(raw_pr["y"]),
|
||||
"z": int(raw_pr.get("z", 0)),
|
||||
"remaining_ticks": int(raw_pr.get("remaining_ticks", 0)),
|
||||
"total_delay_ticks": int(raw_pr.get("total_delay_ticks", 1)),
|
||||
})
|
||||
count += 1
|
||||
|
||||
# v7: current_dialogue (#435, D-061/D-062) — NPC speech + player response options
|
||||
# Options carry response_id for server round-trip and priority for display ordering.
|
||||
# Locked options are invisible (D-062): server filters before sending.
|
||||
var current_dialogue: Variant = null
|
||||
var raw_dialogue: Variant = raw.get("current_dialogue")
|
||||
if raw_dialogue is Dictionary and raw_dialogue.has("speech"):
|
||||
var dialogue_options: Array = []
|
||||
var raw_options: Variant = raw_dialogue.get("options")
|
||||
if raw_options is Array:
|
||||
for raw_opt in raw_options:
|
||||
if raw_opt is Dictionary and raw_opt.has("text"):
|
||||
dialogue_options.append({
|
||||
"text": str(raw_opt["text"]),
|
||||
"response_id": str(raw_opt.get("response_id", "")),
|
||||
"priority": int(raw_opt.get("priority", 0)),
|
||||
"confrontation": bool(raw_opt.get("confrontation", false)),
|
||||
})
|
||||
current_dialogue = {
|
||||
"npc_name": str(raw_dialogue.get("npc_name", "")),
|
||||
"npc_entity_id": int(raw_dialogue.get("npc_entity_id", -1)),
|
||||
"speech": str(raw_dialogue["speech"]),
|
||||
"options": dialogue_options,
|
||||
}
|
||||
|
||||
# v8: dialogue_response (#305, D-028) — NPC's spoken dialogue line from server
|
||||
# Separate from current_dialogue (client-side rich dialogue state).
|
||||
var dialogue_response: Variant = null
|
||||
var raw_dr: Variant = raw.get("dialogue_response")
|
||||
if raw_dr is Dictionary and raw_dr.has("text"):
|
||||
dialogue_response = {
|
||||
"line_id": str(raw_dr.get("line_id", "")),
|
||||
"text": str(raw_dr["text"]),
|
||||
"speaker_entity_id": int(raw_dr.get("speaker_entity_id", -1)),
|
||||
}
|
||||
|
||||
return {
|
||||
"tick": tick,
|
||||
"entities": entities,
|
||||
@@ -77,7 +187,14 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"version": version,
|
||||
"game_time": game_time,
|
||||
"player_facing": player_facing,
|
||||
"player_stance": player_stance,
|
||||
"player_inventory": player_inventory,
|
||||
"visible_tiles": visible_tiles,
|
||||
"nearby_interactions": nearby_interactions,
|
||||
"current_monologue": current_monologue,
|
||||
"current_dialogue": current_dialogue,
|
||||
"dialogue_response": dialogue_response,
|
||||
"pending_recognitions": pending_recognitions,
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +213,19 @@ static func _decode_entity(raw: Dictionary) -> Variant:
|
||||
if raw_vis is String:
|
||||
visibility = raw_vis
|
||||
|
||||
# v4: relationship (D-033) and observation state
|
||||
var relationship: String = "Unknown"
|
||||
var raw_rel: Variant = raw.get("relationship")
|
||||
if raw_rel is String:
|
||||
relationship = raw_rel
|
||||
|
||||
var observation: Variant = "Visible"
|
||||
var raw_obs: Variant = raw.get("observation")
|
||||
if raw_obs is String:
|
||||
observation = raw_obs
|
||||
elif raw_obs is Dictionary:
|
||||
observation = _decode_enum_variant(raw_obs)
|
||||
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"x": float(raw["x"]),
|
||||
@@ -103,6 +233,54 @@ static func _decode_entity(raw: Dictionary) -> Variant:
|
||||
"z": int(raw["z"]),
|
||||
"kind": _decode_enum_variant(raw["kind"]),
|
||||
"visibility": visibility,
|
||||
"relationship": relationship,
|
||||
"observation": observation,
|
||||
}
|
||||
|
||||
|
||||
## Decode a NearbyInteraction from a raw msgpack map.
|
||||
## Returns {entity_id, entity_type, distance, verbs: [{kind, label, priority, available}]} or null.
|
||||
static func _decode_nearby_interaction(raw) -> Variant:
|
||||
if not raw is Dictionary:
|
||||
return null
|
||||
if not raw.has("entity_id") or not raw.has("verbs"):
|
||||
push_warning("Protocol: nearby_interaction missing required fields: %s" % str(raw.keys()))
|
||||
return null
|
||||
var verbs: Array[Dictionary] = []
|
||||
var raw_verbs: Variant = raw.get("verbs")
|
||||
if raw_verbs is Array:
|
||||
for rv in raw_verbs:
|
||||
var verb = _decode_verb_option(rv)
|
||||
if verb != null:
|
||||
verbs.append(verb)
|
||||
if verbs.is_empty():
|
||||
return null
|
||||
var entity_type: String = ""
|
||||
var raw_et: Variant = raw.get("entity_type")
|
||||
if raw_et is String:
|
||||
entity_type = raw_et
|
||||
return {
|
||||
"entity_id": int(raw["entity_id"]),
|
||||
"entity_type": entity_type,
|
||||
"distance": int(raw.get("distance", 0)),
|
||||
"verbs": verbs,
|
||||
}
|
||||
|
||||
|
||||
## Decode a VerbOption from a raw msgpack map.
|
||||
## Returns {kind, label, priority, available} or null.
|
||||
static func _decode_verb_option(raw) -> Variant:
|
||||
if not raw is Dictionary or not raw.has("label"):
|
||||
return null
|
||||
var kind: String = ""
|
||||
var raw_kind: Variant = raw.get("kind")
|
||||
if raw_kind is String:
|
||||
kind = raw_kind
|
||||
return {
|
||||
"kind": kind,
|
||||
"label": str(raw["label"]),
|
||||
"priority": int(raw.get("priority", 0)),
|
||||
"available": bool(raw.get("available", true)),
|
||||
}
|
||||
|
||||
|
||||
@@ -127,13 +305,7 @@ static func _decode_enum_variant(raw) -> Dictionary:
|
||||
## "Interact", "UsePerceptionMode", "Pause", "Unpause"
|
||||
## action_data: null for unit variants, String for UsePerceptionMode
|
||||
static func encode_player_input(tick: int, action_name: String, action_data: Variant = null) -> PackedByteArray:
|
||||
var action_value: Variant
|
||||
if action_data != null:
|
||||
# Data variant → single-element map
|
||||
action_value = { action_name: action_data }
|
||||
else:
|
||||
# Unit variant → bare string
|
||||
action_value = action_name
|
||||
var action_value: Variant = _encode_action(action_name, action_data)
|
||||
|
||||
var input := {
|
||||
"tick": tick,
|
||||
@@ -156,14 +328,9 @@ static func encode_player_inputs(inputs: Array) -> PackedByteArray:
|
||||
for input in inputs:
|
||||
var action_name: String = input["action_name"]
|
||||
var action_data: Variant = input.get("action_data")
|
||||
var action_value: Variant
|
||||
if action_data != null:
|
||||
action_value = { action_name: action_data }
|
||||
else:
|
||||
action_value = action_name
|
||||
wire_inputs.append({
|
||||
"tick": input["tick"],
|
||||
"action": action_value,
|
||||
"action": _encode_action(action_name, action_data),
|
||||
})
|
||||
|
||||
var result = Messagepack.encode(wire_inputs)
|
||||
@@ -174,6 +341,18 @@ static func encode_player_inputs(inputs: Array) -> PackedByteArray:
|
||||
return result.value
|
||||
|
||||
|
||||
## Encode a PlayerAction for the wire.
|
||||
## Struct variants (Interact) always need their fields even when null.
|
||||
## Unit variants (MoveNorth, Pause, etc.) encode as bare strings.
|
||||
static func _encode_action(action_name: String, action_data: Variant) -> Variant:
|
||||
if action_data != null:
|
||||
return { action_name: action_data }
|
||||
# Interact is a struct variant — server expects named fields, not a bare string
|
||||
if action_name == "Interact":
|
||||
return { "Interact": { "target_entity_id": null, "verb": null } }
|
||||
return action_name
|
||||
|
||||
|
||||
## Decode a PlayerInput from MessagePack bytes (used in tests / echo scenarios).
|
||||
## Returns { "tick": int, "action": { "variant": String, "data": Variant } } or null.
|
||||
static func decode_player_input(bytes: PackedByteArray) -> Variant:
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
class_name CursorRenderer
|
||||
extends Node2D
|
||||
|
||||
## Cursor state machine — 4 geometric states, 150ms linear transitions (D-056).
|
||||
## Insert-styled cursor on z-layer 7 (UILayer CanvasLayer).
|
||||
## Detects entity hover via world-space proximity to visible entities.
|
||||
|
||||
enum State { DEFAULT, ENTITY_HOVER, OBJECT_HOVER, WEAPON_AIM }
|
||||
|
||||
# --- Public ---
|
||||
var current_state: State = State.DEFAULT
|
||||
var hovered_entity_id: int = -1
|
||||
var weapon_mode_active: bool = false
|
||||
# OQ-07 (#522): when false, verb labels are suppressed (should_show_interactions → false).
|
||||
# Cursor shape transitions still fire — the character's body still orients to targets.
|
||||
var insert_active: bool = true
|
||||
|
||||
signal state_changed(new_state: State)
|
||||
signal hovered_entity_changed(entity_id: int)
|
||||
|
||||
# D-056 colors
|
||||
const COLOR_DEFAULT := Color("#c8d0e0")
|
||||
const COLOR_OBJECT := Color("#8b8ba0")
|
||||
const COLOR_OBJECT_FLAGGED := Color("#e8c547")
|
||||
const COLOR_WEAPON := Color("#f0e8d8")
|
||||
|
||||
const TRANSITION_SEC := 0.15 # 150ms linear (D-056)
|
||||
const HOVER_RADIUS_PX := 16.0 # World pixels — ~half a tile
|
||||
|
||||
# Bracket geometry (screen pixels — sized for 24px entity at 2x zoom)
|
||||
const BRACKET_HALF := 26.0
|
||||
const BRACKET_ARM := 8.0
|
||||
|
||||
# --- Transition state ---
|
||||
var _target: State = State.DEFAULT
|
||||
var _t: float = 1.0
|
||||
var _from: Dictionary = {}
|
||||
var _time: float = 0.0
|
||||
var _mouse_inside: bool = true
|
||||
var _shift_held: bool = false
|
||||
|
||||
# Hover tracking
|
||||
var _hover_color: Color = COLOR_DEFAULT
|
||||
var _hover_offset: Vector2 = Vector2.ZERO
|
||||
|
||||
# Interpolated draw params
|
||||
var _gap: float = 4.0
|
||||
var _len: float = 6.0
|
||||
var _rot: float = 0.0
|
||||
var _thick: float = 1.0
|
||||
var _color: Color = COLOR_DEFAULT
|
||||
var _bloom: float = 0.4
|
||||
var _bracket_a: float = 0.0
|
||||
var _alpha: float = 0.45
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_HIDDEN)
|
||||
_from = _snapshot()
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_WM_MOUSE_EXIT:
|
||||
_mouse_inside = false
|
||||
visible = false
|
||||
elif what == NOTIFICATION_WM_MOUSE_ENTER:
|
||||
_mouse_inside = true
|
||||
visible = true
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not _mouse_inside:
|
||||
return
|
||||
_time += delta
|
||||
position = get_viewport().get_mouse_position()
|
||||
_detect_hover()
|
||||
if _t < 1.0:
|
||||
_t = minf(_t + delta / TRANSITION_SEC, 1.0)
|
||||
_interpolate()
|
||||
if _t >= 1.0:
|
||||
current_state = _target
|
||||
queue_redraw()
|
||||
|
||||
|
||||
# --- Hover detection (automatic, from GameState.visible_entities) ---
|
||||
|
||||
func _detect_hover() -> void:
|
||||
var prev_id := hovered_entity_id
|
||||
|
||||
if weapon_mode_active:
|
||||
var found := _find_nearest_entity()
|
||||
hovered_entity_id = found.id
|
||||
_hover_color = found.color
|
||||
_hover_offset = found.offset
|
||||
_set_target(State.WEAPON_AIM)
|
||||
if hovered_entity_id != prev_id:
|
||||
hovered_entity_changed.emit(hovered_entity_id)
|
||||
return
|
||||
|
||||
var found := _find_nearest_entity()
|
||||
hovered_entity_id = found.id
|
||||
_hover_color = found.color
|
||||
_hover_offset = found.offset
|
||||
var new_state := State.DEFAULT
|
||||
if found.id >= 0:
|
||||
new_state = State.ENTITY_HOVER if found.kind == "Npc" else State.OBJECT_HOVER
|
||||
_set_target(new_state)
|
||||
if hovered_entity_id != prev_id:
|
||||
hovered_entity_changed.emit(hovered_entity_id)
|
||||
|
||||
|
||||
func _find_nearest_entity() -> Dictionary:
|
||||
var xform := get_viewport().get_canvas_transform()
|
||||
var mouse_screen := get_viewport().get_mouse_position()
|
||||
var mouse_world: Vector2 = xform.affine_inverse() * mouse_screen
|
||||
|
||||
var best_dist := INF
|
||||
var result := { id = -1, kind = "", color = COLOR_DEFAULT, offset = Vector2.ZERO }
|
||||
|
||||
for entity in GameState.visible_entities:
|
||||
if not entity.has("entity_id") or not entity.has("x") or not entity.has("y"):
|
||||
continue
|
||||
if entity.entity_id == GameState.player_entity_id:
|
||||
continue
|
||||
var center := Vector2(
|
||||
floorf(entity.x) * Constants.TILE_SIZE + Constants.TILE_SIZE * 0.5,
|
||||
floorf(entity.y) * Constants.TILE_SIZE + Constants.TILE_SIZE * 0.5
|
||||
)
|
||||
var dist := mouse_world.distance_to(center)
|
||||
if dist < HOVER_RADIUS_PX and dist < best_dist:
|
||||
best_dist = dist
|
||||
result.id = entity.entity_id
|
||||
result.kind = entity.get("kind", {}).get("variant", "")
|
||||
result.color = Constants.color_for_entity_kind(entity)
|
||||
result.offset = (xform * center) - mouse_screen
|
||||
return result
|
||||
|
||||
|
||||
# --- State transitions ---
|
||||
|
||||
func _set_target(new_state: State) -> void:
|
||||
if new_state == _target:
|
||||
return
|
||||
_target = new_state
|
||||
_from = _snapshot()
|
||||
# D-056: weapon aim is "hard transition" — skip interpolation
|
||||
if new_state == State.WEAPON_AIM:
|
||||
_t = 1.0
|
||||
_apply_params(_params_for(State.WEAPON_AIM))
|
||||
current_state = State.WEAPON_AIM
|
||||
else:
|
||||
_t = 0.0
|
||||
state_changed.emit(new_state)
|
||||
|
||||
|
||||
func _snapshot() -> Dictionary:
|
||||
return { gap = _gap, len = _len, rot = _rot, thick = _thick,
|
||||
color = _color, bloom = _bloom, bracket_a = _bracket_a, alpha = _alpha }
|
||||
|
||||
|
||||
func _params_for(state: State) -> Dictionary:
|
||||
match state:
|
||||
State.ENTITY_HOVER:
|
||||
return { gap = 10.0, len = 6.0, rot = 0.0, thick = 1.0,
|
||||
color = _hover_color, bloom = 0.5, bracket_a = 1.0, alpha = 1.0 }
|
||||
State.OBJECT_HOVER:
|
||||
return { gap = 4.0, len = 6.0, rot = PI / 4.0, thick = 1.0,
|
||||
color = _hover_color, bloom = 0.3, bracket_a = 0.0, alpha = 0.8 }
|
||||
State.WEAPON_AIM:
|
||||
return { gap = 12.0, len = 9.0, rot = 0.0, thick = 2.0,
|
||||
color = COLOR_WEAPON, bloom = 0.0, bracket_a = 0.0, alpha = 1.0 }
|
||||
_:
|
||||
return { gap = 4.0, len = 6.0, rot = 0.0, thick = 1.0,
|
||||
color = COLOR_DEFAULT, bloom = 0.4, bracket_a = 0.0, alpha = 0.45 }
|
||||
|
||||
|
||||
func _apply_params(p: Dictionary) -> void:
|
||||
_gap = p.gap; _len = p.len; _rot = p.rot; _thick = p.thick
|
||||
_color = p.color; _bloom = p.bloom; _bracket_a = p.bracket_a; _alpha = p.alpha
|
||||
|
||||
|
||||
func _interpolate() -> void:
|
||||
var p := _params_for(_target)
|
||||
_gap = lerpf(_from.gap, p.gap, _t)
|
||||
_len = lerpf(_from.len, p.len, _t)
|
||||
_rot = lerp_angle(_from.rot, p.rot, _t)
|
||||
_thick = lerpf(_from.thick, p.thick, _t)
|
||||
_color = _from.color.lerp(p.color, _t)
|
||||
_bloom = lerpf(_from.bloom, p.bloom, _t)
|
||||
_bracket_a = lerpf(_from.bracket_a, p.bracket_a, _t)
|
||||
_alpha = lerpf(_from.alpha, p.alpha, _t)
|
||||
|
||||
|
||||
# --- Drawing ---
|
||||
|
||||
func _draw() -> void:
|
||||
# _hover_offset is computed in _detect_hover() during _process(), not recalculated
|
||||
# here. This is safe because Camera2D (tree sibling, earlier in scene order) applies
|
||||
# its smoothing transform before CursorRenderer._process() reads canvas_transform.
|
||||
# Nothing modifies the canvas transform between _process() and _draw().
|
||||
|
||||
# Bloom pass — wider, semi-transparent glow
|
||||
if _bloom > 0.01:
|
||||
var bloom_mod := 1.0
|
||||
# D-056: ~10% bloom pulse during entity hover
|
||||
if current_state == State.ENTITY_HOVER:
|
||||
bloom_mod += sin(_time * 4.0) * 0.1
|
||||
var bloom_a := _alpha * _bloom * 0.6 * bloom_mod
|
||||
_draw_ticks(Color(_color, bloom_a), _thick + 3.0)
|
||||
if _bracket_a > 0.01:
|
||||
_draw_brackets(_hover_offset, Color(_color, _bracket_a * _bloom * 0.4))
|
||||
|
||||
# Crisp pass
|
||||
_draw_ticks(Color(_color, _alpha), _thick)
|
||||
if _bracket_a > 0.01:
|
||||
_draw_brackets(_hover_offset, Color(_color, _bracket_a * _alpha))
|
||||
|
||||
|
||||
func _draw_ticks(color: Color, thickness: float) -> void:
|
||||
var dirs: Array[Vector2] = [Vector2.UP, Vector2.RIGHT, Vector2.DOWN, Vector2.LEFT]
|
||||
for dir: Vector2 in dirs:
|
||||
var d := dir.rotated(_rot)
|
||||
draw_line(d * _gap, d * (_gap + _len), color, thickness, true)
|
||||
|
||||
|
||||
func _draw_brackets(offset: Vector2, color: Color) -> void:
|
||||
for sx in [-1.0, 1.0]:
|
||||
for sy in [-1.0, 1.0]:
|
||||
var corner := offset + Vector2(sx * BRACKET_HALF, sy * BRACKET_HALF)
|
||||
draw_line(corner, corner + Vector2(-sx * BRACKET_ARM, 0), color, 1.0, true)
|
||||
draw_line(corner, corner + Vector2(0, -sy * BRACKET_ARM), color, 1.0, true)
|
||||
|
||||
|
||||
# --- Test-friendly API (expected by test_cursor_states.gd) ---
|
||||
|
||||
func get_state() -> String:
|
||||
match current_state:
|
||||
State.DEFAULT: return "Default"
|
||||
State.ENTITY_HOVER: return "EntityHover"
|
||||
State.OBJECT_HOVER: return "ObjectHover"
|
||||
State.WEAPON_AIM: return "WeaponAim"
|
||||
_: return "Default"
|
||||
|
||||
|
||||
func set_hover_target(data: Dictionary) -> void:
|
||||
var kind: String = data.get("kind", "")
|
||||
# D-056: cursor changes require LOS
|
||||
if not data.get("in_los", true):
|
||||
return
|
||||
|
||||
hovered_entity_id = data.get("entity_id", -1)
|
||||
if kind == "Npc":
|
||||
var rel: String = data.get("relationship", "Unknown")
|
||||
match rel:
|
||||
"Friendly": _hover_color = Constants.ENTITY_COLOR_FRIENDLY
|
||||
"PersonOfInterest": _hover_color = Constants.ENTITY_COLOR_POI
|
||||
"Hostile": _hover_color = Constants.ENTITY_COLOR_HOSTILE
|
||||
_: _hover_color = Constants.ENTITY_COLOR_UNKNOWN
|
||||
_target = State.ENTITY_HOVER
|
||||
_t = 1.0
|
||||
_apply_params(_params_for(State.ENTITY_HOVER))
|
||||
current_state = State.ENTITY_HOVER
|
||||
elif kind == "Object" or kind == "Terrain":
|
||||
_hover_color = COLOR_OBJECT_FLAGGED if data.get("flagged", false) else COLOR_OBJECT
|
||||
_target = State.OBJECT_HOVER
|
||||
_t = 1.0
|
||||
_apply_params(_params_for(State.OBJECT_HOVER))
|
||||
current_state = State.OBJECT_HOVER
|
||||
|
||||
|
||||
func clear_hover_target() -> void:
|
||||
hovered_entity_id = -1
|
||||
_hover_color = COLOR_DEFAULT
|
||||
_target = State.DEFAULT
|
||||
_t = 1.0
|
||||
_apply_params(_params_for(State.DEFAULT))
|
||||
current_state = State.DEFAULT
|
||||
|
||||
|
||||
func set_weapon_mode(active: bool) -> void:
|
||||
weapon_mode_active = active
|
||||
if active:
|
||||
_target = State.WEAPON_AIM
|
||||
_t = 1.0
|
||||
_apply_params(_params_for(State.WEAPON_AIM))
|
||||
current_state = State.WEAPON_AIM
|
||||
else:
|
||||
_target = State.DEFAULT
|
||||
_t = 1.0
|
||||
_apply_params(_params_for(State.DEFAULT))
|
||||
current_state = State.DEFAULT
|
||||
|
||||
|
||||
func set_shift_held(held: bool) -> void:
|
||||
_shift_held = held
|
||||
|
||||
|
||||
func get_transition_duration() -> float:
|
||||
return TRANSITION_SEC
|
||||
|
||||
|
||||
func get_cursor_color() -> Color:
|
||||
return _color
|
||||
|
||||
|
||||
func get_z_layer() -> int:
|
||||
return Constants.CANVAS_UI # D-049/D-056
|
||||
|
||||
|
||||
func should_show_interactions() -> bool:
|
||||
# OQ-07: insert off suppresses verb labels even though cursor shape still changes
|
||||
if not insert_active:
|
||||
return false
|
||||
return not weapon_mode_active or _shift_held
|
||||
|
||||
|
||||
func set_insert_active(active: bool) -> void:
|
||||
insert_active = active
|
||||
|
||||
|
||||
func get_interaction_range() -> int:
|
||||
return 2 # D-056: ~2 sim tiles
|
||||
@@ -0,0 +1 @@
|
||||
uid://bkbfcalfo1kk8
|
||||
@@ -5,6 +5,10 @@ extends Node2D
|
||||
# Creates/updates/removes ColorRect children based on entity data
|
||||
# Entity format (from Protocol v2): {entity_id, x, y, z, kind: {variant, data}, visibility}
|
||||
#
|
||||
# Position lerping: entity sprites smoothly slide between tiles instead of snapping.
|
||||
# The server moves entities in discrete tile steps; the lerp makes this look fluid.
|
||||
# Speed is tuned so Sprint feels snappy and Walk/Careful/Crouch feel deliberate.
|
||||
#
|
||||
# D-033 colors: Phase 1 defaults by entity kind. Phase 2 (#361) will derive
|
||||
# color from RelationshipState via the knowledge graph.
|
||||
|
||||
@@ -12,11 +16,52 @@ const TILE_SIZE: int = Constants.TILE_SIZE
|
||||
const ENTITY_SIZE: int = 24
|
||||
const ENTITY_OFFSET: float = (TILE_SIZE - ENTITY_SIZE) / 2.0 # center within tile
|
||||
|
||||
var entity_nodes: Dictionary = {} # entity_id -> Node2D mapping
|
||||
# Lerp speed — framerate-independent exponential smoothing.
|
||||
# At 12.0: ~70% there after 0.1s, ~95% after 0.25s.
|
||||
# Fast enough for Sprint snappiness, slow enough for Walk to show sliding.
|
||||
const LERP_SPEED: float = 12.0
|
||||
|
||||
var entity_nodes: Dictionary = {} # entity_id -> Node2D
|
||||
var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel position)
|
||||
var _entity_relationships: Dictionary = {} # #521: entity_id -> String (last relationship)
|
||||
var _entity_tweens: Dictionary = {} # #521: entity_id -> {target: Color, elapsed: float}
|
||||
|
||||
# #521: Color transition duration in seconds (D-033: "0.5s fade")
|
||||
const COLOR_FADE_DURATION: float = 0.5
|
||||
|
||||
func _ready() -> void:
|
||||
print("EntityRenderer: Initialized")
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# Lerp all entity visual positions toward their targets each frame.
|
||||
# Uses framerate-independent exponential smoothing.
|
||||
var weight := 1.0 - exp(-LERP_SPEED * delta)
|
||||
for entity_id in entity_nodes.keys():
|
||||
if not _entity_targets.has(entity_id):
|
||||
continue
|
||||
var node = entity_nodes[entity_id]
|
||||
var target: Vector2 = _entity_targets[entity_id]
|
||||
if not node.position.is_equal_approx(target):
|
||||
node.position = node.position.lerp(target, weight)
|
||||
|
||||
# #521: Advance color transitions (manual lerp, testable without SceneTree)
|
||||
var finished_ids: Array = []
|
||||
for entity_id in _entity_tweens.keys():
|
||||
if not entity_nodes.has(entity_id):
|
||||
finished_ids.append(entity_id)
|
||||
continue
|
||||
var tween_data: Dictionary = _entity_tweens[entity_id]
|
||||
tween_data.elapsed += delta
|
||||
var t := clampf(tween_data.elapsed / COLOR_FADE_DURATION, 0.0, 1.0)
|
||||
var node_c: ColorRect = entity_nodes[entity_id] as ColorRect
|
||||
node_c.color = tween_data.from.lerp(tween_data.target, t)
|
||||
if t >= 1.0:
|
||||
finished_ids.append(entity_id)
|
||||
for eid in finished_ids:
|
||||
_entity_tweens.erase(eid)
|
||||
|
||||
|
||||
# Update entities from snapshot data
|
||||
func update_entities(entities: Array) -> void:
|
||||
var active_ids: Array = []
|
||||
@@ -51,33 +96,59 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
entity_node.size = Vector2(ENTITY_SIZE, ENTITY_SIZE)
|
||||
entity_node.pivot_offset = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0)
|
||||
|
||||
# D-033 color by entity kind (Phase 1 default)
|
||||
# TODO(#361): derive from RelationshipState via knowledge graph
|
||||
# D-033 color by relationship (#521)
|
||||
entity_node.color = _color_for_kind(entity_data)
|
||||
|
||||
add_child(entity_node)
|
||||
entity_nodes[entity_id] = entity_node
|
||||
_entity_relationships[entity_id] = entity_data.get("relationship", "Unknown")
|
||||
|
||||
# Add facing indicator for the player entity
|
||||
if entity_id == GameState.player_entity_id:
|
||||
_add_facing_indicator(entity_node)
|
||||
|
||||
# Snap to initial position (no lerp on first appearance)
|
||||
if entity_data.has("x") and entity_data.has("y"):
|
||||
var target := Vector2(
|
||||
floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET,
|
||||
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET
|
||||
)
|
||||
entity_node.position = target
|
||||
_entity_targets[entity_id] = target
|
||||
|
||||
_update_entity_node(entity_id, entity_data)
|
||||
|
||||
# Update an existing entity node (position, visibility dimming, facing)
|
||||
# Update an existing entity node (target position, visibility dimming, facing)
|
||||
func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
if not entity_nodes.has(entity_id):
|
||||
return
|
||||
|
||||
var entity_node = entity_nodes[entity_id]
|
||||
|
||||
# Update position from x, y fields (Protocol format), centered within tile
|
||||
# Update target position — the lerp in _process() will smoothly move there.
|
||||
# Server sends tile-center coords (tile 16 → 16.5), floor to get tile index.
|
||||
if entity_data.has("x") and entity_data.has("y"):
|
||||
entity_node.position = Vector2(
|
||||
entity_data.x * TILE_SIZE + ENTITY_OFFSET,
|
||||
entity_data.y * TILE_SIZE + ENTITY_OFFSET
|
||||
_entity_targets[entity_id] = Vector2(
|
||||
floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET,
|
||||
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET
|
||||
)
|
||||
|
||||
# #521: Detect relationship change → fade D-033 color (0.5s via _process)
|
||||
var new_rel: String = entity_data.get("relationship", "Unknown")
|
||||
var old_rel: String = _entity_relationships.get(entity_id, "Unknown")
|
||||
if new_rel != old_rel:
|
||||
_entity_relationships[entity_id] = new_rel
|
||||
var new_color := _color_for_kind(entity_data)
|
||||
_entity_tweens[entity_id] = {
|
||||
"from": entity_node.color,
|
||||
"target": new_color,
|
||||
"elapsed": 0.0,
|
||||
}
|
||||
|
||||
# Note: modulate.a (peripheral dimming below) and color (D-033 tint above)
|
||||
# are compositionally independent — both can change simultaneously without
|
||||
# interference. If alpha tweening is added later, coordinate with color tween.
|
||||
|
||||
# v2: Peripheral vision dimming (D-015)
|
||||
# null visibility (v1 backward compat) defaults to full alpha
|
||||
var visibility: Variant = entity_data.get("visibility")
|
||||
@@ -85,11 +156,14 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
if not is_equal_approx(entity_node.modulate.a, target_alpha):
|
||||
entity_node.modulate.a = target_alpha
|
||||
|
||||
# v2: Update facing indicator rotation (player entity only)
|
||||
# D-054: Update facing indicator from client-side mouse angle (not server).
|
||||
# InputMapper.facing_angle is a continuous float — smoother than octant snapping.
|
||||
if entity_id == GameState.player_entity_id:
|
||||
var indicator = entity_node.get_node_or_null("FacingIndicator")
|
||||
if indicator != null:
|
||||
indicator.rotation = _facing_to_rotation(GameState.player_facing)
|
||||
# facing_angle: 0=East, -PI/2=North. Indicator: 0=North (up).
|
||||
# Rotate from North basis: add PI/2 to convert.
|
||||
indicator.rotation = InputMapper.facing_angle + PI / 2.0
|
||||
|
||||
# Remove an entity node
|
||||
func _remove_entity_node(entity_id: int) -> void:
|
||||
@@ -99,19 +173,13 @@ func _remove_entity_node(entity_id: int) -> void:
|
||||
var entity_node = entity_nodes[entity_id]
|
||||
entity_node.queue_free()
|
||||
entity_nodes.erase(entity_id)
|
||||
_entity_targets.erase(entity_id)
|
||||
_entity_relationships.erase(entity_id)
|
||||
_entity_tweens.erase(entity_id)
|
||||
|
||||
# D-033 color by entity kind (Phase 1: defaults by kind, not relationship)
|
||||
# D-033 color by entity kind — delegates to Constants.color_for_entity_kind
|
||||
static func _color_for_kind(entity_data: Dictionary) -> Color:
|
||||
var kind_variant: String = entity_data.get("kind", {}).get("variant", "")
|
||||
match kind_variant:
|
||||
"Player":
|
||||
return Constants.ENTITY_COLOR_PLAYER
|
||||
"Npc":
|
||||
return Constants.ENTITY_COLOR_UNKNOWN
|
||||
"Object", "Terrain":
|
||||
return Constants.ENTITY_COLOR_OBJECT
|
||||
_:
|
||||
return Constants.ENTITY_COLOR_OBJECT
|
||||
return Constants.color_for_entity_kind(entity_data)
|
||||
|
||||
# Add a facing direction indicator triangle to the player entity
|
||||
func _add_facing_indicator(parent_node: Control) -> void:
|
||||
@@ -129,16 +197,3 @@ func _add_facing_indicator(parent_node: Control) -> void:
|
||||
# Position at center of parent ColorRect — rotation around this point
|
||||
indicator.position = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0)
|
||||
parent_node.add_child(indicator)
|
||||
|
||||
# Convert facing direction string to rotation in radians (0 = North/up)
|
||||
static func _facing_to_rotation(facing: String) -> float:
|
||||
match facing:
|
||||
"North": return 0.0
|
||||
"Northeast": return PI / 4.0
|
||||
"East": return PI / 2.0
|
||||
"Southeast": return 3.0 * PI / 4.0
|
||||
"South": return PI
|
||||
"Southwest": return 5.0 * PI / 4.0
|
||||
"West": return 3.0 * PI / 2.0
|
||||
"Northwest": return 7.0 * PI / 4.0
|
||||
_: return 0.0
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
class_name FogEntities
|
||||
extends Node2D
|
||||
|
||||
## Fog entity visualization — renders entities undergoing cognitive delay recognition
|
||||
## in the fog layer. Between FogOverlay (z:900) and InsertOverlay (CanvasLayer 10).
|
||||
##
|
||||
## Three visual states per D-059/D-060:
|
||||
## 1. Sound pings: 2-3 thin concentric expanding rings (sonar-style)
|
||||
## 2. Unrecognized entity: grey blob #555566, 0.8s breathing pulse
|
||||
## 3. Recognized entity: D-033 color glow + silhouette + breathing pulse + position drift
|
||||
##
|
||||
## Cognitive delay transition: grey blob → color + silhouette over ~0.3s,
|
||||
## driven by remaining_ticks / total_delay_ticks ratio from server.
|
||||
##
|
||||
## All drawing happens in _draw() — no child scripts needed.
|
||||
|
||||
const TILE_SIZE: int = Constants.TILE_SIZE
|
||||
|
||||
# D-059: Fog entity colors
|
||||
const COLOR_UNRECOGNIZED := Color("#555566") # Neutral grey blob
|
||||
# Intentionally matches Constants.INSERT_COLOR_TEXT / cursor default (#c8d0e0).
|
||||
# Sonar pings are insert-generated — same visual language as cursor and HUD overlays.
|
||||
const COLOR_PING := Color("#c8d0e0") # Insert white-blue (D-048/D-056)
|
||||
|
||||
# Animation timing
|
||||
const PULSE_PERIOD: float = 0.8 # Breathing pulse cycle (seconds)
|
||||
const PULSE_MIN_ALPHA: float = 0.4 # Min alpha during breathing
|
||||
const PULSE_MAX_ALPHA: float = 0.8 # Max alpha during breathing
|
||||
const PING_DURATION: float = 1.5 # Sound ping expand + fade (seconds)
|
||||
const PING_MAX_RADIUS: float = 24.0 # Max ring expand radius (pixels)
|
||||
const PING_RING_COUNT: int = 3 # Concentric rings per ping
|
||||
const PING_RING_WIDTH: float = 1.5 # Ring line width (pixels)
|
||||
const DRIFT_RANGE: float = 0.5 # ±0.5 tile position drift (D-059)
|
||||
const DRIFT_PERIOD: float = 2.0 # Seconds per drift wander
|
||||
|
||||
# Entity blob rendering
|
||||
const BLOB_RADIUS: float = 10.0
|
||||
const SILHOUETTE_SCALE: float = 1.3 # Silhouette slightly larger than blob
|
||||
# Transition thresholds: recognition progress mapped from remaining_ticks/total_delay_ticks.
|
||||
# Color transition starts at 50% to compress the visual change into ~0.3s (D-060).
|
||||
const COLOR_TRANSITION_START: float = 0.5
|
||||
# Silhouette (body shape hint) appears after 80% progress — recognition nearly complete.
|
||||
const SILHOUETTE_APPEAR_THRESHOLD: float = 0.3
|
||||
# Silhouette size: approximate torso-sized rectangle in pixels at TILE_SIZE=32 scale.
|
||||
const SILHOUETTE_SIZE := Vector2(6.0, 10.0)
|
||||
|
||||
# Internal state — no child nodes, pure data + _draw()
|
||||
var _entities: Dictionary = {} # entity_id -> {pos, drift_offset, drift_target, drift_timer, progress}
|
||||
var _pings: Array = [] # [{pos: Vector2, elapsed: float}]
|
||||
var _time: float = 0.0
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# Early return when idle — skip queue_redraw() when nothing to draw
|
||||
if _entities.is_empty() and _pings.is_empty():
|
||||
return
|
||||
|
||||
_time += delta
|
||||
|
||||
# Animate drifts
|
||||
for eid in _entities.keys():
|
||||
var e: Dictionary = _entities[eid]
|
||||
e.drift_timer += delta
|
||||
if e.drift_timer >= DRIFT_PERIOD:
|
||||
e.drift_timer = 0.0
|
||||
e.drift_offset = e.drift_target
|
||||
e.drift_target = _random_drift()
|
||||
var t: float = e.drift_timer / DRIFT_PERIOD
|
||||
e.drift_offset = e.drift_offset.lerp(e.drift_target, t)
|
||||
|
||||
# Advance pings, remove expired
|
||||
var i := _pings.size() - 1
|
||||
while i >= 0:
|
||||
_pings[i].elapsed += delta
|
||||
if _pings[i].elapsed >= PING_DURATION:
|
||||
_pings.remove_at(i)
|
||||
i -= 1
|
||||
|
||||
queue_redraw()
|
||||
|
||||
|
||||
## Called from main.gd each frame after GameState.apply_snapshot()
|
||||
func update_from_state() -> void:
|
||||
var recognitions: Array = GameState.pending_recognitions
|
||||
var active_ids: Array = []
|
||||
|
||||
for rec in recognitions:
|
||||
var eid: int = rec.entity_id
|
||||
active_ids.append(eid)
|
||||
|
||||
if not _entities.has(eid):
|
||||
# New entity — spawn with random drift and trigger sonar ping
|
||||
_entities[eid] = {
|
||||
"pos": Vector2(rec.x, rec.y),
|
||||
"drift_offset": _random_drift(),
|
||||
"drift_target": _random_drift(),
|
||||
"drift_timer": 0.0,
|
||||
"progress": 0.0,
|
||||
}
|
||||
_pings.append({"pos": Vector2(rec.x, rec.y), "elapsed": 0.0})
|
||||
|
||||
# Update server data
|
||||
var e: Dictionary = _entities[eid]
|
||||
e.pos = Vector2(rec.x, rec.y)
|
||||
var total: int = rec.total_delay_ticks
|
||||
var remaining: int = rec.remaining_ticks
|
||||
if total > 0:
|
||||
e.progress = clampf(1.0 - float(remaining) / float(total), 0.0, 1.0)
|
||||
else:
|
||||
e.progress = 1.0
|
||||
|
||||
# Remove entities no longer pending
|
||||
var to_remove: Array = []
|
||||
for eid in _entities.keys():
|
||||
if eid not in active_ids:
|
||||
to_remove.append(eid)
|
||||
for eid in to_remove:
|
||||
_entities.erase(eid)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
# Breathing pulse — shared across all entities
|
||||
var pulse_t := fmod(_time, PULSE_PERIOD) / PULSE_PERIOD
|
||||
var pulse_alpha := lerpf(PULSE_MIN_ALPHA, PULSE_MAX_ALPHA,
|
||||
0.5 + 0.5 * sin(pulse_t * TAU))
|
||||
|
||||
# Draw entity blobs
|
||||
for eid in _entities.keys():
|
||||
var e: Dictionary = _entities[eid]
|
||||
var world_pos: Vector2 = (e.pos + e.drift_offset) * TILE_SIZE
|
||||
|
||||
# Color transition: grey → D-033 teal based on recognition progress
|
||||
# Transition begins at 50% progress (D-060: ~0.3s visual transition within delay window)
|
||||
var progress: float = e.progress
|
||||
var color_t: float = clampf((progress - COLOR_TRANSITION_START) / (1.0 - COLOR_TRANSITION_START), 0.0, 1.0)
|
||||
var blob_color: Color = COLOR_UNRECOGNIZED.lerp(Constants.ENTITY_COLOR_UNKNOWN, color_t)
|
||||
blob_color.a = pulse_alpha
|
||||
|
||||
# Blob — unrecognized: plain circle. Recognized: larger glow + inner shape
|
||||
if color_t < 1e-3:
|
||||
# Pure unrecognized: grey blob, no silhouette (D-059)
|
||||
draw_circle(world_pos, BLOB_RADIUS, blob_color)
|
||||
else:
|
||||
# Transitioning / recognized: outer glow + inner silhouette hint
|
||||
var glow_color := blob_color
|
||||
glow_color.a *= 0.4
|
||||
draw_circle(world_pos, BLOB_RADIUS * SILHOUETTE_SCALE, glow_color)
|
||||
draw_circle(world_pos, BLOB_RADIUS, blob_color)
|
||||
# Faint silhouette: body shape rectangle emerges late in recognition
|
||||
if color_t > SILHOUETTE_APPEAR_THRESHOLD:
|
||||
var sil_color := blob_color
|
||||
sil_color.a *= 0.6
|
||||
var sil_size := SILHOUETTE_SIZE * color_t
|
||||
draw_rect(Rect2(world_pos - sil_size * 0.5, sil_size), sil_color)
|
||||
|
||||
# Draw sound pings — concentric expanding rings
|
||||
for ping in _pings:
|
||||
var pos: Vector2 = ping.pos * TILE_SIZE
|
||||
var t: float = ping.elapsed / PING_DURATION
|
||||
var fade: float = 1.0 - t # Linear fade out
|
||||
|
||||
for ring_i in range(PING_RING_COUNT):
|
||||
# Stagger rings: each starts slightly later
|
||||
var ring_t: float = t - ring_i * 0.15
|
||||
if ring_t < 0.0 or ring_t > 1.0:
|
||||
continue
|
||||
var radius: float = ring_t * PING_MAX_RADIUS
|
||||
var ring_fade: float = (1.0 - ring_t) * fade
|
||||
var ring_color := COLOR_PING
|
||||
ring_color.a = ring_fade * 0.7
|
||||
draw_arc(pos, radius, 0.0, TAU, 32, ring_color, PING_RING_WIDTH)
|
||||
|
||||
|
||||
func _random_drift() -> Vector2:
|
||||
return Vector2(
|
||||
randf_range(-DRIFT_RANGE, DRIFT_RANGE),
|
||||
randf_range(-DRIFT_RANGE, DRIFT_RANGE)
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cw154tgjdyhe1
|
||||
@@ -1,88 +0,0 @@
|
||||
class_name FogRenderer
|
||||
extends TileMapLayer
|
||||
|
||||
# Fog renderer — draws fog overlay on non-visible tiles (D-011)
|
||||
# Three visibility states per tile:
|
||||
# visible = no fog tile (clear)
|
||||
# fog-edge = semi-transparent dark overlay (adjacent to visible)
|
||||
# hidden = opaque black overlay
|
||||
#
|
||||
# Atlas layout:
|
||||
# (0,0) = full fog (opaque black)
|
||||
# (1,0) = fog edge (semi-transparent)
|
||||
#
|
||||
# Note: fog-edge uses 8-directional neighbors for visual smoothness.
|
||||
# Actual visibility boundaries come from the server's shadowcasting (D-011).
|
||||
# Fog-returns-over-time (D-011 decay) is tracked in #113, not here.
|
||||
|
||||
const TILE_SIZE: int = Constants.TILE_SIZE
|
||||
|
||||
var _initialized: bool = false
|
||||
var _all_tile_positions: Dictionary = {} # Vector2i -> true, all known map tiles
|
||||
|
||||
func _ready() -> void:
|
||||
_setup_tileset()
|
||||
_initialized = true
|
||||
print("FogRenderer: Initialized")
|
||||
|
||||
func _setup_tileset() -> void:
|
||||
var ts := TileSet.new()
|
||||
ts.tile_size = Vector2i(TILE_SIZE, TILE_SIZE)
|
||||
|
||||
var source := TileSetAtlasSource.new()
|
||||
var img := Image.create(TILE_SIZE * 2, TILE_SIZE, false, Image.FORMAT_RGBA8)
|
||||
|
||||
# Full fog (0,0) — opaque black
|
||||
img.fill_rect(Rect2i(0, 0, TILE_SIZE, TILE_SIZE), Color(0.02, 0.02, 0.05, 1.0))
|
||||
# Fog edge (1,0) — semi-transparent dark
|
||||
img.fill_rect(Rect2i(TILE_SIZE, 0, TILE_SIZE, TILE_SIZE), Color(0.02, 0.02, 0.05, 0.6))
|
||||
|
||||
var tex := ImageTexture.create_from_image(img)
|
||||
source.texture = tex
|
||||
source.texture_region_size = Vector2i(TILE_SIZE, TILE_SIZE)
|
||||
|
||||
source.create_tile(Vector2i(0, 0))
|
||||
source.create_tile(Vector2i(1, 0))
|
||||
|
||||
ts.add_source(source)
|
||||
tile_set = ts
|
||||
|
||||
# Register all known tile positions (called when tile data arrives)
|
||||
func register_tile_positions(tiles: Array) -> void:
|
||||
_all_tile_positions.clear()
|
||||
for tile_data in tiles:
|
||||
if tile_data.has("x") and tile_data.has("y"):
|
||||
_all_tile_positions[Vector2i(tile_data.x, tile_data.y)] = true
|
||||
|
||||
# Update fog based on visible positions.
|
||||
# player_pos reserved for future fog-decay tracking (#113).
|
||||
func update_fog(visible_positions: Dictionary, _player_pos: Vector2) -> void:
|
||||
if not _initialized:
|
||||
return
|
||||
|
||||
clear()
|
||||
|
||||
if _all_tile_positions.is_empty() or visible_positions.is_empty():
|
||||
return
|
||||
|
||||
# Build set of fog-edge positions (8-directional neighbors of visible tiles)
|
||||
var fog_edge: Dictionary = {}
|
||||
var neighbors := [
|
||||
Vector2i(-1, 0), Vector2i(1, 0), Vector2i(0, -1), Vector2i(0, 1),
|
||||
Vector2i(-1, -1), Vector2i(1, -1), Vector2i(-1, 1), Vector2i(1, 1),
|
||||
]
|
||||
|
||||
for pos in visible_positions:
|
||||
for offset in neighbors:
|
||||
var neighbor_pos: Vector2i = pos + offset
|
||||
if not visible_positions.has(neighbor_pos) and _all_tile_positions.has(neighbor_pos):
|
||||
fog_edge[neighbor_pos] = true
|
||||
|
||||
# Place fog tiles on all known positions that aren't visible
|
||||
for pos in _all_tile_positions:
|
||||
if visible_positions.has(pos):
|
||||
continue # Visible — no fog
|
||||
elif fog_edge.has(pos):
|
||||
set_cell(pos, 0, Vector2i(1, 0)) # Fog edge — semi-transparent
|
||||
else:
|
||||
set_cell(pos, 0, Vector2i(0, 0)) # Full fog — opaque
|
||||
@@ -0,0 +1,71 @@
|
||||
extends Node2D
|
||||
|
||||
## Fog overlay controller — manages ColorRect + shader uniforms for D-059 fog.
|
||||
## Reads textures from FogState autoload, positions rect to cover viewport.
|
||||
## Architecture: docs/architecture/fog-shader-spec.md
|
||||
|
||||
var _fog_rect: ColorRect
|
||||
var _shader_mat: ShaderMaterial
|
||||
|
||||
const TILE_SIZE := float(Constants.TILE_SIZE)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Create the fog overlay ColorRect — transparent fallback so a shader failure
|
||||
# doesn't paint solid white over the entire world (z:900 covers everything).
|
||||
_fog_rect = ColorRect.new()
|
||||
_fog_rect.name = "FogRect"
|
||||
_fog_rect.color = Color.TRANSPARENT
|
||||
add_child(_fog_rect)
|
||||
|
||||
# Load shader and create material
|
||||
var shader := load("res://shaders/fog.gdshader") as Shader
|
||||
if shader == null:
|
||||
push_error("FogShader: failed to load res://shaders/fog.gdshader — fog disabled")
|
||||
return
|
||||
_shader_mat = ShaderMaterial.new()
|
||||
_shader_mat.shader = shader
|
||||
_fog_rect.material = _shader_mat
|
||||
|
||||
# Create seamless noise texture for fog animation
|
||||
var noise := FastNoiseLite.new()
|
||||
noise.noise_type = FastNoiseLite.TYPE_PERLIN
|
||||
noise.frequency = 0.03
|
||||
var noise_tex := NoiseTexture2D.new()
|
||||
noise_tex.noise = noise
|
||||
noise_tex.width = 256
|
||||
noise_tex.height = 256
|
||||
noise_tex.seamless = true
|
||||
_shader_mat.set_shader_parameter("noise_tex", noise_tex)
|
||||
_shader_mat.set_shader_parameter("tile_size", TILE_SIZE)
|
||||
|
||||
print("FogShader: Initialized (D-059 5-layer)")
|
||||
|
||||
|
||||
func update_fog() -> void:
|
||||
if _shader_mat == null:
|
||||
return # Shader failed to load — fog disabled
|
||||
|
||||
# 1. Update FogState textures from GameState
|
||||
FogState.update_from_state()
|
||||
|
||||
# 2. Position ColorRect to cover the current viewport.
|
||||
# Use the camera's actual position (not player position) so the fog rect
|
||||
# tracks the smoothed camera and never desynchronizes during smooth pan.
|
||||
var vp_size := get_viewport().get_visible_rect().size
|
||||
var cam := get_viewport().get_camera_2d()
|
||||
var zoom := cam.zoom if cam else Vector2(2.0, 2.0)
|
||||
var camera_pos := cam.global_position if cam else GameState.player_position * TILE_SIZE
|
||||
var half_view := vp_size / (2.0 * zoom)
|
||||
_fog_rect.position = camera_pos - half_view
|
||||
_fog_rect.size = vp_size / zoom
|
||||
|
||||
# 3. Update shader uniforms
|
||||
_shader_mat.set_shader_parameter("visibility_tex", FogState.visibility_texture)
|
||||
_shader_mat.set_shader_parameter("exploration_tex", FogState.exploration_texture)
|
||||
_shader_mat.set_shader_parameter("zone_tint_tex", FogState.zone_tint_texture)
|
||||
_shader_mat.set_shader_parameter("rect_pos", _fog_rect.position)
|
||||
_shader_mat.set_shader_parameter("rect_sz", _fog_rect.size)
|
||||
_shader_mat.set_shader_parameter("map_offset", Vector2(FogState.map_bounds.position))
|
||||
_shader_mat.set_shader_parameter("map_size", Vector2(FogState.map_bounds.size))
|
||||
_shader_mat.set_shader_parameter("time", Time.get_ticks_msec() / 1000.0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dje4vilxh5xxu
|
||||
@@ -0,0 +1,152 @@
|
||||
class_name SoundIndicatorRenderer
|
||||
extends Node2D
|
||||
|
||||
## Medium-range sound indicators (#126, D-018).
|
||||
## Renders directional arrows at the fog boundary for sounds outside LOS.
|
||||
##
|
||||
## Close-range sounds → 2D positional audio (handled by AudioManager).
|
||||
## Medium-range sounds → visual arrow at fog edge pointing toward source.
|
||||
##
|
||||
## Color per D-018/D-069:
|
||||
## Neutral #c8d0e0 — footsteps, generic sounds
|
||||
## Voice #e8c547 — speech, conversation, social sounds
|
||||
## Danger #d45d5d — alert, gunshot, explosion, threat
|
||||
##
|
||||
## Each indicator lives for INDICATOR_LIFETIME seconds and fades out over
|
||||
## the last FADE_DURATION seconds. New events are appended each tick;
|
||||
## expired indicators are removed by _process(). Deduplication prevents
|
||||
## the same source position from stacking multiple arrows.
|
||||
|
||||
const TILE_SIZE: int = Constants.TILE_SIZE
|
||||
|
||||
# Visual parameters
|
||||
const INDICATOR_LIFETIME: float = 3.5 # Total seconds visible
|
||||
const FADE_DURATION: float = 0.6 # Fade-out window at end
|
||||
const EDGE_INSET: float = 20.0 # Pixels inward from viewport edge
|
||||
const ARROW_HALF: float = 7.0 # Half-width of arrowhead base
|
||||
const ARROW_LEN: float = 12.0 # Length from tip to base
|
||||
|
||||
# D-018/D-069 colors — sourced from Constants to prevent palette drift
|
||||
const COLOR_NEUTRAL: Color = Constants.INSERT_COLOR_TEXT # Generic / footstep
|
||||
const COLOR_VOICE: Color = Constants.ENTITY_COLOR_POI # Speech / conversation
|
||||
const COLOR_DANGER: Color = Constants.ENTITY_COLOR_HOSTILE # Alert / threat / gunshot
|
||||
|
||||
# Indicators: [{x, y, event_type, elapsed}]
|
||||
var _indicators: Array = []
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _indicators.is_empty():
|
||||
return
|
||||
|
||||
var i := _indicators.size() - 1
|
||||
while i >= 0:
|
||||
_indicators[i].elapsed += delta
|
||||
if _indicators[i].elapsed >= INDICATOR_LIFETIME:
|
||||
_indicators.remove_at(i)
|
||||
i -= 1
|
||||
|
||||
queue_redraw()
|
||||
|
||||
|
||||
## Append new medium-range sound events from snapshot.
|
||||
## events: Array of {x: float, y: float, event_type: String}
|
||||
## Only Medium range_category events should be passed.
|
||||
## Deduplicates by tile position — if an indicator already exists at (x,y),
|
||||
## its timer resets instead of spawning a duplicate arrow.
|
||||
func update_sound_events(events: Array) -> void:
|
||||
for evt in events:
|
||||
if not evt.has("x") or not evt.has("y"):
|
||||
continue
|
||||
var ex: float = float(evt.x)
|
||||
var ey: float = float(evt.y)
|
||||
# Deduplicate: reset timer if an indicator already exists at this tile
|
||||
var found := false
|
||||
for ind in _indicators:
|
||||
if is_equal_approx(ind.x, ex) and is_equal_approx(ind.y, ey):
|
||||
ind.elapsed = 0.0
|
||||
ind.event_type = evt.get("event_type", "")
|
||||
found = true
|
||||
break
|
||||
if not found:
|
||||
_indicators.append({
|
||||
"x": ex,
|
||||
"y": ey,
|
||||
"event_type": evt.get("event_type", ""),
|
||||
"elapsed": 0.0,
|
||||
})
|
||||
if not _indicators.is_empty():
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if _indicators.is_empty():
|
||||
return
|
||||
|
||||
var player_world: Vector2 = GameState.player_position * TILE_SIZE
|
||||
|
||||
# Compute half-extents of the visible world area from camera zoom + viewport.
|
||||
# Arrows are placed at this boundary minus EDGE_INSET so they sit just inside
|
||||
# the fog edge and don't clip to the physical screen border.
|
||||
var vp_size := get_viewport().get_visible_rect().size
|
||||
var cam := get_viewport().get_camera_2d()
|
||||
var zoom := cam.zoom if cam else Constants.CAMERA_DEFAULT_ZOOM
|
||||
var half_extents: Vector2 = vp_size / (2.0 * zoom)
|
||||
|
||||
for ind in _indicators:
|
||||
var sound_world: Vector2 = Vector2(ind.x, ind.y) * TILE_SIZE
|
||||
var dir: Vector2 = sound_world - player_world
|
||||
if dir.is_zero_approx():
|
||||
continue
|
||||
dir = dir.normalized()
|
||||
|
||||
# Project direction onto the visible-area rectangle boundary
|
||||
var edge_pt: Vector2 = _rect_edge_point(player_world, dir, half_extents, EDGE_INSET)
|
||||
|
||||
# Alpha: full for most of lifetime, fade out over last FADE_DURATION seconds
|
||||
var t: float = ind.elapsed / INDICATOR_LIFETIME
|
||||
var fade_start: float = 1.0 - FADE_DURATION / INDICATOR_LIFETIME
|
||||
var alpha: float
|
||||
if t < fade_start:
|
||||
alpha = 1.0
|
||||
else:
|
||||
alpha = lerpf(1.0, 0.0, (t - fade_start) / (FADE_DURATION / INDICATOR_LIFETIME))
|
||||
|
||||
var color: Color = color_for_type(ind.event_type)
|
||||
color.a = alpha * 0.9
|
||||
_draw_arrow(edge_pt, dir, color)
|
||||
|
||||
|
||||
## Project from center along dir to the boundary of a rectangle with
|
||||
## half_extents, inset by inset pixels. Returns the boundary point.
|
||||
func _rect_edge_point(center: Vector2, dir: Vector2, half: Vector2, inset: float) -> Vector2:
|
||||
var h := Vector2(
|
||||
maxf(half.x - inset, 8.0),
|
||||
maxf(half.y - inset, 8.0),
|
||||
)
|
||||
# Ray-AABB slab test: find smallest positive t where the ray exits
|
||||
var t_x: float = INF if abs(dir.x) < 1e-6 else abs(h.x / dir.x)
|
||||
var t_y: float = INF if abs(dir.y) < 1e-6 else abs(h.y / dir.y)
|
||||
var t: float = minf(t_x, t_y)
|
||||
return center + dir * t
|
||||
|
||||
|
||||
## Draw a filled arrowhead at pos pointing in dir.
|
||||
## The tip is at pos; the base is ARROW_LEN pixels behind along -dir.
|
||||
func _draw_arrow(pos: Vector2, dir: Vector2, color: Color) -> void:
|
||||
var perp := Vector2(-dir.y, dir.x)
|
||||
var base_center := pos - dir * ARROW_LEN
|
||||
draw_polygon(
|
||||
PackedVector2Array([pos, base_center - perp * ARROW_HALF, base_center + perp * ARROW_HALF]),
|
||||
PackedColorArray([color, color, color])
|
||||
)
|
||||
|
||||
|
||||
## Map event type string → D-018 color category.
|
||||
func color_for_type(event_type: String) -> Color:
|
||||
var et := event_type.to_lower()
|
||||
if et.contains("voice") or et.contains("speech") or et.contains("convers") or et.contains("talk"):
|
||||
return COLOR_VOICE
|
||||
if et.contains("danger") or et.contains("gunshot") or et.contains("explosion") \
|
||||
or et.contains("alert") or et.contains("threat"):
|
||||
return COLOR_DANGER
|
||||
return COLOR_NEUTRAL
|
||||
@@ -5,14 +5,15 @@ extends TileMapLayer
|
||||
# Uses a programmatic TileSet with placeholder colored rectangles (D-014)
|
||||
#
|
||||
# Tile types (atlas coords in the programmatic source):
|
||||
# (0,0) = floor — dark gray
|
||||
# (1,0) = wall — lighter gray
|
||||
# (2,0) = door — brown
|
||||
# (3,0) = object — teal
|
||||
# (0,0) = floor — dark gray
|
||||
# (1,0) = wall — lighter gray
|
||||
# (2,0) = door — brown
|
||||
# (3,0) = object — teal
|
||||
# (4,0) = reset_plate — amber (#502)
|
||||
|
||||
const TILE_SIZE: int = Constants.TILE_SIZE
|
||||
|
||||
enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3 }
|
||||
enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3, RESET_PLATE = 4 }
|
||||
|
||||
# Wire-format string to TileType mapping
|
||||
const TILE_TYPE_MAP: Dictionary = {
|
||||
@@ -20,6 +21,7 @@ const TILE_TYPE_MAP: Dictionary = {
|
||||
"wall": TileType.WALL,
|
||||
"door": TileType.DOOR,
|
||||
"object": TileType.OBJECT,
|
||||
"reset_plate": TileType.RESET_PLATE,
|
||||
}
|
||||
|
||||
var _initialized: bool = false
|
||||
@@ -36,7 +38,7 @@ func _setup_tileset() -> void:
|
||||
|
||||
# Create an atlas source backed by a programmatic image
|
||||
var source := TileSetAtlasSource.new()
|
||||
var img := Image.create(TILE_SIZE * 4, TILE_SIZE, false, Image.FORMAT_RGBA8)
|
||||
var img := Image.create(TILE_SIZE * 5, TILE_SIZE, false, Image.FORMAT_RGBA8)
|
||||
|
||||
# Floor (0,0) — dark gray
|
||||
_fill_tile(img, 0, Color(0.18, 0.18, 0.22))
|
||||
@@ -46,13 +48,15 @@ func _setup_tileset() -> void:
|
||||
_fill_tile_with_border(img, 2, Color(0.5, 0.35, 0.2), Color(0.35, 0.25, 0.15))
|
||||
# Object (3,0) — teal
|
||||
_fill_tile(img, 3, Color(0.2, 0.45, 0.45))
|
||||
# Reset plate (4,0) — amber (#502)
|
||||
_fill_tile_with_border(img, 4, Color(0.91, 0.77, 0.28), Color(0.65, 0.55, 0.2))
|
||||
|
||||
var tex := ImageTexture.create_from_image(img)
|
||||
source.texture = tex
|
||||
source.texture_region_size = Vector2i(TILE_SIZE, TILE_SIZE)
|
||||
|
||||
# Create tile entries in the atlas
|
||||
for i in range(4):
|
||||
for i in range(5):
|
||||
source.create_tile(Vector2i(i, 0))
|
||||
|
||||
var source_id := ts.add_source(source)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://d2evnkstnqtpb
|
||||
@@ -2,16 +2,26 @@ extends Node2D
|
||||
|
||||
# World renderer — manages all visual representation from GameState
|
||||
# Attached to the World node in main.tscn
|
||||
# Render order (scene tree): TileMapLayer -> FogOverlay -> Entities
|
||||
#
|
||||
# D-049 Z-level rendering pipeline (z-layer-gap-analysis.md):
|
||||
# FogGroup (CanvasGroup) composites world content:
|
||||
# FloorTiles (TileMapLayer) — z:0 ground plane
|
||||
# FloorObjects (Node2D) — z:10 cosmetic detail (placeholder)
|
||||
# YSortGroup (Node2D, y_sort) — z:100 furniture + entities (all z:0 relative)
|
||||
# Furniture (Node2D, y_sort) — z:0 placed objects (placeholder)
|
||||
# Entities (Node2D, y_sort) — z:0 entity sprites (D-033 colors)
|
||||
# Overhead (Node2D) — z:300 ceiling/upper structure (placeholder)
|
||||
# FogOverlay (Node2D) — z:900 fog shader (OUTSIDE FogGroup)
|
||||
|
||||
@onready var tile_renderer = $TileMapLayer
|
||||
@onready var tile_renderer = $FogGroup/FloorTiles
|
||||
@onready var fog_renderer = $FogOverlay
|
||||
@onready var entity_renderer = $Entities
|
||||
@onready var entity_renderer = $FogGroup/YSortGroup/Entities
|
||||
@onready var sound_indicator_renderer = $SoundIndicators # #126 D-018 medium-range indicators
|
||||
|
||||
var _last_tick: int = -1
|
||||
|
||||
func _ready() -> void:
|
||||
print("WorldRenderer: Initialized")
|
||||
print("WorldRenderer: Initialized (D-049 z-stack)")
|
||||
|
||||
# Called each frame to update visuals from game state.
|
||||
# Uses tick-based invalidation — re-renders all layers when a new snapshot arrives.
|
||||
@@ -25,13 +35,15 @@ func update_from_state() -> void:
|
||||
if tile_renderer and tile_renderer.has_method("update_tiles"):
|
||||
if GameState.visible_tiles.size() > 0:
|
||||
tile_renderer.update_tiles(GameState.visible_tiles)
|
||||
if fog_renderer and fog_renderer.has_method("register_tile_positions"):
|
||||
fog_renderer.register_tile_positions(GameState.visible_tiles)
|
||||
|
||||
# Update fog overlay from visibility data
|
||||
# Update fog overlay — shader-based, reads from FogState autoload
|
||||
if fog_renderer and fog_renderer.has_method("update_fog"):
|
||||
fog_renderer.update_fog(GameState.visible_positions, GameState.player_position)
|
||||
fog_renderer.update_fog()
|
||||
|
||||
# Update entity sprites
|
||||
if entity_renderer and entity_renderer.has_method("update_entities"):
|
||||
entity_renderer.update_entities(GameState.visible_entities)
|
||||
|
||||
# D-018 #126: Update medium-range sound indicators
|
||||
if sound_indicator_renderer and sound_indicator_renderer.has_method("update_sound_events"):
|
||||
sound_indicator_renderer.update_sound_events(GameState.medium_sound_events)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
shader_type canvas_item;
|
||||
|
||||
// D-059: 5-layer fog shader. Composites over world content (layers 0-4).
|
||||
// Layer 1: Clear (vision cone) — transparent, soft gradient edge
|
||||
// Layer 2: Light fog (peripheral) — desaturated + dim + animated noise, 8-10s cycle
|
||||
// Layer 3: Deep fog (explored) — near-monochrome + zone tint + breathing, 15-20s cycle
|
||||
// Layer 4: Unexplored + maps — wireframe (Sprint 6: deferred, treated as Layer 5)
|
||||
// Layer 5: Unexplored, no maps — solid near-black #12141a
|
||||
|
||||
uniform sampler2D visibility_tex : filter_linear, repeat_disable;
|
||||
uniform sampler2D exploration_tex : filter_linear, repeat_disable;
|
||||
uniform sampler2D zone_tint_tex : filter_nearest, repeat_disable;
|
||||
uniform sampler2D noise_tex : filter_linear, repeat_enable;
|
||||
uniform vec2 rect_pos; // World-space position of the ColorRect (pixels)
|
||||
uniform vec2 rect_sz; // World-space size of the ColorRect (pixels)
|
||||
uniform vec2 map_offset; // map_bounds.position (tiles)
|
||||
uniform vec2 map_size; // map_bounds.size (tiles)
|
||||
uniform float tile_size; // Pixels per sim tile
|
||||
uniform float time; // Seconds since start
|
||||
|
||||
// D-059 fog layer colors
|
||||
const vec3 UNEXPLORED_COLOR = vec3(0.071, 0.078, 0.102); // #12141a
|
||||
const vec3 DARK_OVERLAY = vec3(0.02, 0.02, 0.05);
|
||||
|
||||
// D-059 thresholds (after bilinear filtering)
|
||||
// Forward tiles = 1.0, Peripheral = 0.706 (180/255), not-visible = 0.0
|
||||
const float CLEAR_THRESHOLD = 0.85; // Above this: fully clear
|
||||
const float PERIPHERAL_LOW = 0.55; // Below this: transition to deep/unexplored
|
||||
|
||||
void fragment() {
|
||||
// Map UV (0-1 across ColorRect) to world pixels, then to tile coordinates
|
||||
vec2 world_px = rect_pos + UV * rect_sz;
|
||||
vec2 tile = world_px / tile_size;
|
||||
|
||||
// Map tile coordinate to texture UV
|
||||
vec2 tex_uv = (tile - map_offset) / map_size;
|
||||
|
||||
// Outside known map → unexplored
|
||||
if (tex_uv.x < 0.0 || tex_uv.x > 1.0 || tex_uv.y < 0.0 || tex_uv.y > 1.0) {
|
||||
COLOR = vec4(UNEXPLORED_COLOR, 1.0);
|
||||
} else {
|
||||
float vis = texture(visibility_tex, tex_uv).r;
|
||||
float explored = texture(exploration_tex, tex_uv).r;
|
||||
|
||||
if (vis > PERIPHERAL_LOW) {
|
||||
// In or near vision cone
|
||||
if (vis > CLEAR_THRESHOLD) {
|
||||
// Layer 1: Clear — soft edge gradient
|
||||
float edge = smoothstep(CLEAR_THRESHOLD, 1.0, vis);
|
||||
COLOR = vec4(0.0, 0.0, 0.0, 1.0 - edge);
|
||||
} else {
|
||||
// Layer 2: Light fog (peripheral + forward edge)
|
||||
// D-059: animated Perlin noise, 8-10s cycle
|
||||
float noise_val = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r;
|
||||
float coverage = smoothstep(PERIPHERAL_LOW, CLEAR_THRESHOLD, vis);
|
||||
// Blend from heavy fog (alpha ~0.55) to lighter fog near clear edge
|
||||
float alpha = mix(0.55, 0.25, coverage) + noise_val * 0.1;
|
||||
COLOR = vec4(DARK_OVERLAY, alpha);
|
||||
}
|
||||
} else if (explored > 0.3) {
|
||||
// Layer 3: Deep fog (previously explored, no longer in LOS)
|
||||
// D-059: near-monochrome, ~10% zone temperature tint, 15-20s breathing cycle
|
||||
vec3 zone_tint = texture(zone_tint_tex, tex_uv).rgb;
|
||||
float noise_val = texture(noise_tex, tile * 0.015 + vec2(time * 0.045, time * 0.03)).r;
|
||||
vec3 tint_color = mix(vec3(0.04), zone_tint, 0.1);
|
||||
float alpha = mix(0.78, 0.90, noise_val); // Fog breathes
|
||||
COLOR = vec4(tint_color, alpha);
|
||||
} else {
|
||||
// Layer 5: Unexplored, no maps — information zero
|
||||
COLOR = vec4(UNEXPLORED_COLOR, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bcc1wwpk3moh1
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
̀
|
||||
@@ -0,0 +1 @@
|
||||
����
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
��
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
��
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
�����
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user