Compare commits
179
Commits
218673f0ea
..
v0.1.9
+60
-1
@@ -2,5 +2,64 @@
|
||||
"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 *)",
|
||||
|
||||
"Skill(commit)",
|
||||
"Skill(worktree-update)",
|
||||
"Skill(start-sprint)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(git push --force *)",
|
||||
"Bash(git reset --hard *)",
|
||||
"Bash(git clean -f *)",
|
||||
"Bash(rm -rf *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
---
|
||||
name: gen-audio
|
||||
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 audio
|
||||
db/connectors/audio-generate "prompt text" \
|
||||
--duration 10 \
|
||||
--steps 100 \
|
||||
--cfg 7 \
|
||||
--output path/to/output.wav \
|
||||
--timeout 600
|
||||
```
|
||||
|
||||
### 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 file path. Auto-names from prompt if omitted. |
|
||||
| `--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.
|
||||
- **Output is WAV at 44.1kHz stereo.** Convert to .ogg for Godot import:
|
||||
`ffmpeg -i input.wav -c:a libvorbis -q:a 6 output.ogg`
|
||||
|
||||
## 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`
|
||||
|
||||
## Single Asset Workflow
|
||||
|
||||
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 the assembled prompt. **One request
|
||||
at a time. Wait for completion.**
|
||||
7. Listen to the output (or describe it based on file size/duration).
|
||||
8. If acceptable, convert to .ogg and place in `client/assets/audio/`.
|
||||
9. Update the asset status 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, trim, normalize, convert.
|
||||
|
||||
## Post-Processing
|
||||
|
||||
After selecting the best generation:
|
||||
|
||||
```bash
|
||||
# Trim silence from start/end
|
||||
ffmpeg -i input.wav -af "silenceremove=start_periods=1:start_silence=0.1:start_threshold=-50dB,areverse,silenceremove=start_periods=1:start_silence=0.1:start_threshold=-50dB,areverse" trimmed.wav
|
||||
|
||||
# LUFS normalize to -16 LUFS (broadcast standard, good for game audio)
|
||||
ffmpeg -i trimmed.wav -af loudnorm=I=-16:LRA=11:TP=-1 normalized.wav
|
||||
|
||||
# Convert to .ogg for Godot
|
||||
ffmpeg -i normalized.wav -c:a libvorbis -q:a 6 output.ogg
|
||||
|
||||
# For loops: verify loop point
|
||||
ffplay -loop 0 output.ogg
|
||||
```
|
||||
|
||||
For ambient loops, create crossfade overlap:
|
||||
```bash
|
||||
# Create a 45s loop with 3s crossfade overlap
|
||||
# (manual: export 48s, crossfade first 3s with last 3s in Audacity)
|
||||
```
|
||||
|
||||
## Manual Synthesis (Insert-Tech Sounds)
|
||||
|
||||
For sounds under 200ms (cursor hover, weapon aim), Stable Audio Open cannot
|
||||
produce meaningful output. Use manual synthesis instead:
|
||||
|
||||
```python
|
||||
# Example: 50ms cursor hover tick
|
||||
import numpy as np
|
||||
import wave
|
||||
|
||||
sr = 44100
|
||||
duration = 0.05 # 50ms
|
||||
t = np.linspace(0, duration, int(sr * duration), endpoint=False)
|
||||
freq = 3200 # Hz
|
||||
signal = np.sin(2 * np.pi * freq * t)
|
||||
envelope = np.exp(-t * 80) # exponential decay
|
||||
audio = (signal * envelope * 32767).astype(np.int16)
|
||||
|
||||
with wave.open("cursor_hover.wav", "w") as f:
|
||||
f.setnchannels(1)
|
||||
f.setsampwidth(2)
|
||||
f.setframerate(sr)
|
||||
f.writeframes(audio.tobytes())
|
||||
```
|
||||
|
||||
## 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/palette.md)
|
||||
- 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:
|
||||
|
||||
```
|
||||
client/assets/audio/
|
||||
amb_station_base.ogg # Ambient bus
|
||||
amb_workplace_layer.ogg # Ambient bus
|
||||
amb_bar_layer.ogg # Ambient bus
|
||||
amb_corridor_layer.ogg # Ambient bus
|
||||
sfx_footstep_metal.ogg # Player Actions bus
|
||||
sfx_footstep_metal_run.ogg # Player Actions bus
|
||||
cursor_hover.ogg # UI Sounds bus
|
||||
implant_open.ogg # UI Sounds bus
|
||||
fog_recognition.ogg # UI Sounds bus
|
||||
weapon_aim.ogg # UI Sounds bus
|
||||
sfx_monologue_chime.ogg # UI Sounds bus
|
||||
sfx_monologue_chime_urgent.ogg # UI Sounds bus
|
||||
```
|
||||
|
||||
AudioManager discovers these 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,5 +1,5 @@
|
||||
---
|
||||
name: asset-gen
|
||||
name: gen-image
|
||||
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).
|
||||
@@ -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
|
||||
|
||||
@@ -103,6 +103,25 @@ tea pr create \
|
||||
|
||||
Report PR URL when done.
|
||||
|
||||
### 7. 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., `/push-pr "my title"`), use them as the
|
||||
|
||||
@@ -140,34 +140,36 @@ 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:
|
||||
**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)`:
|
||||
|
||||
```bash
|
||||
# Write review to temp file, then pass via cat
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER> "$(cat /tmp/review.md)"
|
||||
# Step 1: Write review to temp file (use Bash heredoc, NOT the Write tool)
|
||||
cat > /tmp/review-<branch>.md << 'EOF'
|
||||
## Review: <branch> -> main
|
||||
...review content...
|
||||
EOF
|
||||
|
||||
# Step 2: Post to Gitea (separate Bash call)
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER> "$(cat /tmp/review-<branch>.md)"
|
||||
```
|
||||
|
||||
These MUST be separate sequential Bash calls — do not chain them in a single
|
||||
command. The heredoc write must complete before `tea comment` reads the file.
|
||||
|
||||
## 7. Merging approved PRs
|
||||
|
||||
`tea pr merge` fails (405) when branches have conflicts with main. Merge
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
---
|
||||
name: start-sprint
|
||||
description: >
|
||||
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
|
||||
/start-sprint. Merges main into the team branch, finds the active sprint,
|
||||
reads the sprint briefing, and presents the work plan with ticket details.
|
||||
Start sprint work on a team branch, or close a sprint from main. Use when
|
||||
the user says "start sprint", "start working on the server/client/copy",
|
||||
"begin sprint", or invokes /start-sprint. On a team branch: merges main,
|
||||
finds the active sprint, reads the briefing, presents the work plan. On
|
||||
main: closes the active sprint, bumps the version (v0.1.N), updates the
|
||||
changelog, tags, and pushes.
|
||||
user-invocable: true
|
||||
allowed-tools: Bash, Read, Grep, Glob, TeamCreate, Task, TaskCreate, TaskUpdate, TaskList, SendMessage, AskUserQuestion
|
||||
---
|
||||
@@ -24,7 +26,68 @@ git branch --show-current
|
||||
```
|
||||
|
||||
Valid team branches: `server`, `client`, `copy`, `audio`, `visual`, `ci`.
|
||||
If on `main`, ask the user which team branch to check out first.
|
||||
|
||||
If on `main`, follow the **Main branch workflow** below instead of
|
||||
the team branch workflow (steps 2–8).
|
||||
|
||||
---
|
||||
|
||||
## Main branch workflow (sprint close + version bump)
|
||||
|
||||
When `/start-sprint` is run on `main`, it means the user wants to close
|
||||
the current sprint, cut a version, and prepare for the next one.
|
||||
|
||||
### M1. 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.
|
||||
|
||||
### M2. Bump the version
|
||||
|
||||
The project version scheme is `v0.1.{sprint_number}`. After closing
|
||||
sprint N, the version is `v0.1.N`.
|
||||
|
||||
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.
|
||||
|
||||
### M3. Commit the release
|
||||
|
||||
Stage and commit `CHANGELOG.md`:
|
||||
```
|
||||
chore(meta): release v0.1.N
|
||||
```
|
||||
|
||||
### M4. Tag the release
|
||||
|
||||
```bash
|
||||
git tag v0.1.N
|
||||
```
|
||||
|
||||
### M5. Push
|
||||
|
||||
```bash
|
||||
git push && git push --tags
|
||||
```
|
||||
|
||||
### M6. Report
|
||||
|
||||
Output a summary:
|
||||
- Sprint closed (name, done/total tickets, carry-over count)
|
||||
- Version tagged (`v0.1.N`)
|
||||
- Carry-over candidates (if any)
|
||||
- Suggest running `/plan-sprint` next to prepare the next sprint
|
||||
|
||||
---
|
||||
|
||||
## Team branch workflow
|
||||
|
||||
### 2. Sync with main
|
||||
|
||||
@@ -35,51 +98,56 @@ git merge origin/main --no-edit
|
||||
|
||||
If the merge has conflicts, report them and stop — do not force-resolve.
|
||||
|
||||
### 3. Find the active sprint
|
||||
### 3. Load sprint context
|
||||
|
||||
Run the sprint CLI to get the full context dump in one shot:
|
||||
|
||||
```bash
|
||||
db/connectors/ticket sprint --active
|
||||
db/connectors/sprint start-work
|
||||
```
|
||||
|
||||
Extract the sprint ID and name from the JSON output. If no active sprint,
|
||||
report that and stop.
|
||||
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 `docs/sprints/sprint-N/<team>.md` where N is the sprint ID
|
||||
and team matches the branch name (e.g. `server.md`, `client.md`, `copy.md`).
|
||||
If no matching briefing exists for the team, report that and suggest running
|
||||
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
|
||||
`/plan-sprint` to generate one.
|
||||
|
||||
Also check for a `joint.md` briefing — joint tasks involve both teams and
|
||||
should be mentioned.
|
||||
|
||||
### 5. Load ticket details
|
||||
|
||||
For each ticket listed in the briefing, run:
|
||||
For tickets that need more detail than the `start-work` summary provides:
|
||||
```bash
|
||||
db/connectors/ticket show <id>
|
||||
```
|
||||
|
||||
Identify which tickets are actionable now (no open blockers) vs blocked.
|
||||
|
||||
### 6. Read key decisions
|
||||
|
||||
Read the decision files referenced in the sprint briefing so the agent has
|
||||
full architectural context before starting work.
|
||||
|
||||
### 7. Present the work plan
|
||||
### 7. Mark tickets in progress and present the work plan
|
||||
|
||||
Output a summary:
|
||||
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)
|
||||
- Actionable tickets (unblocked, ready to start)
|
||||
- Tickets marked in_progress (list with IDs)
|
||||
- Blocked tickets (and what blocks them)
|
||||
- Key decisions loaded
|
||||
- Suggested first task (lowest ID unblocked ticket)
|
||||
|
||||
Do NOT mark any ticket as `in_progress` yet.
|
||||
|
||||
### 8. Confirm and spawn the team
|
||||
|
||||
Before spawning agents, use `AskUserQuestion` to confirm the work plan and
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
name: start-workshop
|
||||
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 /start-workshop.
|
||||
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 |
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
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
|
||||
@@ -3,6 +3,7 @@
|
||||
server/target/
|
||||
tooling/content-converter/target/
|
||||
tooling/line-previewer/target/
|
||||
tooling/test-client/target/
|
||||
content-ron/
|
||||
|
||||
# Godot client (further ignores managed by client team)
|
||||
|
||||
+247
-2
@@ -6,7 +6,254 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
@@ -22,8 +269,6 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
- 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
|
||||
|
||||
### Added
|
||||
- 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
|
||||
|
||||
@@ -33,10 +33,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
|
||||
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 +50,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 +80,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'"
|
||||
|
||||
@@ -2,7 +2,11 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
|
||||
|
||||
.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 validate-content content-ron
|
||||
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
|
||||
|
||||
# --- Configuration ---
|
||||
|
||||
@@ -32,14 +36,27 @@ 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 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 ""
|
||||
@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 ""
|
||||
@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:
|
||||
@@ -53,6 +70,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 ---
|
||||
|
||||
@@ -63,7 +86,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 ---
|
||||
|
||||
@@ -74,7 +97,7 @@ client:
|
||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||
$(GODOT) --path client
|
||||
|
||||
game: stop build-server
|
||||
game: stop build
|
||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||
@echo "Starting server..."
|
||||
@cd server && cargo run &
|
||||
@@ -97,6 +120,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/
|
||||
@@ -114,6 +172,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
|
||||
@@ -149,6 +271,15 @@ decisions-orphan:
|
||||
validate-content:
|
||||
@tooling/validate-content
|
||||
|
||||
check-fact-ids:
|
||||
@tooling/check-fact-ids
|
||||
|
||||
checklist-validate:
|
||||
@tooling/validate-checklist --check
|
||||
|
||||
checklist-generate:
|
||||
@tooling/validate-checklist
|
||||
|
||||
content-ron:
|
||||
cd tooling/content-converter && cargo build --release
|
||||
tooling/content-converter/target/release/content-converter --input content --output content-ron --verbose
|
||||
@@ -158,5 +289,5 @@ content-ron:
|
||||
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.
@@ -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.
@@ -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://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
|
||||
@@ -144,6 +144,14 @@ tutorial_prompts:
|
||||
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.
|
||||
|
||||
@@ -20,6 +20,9 @@ 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"
|
||||
|
||||
[display]
|
||||
|
||||
@@ -93,6 +96,21 @@ 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)
|
||||
]
|
||||
}
|
||||
|
||||
[rendering]
|
||||
|
||||
|
||||
+118
-7
@@ -1,36 +1,128 @@
|
||||
[gd_scene load_steps=10 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
[gd_scene load_steps=19 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="PackedScene" path="res://ui/gauntlet_hud.tscn" id="17_gauntlet"]
|
||||
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="18_bugreport"]
|
||||
|
||||
[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")
|
||||
|
||||
; --- 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")]
|
||||
|
||||
@@ -38,4 +130,23 @@ zoom = Vector2(2, 2)
|
||||
|
||||
[node name="MonologueDisplay" parent="UILayer" instance=ExtResource("8_monologue")]
|
||||
|
||||
[node name="InteractionPrompt" parent="UILayer" instance=ExtResource("9_prompt")]
|
||||
; 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")]
|
||||
|
||||
; 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("18_bugreport")]
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
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).
|
||||
|
||||
# --- 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
|
||||
|
||||
# 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 = {}
|
||||
|
||||
signal dip_changed(profile: String)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_setup_buses()
|
||||
_scan_registry()
|
||||
|
||||
|
||||
# --- 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()
|
||||
|
||||
|
||||
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
|
||||
_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)
|
||||
|
||||
|
||||
# --- 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)
|
||||
|
||||
|
||||
func get_volume(bus: String) -> float:
|
||||
return _bus_volumes.get(bus, 0.0)
|
||||
|
||||
|
||||
# --- Zone crossfade (D-073 stub) ---
|
||||
|
||||
## Handle zone transition. Server sends zone_id per tile in ObserverSnapshot.
|
||||
## Full crossfade implementation deferred to Sprint 9+ (D-073).
|
||||
## Stub exists so server integration can call it without conditional checks.
|
||||
func set_zone(_zone_id: String) -> void:
|
||||
pass
|
||||
|
||||
|
||||
# --- 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
|
||||
@@ -22,6 +22,27 @@ 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
|
||||
|
||||
# v7 fields (#431, D-059/D-060)
|
||||
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
|
||||
|
||||
func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
current_snapshot = snapshot
|
||||
|
||||
@@ -73,6 +94,44 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
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
|
||||
|
||||
# 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,107 @@
|
||||
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).
|
||||
# Composite diagonals: holding W+D simultaneously → northeast.
|
||||
#
|
||||
# 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
|
||||
}
|
||||
|
||||
var input_queue: Array[Dictionary] = []
|
||||
|
||||
# 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.
|
||||
# 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:
|
||||
if GameState.dialogue_active:
|
||||
return
|
||||
var dir := Vector2i.ZERO
|
||||
if Input.is_action_pressed("move_north"):
|
||||
dir.y -= 1
|
||||
if Input.is_action_pressed("move_south"):
|
||||
dir.y += 1
|
||||
if Input.is_action_pressed("move_east"):
|
||||
dir.x += 1
|
||||
if Input.is_action_pressed("move_west"):
|
||||
dir.x -= 1
|
||||
|
||||
if 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
|
||||
var action: Action = _dir_to_action(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
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 _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 +39,7 @@ func reset_test_state() -> void:
|
||||
_test_player_pos = Vector2i(10, 10)
|
||||
_test_facing = "North"
|
||||
_test_input_queue.clear()
|
||||
_test_in_dialogue = false
|
||||
|
||||
# Change connection state and emit signal
|
||||
func _set_state(new_state: ConnectionState) -> void:
|
||||
@@ -212,14 +214,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]:
|
||||
@@ -242,10 +251,13 @@ 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
|
||||
_:
|
||||
push_warning("SimBridge: unknown action enum %s" % action)
|
||||
return ""
|
||||
@@ -261,12 +273,22 @@ func _test_snapshot() -> Dictionary:
|
||||
|
||||
# Process queued inputs
|
||||
for action_name in _test_input_queue:
|
||||
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
|
||||
@@ -309,6 +331,49 @@ func _test_snapshot() -> Dictionary:
|
||||
],
|
||||
})
|
||||
|
||||
# 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": Protocol.PROTOCOL_VERSION,
|
||||
@@ -319,11 +384,16 @@ func _test_snapshot() -> Dictionary:
|
||||
"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,
|
||||
}
|
||||
|
||||
# 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
|
||||
@@ -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,6 +59,21 @@ 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 entity kind (Phase 1: defaults, Phase 2 #361: relationship-based)
|
||||
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
|
||||
"Npc": return ENTITY_COLOR_UNKNOWN
|
||||
"Object", "Terrain": return ENTITY_COLOR_OBJECT
|
||||
_: 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
|
||||
|
||||
|
||||
+188
-7
@@ -1,39 +1,220 @@
|
||||
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 = $UILayer/InteractionPrompt
|
||||
@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 bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
GameState.apply_snapshot(snapshot)
|
||||
|
||||
# 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()
|
||||
|
||||
# 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()
|
||||
|
||||
# #496: Update gauntlet HUD (room timer + personal bests)
|
||||
if gauntlet_hud and gauntlet_hud.has_method("update_from_state"):
|
||||
gauntlet_hud.update_from_state()
|
||||
|
||||
# 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.
|
||||
if _camera_anchored and not camera.position_smoothing_enabled:
|
||||
camera.position_smoothing_enabled = true
|
||||
camera.reset_smoothing()
|
||||
|
||||
# Send queued input to simulation
|
||||
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
|
||||
if input.action == InputMapper.Action.INTERACT:
|
||||
var target_id: int = interaction_prompt.get_interaction_target()
|
||||
# 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": interaction_prompt.get_selected_verb(),
|
||||
"verb": verb,
|
||||
}
|
||||
else:
|
||||
input["action_data"] = {
|
||||
"target_entity_id": null,
|
||||
"verb": null,
|
||||
}
|
||||
SimBridge.send_input(input)
|
||||
|
||||
|
||||
# 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))
|
||||
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()
|
||||
|
||||
@@ -11,7 +11,7 @@ class_name Protocol
|
||||
|
||||
## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs.
|
||||
## Reject snapshots where version != this value.
|
||||
const PROTOCOL_VERSION: int = 4
|
||||
const PROTOCOL_VERSION: int = 8
|
||||
|
||||
|
||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||
@@ -94,6 +94,92 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
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,
|
||||
@@ -101,8 +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,
|
||||
}
|
||||
|
||||
|
||||
@@ -213,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,
|
||||
@@ -242,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)
|
||||
@@ -260,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,312 @@
|
||||
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
|
||||
|
||||
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:
|
||||
return not weapon_mode_active or _shift_held
|
||||
|
||||
|
||||
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,31 @@ 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)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# Update entities from snapshot data
|
||||
func update_entities(entities: Array) -> void:
|
||||
var active_ids: Array = []
|
||||
@@ -62,19 +86,28 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
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_targets[entity_id] = Vector2(
|
||||
floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET,
|
||||
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET
|
||||
)
|
||||
@@ -100,19 +133,11 @@ 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)
|
||||
|
||||
# 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:
|
||||
|
||||
@@ -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
|
||||
@@ -2,16 +2,25 @@ 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
|
||||
|
||||
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,12 +34,10 @@ 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"):
|
||||
|
||||
@@ -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 @@
|
||||
�����
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
���
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
��������
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,173 @@
|
||||
## Generate MessagePack fixtures from GDScript encoder for Rust decoder testing.
|
||||
## Run with: make fixtures-client
|
||||
## (or: godot --headless --path client -s res://tests/gen_client_fixtures.gd)
|
||||
##
|
||||
## Reverse direction of server/tests/gen_fixtures.rs: GDScript encodes -> Rust decodes.
|
||||
## Together with the Rust-generated fixtures, this closes the cross-encoder
|
||||
## compatibility loop (D-030 Layer 1, #475).
|
||||
##
|
||||
## Loads Messagepack encoder directly (not via class_name) because -s scripts
|
||||
## run before the project's class_name registry is fully populated.
|
||||
extends SceneTree
|
||||
|
||||
var _Msgpack: GDScript
|
||||
var _count := 0
|
||||
var _errors := 0
|
||||
var _output_dir: String
|
||||
|
||||
|
||||
func _init():
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _run():
|
||||
_Msgpack = load("res://addons/messagepack/messagepack.gd")
|
||||
|
||||
# Resolve repo root from Godot project root (client/).
|
||||
# Assumes client/ is one level below repo root — validated below.
|
||||
var project_root := ProjectSettings.globalize_path("res://")
|
||||
var repo_root := project_root.rstrip("/").get_base_dir()
|
||||
_output_dir = repo_root.path_join("server/tests/fixtures/gdscript")
|
||||
|
||||
if not DirAccess.dir_exists_absolute(repo_root.path_join("server")):
|
||||
push_error("Repo root detection failed: %s/server/ does not exist" % repo_root)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
DirAccess.make_dir_recursive_absolute(_output_dir)
|
||||
|
||||
_generate_inputs()
|
||||
_generate_boundary_inputs()
|
||||
_generate_batch()
|
||||
|
||||
if _errors > 0:
|
||||
push_error("FAILED: %d encode errors encountered" % _errors)
|
||||
quit(1)
|
||||
return
|
||||
if _count == 0:
|
||||
push_error("FAILED: no fixtures generated")
|
||||
quit(1)
|
||||
return
|
||||
print("Generated %d GDScript fixtures at %s" % [_count, _output_dir])
|
||||
quit()
|
||||
|
||||
|
||||
func _write_fixture(name: String, bytes: PackedByteArray) -> void:
|
||||
if bytes.is_empty():
|
||||
push_error("Encode produced empty bytes for fixture: %s" % name)
|
||||
_errors += 1
|
||||
return
|
||||
var path := _output_dir.path_join(name + ".msgpack")
|
||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_error("Failed to write fixture: %s (error: %d)" % [path, FileAccess.get_open_error()])
|
||||
_errors += 1
|
||||
return
|
||||
file.store_buffer(bytes)
|
||||
file.flush()
|
||||
file.close()
|
||||
print(" Wrote %s (%d bytes)" % [name, bytes.size()])
|
||||
_count += 1
|
||||
|
||||
|
||||
## Encode a single PlayerInput to MessagePack bytes.
|
||||
## Mirrors Protocol.encode_player_input() from protocol.gd.
|
||||
func _encode_input(tick: int, action_name: String, action_data: Variant = null) -> PackedByteArray:
|
||||
var action: Variant
|
||||
if action_data != null:
|
||||
action = {action_name: action_data}
|
||||
elif action_name == "Interact":
|
||||
action = {"Interact": {"target_entity_id": null, "verb": null}}
|
||||
else:
|
||||
action = action_name
|
||||
|
||||
var result = _Msgpack.encode({"tick": tick, "action": action})
|
||||
if result.status != null:
|
||||
push_error("Encode failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
return result.value
|
||||
|
||||
|
||||
## Encode an array of PlayerInputs to MessagePack bytes.
|
||||
## Mirrors Protocol.encode_player_inputs() from protocol.gd.
|
||||
func _encode_inputs(inputs: Array) -> PackedByteArray:
|
||||
var wire_inputs: Array = []
|
||||
for input in inputs:
|
||||
var action_name: String = input["action_name"]
|
||||
var action_data: Variant = input.get("action_data")
|
||||
var action: Variant
|
||||
if action_data != null:
|
||||
action = {action_name: action_data}
|
||||
elif action_name == "Interact":
|
||||
action = {"Interact": {"target_entity_id": null, "verb": null}}
|
||||
else:
|
||||
action = action_name
|
||||
wire_inputs.append({"tick": input["tick"], "action": action})
|
||||
|
||||
var result = _Msgpack.encode(wire_inputs)
|
||||
if result.status != null:
|
||||
push_error("Batch encode failed: %s" % result.status)
|
||||
return PackedByteArray()
|
||||
return result.value
|
||||
|
||||
|
||||
func _generate_inputs() -> void:
|
||||
# Unit variants: all 8 movement directions (tick=100)
|
||||
for dir_name in [
|
||||
"MoveNorth", "MoveSouth", "MoveEast", "MoveWest",
|
||||
"MoveNortheast", "MoveSoutheast", "MoveSouthwest", "MoveNorthwest",
|
||||
]:
|
||||
_write_fixture("input_%s" % dir_name.to_snake_case(),
|
||||
_encode_input(100, dir_name))
|
||||
|
||||
# Data variant: UsePerceptionMode (tick=200)
|
||||
_write_fixture("input_perception_mode",
|
||||
_encode_input(200, "UsePerceptionMode", "thermal"))
|
||||
|
||||
# Struct variant: Interact with null fields (tick=100)
|
||||
_write_fixture("input_interact",
|
||||
_encode_input(100, "Interact"))
|
||||
|
||||
# Other unit variants
|
||||
_write_fixture("input_pause",
|
||||
_encode_input(100, "Pause"))
|
||||
_write_fixture("input_unpause",
|
||||
_encode_input(100, "Unpause"))
|
||||
_write_fixture("input_toggle_stance_up",
|
||||
_encode_input(100, "ToggleStanceUp"))
|
||||
_write_fixture("input_toggle_stance_down",
|
||||
_encode_input(100, "ToggleStanceDown"))
|
||||
_write_fixture("input_walk_away",
|
||||
_encode_input(100, "WalkAway"))
|
||||
|
||||
|
||||
func _generate_boundary_inputs() -> void:
|
||||
# Tick values at MessagePack encoding format boundaries.
|
||||
# GDScript encodes 256-32767 as int_16 (0xd1); Rust encodes as uint_16 (0xcd).
|
||||
# GDScript encodes 65536-2147483647 as int_32 (0xd2); Rust as uint_32 (0xce).
|
||||
# Both are valid MessagePack. Rust's rmp_serde must accept both.
|
||||
var boundary_ticks: Array = [
|
||||
[0, "boundary_tick_0"],
|
||||
[127, "boundary_tick_127"],
|
||||
[128, "boundary_tick_128"],
|
||||
[255, "boundary_tick_255"],
|
||||
[256, "boundary_tick_256"], # int_16 asymmetry start
|
||||
[32767, "boundary_tick_32767"], # int_16 asymmetry end
|
||||
[32768, "boundary_tick_32768"],
|
||||
[65535, "boundary_tick_65535"],
|
||||
[65536, "boundary_tick_65536"], # int_32 asymmetry start
|
||||
[2147483647, "boundary_tick_2147483647"], # int_32 asymmetry end
|
||||
]
|
||||
|
||||
for pair in boundary_ticks:
|
||||
_write_fixture(pair[1],
|
||||
_encode_input(pair[0], "Pause"))
|
||||
|
||||
|
||||
func _generate_batch() -> void:
|
||||
# Vec<PlayerInput> with two actions (mirrors Rust input_batch_two fixture)
|
||||
_write_fixture("input_batch_two",
|
||||
_encode_inputs([
|
||||
{"tick": 0, "action_name": "MoveNorth"},
|
||||
{"tick": 0, "action_name": "Interact"},
|
||||
]))
|
||||
@@ -0,0 +1 @@
|
||||
uid://njychcgqlvtv
|
||||
@@ -0,0 +1,612 @@
|
||||
## Anti-tedium regression tests (#494):
|
||||
## Guard against UI clutter that makes testing tedious.
|
||||
##
|
||||
## Test 1: F12 bug report capture — pressing F12 must not crash; when #495 lands,
|
||||
## the handler should pause, show capture dialog, save 3 files, unpause.
|
||||
## Test 2: Gauntlet progress UI hidden — in non-Gauntlet mode (no room_id or
|
||||
## gauntlet_mode flag in snapshot), timer and personal-bests must not appear.
|
||||
##
|
||||
## #495 blocked by #481/#490 (server). #496 blocked by #487 (server).
|
||||
## Tests stub blocked features and verify anti-tedium guards.
|
||||
## Spec ref: Sprint 9 briefing (client.md), #495, #496.
|
||||
class_name TestAntiTedium
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
var _instance: Node = null
|
||||
var GauntletHUDScript = load("res://ui/gauntlet_hud.gd")
|
||||
var BugReportDialogScript = load("res://ui/bug_report_dialog.gd")
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
SimBridge.reset_test_state()
|
||||
SimBridge._last_snapshot = null
|
||||
GameState.current_tick = 0
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.visible_positions = {}
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.game_time = {}
|
||||
GameState.pending_recognitions = []
|
||||
GameState.room_id = null
|
||||
GameState.gauntlet_mode = false
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
if _instance and is_instance_valid(_instance):
|
||||
_instance.queue_free()
|
||||
_instance = null
|
||||
|
||||
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
## Encode a minimal valid snapshot as MessagePack bytes (same helper as P0 tests).
|
||||
func _make_snapshot_bytes(overrides: Dictionary = {}) -> PackedByteArray:
|
||||
var snapshot := {
|
||||
"tick": overrides.get("tick", 1),
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"entities": overrides.get("entities", [{
|
||||
"entity_id": 1,
|
||||
"x": 10.0,
|
||||
"y": 10.0,
|
||||
"z": 0,
|
||||
"kind": "Player",
|
||||
"visibility": "Forward",
|
||||
}]),
|
||||
"game_time": {
|
||||
"day": 0,
|
||||
"time_of_day": 0,
|
||||
"day_phase": "Morning",
|
||||
"tick_rate": overrides.get("tick_rate", "Full"),
|
||||
},
|
||||
"player_facing": "North",
|
||||
"player_stance": "Walk",
|
||||
"player_inventory": [],
|
||||
"visible_tiles": [],
|
||||
"nearby_interactions": [],
|
||||
"pending_recognitions": [],
|
||||
}
|
||||
if overrides.has("current_monologue"):
|
||||
snapshot["current_monologue"] = overrides["current_monologue"]
|
||||
if overrides.has("current_dialogue"):
|
||||
snapshot["current_dialogue"] = overrides["current_dialogue"]
|
||||
# Gauntlet fields: only include if explicitly provided (absence = non-gauntlet)
|
||||
if overrides.has("room_id"):
|
||||
snapshot["room_id"] = overrides["room_id"]
|
||||
if overrides.has("gauntlet_mode"):
|
||||
snapshot["gauntlet_mode"] = overrides["gauntlet_mode"]
|
||||
var result = Messagepack.encode(snapshot)
|
||||
return result.value
|
||||
|
||||
|
||||
func _make_gauntlet_hud() -> Control:
|
||||
var hud = Control.new()
|
||||
hud.set_script(GauntletHUDScript)
|
||||
auto_free(hud)
|
||||
add_child(hud)
|
||||
hud._personal_bests = {} # Clear any stats loaded from disk
|
||||
return hud
|
||||
|
||||
|
||||
func _make_bug_report_dialog() -> Control:
|
||||
var dialog = Control.new()
|
||||
dialog.set_script(BugReportDialogScript)
|
||||
auto_free(dialog)
|
||||
add_child(dialog)
|
||||
return dialog
|
||||
|
||||
|
||||
# -- Test 1: F12 Bug Report Capture -------------------------------------------
|
||||
# #495: BUG_REPORT action triggers the WRONG button capture flow.
|
||||
# Verifies: dialog exists, activates on action, no state corruption.
|
||||
# Note: Input.parse_input_event + _unhandled_input is unreliable in headless
|
||||
# test mode. Tests drive the input queue directly for deterministic coverage.
|
||||
|
||||
func test_bug_report_dialog_exists_in_scene() -> void:
|
||||
# Verify the BugReportDialog node is present and hidden by default.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
_instance._process(0.016)
|
||||
|
||||
var dialog: Node = _find_node_recursive(_instance, "BugReportDialog")
|
||||
assert_that(dialog).override_failure_message(
|
||||
"BugReportDialog node should exist in scene tree"
|
||||
).is_not_null()
|
||||
if dialog is CanvasItem:
|
||||
assert_that((dialog as CanvasItem).visible).override_failure_message(
|
||||
"BugReportDialog should be hidden by default"
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_bug_report_activates_on_action() -> void:
|
||||
# Inject BUG_REPORT action directly into the queue and verify main.gd
|
||||
# triggers the dialog. This tests the full main._process() handling path.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
_instance._process(0.016)
|
||||
|
||||
# Record baseline state
|
||||
var mono_before: Variant = GameState.current_monologue
|
||||
var dialogue_before: Variant = GameState.current_dialogue
|
||||
|
||||
# Inject BUG_REPORT action into InputMapper queue
|
||||
InputMapper.input_queue.append({
|
||||
"action": InputMapper.Action.BUG_REPORT,
|
||||
"timestamp_msec": Time.get_ticks_msec(),
|
||||
})
|
||||
|
||||
# Process a frame — main.gd flushes the queue and triggers dialog
|
||||
_instance._process(0.016)
|
||||
|
||||
# Assert: dialog should be active
|
||||
var dialog: Node = _find_node_recursive(_instance, "BugReportDialog")
|
||||
assert_that(dialog).is_not_null()
|
||||
if dialog and dialog.has_method("is_active"):
|
||||
assert_that(dialog.is_active()).override_failure_message(
|
||||
"BugReportDialog should be active after BUG_REPORT action"
|
||||
).is_true()
|
||||
|
||||
# Assert: no unintended state corruption
|
||||
assert_that(GameState.current_monologue).override_failure_message(
|
||||
"BUG_REPORT should not spawn a monologue"
|
||||
).is_equal(mono_before)
|
||||
assert_that(GameState.current_dialogue).override_failure_message(
|
||||
"BUG_REPORT should not spawn a dialogue"
|
||||
).is_equal(dialogue_before)
|
||||
|
||||
|
||||
# -- Test 2: Gauntlet Progress UI Hidden in Non-Gauntlet Mode -----------------
|
||||
# When #496 lands, it adds a room timer and personal-bests overlay.
|
||||
# Anti-tedium guard: these must NOT be visible in normal (non-Gauntlet) play.
|
||||
#
|
||||
# Current state: no gauntlet UI exists in the scene tree.
|
||||
# This test guards against #496 accidentally showing gauntlet UI in all modes.
|
||||
|
||||
func test_no_gauntlet_ui_visible_in_default_mode() -> void:
|
||||
# Load main scene — represents non-Gauntlet (default) play mode
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
# Process one frame to initialize all children
|
||||
_instance._process(0.016)
|
||||
|
||||
# Check that no gauntlet-specific UI nodes are visible in the scene tree.
|
||||
# When #496 adds GauntletHUD/RoomTimer/PersonalBests, they must be hidden
|
||||
# by default (only shown when gauntlet_mode is true in the snapshot).
|
||||
var gauntlet_node_names := [
|
||||
"GauntletHUD", "RoomTimer", "PersonalBests", "GauntletOverlay",
|
||||
"GauntletTimer", "GauntletProgress",
|
||||
]
|
||||
for node_name in gauntlet_node_names:
|
||||
var node: Node = _find_node_recursive(_instance, node_name)
|
||||
if node != null and node is CanvasItem:
|
||||
assert_that((node as CanvasItem).visible).override_failure_message(
|
||||
"Gauntlet UI node '%s' must not be visible in non-Gauntlet mode" % node_name
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_snapshot_without_room_id_shows_no_gauntlet_ui() -> void:
|
||||
# A snapshot that lacks room_id and gauntlet_mode fields represents
|
||||
# normal play. Apply it and verify no gauntlet state leaks into GameState.
|
||||
var bytes := _make_snapshot_bytes({"tick": 1})
|
||||
SimBridge.receive_bytes(bytes)
|
||||
|
||||
var snapshot: Variant = SimBridge._last_snapshot
|
||||
assert_that(snapshot).is_not_null()
|
||||
|
||||
# Snapshot should NOT contain gauntlet fields
|
||||
assert_that(snapshot.has("room_id")).override_failure_message(
|
||||
"Non-gauntlet snapshot must not contain room_id"
|
||||
).is_false()
|
||||
assert_that(snapshot.has("gauntlet_mode")).override_failure_message(
|
||||
"Non-gauntlet snapshot must not contain gauntlet_mode"
|
||||
).is_false()
|
||||
|
||||
# Apply to GameState — gauntlet-related state should not exist
|
||||
GameState.apply_snapshot(snapshot)
|
||||
# #496: GameState now has room_id (Variant, null) and gauntlet_mode (bool, false).
|
||||
# A non-gauntlet snapshot must leave these at their defaults.
|
||||
assert_that(GameState.room_id).override_failure_message(
|
||||
"GameState.room_id should be null in non-gauntlet mode"
|
||||
).is_null()
|
||||
assert_that(GameState.gauntlet_mode).override_failure_message(
|
||||
"GameState.gauntlet_mode should be false in non-gauntlet mode"
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_gauntlet_ui_stays_hidden_after_multiple_snapshots() -> void:
|
||||
# Simulate several ticks of normal play — gauntlet UI must never appear.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
# Process 5 frames with normal (non-gauntlet) snapshots
|
||||
for tick in range(1, 6):
|
||||
var bytes := _make_snapshot_bytes({"tick": tick})
|
||||
SimBridge.receive_bytes(bytes)
|
||||
_instance._process(0.016)
|
||||
|
||||
# After 5 frames, no gauntlet nodes should have appeared
|
||||
var gauntlet_node_names := [
|
||||
"GauntletHUD", "RoomTimer", "PersonalBests", "GauntletOverlay",
|
||||
"GauntletTimer", "GauntletProgress",
|
||||
]
|
||||
for node_name in gauntlet_node_names:
|
||||
var node: Node = _find_node_recursive(_instance, node_name)
|
||||
if node != null and node is CanvasItem:
|
||||
assert_that((node as CanvasItem).visible).override_failure_message(
|
||||
"Gauntlet UI '%s' must stay hidden after %d non-gauntlet ticks" % [node_name, 5]
|
||||
).is_false()
|
||||
|
||||
|
||||
# -- GauntletHUD Feature Tests (#496) ----------------------------------------
|
||||
# Timer lifecycle, personal bests, room change, visibility.
|
||||
# Spec: sprint-9/client.md #496. Ticket: db/connectors/ticket show 496.
|
||||
|
||||
func test_format_time_zero() -> void:
|
||||
# Static utility: 0 seconds → "00:00"
|
||||
var hud := _make_gauntlet_hud()
|
||||
assert_that(hud._format_time(0.0)).is_equal("00:00")
|
||||
|
||||
|
||||
func test_format_time_sub_minute() -> void:
|
||||
# 47.9 seconds (truncates, no rounding) → "00:47"
|
||||
var hud := _make_gauntlet_hud()
|
||||
assert_that(hud._format_time(47.9)).is_equal("00:47")
|
||||
|
||||
|
||||
func test_format_time_over_minute() -> void:
|
||||
# 98.3 seconds → 1 min 38 sec → "01:38"
|
||||
var hud := _make_gauntlet_hud()
|
||||
assert_that(hud._format_time(98.3)).is_equal("01:38")
|
||||
|
||||
|
||||
func test_gauntlet_hud_shows_when_gauntlet_mode_active() -> void:
|
||||
# HUD must become visible when gauntlet_mode is true in the snapshot.
|
||||
var hud := _make_gauntlet_hud()
|
||||
assert_that(hud.visible).is_false()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
assert_that(hud.visible).override_failure_message(
|
||||
"GauntletHUD must be visible when gauntlet_mode is true"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_gauntlet_hud_hides_when_gauntlet_mode_deactivates() -> void:
|
||||
# HUD must hide when gauntlet_mode transitions from true to false.
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
assert_that(hud.visible).is_true()
|
||||
# Switch to non-gauntlet
|
||||
GameState.gauntlet_mode = false
|
||||
GameState.room_id = null
|
||||
hud.update_from_state()
|
||||
assert_that(hud.visible).override_failure_message(
|
||||
"GauntletHUD must hide when gauntlet_mode becomes false"
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_gauntlet_hud_timer_starts_on_room_entry() -> void:
|
||||
# Timer starts running and tracks room_id after room entry.
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
assert_that(hud.is_timer_running()).override_failure_message(
|
||||
"Timer should be running after room entry"
|
||||
).is_true()
|
||||
assert_that(hud.get_current_room_id()).is_equal("room_1")
|
||||
|
||||
|
||||
func test_gauntlet_hud_timer_increments_with_delta() -> void:
|
||||
# Timer accumulates real time via _process(delta).
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
hud._process(0.5)
|
||||
assert_that(hud.get_timer_seconds()).override_failure_message(
|
||||
"Timer should increment by delta (0.5s)"
|
||||
).is_equal_approx(0.5, 0.01)
|
||||
hud._process(1.0)
|
||||
assert_that(hud.get_timer_seconds()).override_failure_message(
|
||||
"Timer should accumulate (0.5 + 1.0 = 1.5s)"
|
||||
).is_equal_approx(1.5, 0.01)
|
||||
|
||||
|
||||
func test_gauntlet_hud_timer_resets_on_room_change() -> void:
|
||||
# Changing room_id resets the timer to 0 and starts counting for the new room.
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
hud._process(5.0)
|
||||
assert_that(hud.get_timer_seconds() > 4.0).is_true()
|
||||
# Change room
|
||||
GameState.room_id = "room_2"
|
||||
hud.update_from_state()
|
||||
assert_that(hud.get_timer_seconds()).override_failure_message(
|
||||
"Timer should reset to 0 on room change"
|
||||
).is_equal_approx(0.0, 0.01)
|
||||
assert_that(hud.get_current_room_id()).is_equal("room_2")
|
||||
assert_that(hud.is_timer_running()).is_true()
|
||||
|
||||
|
||||
func test_gauntlet_hud_records_personal_best() -> void:
|
||||
# Completing a room (by entering a new room) records a personal best.
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
hud._process(10.0)
|
||||
# Entering room_2 triggers _record_room_completion for room_1
|
||||
GameState.room_id = "room_2"
|
||||
hud.update_from_state()
|
||||
assert_that(hud.get_personal_best("room_1")).override_failure_message(
|
||||
"PB for room_1 should be ~10.0s after first completion"
|
||||
).is_equal_approx(10.0, 0.1)
|
||||
|
||||
|
||||
func test_gauntlet_hud_non_pb_does_not_overwrite() -> void:
|
||||
# A worse time must not overwrite an existing personal best.
|
||||
var hud := _make_gauntlet_hud()
|
||||
hud._personal_bests["room_1"] = 5.0 # Existing PB of 5 seconds
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
hud._process(10.0) # Worse time than existing PB
|
||||
GameState.room_id = "room_2"
|
||||
hud.update_from_state()
|
||||
assert_that(hud.get_personal_best("room_1")).override_failure_message(
|
||||
"PB should remain 5.0 when new time (10.0) is worse"
|
||||
).is_equal_approx(5.0, 0.01)
|
||||
|
||||
|
||||
func test_gauntlet_hud_pb_overwrites_when_faster() -> void:
|
||||
# A faster time must overwrite an existing personal best.
|
||||
var hud := _make_gauntlet_hud()
|
||||
hud._personal_bests["room_1"] = 15.0 # Existing PB of 15 seconds
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
hud._process(8.0) # Better time
|
||||
GameState.room_id = "room_2"
|
||||
hud.update_from_state()
|
||||
assert_that(hud.get_personal_best("room_1")).override_failure_message(
|
||||
"PB should update to 8.0 when new time beats old PB (15.0)"
|
||||
).is_equal_approx(8.0, 0.1)
|
||||
|
||||
|
||||
func test_gauntlet_hud_null_room_in_gauntlet_mode() -> void:
|
||||
# Gauntlet mode active but no room_id yet — HUD visible, timer stopped.
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = null
|
||||
hud.update_from_state()
|
||||
assert_that(hud.visible).override_failure_message(
|
||||
"HUD should be visible in gauntlet mode even without room_id"
|
||||
).is_true()
|
||||
assert_that(hud.is_timer_running()).override_failure_message(
|
||||
"Timer should NOT run when room_id is null"
|
||||
).is_false()
|
||||
|
||||
|
||||
func test_gauntlet_hud_timer_paused_when_not_visible() -> void:
|
||||
# Timer should not accumulate when HUD is not visible (non-gauntlet mode).
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
hud._process(2.0)
|
||||
assert_that(hud.get_timer_seconds()).is_equal_approx(2.0, 0.01)
|
||||
# Switch to non-gauntlet (hides HUD)
|
||||
GameState.gauntlet_mode = false
|
||||
hud.update_from_state()
|
||||
hud._process(5.0) # Should NOT accumulate
|
||||
assert_that(hud.get_timer_seconds()).override_failure_message(
|
||||
"Timer must not accumulate when HUD is hidden"
|
||||
).is_equal_approx(2.0, 0.01)
|
||||
|
||||
|
||||
func test_gauntlet_hud_finalize_records_current_room() -> void:
|
||||
# finalize() records the current room's time and stops the timer.
|
||||
# Called on disconnect via _on_connection_state_changed.
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
hud._process(7.5)
|
||||
hud.finalize()
|
||||
assert_that(hud.is_timer_running()).override_failure_message(
|
||||
"Timer should stop after finalize"
|
||||
).is_false()
|
||||
assert_that(hud.get_personal_best("room_1")).override_failure_message(
|
||||
"Finalize should record current room's time as PB"
|
||||
).is_equal_approx(7.5, 0.1)
|
||||
|
||||
|
||||
func test_gauntlet_hud_session_tracks_attempts() -> void:
|
||||
# Each room entry increments the attempt counter in _session_rooms.
|
||||
var hud := _make_gauntlet_hud()
|
||||
GameState.gauntlet_mode = true
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
hud._process(3.0)
|
||||
# Re-enter same room (via room change and back)
|
||||
GameState.room_id = "room_2"
|
||||
hud.update_from_state()
|
||||
GameState.room_id = "room_1"
|
||||
hud.update_from_state()
|
||||
assert_that(hud._session_rooms.has("room_1")).is_true()
|
||||
assert_that(hud._session_rooms["room_1"]["attempts"]).override_failure_message(
|
||||
"Room should show 2 attempts after two entries"
|
||||
).is_equal(2)
|
||||
|
||||
|
||||
func test_gauntlet_snapshot_roundtrip_via_apply() -> void:
|
||||
# Snapshot with gauntlet fields applied to GameState, consumed by HUD.
|
||||
# End-to-end: bytes → decode → apply → update_from_state.
|
||||
var bytes := _make_snapshot_bytes({
|
||||
"tick": 10,
|
||||
"gauntlet_mode": true,
|
||||
"room_id": "warehouse_01",
|
||||
})
|
||||
SimBridge.receive_bytes(bytes)
|
||||
GameState.apply_snapshot(SimBridge._last_snapshot)
|
||||
assert_that(GameState.gauntlet_mode).is_true()
|
||||
assert_that(GameState.room_id).is_equal("warehouse_01")
|
||||
var hud := _make_gauntlet_hud()
|
||||
hud.update_from_state()
|
||||
assert_that(hud.visible).is_true()
|
||||
assert_that(hud.get_current_room_id()).is_equal("warehouse_01")
|
||||
assert_that(hud.is_timer_running()).is_true()
|
||||
|
||||
|
||||
# -- BugReportDialog Feature Tests (#495) ------------------------------------
|
||||
# Pause/unpause lifecycle, wire guard, text render, state machine.
|
||||
# Spec: sprint-9/client.md #495. Ticket: db/connectors/ticket show 495.
|
||||
|
||||
func test_bug_report_sends_pause_on_open() -> void:
|
||||
# start_capture() must send Pause to the server.
|
||||
SimBridge.connect_to_sim()
|
||||
SimBridge._test_input_queue.clear()
|
||||
var dialog := _make_bug_report_dialog()
|
||||
dialog.start_capture()
|
||||
assert_that(dialog.is_active()).is_true()
|
||||
assert_that(SimBridge._test_input_queue.has("Pause")).override_failure_message(
|
||||
"Opening bug report should send Pause to server"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_bug_report_sends_unpause_on_close() -> void:
|
||||
# _close() must send Unpause to the server.
|
||||
SimBridge.connect_to_sim()
|
||||
var dialog := _make_bug_report_dialog()
|
||||
dialog.start_capture()
|
||||
SimBridge._test_input_queue.clear()
|
||||
dialog._close()
|
||||
assert_that(dialog.is_active()).is_false()
|
||||
assert_that(SimBridge._test_input_queue.has("Unpause")).override_failure_message(
|
||||
"Closing bug report should send Unpause to server"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_bug_report_not_double_activatable() -> void:
|
||||
# Calling start_capture() twice must not double-activate or send duplicate Pause.
|
||||
SimBridge.connect_to_sim()
|
||||
var dialog := _make_bug_report_dialog()
|
||||
dialog.start_capture()
|
||||
assert_that(dialog.is_active()).is_true()
|
||||
SimBridge._test_input_queue.clear()
|
||||
dialog.start_capture() # Second call — should be blocked
|
||||
assert_that(SimBridge._test_input_queue.size()).override_failure_message(
|
||||
"Double-activation should be blocked (no second Pause sent)"
|
||||
).is_equal(0)
|
||||
|
||||
|
||||
func test_bug_report_action_not_on_wire() -> void:
|
||||
# BUG_REPORT is client-only — must not produce a wire-format action name.
|
||||
var wire_name := SimBridge._action_enum_to_wire(InputMapper.Action.BUG_REPORT)
|
||||
assert_that(wire_name).override_failure_message(
|
||||
"BUG_REPORT must not produce a wire action name (client-only)"
|
||||
).is_equal("")
|
||||
|
||||
|
||||
func test_bug_report_render_text_contains_tick_and_entities() -> void:
|
||||
# _render_snapshot_text() must include tick number and entity data from GameState.
|
||||
GameState.current_tick = 42
|
||||
GameState.player_position = Vector2(5.0, 7.0)
|
||||
GameState.player_facing = "South"
|
||||
GameState.player_stance = "Crouch"
|
||||
GameState.visible_entities = [{
|
||||
"entity_id": 1, "x": 5.0, "y": 7.0, "z": 0,
|
||||
"kind": {"variant": "Player", "data": null}, "visibility": "Forward",
|
||||
}]
|
||||
GameState.visible_tiles = []
|
||||
GameState.game_time = {"day": 1, "day_phase": "Evening", "tick_rate": "Full"}
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
var dialog := _make_bug_report_dialog()
|
||||
var text: String = dialog._render_snapshot_text()
|
||||
assert_that(text.contains("t42")).override_failure_message(
|
||||
"Render text should contain tick number"
|
||||
).is_true()
|
||||
assert_that(text.contains("Entities (1)")).override_failure_message(
|
||||
"Render text should show entity count"
|
||||
).is_true()
|
||||
assert_that(text.contains("Player")).override_failure_message(
|
||||
"Render text should contain entity kind"
|
||||
).is_true()
|
||||
assert_that(text.contains("South")).override_failure_message(
|
||||
"Render text should contain player facing"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_bug_report_render_text_with_monologue_and_dialogue() -> void:
|
||||
# _render_snapshot_text() should include active monologue and dialogue.
|
||||
GameState.current_tick = 10
|
||||
GameState.player_position = Vector2(3.0, 3.0)
|
||||
GameState.player_facing = "North"
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.game_time = {"day": 0, "day_phase": "Morning", "tick_rate": "Full"}
|
||||
GameState.current_monologue = {"text": "Something is wrong here."}
|
||||
GameState.current_dialogue = {"npc_name": "Kael", "speech": "Who are you?"}
|
||||
var dialog := _make_bug_report_dialog()
|
||||
var text: String = dialog._render_snapshot_text()
|
||||
assert_that(text.contains("Something is wrong here.")).override_failure_message(
|
||||
"Render text should include monologue text"
|
||||
).is_true()
|
||||
assert_that(text.contains("Kael")).override_failure_message(
|
||||
"Render text should include dialogue NPC name"
|
||||
).is_true()
|
||||
assert_that(text.contains("Who are you?")).override_failure_message(
|
||||
"Render text should include dialogue speech"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_bug_report_render_text_empty_snapshot() -> void:
|
||||
# Edge case: F12 pressed before any snapshot data arrives.
|
||||
GameState.current_tick = 0
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.player_facing = "North"
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.game_time = {}
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
var dialog := _make_bug_report_dialog()
|
||||
var text: String = dialog._render_snapshot_text()
|
||||
# Should not crash, should produce some output
|
||||
assert_that(text.length() > 0).override_failure_message(
|
||||
"Render text should not be empty even with no snapshot data"
|
||||
).is_true()
|
||||
assert_that(text.contains("Entities (0)")).override_failure_message(
|
||||
"Empty snapshot should show 0 entities"
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Helper: recursive node search --------------------------------------------
|
||||
|
||||
func _find_node_recursive(root: Node, target_name: String) -> Node:
|
||||
if root.name == target_name:
|
||||
return root
|
||||
for child in root.get_children():
|
||||
var found := _find_node_recursive(child, target_name)
|
||||
if found != null:
|
||||
return found
|
||||
return null
|
||||
@@ -0,0 +1 @@
|
||||
uid://d3fj584itowcl
|
||||
@@ -0,0 +1,145 @@
|
||||
## Tests for camera anchor behavior — the camera must show the player's
|
||||
## position from the very first rendered frame, with no visible drift from (0,0).
|
||||
##
|
||||
## These tests verify the full pipeline: SimBridge → GameState → Camera2D.
|
||||
## The player starts at (10,10) in test mode, so the expected camera position
|
||||
## is (10,10) * TILE_SIZE = (320, 320).
|
||||
class_name TestCameraAnchor
|
||||
extends GdUnitTestSuite
|
||||
|
||||
const EXPECTED_PLAYER_POS := Vector2(10, 10)
|
||||
const EXPECTED_CAMERA_POS := Vector2(320, 320) # 10 * 32, 10 * 32
|
||||
|
||||
var _instance: Node = null
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
SimBridge.reset_test_state()
|
||||
GameState.current_tick = 0
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.visible_positions = {}
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
if _instance and is_instance_valid(_instance):
|
||||
_instance.queue_free()
|
||||
_instance = null
|
||||
|
||||
|
||||
# --- Pipeline precondition tests ---
|
||||
|
||||
func test_simbridge_connected_in_test_mode() -> void:
|
||||
SimBridge.state = SimBridge.ConnectionState.DISCONNECTED
|
||||
SimBridge.connect_to_sim()
|
||||
assert_that(SimBridge.state).is_equal(SimBridge.ConnectionState.CONNECTED)
|
||||
|
||||
|
||||
func test_poll_snapshot_returns_data_when_connected() -> void:
|
||||
SimBridge.connect_to_sim()
|
||||
var snapshot: Variant = SimBridge.poll_snapshot()
|
||||
assert_that(snapshot).is_not_null()
|
||||
assert_that(snapshot is Dictionary).is_true()
|
||||
|
||||
|
||||
func test_snapshot_has_player_at_10_10() -> void:
|
||||
var snap := SimBridge._test_snapshot()
|
||||
var player_x: float = -1.0
|
||||
var player_y: float = -1.0
|
||||
for entity in snap.entities:
|
||||
if entity.kind.variant == "Player":
|
||||
player_x = entity.x
|
||||
player_y = entity.y
|
||||
break
|
||||
assert_that(player_x).is_equal(10.0)
|
||||
assert_that(player_y).is_equal(10.0)
|
||||
|
||||
|
||||
func test_apply_snapshot_sets_player_position() -> void:
|
||||
var snap := SimBridge._test_snapshot()
|
||||
GameState.apply_snapshot(snap)
|
||||
assert_that(GameState.player_position).is_equal(EXPECTED_PLAYER_POS)
|
||||
|
||||
|
||||
# --- Camera anchor after _ready() ---
|
||||
|
||||
func test_camera_position_after_ready() -> void:
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
var camera: Camera2D = _instance.get_node("Camera2D")
|
||||
assert_that(camera.global_position).is_equal(EXPECTED_CAMERA_POS)
|
||||
|
||||
|
||||
func test_camera_anchored_flag_after_ready() -> void:
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
assert_that(_instance._camera_anchored).is_true()
|
||||
|
||||
|
||||
func test_camera_smoothing_off_after_ready() -> void:
|
||||
# Camera smoothing must be disabled during init to prevent lerp from (0,0).
|
||||
# If this test fails, the camera will visibly drift from origin to player.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
var camera: Camera2D = _instance.get_node("Camera2D")
|
||||
assert_that(camera.position_smoothing_enabled).is_false()
|
||||
|
||||
|
||||
# --- Camera behavior across frames ---
|
||||
|
||||
func test_camera_tracks_player_after_process() -> void:
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
# Simulate one game frame
|
||||
_instance._process(0.016)
|
||||
|
||||
var camera: Camera2D = _instance.get_node("Camera2D")
|
||||
var expected := GameState.player_position * Constants.TILE_SIZE
|
||||
assert_that(camera.global_position).is_equal(expected)
|
||||
|
||||
|
||||
func test_camera_smoothing_reenabled_after_process() -> void:
|
||||
# After the first anchored frame, smoothing should be back on for gameplay.
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
_instance._process(0.016)
|
||||
|
||||
var camera: Camera2D = _instance.get_node("Camera2D")
|
||||
assert_that(camera.position_smoothing_enabled).is_true()
|
||||
|
||||
|
||||
func test_camera_follows_player_movement() -> void:
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
|
||||
var camera: Camera2D = _instance.get_node("Camera2D")
|
||||
var initial_pos := camera.global_position
|
||||
|
||||
# Move player north via SimBridge test mode
|
||||
SimBridge._test_input_queue.append("MoveNorth")
|
||||
_instance._process(0.016)
|
||||
|
||||
# Camera should have moved with the player
|
||||
assert_that(camera.global_position.y < initial_pos.y).is_true()
|
||||
assert_that(camera.global_position).is_equal(
|
||||
GameState.player_position * Constants.TILE_SIZE)
|
||||
@@ -0,0 +1 @@
|
||||
uid://vw0iqhai1kum
|
||||
@@ -0,0 +1,206 @@
|
||||
## P1 client tests: fog shader state (4), entity lifecycle (2), pending recognition blob (1).
|
||||
## Information boundary + core rendering contract tests — the perception/fog system
|
||||
## that makes asymmetric information work.
|
||||
## Bug #5 class prevention: fog byte encoding errors. Bug #2 class: entity lifecycle.
|
||||
## Spec ref: stig-round3.md Section 1 (tests #3-#9), workshop-outcomes.md.
|
||||
class_name TestClientP1
|
||||
extends GdUnitTestSuite
|
||||
|
||||
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
|
||||
var FogEntitiesScript = load("res://scripts/rendering/fog_entities.gd")
|
||||
|
||||
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
func _get_fog_state() -> Node:
|
||||
var node = get_node_or_null("/root/FogState")
|
||||
if node == null:
|
||||
push_warning("TestClientP1: FogState autoload not found — test skipped")
|
||||
return node
|
||||
|
||||
|
||||
func _make_entity_renderer() -> Node2D:
|
||||
var renderer = Node2D.new()
|
||||
renderer.set_script(EntityRendererScript)
|
||||
add_child(renderer)
|
||||
return renderer
|
||||
|
||||
|
||||
func _make_fog_entities() -> Node2D:
|
||||
var node = Node2D.new()
|
||||
node.set_script(FogEntitiesScript)
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
# -- Fog state: visibility byte values (#3-#4) --------------------------------
|
||||
|
||||
func test_fog_visibility_forward_tile() -> void:
|
||||
# P1 #3: Forward-sector tile writes VIS_FORWARD (255) to _vis_bytes.
|
||||
var fog = _get_fog_state()
|
||||
if fog == null:
|
||||
return
|
||||
# Reset to deterministic state — 64x64 map at origin, all bytes zeroed
|
||||
GameState.visible_tiles = []
|
||||
fog._resize(Rect2i(0, 0, 64, 64))
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.visibility_sectors = {Vector2i(5, 5): "Forward"}
|
||||
fog.update_from_state()
|
||||
# Index: row 5 * width 64 + col 5
|
||||
assert_that(fog._vis_bytes[5 * 64 + 5]).override_failure_message(
|
||||
"Forward tile at (5,5) should be VIS_FORWARD=%d" % FogState.VIS_FORWARD
|
||||
).is_equal(FogState.VIS_FORWARD)
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visibility_sectors.clear()
|
||||
|
||||
|
||||
func test_fog_visibility_peripheral_tile() -> void:
|
||||
# P1 #4: Peripheral-sector tile writes VIS_PERIPHERAL (180) to _vis_bytes.
|
||||
var fog = _get_fog_state()
|
||||
if fog == null:
|
||||
return
|
||||
GameState.visible_tiles = []
|
||||
fog._resize(Rect2i(0, 0, 64, 64))
|
||||
GameState.visible_positions = {Vector2i(10, 8): true}
|
||||
GameState.visibility_sectors = {Vector2i(10, 8): "Peripheral"}
|
||||
fog.update_from_state()
|
||||
assert_that(fog._vis_bytes[8 * 64 + 10]).override_failure_message(
|
||||
"Peripheral tile at (10,8) should be VIS_PERIPHERAL=%d" % FogState.VIS_PERIPHERAL
|
||||
).is_equal(FogState.VIS_PERIPHERAL)
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visibility_sectors.clear()
|
||||
|
||||
|
||||
# -- Fog state: exploration persistence (#5) -----------------------------------
|
||||
|
||||
func test_fog_exploration_persistence() -> void:
|
||||
# P1 #5: Tile visible in frame 1 (EXP_VISIBLE), not visible in frame 2
|
||||
# → _exp_bytes decays to EXP_EXPLORED (128), not EXP_UNEXPLORED (0).
|
||||
var fog = _get_fog_state()
|
||||
if fog == null:
|
||||
return
|
||||
GameState.visible_tiles = []
|
||||
fog._resize(Rect2i(0, 0, 64, 64))
|
||||
var pos := Vector2i(5, 5)
|
||||
var idx: int = 5 * 64 + 5
|
||||
# Frame 1: tile visible
|
||||
GameState.visible_positions = {pos: true}
|
||||
GameState.visibility_sectors = {pos: "Forward"}
|
||||
fog.update_from_state()
|
||||
assert_that(fog._exp_bytes[idx]).override_failure_message(
|
||||
"Visible tile should have EXP_VISIBLE=%d" % FogState.EXP_VISIBLE
|
||||
).is_equal(FogState.EXP_VISIBLE)
|
||||
# Frame 2: tile no longer visible — should decay to EXP_EXPLORED, not EXP_UNEXPLORED
|
||||
GameState.visible_positions = {}
|
||||
GameState.visibility_sectors = {}
|
||||
fog.update_from_state()
|
||||
assert_that(fog._exp_bytes[idx]).override_failure_message(
|
||||
"Previously visible tile should decay to EXP_EXPLORED=%d, not EXP_UNEXPLORED" % FogState.EXP_EXPLORED
|
||||
).is_equal(FogState.EXP_EXPLORED)
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visibility_sectors.clear()
|
||||
|
||||
|
||||
# -- Fog state: hidden tile default (#6) --------------------------------------
|
||||
|
||||
func test_fog_hidden_tile_value() -> void:
|
||||
# P1 #6: Tile never in visible_positions → _vis_bytes == VIS_HIDDEN (0).
|
||||
# Verifies default state after _resize() and after update with other visible tiles.
|
||||
var fog = _get_fog_state()
|
||||
if fog == null:
|
||||
return
|
||||
GameState.visible_tiles = []
|
||||
fog._resize(Rect2i(0, 0, 64, 64))
|
||||
# Check a tile that was never made visible (30,30)
|
||||
var idx: int = 30 * 64 + 30
|
||||
assert_that(fog._vis_bytes[idx]).override_failure_message(
|
||||
"Never-visible tile should be VIS_HIDDEN=%d after resize" % FogState.VIS_HIDDEN
|
||||
).is_equal(FogState.VIS_HIDDEN)
|
||||
# Also verify it stays VIS_HIDDEN after an update that makes OTHER tiles visible
|
||||
GameState.visible_positions = {Vector2i(5, 5): true}
|
||||
GameState.visibility_sectors = {Vector2i(5, 5): "Forward"}
|
||||
fog.update_from_state()
|
||||
assert_that(fog._vis_bytes[idx]).override_failure_message(
|
||||
"Non-visible tile should remain VIS_HIDDEN=%d after update" % FogState.VIS_HIDDEN
|
||||
).is_equal(FogState.VIS_HIDDEN)
|
||||
GameState.visible_positions.clear()
|
||||
GameState.visibility_sectors.clear()
|
||||
|
||||
|
||||
# -- Entity lifecycle: removal on leaving visibility (#7) ----------------------
|
||||
|
||||
func test_entity_removed_when_leaving_visibility() -> void:
|
||||
# P1 #7: Entity present in one update, absent in next → entity_nodes empty.
|
||||
# Verifies removal (queue_free), not just hiding.
|
||||
var renderer := _make_entity_renderer()
|
||||
var entity := [{"entity_id": 42, "x": 5.0, "y": 5.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null}}]
|
||||
renderer.update_entities(entity)
|
||||
assert_that(renderer.entity_nodes.size()).override_failure_message(
|
||||
"Entity should exist after first update"
|
||||
).is_equal(1)
|
||||
assert_that(renderer.entity_nodes.has(42)).is_true()
|
||||
# Entity leaves visibility — removed from entity_nodes, node queue_free'd
|
||||
renderer.update_entities([])
|
||||
assert_that(renderer.entity_nodes.size()).override_failure_message(
|
||||
"Entity should be removed when absent from update"
|
||||
).is_equal(0)
|
||||
assert_that(renderer.entity_nodes.has(42)).is_false()
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
# -- Entity lifecycle: creation on first appearance (#8) -----------------------
|
||||
|
||||
func test_entity_created_on_first_appearance() -> void:
|
||||
# P1 #8: Empty renderer → update with 1 entity → entity_nodes.size() == 1,
|
||||
# child exists in scene tree.
|
||||
var renderer := _make_entity_renderer()
|
||||
assert_that(renderer.entity_nodes.size()).override_failure_message(
|
||||
"Fresh renderer should have no entities"
|
||||
).is_equal(0)
|
||||
var entity := [{"entity_id": 99, "x": 3.0, "y": 7.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null}}]
|
||||
renderer.update_entities(entity)
|
||||
assert_that(renderer.entity_nodes.size()).override_failure_message(
|
||||
"Renderer should have 1 entity after update"
|
||||
).is_equal(1)
|
||||
assert_that(renderer.entity_nodes.has(99)).is_true()
|
||||
# Verify child actually exists in the scene tree
|
||||
var node = renderer.entity_nodes[99]
|
||||
assert_that(node.get_parent()).is_equal(renderer)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
# -- Pending recognition blob rendering (#9) ----------------------------------
|
||||
|
||||
func test_pending_recognition_blob_rendering() -> void:
|
||||
# P1 #9: pending_recognitions with 1 entry → FogEntities tracks blob entity
|
||||
# (not a full entity sprite — drawn via _draw(), data in _entities dict).
|
||||
var fog_entities := _make_fog_entities()
|
||||
var saved_recognitions := GameState.pending_recognitions
|
||||
GameState.pending_recognitions = [{
|
||||
"entity_id": 77,
|
||||
"x": 10.0,
|
||||
"y": 15.0,
|
||||
"z": 0,
|
||||
"remaining_ticks": 5,
|
||||
"total_delay_ticks": 10,
|
||||
}]
|
||||
fog_entities.update_from_state()
|
||||
# FogEntities uses _draw() with no child nodes — verify internal tracking
|
||||
assert_that(fog_entities._entities.size()).override_failure_message(
|
||||
"FogEntities should track 1 blob entity"
|
||||
).is_equal(1)
|
||||
assert_that(fog_entities._entities.has(77)).is_true()
|
||||
# Verify position stored correctly
|
||||
var blob_data: Dictionary = fog_entities._entities[77]
|
||||
assert_that(blob_data.pos).is_equal(Vector2(10.0, 15.0))
|
||||
# Recognition progress should be calculated: 1.0 - 5/10 = 0.5
|
||||
assert_that(blob_data.progress).is_equal_approx(0.5, 0.01)
|
||||
# New entity triggers a sonar ping (D-059)
|
||||
assert_that(fog_entities._pings.size()).override_failure_message(
|
||||
"New fog entity should trigger a sonar ping"
|
||||
).is_greater_equal(1)
|
||||
# Cleanup
|
||||
GameState.pending_recognitions = saved_recognitions
|
||||
fog_entities.queue_free()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cw0yyp8mrchi7
|
||||
@@ -0,0 +1,320 @@
|
||||
## P2 client tests: camera (5), entity alpha/color (4), UI (7).
|
||||
## Validates camera tracking, entity visual state, and UI component lifecycle.
|
||||
## Spec ref: sprint-9/client.md #492.
|
||||
class_name TestClientP2
|
||||
extends GdUnitTestSuite
|
||||
|
||||
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
|
||||
var FogEntitiesScript = load("res://scripts/rendering/fog_entities.gd")
|
||||
|
||||
var _instance: Node = null
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
SimBridge.reset_test_state()
|
||||
SimBridge._last_snapshot = null
|
||||
GameState.current_tick = 0
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.visible_positions = {}
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.game_time = {}
|
||||
GameState.pending_recognitions = []
|
||||
GameState.nearby_interactions = []
|
||||
GameState.player_inventory = []
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.dialogue_active = false
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
if _instance and is_instance_valid(_instance):
|
||||
_instance.queue_free()
|
||||
_instance = null
|
||||
|
||||
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
func _make_scene() -> Node:
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
return _instance
|
||||
|
||||
|
||||
func _make_entity_renderer() -> Node2D:
|
||||
var renderer = Node2D.new()
|
||||
renderer.set_script(EntityRendererScript)
|
||||
add_child(renderer)
|
||||
return renderer
|
||||
|
||||
|
||||
func _make_fog_entities() -> Node2D:
|
||||
var node = Node2D.new()
|
||||
node.set_script(FogEntitiesScript)
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
# -- Camera (5) ----------------------------------------------------------------
|
||||
|
||||
func test_camera_zoom_default_2x() -> void:
|
||||
# P2-C01: Camera zoom should be 2x as set in main.tscn.
|
||||
var inst := _make_scene()
|
||||
var camera: Camera2D = inst.get_node("Camera2D")
|
||||
assert_that(camera.zoom).is_equal(Vector2(2, 2))
|
||||
|
||||
|
||||
func test_camera_smoothing_convergence() -> void:
|
||||
# P2-C02: After first _process, smoothing re-enables for gameplay feel.
|
||||
# After several frames, camera position should still match player position
|
||||
# (smoothing converges because target == position when stationary).
|
||||
var inst := _make_scene()
|
||||
var camera: Camera2D = inst.get_node("Camera2D")
|
||||
# First frame re-enables smoothing
|
||||
inst._process(0.016)
|
||||
assert_that(camera.position_smoothing_enabled).is_true()
|
||||
# Several more frames — stationary player, camera converges
|
||||
for i in 5:
|
||||
inst._process(0.016)
|
||||
var expected := GameState.player_position * Constants.TILE_SIZE
|
||||
var dist := camera.global_position.distance_to(expected)
|
||||
assert_that(dist < 0.1).override_failure_message(
|
||||
"Camera should converge to player position (dist: %.4f)" % dist
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_camera_viewport_tracks_player_position() -> void:
|
||||
# P2-C03: Camera position always equals player_position * TILE_SIZE.
|
||||
# No offset, no viewport clamping beyond player tracking.
|
||||
var inst := _make_scene()
|
||||
var camera: Camera2D = inst.get_node("Camera2D")
|
||||
var expected := GameState.player_position * Constants.TILE_SIZE
|
||||
assert_that(camera.global_position).override_failure_message(
|
||||
"Camera must track player position exactly"
|
||||
).is_equal(expected)
|
||||
|
||||
|
||||
func test_camera_follows_player_after_movement() -> void:
|
||||
# P2-C04: After player moves, camera position updates to new player position.
|
||||
var inst := _make_scene()
|
||||
var camera: Camera2D = inst.get_node("Camera2D")
|
||||
var initial_pos := camera.global_position
|
||||
# Move player south via SimBridge test mode
|
||||
SimBridge._test_input_queue.append("MoveSouth")
|
||||
inst._process(0.016)
|
||||
# Camera should have moved with player
|
||||
assert_that(camera.global_position.y > initial_pos.y).override_failure_message(
|
||||
"Camera Y should increase after moving south"
|
||||
).is_true()
|
||||
assert_that(camera.global_position).is_equal(
|
||||
GameState.player_position * Constants.TILE_SIZE)
|
||||
|
||||
|
||||
func test_camera_no_panning_locked_to_player() -> void:
|
||||
# P2-C05 (D-014): Camera is locked to player — no panning.
|
||||
# Camera position equals player_position * TILE_SIZE every frame.
|
||||
var inst := _make_scene()
|
||||
var camera: Camera2D = inst.get_node("Camera2D")
|
||||
# Process multiple frames
|
||||
for i in 10:
|
||||
inst._process(0.016)
|
||||
var expected := GameState.player_position * Constants.TILE_SIZE
|
||||
assert_that(camera.global_position).override_failure_message(
|
||||
"Frame %d: camera must be locked to player" % i
|
||||
).is_equal(expected)
|
||||
|
||||
|
||||
# -- Entity alpha/color (4) ---------------------------------------------------
|
||||
|
||||
func test_entity_null_visibility_defaults_full_alpha() -> void:
|
||||
# P2-E01: Entity with no visibility field (v1 backward compat) → alpha 1.0.
|
||||
var renderer := _make_entity_renderer()
|
||||
var entity := [{"entity_id": 50, "x": 3.0, "y": 3.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null}}]
|
||||
renderer.update_entities(entity)
|
||||
var node = renderer.entity_nodes[50]
|
||||
assert_that(node.modulate.a).override_failure_message(
|
||||
"Null visibility should default to full alpha"
|
||||
).is_equal_approx(1.0, 0.01)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
func test_entity_peripheral_to_forward_alpha_transition() -> void:
|
||||
# P2-E02: Updating entity from Peripheral to Forward changes alpha.
|
||||
var renderer := _make_entity_renderer()
|
||||
# First: Peripheral
|
||||
var entity_p := [{"entity_id": 60, "x": 4.0, "y": 4.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null}, "visibility": "Peripheral"}]
|
||||
renderer.update_entities(entity_p)
|
||||
var node = renderer.entity_nodes[60]
|
||||
assert_that(node.modulate.a).is_equal_approx(Constants.PERIPHERAL_ALPHA, 0.01)
|
||||
# Update to Forward
|
||||
var entity_f := [{"entity_id": 60, "x": 4.0, "y": 4.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null}, "visibility": "Forward"}]
|
||||
renderer.update_entities(entity_f)
|
||||
assert_that(node.modulate.a).override_failure_message(
|
||||
"Forward visibility should set alpha to 1.0"
|
||||
).is_equal_approx(1.0, 0.01)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
func test_entity_terrain_uses_object_color() -> void:
|
||||
# P2-E03: Terrain entity kind maps to ENTITY_COLOR_OBJECT.
|
||||
var renderer := _make_entity_renderer()
|
||||
var entity := [{"entity_id": 70, "x": 2.0, "y": 2.0, "z": 0,
|
||||
"kind": {"variant": "Terrain", "data": null}, "visibility": "Forward"}]
|
||||
renderer.update_entities(entity)
|
||||
var node = renderer.entity_nodes[70] as ColorRect
|
||||
assert_that(node.color).override_failure_message(
|
||||
"Terrain kind should use ENTITY_COLOR_OBJECT"
|
||||
).is_equal(Constants.ENTITY_COLOR_OBJECT)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
func test_entity_player_color_regardless_of_sector() -> void:
|
||||
# P2-E04: Player uses ENTITY_COLOR_PLAYER even in Peripheral sector.
|
||||
GameState.player_entity_id = 80
|
||||
var renderer := _make_entity_renderer()
|
||||
var entity := [{"entity_id": 80, "x": 5.0, "y": 5.0, "z": 0,
|
||||
"kind": {"variant": "Player", "data": null}, "visibility": "Peripheral"}]
|
||||
renderer.update_entities(entity)
|
||||
var node = renderer.entity_nodes[80] as ColorRect
|
||||
assert_that(node.color).override_failure_message(
|
||||
"Player color must be constant regardless of visibility sector"
|
||||
).is_equal(Constants.ENTITY_COLOR_PLAYER)
|
||||
# Alpha should still be dimmed for Peripheral
|
||||
assert_that(node.modulate.a).is_equal_approx(Constants.PERIPHERAL_ALPHA, 0.01)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
# -- UI (7) --------------------------------------------------------------------
|
||||
|
||||
func test_monologue_display_visible_hidden() -> void:
|
||||
# P2-U01: MonologueDisplay starts hidden, becomes visible after show_monologue.
|
||||
# Note: mono.is_visible is a custom bool property on MonologueDisplay
|
||||
# (monologue_display.gd:11), not the built-in CanvasItem.is_visible() method.
|
||||
# The monologue uses tween alpha for visual hide/show, so the built-in
|
||||
# .visible stays true — we test the script's own state tracking.
|
||||
var inst := _make_scene()
|
||||
var mono = inst.get_node("UILayer/MonologueDisplay")
|
||||
assert_that(mono.is_visible).override_failure_message(
|
||||
"Monologue should start hidden"
|
||||
).is_false()
|
||||
mono.show_monologue("Test thought.", 3.0)
|
||||
assert_that(mono.is_visible).override_failure_message(
|
||||
"Monologue should be visible after show_monologue"
|
||||
).is_true()
|
||||
assert_that(mono.text_label.text).is_equal("Test thought.")
|
||||
|
||||
|
||||
func test_interaction_list_shows_nearest_verb() -> void:
|
||||
# P2-U02: When nearby_interactions are present, interaction list shows verbs.
|
||||
var inst := _make_scene()
|
||||
var ilist = inst.get_node("InsertOverlay/InteractionList")
|
||||
GameState.nearby_interactions = [{
|
||||
"entity_id": 2,
|
||||
"entity_type": "Npc",
|
||||
"distance": 1,
|
||||
"verbs": [
|
||||
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
|
||||
{"kind": "Observe", "label": "Observe", "priority": 2, "available": true},
|
||||
],
|
||||
}]
|
||||
ilist.update_from_state()
|
||||
assert_that(ilist.get_visible_verb_count()).override_failure_message(
|
||||
"Interaction list should show 2 verbs"
|
||||
).is_equal(2)
|
||||
assert_that(ilist.is_showing()).is_true()
|
||||
# Cleanup
|
||||
GameState.nearby_interactions = []
|
||||
|
||||
|
||||
func test_inventory_grid_shows_carried_items() -> void:
|
||||
# P2-U03: Inventory grid becomes visible when player has items.
|
||||
var inst := _make_scene()
|
||||
var inv = inst.get_node("UILayer/InventoryGrid")
|
||||
# Initially empty — hidden
|
||||
GameState.player_inventory = []
|
||||
inv.update_from_state()
|
||||
assert_that(inv.visible).override_failure_message(
|
||||
"Inventory should be hidden when empty"
|
||||
).is_false()
|
||||
# Add items
|
||||
GameState.player_inventory = [
|
||||
{"item_id": 1, "name": "Keycard", "slot": 0},
|
||||
{"item_id": 2, "name": "Datapad", "slot": 1},
|
||||
]
|
||||
inv.update_from_state()
|
||||
assert_that(inv.visible).override_failure_message(
|
||||
"Inventory should be visible with items"
|
||||
).is_true()
|
||||
assert_that(inv.get_slot_count()).is_equal(2)
|
||||
# Cleanup
|
||||
GameState.player_inventory = []
|
||||
|
||||
|
||||
func test_dialogue_overlay_active_on_show() -> void:
|
||||
# P2-U04: Dialogue box reports active after show_dialogue.
|
||||
var inst := _make_scene()
|
||||
var dlg = inst.get_node("InsertOverlay/DialogueBox")
|
||||
assert_that(dlg.is_dialogue_active()).override_failure_message(
|
||||
"Dialogue should start inactive"
|
||||
).is_false()
|
||||
dlg.show_dialogue("Kael", "How's it going?", [
|
||||
{"text": "Fine.", "response_id": "r1", "priority": 1, "confrontation": false},
|
||||
])
|
||||
assert_that(dlg.is_dialogue_active()).override_failure_message(
|
||||
"Dialogue should be active after show_dialogue"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_game_state_tick_rate_paused() -> void:
|
||||
# P2-U05: TickRate::Paused is stored in game_time and queryable.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"game_time": {
|
||||
"day": 0,
|
||||
"time_of_day": 100,
|
||||
"day_phase": "Morning",
|
||||
"tick_rate": "Paused",
|
||||
},
|
||||
})
|
||||
assert_that(GameState.game_time.tick_rate).override_failure_message(
|
||||
"Paused tick rate must be stored in game_time"
|
||||
).is_equal("Paused")
|
||||
|
||||
|
||||
func test_fog_entities_blob_count_matches_recognitions() -> void:
|
||||
# P2-U06: FogEntities tracks one blob per pending recognition.
|
||||
var fog_entities := _make_fog_entities()
|
||||
var saved := GameState.pending_recognitions
|
||||
GameState.pending_recognitions = [
|
||||
{"entity_id": 101, "x": 5.0, "y": 5.0, "z": 0, "remaining_ticks": 3, "total_delay_ticks": 6},
|
||||
{"entity_id": 102, "x": 8.0, "y": 8.0, "z": 0, "remaining_ticks": 1, "total_delay_ticks": 6},
|
||||
]
|
||||
fog_entities.update_from_state()
|
||||
assert_that(fog_entities._entities.size()).override_failure_message(
|
||||
"FogEntities should track 2 blob entities"
|
||||
).is_equal(2)
|
||||
assert_that(fog_entities._entities.has(101)).is_true()
|
||||
assert_that(fog_entities._entities.has(102)).is_true()
|
||||
GameState.pending_recognitions = saved
|
||||
fog_entities.queue_free()
|
||||
|
||||
|
||||
func test_fog_overlay_z_index_covers_world() -> void:
|
||||
# P2-U07: FogOverlay renders above world content (z:900), ensuring
|
||||
# occluded tiles are visually covered.
|
||||
var inst := _make_scene()
|
||||
var fog_overlay = inst.get_node("World/FogOverlay")
|
||||
assert_that(fog_overlay.z_index).override_failure_message(
|
||||
"FogOverlay z_index must be %d (Z_FOG)" % Constants.Z_FOG
|
||||
).is_equal(Constants.Z_FOG)
|
||||
# FogEntities at z:950 — above fog, below insert overlay
|
||||
var fog_entities = inst.get_node("World/FogEntities")
|
||||
assert_that(fog_entities.z_index).override_failure_message(
|
||||
"FogEntities z_index must be %d (Z_FOG_ENTITIES)" % Constants.Z_FOG_ENTITIES
|
||||
).is_equal(Constants.Z_FOG_ENTITIES)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ci76ggq7n33w7
|
||||
@@ -0,0 +1,339 @@
|
||||
## P3 client tests: z-layer ordering (4), entity lerp (3), Tyre additions (5).
|
||||
## Validates scene tree draw order, framerate-independent entity interpolation,
|
||||
## and recognition transition timing.
|
||||
## Spec ref: sprint-9/client.md #493.
|
||||
class_name TestClientP3
|
||||
extends GdUnitTestSuite
|
||||
|
||||
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
|
||||
var FogEntitiesScript = load("res://scripts/rendering/fog_entities.gd")
|
||||
|
||||
var _instance: Node = null
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
SimBridge.reset_test_state()
|
||||
SimBridge._last_snapshot = null
|
||||
GameState.current_tick = 0
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.visible_positions = {}
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.game_time = {}
|
||||
GameState.pending_recognitions = []
|
||||
GameState.nearby_interactions = []
|
||||
GameState.player_inventory = []
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.player_facing = "North"
|
||||
GameState.dialogue_active = false
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
if _instance and is_instance_valid(_instance):
|
||||
_instance.queue_free()
|
||||
_instance = null
|
||||
|
||||
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
func _make_scene() -> Node:
|
||||
var scene := load("res://scenes/main.tscn")
|
||||
_instance = scene.instantiate()
|
||||
auto_free(_instance)
|
||||
add_child(_instance)
|
||||
return _instance
|
||||
|
||||
|
||||
func _make_entity_renderer() -> Node2D:
|
||||
var renderer = Node2D.new()
|
||||
renderer.set_script(EntityRendererScript)
|
||||
add_child(renderer)
|
||||
return renderer
|
||||
|
||||
|
||||
func _make_fog_entities() -> Node2D:
|
||||
var node = Node2D.new()
|
||||
node.set_script(FogEntitiesScript)
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
# -- Z-layer ordering (4) -----------------------------------------------------
|
||||
|
||||
func test_z_floor_below_ysort() -> void:
|
||||
# P3-Z01: FloorTiles (z:0) renders below YSortGroup (z:100).
|
||||
var inst := _make_scene()
|
||||
var floor_tiles = inst.get_node("World/FogGroup/FloorTiles")
|
||||
var ysort = inst.get_node("World/FogGroup/YSortGroup")
|
||||
assert_that(floor_tiles.z_index).override_failure_message(
|
||||
"FloorTiles z_index must be Z_FLOOR (%d)" % Constants.Z_FLOOR
|
||||
).is_equal(Constants.Z_FLOOR)
|
||||
assert_that(ysort.z_index).override_failure_message(
|
||||
"YSortGroup z_index must be Z_YSORT (%d)" % Constants.Z_YSORT
|
||||
).is_equal(Constants.Z_YSORT)
|
||||
assert_that(floor_tiles.z_index < ysort.z_index).is_true()
|
||||
|
||||
|
||||
func test_z_entities_inside_ysort_at_zero() -> void:
|
||||
# P3-Z02: Entities node lives inside YSortGroup with z_index = 0.
|
||||
# Y-sort contract: children of YSortGroup must use z_index = 0.
|
||||
var inst := _make_scene()
|
||||
var entities = inst.get_node("World/FogGroup/YSortGroup/Entities")
|
||||
assert_that(entities.z_index).override_failure_message(
|
||||
"Entities z_index must be 0 inside YSortGroup (y-sort contract)"
|
||||
).is_equal(0)
|
||||
assert_that(entities.get_parent().y_sort_enabled).override_failure_message(
|
||||
"Entities parent must have y_sort_enabled"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_z_fog_above_world_content() -> void:
|
||||
# P3-Z03: FogOverlay (z:900) renders above all world content including
|
||||
# YSortGroup (z:100) and Overhead (z:300).
|
||||
# Spec #34: FogOverlay z_index == Z_FOG (900).
|
||||
# Spec #35: FogEntities z_index == Z_FOG_ENTITIES (950).
|
||||
var inst := _make_scene()
|
||||
var fog = inst.get_node("World/FogOverlay")
|
||||
var fog_entities = inst.get_node("World/FogEntities")
|
||||
var fog_group = inst.get_node("World/FogGroup")
|
||||
var overhead = inst.get_node("World/FogGroup/Overhead")
|
||||
# FogOverlay must be a sibling of FogGroup (both children of World),
|
||||
# not a child of FogGroup — fog renders OVER the composited group.
|
||||
assert_that(fog.get_parent()).override_failure_message(
|
||||
"FogOverlay must be sibling of FogGroup (both under World)"
|
||||
).is_equal(fog_group.get_parent())
|
||||
assert_that(fog.z_index).override_failure_message(
|
||||
"FogOverlay z_index must be Z_FOG (%d) per D-049" % Constants.Z_FOG
|
||||
).is_equal(Constants.Z_FOG)
|
||||
assert_that(fog_entities.z_index).override_failure_message(
|
||||
"FogEntities z_index must be Z_FOG_ENTITIES (%d) per D-049/D-059" % Constants.Z_FOG_ENTITIES
|
||||
).is_equal(Constants.Z_FOG_ENTITIES)
|
||||
assert_that(fog.z_index > overhead.z_index).override_failure_message(
|
||||
"FogOverlay (z:%d) must render above Overhead (z:%d)" % [fog.z_index, overhead.z_index]
|
||||
).is_true()
|
||||
assert_that(fog_entities.z_index > fog.z_index).override_failure_message(
|
||||
"FogEntities (z:%d) must render above FogOverlay (z:%d)" % [fog_entities.z_index, fog.z_index]
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_z_ui_layer_above_world() -> void:
|
||||
# P3-Z04: UILayer (CanvasLayer 20) renders above InsertOverlay (CanvasLayer 10)
|
||||
# and both render above world content.
|
||||
var inst := _make_scene()
|
||||
var ui_layer = inst.get_node("UILayer") as CanvasLayer
|
||||
var insert_layer = inst.get_node("InsertOverlay") as CanvasLayer
|
||||
var modal_layer = inst.get_node("ModalLayer") as CanvasLayer
|
||||
assert_that(insert_layer.layer).override_failure_message(
|
||||
"InsertOverlay must be CanvasLayer %d" % Constants.CANVAS_INSERT
|
||||
).is_equal(Constants.CANVAS_INSERT)
|
||||
assert_that(ui_layer.layer).override_failure_message(
|
||||
"UILayer must be CanvasLayer %d" % Constants.CANVAS_UI
|
||||
).is_equal(Constants.CANVAS_UI)
|
||||
assert_that(modal_layer.layer).override_failure_message(
|
||||
"ModalLayer must be CanvasLayer %d" % Constants.CANVAS_MODAL
|
||||
).is_equal(Constants.CANVAS_MODAL)
|
||||
assert_that(ui_layer.layer > insert_layer.layer).override_failure_message(
|
||||
"UILayer must render above InsertOverlay"
|
||||
).is_true()
|
||||
assert_that(modal_layer.layer > ui_layer.layer).override_failure_message(
|
||||
"ModalLayer must render above UILayer"
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Entity lerp (3) ----------------------------------------------------------
|
||||
|
||||
func test_entity_snap_on_first_appear() -> void:
|
||||
# P3-L01: Entity spawns at its position immediately — no lerp on first appear.
|
||||
var renderer := _make_entity_renderer()
|
||||
var entity := [{"entity_id": 10, "x": 8.0, "y": 6.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null}}]
|
||||
renderer.update_entities(entity)
|
||||
var node = renderer.entity_nodes[10]
|
||||
var expected := Vector2(
|
||||
floorf(8.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET,
|
||||
floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET
|
||||
)
|
||||
assert_that(node.position).override_failure_message(
|
||||
"Entity should snap to position on first appear (no lerp)"
|
||||
).is_equal(expected)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
func test_entity_lerp_moves_toward_target() -> void:
|
||||
# P3-L02: After updating target position, entity moves toward it over time.
|
||||
var renderer := _make_entity_renderer()
|
||||
# Spawn at (5, 5)
|
||||
var entity := [{"entity_id": 11, "x": 5.0, "y": 5.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null}}]
|
||||
renderer.update_entities(entity)
|
||||
var node: ColorRect = renderer.entity_nodes[11]
|
||||
var start_pos: Vector2 = node.position
|
||||
# Move target to (6, 5)
|
||||
var entity_moved := [{"entity_id": 11, "x": 6.0, "y": 5.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null}}]
|
||||
renderer.update_entities(entity_moved)
|
||||
# Process several frames — entity should move toward target
|
||||
renderer._process(0.016)
|
||||
renderer._process(0.016)
|
||||
var after_pos: Vector2 = node.position
|
||||
var target := Vector2(
|
||||
floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET,
|
||||
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET
|
||||
)
|
||||
# Position should have moved toward target (x increased)
|
||||
assert_that(after_pos.x > start_pos.x).override_failure_message(
|
||||
"Entity x should move toward target after _process"
|
||||
).is_true()
|
||||
# But should not have snapped — still in transit
|
||||
assert_that(after_pos.x < target.x).override_failure_message(
|
||||
"Entity should still be in transit after 2 frames"
|
||||
).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
func test_entity_lerp_converges_within_300ms() -> void:
|
||||
# P3-L03: At LERP_SPEED=12.0, entity converges within ~0.3s.
|
||||
# At 12.0: weight = 1.0 - exp(-12.0 * 0.3) ≈ 0.973 — 97% there.
|
||||
var renderer := _make_entity_renderer()
|
||||
# Spawn at (5, 5)
|
||||
var entity := [{"entity_id": 12, "x": 5.0, "y": 5.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null}}]
|
||||
renderer.update_entities(entity)
|
||||
# Move target to (7, 5) — 2 tiles
|
||||
var entity_moved := [{"entity_id": 12, "x": 7.0, "y": 5.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null}}]
|
||||
renderer.update_entities(entity_moved)
|
||||
var target := Vector2(
|
||||
floorf(7.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET,
|
||||
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET
|
||||
)
|
||||
# Simulate 0.3s at 60fps (18 frames × 0.016s ≈ 0.288s)
|
||||
for i in 20:
|
||||
renderer._process(0.016)
|
||||
var final_node: ColorRect = renderer.entity_nodes[12]
|
||||
var final_pos: Vector2 = final_node.position
|
||||
# Should be within 5% of target (97% convergence at 0.3s)
|
||||
var dist: float = final_pos.distance_to(target)
|
||||
var total_dist: float = 2.0 * Constants.TILE_SIZE
|
||||
assert_that(dist / total_dist < 0.05).override_failure_message(
|
||||
"Entity should be within 5%% of target after 0.3s (dist: %.1f / %.1f)" % [dist, total_dist]
|
||||
).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
# -- Tyre additions (5) -------------------------------------------------------
|
||||
|
||||
func test_recognition_transition_progress() -> void:
|
||||
# P3-T01: Recognition progress calculated correctly from remaining/total ticks.
|
||||
# Grey blob → colored entity over total_delay_ticks.
|
||||
var fog_entities := _make_fog_entities()
|
||||
var saved := GameState.pending_recognitions
|
||||
# 3 remaining out of 10 total → progress 0.7
|
||||
GameState.pending_recognitions = [{
|
||||
"entity_id": 200, "x": 5.0, "y": 5.0, "z": 0,
|
||||
"remaining_ticks": 3, "total_delay_ticks": 10,
|
||||
}]
|
||||
fog_entities.update_from_state()
|
||||
var blob: Dictionary = fog_entities._entities[200]
|
||||
assert_that(blob.progress).override_failure_message(
|
||||
"Progress should be 0.7 (1.0 - 3/10)"
|
||||
).is_equal_approx(0.7, 0.01)
|
||||
# 0 remaining → fully recognized (progress 1.0)
|
||||
GameState.pending_recognitions = [{
|
||||
"entity_id": 200, "x": 5.0, "y": 5.0, "z": 0,
|
||||
"remaining_ticks": 0, "total_delay_ticks": 10,
|
||||
}]
|
||||
fog_entities.update_from_state()
|
||||
assert_that(fog_entities._entities[200].progress).override_failure_message(
|
||||
"Zero remaining should be progress 1.0"
|
||||
).is_equal_approx(1.0, 0.01)
|
||||
GameState.pending_recognitions = saved
|
||||
fog_entities.queue_free()
|
||||
|
||||
|
||||
func test_facing_indicator_rotation_matches_player_facing() -> void:
|
||||
# P3-T02: Facing indicator rotation matches player_facing from snapshot.
|
||||
GameState.player_entity_id = 1
|
||||
var renderer := _make_entity_renderer()
|
||||
var entity := [{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0,
|
||||
"kind": {"variant": "Player", "data": null}, "visibility": "Forward"}]
|
||||
renderer.update_entities(entity)
|
||||
var indicator = renderer.entity_nodes[1].get_node("FacingIndicator")
|
||||
# Test each cardinal + diagonal direction
|
||||
var expected := {
|
||||
"North": 0.0,
|
||||
"East": PI / 2.0,
|
||||
"South": PI,
|
||||
"West": 3.0 * PI / 2.0,
|
||||
}
|
||||
for dir in expected:
|
||||
GameState.player_facing = dir
|
||||
renderer.update_entities(entity)
|
||||
assert_that(indicator.rotation).override_failure_message(
|
||||
"%s: expected rotation %.3f, got %.3f" % [dir, expected[dir], indicator.rotation]
|
||||
).is_equal_approx(expected[dir], 0.001)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
func test_lerp_weight_increases_with_delta() -> void:
|
||||
# P3-T03: Sprint snappiness — larger delta → larger lerp weight → faster arrival.
|
||||
# Exponential smoothing: weight = 1.0 - exp(-LERP_SPEED * delta).
|
||||
# Higher delta (or higher lerp multiplier) means more progress per frame.
|
||||
var renderer := _make_entity_renderer()
|
||||
# Spawn entity, then move target
|
||||
var entity := [{"entity_id": 20, "x": 5.0, "y": 5.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null}}]
|
||||
renderer.update_entities(entity)
|
||||
var entity_moved := [{"entity_id": 20, "x": 8.0, "y": 5.0, "z": 0,
|
||||
"kind": {"variant": "Npc", "data": null}}]
|
||||
renderer.update_entities(entity_moved)
|
||||
# Small delta step
|
||||
var small_node: ColorRect = renderer.entity_nodes[20]
|
||||
var small_start: float = small_node.position.x
|
||||
renderer._process(0.008)
|
||||
var small_progress: float = small_node.position.x - small_start
|
||||
# Reset position for large delta test
|
||||
small_node.position = Vector2(
|
||||
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET,
|
||||
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET
|
||||
)
|
||||
# Large delta step
|
||||
var large_start: float = small_node.position.x
|
||||
renderer._process(0.032)
|
||||
var large_progress: float = small_node.position.x - large_start
|
||||
assert_that(large_progress > small_progress).override_failure_message(
|
||||
"Larger delta should produce more lerp progress (%.2f vs %.2f)" % [large_progress, small_progress]
|
||||
).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
func test_recognition_blob_removed_when_absent() -> void:
|
||||
# P3-T04: When a pending recognition disappears from state, FogEntities
|
||||
# removes the blob.
|
||||
var fog_entities := _make_fog_entities()
|
||||
var saved := GameState.pending_recognitions
|
||||
# Add entity
|
||||
GameState.pending_recognitions = [{
|
||||
"entity_id": 300, "x": 10.0, "y": 10.0, "z": 0,
|
||||
"remaining_ticks": 5, "total_delay_ticks": 10,
|
||||
}]
|
||||
fog_entities.update_from_state()
|
||||
assert_that(fog_entities._entities.size()).is_equal(1)
|
||||
# Remove from state
|
||||
GameState.pending_recognitions = []
|
||||
fog_entities.update_from_state()
|
||||
assert_that(fog_entities._entities.size()).override_failure_message(
|
||||
"Blob should be removed when absent from pending_recognitions"
|
||||
).is_equal(0)
|
||||
GameState.pending_recognitions = saved
|
||||
fog_entities.queue_free()
|
||||
|
||||
|
||||
func test_entity_renderer_lerp_speed_constant() -> void:
|
||||
# P3-T05: LERP_SPEED is tuned at 12.0 — exponential smoothing constant
|
||||
# for entity visual interpolation. Pin value to prevent accidental changes.
|
||||
assert_that(EntityRenderer.LERP_SPEED).override_failure_message(
|
||||
"LERP_SPEED must be 12.0 (entity movement feel constant)"
|
||||
).is_equal(12.0)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user