Compare commits
@@ -114,7 +114,7 @@ Synthesize findings.
|
||||
|
||||
### Qatux (Documenter & Librarian)
|
||||
- Core team member — participates in discussion rounds as documenter
|
||||
- Manages document search via `/search-docs` skill
|
||||
- Manages document search via `/docs-search` skill
|
||||
- Maintains DECISIONS.md, DISCUSSION.md, briefings, and Qdrant search index
|
||||
- Answers "did we discuss this?" with citations
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ Named after Qatux, the Raiel with perfect memory who helped Paula Myo by recalli
|
||||
- Provide "state of the project" summaries when asked
|
||||
|
||||
### Knowledge management
|
||||
- Maintain the Qdrant document index via /search-docs skill
|
||||
- Maintain the Qdrant document index via /docs-search skill
|
||||
- Update briefing files when decisions change
|
||||
- Answer retrieval questions: "did we discuss X?", "what did we decide about Y?"
|
||||
- Catch staleness in briefings and flag for update
|
||||
|
||||
@@ -50,10 +50,17 @@
|
||||
"Bash(find *)",
|
||||
"Bash(list *)",
|
||||
"Bash(tree *)",
|
||||
"Bash(sed -n *)",
|
||||
|
||||
"Skill(commit)",
|
||||
"Skill(git-commit)",
|
||||
"Skill(worktree-update)",
|
||||
"Skill(start-sprint)"
|
||||
"Skill(sprint-start)",
|
||||
"Skill(sprint-plan)",
|
||||
"Skill(pr-push)",
|
||||
"Skill(pr-review)",
|
||||
"Skill(ticket)",
|
||||
"Skill(docs-search)",
|
||||
"Skill(workshop-start)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(git push --force *)",
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
---
|
||||
name: audio-gen
|
||||
description: >
|
||||
Generate audio assets for The Settled Reach using the Stable Audio Open API
|
||||
(self-hosted Gradio app at tower-of-joy:11500). Use when generating any game
|
||||
audio: ambient loops, SFX, UI sounds, monologue chimes, footsteps, or any
|
||||
sound asset from docs/assets/audio/. Also use when the user asks about audio
|
||||
generation, sound design pipeline, or audio asset iteration. Triggers on:
|
||||
"generate audio", "make sounds", "create ambient", "audio pipeline",
|
||||
"generate sfx", "stable audio", "gen audio", "sound design".
|
||||
---
|
||||
|
||||
# Audio Generation — The Settled Reach
|
||||
|
||||
Generate sonically consistent audio assets using the Stable Audio Open API via
|
||||
wrapper scripts at `db/connectors/audio-*`.
|
||||
|
||||
Asset descriptions, filenames, bus routing, and design intent are documented in
|
||||
`docs/assets/audio/`. This skill provides the prompt system, generation
|
||||
workflow, and quality validation.
|
||||
|
||||
## API Access
|
||||
|
||||
**Never call the API directly.** Use the wrapper scripts:
|
||||
|
||||
```bash
|
||||
# Check API health
|
||||
db/connectors/audio-health
|
||||
|
||||
# Generate a single asset (WAV only)
|
||||
db/connectors/audio-generate "prompt text" \
|
||||
--duration 10 --steps 100 --cfg 7 \
|
||||
--output path/to/output.wav
|
||||
|
||||
# Generate + post-process in one command (WAV → trim → normalize → OGG)
|
||||
db/connectors/audio-generate "prompt text" \
|
||||
--duration 10 --steps 100 --cfg 7 \
|
||||
--output path/to/gen/intermediate.wav \
|
||||
--output-ogg client/assets/audio/final.ogg
|
||||
|
||||
# Batch-generate from a manifest (preferred for multiple assets)
|
||||
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Parameter | Default | Range | Notes |
|
||||
|-----------|---------|-------|-------|
|
||||
| `--duration` | 10 | 0-47s | Max 47s per generation. For longer loops, generate 45s with crossfade overlap. |
|
||||
| `--steps` | 100 | 10-200 | More steps = better quality, slower. Use 50 for quick previews, 100-150 for final. |
|
||||
| `--cfg` | 7 | 1-15 | Classifier-free guidance. Higher = more prompt-adherent but less natural. 5-9 is the sweet spot. |
|
||||
| `--output` | auto | — | Output WAV file path. Auto-names from prompt if omitted. |
|
||||
| `--post` | off | — | Run trim + normalize + convert after generation. |
|
||||
| `--output-ogg` | auto | — | OGG output path (implies `--post`). Defaults to same basename as WAV. |
|
||||
| `--timeout` | 600 | — | Max wait in seconds. Generation can take 2-5 minutes on 11GB VRAM. |
|
||||
|
||||
### Critical Constraints
|
||||
|
||||
- **NEVER parallelize requests.** The server has 11GB VRAM and runs one
|
||||
generation at a time. Always wait for a generation to complete before
|
||||
starting the next. Sequential only.
|
||||
- **Generation takes 2-5 minutes** per clip depending on duration and steps.
|
||||
Be patient. The timeout default (600s) is generous.
|
||||
- **Max 47 seconds** per generation. For 60-90s ambient loops, generate 45s
|
||||
clips and crossfade-stitch in post-processing.
|
||||
|
||||
## Prompt Assembly
|
||||
|
||||
Every generation uses three parts:
|
||||
|
||||
```
|
||||
[SONIC FAMILY PREFIX] + [CATEGORY TEMPLATE] + [ASSET DESCRIPTION from docs/assets/audio/]
|
||||
```
|
||||
|
||||
Never call the API with just the asset description. Always prepend the sonic
|
||||
family prefix and matching category template.
|
||||
|
||||
- **Sonic palette and families:** Read `references/sonic-palette.md`
|
||||
- **Category templates:** Read `references/category-templates.md` and match by
|
||||
asset type (ambient, sfx, ui)
|
||||
- **Asset description:** Look up the specific asset in `docs/assets/audio/{category}.md`
|
||||
|
||||
## Batch Workflow (Preferred)
|
||||
|
||||
For generating multiple assets, use a manifest file. This reduces prompt
|
||||
approvals to 2: one Write (manifest) + one Bash (batch run).
|
||||
|
||||
### 1. Create the manifest
|
||||
|
||||
Write a JSON manifest to `docs/assets/audio/batch-{sprint}-{ticket}.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"description": "Sprint 10 ambient + world SFX batch",
|
||||
"output_dir": "client/assets/audio",
|
||||
"gen_dir": "client/assets/audio/gen",
|
||||
"defaults": {
|
||||
"steps": 100,
|
||||
"cfg": 7,
|
||||
"lufs": -16,
|
||||
"quality": 6
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"id": "AMB-001",
|
||||
"filename": "amb_station_base.ogg",
|
||||
"method": "sao",
|
||||
"duration": 45,
|
||||
"steps": 150,
|
||||
"cfg": 5,
|
||||
"prompt": "[sonic family prefix] + [template] + [description]"
|
||||
},
|
||||
{
|
||||
"id": "UI-005",
|
||||
"filename": "sfx_monologue_chime.ogg",
|
||||
"method": "synth",
|
||||
"synth": {
|
||||
"type": "harmonic",
|
||||
"duration": 0.8,
|
||||
"fundamental": 1200,
|
||||
"harmonics": [
|
||||
{"freq": 2400, "db": -12},
|
||||
{"freq": 3600, "db": -24}
|
||||
],
|
||||
"attack_ms": 15,
|
||||
"sustain_ratio": 0.2,
|
||||
"decay": "exponential"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Asset `id` values must match IDs in `docs/assets/audio/{category}.md` (e.g.,
|
||||
AMB-001, SFX-002, UI-005). This couples the manifest to the asset inventory.
|
||||
|
||||
### 2. Run the batch
|
||||
|
||||
```bash
|
||||
# Full run
|
||||
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json
|
||||
|
||||
# Dry run — preview what would be generated
|
||||
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --dry-run
|
||||
|
||||
# Generate only specific assets
|
||||
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --only AMB-001,AMB-002
|
||||
|
||||
# Skip assets that already have OGG files
|
||||
db/connectors/audio-batch docs/assets/audio/batch-s10-327.json --skip-existing
|
||||
```
|
||||
|
||||
### 3. Update asset docs with prompts
|
||||
|
||||
After the batch completes, write the exact prompts used back into the
|
||||
Prompt/Notes column of `docs/assets/audio/{category}.md`. The manifest records
|
||||
what was generated; the asset docs record what we have.
|
||||
|
||||
### Manifest fields
|
||||
|
||||
| Field | Required | Notes |
|
||||
|-------|----------|-------|
|
||||
| `id` | yes | Asset ID from docs (AMB-001, SFX-002, UI-005) |
|
||||
| `filename` | yes | Output filename (must match asset doc) |
|
||||
| `method` | yes | `sao` (Stable Audio Open) or `synth` (harmonic synthesis) |
|
||||
| `duration` | SAO only | Duration in seconds |
|
||||
| `prompt` | SAO only | Full assembled prompt |
|
||||
| `steps` | no | Override default steps |
|
||||
| `cfg` | no | Override default CFG |
|
||||
| `synth` | synth only | Synthesis parameters (see below) |
|
||||
|
||||
### Synth parameters
|
||||
|
||||
| Field | Default | Notes |
|
||||
|-------|---------|-------|
|
||||
| `type` | harmonic | Only `harmonic` supported currently |
|
||||
| `duration` | — | Duration in seconds |
|
||||
| `fundamental` | — | Fundamental frequency in Hz |
|
||||
| `harmonics` | [] | List of `{"freq": Hz, "db": dB}` objects |
|
||||
| `attack_ms` | 10 | Attack time in milliseconds |
|
||||
| `sustain_ratio` | 0.2 | Fraction of duration at full level before decay |
|
||||
| `decay` | exponential | `exponential` or `linear` |
|
||||
|
||||
## Single Asset Workflow
|
||||
|
||||
For one-off generation or iteration on a specific asset:
|
||||
|
||||
1. Find the asset in `docs/assets/audio/{ambient,sfx,ui}.md` — note filename,
|
||||
duration, bus, method, and design intent.
|
||||
2. Read `references/sonic-palette.md` for the sonic family prefix.
|
||||
3. Read `references/category-templates.md` for the matching template.
|
||||
4. Assemble the full prompt.
|
||||
5. Run `db/connectors/audio-health` to verify the API is up.
|
||||
6. Run `db/connectors/audio-generate` with `--post` or `--output-ogg` to
|
||||
generate and post-process in one step.
|
||||
7. Verify the output (file size, duration).
|
||||
8. Update the asset status and prompt in `docs/assets/audio/{category}.md`.
|
||||
|
||||
## Iteration Workflow
|
||||
|
||||
For each asset, generate 4-6 candidates:
|
||||
|
||||
1. **Generate candidates** — vary the prompt slightly (add/remove descriptors,
|
||||
adjust CFG between 5-9). Run each generation sequentially — never in
|
||||
parallel.
|
||||
2. **Solo test** — does each candidate sound right alone?
|
||||
3. **Stack test** — play the candidate alongside other layers. Does it mask or
|
||||
clash?
|
||||
4. **Fatigue test** (loops only) — can you listen for 5+ minutes without a
|
||||
jarring repeat?
|
||||
5. **Close-your-eyes test** — does it create a mental image or sensation?
|
||||
6. Select the best candidate (post-processing is already done if `--post` was
|
||||
used).
|
||||
|
||||
## Post-Processing (Standalone)
|
||||
|
||||
If you need to post-process separately (e.g., re-normalizing an existing file):
|
||||
|
||||
```bash
|
||||
# Full pipeline: trim → normalize → convert
|
||||
db/connectors/audio-post pipeline input.wav --output output.ogg
|
||||
|
||||
# Individual steps
|
||||
db/connectors/audio-post trim input.wav
|
||||
db/connectors/audio-post normalize input.wav --lufs -16
|
||||
db/connectors/audio-post convert input.wav --output output.ogg
|
||||
```
|
||||
|
||||
## Manual Synthesis (Insert-Tech Sounds)
|
||||
|
||||
For sounds under 200ms (cursor hover, weapon aim), Stable Audio Open cannot
|
||||
produce meaningful output. Use manual synthesis via `tooling/synth_ui_sounds.py`
|
||||
or the batch manifest's `method: "synth"` with harmonic parameters.
|
||||
|
||||
For complex synthesis beyond the `harmonic` type (FM, filtered noise, bandpass
|
||||
impulse), write a custom script in `tooling/` following the pattern in
|
||||
`tooling/synth_ui_sounds.py`.
|
||||
|
||||
## Quality Checklist
|
||||
|
||||
After generating, verify:
|
||||
- Sound matches the sonic family (insert-tech = synthetic/precise, organic = warm/natural)
|
||||
- Frequency range doesn't mask other layers (check docs/assets/audio/)
|
||||
- Duration matches spec
|
||||
- No unwanted artifacts (clicks, pops, digital noise at start/end)
|
||||
- Loop point is clean (ambient loops only)
|
||||
- Volume sits well relative to other assets (LUFS normalized)
|
||||
- Passes the close-your-eyes test
|
||||
|
||||
## File Placement
|
||||
|
||||
Generated assets go to `client/assets/audio/` with exact filenames from the
|
||||
asset docs. Intermediates go to `client/assets/audio/gen/` (gitignored).
|
||||
|
||||
AudioManager discovers assets by directory scan — filenames must match exactly.
|
||||
@@ -0,0 +1,231 @@
|
||||
---
|
||||
name: bug-report
|
||||
description: >
|
||||
Process in-game bug reports captured by the Godot client's bug reporter.
|
||||
Use when the user says "bug reports", "check bug reports", "process bugs",
|
||||
or invokes /bug-report. Scans the user:// bug-reports directory, summarizes
|
||||
each report, and offers investigation, ticket creation, or dismissal.
|
||||
user-invocable: true
|
||||
allowed-tools: Bash, Read, Grep, Glob, Write
|
||||
---
|
||||
|
||||
# Bug Report Skill
|
||||
|
||||
Process in-game bug reports exported by the Godot client to the user data
|
||||
directory. Each report is a directory containing a snapshot of game state at the
|
||||
moment the tester filed the report.
|
||||
|
||||
```
|
||||
BUG_REPORT_DIR: /var/home/jeroenschweitzer/.local/share/godot/app_userdata/The Settled Reach/bug-reports/
|
||||
```
|
||||
|
||||
## Report structure
|
||||
|
||||
Each report lives in a directory named `gauntlet-t{tick}-{timestamp}/` and
|
||||
contains these files:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `description.txt` | Tester notes + metadata (tick, room, stance, facing, position) |
|
||||
| `render.txt` | Simplified text render of the game snapshot |
|
||||
| `snapshot.json` | Full JSON snapshot (entities, dialogue state, etc.) |
|
||||
| `inputs.jsonl` | Last 60 ticks of player input (replay format) |
|
||||
| `snapshots.jsonl` | Last 60 ticks of observer snapshots |
|
||||
| `seed.txt` | RNG seed for deterministic replay |
|
||||
|
||||
## Invocation
|
||||
|
||||
- `/bug-report` — scan and process all unprocessed reports
|
||||
- `/bug-report <directory-name>` — process a specific report by directory name
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Scan for unprocessed reports
|
||||
|
||||
List all report directories in the bug reports directory:
|
||||
|
||||
```bash
|
||||
ls -1d "/var/home/jeroenschweitzer/.local/share/godot/app_userdata/The Settled Reach/bug-reports/"*/
|
||||
```
|
||||
|
||||
If no directories are found, report "No bug reports found." and stop.
|
||||
|
||||
If the user provided a specific directory name as argument, filter to only that
|
||||
directory. If it does not exist, report the error and list available reports.
|
||||
|
||||
### 2. Read and summarize each report
|
||||
|
||||
For each report directory, read the following files using the Read tool:
|
||||
|
||||
1. **`description.txt`** — extract:
|
||||
- Tester description / notes (free text at top)
|
||||
- Tick number
|
||||
- Room name
|
||||
- Player stance, facing, position
|
||||
2. **`render.txt`** — extract:
|
||||
- A brief description of what the text render shows (room layout, visible
|
||||
entities, player position marker)
|
||||
3. **`snapshot.json`** — extract:
|
||||
- Total entity count
|
||||
- Whether dialogue is active (look for `dialogue` or `conversation` keys
|
||||
with non-null/non-empty values)
|
||||
- Whether monologue is active (look for `monologue` keys with non-null/
|
||||
non-empty values)
|
||||
- NPC names and positions if present
|
||||
- Any error or anomaly fields
|
||||
4. **`seed.txt`** — note the seed value for reference
|
||||
|
||||
Do NOT read `inputs.jsonl` or `snapshots.jsonl` during the summary phase.
|
||||
These are large files reserved for the investigation step.
|
||||
|
||||
### 3. Present the summary list
|
||||
|
||||
Present a numbered list of all reports with their summaries. Format:
|
||||
|
||||
```
|
||||
## Bug Reports Found: N
|
||||
|
||||
### 1. gauntlet-t{tick}-{timestamp}
|
||||
- **Tick:** {tick} | **Room:** {room} | **Position:** ({x}, {y})
|
||||
- **Stance:** {stance} | **Facing:** {facing}
|
||||
- **Entities:** {count} | **Dialogue active:** yes/no | **Monologue active:** yes/no
|
||||
- **Seed:** {seed}
|
||||
- **Description:** {tester notes, first 2-3 lines}
|
||||
- **Render overview:** {brief description of what render.txt shows}
|
||||
- **Observations:** {any anomalies spotted in the snapshot}
|
||||
|
||||
### 2. gauntlet-t{tick}-{timestamp}
|
||||
...
|
||||
```
|
||||
|
||||
### 4. Offer actions per report
|
||||
|
||||
After presenting the summary list, ask the user which action to take for each
|
||||
report. The three actions are:
|
||||
|
||||
#### Investigate
|
||||
|
||||
Dig deeper into the report for root cause analysis:
|
||||
|
||||
1. Read `snapshot.json` in full — analyze entity states, component values,
|
||||
relationships between entities, any inconsistencies
|
||||
2. Read `inputs.jsonl` — reconstruct what the player was doing in the 60 ticks
|
||||
leading up to the report. Look for:
|
||||
- Rapid input changes (stuck keys, input spam)
|
||||
- Movement into walls or invalid positions
|
||||
- Interaction attempts that may have failed
|
||||
- Timing patterns (actions on same tick as state changes)
|
||||
3. Read `snapshots.jsonl` — compare entity states across recent ticks to find
|
||||
when the bug manifested:
|
||||
- Entity position jumps
|
||||
- State machine transitions that look wrong
|
||||
- Component values going out of expected range
|
||||
- Entities appearing or disappearing unexpectedly
|
||||
4. Cross-reference with `render.txt` to confirm visual manifestation
|
||||
5. Read `seed.txt` and note it — the seed plus `inputs.jsonl` should allow
|
||||
deterministic replay of the scenario
|
||||
|
||||
Present findings as a root cause analysis:
|
||||
|
||||
```
|
||||
## Investigation: gauntlet-t{tick}-{timestamp}
|
||||
|
||||
### Timeline
|
||||
- t{tick-N}: {what happened}
|
||||
- t{tick-M}: {state change}
|
||||
- t{tick}: {bug manifests}
|
||||
|
||||
### Root cause
|
||||
{Analysis of what went wrong and why}
|
||||
|
||||
### Affected systems
|
||||
- {system 1}: {how it's involved}
|
||||
- {system 2}: {how it's involved}
|
||||
|
||||
### Reproduction
|
||||
Seed: {seed}
|
||||
Replay inputs.jsonl from tick {start} to reproduce.
|
||||
|
||||
### Suggested fix
|
||||
{If identifiable from the snapshot data}
|
||||
```
|
||||
|
||||
After investigation, return to the action prompt for this report (the user
|
||||
may want to create a ticket or dismiss after investigating).
|
||||
|
||||
#### Create ticket
|
||||
|
||||
Create a bug ticket in the project database. Determine the team from the
|
||||
nature of the bug:
|
||||
|
||||
- **server** — simulation bugs (entity state, movement, AI, ECS systems,
|
||||
perception, knowledge graph)
|
||||
- **client** — rendering bugs (display glitches, UI issues, input handling,
|
||||
audio, visual artifacts)
|
||||
- **server,client** — integration bugs (protocol mismatch, desync, bridge
|
||||
issues)
|
||||
|
||||
Construct the ticket title and description from the report summary and any
|
||||
investigation findings. Use the ticket CLI:
|
||||
|
||||
```bash
|
||||
db/connectors/ticket create bug "{title}" --team {team} --description "{description}"
|
||||
```
|
||||
|
||||
The description should include:
|
||||
- Bug summary (from tester notes)
|
||||
- Tick, room, position
|
||||
- Key observations from snapshot analysis
|
||||
- Seed for reproduction
|
||||
- Report directory name for reference
|
||||
|
||||
After creating the ticket, report the ticket ID to the user.
|
||||
|
||||
#### Dismiss
|
||||
|
||||
Mark the report as not actionable. Remove the report directory:
|
||||
|
||||
```bash
|
||||
rm -rf "/var/home/jeroenschweitzer/.local/share/godot/app_userdata/The Settled Reach/bug-reports/{report-dir}/"
|
||||
```
|
||||
|
||||
**Always confirm with the user before deleting.** State clearly which directory
|
||||
will be removed and wait for confirmation.
|
||||
|
||||
### 5. Batch processing
|
||||
|
||||
When processing multiple reports, work through them one at a time in the
|
||||
numbered order presented. For each report, complete the chosen action before
|
||||
moving to the next.
|
||||
|
||||
If the user wants to batch-dismiss multiple reports, confirm the full list
|
||||
of directories that will be deleted before proceeding.
|
||||
|
||||
### 6. Final summary
|
||||
|
||||
After all reports have been processed, present a summary:
|
||||
|
||||
```
|
||||
## Bug Report Processing Complete
|
||||
|
||||
- **Investigated:** {count}
|
||||
- **Tickets created:** {count} ({ticket IDs})
|
||||
- **Dismissed:** {count}
|
||||
- **Remaining unprocessed:** {count}
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Large `snapshot.json` files may need to be read with offset/limit parameters.
|
||||
Start with the first 200 lines to get the structure, then target specific
|
||||
sections.
|
||||
- `inputs.jsonl` and `snapshots.jsonl` are newline-delimited JSON. Each line
|
||||
is one tick. Read the last 10-20 lines first to focus on the moments before
|
||||
the report was filed.
|
||||
- The `render.txt` is a text-art representation of the game view. Entity
|
||||
positions in the render should match positions in the snapshot. Mismatches
|
||||
are themselves a bug signal (rendering vs simulation desync).
|
||||
- The seed in `seed.txt` combined with `inputs.jsonl` enables deterministic
|
||||
replay on the server. Note this in any ticket you create.
|
||||
- If the bug-reports directory does not exist, the tester has not yet run any
|
||||
gauntlet sessions or has not filed any reports. This is not an error.
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
name: d2-diagram
|
||||
description: >
|
||||
Generate technical diagrams using d2 (text-to-diagram CLI). Use when the
|
||||
user says "create a diagram", "draw architecture", "make a flowchart",
|
||||
"diagram this", "render d2", "d2", "data flow diagram", "entity relationship",
|
||||
"state machine", "sequence diagram", "UI flow", or invokes /d2-diagram.
|
||||
Produces .d2 source files and renders them to PNG. Also use when asked
|
||||
to update, re-render, or batch render existing diagrams.
|
||||
---
|
||||
|
||||
# d2 Diagram Generation
|
||||
|
||||
Generate technical diagrams from text using d2 (v0.7.1). Pure CLI, no
|
||||
external dependencies beyond the d2 binary.
|
||||
|
||||
**Binary:** `/home/linuxbrew/.linuxbrew/bin/d2`
|
||||
|
||||
## Project Defaults
|
||||
|
||||
| Setting | Value | Override |
|
||||
|---------|-------|----------|
|
||||
| Theme | 200 (Dark Mauve) | `--theme N` |
|
||||
| Layout | dagre | `--layout elk` |
|
||||
| Padding | 100px | — |
|
||||
| Format | PNG | `--svg` |
|
||||
|
||||
## Output Convention
|
||||
|
||||
```
|
||||
docs/diagrams/
|
||||
architecture/ # System architecture, IPC, component layout
|
||||
data-flow/ # Sequence diagrams, data pipelines
|
||||
entity/ # ER diagrams, ECS component schemas
|
||||
state/ # State machines, behavior trees
|
||||
ui/ # UI navigation flow, screen transitions
|
||||
```
|
||||
|
||||
Both `.d2` source and `.png` output are tracked in git.
|
||||
|
||||
## Single Diagram Workflow
|
||||
|
||||
1. **Determine category** — architecture, data-flow, entity, state, or ui
|
||||
2. **Read template** — `references/diagram-templates.md` for the matching category
|
||||
3. **Read syntax** — `references/d2-syntax-guide.md` if unfamiliar with d2 syntax
|
||||
4. **Write .d2 source** — to `docs/diagrams/{category}/{name}.d2`
|
||||
5. **Validate** — `.claude/skills/d2-diagram/scripts/d2-render.sh validate {file}`
|
||||
6. **Render** — `.claude/skills/d2-diagram/scripts/d2-render.sh {file}`
|
||||
7. **Read SVG** — verify the output, present to user
|
||||
|
||||
### Script Usage
|
||||
|
||||
```bash
|
||||
# Render with project defaults
|
||||
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/architecture/ipc-bridge.d2
|
||||
|
||||
# Validate syntax only
|
||||
.claude/skills/d2-diagram/scripts/d2-render.sh validate docs/diagrams/architecture/ipc-bridge.d2
|
||||
|
||||
# Auto-format source
|
||||
.claude/skills/d2-diagram/scripts/d2-render.sh fmt docs/diagrams/architecture/ipc-bridge.d2
|
||||
|
||||
# Sketch mode (hand-drawn look for drafts)
|
||||
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/ui/flow.d2 --sketch
|
||||
|
||||
# Light theme (for printable docs)
|
||||
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/entity/schema.d2 --theme 0
|
||||
|
||||
# SVG output (if specifically needed)
|
||||
.claude/skills/d2-diagram/scripts/d2-render.sh docs/diagrams/architecture/overview.d2 --svg
|
||||
```
|
||||
|
||||
## Batch Render
|
||||
|
||||
Re-render all diagrams after theme or style changes:
|
||||
|
||||
```bash
|
||||
# All diagrams
|
||||
.claude/skills/d2-diagram/scripts/d2-batch.sh
|
||||
|
||||
# One category
|
||||
.claude/skills/d2-diagram/scripts/d2-batch.sh docs/diagrams/architecture/
|
||||
|
||||
# Preview what would render
|
||||
.claude/skills/d2-diagram/scripts/d2-batch.sh --dry-run
|
||||
|
||||
# Force re-render everything
|
||||
.claude/skills/d2-diagram/scripts/d2-batch.sh --force
|
||||
```
|
||||
|
||||
Batch skips files whose PNG is newer than the `.d2` source unless `--force`.
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Variables for consistent styling
|
||||
|
||||
```d2
|
||||
vars: {
|
||||
color-bg: "#2a3040"
|
||||
color-stroke: "#333340"
|
||||
color-text: "#c8d0e0"
|
||||
color-accent: "#c8d8f0"
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-board (layers)
|
||||
|
||||
```d2
|
||||
# Base diagram here
|
||||
|
||||
layers: {
|
||||
detailed: {
|
||||
# More detailed view
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sequence diagrams
|
||||
|
||||
```d2
|
||||
shape: sequence_diagram
|
||||
client: Godot Client
|
||||
server: Rust Server
|
||||
|
||||
client -> server: TickRequest(delta)
|
||||
server -> client: WorldState(entities)
|
||||
```
|
||||
|
||||
### Imports
|
||||
|
||||
Split shared definitions into a separate file and import:
|
||||
|
||||
```d2
|
||||
...@shared-defs.d2
|
||||
```
|
||||
|
||||
## Agent Guidance
|
||||
|
||||
- **Qatux** — Architecture decision records, system overview diagrams, data
|
||||
schemas. Prefer architecture and entity templates.
|
||||
- **Tyre** — IPC bridge, ECS system flow, chunk loading pipeline, perception
|
||||
system data flow. Prefer architecture and data-flow templates.
|
||||
- **Araminta** — UI navigation flow, screen transitions, component hierarchy.
|
||||
Prefer UI flow template.
|
||||
|
||||
## References
|
||||
|
||||
- `references/d2-syntax-guide.md` — Language quick reference (shapes, edges,
|
||||
containers, styling, variables). Read when unfamiliar with d2 syntax.
|
||||
- `references/diagram-templates.md` — Five category templates with complete
|
||||
d2 source examples. Read when starting a new diagram.
|
||||
@@ -0,0 +1,212 @@
|
||||
# D2 Language Quick Reference
|
||||
|
||||
## Nodes
|
||||
|
||||
```d2
|
||||
server # Implicit label from key
|
||||
server: Simulation Server # Explicit label
|
||||
server: Simulation Server { # With properties
|
||||
shape: hexagon
|
||||
style.fill: "#2d3436"
|
||||
}
|
||||
```
|
||||
|
||||
## Edges
|
||||
|
||||
```d2
|
||||
a -> b # Directed
|
||||
a <- b # Reverse directed
|
||||
a <-> b # Bidirectional
|
||||
a -- b # Undirected
|
||||
a -> b: "label" # Labeled edge
|
||||
a -> b -> c # Chained
|
||||
```
|
||||
|
||||
## Containers (nesting)
|
||||
|
||||
```d2
|
||||
infrastructure: {
|
||||
server: Simulation Server
|
||||
database: State Store {
|
||||
shape: cylinder
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Shapes
|
||||
|
||||
| Shape | Use for |
|
||||
|-------|---------|
|
||||
| `rectangle` | Default. Components, modules, generic. |
|
||||
| `hexagon` | Systems, services, major components. |
|
||||
| `cylinder` | Databases, storage, persistent state. |
|
||||
| `diamond` | Decisions, conditions, branch points. |
|
||||
| `oval` / `circle` | Start/end states, events. |
|
||||
| `cloud` | External systems, networks. |
|
||||
| `person` | Actors, users, NPCs. |
|
||||
| `queue` | Message queues, buffers. |
|
||||
| `page` | Documents, files. |
|
||||
| `package` | Packages, modules, crates. |
|
||||
| `sql_table` | Database tables, ECS component schemas. |
|
||||
| `class` | Class diagrams, ECS system definitions. |
|
||||
| `code` | Code blocks (set `language` property). |
|
||||
| `markdown` | Rich text blocks. |
|
||||
|
||||
## SQL Tables
|
||||
|
||||
```d2
|
||||
entity: {
|
||||
shape: sql_table
|
||||
id: u64 {constraint: primary_key}
|
||||
name: String
|
||||
position: Vec2
|
||||
faction_id: u64 {constraint: foreign_key}
|
||||
}
|
||||
```
|
||||
|
||||
## Class Diagrams
|
||||
|
||||
```d2
|
||||
perception_system: {
|
||||
shape: class
|
||||
+run(world: &mut World)
|
||||
-calculate_los(entity: Entity): HashSet<Entity>
|
||||
#update_knowledge(entity: Entity, seen: HashSet<Entity>)
|
||||
}
|
||||
```
|
||||
|
||||
## Sequence Diagrams
|
||||
|
||||
```d2
|
||||
shape: sequence_diagram
|
||||
client: Godot Client
|
||||
server: Rust Server
|
||||
|
||||
client -> server: TickRequest(delta)
|
||||
server -> server: run ECS systems
|
||||
server -> client: WorldState(entities)
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
```d2
|
||||
node: Label {
|
||||
style: {
|
||||
fill: "#2d3436"
|
||||
stroke: "#333340"
|
||||
stroke-width: 2
|
||||
stroke-dash: 5 # Dashed line
|
||||
opacity: 0.8
|
||||
font-size: 14
|
||||
font-color: "#c8d0e0"
|
||||
bold: true
|
||||
italic: false
|
||||
border-radius: 4
|
||||
shadow: true
|
||||
3d: true # Rectangles only
|
||||
multiple: true # Stacked appearance
|
||||
double-border: true # Rectangles/ovals only
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Edge styling
|
||||
|
||||
```d2
|
||||
a -> b: {
|
||||
style: {
|
||||
stroke: "#c8d8f0"
|
||||
stroke-width: 2
|
||||
stroke-dash: 5
|
||||
opacity: 0.8
|
||||
animated: true # Animated flow
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Variables
|
||||
|
||||
```d2
|
||||
vars: {
|
||||
color-bg: "#1a1e24"
|
||||
color-stroke: "#333340"
|
||||
color-text: "#c8d0e0"
|
||||
color-accent: "#c8d8f0"
|
||||
}
|
||||
|
||||
node: {
|
||||
style.fill: ${color-bg}
|
||||
style.stroke: ${color-stroke}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
```
|
||||
|
||||
## Direction
|
||||
|
||||
```d2
|
||||
direction: right # left-to-right (default for dagre)
|
||||
direction: down # top-to-bottom
|
||||
direction: up
|
||||
direction: left
|
||||
```
|
||||
|
||||
## Imports
|
||||
|
||||
```d2
|
||||
...@shared-defs.d2 # Spread import (inline all definitions)
|
||||
```
|
||||
|
||||
## Icons
|
||||
|
||||
```d2
|
||||
node: Label {
|
||||
icon: https://icons.terrastruct.com/essentials/time.svg
|
||||
}
|
||||
```
|
||||
|
||||
## Layers (multi-board)
|
||||
|
||||
```d2
|
||||
# Base diagram content here
|
||||
|
||||
layers: {
|
||||
detailed: {
|
||||
# More detailed view
|
||||
}
|
||||
simplified: {
|
||||
# Simplified overview
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Scenarios (animated transitions)
|
||||
|
||||
```d2
|
||||
# Base state
|
||||
|
||||
scenarios: {
|
||||
alert: {
|
||||
# Changes from base for alert state
|
||||
}
|
||||
combat: {
|
||||
# Changes from base for combat state
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Comments
|
||||
|
||||
```d2
|
||||
# This is a comment
|
||||
node: Label # Inline comment
|
||||
```
|
||||
|
||||
## Project Colors (from visual-grammar-v01.md)
|
||||
|
||||
| Constant | Hex | Usage |
|
||||
|----------|-----|-------|
|
||||
| Zone 1 floor | `#1a1e24` | Dark backgrounds |
|
||||
| Zone 1 wall | `#2a3040` | Component fill |
|
||||
| Outline standard | `#333340` | Borders, strokes |
|
||||
| Insert chrome | `#c8d0e0` | Text, labels |
|
||||
| Zone 1 fixture | `#c8d8f0` | Accents, highlights |
|
||||
@@ -0,0 +1,251 @@
|
||||
# Diagram Templates
|
||||
|
||||
Copy, adapt, and render. Each template uses project colors from visual-grammar-v01.md.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture Diagram
|
||||
|
||||
System components, relationships, communication channels.
|
||||
|
||||
**When to use:** IPC bridge, perception pipeline, chunk loading, ECS system layout, client-server architecture.
|
||||
|
||||
**Agents:** Tyre (system architecture), Qatux (architecture decision records).
|
||||
|
||||
```d2
|
||||
vars: {
|
||||
color-bg: "#2a3040"
|
||||
color-stroke: "#333340"
|
||||
color-text: "#c8d0e0"
|
||||
color-accent: "#c8d8f0"
|
||||
}
|
||||
|
||||
direction: right
|
||||
|
||||
client: Godot Client {
|
||||
shape: hexagon
|
||||
style.fill: ${color-bg}
|
||||
style.font-color: ${color-text}
|
||||
|
||||
rendering: Rendering {
|
||||
style.fill: ${color-bg}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
ui: UI Layer {
|
||||
style.fill: ${color-bg}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
bridge: IPC Bridge {
|
||||
style.fill: ${color-bg}
|
||||
style.font-color: ${color-text}
|
||||
style.stroke: ${color-accent}
|
||||
}
|
||||
}
|
||||
|
||||
server: Rust Server {
|
||||
shape: hexagon
|
||||
style.fill: ${color-bg}
|
||||
style.font-color: ${color-text}
|
||||
|
||||
ecs: bevy_ecs {
|
||||
style.fill: ${color-bg}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
perception: Perception {
|
||||
style.fill: ${color-bg}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
bridge: IPC Bridge {
|
||||
style.fill: ${color-bg}
|
||||
style.font-color: ${color-text}
|
||||
style.stroke: ${color-accent}
|
||||
}
|
||||
}
|
||||
|
||||
client.bridge -> server.bridge: "stdin/stdout" {
|
||||
style.stroke: ${color-accent}
|
||||
style.stroke-dash: 5
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Entity Relationship
|
||||
|
||||
Data schemas, ECS components, knowledge graph structure.
|
||||
|
||||
**When to use:** Database tables, component definitions, entity relationships, knowledge store schema.
|
||||
|
||||
**Agents:** Tyre (ECS component design), Qatux (schema documentation).
|
||||
|
||||
```d2
|
||||
entity: Entity {
|
||||
shape: sql_table
|
||||
id: u64 {constraint: primary_key}
|
||||
name: String
|
||||
faction_id: u64 {constraint: foreign_key}
|
||||
}
|
||||
|
||||
position: Position {
|
||||
shape: sql_table
|
||||
entity_id: u64 {constraint: foreign_key}
|
||||
x: f32
|
||||
y: f32
|
||||
chunk_id: u32
|
||||
}
|
||||
|
||||
knowledge: KnowledgeEntry {
|
||||
shape: sql_table
|
||||
observer_id: u64 {constraint: foreign_key}
|
||||
subject_id: u64 {constraint: foreign_key}
|
||||
fact_type: FactType
|
||||
confidence: f32
|
||||
last_seen_tick: u64
|
||||
}
|
||||
|
||||
entity.id -> position.entity_id
|
||||
entity.id -> knowledge.observer_id
|
||||
entity.id -> knowledge.subject_id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Sequence / Data Flow
|
||||
|
||||
Ordered interactions between systems over time.
|
||||
|
||||
**When to use:** IPC message flow, tick processing, perception update cycle, dialogue system exchanges.
|
||||
|
||||
**Agents:** Tyre (system interaction design), Qatux (protocol documentation).
|
||||
|
||||
```d2
|
||||
shape: sequence_diagram
|
||||
|
||||
client: Godot Client
|
||||
bridge: IPC Bridge
|
||||
server: Rust Server
|
||||
ecs: ECS Systems
|
||||
|
||||
client -> bridge: TickRequest(delta, input)
|
||||
bridge -> server: deserialize + dispatch
|
||||
server -> ecs: run_systems(delta)
|
||||
ecs -> ecs: perception, AI, physics
|
||||
ecs -> server: collect WorldState
|
||||
server -> bridge: serialize WorldState
|
||||
bridge -> client: WorldState(entities, events)
|
||||
client -> client: update rendering
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. State Machine
|
||||
|
||||
Entity states, transitions, conditions.
|
||||
|
||||
**When to use:** NPC behavior states, game mode transitions, dialogue state, investigation phases.
|
||||
|
||||
**Agents:** Tyre (behavior system design), Qatux (state documentation).
|
||||
|
||||
```d2
|
||||
vars: {
|
||||
color-state: "#2a3040"
|
||||
color-text: "#c8d0e0"
|
||||
color-edge: "#c8d8f0"
|
||||
color-decision: "#333340"
|
||||
}
|
||||
|
||||
idle: Idle {
|
||||
style.fill: ${color-state}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
|
||||
alert: Alert {
|
||||
style.fill: ${color-state}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
|
||||
investigate: Investigate {
|
||||
style.fill: ${color-state}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
|
||||
combat: Combat {
|
||||
style.fill: ${color-state}
|
||||
style.font-color: ${color-text}
|
||||
style.stroke: "#f0b840"
|
||||
}
|
||||
|
||||
flee: Flee {
|
||||
style.fill: ${color-state}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
|
||||
idle -> alert: "stimulus detected" { style.stroke: ${color-edge} }
|
||||
alert -> investigate: "stimulus confirmed" { style.stroke: ${color-edge} }
|
||||
alert -> idle: "timeout / stimulus lost" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
|
||||
investigate -> combat: "threat confirmed" { style.stroke: "#f0b840" }
|
||||
investigate -> idle: "nothing found" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
|
||||
combat -> flee: "health < threshold" { style.stroke: "#f0b840" }
|
||||
combat -> idle: "threat eliminated" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
|
||||
flee -> idle: "safe distance reached" { style.stroke: ${color-edge}; style.stroke-dash: 5 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. UI Flow
|
||||
|
||||
Screen navigation, component hierarchy, interaction paths.
|
||||
|
||||
**When to use:** HUD layout relationships, menu navigation, dialogue flow, insert mode transitions.
|
||||
|
||||
**Agents:** Araminta (UI/visual design), Tyre (interface architecture), Qatux (UI documentation).
|
||||
|
||||
```d2
|
||||
vars: {
|
||||
color-screen: "#1a1e24"
|
||||
color-panel: "#2a3040"
|
||||
color-text: "#c8d0e0"
|
||||
color-nav: "#c8d8f0"
|
||||
}
|
||||
|
||||
gameplay: Gameplay {
|
||||
style.fill: ${color-screen}
|
||||
style.font-color: ${color-text}
|
||||
|
||||
hud: HUD {
|
||||
style.fill: ${color-panel}
|
||||
style.font-color: ${color-text}
|
||||
|
||||
minimap: Minimap
|
||||
monologue: Monologue Panel
|
||||
insert_display: Insert Display
|
||||
}
|
||||
|
||||
world: World View {
|
||||
style.fill: ${color-panel}
|
||||
style.font-color: ${color-text}
|
||||
}
|
||||
}
|
||||
|
||||
pause: Pause Menu {
|
||||
style.fill: ${color-screen}
|
||||
style.font-color: ${color-text}
|
||||
|
||||
inventory: Inventory
|
||||
journal: Journal
|
||||
settings: Settings
|
||||
}
|
||||
|
||||
dialogue: Dialogue Mode {
|
||||
style.fill: ${color-screen}
|
||||
style.font-color: ${color-text}
|
||||
|
||||
speaker: Speaker Panel
|
||||
responses: Response List
|
||||
}
|
||||
|
||||
gameplay -> pause: "ESC" { style.stroke: ${color-nav} }
|
||||
pause -> gameplay: "ESC / Resume" { style.stroke: ${color-nav}; style.stroke-dash: 5 }
|
||||
gameplay -> dialogue: "interact with NPC" { style.stroke: ${color-nav} }
|
||||
dialogue -> gameplay: "end conversation" { style.stroke: ${color-nav}; style.stroke-dash: 5 }
|
||||
```
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR" && git rev-parse --show-toplevel)"
|
||||
RENDER="$SCRIPT_DIR/d2-render.sh"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [directory] [options]
|
||||
|
||||
Batch render all .d2 files in a directory. Skips files whose PNG is
|
||||
newer than the source unless --force is used.
|
||||
|
||||
Options:
|
||||
--dry-run List files that would be rendered
|
||||
--force Re-render even if SVG is up to date
|
||||
--theme N Override theme for all files
|
||||
|
||||
Examples:
|
||||
$(basename "$0") # All in docs/diagrams/
|
||||
$(basename "$0") docs/diagrams/architecture/ # One category
|
||||
$(basename "$0") --dry-run # Preview
|
||||
$(basename "$0") --force # Re-render everything
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
DIR="$REPO_ROOT/docs/diagrams"
|
||||
DRY_RUN=false
|
||||
FORCE=false
|
||||
EXTRA_ARGS=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
--force) FORCE=true; shift ;;
|
||||
--theme) EXTRA_ARGS+=(--theme "$2"); shift 2 ;;
|
||||
--help|-h) usage ;;
|
||||
*)
|
||||
if [[ -d "$1" ]] || [[ -d "$REPO_ROOT/$1" ]]; then
|
||||
DIR="$1"
|
||||
[[ "$DIR" != /* ]] && DIR="$REPO_ROOT/$DIR"
|
||||
else
|
||||
echo "Unknown option or directory: $1" >&2; exit 1
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ ! -d "$DIR" ]] && { echo "ERROR: Directory not found: $DIR" >&2; exit 1; }
|
||||
|
||||
RENDERED=0
|
||||
SKIPPED=0
|
||||
FAILED=0
|
||||
|
||||
while IFS= read -r -d '' d2_file; do
|
||||
png_file="${d2_file%.d2}.png"
|
||||
|
||||
# Skip if PNG is newer than source (unless --force)
|
||||
if [[ "$FORCE" != true ]] && [[ -f "$png_file" ]] && [[ "$png_file" -nt "$d2_file" ]]; then
|
||||
SKIPPED=$((SKIPPED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
rel_path="${d2_file#"$REPO_ROOT/"}"
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
echo "Would render: $rel_path"
|
||||
RENDERED=$((RENDERED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if "$RENDER" "$d2_file" "${EXTRA_ARGS[@]+"${EXTRA_ARGS[@]}"}"; then
|
||||
RENDERED=$((RENDERED + 1))
|
||||
else
|
||||
echo "FAILED: $rel_path" >&2
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
done < <(find "$DIR" -name '*.d2' -print0 | sort -z)
|
||||
|
||||
echo ""
|
||||
echo "Batch complete: $RENDERED rendered, $SKIPPED skipped (up to date), $FAILED failed"
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
D2="/home/linuxbrew/.linuxbrew/bin/d2"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR" && git rev-parse --show-toplevel)"
|
||||
|
||||
DEFAULT_THEME=200
|
||||
DEFAULT_LAYOUT="dagre"
|
||||
DEFAULT_PAD=100
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [validate|fmt] <file.d2> [options]
|
||||
|
||||
Render a .d2 file to PNG with project defaults (theme $DEFAULT_THEME, $DEFAULT_LAYOUT layout).
|
||||
|
||||
Commands:
|
||||
validate <file> Check syntax without rendering
|
||||
fmt <file> Auto-format in place
|
||||
|
||||
Options:
|
||||
--theme N Override theme (default: $DEFAULT_THEME)
|
||||
--layout NAME Override layout engine (default: $DEFAULT_LAYOUT)
|
||||
--sketch Enable hand-drawn sketch mode
|
||||
--output PATH Override output path (default: input with .png extension)
|
||||
--svg Render to SVG instead of PNG
|
||||
|
||||
Examples:
|
||||
$(basename "$0") docs/diagrams/architecture/ipc-bridge.d2
|
||||
$(basename "$0") validate docs/diagrams/architecture/ipc-bridge.d2
|
||||
$(basename "$0") docs/diagrams/architecture/ipc-bridge.d2 --sketch --theme 0
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ $# -lt 1 ]] && usage
|
||||
|
||||
# Parse subcommand
|
||||
SUBCMD=""
|
||||
case "$1" in
|
||||
validate|fmt)
|
||||
SUBCMD="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
|
||||
[[ $# -lt 1 ]] && usage
|
||||
|
||||
INPUT="$1"
|
||||
shift
|
||||
|
||||
# Resolve to absolute path
|
||||
[[ "$INPUT" != /* ]] && INPUT="$REPO_ROOT/$INPUT"
|
||||
|
||||
[[ ! -f "$INPUT" ]] && { echo "ERROR: File not found: $INPUT" >&2; exit 1; }
|
||||
|
||||
# Handle subcommands
|
||||
if [[ -n "$SUBCMD" ]]; then
|
||||
"$D2" "$SUBCMD" "$INPUT"
|
||||
echo "OK: $SUBCMD $INPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Parse render options
|
||||
THEME="$DEFAULT_THEME"
|
||||
LAYOUT="$DEFAULT_LAYOUT"
|
||||
SKETCH=""
|
||||
OUTPUT=""
|
||||
FORMAT="png"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--theme) THEME="$2"; shift 2 ;;
|
||||
--layout) LAYOUT="$2"; shift 2 ;;
|
||||
--sketch) SKETCH="-s"; shift ;;
|
||||
--output) OUTPUT="$2"; shift 2 ;;
|
||||
--svg) FORMAT="svg"; shift ;;
|
||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Derive output path
|
||||
if [[ -z "$OUTPUT" ]]; then
|
||||
OUTPUT="${INPUT%.d2}.$FORMAT"
|
||||
fi
|
||||
|
||||
# Render
|
||||
"$D2" -t "$THEME" -l "$LAYOUT" --pad "$DEFAULT_PAD" $SKETCH "$INPUT" "$OUTPUT"
|
||||
|
||||
SIZE=$(stat --printf="%s" "$OUTPUT" 2>/dev/null || stat -f%z "$OUTPUT" 2>/dev/null)
|
||||
echo "Rendered: $OUTPUT ($(( SIZE / 1024 ))KB)"
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: search-docs
|
||||
name: docs-search
|
||||
description: >
|
||||
Search project documents using semantic search (Qdrant + ollama) or grep fallback.
|
||||
Use when the user asks "did we discuss X?", "find references to Y", "search docs",
|
||||
or invokes /search-docs. Wraps the qdrant_connector.py for semantic document search.
|
||||
or invokes /docs-search. Wraps the qdrant_connector.py for semantic document search.
|
||||
user-invocable: true
|
||||
allowed-tools: Bash, Read, Grep, Glob
|
||||
---
|
||||
@@ -0,0 +1,197 @@
|
||||
---
|
||||
name: frame0-wireframe
|
||||
description: >
|
||||
Create and export UI wireframes using Frame0 (local desktop wireframing
|
||||
app with HTTP API). Use when the user says "create wireframe", "wireframe
|
||||
this", "mock up the UI", "draw a screen", "UI layout", "wireframe the HUD",
|
||||
"Frame0", "frame0", "export wireframe", or invokes /frame0-wireframe.
|
||||
Wireframes are authored as local JSON files (source of truth) and synced
|
||||
to Frame0 for rendering and export. Requires Frame0 to be running locally.
|
||||
---
|
||||
|
||||
# Frame0 Wireframe Generation
|
||||
|
||||
Create UI wireframes as JSON files, sync them to Frame0 for rendering, and
|
||||
export as PNG. Local JSON is the source of truth — Frame0 is the renderer.
|
||||
|
||||
**Frame0 is a renderer, not a workspace.** Treat it as disposable output.
|
||||
Push freely, delete test pages, keep it clean. Never pull from Frame0 unless
|
||||
the user explicitly says they have made edits in Frame0 and want to import
|
||||
them. The pull workflow exists for that case only — do not use it proactively.
|
||||
|
||||
**Prerequisite:** Frame0 desktop app must be running. If not available,
|
||||
stop and inform the user. Point to `references/setup-guide.md`.
|
||||
|
||||
## Health Check
|
||||
|
||||
Always check first:
|
||||
|
||||
```bash
|
||||
.claude/skills/frame0-wireframe/scripts/frame0-cmd.sh health
|
||||
```
|
||||
|
||||
## Core Workflow
|
||||
|
||||
1. **Health check** — verify Frame0 is running
|
||||
2. **Write wireframe JSON** — to `docs/design/wireframes/{category}/{name}.json`
|
||||
3. **Push to Frame0** — `frame0-sync.py push <file.json>`
|
||||
4. **Export PNG** — `frame0-sync.py export <file.json> <output.png>`
|
||||
5. **Clean up** — delete test/scratch pages from Frame0 when done
|
||||
|
||||
### Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `scripts/frame0-sync.py` | **Primary.** Push/pull/export wireframes between JSON and Frame0 |
|
||||
| `scripts/frame0-cmd.sh` | Low-level API wrapper for ad-hoc commands |
|
||||
|
||||
## Wireframe JSON Format
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Dialogue Box",
|
||||
"shapes": {
|
||||
"panel": {
|
||||
"type": "Rectangle",
|
||||
"left": 170, "top": 500, "width": 800, "height": 260,
|
||||
"fillColor": "#1a1e24",
|
||||
"strokeColor": "#333340",
|
||||
"corners": [8, 8, 8, 8]
|
||||
},
|
||||
"speaker": {
|
||||
"type": "Text",
|
||||
"parent": "panel",
|
||||
"left": 190, "top": 520,
|
||||
"text": "LERA KONSTANTIN",
|
||||
"fontColor": "#c8d0e0",
|
||||
"fontSize": 16
|
||||
},
|
||||
"btn-ask": {
|
||||
"type": "Rectangle",
|
||||
"parent": "panel",
|
||||
"left": 190, "top": 670, "width": 370, "height": 30,
|
||||
"fillColor": "#2a3040",
|
||||
"strokeColor": "#c8d8f0",
|
||||
"corners": [4, 4, 4, 4]
|
||||
}
|
||||
},
|
||||
"connectors": {
|
||||
"flow-1": {
|
||||
"tailId": "panel",
|
||||
"headId": "btn-ask",
|
||||
"strokeColor": "#c8d8f0"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Key rules
|
||||
|
||||
- **Shape IDs are stable local IDs** you control (e.g. `"panel"`, `"btn-ask"`)
|
||||
- **`parent`** references another local shape ID for nesting
|
||||
- **`type`** uses create-API names: `Rectangle`, `Ellipse`, `Text`, `Line`
|
||||
- **Colors** can be hex (`#2a3040`) or Frame0 theme tokens (`$slate6`)
|
||||
- After a pull, Frame0 returns its native vocabulary (`Box` for Rectangle,
|
||||
theme tokens for colors). The sync script handles the mapping transparently.
|
||||
- The `.idmap.json` mapping file (gitignored) tracks local ID ↔ Frame0 ID
|
||||
|
||||
### Sync commands
|
||||
|
||||
```bash
|
||||
SYNC=".claude/skills/frame0-wireframe/scripts/frame0-sync.py"
|
||||
|
||||
# Push local JSON to Frame0 (clears page, recreates all shapes)
|
||||
$SYNC push docs/design/wireframes/dialogue/dialogue-box.json
|
||||
|
||||
# Pull Frame0 page back to local JSON (preserves local IDs via mapping)
|
||||
$SYNC pull "Dialogue Box" docs/design/wireframes/dialogue/dialogue-box.json
|
||||
|
||||
# Push + export as PNG in one step
|
||||
$SYNC export docs/design/wireframes/dialogue/dialogue-box.json \
|
||||
docs/design/wireframes/dialogue/dialogue-box.png
|
||||
```
|
||||
|
||||
### Batch export
|
||||
|
||||
Use this when exporting multiple wireframes. It runs as a single Bash call,
|
||||
avoiding repeated permission prompts.
|
||||
|
||||
```bash
|
||||
BATCH=".claude/skills/frame0-wireframe/scripts/frame0-export-batch.sh"
|
||||
|
||||
# Dry run first — shows full manifest, no Frame0 calls
|
||||
$BATCH --dry-run
|
||||
|
||||
# Export everything (skips PNGs already newer than their JSON)
|
||||
$BATCH
|
||||
|
||||
# Export one category only
|
||||
$BATCH --category dialogue
|
||||
|
||||
# Force re-export of everything
|
||||
$BATCH --force
|
||||
```
|
||||
|
||||
**Always dry-run first, then get approval before running the live export.**
|
||||
|
||||
## Low-Level Commands
|
||||
|
||||
For ad-hoc operations or exec calls not covered by sync:
|
||||
|
||||
```bash
|
||||
CMD=".claude/skills/frame0-wireframe/scripts/frame0-cmd.sh"
|
||||
$CMD health
|
||||
$CMD list-pages
|
||||
$CMD current-page
|
||||
$CMD get-page <page-id>
|
||||
$CMD create-shape Rectangle '{"name":"btn","left":100,"top":100,"width":120,"height":36}'
|
||||
$CMD create-connector <tail-id> <head-id>
|
||||
$CMD move <shape-id> <dx> <dy>
|
||||
$CMD export --format image/png
|
||||
$CMD exec "view:fit-to-screen" '{}'
|
||||
```
|
||||
|
||||
If you find yourself using `exec` for the same command repeatedly, flag it as
|
||||
a candidate for a proper wrapper in `frame0-cmd.sh`.
|
||||
|
||||
## Project Styling Defaults
|
||||
|
||||
Colors from `docs/design/visual-grammar-v01.md`:
|
||||
|
||||
| Role | Hex | Frame0 token |
|
||||
|------|-----|-------------|
|
||||
| Background | `#1a1e24` | `$sage3` |
|
||||
| Stroke | `#333340` | `$slate6` |
|
||||
| Fill | `#2a3040` | `$slate5` |
|
||||
| Text | `#c8d0e0` | `$mint12` |
|
||||
| Accent | `#c8d8f0` | `$blue12` |
|
||||
|
||||
Use hex when authoring new wireframes. Frame0 maps them to theme tokens on push.
|
||||
|
||||
## Output Convention
|
||||
|
||||
```
|
||||
docs/design/wireframes/
|
||||
hud/ # HUD layout wireframes
|
||||
menus/ # Menu screen wireframes
|
||||
dialogue/ # Dialogue box wireframes
|
||||
insert/ # Neural insert wireframes
|
||||
```
|
||||
|
||||
Both `.json` source and `.png` exports are tracked in git.
|
||||
`.idmap.json` mapping files are gitignored.
|
||||
|
||||
## Agent Guidance
|
||||
|
||||
- **Araminta** — Primary user. Full wireframe creation, layout iteration,
|
||||
visual consistency. Uses all component library patterns.
|
||||
- **Tyre** — Interface architecture wireframes. System boundary diagrams.
|
||||
- **Qatux** — Export wireframes for UI decision records and documentation.
|
||||
|
||||
## References
|
||||
|
||||
- `references/component-library.md` — Pre-built JSON wireframe templates
|
||||
(HUD, dialogue, menus, modals, lists, inventory). Copy and adapt.
|
||||
- `references/api-reference.md` — Frame0 HTTP API command reference and
|
||||
type mappings. Read for low-level control.
|
||||
- `references/setup-guide.md` — Frame0 installation and startup for Fedora.
|
||||
@@ -0,0 +1,241 @@
|
||||
# Frame0 HTTP API Reference
|
||||
|
||||
Frame0 exposes a local HTTP API when the desktop app is running.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
POST http://localhost:{port}/execute_command
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Default port: **58320** (override via `FRAME0_PORT` env var or `--port` flag).
|
||||
|
||||
## Request / Response
|
||||
|
||||
```json
|
||||
{"command": "namespace:action", "args": { ... }}
|
||||
```
|
||||
|
||||
```json
|
||||
{"success": true, "data": { ... }}
|
||||
{"success": false, "error": "description"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Type Mapping
|
||||
|
||||
Frame0 uses different type names for create vs get:
|
||||
|
||||
| Create API (`type`) | Get API (internal) | Description |
|
||||
|--------------------|--------------------|-------------|
|
||||
| `Rectangle` | `Box` | Rectangle with optional corners |
|
||||
| `Ellipse` | `Ellipse` | Circle/ellipse |
|
||||
| `Text` | `Text` | Text label |
|
||||
| `Line` | `Line` | Line/polyline |
|
||||
| `Frame` | `Frame` | Container from library |
|
||||
| `Freehand` | `Freehand` | Freehand drawing |
|
||||
| `Highlighter` | `Highlighter` | Highlighter stroke |
|
||||
|
||||
The sync script handles this mapping transparently.
|
||||
|
||||
## Color Tokens
|
||||
|
||||
Frame0 maps hex colors to theme tokens on creation (`convertColors: true`):
|
||||
|
||||
| Hex | Token | Role |
|
||||
|-----|-------|------|
|
||||
| `#1a1e24` | `$sage3` | Background |
|
||||
| `#2a3040` | `$slate5` | Fill |
|
||||
| `#333340` | `$slate6` | Stroke |
|
||||
| `#c8d0e0` | `$mint12` | Text |
|
||||
| `#c8d8f0` | `$blue12` | Accent |
|
||||
|
||||
Both hex and token strings work in the API. Tokens are preserved on round-trip.
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
### shape:create-shape
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "shape:create-shape",
|
||||
"args": {
|
||||
"type": "Rectangle",
|
||||
"shapeProps": {
|
||||
"name": "my-button",
|
||||
"left": 100, "top": 200, "width": 120, "height": 36,
|
||||
"fillColor": "#2a3040",
|
||||
"strokeColor": "#c8d8f0",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"parentId": "optional-parent-shape-id",
|
||||
"convertColors": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Returns: shape ID (string).
|
||||
|
||||
### shape:get-shape
|
||||
|
||||
```json
|
||||
{"command": "shape:get-shape", "args": {"shapeId": "id"}}
|
||||
```
|
||||
|
||||
### shape:update-shape
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "shape:update-shape",
|
||||
"args": {
|
||||
"shapeId": "id",
|
||||
"shapeProps": {"fillColor": "#1a1e24", "text": "Updated"},
|
||||
"convertColors": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### shape:move
|
||||
|
||||
```json
|
||||
{"command": "shape:move", "args": {"shapeId": "id", "dx": 50, "dy": -20}}
|
||||
```
|
||||
|
||||
### shape:create-connector
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "shape:create-connector",
|
||||
"args": {
|
||||
"tailId": "source-id",
|
||||
"headId": "target-id",
|
||||
"shapeProps": {"strokeColor": "#c8d8f0"},
|
||||
"convertColors": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### shape:create-icon
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "shape:create-icon",
|
||||
"args": {
|
||||
"iconName": "search",
|
||||
"shapeProps": {"left": 100, "top": 100, "width": 24, "height": 24}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### shape:get-available-icons
|
||||
|
||||
```json
|
||||
{"command": "shape:get-available-icons", "args": {}}
|
||||
```
|
||||
|
||||
### shape:group / shape:ungroup
|
||||
|
||||
```json
|
||||
{"command": "shape:group", "args": {"shapeIdArray": ["id1", "id2"]}}
|
||||
{"command": "shape:ungroup", "args": {"shapeIdArray": ["group-id"]}}
|
||||
```
|
||||
|
||||
### edit:delete / edit:duplicate
|
||||
|
||||
```json
|
||||
{"command": "edit:delete", "args": {"shapeIdArray": ["id1", "id2"]}}
|
||||
{"command": "edit:duplicate", "args": {"shapeIdArray": ["id"], "dx": 20, "dy": 0}}
|
||||
```
|
||||
|
||||
### page:add
|
||||
|
||||
```json
|
||||
{"command": "page:add", "args": {"pageProps": {"name": "Page Name"}}}
|
||||
```
|
||||
|
||||
Returns: `{id, type, name}`.
|
||||
|
||||
### page:get
|
||||
|
||||
```json
|
||||
{"command": "page:get", "args": {"pageId": "id", "exportShapes": true}}
|
||||
```
|
||||
|
||||
### page:get-current-page
|
||||
|
||||
```json
|
||||
{"command": "page:get-current-page", "args": {}}
|
||||
```
|
||||
|
||||
Returns: page ID string.
|
||||
|
||||
### page:set-current-page
|
||||
|
||||
```json
|
||||
{"command": "page:set-current-page", "args": {"pageId": "id"}}
|
||||
```
|
||||
|
||||
### doc:get (list all pages)
|
||||
|
||||
```json
|
||||
{"command": "doc:get", "args": {"exportPages": true, "exportShapes": false}}
|
||||
```
|
||||
|
||||
### page:delete
|
||||
|
||||
```json
|
||||
{"command": "page:delete", "args": {"pageId": "id"}}
|
||||
```
|
||||
|
||||
### file:export-image
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "file:export-image",
|
||||
"args": {
|
||||
"pageId": "optional-page-id",
|
||||
"format": "image/png",
|
||||
"fillBackground": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Formats: `image/png`, `image/jpeg`, `image/webp`, `image/svg+xml`.
|
||||
Returns: base64-encoded image data.
|
||||
|
||||
### view:fit-to-screen
|
||||
|
||||
```json
|
||||
{"command": "view:fit-to-screen", "args": {}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shape Properties
|
||||
|
||||
| Property | Type | Notes |
|
||||
|----------|------|-------|
|
||||
| `name` | string | Shape identifier/label |
|
||||
| `left` | number | X position (origin: top-left) |
|
||||
| `top` | number | Y position |
|
||||
| `width` | number | Width in pixels |
|
||||
| `height` | number | Height in pixels |
|
||||
| `fillColor` | string | Hex or `$token` |
|
||||
| `strokeColor` | string | Hex or `$token` |
|
||||
| `strokeWidth` | number | Border width |
|
||||
| `fontColor` | string | Text color (hex or `$token`) |
|
||||
| `fontSize` | number | Font size in pixels |
|
||||
| `fontFamily` | string | Font name (Frame0 default: `Loranthus`) |
|
||||
| `text` | string | Text content |
|
||||
| `wordWrap` | boolean | Enable word wrapping |
|
||||
| `corners` | number[4] | Border radius [TL, TR, BR, BL] |
|
||||
| `roughness` | number | Sketch roughness (Frame0 default: 1) |
|
||||
| `constraints` | array | Auto-sizing constraints |
|
||||
| `horzAlign` | string | Horizontal text alignment |
|
||||
| `vertAlign` | string | Vertical text alignment |
|
||||
| `fillStyle` | string | Fill style (`none` for transparent) |
|
||||
| `path` | array | Coordinate pairs for lines |
|
||||
@@ -0,0 +1,467 @@
|
||||
# Component Library
|
||||
|
||||
Pre-built wireframe JSON templates for The Settled Reach UI. Copy the JSON,
|
||||
adapt positions/sizes, save to `docs/design/wireframes/{category}/`, and push.
|
||||
|
||||
**Viewport:** 1140x780 (Godot project settings)
|
||||
**Grid unit:** 8px
|
||||
**Min touch target:** 36px height
|
||||
**Font sizes:** 12 (label), 14 (body), 16 (subtitle), 18 (heading), 24 (title)
|
||||
|
||||
---
|
||||
|
||||
## 1. HUD Layout
|
||||
|
||||
Main gameplay overlay. Minimap top-right, monologue bottom-center,
|
||||
insert display bottom-left, action hints bottom-right.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "HUD Layout",
|
||||
"shapes": {
|
||||
"minimap": {
|
||||
"type": "Rectangle",
|
||||
"left": 880, "top": 20, "width": 240, "height": 240,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"minimap-label": {
|
||||
"type": "Text",
|
||||
"parent": "minimap",
|
||||
"left": 890, "top": 30,
|
||||
"text": "Minimap",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14
|
||||
},
|
||||
"monologue": {
|
||||
"type": "Rectangle",
|
||||
"left": 300, "top": 680, "width": 520, "height": 80,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"monologue-text": {
|
||||
"type": "Text",
|
||||
"parent": "monologue",
|
||||
"left": 310, "top": 700, "width": 500,
|
||||
"text": "Internal monologue text appears here...",
|
||||
"fontColor": "#c8d0e0", "fontSize": 13, "wordWrap": true
|
||||
},
|
||||
"insert": {
|
||||
"type": "Rectangle",
|
||||
"left": 20, "top": 600, "width": 260, "height": 160,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"insert-label": {
|
||||
"type": "Text",
|
||||
"parent": "insert",
|
||||
"left": 30, "top": 620,
|
||||
"text": "Neural Insert Data",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14
|
||||
},
|
||||
"hints": {
|
||||
"type": "Rectangle",
|
||||
"left": 880, "top": 700, "width": 240, "height": 60,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"hints-label": {
|
||||
"type": "Text",
|
||||
"parent": "hints",
|
||||
"left": 890, "top": 720,
|
||||
"text": "[E] Interact [TAB] Insert",
|
||||
"fontColor": "#c8d0e0", "fontSize": 12
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Dialogue Box
|
||||
|
||||
Speaker panel with response options. Anchored bottom-center during dialogue mode.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Dialogue Box",
|
||||
"shapes": {
|
||||
"panel": {
|
||||
"type": "Rectangle",
|
||||
"left": 170, "top": 500, "width": 800, "height": 260,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||
"corners": [8, 8, 8, 8]
|
||||
},
|
||||
"speaker": {
|
||||
"type": "Text",
|
||||
"parent": "panel",
|
||||
"left": 190, "top": 520,
|
||||
"text": "LERA KONSTANTIN",
|
||||
"fontColor": "#c8d0e0", "fontSize": 16
|
||||
},
|
||||
"text-area": {
|
||||
"type": "Rectangle",
|
||||
"parent": "panel",
|
||||
"left": 190, "top": 550, "width": 760, "height": 100,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"dialogue-text": {
|
||||
"type": "Text",
|
||||
"parent": "text-area",
|
||||
"left": 200, "top": 560, "width": 740,
|
||||
"text": "You look like you could use a drink. First time on the station?",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14, "wordWrap": true
|
||||
},
|
||||
"btn-option1": {
|
||||
"type": "Rectangle",
|
||||
"parent": "panel",
|
||||
"left": 190, "top": 670, "width": 370, "height": 30,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-option1-label": {
|
||||
"type": "Text",
|
||||
"parent": "btn-option1",
|
||||
"left": 200, "top": 674,
|
||||
"text": "[1] Ask about the station",
|
||||
"fontColor": "#c8d8f0", "fontSize": 12
|
||||
},
|
||||
"btn-option2": {
|
||||
"type": "Rectangle",
|
||||
"parent": "panel",
|
||||
"left": 190, "top": 710, "width": 370, "height": 30,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-option2-label": {
|
||||
"type": "Text",
|
||||
"parent": "btn-option2",
|
||||
"left": 200, "top": 714,
|
||||
"text": "[2] Ask about recent events",
|
||||
"fontColor": "#c8d8f0", "fontSize": 12
|
||||
},
|
||||
"btn-leave": {
|
||||
"type": "Rectangle",
|
||||
"parent": "panel",
|
||||
"left": 580, "top": 670, "width": 180, "height": 30,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-leave-label": {
|
||||
"type": "Text",
|
||||
"parent": "btn-leave",
|
||||
"left": 590, "top": 674,
|
||||
"text": "[3] Leave",
|
||||
"fontColor": "#c8d8f0", "fontSize": 12
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Menu Screen
|
||||
|
||||
Full-screen menu with sidebar navigation and content area.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Pause Menu",
|
||||
"shapes": {
|
||||
"bg": {
|
||||
"type": "Rectangle",
|
||||
"left": 0, "top": 0, "width": 1140, "height": 780,
|
||||
"fillColor": "#1a1e24"
|
||||
},
|
||||
"nav": {
|
||||
"type": "Rectangle",
|
||||
"parent": "bg",
|
||||
"left": 20, "top": 20, "width": 200, "height": 740,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-inventory": {
|
||||
"type": "Rectangle", "parent": "nav",
|
||||
"left": 30, "top": 40, "width": 180, "height": 36,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-inventory-label": {
|
||||
"type": "Text", "parent": "btn-inventory",
|
||||
"left": 40, "top": 48, "text": "Inventory",
|
||||
"fontColor": "#c8d8f0", "fontSize": 14
|
||||
},
|
||||
"btn-journal": {
|
||||
"type": "Rectangle", "parent": "nav",
|
||||
"left": 30, "top": 86, "width": 180, "height": 36,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-journal-label": {
|
||||
"type": "Text", "parent": "btn-journal",
|
||||
"left": 40, "top": 94, "text": "Journal",
|
||||
"fontColor": "#c8d8f0", "fontSize": 14
|
||||
},
|
||||
"btn-map": {
|
||||
"type": "Rectangle", "parent": "nav",
|
||||
"left": 30, "top": 132, "width": 180, "height": 36,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-map-label": {
|
||||
"type": "Text", "parent": "btn-map",
|
||||
"left": 40, "top": 140, "text": "Map",
|
||||
"fontColor": "#c8d8f0", "fontSize": 14
|
||||
},
|
||||
"btn-settings": {
|
||||
"type": "Rectangle", "parent": "nav",
|
||||
"left": 30, "top": 178, "width": 180, "height": 36,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-settings-label": {
|
||||
"type": "Text", "parent": "btn-settings",
|
||||
"left": 40, "top": 186, "text": "Settings",
|
||||
"fontColor": "#c8d8f0", "fontSize": 14
|
||||
},
|
||||
"btn-resume": {
|
||||
"type": "Rectangle", "parent": "nav",
|
||||
"left": 30, "top": 720, "width": 180, "height": 36,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-resume-label": {
|
||||
"type": "Text", "parent": "btn-resume",
|
||||
"left": 40, "top": 728, "text": "Resume",
|
||||
"fontColor": "#c8d8f0", "fontSize": 14
|
||||
},
|
||||
"content": {
|
||||
"type": "Rectangle",
|
||||
"parent": "bg",
|
||||
"left": 240, "top": 20, "width": 880, "height": 740,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340",
|
||||
"corners": [4, 4, 4, 4]
|
||||
},
|
||||
"content-label": {
|
||||
"type": "Text", "parent": "content",
|
||||
"left": 260, "top": 40,
|
||||
"text": "Content area",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Modal Dialog
|
||||
|
||||
Centered overlay for confirmations, alerts, choices.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Modal Dialog",
|
||||
"shapes": {
|
||||
"overlay": {
|
||||
"type": "Rectangle",
|
||||
"left": 0, "top": 0, "width": 1140, "height": 780,
|
||||
"fillColor": "#0a0c10"
|
||||
},
|
||||
"modal": {
|
||||
"type": "Rectangle",
|
||||
"parent": "overlay",
|
||||
"left": 320, "top": 240, "width": 500, "height": 300,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340",
|
||||
"corners": [8, 8, 8, 8]
|
||||
},
|
||||
"title": {
|
||||
"type": "Text", "parent": "modal",
|
||||
"left": 340, "top": 260,
|
||||
"text": "Confirm Action",
|
||||
"fontColor": "#c8d0e0", "fontSize": 18
|
||||
},
|
||||
"divider": {
|
||||
"type": "Line", "parent": "modal",
|
||||
"left": 340, "top": 290, "width": 460, "height": 0,
|
||||
"strokeColor": "#333340"
|
||||
},
|
||||
"body-1": {
|
||||
"type": "Text", "parent": "modal",
|
||||
"left": 340, "top": 310,
|
||||
"text": "Are you sure you want to proceed?",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14
|
||||
},
|
||||
"body-2": {
|
||||
"type": "Text", "parent": "modal",
|
||||
"left": 340, "top": 340,
|
||||
"text": "This action cannot be undone.",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14
|
||||
},
|
||||
"btn-cancel": {
|
||||
"type": "Rectangle", "parent": "modal",
|
||||
"left": 480, "top": 480, "width": 120, "height": 36,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-cancel-label": {
|
||||
"type": "Text", "parent": "btn-cancel",
|
||||
"left": 510, "top": 488,
|
||||
"text": "Cancel",
|
||||
"fontColor": "#c8d8f0", "fontSize": 14
|
||||
},
|
||||
"btn-confirm": {
|
||||
"type": "Rectangle", "parent": "modal",
|
||||
"left": 620, "top": 480, "width": 120, "height": 36,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"btn-confirm-label": {
|
||||
"type": "Text", "parent": "btn-confirm",
|
||||
"left": 645, "top": 488,
|
||||
"text": "Confirm",
|
||||
"fontColor": "#c8d8f0", "fontSize": 14
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. List View
|
||||
|
||||
Scrollable list with item selection and detail panel.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "List View",
|
||||
"shapes": {
|
||||
"list-panel": {
|
||||
"type": "Rectangle",
|
||||
"left": 20, "top": 20, "width": 400, "height": 740,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"item-1": {
|
||||
"type": "Rectangle", "parent": "list-panel",
|
||||
"left": 30, "top": 30, "width": 380, "height": 40,
|
||||
"fillColor": "#2a3040", "strokeColor": "#c8d8f0", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"item-1-label": {
|
||||
"type": "Text", "parent": "item-1",
|
||||
"left": 40, "top": 38, "text": "Item Alpha",
|
||||
"fontColor": "#c8d8f0", "fontSize": 14
|
||||
},
|
||||
"item-2": {
|
||||
"type": "Rectangle", "parent": "list-panel",
|
||||
"left": 30, "top": 80, "width": 380, "height": 40,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"item-2-label": {
|
||||
"type": "Text", "parent": "item-2",
|
||||
"left": 40, "top": 88, "text": "Item Beta",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14
|
||||
},
|
||||
"item-3": {
|
||||
"type": "Rectangle", "parent": "list-panel",
|
||||
"left": 30, "top": 130, "width": 380, "height": 40,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"item-3-label": {
|
||||
"type": "Text", "parent": "item-3",
|
||||
"left": 40, "top": 138, "text": "Item Gamma",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14
|
||||
},
|
||||
"detail-panel": {
|
||||
"type": "Rectangle",
|
||||
"left": 440, "top": 20, "width": 680, "height": 740,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"detail-title": {
|
||||
"type": "Text", "parent": "detail-panel",
|
||||
"left": 460, "top": 40,
|
||||
"text": "Item Alpha",
|
||||
"fontColor": "#c8d0e0", "fontSize": 18
|
||||
},
|
||||
"detail-body": {
|
||||
"type": "Text", "parent": "detail-panel",
|
||||
"left": 460, "top": 80, "width": 640,
|
||||
"text": "Description and properties appear here.",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14, "wordWrap": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Inventory Grid
|
||||
|
||||
Grid of cells for item management.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Inventory Grid",
|
||||
"shapes": {
|
||||
"panel": {
|
||||
"type": "Rectangle",
|
||||
"left": 240, "top": 100, "width": 660, "height": 580,
|
||||
"fillColor": "#1a1e24", "strokeColor": "#333340", "corners": [8, 8, 8, 8]
|
||||
},
|
||||
"title": {
|
||||
"type": "Text", "parent": "panel",
|
||||
"left": 260, "top": 120,
|
||||
"text": "INVENTORY",
|
||||
"fontColor": "#c8d0e0", "fontSize": 18
|
||||
},
|
||||
"cell-1-1": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 260, "top": 160, "width": 64, "height": 64,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"cell-1-2": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 332, "top": 160, "width": 64, "height": 64,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"cell-1-3": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 404, "top": 160, "width": 64, "height": 64,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"cell-1-4": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 476, "top": 160, "width": 64, "height": 64,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"cell-2-1": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 260, "top": 232, "width": 64, "height": 64,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"cell-2-2": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 332, "top": 232, "width": 64, "height": 64,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"cell-2-3": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 404, "top": 232, "width": 64, "height": 64,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"cell-2-4": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 476, "top": 232, "width": 64, "height": 64,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"detail": {
|
||||
"type": "Rectangle", "parent": "panel",
|
||||
"left": 580, "top": 160, "width": 300, "height": 400,
|
||||
"fillColor": "#2a3040", "strokeColor": "#333340", "corners": [4, 4, 4, 4]
|
||||
},
|
||||
"detail-title": {
|
||||
"type": "Text", "parent": "detail",
|
||||
"left": 600, "top": 180,
|
||||
"text": "Selected Item Name",
|
||||
"fontColor": "#c8d0e0", "fontSize": 16
|
||||
},
|
||||
"detail-body": {
|
||||
"type": "Text", "parent": "detail",
|
||||
"left": 600, "top": 210, "width": 260,
|
||||
"text": "Item description and stats",
|
||||
"fontColor": "#c8d0e0", "fontSize": 14, "wordWrap": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,53 @@
|
||||
# Frame0 Setup Guide
|
||||
|
||||
## Installation (Fedora)
|
||||
|
||||
Download from https://frame0.app/download and install the RPM:
|
||||
|
||||
```bash
|
||||
sudo dnf install ./frame0-*.x86_64.rpm
|
||||
```
|
||||
|
||||
Requires: Fedora 40 or later (x86_64).
|
||||
|
||||
## Starting Frame0
|
||||
|
||||
Launch the desktop application:
|
||||
|
||||
```bash
|
||||
frame0 &
|
||||
```
|
||||
|
||||
Frame0 exposes an HTTP API at `localhost:58320` when running.
|
||||
|
||||
## Verify API Access
|
||||
|
||||
```bash
|
||||
.claude/skills/frame0-wireframe/scripts/frame0-cmd.sh health
|
||||
```
|
||||
|
||||
Expected output: `Frame0 is running on port 58320`
|
||||
|
||||
## Port Configuration
|
||||
|
||||
Default port: **58320**
|
||||
|
||||
To use a different port, set the environment variable:
|
||||
|
||||
```bash
|
||||
export FRAME0_PORT=58321
|
||||
```
|
||||
|
||||
Or pass `--port` to any script:
|
||||
|
||||
```bash
|
||||
.claude/skills/frame0-wireframe/scripts/frame0-cmd.sh --port 58321 health
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| "Connection refused" | Frame0 not running | Start the desktop app |
|
||||
| "Port in use" | Another instance running | Close duplicate or use different port |
|
||||
| Script hangs | API unresponsive | Restart Frame0 |
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
PORT="${FRAME0_PORT:-58320}"
|
||||
ENDPOINT="http://localhost:${PORT}/execute_command"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") <command> [args...] [--port N]
|
||||
|
||||
Low-level Frame0 HTTP API wrapper. Replaces the MCP server with direct
|
||||
curl calls. Requires Frame0 desktop app to be running.
|
||||
|
||||
Commands:
|
||||
health Check if Frame0 is running
|
||||
exec <namespace:action> <json> Execute a raw API command
|
||||
create-shape <type> <json-props> Create a shape (Rectangle, Ellipse, Text, Line)
|
||||
get-shape <id> Get shape details
|
||||
update-shape <id> <json-props> Update shape properties
|
||||
delete <id> [id...] Delete shapes by ID
|
||||
move <id> <dx> <dy> Move a shape by pixel offset
|
||||
duplicate <id> Duplicate a shape
|
||||
group <id> [id...] Group shapes
|
||||
ungroup <group-id> Ungroup a group
|
||||
create-connector <tail-id> <head-id> [json-props] Connect two shapes
|
||||
create-icon <name> <json-props> Create an icon shape
|
||||
add-page <name> Add a new page (becomes current)
|
||||
get-page [page-id] Get current or specific page data
|
||||
list-pages [--shapes] List all pages (--shapes for shape data)
|
||||
current-page Get current page ID
|
||||
set-page <page-id> Set current page
|
||||
export [page-id] [--format mime] Export page as image (default: image/png)
|
||||
fit Fit view to screen
|
||||
|
||||
Options:
|
||||
--port N Frame0 API port (default: $PORT, env: FRAME0_PORT)
|
||||
|
||||
Examples:
|
||||
$(basename "$0") health
|
||||
$(basename "$0") add-page "HUD Layout"
|
||||
$(basename "$0") create-shape Rectangle '{"name":"btn","left":100,"top":100,"width":120,"height":36}'
|
||||
$(basename "$0") list-pages
|
||||
$(basename "$0") export --format image/png
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Parse --port from anywhere in args
|
||||
ARGS=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--port) PORT="$2"; ENDPOINT="http://localhost:${PORT}/execute_command"; shift 2 ;;
|
||||
*) ARGS+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
set -- "${ARGS[@]+"${ARGS[@]}"}"
|
||||
|
||||
[[ $# -lt 1 ]] && usage
|
||||
|
||||
# Execute a Frame0 API command, return data or error
|
||||
frame0_exec() {
|
||||
local command="$1"
|
||||
local args
|
||||
args="${2:-"{}"}"
|
||||
|
||||
local response
|
||||
response=$(curl -s -w "\n%{http_code}" -X POST "$ENDPOINT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"command\": \"$command\", \"args\": $args}" 2>&1) || {
|
||||
echo "ERROR: Cannot connect to Frame0 at localhost:$PORT" >&2
|
||||
echo "Is Frame0 running? See: .claude/skills/frame0-wireframe/references/setup-guide.md" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
local http_code body
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
body=$(echo "$response" | sed '$d')
|
||||
|
||||
if [[ "$http_code" != 2* ]]; then
|
||||
echo "ERROR: HTTP $http_code from Frame0" >&2
|
||||
echo "$body" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Parse success/error from response
|
||||
python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
r = json.loads(sys.stdin.read())
|
||||
if r.get('success'):
|
||||
d = r.get('data')
|
||||
if d is not None:
|
||||
print(json.dumps(d, indent=2))
|
||||
else:
|
||||
print('ERROR: ' + str(r.get('error', 'Unknown error')), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f'ERROR: Invalid JSON response: {e}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
" <<< "$body"
|
||||
}
|
||||
|
||||
# Build JSON array from remaining args
|
||||
ids_to_json_array() {
|
||||
local arr="["
|
||||
local first=true
|
||||
for id in "$@"; do
|
||||
[[ "$first" == true ]] && first=false || arr+=","
|
||||
arr+="\"$id\""
|
||||
done
|
||||
arr+="]"
|
||||
echo "$arr"
|
||||
}
|
||||
|
||||
CMD="${1:-}"
|
||||
shift || true
|
||||
|
||||
case "$CMD" in
|
||||
health)
|
||||
if curl -s -o /dev/null -w "%{http_code}" "http://localhost:${PORT}/" 2>/dev/null | grep -q "^[23]"; then
|
||||
echo "Frame0 is running on port $PORT"
|
||||
else
|
||||
echo "Frame0 is NOT running on port $PORT" >&2
|
||||
echo "Start Frame0 desktop app, then retry." >&2
|
||||
echo "See: .claude/skills/frame0-wireframe/references/setup-guide.md" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
|
||||
exec)
|
||||
[[ $# -lt 2 ]] && { echo "Usage: exec <command> <json-args>" >&2; exit 1; }
|
||||
frame0_exec "$1" "$2"
|
||||
;;
|
||||
|
||||
create-shape)
|
||||
[[ $# -lt 2 ]] && { echo "Usage: create-shape <Type> <json-props>" >&2; exit 1; }
|
||||
local_type="$1"
|
||||
local_props="$2"
|
||||
local_parent="${3:-}"
|
||||
local_parent_arg=""
|
||||
[[ -n "$local_parent" ]] && local_parent_arg=", \"parentId\": \"$local_parent\""
|
||||
frame0_exec "shape:create-shape" "{\"type\": \"$local_type\", \"shapeProps\": $local_props$local_parent_arg, \"convertColors\": true}"
|
||||
;;
|
||||
|
||||
get-shape)
|
||||
[[ $# -lt 1 ]] && { echo "Usage: get-shape <id>" >&2; exit 1; }
|
||||
frame0_exec "shape:get-shape" "{\"shapeId\": \"$1\"}"
|
||||
;;
|
||||
|
||||
update-shape)
|
||||
[[ $# -lt 2 ]] && { echo "Usage: update-shape <id> <json-props>" >&2; exit 1; }
|
||||
frame0_exec "shape:update-shape" "{\"shapeId\": \"$1\", \"shapeProps\": $2, \"convertColors\": true}"
|
||||
;;
|
||||
|
||||
delete)
|
||||
[[ $# -lt 1 ]] && { echo "Usage: delete <id> [id...]" >&2; exit 1; }
|
||||
local_arr=$(ids_to_json_array "$@")
|
||||
frame0_exec "edit:delete" "{\"shapeIdArray\": $local_arr}"
|
||||
;;
|
||||
|
||||
move)
|
||||
[[ $# -lt 3 ]] && { echo "Usage: move <id> <dx> <dy>" >&2; exit 1; }
|
||||
frame0_exec "shape:move" "{\"shapeId\": \"$1\", \"dx\": $2, \"dy\": $3}"
|
||||
;;
|
||||
|
||||
duplicate)
|
||||
[[ $# -lt 1 ]] && { echo "Usage: duplicate <id> [dx] [dy]" >&2; exit 1; }
|
||||
local_dx="${2:-0}"
|
||||
local_dy="${3:-0}"
|
||||
frame0_exec "edit:duplicate" "{\"shapeIdArray\": [\"$1\"], \"dx\": $local_dx, \"dy\": $local_dy}"
|
||||
;;
|
||||
|
||||
group)
|
||||
[[ $# -lt 2 ]] && { echo "Usage: group <id> <id> [id...]" >&2; exit 1; }
|
||||
local_arr=$(ids_to_json_array "$@")
|
||||
frame0_exec "shape:group" "{\"shapeIdArray\": $local_arr}"
|
||||
;;
|
||||
|
||||
ungroup)
|
||||
[[ $# -lt 1 ]] && { echo "Usage: ungroup <group-id>" >&2; exit 1; }
|
||||
frame0_exec "shape:ungroup" "{\"shapeIdArray\": [\"$1\"]}"
|
||||
;;
|
||||
|
||||
create-connector)
|
||||
[[ $# -lt 2 ]] && { echo "Usage: create-connector <tail-id> <head-id> [json-props]" >&2; exit 1; }
|
||||
local_props="${3:-{}}"
|
||||
frame0_exec "shape:create-connector" "{\"tailId\": \"$1\", \"headId\": \"$2\", \"shapeProps\": $local_props, \"convertColors\": true}"
|
||||
;;
|
||||
|
||||
create-icon)
|
||||
[[ $# -lt 2 ]] && { echo "Usage: create-icon <name> <json-props>" >&2; exit 1; }
|
||||
frame0_exec "shape:create-icon" "{\"iconName\": \"$1\", \"shapeProps\": $2, \"convertColors\": true}"
|
||||
;;
|
||||
|
||||
add-page)
|
||||
[[ $# -lt 1 ]] && { echo "Usage: add-page <name>" >&2; exit 1; }
|
||||
frame0_exec "page:add" "{\"pageProps\": {\"name\": \"$1\"}}"
|
||||
;;
|
||||
|
||||
get-page)
|
||||
if [[ $# -ge 1 ]]; then
|
||||
frame0_exec "page:get" "{\"pageId\": \"$1\", \"exportShapes\": true}"
|
||||
else
|
||||
local_id
|
||||
local_id=$(frame0_exec "page:get-current-page")
|
||||
# Strip quotes from returned ID
|
||||
local_id=$(echo "$local_id" | tr -d '"')
|
||||
frame0_exec "page:get" "{\"pageId\": \"$local_id\", \"exportShapes\": true}"
|
||||
fi
|
||||
;;
|
||||
|
||||
list-pages)
|
||||
local_shapes="false"
|
||||
[[ "${1:-}" == "--shapes" ]] && local_shapes="true"
|
||||
frame0_exec "doc:get" "{\"exportPages\": true, \"exportShapes\": $local_shapes}"
|
||||
;;
|
||||
|
||||
current-page)
|
||||
frame0_exec "page:get-current-page"
|
||||
;;
|
||||
|
||||
set-page)
|
||||
[[ $# -lt 1 ]] && { echo "Usage: set-page <page-id>" >&2; exit 1; }
|
||||
frame0_exec "page:set-current-page" "{\"pageId\": \"$1\"}"
|
||||
;;
|
||||
|
||||
export)
|
||||
local_page_id=""
|
||||
local_format="image/png"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--format) local_format="$2"; shift 2 ;;
|
||||
*) local_page_id="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
local_page_arg=""
|
||||
[[ -n "$local_page_id" ]] && local_page_arg="\"pageId\": \"$local_page_id\", "
|
||||
frame0_exec "file:export-image" "{${local_page_arg}\"format\": \"$local_format\", \"fillBackground\": true}"
|
||||
;;
|
||||
|
||||
fit)
|
||||
frame0_exec "view:fit-to-screen"
|
||||
;;
|
||||
|
||||
--help|-h|help)
|
||||
usage
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Unknown command: $CMD" >&2
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env bash
|
||||
# Batch export wireframes from JSON to PNG via Frame0.
|
||||
#
|
||||
# Finds all .json wireframe files under docs/design/wireframes/ and exports
|
||||
# each to a matching .png. Skips files whose PNG is already newer than the
|
||||
# JSON, unless --force is passed.
|
||||
#
|
||||
# Usage:
|
||||
# frame0-export-batch.sh [--dry-run] [--force] [--category CAT] [--root DIR]
|
||||
#
|
||||
# Options:
|
||||
# --dry-run Print manifest only, don't touch Frame0.
|
||||
# --force Re-export even if PNG already exists and is up to date.
|
||||
# --category CAT Limit to one subdirectory (e.g. --category dialogue)
|
||||
# --root DIR Wireframes root dir (default: docs/design/wireframes)
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 All exports succeeded (or nothing to do)
|
||||
# 1 One or more exports failed
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||
SYNC="$SCRIPT_DIR/frame0-sync.py"
|
||||
DEFAULT_ROOT="$REPO_ROOT/docs/design/wireframes"
|
||||
|
||||
DRY_RUN=false
|
||||
FORCE=false
|
||||
CATEGORY=""
|
||||
WF_ROOT="$DEFAULT_ROOT"
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
--force) FORCE=true; shift ;;
|
||||
--category) CATEGORY="$2"; shift 2 ;;
|
||||
--root) WF_ROOT="$2"; shift 2 ;;
|
||||
-h|--help)
|
||||
sed -n '/^# /p' "$0" | sed 's/^# //'
|
||||
exit 0
|
||||
;;
|
||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -d "$WF_ROOT" ]]; then
|
||||
echo "ERROR: Wireframes directory not found: $WF_ROOT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Collect JSON files, optionally filtered by category subdirectory
|
||||
mapfile -t JSON_FILES < <(
|
||||
if [[ -n "$CATEGORY" ]]; then
|
||||
find "$WF_ROOT/$CATEGORY" -name "*.json" ! -name ".*" | sort
|
||||
else
|
||||
find "$WF_ROOT" -name "*.json" ! -name ".*" | sort
|
||||
fi
|
||||
)
|
||||
|
||||
if [[ ${#JSON_FILES[@]} -eq 0 ]]; then
|
||||
echo "No wireframe JSON files found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Classify files into to-export and to-skip
|
||||
TO_EXPORT=()
|
||||
TO_SKIP=()
|
||||
|
||||
for json in "${JSON_FILES[@]}"; do
|
||||
png="${json%.json}.png"
|
||||
if $FORCE || [[ ! -f "$png" ]] || [[ "$json" -nt "$png" ]]; then
|
||||
TO_EXPORT+=("$json")
|
||||
else
|
||||
TO_SKIP+=("$json")
|
||||
fi
|
||||
done
|
||||
|
||||
# Print manifest
|
||||
if [[ ${#TO_EXPORT[@]} -gt 0 ]]; then
|
||||
echo ""
|
||||
echo "Will export (${#TO_EXPORT[@]} files):"
|
||||
for json in "${TO_EXPORT[@]}"; do
|
||||
png="${json%.json}.png"
|
||||
rel="${json#$REPO_ROOT/}"
|
||||
if [[ ! -f "$png" ]]; then
|
||||
status="new"
|
||||
else
|
||||
status="updated"
|
||||
fi
|
||||
printf " [%-7s] %s\n" "$status" "$rel"
|
||||
done
|
||||
else
|
||||
echo ""
|
||||
echo "Nothing to export (all PNGs up to date)."
|
||||
fi
|
||||
|
||||
if [[ ${#TO_SKIP[@]} -gt 0 ]]; then
|
||||
echo ""
|
||||
echo "Will skip (${#TO_SKIP[@]} files already up to date):"
|
||||
for json in "${TO_SKIP[@]}"; do
|
||||
rel="${json#$REPO_ROOT/}"
|
||||
printf " [skip ] %s\n" "$rel"
|
||||
done
|
||||
fi
|
||||
|
||||
if $DRY_RUN; then
|
||||
echo ""
|
||||
echo "Dry run — no exports performed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ${#TO_EXPORT[@]} -eq 0 ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
PASSED=0
|
||||
FAILED=0
|
||||
FAILED_FILES=()
|
||||
|
||||
TOTAL=${#TO_EXPORT[@]}
|
||||
IDX=0
|
||||
|
||||
for json in "${TO_EXPORT[@]}"; do
|
||||
IDX=$((IDX + 1))
|
||||
png="${json%.json}.png"
|
||||
rel="${json#$REPO_ROOT/}"
|
||||
|
||||
printf "[%d/%d] %s ... " "$IDX" "$TOTAL" "$rel"
|
||||
|
||||
output=$(python3 "$SYNC" export "$json" "$png" 2>/tmp/frame0-batch-err.txt)
|
||||
rc=$?
|
||||
if [[ $rc -eq 0 ]]; then
|
||||
size=$(echo "$output" | tail -1 | grep -oP '\(\K[^)]+' || true)
|
||||
echo "ok $size"
|
||||
PASSED=$((PASSED + 1))
|
||||
else
|
||||
echo "FAILED"
|
||||
cat /tmp/frame0-batch-err.txt >&2
|
||||
FAILED=$((FAILED + 1))
|
||||
FAILED_FILES+=("$rel")
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "$PASSED exported, $FAILED failed."
|
||||
|
||||
if [[ $FAILED -gt 0 ]]; then
|
||||
echo ""
|
||||
echo "Failed:" >&2
|
||||
for f in "${FAILED_FILES[@]}"; do
|
||||
echo " $f" >&2
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
+414
@@ -0,0 +1,414 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Frame0 sync: push local JSON wireframes to Frame0, pull pages back.
|
||||
|
||||
Local JSON is source of truth. Frame0 is a renderer.
|
||||
A mapping file tracks local_id <-> frame0_id across push/pull cycles.
|
||||
|
||||
Usage:
|
||||
frame0-sync.py push <wireframe.json> [--port PORT]
|
||||
frame0-sync.py pull <page-id|page-name> <output.json> [--port PORT]
|
||||
frame0-sync.py export <wireframe.json> <output.png> [--port PORT] [--format MIME]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
DEFAULT_PORT = 58320
|
||||
|
||||
|
||||
def api(port, command, args=None):
|
||||
"""Execute a Frame0 API command. Returns the data field on success."""
|
||||
url = f"http://localhost:{port}/execute_command"
|
||||
payload = json.dumps({"command": command, "args": args or {}}).encode()
|
||||
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
result = json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode()
|
||||
print(f"ERROR: {command}: HTTP {e.code}: {body[:500]}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except urllib.error.URLError as e:
|
||||
print(f"ERROR: Cannot connect to Frame0 on port {port}: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not result.get("success"):
|
||||
print(f"ERROR: {command}: {result.get('error', 'unknown')}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
return result.get("data")
|
||||
|
||||
|
||||
# -- Mapping file management --------------------------------------------------
|
||||
|
||||
def mapping_path(wireframe_path):
|
||||
"""Derive the idmap path from the wireframe JSON path."""
|
||||
d = os.path.dirname(wireframe_path)
|
||||
base = os.path.splitext(os.path.basename(wireframe_path))[0]
|
||||
return os.path.join(d, f".{base}.idmap.json")
|
||||
|
||||
|
||||
def load_mapping(wireframe_path):
|
||||
p = mapping_path(wireframe_path)
|
||||
if os.path.exists(p):
|
||||
with open(p) as f:
|
||||
return json.load(f)
|
||||
return {"page_id": None, "shapes": {}, "connectors": {}}
|
||||
|
||||
|
||||
def save_mapping(wireframe_path, mapping):
|
||||
p = mapping_path(wireframe_path)
|
||||
os.makedirs(os.path.dirname(p) or ".", exist_ok=True)
|
||||
with open(p, "w") as f:
|
||||
json.dump(mapping, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
# -- Push: local JSON -> Frame0 -----------------------------------------------
|
||||
|
||||
# Frame0 returns different type names from get vs what create accepts.
|
||||
# Map internal types back to create-API types.
|
||||
TYPE_TO_CREATE = {
|
||||
"Box": "Rectangle",
|
||||
"box": "Rectangle",
|
||||
}
|
||||
|
||||
|
||||
def topo_sort_shapes(shapes):
|
||||
"""Sort shape IDs so parents come before children."""
|
||||
order = []
|
||||
visited = set()
|
||||
|
||||
def visit(sid):
|
||||
if sid in visited:
|
||||
return
|
||||
visited.add(sid)
|
||||
shape = shapes[sid]
|
||||
parent = shape.get("parent")
|
||||
if parent and parent in shapes:
|
||||
visit(parent)
|
||||
order.append(sid)
|
||||
|
||||
for sid in shapes:
|
||||
visit(sid)
|
||||
return order
|
||||
|
||||
|
||||
def find_or_create_page(port, name, mapping):
|
||||
"""Find existing page by mapping or name, or create a new one."""
|
||||
# Try mapped page_id first
|
||||
if mapping.get("page_id"):
|
||||
try:
|
||||
page = api(port, "page:get", {"pageId": mapping["page_id"]})
|
||||
if page:
|
||||
return mapping["page_id"]
|
||||
except SystemExit:
|
||||
pass # Page no longer exists, fall through
|
||||
|
||||
# Search by name in existing pages
|
||||
doc = api(port, "doc:get", {"exportPages": True, "exportShapes": False})
|
||||
if doc and "children" in doc:
|
||||
for page in doc["children"]:
|
||||
if page.get("name") == name:
|
||||
return page["id"]
|
||||
|
||||
# Create new page
|
||||
page = api(port, "page:add", {"pageProps": {"name": name}})
|
||||
return page["id"]
|
||||
|
||||
|
||||
def clear_page(port, page_id):
|
||||
"""Delete all shapes on a page."""
|
||||
page = api(port, "page:get", {"pageId": page_id, "exportShapes": True})
|
||||
if not page or "children" not in page:
|
||||
return
|
||||
shape_ids = [s["id"] for s in page["children"]]
|
||||
if shape_ids:
|
||||
api(port, "edit:delete", {"shapeIdArray": shape_ids})
|
||||
|
||||
|
||||
def push(wireframe_path, port):
|
||||
"""Push local wireframe JSON to Frame0."""
|
||||
with open(wireframe_path) as f:
|
||||
wireframe = json.load(f)
|
||||
|
||||
name = wireframe.get("name", os.path.splitext(os.path.basename(wireframe_path))[0])
|
||||
shapes = wireframe.get("shapes", {})
|
||||
connectors = wireframe.get("connectors", {})
|
||||
|
||||
mapping = load_mapping(wireframe_path)
|
||||
|
||||
# Find or create page
|
||||
page_id = find_or_create_page(port, name, mapping)
|
||||
mapping["page_id"] = page_id
|
||||
|
||||
# Switch to page and clear it
|
||||
api(port, "page:set-current-page", {"pageId": page_id})
|
||||
clear_page(port, page_id)
|
||||
|
||||
# Reset ID mappings (shapes are recreated)
|
||||
mapping["shapes"] = {}
|
||||
mapping["connectors"] = {}
|
||||
|
||||
# Create shapes in dependency order
|
||||
order = topo_sort_shapes(shapes)
|
||||
for local_id in order:
|
||||
shape = shapes[local_id]
|
||||
raw_type = shape.get("type", "Rectangle")
|
||||
shape_type = TYPE_TO_CREATE.get(raw_type, raw_type)
|
||||
parent_local = shape.get("parent")
|
||||
|
||||
# Build shapeProps from everything except meta fields
|
||||
meta_keys = {"type", "parent"}
|
||||
props = {k: v for k, v in shape.items() if k not in meta_keys}
|
||||
|
||||
# Set name to local_id if not explicitly set
|
||||
if "name" not in props:
|
||||
props["name"] = local_id
|
||||
|
||||
create_args = {
|
||||
"type": shape_type,
|
||||
"shapeProps": props,
|
||||
"convertColors": True,
|
||||
}
|
||||
|
||||
# Resolve parent ID
|
||||
if parent_local and parent_local in mapping["shapes"]:
|
||||
create_args["parentId"] = mapping["shapes"][parent_local]
|
||||
|
||||
f0_id = api(port, "shape:create-shape", create_args)
|
||||
mapping["shapes"][local_id] = f0_id
|
||||
|
||||
# Create connectors
|
||||
for local_id, conn in connectors.items():
|
||||
tail_local = conn.get("tailId")
|
||||
head_local = conn.get("headId")
|
||||
|
||||
if tail_local not in mapping["shapes"] or head_local not in mapping["shapes"]:
|
||||
print(f"WARNING: connector '{local_id}' references unknown shape, skipping", file=sys.stderr)
|
||||
continue
|
||||
|
||||
meta_keys = {"tailId", "headId"}
|
||||
props = {k: v for k, v in conn.items() if k not in meta_keys}
|
||||
if "name" not in props:
|
||||
props["name"] = local_id
|
||||
|
||||
f0_id = api(port, "shape:create-connector", {
|
||||
"tailId": mapping["shapes"][tail_local],
|
||||
"headId": mapping["shapes"][head_local],
|
||||
"shapeProps": props,
|
||||
"convertColors": True,
|
||||
})
|
||||
mapping["connectors"][local_id] = f0_id
|
||||
|
||||
# Fit to screen
|
||||
api(port, "view:fit-to-screen")
|
||||
|
||||
save_mapping(wireframe_path, mapping)
|
||||
total = len(mapping["shapes"]) + len(mapping["connectors"])
|
||||
print(f"Pushed '{name}' to Frame0: {len(mapping['shapes'])} shapes, {len(mapping['connectors'])} connectors")
|
||||
|
||||
|
||||
# -- Pull: Frame0 -> local JSON -----------------------------------------------
|
||||
|
||||
def pull(page_ref, output_path, port):
|
||||
"""Pull a Frame0 page into local wireframe JSON."""
|
||||
# Resolve page_ref: could be an ID or a name
|
||||
page_id = None
|
||||
doc = api(port, "doc:get", {"exportPages": True, "exportShapes": False})
|
||||
if doc and "children" in doc:
|
||||
for page in doc["children"]:
|
||||
if page["id"] == page_ref or page.get("name") == page_ref:
|
||||
page_id = page["id"]
|
||||
page_name = page.get("name", page_ref)
|
||||
break
|
||||
|
||||
if not page_id:
|
||||
print(f"ERROR: Page not found: {page_ref}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Load existing mapping for reverse lookup
|
||||
mapping = load_mapping(output_path)
|
||||
reverse_map = {v: k for k, v in mapping.get("shapes", {}).items()}
|
||||
reverse_conn = {v: k for k, v in mapping.get("connectors", {}).items()}
|
||||
|
||||
# Get full page with shapes
|
||||
page = api(port, "page:get", {"pageId": page_id, "exportShapes": True})
|
||||
|
||||
shapes = {}
|
||||
connectors = {}
|
||||
new_mapping = {"page_id": page_id, "shapes": {}, "connectors": {}}
|
||||
auto_id_counter = [0]
|
||||
|
||||
def auto_id(f0_shape):
|
||||
"""Generate a stable local ID from shape name or auto-number."""
|
||||
# Prefer existing mapping
|
||||
f0_id = f0_shape["id"]
|
||||
if f0_id in reverse_map:
|
||||
return reverse_map[f0_id]
|
||||
# Use sanitized name
|
||||
name = f0_shape.get("name", "")
|
||||
if name:
|
||||
sanitized = name.lower().replace(" ", "-").replace("_", "-")
|
||||
if sanitized not in shapes:
|
||||
return sanitized
|
||||
# Fallback: auto-number
|
||||
auto_id_counter[0] += 1
|
||||
return f"s{auto_id_counter[0]:03d}"
|
||||
|
||||
def process_shape(f0_shape, parent_local_id=None):
|
||||
f0_id = f0_shape["id"]
|
||||
local_id = auto_id(f0_shape)
|
||||
new_mapping["shapes"][local_id] = f0_id
|
||||
|
||||
# Extract shape properties — only strip structural keys that our
|
||||
# ID mapping replaces. Everything else passes through as-is so the
|
||||
# local JSON speaks Frame0's native vocabulary.
|
||||
shape_type = f0_shape.get("type", "Box")
|
||||
skip_keys = {"id", "type", "children", "pageId", "parentId"}
|
||||
props = {k: v for k, v in f0_shape.items() if k not in skip_keys and v is not None}
|
||||
|
||||
entry = {"type": shape_type}
|
||||
if parent_local_id:
|
||||
entry["parent"] = parent_local_id
|
||||
entry.update(props)
|
||||
|
||||
# Remove name if it matches local_id (redundant)
|
||||
if entry.get("name") == local_id:
|
||||
del entry["name"]
|
||||
|
||||
shapes[local_id] = entry
|
||||
|
||||
# Process children recursively
|
||||
for child in f0_shape.get("children", []):
|
||||
child_type = child.get("type", "")
|
||||
if child_type == "Connector":
|
||||
process_connector(child)
|
||||
else:
|
||||
process_shape(child, local_id)
|
||||
|
||||
def process_connector(f0_conn):
|
||||
f0_id = f0_conn["id"]
|
||||
local_id = reverse_conn.get(f0_id)
|
||||
if not local_id:
|
||||
auto_id_counter[0] += 1
|
||||
local_id = f"c{auto_id_counter[0]:03d}"
|
||||
|
||||
new_mapping["connectors"][local_id] = f0_id
|
||||
|
||||
tail_f0 = f0_conn.get("tail", {}).get("id")
|
||||
head_f0 = f0_conn.get("head", {}).get("id")
|
||||
|
||||
entry = {}
|
||||
if tail_f0:
|
||||
# Will be resolved after all shapes are processed
|
||||
entry["_tailF0"] = tail_f0
|
||||
if head_f0:
|
||||
entry["_headF0"] = head_f0
|
||||
|
||||
skip_keys = {"id", "type", "children", "pageId", "tail", "head"}
|
||||
props = {k: v for k, v in f0_conn.items() if k not in skip_keys and v is not None}
|
||||
entry.update(props)
|
||||
|
||||
connectors[local_id] = entry
|
||||
|
||||
# Process all top-level shapes
|
||||
for child in page.get("children", []):
|
||||
child_type = child.get("type", "")
|
||||
if child_type == "Connector":
|
||||
process_connector(child)
|
||||
else:
|
||||
process_shape(child)
|
||||
|
||||
# Resolve connector references to local IDs
|
||||
f0_to_local = {v: k for k, v in new_mapping["shapes"].items()}
|
||||
for conn in connectors.values():
|
||||
tail_f0 = conn.pop("_tailF0", None)
|
||||
head_f0 = conn.pop("_headF0", None)
|
||||
if tail_f0 and tail_f0 in f0_to_local:
|
||||
conn["tailId"] = f0_to_local[tail_f0]
|
||||
if head_f0 and head_f0 in f0_to_local:
|
||||
conn["headId"] = f0_to_local[head_f0]
|
||||
|
||||
wireframe = {"name": page_name}
|
||||
if shapes:
|
||||
wireframe["shapes"] = shapes
|
||||
if connectors:
|
||||
wireframe["connectors"] = connectors
|
||||
|
||||
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
||||
with open(output_path, "w") as f:
|
||||
json.dump(wireframe, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
save_mapping(output_path, new_mapping)
|
||||
print(f"Pulled '{page_name}' -> {output_path}: {len(shapes)} shapes, {len(connectors)} connectors")
|
||||
|
||||
|
||||
# -- Export: push then export as image -----------------------------------------
|
||||
|
||||
def export_image(wireframe_path, output_path, port, fmt="image/png"):
|
||||
"""Push wireframe to Frame0 and export the page as an image."""
|
||||
import base64
|
||||
|
||||
# Push first to ensure Frame0 is up to date
|
||||
push(wireframe_path, port)
|
||||
|
||||
mapping = load_mapping(wireframe_path)
|
||||
page_id = mapping.get("page_id")
|
||||
if not page_id:
|
||||
print("ERROR: No page_id in mapping after push", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
image_b64 = api(port, "file:export-image", {
|
||||
"pageId": page_id,
|
||||
"format": fmt,
|
||||
"fillBackground": True,
|
||||
})
|
||||
|
||||
image_bytes = base64.b64decode(image_b64)
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
print(f"Exported: {output_path} ({len(image_bytes) // 1024}KB)")
|
||||
|
||||
|
||||
# -- CLI -----------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Sync wireframe JSON with Frame0")
|
||||
parser.add_argument("--port", type=int, default=int(os.environ.get("FRAME0_PORT", DEFAULT_PORT)))
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
p_push = sub.add_parser("push", help="Push local JSON to Frame0")
|
||||
p_push.add_argument("wireframe", help="Path to wireframe .json file")
|
||||
|
||||
p_pull = sub.add_parser("pull", help="Pull Frame0 page to local JSON")
|
||||
p_pull.add_argument("page", help="Page ID or page name")
|
||||
p_pull.add_argument("output", help="Output .json path")
|
||||
|
||||
p_export = sub.add_parser("export", help="Push and export as image")
|
||||
p_export.add_argument("wireframe", help="Path to wireframe .json file")
|
||||
p_export.add_argument("output", help="Output image path (e.g. wireframe.png)")
|
||||
p_export.add_argument("--format", default="image/png",
|
||||
help="Export MIME type (default: image/png)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "push":
|
||||
push(args.wireframe, args.port)
|
||||
elif args.command == "pull":
|
||||
pull(args.page, args.output, args.port)
|
||||
elif args.command == "export":
|
||||
export_image(args.wireframe, args.output, args.port, args.format)
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,187 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
name: commit
|
||||
name: git-commit
|
||||
description: >
|
||||
Commit changes with clean, structured messages. Use when the user says
|
||||
"commit", "save my work", "commit changes", or invokes /commit. Enforces
|
||||
"commit", "save my work", "commit changes", or invokes /git-commit. Enforces
|
||||
conventional commit format, groups changes into logical commits, and maintains
|
||||
CHANGELOG.md. Never squash unrelated changes into one commit.
|
||||
user-invocable: true
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: gen-image
|
||||
name: image-gen
|
||||
description: >
|
||||
Generate themed visual assets for the Lords of Ash CK3 total conversion mod
|
||||
using the generate_image MCP tool (Nano Banana / Gemini 2.5 Flash Image).
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: push-pr
|
||||
name: pr-push
|
||||
description: >
|
||||
Push commits and create or update a pull request. Use when the user says
|
||||
"push pr", "push and create pr", "update pr", "create a pr", "open a pr",
|
||||
or invokes /push-pr. NOT triggered by plain "push" (that's just git push).
|
||||
or invokes /pr-push. NOT triggered by plain "push" (that's just git push).
|
||||
Pushes the current branch, creates a PR if none exists, or confirms the
|
||||
existing PR was updated. NEVER merges the PR into main — this skill only
|
||||
pushes to the branch and manages the PR lifecycle.
|
||||
@@ -41,7 +41,7 @@ git status
|
||||
```
|
||||
|
||||
If there are uncommitted changes (staged or unstaged), run the **commit skill**
|
||||
first. Use the `/commit` skill to group changes into logical commits with
|
||||
first. Use the `/git-commit` skill to group changes into logical commits with
|
||||
proper conventional commit messages. Wait for commit to complete before
|
||||
proceeding.
|
||||
|
||||
@@ -136,5 +136,5 @@ already `done`, `review`, `cancelled`, or `backlog` (only transition
|
||||
|
||||
## Arguments
|
||||
|
||||
If the user passes arguments (e.g., `/push-pr "my title"`), use them as the
|
||||
If the user passes arguments (e.g., `/pr-push "my title"`), use them as the
|
||||
PR title instead of generating one.
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: review-pr
|
||||
name: pr-review
|
||||
description: >
|
||||
Review a branch diff with team-appropriate agents before merge. Use when the
|
||||
user says "review-pr", "review this PR", "review this branch", or invokes
|
||||
/review-pr. Spawns reviewers matched to the branch type (code, copy, visual,
|
||||
/pr-review. Spawns reviewers matched to the branch type (code, copy, visual,
|
||||
audio) in parallel. Reports approve/reject with inline comments.
|
||||
user-invocable: true
|
||||
allowed-tools: Bash, Read, Grep, Glob, Task
|
||||
@@ -63,23 +63,44 @@ If the diff is empty, report "No changes to review" and stop.
|
||||
|
||||
Three-dot diff with pathspec exclusions is unreliable. Instead, either:
|
||||
1. Use `git diff main...<branch>` (full diff) and filter in the prompt, or
|
||||
2. Read source files directly from the branch:
|
||||
`git show origin/<branch>:<path>`
|
||||
2. Read source files directly from the branch worktree (see below).
|
||||
|
||||
For large diffs (>1000 lines of source), provide **source files** rather than
|
||||
raw diff to reviewers — cleaner context, better reviews. Read files with
|
||||
`git show origin/<branch>:<path>` and include them in the prompt.
|
||||
raw diff to reviewers — cleaner context, better reviews.
|
||||
|
||||
**IMPORTANT — agent tool access:** Not all reviewer agents have Bash access.
|
||||
Agents that CAN read from branches themselves: **Hoshe, Tyre, Araminta**.
|
||||
Agents that CANNOT (no Bash tool): **Paula, Miri, Ozzie, Gestalt, Gore, Nigel**.
|
||||
**IMPORTANT — use worktree paths for ALL agents.** This project uses git
|
||||
worktrees. Each team branch is checked out at:
|
||||
|
||||
For agents without Bash, you MUST read the source files yourself (via
|
||||
`git show origin/<branch>:<path>`) and **paste the file contents directly
|
||||
into the agent prompt**. Do not tell these agents to read files — they can't.
|
||||
For very large PRs, read the key files (new/heavily modified) and include
|
||||
summaries or excerpts of minor changes. Also read and include the relevant
|
||||
`decisions/*.md` files these agents need for context.
|
||||
```
|
||||
/var/home/jeroenschweitzer/Projects/settled-reach/<branch>/
|
||||
```
|
||||
|
||||
For example, the `copy` branch lives at:
|
||||
```
|
||||
/var/home/jeroenschweitzer/Projects/settled-reach/copy/content/dialogue/...
|
||||
```
|
||||
|
||||
**All reviewer agents** (regardless of Bash access) should read source files
|
||||
from the worktree path using the Read tool. This is more reliable than
|
||||
`git show origin/<branch>:<path>` because:
|
||||
- All agents have Read access (no Bash dependency)
|
||||
- Files are always the actual branch checkout (no stale cache)
|
||||
- No risk of accidentally reading from main's working directory
|
||||
|
||||
When constructing reviewer prompts, tell agents to read files from the
|
||||
worktree path. Example instruction for agents:
|
||||
|
||||
```
|
||||
Read the changed files from the branch worktree. The branch is checked
|
||||
out at: /var/home/jeroenschweitzer/Projects/settled-reach/<branch>/
|
||||
|
||||
For example, to read `content/dialogue/the-terminal/kael-davan.yaml`,
|
||||
use: /var/home/jeroenschweitzer/Projects/settled-reach/<branch>/content/dialogue/the-terminal/kael-davan.yaml
|
||||
```
|
||||
|
||||
Also tell agents to read relevant `decisions/*.md` files from the same
|
||||
worktree (they're identical to main, but using the worktree path keeps
|
||||
agents grounded in the correct directory).
|
||||
|
||||
### 4. Spawn reviewers in parallel
|
||||
|
||||
@@ -156,19 +177,16 @@ Note: `tea pr reject` does not work on your own PRs. Use `tea comment` 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
|
||||
# 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 1: Write review to .tmp/ using the Write tool (no permission prompt)
|
||||
Write(file_path: "<repo_root>/.tmp/review-<branch>.md", content: "...review content...")
|
||||
|
||||
# Step 2: Post to Gitea (separate Bash call)
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER> "$(cat /tmp/review-<branch>.md)"
|
||||
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.
|
||||
Use the Write tool for step 1 (avoids Bash permission prompts). The `.tmp/`
|
||||
directory is gitignored and exists in the repo root for this purpose.
|
||||
|
||||
## 7. Merging approved PRs
|
||||
|
||||
+34
-24
@@ -2,11 +2,20 @@
|
||||
|
||||
Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
|
||||
|
||||
**All agents read from worktree paths.** Each branch is checked out at:
|
||||
`/var/home/jeroenschweitzer/Projects/settled-reach/<branch>/`
|
||||
|
||||
Tell every reviewer agent to read source files from the worktree using the
|
||||
Read tool. Include the worktree base path and a list of changed files in
|
||||
every prompt. Do NOT rely on `git show` or paste file contents — agents
|
||||
can read directly from the worktree.
|
||||
|
||||
## Code reviews (`server`, `client`, `ci`)
|
||||
|
||||
**Hoshe (Code Quality)**
|
||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
||||
- Prompt: Include source code and commit log. Ask Hoshe to review for:
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
Tell Hoshe to read source files from the worktree, then review for:
|
||||
- Correctness and bug risks
|
||||
- Error handling gaps
|
||||
- Test coverage (are new features tested?)
|
||||
@@ -16,44 +25,43 @@ Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
|
||||
|
||||
**Tyre (Architecture)**
|
||||
- `subagent_type`: `tyre`, `model`: `sonnet`
|
||||
- Prompt: Include source code and commit log. Tell Tyre to read the relevant
|
||||
`decisions/*.md` files first, then review for:
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
Tell Tyre to read the relevant `decisions/*.md` files from the worktree
|
||||
first, then review for:
|
||||
- Architectural consistency with project decisions
|
||||
- API/interface design quality
|
||||
- Dependency and coupling concerns
|
||||
- Scalability implications
|
||||
- Whether the change respects non-negotiable baselines (D-010, D-012)
|
||||
- Tyre can read files directly from the branch using `git show origin/<branch>:<path>`
|
||||
|
||||
## Copy reviews (`copy`)
|
||||
|
||||
**Hoshe (QA)**
|
||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
||||
- Prompt: Include the changed files and commit log. Ask Hoshe to review for:
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
Tell Hoshe to read the changed files from the worktree, then review for:
|
||||
- Formatting consistency (markdown, file naming, frontmatter)
|
||||
- Broken references or links
|
||||
- Spelling and grammar
|
||||
- File organization and structure
|
||||
- Missing or orphaned files
|
||||
|
||||
**Paula (Narrative Depth)** — NO BASH ACCESS
|
||||
**Paula (Narrative Depth)**
|
||||
- `subagent_type`: `paula`, `model`: `sonnet`
|
||||
- Paula cannot read from branches. You must paste file contents and decision
|
||||
files directly into the prompt.
|
||||
- Prompt: Include full text of changed files, commit log, and relevant
|
||||
`decisions/*.md` content. Ask Paula to review for:
|
||||
- Prompt: Provide the worktree path, list of changed files, commit log, and
|
||||
list of relevant `decisions/*.md` files to read. Tell Paula to read all
|
||||
files from the worktree using the Read tool, then review for:
|
||||
- Narrative quality and character voice consistency
|
||||
- Whether dialogue and monologue feel authentic to the characters
|
||||
- Consequences and stakes — do choices carry weight?
|
||||
- Political and interpersonal depth
|
||||
- Emotional resonance — does the text make you feel something?
|
||||
|
||||
**Miri (World Consistency)** — NO BASH ACCESS
|
||||
**Miri (World Consistency)**
|
||||
- `subagent_type`: `miri`, `model`: `sonnet`
|
||||
- Miri cannot read from branches. You must paste file contents and decision
|
||||
files directly into the prompt.
|
||||
- Prompt: Include full text of changed files, commit log, and relevant
|
||||
`decisions/*.md` content. Ask Miri to review for:
|
||||
- Prompt: Provide the worktree path, list of changed files, commit log, and
|
||||
list of relevant `decisions/*.md` files to read. Tell Miri to read all
|
||||
files from the worktree using the Read tool, then review for:
|
||||
- Lore accuracy — do facts match established setting?
|
||||
- Internal consistency across files
|
||||
- IP originality — nothing should read as a copy from another franchise
|
||||
@@ -64,16 +72,18 @@ Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
|
||||
|
||||
**Hoshe (QA)**
|
||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
||||
- Prompt: Include the changed files and commit log. Ask Hoshe to review for:
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
Tell Hoshe to read the changed files from the worktree, then review for:
|
||||
- File format and naming conventions
|
||||
- Asset organization and directory structure
|
||||
- Missing or broken references in scene/resource files
|
||||
- Import settings consistency
|
||||
|
||||
**Araminta (Art Direction)** — HAS BASH ACCESS
|
||||
**Araminta (Art Direction)**
|
||||
- `subagent_type`: `araminta`, `model`: `sonnet`
|
||||
- Prompt: Include the changed files and commit log. Tell Araminta to read
|
||||
the style guide and relevant design docs first, then review for:
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
Tell Araminta to read the style guide and relevant design docs from the
|
||||
worktree first, then review for:
|
||||
- Visual consistency with the established style guide
|
||||
- Color palette adherence
|
||||
- UI pattern consistency (diegetic-first, clarity over beauty)
|
||||
@@ -84,17 +94,17 @@ Use `model: sonnet` for all reviewers — sufficient for review, saves cost.
|
||||
|
||||
**Hoshe (QA)**
|
||||
- `subagent_type`: `hoshe`, `model`: `sonnet`
|
||||
- Prompt: Include the changed files and commit log. Ask Hoshe to review for:
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
Tell Hoshe to read the changed files from the worktree, then review for:
|
||||
- File format and naming conventions
|
||||
- Audio asset organization and directory structure
|
||||
- Missing or broken references
|
||||
- Import/bus configuration consistency
|
||||
|
||||
**Ozzie (Player Experience)** — NO BASH ACCESS
|
||||
**Ozzie (Player Experience)**
|
||||
- `subagent_type`: `ozzie`, `model`: `sonnet`
|
||||
- Ozzie cannot read from branches. You must paste file contents directly
|
||||
into the prompt.
|
||||
- Prompt: Include full text of changed files and commit log. Ask Ozzie to
|
||||
- Prompt: Provide the worktree path, list of changed files, and commit log.
|
||||
Tell Ozzie to read all files from the worktree using the Read tool, then
|
||||
review for:
|
||||
- Emotional impact — does the audio enhance the moment?
|
||||
- Atmosphere and tone — does it feel like the Commonwealth?
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: create-skill
|
||||
name: skill-create
|
||||
description: >
|
||||
Guidance for creating effective Claude Code skills (.skill packages).
|
||||
Use when the user wants to create, build, design, or iterate on a skill —
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: plan-sprint
|
||||
name: sprint-plan
|
||||
description: >
|
||||
Plan the next sprint and generate team briefing files. Use when the user says
|
||||
"plan sprint", "prep sprint briefing", "plan next sprint", "sprint planning",
|
||||
or invokes /plan-sprint. Gathers current sprint status, scans the backlog,
|
||||
or invokes /sprint-plan. Gathers current sprint status, scans the backlog,
|
||||
proposes ticket selection, and writes briefing files per team to
|
||||
docs/sprints/sprint-N/.
|
||||
user-invocable: true
|
||||
@@ -19,8 +19,8 @@ When this skill is invoked, spawn SI using the Task tool:
|
||||
```
|
||||
Task(
|
||||
subagent_type: "si",
|
||||
prompt: "Run /plan-sprint for Sprint N. Read the skill at
|
||||
.claude/skills/plan-sprint/SKILL.md for the full workflow,
|
||||
prompt: "Run /sprint-plan for Sprint N. Read the skill at
|
||||
.claude/skills/sprint-plan/SKILL.md for the full workflow,
|
||||
then execute it. Use the arguments provided: {args}",
|
||||
description: "Plan sprint N"
|
||||
)
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: start-sprint
|
||||
name: sprint-start
|
||||
description: >
|
||||
Manage the sprint lifecycle from main, or start sprint work on a team
|
||||
branch. Use when the user says "start sprint", "start working on the
|
||||
server/client/copy", "begin sprint", or invokes /start-sprint. On main:
|
||||
server/client/copy", "begin sprint", or invokes /sprint-start. On main:
|
||||
assesses sprint state and does the next right thing (close, activate, or
|
||||
guide). On a team branch: merges main, loads the briefing, presents the
|
||||
work plan.
|
||||
@@ -34,7 +34,7 @@ the team branch workflow (steps 2–8).
|
||||
|
||||
## Main branch workflow (sprint lifecycle management)
|
||||
|
||||
When `/start-sprint` is run on `main`, assess the current sprint state
|
||||
When `/sprint-start` is run on `main`, assess the current sprint state
|
||||
and do the next right thing. Query the database to determine the state:
|
||||
|
||||
```bash
|
||||
@@ -45,7 +45,38 @@ Then follow the **first matching case**:
|
||||
|
||||
### Case A: An active sprint exists
|
||||
|
||||
The active sprint needs to be closed before moving on.
|
||||
First, check whether the sprint's work is actually done:
|
||||
|
||||
```bash
|
||||
db/connectors/sprint status
|
||||
```
|
||||
|
||||
This shows ticket counts by status (done, in_progress, backlog).
|
||||
|
||||
Also check for open PRs that may contain completed work waiting for
|
||||
review or merge:
|
||||
|
||||
```bash
|
||||
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
|
||||
```
|
||||
|
||||
Report the full picture to the user:
|
||||
- Ticket progress (done / in_progress / backlog counts)
|
||||
- Open PRs (if any — these represent work that's done but not merged)
|
||||
|
||||
**If tickets remain unfinished** (in_progress or backlog) **or open PRs
|
||||
exist**, do NOT close the sprint. Instead, report the current progress
|
||||
and ask the user what they want to do:
|
||||
|
||||
- **Continue working** — switch to a team branch and run `/sprint-start`
|
||||
there to resume work
|
||||
- **Review & merge PRs first** — (only if open PRs exist) merge pending
|
||||
work before deciding whether to close
|
||||
- **Close anyway** — proceed with the close workflow below (carries over
|
||||
unfinished tickets)
|
||||
|
||||
Use `AskUserQuestion` to confirm. Do not proceed to A1 unless the user
|
||||
explicitly chooses to close.
|
||||
|
||||
#### A1. Close the active sprint
|
||||
|
||||
@@ -97,7 +128,7 @@ git push && git push --tags
|
||||
|
||||
After closing, re-query the database. If a sprint in `planning` status
|
||||
exists, continue to **Case B**. Otherwise, report the close and suggest
|
||||
running `/plan-sprint`.
|
||||
running `/sprint-plan`.
|
||||
|
||||
---
|
||||
|
||||
@@ -115,7 +146,7 @@ A sprint is ready to activate. Verify it looks complete:
|
||||
```
|
||||
|
||||
If briefings are missing or the sprint has 0 tickets, report the gap
|
||||
and suggest running `/plan-sprint` to complete planning.
|
||||
and suggest running `/sprint-plan` to complete planning.
|
||||
|
||||
If everything looks ready, activate the sprint:
|
||||
|
||||
@@ -125,14 +156,14 @@ db/connectors/sprint start
|
||||
|
||||
Then report:
|
||||
- Sprint activated (name, ticket count per team)
|
||||
- Remind the user to switch to a team branch and run `/start-sprint`
|
||||
- Remind the user to switch to a team branch and run `/sprint-start`
|
||||
there (or `cd` into the relevant worktree)
|
||||
|
||||
---
|
||||
|
||||
### Case C: No active sprint and no planned sprint
|
||||
|
||||
Nothing is ready. Report the state and suggest running `/plan-sprint`
|
||||
Nothing is ready. Report the state and suggest running `/sprint-plan`
|
||||
to plan the next sprint.
|
||||
|
||||
---
|
||||
@@ -167,7 +198,7 @@ If no active sprint is found, report that and stop.
|
||||
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.
|
||||
`/sprint-plan` to generate one.
|
||||
|
||||
### 5. Load ticket details
|
||||
|
||||
@@ -257,6 +288,17 @@ Task(
|
||||
prompt: "You are on the {team} team for Sprint {N}.
|
||||
Branch: `{team}`
|
||||
|
||||
RULES:
|
||||
- GIT: Do NOT run any git commands (commit, push, pull, merge,
|
||||
checkout, branch, stash, tag, etc.). All git operations are
|
||||
handled by the team lead.
|
||||
- DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use
|
||||
the exact command with no wrappers or chaining. Examples:
|
||||
db/connectors/ticket show 528
|
||||
db/connectors/ticket list --sprint {N}
|
||||
Do NOT prepend python3, do NOT chain with && or ;, do NOT
|
||||
add cleanup commands. Just the bare command.
|
||||
|
||||
1. Read the sprint briefing: docs/sprints/sprint-{N}/{team}.md
|
||||
2. Read the decision files referenced in the briefing.
|
||||
3. Check TaskList for available work.
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: render-sprite
|
||||
name: sprite-gen
|
||||
description: >
|
||||
Render a 3D model to 2D sprites via the Godot render pipeline. Produces
|
||||
sprites at 3 resolutions (1024, 256, 64) from 4 cardinal directions (north,
|
||||
east, south, west) with outline applied at working resolution. Use when the
|
||||
user says "render sprite", "render model", "run the render pipeline",
|
||||
"test the pipeline", "/render-sprite", or asks to render a specific model
|
||||
"test the pipeline", "/sprite-gen", or asks to render a specific model
|
||||
(e.g., "render wall_structural"). Output: 12 PNG files in renderer/output/.
|
||||
---
|
||||
|
||||
@@ -14,7 +14,7 @@ description: >
|
||||
Run the render script with the model name:
|
||||
|
||||
```bash
|
||||
.claude/skills/render-sprite/scripts/render.sh <model_name>
|
||||
.claude/skills/sprite-gen/scripts/render.sh <model_name>
|
||||
```
|
||||
|
||||
### Available Models
|
||||
@@ -52,4 +52,4 @@ ls renderer/models/*.tscn | xargs -I{} basename {} .tscn
|
||||
|
||||
1. Create model scene at `renderer/models/<name>.tscn`
|
||||
2. Apply texture from `renderer/textures/` via StandardMaterial3D
|
||||
3. Run: `.claude/skills/render-sprite/scripts/render.sh <name>`
|
||||
3. Run: `.claude/skills/sprite-gen/scripts/render.sh <name>`
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
name: start-workshop
|
||||
name: workshop-start
|
||||
description: >
|
||||
Start a multi-agent design workshop from a workshop brief. Use when the user says
|
||||
"start workshop", "run workshop", "let's start the workshop", or invokes /start-workshop.
|
||||
"start workshop", "run workshop", "let's start the workshop", or invokes /workshop-start.
|
||||
Parses the workshop brief to extract participants, questions, and round format.
|
||||
Creates a team, tasks, and spawns agents as teammates via the Task tool.
|
||||
---
|
||||
@@ -88,9 +88,10 @@ When all Round N tasks are complete:
|
||||
|
||||
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
|
||||
2. Qatux creates or updates diagrams (via `/d2-diagram`) for any new D-records produced by the workshop
|
||||
3. If SI is present, SI creates tickets from decided items
|
||||
4. Send shutdown_request to all agents (qatux and si last, after they finish their output tasks)
|
||||
5. TeamDelete to clean up
|
||||
|
||||
## Agent Type Reference
|
||||
|
||||
@@ -73,7 +73,7 @@ Cross-reference open PR head branches with the ahead-of-main branches.
|
||||
|
||||
Show a summary table of branches ahead of main. For each branch, indicate:
|
||||
- `[PR]` if it has an open pull request — warn that it should go through
|
||||
normal review channels (use `/review-pr` instead)
|
||||
normal review channels (use `/pr-review` instead)
|
||||
- Commit count and summary
|
||||
|
||||
Use `AskUserQuestion` to let the user pick which branches to merge.
|
||||
@@ -116,12 +116,10 @@ git merge origin/main --no-edit
|
||||
If clean, report the result (fast-forward or merge commit, files changed).
|
||||
If conflicts, report them and stop.
|
||||
|
||||
### 3. Push prompt
|
||||
### 3. Push
|
||||
|
||||
After a successful merge, ask the user if they want to push:
|
||||
After a successful merge, push the branch:
|
||||
|
||||
```bash
|
||||
git push origin <current-branch>
|
||||
```
|
||||
|
||||
Never push without explicit confirmation.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Build and cache
|
||||
.cache/
|
||||
.tmp/
|
||||
server/target/
|
||||
tooling/content-converter/target/
|
||||
tooling/line-previewer/target/
|
||||
@@ -20,6 +21,9 @@ renderer/output/*.png
|
||||
# Database (shared across worktrees at ../settledreach.db, not tracked)
|
||||
db/commonwealth.db*
|
||||
|
||||
# Frame0 ID mapping files (ephemeral, per-machine)
|
||||
*.idmap.json
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
+149
@@ -6,6 +6,150 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.1.15] — 2026-02-23
|
||||
|
||||
### Added
|
||||
- Sprint 16 "Converse" briefings — 8 tickets across server/client/copy/visual teams
|
||||
- 19 UI wireframes — HUD, dialogue, monologue, popups, menus in v0.1 and v1.0 variants with D-record cross-references
|
||||
- d2-diagram skill — text-to-diagram generation with project defaults (theme 200, dagre, PNG)
|
||||
- frame0-wireframe skill — UI wireframing via Frame0 HTTP API, replaces MCP dependency with bash+curl
|
||||
- 16 decision diagrams — architecture, data-flow, entity, state, and UI categories covering all project decisions
|
||||
|
||||
### Changed
|
||||
- frame0-wireframe skill rewritten — JSON-as-truth workflow with frame0-sync.py, batch export, renderer-only guidance
|
||||
- pr-review skill — all reviewer agents now use worktree paths instead of git show
|
||||
- Dialogue panel is always visible as permanent insert UI element (D-061)
|
||||
- Makefile: check-protocol target verifies server/client protocol versions match before build
|
||||
- D-035 amended: line ID namespace changed from location-scoped to NPC-scoped (Sprint 15)
|
||||
- Tilemap z-layer filtering — FloorTiles renders z=0 only, z=1/z>1 reserved for future layer nodes (#71, D-049)
|
||||
- Entity 24x32 footprint per D-044 visual hierarchy — split ENTITY_SIZE into WIDTH/HEIGHT with separate offsets (#72)
|
||||
- Follow target stub on GameState — `follow_target_id` field ready for server #241 Follow verb
|
||||
- Manual exponential camera smoothing — CAMERA_SMOOTHING_SPEED constant (8.0), same lerp pattern as entity renderer (#117)
|
||||
- 31 new Sprint 15 validation tests — camera smoothing, UI framework z-layers, entity footprint, Sprint 14 regressions
|
||||
- SpatialIndex trait with naive Vec implementation — entities_in_range, entities_at, update methods with Manhattan distance (#340)
|
||||
- NPC generation pipeline — procedural seeding of all 10 D-024 axes via SimRng with constraint validation (#92)
|
||||
- Personality and tell system — 5 tell categories (Nervous, Angry, Friendly, Guarded, RoutineDeviation) derived from NPC axis values each tick (#90)
|
||||
- Tolerance threshold monitoring — ToleranceBreachEvent on stress exceeding per-NPC threshold, mood FSM integration (#105)
|
||||
- Routine deviation detection — RoutineDeviationEvent on wrong location/activity for day phase, absence detection, pathfinding-aware (#243)
|
||||
- Follow mechanic — Follow verb, proximity/LOS tracking, double-frequency observation events, NPC suspicion accumulation, configurable thresholds (#241)
|
||||
- Monologue event triggers — observe_npc, hear_sound, observe_anomaly, witness_interaction, post_conversation with D-035 context tags (#119)
|
||||
- Protocol v13 — tell_state on VisibleEntity, follow_state on ObserverSnapshot, Follow verb
|
||||
|
||||
## [v0.1.14] — 2026-02-21
|
||||
|
||||
### Added
|
||||
- Unified dialogue log — player-NPC and overheard NPC-NPC conversations in one chronological scrolling panel (#535, D-061/D-078)
|
||||
- F3 debug overlay — real-time game state display with tick, FPS, position, entity counts, dialogue/monologue status (#511)
|
||||
- Monologue display — multi-line priority queue with character colours, italic BBCode, stagger animation (#122)
|
||||
- Protocol v9 — conversation_events, conversation_ended, dialogue_response fields with carry-forward logic
|
||||
- Dialogue theme system — configurable NPC name colour palette, entry timing, passive opacity via dialogue-theme.yaml
|
||||
- Monologue display system visual spec — typography, positioning, stacking, priority, fade animation, character color differentiation, 80-char line constraint (#315)
|
||||
- Entity color system spec — D-033 relationship-to-player mapping, transition animations, color blindness assessment (#304)
|
||||
- Text display hierarchy spec — 4 content pipelines (dialogue, monologue, observation, environmental) with z-layers and positioning (#316)
|
||||
- Sound indicator visual design — fog-edge pulse for D-018 three-range sound model with direction encoding and range differentiation (#317)
|
||||
- THE FRIEND visual treatment spec — 3-phase earned visual detail for Kael Davan and Sera Venn (#318)
|
||||
- Environmental text visual standards — signage, terminal, and news ticker rendering with bilingual Concordat/Krenn treatment (#334)
|
||||
- Tell visual/behavioral expression spec — 5 tell categories mapped to 6 Tier 2 behaviors (#251)
|
||||
- Monologue line pool maxLength raised from 160 to 256 chars (soft guidance ≤160)
|
||||
- NPC name masking infrastructure — entity-anchored dialogue log with server-side role labels, retroactive name update on learning, NpcColorIndex for stable color assignment
|
||||
- Dialogue option keyboard selection (1/2/3 number keys) and numbered option labels
|
||||
- Interaction list chrome — background panel, mouse hover highlighting, click-to-interact, pointing hand cursor
|
||||
|
||||
### Changed
|
||||
- D-061 updated to document unified conversation log architecture from Sprint 14
|
||||
- Dialogue options switched from RichTextLabel to Label for reliable VBoxContainer sizing
|
||||
|
||||
### Fixed
|
||||
- Visual grammar dialogue max-width corrected from "~70% screen width" to 640px per D-076
|
||||
- BBCode injection in dialogue log formatting — server-sourced strings now escaped with [lb]
|
||||
- Per-frame dialogue log rebuild replaced with dirty flag (performance)
|
||||
- dialogue_active lifecycle — now cleared after panel fade completes per D-064
|
||||
- PAUSE/UNPAUSE routed through main.gd input recording for bug report replay (#507)
|
||||
- WASD input freeze after filing bug report — LineEdit focus not released before queue_free() across CanvasLayers
|
||||
- WASD not reactivating after Talk — dialogue_active held for entry_lifetime instead of cleared immediately
|
||||
- Recognition chime spam — entity IDs now tracked permanently per room instead of expiring
|
||||
- Audio path warning — res://audio/ corrected to res://assets/audio/ in AudioManager
|
||||
- world_radial.tscn anchors_preset warning — changed from 15 to 0
|
||||
- bug_report_dialog.gd push_warning changed to print for informational message
|
||||
|
||||
## [v0.1.13] — 2026-02-20
|
||||
|
||||
### Added
|
||||
- D-078: Overheard NPC conversation — passive dialogue panel with server-authoritative stochastic word occlusion
|
||||
- Sprint 14 "Live" briefings — 22 tickets across server (7), client (3), copy (6), visual (6)
|
||||
|
||||
## [v0.1.12] — 2026-02-19
|
||||
|
||||
### Added
|
||||
- Tier marker components (#93), active tier simulation (#94), tier transition logic (#99)
|
||||
- Information tag schema (#138), component-level access control (#139)
|
||||
- Line previewer CLI (#193)
|
||||
- Sound event system — server pipeline (#124)
|
||||
- Close-range stereo audio — client positional 2D (#125)
|
||||
- Medium-range visual indicators — fog-edge directional arrows (#126)
|
||||
- HashMap ban in simulation crate via clippy (#343)
|
||||
- Tracing crate infrastructure — JSON format, tick duration logging (#344)
|
||||
- System dependency graph debug command — `--dump-schedule` CLI flag (#346)
|
||||
- rng_seed field on ObserverSnapshot for deterministic replay (#527)
|
||||
- v0.1 Visual Grammar Document (#303)
|
||||
- Placeholder art specification (#252)
|
||||
- Spatial layouts: Logistics Hub (#311), Bar (#312), Smuggling corridors (#313)
|
||||
- Cultural generation guide — 5-dimension framework for Sova Transit District cultural voice (#189)
|
||||
- Sova Texture Appendix — 20-term slang glossary, sensory profile, Meridian self-censorship rules (#302)
|
||||
- Contraband specification — unlicensed lattice components, supply chain, street terminology (#321)
|
||||
- Sova Station Profile — 6 districts, governance, off-station references (#320)
|
||||
- Span Gate Transit Schedule — hourly schedule, maintenance windows, ring operational calendar (#336)
|
||||
- Meridian Coverage Map — 10 named zones from Commission-grade to dead air (#335)
|
||||
- Character definition schema and both character builds — smuggler + detective (#179, #180, #181)
|
||||
- Divergent starting knowledge and relationships per character (#182, #183)
|
||||
- Detective institutional chain of command (#322)
|
||||
- Contradiction arc design document — reusable FRIEND pattern (#332)
|
||||
- Mirror moment design document — 7 core dual-perspective observation triggers (#329)
|
||||
- First 5 minutes experience design — systemic opening per character (#259)
|
||||
- Opening hook content per character (#260)
|
||||
- Knowledge vocabulary for v0.1 content — entity/world categories, prerequisite format (#368)
|
||||
- Knowledge state vocabulary — author-facing quick reference (#309)
|
||||
- Knowledge fact catalogs — 10 YAML files in content/global/knowledge/, 73 canonical facts
|
||||
- D-075 endorsement — archetype dimension review recorded in decisions/content.md
|
||||
- Flat NPC memorable trait pass — Pael, Ren, Tev with noise-floor profiles (#307)
|
||||
- Environmental text content — 20 items across Terminal, Bar, and Corridors with dual-lens notes (#262)
|
||||
- Diegetic insert flavor text — per-character labels and notification strings (#331)
|
||||
- News ticker / Meridian feed — 30 lines including batch 44xx recall dual-lens moment (#306)
|
||||
- Workplace content pack — The Terminal: 5 NPC dialogue files (#190)
|
||||
- Bar content pack — The Last Shift: 3 NPC dialogue files (#191)
|
||||
- Smuggling ring content pack — maintenance corridors: coded vocabulary, dual registers (#192)
|
||||
- Generation pass expansion — 80 ambient variant lines across all 9 dialogue files (#194)
|
||||
- Sprint 13 "Sound" briefings — 9 tickets across server, client, audio, visual teams; full audio architecture + gauntlet expansion + monologue display spec
|
||||
|
||||
### Fixed
|
||||
- Entity renderer field name bug — `id` vs `entity_id` (#345)
|
||||
- Dialogue max-width pixel value — 640px per D-076 (#447)
|
||||
- Routine tests missing ActiveSim — 3 of 5 tests passed trivially without the required tier marker
|
||||
- `_observer_pos` misleading unused prefix renamed to `observer_pos` (used for sound event filtering)
|
||||
- Stale protocol version doc comment "Current: 9" corrected to 10
|
||||
- FactionOnly non-numeric `faction_id` attribute now logs a tracing::warn instead of silently denying
|
||||
- SOUND_EVENT_ASSETS walk-speed key mismatch — `sfx_footstep_metal` corrected to `sfx_footstep_metal_walk`
|
||||
|
||||
### Changed
|
||||
- Removed orphaned `SimulationTier`/`LastInteraction`/`ScopeTag`/`ScopeKind` types from tier.rs (unused outside own tests)
|
||||
- Sound pipeline documented as intentionally empty in v0.1 (no producers yet, full pipeline wired)
|
||||
- Observer test setup now inserts SoundEventQueue resource for integration coverage
|
||||
- Added FactionOnly positive test case and Medium-range occlusion TODO
|
||||
- Sound indicator colors sourced from Constants instead of duplicated hex literals
|
||||
- play_loop() null guard on stream.duplicate()
|
||||
- Camera zoom fallback uses Constants.CAMERA_DEFAULT_ZOOM
|
||||
|
||||
## [v0.1.11] — 2026-02-19
|
||||
|
||||
### Added
|
||||
- Sprint 12 "Build" briefings — 50 tickets across server, client, copy, visual, ci teams; production-layer foundations + all v0.1 copy authoring
|
||||
- `.tmp/` gitignored repo directory for agent temp files — avoids Bash permission prompts during PR review comment posting
|
||||
- `sed -n` blanket permission in shared settings
|
||||
|
||||
### Changed
|
||||
- All skills renamed to domain-action convention (e.g. `commit`→`git-commit`, `review-pr`→`pr-review`, `gen-audio`→`audio-gen`, `render-sprite`→`sprite-gen`) — 12 renames total
|
||||
- `pr-review` skill uses Write tool into `.tmp/` instead of Bash heredocs to `/tmp/`
|
||||
|
||||
## [v0.1.10] — 2026-02-19
|
||||
|
||||
### Added
|
||||
@@ -17,12 +161,17 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
- Mouse-relative facing and movement (#526, D-054) — mouse position determines facing direction (client-side float), WASD remapped to cursor-relative (W=toward, S=away, A/D=strafe), SET_FACING action sends octant to server, smooth facing indicator rotation
|
||||
- Room reset client UX (#502) — amber reset_plate tile type, 0.15s screen flash on room reset, 'Reset Room' interaction verb
|
||||
- Auto-checklist progress tracking (#503) — ChecklistEvaluator parses room YAML and evaluates 7 condition types against GameState with latching, ChecklistOverlay renders progress in gauntlet mode only, 48 new tests
|
||||
- 4 ambient zone loops: station base, workplace, bar, corridor — SAO-generated organic soundscape with crossfade loop points (#327)
|
||||
- 2 footstep SFX: metal walk and run — SAO hybrid with best-transient extraction (#327)
|
||||
- `audio-batch` command — batch audio generation from JSON manifests, supports SAO and harmonic synthesis, with `--dry-run`, `--only`, and `--skip-existing` flags
|
||||
- `--post` and `--output-ogg` flags on `audio-generate` — chain post-processing (trim, normalize, convert) into a single command
|
||||
|
||||
### Changed
|
||||
- `push-pr` skill now runs `/commit` first when uncommitted changes are detected
|
||||
- Insert open/close now sends explicit PauseSimulation/ResumeSimulation (#518, D-058) — replaces toggle-style pause with idempotent pair
|
||||
- Interaction list colors reference Constants.IMPLANT_TEXT_COLOR instead of hardcoded values
|
||||
- World radial menu uses theme font instead of ThemeDB.fallback_font
|
||||
- Monologue chimes replaced with production-quality manual synthesis — insert-tech aesthetic per D-074, pure sine harmonics with mathematical envelopes (#327)
|
||||
|
||||
### Fixed
|
||||
- Bidirectional relationship check (#515) — Check 9 tested `target in npc_rels` which missed NPCs with no relationship entries; changed to `target in self.npcs`
|
||||
|
||||
@@ -20,6 +20,7 @@ docs/
|
||||
briefings/ # Per-agent context briefings (maintained by Qatux)
|
||||
architecture/ # Technical architecture documents
|
||||
design/ # Game design documents
|
||||
diagrams/ # d2 source + PNG renders (architecture, data-flow, entity, state, ui)
|
||||
sprints/ # Sprint briefings per team (server.md, client.md, copy.md, joint.md, etc.)
|
||||
workshops/ # Workshop briefs and outputs (per-workshop subdirectories)
|
||||
db/
|
||||
@@ -35,7 +36,7 @@ db/
|
||||
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, 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
|
||||
perception.md # D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043-D-049, D-052, D-056-D-061, D-067, D-069-D-072, D-076-D-078
|
||||
content.md # D-023, D-024, D-025, D-028, D-029, D-032, D-034-D-037, D-050, D-062-D-064
|
||||
scope.md # D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065
|
||||
process.md # D-004, D-021, D-022
|
||||
@@ -132,12 +133,19 @@ tea issue list --login schweitz --repo jpmschweitzer/settled-reach --state open
|
||||
Key rules:
|
||||
- **All flags must be explicit** — omitting `--login` or `--repo` triggers interactive prompts that crash in Claude Code (no TTY)
|
||||
- **Use `--output simple`** for machine-readable output (no table borders)
|
||||
- **`tea comment` hangs with inline heredocs and multi-line strings.** Always write the comment body to a temp file first, then pass it via `$(cat)`:
|
||||
```bash
|
||||
# Step 1: Write content to .tmp/ (gitignored) using the Write tool
|
||||
# Step 2: Post via cat
|
||||
tea comment --login schweitz --repo jpmschweitzer/settled-reach <NUMBER> "$(cat .tmp/review-branch.md)"
|
||||
```
|
||||
- **`tea pr reject` does not work on your own PRs** — use `tea comment` instead
|
||||
- **Never delete protected branches:** `main`, `maintenance`, `server`, `client`, `copy`, `audio`, `visual`, `ci` are protected on Gitea. Do not use `tea pr clean`, `git push --delete`, or `git branch -D` on these branches.
|
||||
|
||||
### File conventions
|
||||
- Decisions: domain files in `decisions/` (see `decisions/README.md` for index)
|
||||
- Decision IDs: `D-NNN` (confirmed), `Q-NNN` (open questions), `R-NNN` (rejected)
|
||||
- Diagrams: `.d2` source + `.png` renders in `docs/diagrams/{category}/`. Create or update diagrams via `/d2-diagram` when D-records are added or modified.
|
||||
- Discussion rounds: numbered sequentially, archived to `docs/discussions/` when complete
|
||||
- Briefings: one per agent, updated after decision-producing rounds
|
||||
- Tickets: managed via `db/connectors/ticket` CLI or `/ticket` skill
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
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 \
|
||||
.PHONY: help setup build check-protocol 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 check-fact-ids setup-hooks \
|
||||
pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
|
||||
pre-pr-server pre-pr-client pre-pr-content \
|
||||
fixtures-client golden-diff golden-update \
|
||||
checklist-validate checklist-generate \
|
||||
perf-baseline
|
||||
perf-baseline debug-schedule
|
||||
|
||||
# --- Configuration ---
|
||||
|
||||
@@ -28,6 +28,7 @@ help:
|
||||
@echo " make ci Run full CI pipeline locally"
|
||||
@echo " make ci-client Run client CI checks"
|
||||
@echo " make ci-server Run server CI checks"
|
||||
@echo " make check-protocol Verify server/client protocol versions match"
|
||||
@echo " make clean Remove build artifacts and caches"
|
||||
@echo ""
|
||||
@echo " make db-backup Backup shared database to git (main only)"
|
||||
@@ -53,6 +54,7 @@ help:
|
||||
@echo " make pre-pr-content Content-scoped pre-PR (schema + cross-ref validation)"
|
||||
@echo ""
|
||||
@echo " make setup-hooks Install pre-commit hooks (included in setup)"
|
||||
@echo " make debug-schedule Print bevy_ecs schedule graph (diff for PR artifacts)"
|
||||
@echo ""
|
||||
@echo " GODOT_VERSION=4.6 make setup Override Godot version"
|
||||
|
||||
@@ -81,7 +83,16 @@ setup-hooks:
|
||||
|
||||
# --- Build ---
|
||||
|
||||
build: build-server build-client
|
||||
check-protocol:
|
||||
@SERVER_V=$$(grep 'pub const PROTOCOL_VERSION' server/src/bridge/types.rs | sed 's/.*= *//;s/[^0-9]//g'); \
|
||||
CLIENT_V=$$(grep 'const PROTOCOL_VERSION' client/scripts/protocol/protocol.gd | sed 's/.*= *//;s/[^0-9]//g'); \
|
||||
if [ "$$SERVER_V" != "$$CLIENT_V" ]; then \
|
||||
echo "ERROR: Protocol version mismatch — server=$$SERVER_V, client=$$CLIENT_V"; \
|
||||
echo " Fix: update client/scripts/protocol/protocol.gd to match server/src/bridge/types.rs"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
build: check-protocol build-server build-client
|
||||
|
||||
build-server:
|
||||
cd server && cargo build
|
||||
@@ -93,7 +104,7 @@ build-client:
|
||||
# --- Run ---
|
||||
|
||||
server:
|
||||
cd server && cargo run
|
||||
cd server && cargo run --bin settled-reach-server
|
||||
|
||||
client:
|
||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||
@@ -102,7 +113,7 @@ client:
|
||||
game: stop build
|
||||
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
|
||||
@echo "Starting server..."
|
||||
@cd server && cargo run &
|
||||
@cd server && cargo run --bin settled-reach-server &
|
||||
@sleep 2
|
||||
@echo "Starting client..."
|
||||
@SR_LIVE=1 $(GODOT) --path client
|
||||
@@ -285,6 +296,12 @@ checklist-generate:
|
||||
perf-baseline:
|
||||
@tooling/perf-baseline
|
||||
|
||||
# --- Schedule debug (#346) ---
|
||||
|
||||
debug-schedule:
|
||||
@echo "Dumping bevy_ecs schedule graph..."
|
||||
@cd server && cargo run --bin settled-reach-server -- --dump-schedule
|
||||
|
||||
content-ron:
|
||||
cd tooling/content-converter && cargo build --release
|
||||
tooling/content-converter/target/release/content-converter --input content --output content-ron --verbose
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://dfy0ye3srawos"
|
||||
path="res://.godot/imported/amb_bar_layer.ogg-8e32a9c679a33f27226744a176d7d405.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/amb_bar_layer.ogg"
|
||||
dest_files=["res://.godot/imported/amb_bar_layer.ogg-8e32a9c679a33f27226744a176d7d405.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://cne005dbmwt6d"
|
||||
path="res://.godot/imported/amb_corridor_layer.ogg-f14ba54010b129b65b0d248444331998.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/amb_corridor_layer.ogg"
|
||||
dest_files=["res://.godot/imported/amb_corridor_layer.ogg-f14ba54010b129b65b0d248444331998.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://b6ycjdvxpphfa"
|
||||
path="res://.godot/imported/amb_station_base.ogg-8056e0ae231edecc9ec0e49bb90136b7.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/amb_station_base.ogg"
|
||||
dest_files=["res://.godot/imported/amb_station_base.ogg-8056e0ae231edecc9ec0e49bb90136b7.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://dw2pokvg5d7v2"
|
||||
path="res://.godot/imported/amb_workplace_layer.ogg-580ba32198a3f6c782381c66f9520e63.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/amb_workplace_layer.ogg"
|
||||
dest_files=["res://.godot/imported/amb_workplace_layer.ogg-580ba32198a3f6c782381c66f9520e63.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://da81bx5y6iw87"
|
||||
path="res://.godot/imported/sfx_footstep_metal_run.ogg-0d448f29204d35d4133f5314a179d054.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/sfx_footstep_metal_run.ogg"
|
||||
dest_files=["res://.godot/imported/sfx_footstep_metal_run.ogg-0d448f29204d35d4133f5314a179d054.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://d14dx3q0qd483"
|
||||
path="res://.godot/imported/sfx_footstep_metal_walk.ogg-af30f14f9f80059ea65ad6e78234cc05.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/sfx_footstep_metal_walk.ogg"
|
||||
dest_files=["res://.godot/imported/sfx_footstep_metal_walk.ogg-af30f14f9f80059ea65ad6e78234cc05.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
[remap]
|
||||
|
||||
importer="oggvorbisstr"
|
||||
type="AudioStreamOggVorbis"
|
||||
uid="uid://cow7symyvpmal"
|
||||
path="res://.godot/imported/sfx_npc_murmur.ogg-bfd7592cfea1b592d89f107b6cd33838.oggvorbisstr"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/audio/sfx_npc_murmur.ogg"
|
||||
dest_files=["res://.godot/imported/sfx_npc_murmur.ogg-bfd7592cfea1b592d89f107b6cd33838.oggvorbisstr"]
|
||||
|
||||
[params]
|
||||
|
||||
loop=false
|
||||
loop_offset=0
|
||||
bpm=0
|
||||
beat_count=0
|
||||
bar_beats=4
|
||||
@@ -0,0 +1,36 @@
|
||||
[remap]
|
||||
|
||||
importer="font_data_dynamic"
|
||||
type="FontFile"
|
||||
uid="uid://qbg8rfnetlqn"
|
||||
path="res://.godot/imported/Michroma-Regular.ttf-928de7d8513fc5249047ef3681175fc2.fontdata"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/fonts/Michroma-Regular.ttf"
|
||||
dest_files=["res://.godot/imported/Michroma-Regular.ttf-928de7d8513fc5249047ef3681175fc2.fontdata"]
|
||||
|
||||
[params]
|
||||
|
||||
Rendering=null
|
||||
antialiasing=1
|
||||
generate_mipmaps=false
|
||||
disable_embedded_bitmaps=true
|
||||
multichannel_signed_distance_field=false
|
||||
msdf_pixel_range=8
|
||||
msdf_size=48
|
||||
allow_system_fallback=true
|
||||
force_autohinter=false
|
||||
modulate_color_glyphs=false
|
||||
hinting=1
|
||||
subpixel_positioning=4
|
||||
keep_rounding_remainders=true
|
||||
oversampling=0.0
|
||||
Fallbacks=null
|
||||
fallbacks=[]
|
||||
Compress=null
|
||||
compress=true
|
||||
preload=[]
|
||||
language_support={}
|
||||
script_support={}
|
||||
opentype_features={}
|
||||
@@ -0,0 +1,47 @@
|
||||
# Dialogue Log Theme — The Settled Reach
|
||||
#
|
||||
# Ticket: #535 | Sprint: 14
|
||||
# Colors and timing for the unified dialogue log panel.
|
||||
# Loaded by dialogue_box.gd at runtime.
|
||||
|
||||
# ============================================================
|
||||
# PLAYER COLOR
|
||||
# Fixed color for the player's name in dialogue log entries.
|
||||
# ============================================================
|
||||
player_color: "#e0e8ff"
|
||||
|
||||
# ============================================================
|
||||
# NPC COLOR PALETTE
|
||||
# 8 distinct colors for NPC names. Indexed by hash(npc_name) % 8.
|
||||
# Must be readable on a dark semi-transparent panel background.
|
||||
# ============================================================
|
||||
npc_colors:
|
||||
0: "#4a9ebb" # teal
|
||||
1: "#6bc9a6" # green
|
||||
2: "#e8c547" # amber
|
||||
3: "#d49e5d" # warm orange
|
||||
4: "#b586d4" # lavender
|
||||
5: "#d45d5d" # muted red
|
||||
6: "#5daa7d" # forest
|
||||
7: "#7daccc" # sky blue
|
||||
|
||||
# ============================================================
|
||||
# TEXT COLORS
|
||||
# Arrow separator and speech text color.
|
||||
# ============================================================
|
||||
arrow_color: "#8890a0"
|
||||
speech_color: "#c8d0e0"
|
||||
|
||||
# ============================================================
|
||||
# PASSIVE (OVERHEARD) OPACITY
|
||||
# Base opacity multiplier for non-player-centric lines.
|
||||
# 1.0 = full opacity, 0.0 = invisible.
|
||||
# ============================================================
|
||||
passive_opacity: 0.9
|
||||
|
||||
# ============================================================
|
||||
# ENTRY TIMING
|
||||
# All entry types share the same lifetime and fade duration.
|
||||
# ============================================================
|
||||
entry_lifetime_seconds: 45.0
|
||||
entry_fade_seconds: 5.0
|
||||
@@ -0,0 +1,43 @@
|
||||
[gd_resource type="AudioBusLayout" format=3]
|
||||
|
||||
; D-068: 5-bus audio architecture — Music, Ambient, WorldSFX, PlayerActions, UISounds.
|
||||
; All buses route to Master. Volumes managed at runtime by AudioManager autoload.
|
||||
; AudioManager._setup_buses() creates any missing buses on startup (no-op if present).
|
||||
|
||||
[resource]
|
||||
bus/0/name = "Master"
|
||||
bus/0/solo = false
|
||||
bus/0/mute = false
|
||||
bus/0/bypass_fx = false
|
||||
bus/0/volume_db = 0.0
|
||||
bus/0/send = &""
|
||||
bus/1/name = "Music"
|
||||
bus/1/solo = false
|
||||
bus/1/mute = false
|
||||
bus/1/bypass_fx = false
|
||||
bus/1/volume_db = 0.0
|
||||
bus/1/send = &"Master"
|
||||
bus/2/name = "Ambient"
|
||||
bus/2/solo = false
|
||||
bus/2/mute = false
|
||||
bus/2/bypass_fx = false
|
||||
bus/2/volume_db = 0.0
|
||||
bus/2/send = &"Master"
|
||||
bus/3/name = "WorldSFX"
|
||||
bus/3/solo = false
|
||||
bus/3/mute = false
|
||||
bus/3/bypass_fx = false
|
||||
bus/3/volume_db = 0.0
|
||||
bus/3/send = &"Master"
|
||||
bus/4/name = "PlayerActions"
|
||||
bus/4/solo = false
|
||||
bus/4/mute = false
|
||||
bus/4/bypass_fx = false
|
||||
bus/4/volume_db = 0.0
|
||||
bus/4/send = &"Master"
|
||||
bus/5/name = "UISounds"
|
||||
bus/5/solo = false
|
||||
bus/5/mute = false
|
||||
bus/5/bypass_fx = false
|
||||
bus/5/volume_db = 0.0
|
||||
bus/5/send = &"Master"
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="128" height="128" fill="#1a1a2e"/></svg>
|
||||
|
After Width: | Height: | Size: 119 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://caxeq5xayr0iy"
|
||||
path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://icon.svg"
|
||||
dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
@@ -24,6 +24,10 @@ UIStrings="*res://scripts/autoloads/ui_strings.gd"
|
||||
FogState="*res://scripts/autoloads/fog_state.gd"
|
||||
AudioManager="*res://scripts/autoloads/audio_manager.gd"
|
||||
|
||||
[audio]
|
||||
|
||||
buses/default_bus_layout="res://default_bus_layout.tres"
|
||||
|
||||
[gui]
|
||||
|
||||
theme/custom="res://assets/theme/game_theme.tres"
|
||||
@@ -116,6 +120,11 @@ bug_report={
|
||||
"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)
|
||||
]
|
||||
}
|
||||
debug_overlay={
|
||||
"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":4194334,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
teleport_hub={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194317,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
|
||||
+24
-1
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=20 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
[gd_scene load_steps=23 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"]
|
||||
@@ -16,9 +16,12 @@
|
||||
[ext_resource type="PackedScene" path="res://ui/world_radial.tscn" id="14_radial"]
|
||||
[ext_resource type="PackedScene" path="res://ui/dialogue_box.tscn" id="15_dialogue"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/fog_entities.gd" id="16_fogent"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/sound_indicator_renderer.gd" id="20_soundind"]
|
||||
[ext_resource type="PackedScene" path="res://ui/gauntlet_hud.tscn" id="17_gauntlet"]
|
||||
[ext_resource type="PackedScene" path="res://ui/checklist_overlay.tscn" id="18_checklist"]
|
||||
[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"]
|
||||
[ext_resource type="PackedScene" path="res://ui/settings_dialog.tscn" id="21_settings"]
|
||||
[ext_resource type="Script" path="res://scripts/ui/debug_overlay.gd" id="22_debug"]
|
||||
|
||||
[node name="Game" type="Node2D"]
|
||||
script = ExtResource("1_main")
|
||||
@@ -96,6 +99,13 @@ script = ExtResource("4_fog")
|
||||
z_index = 950
|
||||
script = ExtResource("16_fogent")
|
||||
|
||||
; --- z:951 — Medium-range sound indicators (#126, D-018) ---
|
||||
; Directional arrows at fog boundary for sounds outside LOS.
|
||||
; Above FogEntities (z:950), below InsertOverlay (CanvasLayer 10).
|
||||
[node name="SoundIndicators" type="Node2D" parent="World"]
|
||||
z_index = 951
|
||||
script = ExtResource("20_soundind")
|
||||
|
||||
; --- Camera ---
|
||||
[node name="Camera2D" type="Camera2D" parent="."]
|
||||
position_smoothing_enabled = true
|
||||
@@ -143,6 +153,16 @@ layer = 20
|
||||
; D-065: Inventory grid — 3x3, bottom-right, 40x40px, 1-9 hotkeys
|
||||
[node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")]
|
||||
|
||||
; #511: F3 debug overlay — real-time game state, toggled by F3
|
||||
[node name="DebugOverlay" type="Control" parent="UILayer"]
|
||||
anchors_preset = 0
|
||||
offset_left = 16
|
||||
offset_top = 120
|
||||
offset_right = 400
|
||||
offset_bottom = 400
|
||||
mouse_filter = 2
|
||||
script = ExtResource("22_debug")
|
||||
|
||||
; D-056: Cursor state machine — insert-styled geometric cursor, topmost in UILayer
|
||||
[node name="CursorRenderer" type="Node2D" parent="UILayer"]
|
||||
script = ExtResource("10_cursor")
|
||||
@@ -154,3 +174,6 @@ layer = 30
|
||||
|
||||
; #495: WRONG button (F12) — bug report capture dialog
|
||||
[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("19_bugreport")]
|
||||
|
||||
; #528: Audio settings dialog — 5-bus volume sliders, ESC/OPEN_MENU to toggle
|
||||
[node name="SettingsDialog" parent="ModalLayer" instance=ExtResource("21_settings")]
|
||||
|
||||
@@ -5,6 +5,11 @@ extends Node
|
||||
## No-op fallback when audio assets absent (D-038).
|
||||
## Spatial audio positioning for close-range sounds (D-018).
|
||||
|
||||
# --- D-067: Recognition chime asset key ---
|
||||
# Fires on first fog recognition (cognitive delay onset). UISounds bus (not WorldSFX).
|
||||
# Matches sfx_monologue_chime.ogg from D-038 — "neural lattice firing" feel.
|
||||
const CHIME_RECOGNITION := "sfx_monologue_chime"
|
||||
|
||||
# --- Bus names (D-068) ---
|
||||
const BUS_MUSIC := "Music"
|
||||
const BUS_AMBIENT := "Ambient"
|
||||
@@ -36,6 +41,23 @@ const DIP_SPECS := {
|
||||
# Low-pass filter default cutoff — effectively bypassed at this value.
|
||||
const FILTER_CUTOFF_DEFAULT := 20500.0
|
||||
|
||||
# --- D-073: Zone crossfade constants ---
|
||||
const CROSSFADE_DURATION := 1.8 # D-073: 1.5-2s spec, mid-range
|
||||
|
||||
# Maps server zone_id strings to ambient asset keys (filenames in res://assets/audio/).
|
||||
# Hub and Workplace intentionally share the same ambient layer (amb_hub_layer) —
|
||||
# they are the same location type, so hub→workplace transition is a same-asset no-op
|
||||
# (old_asset != new_asset guard skips the fade-out). Sprint brief consolidates
|
||||
# D-038's "amb_workplace_layer" to "amb_hub_layer" for v0.1.
|
||||
# Note: amb_station_base (D-038 global base hum) plays globally via play_loop()
|
||||
# at startup — it is not zone-dependent and has no ZONE_ASSETS entry.
|
||||
const ZONE_ASSETS: Dictionary = {
|
||||
"hub": "amb_hub_layer",
|
||||
"workplace": "amb_hub_layer",
|
||||
"bar": "amb_bar_layer",
|
||||
"corridor": "amb_corridor_layer",
|
||||
}
|
||||
|
||||
# Asset registry: filename stem (e.g. "amb_station_base") → AudioStream
|
||||
var _registry: Dictionary = {}
|
||||
|
||||
@@ -52,12 +74,19 @@ var _ambient_filter: AudioEffectLowPassFilter = null
|
||||
# Ambient loop players keyed by asset_key (D-073 zone crossfade)
|
||||
var _ambient_players: Dictionary = {}
|
||||
|
||||
# D-073: Zone crossfade state
|
||||
var _current_zone_id: String = ""
|
||||
var _zone_tweens: Array = []
|
||||
|
||||
signal dip_changed(profile: String)
|
||||
|
||||
|
||||
const PREFS_PATH := "user://audio_prefs.cfg"
|
||||
|
||||
func _ready() -> void:
|
||||
_setup_buses()
|
||||
_scan_registry()
|
||||
_load_prefs()
|
||||
|
||||
|
||||
# --- Bus setup ---
|
||||
@@ -83,15 +112,15 @@ func _setup_buses() -> void:
|
||||
# --- Asset registry (D-068 directory-scan pattern) ---
|
||||
|
||||
func _scan_registry() -> void:
|
||||
_scan_dir("res://audio/")
|
||||
_scan_dir("res://assets/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")
|
||||
if path == "res://assets/audio/":
|
||||
print("AudioManager: res://assets/audio/ not found — all play methods no-op")
|
||||
return
|
||||
dir.list_dir_begin()
|
||||
var file_name := dir.get_next()
|
||||
@@ -105,6 +134,7 @@ func _scan_dir(path: String) -> void:
|
||||
if stream:
|
||||
_registry[file_name.get_basename()] = stream
|
||||
file_name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
|
||||
|
||||
func has_asset(asset_key: String) -> bool:
|
||||
@@ -137,6 +167,8 @@ func play_loop(asset_key: String, bus: String = BUS_AMBIENT) -> AudioStreamPlaye
|
||||
return null
|
||||
stop_loop(asset_key)
|
||||
var loop_stream := stream.duplicate() as AudioStream
|
||||
if loop_stream == null:
|
||||
return null
|
||||
_enable_loop(loop_stream)
|
||||
var player := AudioStreamPlayer.new()
|
||||
player.stream = loop_stream
|
||||
@@ -163,6 +195,32 @@ func stop_all_loops() -> void:
|
||||
stop_loop(key)
|
||||
|
||||
|
||||
# --- Audio asset registry: event type → asset key (D-018, #125) ---
|
||||
# Maps server-sent sound event_type strings to audio asset keys.
|
||||
# Keys match filename stems in res://assets/audio/ (scanned by _scan_registry).
|
||||
# Audio assets per D-038: footstep variants (walk / run), NPC murmur (D-072, #532).
|
||||
# Missing assets no-op gracefully (D-038 fallback pattern).
|
||||
const SOUND_EVENT_ASSETS: Dictionary = {
|
||||
"Footstep": "sfx_footstep_metal_walk",
|
||||
"FootstepWalk": "sfx_footstep_metal_walk",
|
||||
"FootstepCareful":"sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
|
||||
"FootstepCrouch": "sfx_footstep_metal_walk", # D-053: same asset until stance-differentiated audio lands
|
||||
"FootstepSprint": "sfx_footstep_metal_run",
|
||||
"FootstepRun": "sfx_footstep_metal_run",
|
||||
}
|
||||
|
||||
|
||||
## Play a close-range sound event at a world tile position (D-018, #125).
|
||||
## event_type: server RangeCategory::Close event type string (e.g. "Footstep").
|
||||
## world_tile_pos: server tile coordinates — converted to world pixels internally.
|
||||
## No-ops if event_type has no registered asset or asset file is absent.
|
||||
func play_sound_event(event_type: String, world_tile_pos: Vector2) -> void:
|
||||
var asset_key: String = SOUND_EVENT_ASSETS.get(event_type, "")
|
||||
if asset_key.is_empty():
|
||||
return
|
||||
play_at(asset_key, world_tile_pos * Constants.TILE_SIZE)
|
||||
|
||||
|
||||
# --- Playback: spatial (D-018 close-range) ---
|
||||
|
||||
## Play a one-shot spatial sound at a world position (pixels).
|
||||
@@ -270,19 +328,85 @@ func set_volume(bus: String, volume_db: float) -> void:
|
||||
var buses: Dictionary = spec.get("buses", {})
|
||||
var offset_db: float = buses.get(bus, 0.0)
|
||||
AudioServer.set_bus_volume_db(idx, volume_db + offset_db)
|
||||
_save_prefs()
|
||||
|
||||
|
||||
func get_volume(bus: String) -> float:
|
||||
return _bus_volumes.get(bus, 0.0)
|
||||
|
||||
|
||||
# --- Zone crossfade (D-073 stub) ---
|
||||
# --- Volume persistence (user://audio_prefs.cfg) ---
|
||||
|
||||
func _load_prefs() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
if cfg.load(PREFS_PATH) != OK:
|
||||
return
|
||||
for bus_name in BUSES:
|
||||
if cfg.has_section_key("audio", bus_name):
|
||||
var db: float = cfg.get_value("audio", bus_name, 0.0)
|
||||
# Apply directly: bypass _save_prefs() on initial load.
|
||||
_bus_volumes[bus_name] = db
|
||||
var idx := AudioServer.get_bus_index(bus_name)
|
||||
if idx >= 0:
|
||||
AudioServer.set_bus_volume_db(idx, db)
|
||||
|
||||
|
||||
func _save_prefs() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
for bus_name in BUSES:
|
||||
cfg.set_value("audio", bus_name, _bus_volumes.get(bus_name, 0.0))
|
||||
var err := cfg.save(PREFS_PATH)
|
||||
if err != OK:
|
||||
push_warning("AudioManager: failed to save prefs to %s (error %d)" % [PREFS_PATH, err])
|
||||
|
||||
|
||||
# --- Zone crossfade (D-073) ---
|
||||
|
||||
## Handle zone transition. Server sends zone_id per tile in ObserverSnapshot.
|
||||
## 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
|
||||
## Hard boundary trigger with 1.5-2s audio crossfade between ambient layers.
|
||||
## Interruptible — mid-crossfade zone change tweens from current position.
|
||||
## No-op if assets absent (D-038) or same zone.
|
||||
func set_zone(zone_id: String) -> void:
|
||||
if zone_id == _current_zone_id:
|
||||
return
|
||||
var new_asset: String = ZONE_ASSETS.get(zone_id, "")
|
||||
var old_asset: String = ZONE_ASSETS.get(_current_zone_id, "")
|
||||
_current_zone_id = zone_id
|
||||
_kill_zone_tweens()
|
||||
|
||||
# Fade out old ambient layer (if different asset from incoming zone)
|
||||
if not old_asset.is_empty() and old_asset != new_asset:
|
||||
if _ambient_players.has(old_asset):
|
||||
var old_player: AudioStreamPlayer = _ambient_players[old_asset]
|
||||
if is_instance_valid(old_player):
|
||||
var tween := create_tween()
|
||||
tween.tween_property(old_player, "volume_db", -80.0, CROSSFADE_DURATION)
|
||||
tween.tween_callback(stop_loop.bind(old_asset))
|
||||
_zone_tweens.append(tween)
|
||||
|
||||
# Fade in new ambient layer
|
||||
if not new_asset.is_empty():
|
||||
if _ambient_players.has(new_asset):
|
||||
# Already playing (interrupted reverse crossfade) — tween from current volume
|
||||
var existing: AudioStreamPlayer = _ambient_players[new_asset]
|
||||
if is_instance_valid(existing):
|
||||
var tween := create_tween()
|
||||
tween.tween_property(existing, "volume_db", 0.0, CROSSFADE_DURATION)
|
||||
_zone_tweens.append(tween)
|
||||
elif has_asset(new_asset):
|
||||
var new_player := play_loop(new_asset, BUS_AMBIENT)
|
||||
if new_player:
|
||||
new_player.volume_db = -80.0
|
||||
var tween := create_tween()
|
||||
tween.tween_property(new_player, "volume_db", 0.0, CROSSFADE_DURATION)
|
||||
_zone_tweens.append(tween)
|
||||
|
||||
|
||||
func _kill_zone_tweens() -> void:
|
||||
for tween in _zone_tweens:
|
||||
if tween != null and tween.is_valid():
|
||||
tween.kill()
|
||||
_zone_tweens.clear()
|
||||
|
||||
|
||||
# --- Internal helpers ---
|
||||
|
||||
@@ -19,11 +19,20 @@ var visibility_sectors: Dictionary = {} # Vector2i -> "Forward"/"Peripheral"
|
||||
# refined when the server assigns explicit player entity IDs).
|
||||
var player_entity_id: int = 1
|
||||
|
||||
# #241: Follow target — entity_id of the NPC the player is following, -1 when not following.
|
||||
# Stub for server ticket #241 (Follow verb). Client reads this for camera/UI behavior.
|
||||
var follow_target_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
|
||||
var current_monologue: Variant = null # {id, text, duration_seconds, priority, is_urgent} or null
|
||||
|
||||
# #122 (D-032): Character lattice profile — selects monologue text colour palette.
|
||||
# "lattice_augmented" = detective, "lattice_baseline" = smuggler.
|
||||
# Server sends this field as part of the player's capability snapshot.
|
||||
var lattice_profile: String = "lattice_baseline"
|
||||
|
||||
# v6 fields (#449, D-053, D-065)
|
||||
var player_stance: String = "Walk" # Sprint/Walk/Careful/Crouch
|
||||
@@ -40,9 +49,46 @@ var dialogue_active: bool = false
|
||||
var room_id: Variant = null # String room_id from snapshot, null in non-gauntlet mode
|
||||
var gauntlet_mode: bool = false # true when snapshot includes gauntlet_mode flag
|
||||
|
||||
# OQ-07 (#522): Insert active state — false suppresses verb labels (z-layer 6).
|
||||
# Cursor shape changes still fire when false (D-056 option a).
|
||||
# v0.1 assumption: always true — both playable characters (detective and smuggler)
|
||||
# have neural inserts. Future characters without inserts would receive false from
|
||||
# the server's "insert_active" snapshot field, disabling all z-layer-6 UI.
|
||||
var insert_active: bool = true
|
||||
|
||||
# #507: RNG seed for replay determinism — populated from snapshot "rng_seed" field.
|
||||
# Null in v0.1 (server does not yet send this field; protocol change required).
|
||||
var rng_seed: Variant = null
|
||||
|
||||
# v7 fields (#431, D-059/D-060)
|
||||
var pending_recognitions: Array = [] # [{entity_id, x, y, z, remaining_ticks, total_delay_ticks}]
|
||||
|
||||
# v8 fields (#305, D-028): NPC follow-up after player dialogue choice
|
||||
var dialogue_response: Variant = null # {line_id, text, speaker_entity_id}
|
||||
|
||||
# v9 fields (#535, D-078): Overheard NPC-to-NPC conversations
|
||||
var conversation_events: Array = [] # [{speaker_id, target_id, speaker_name, target_name, occluded_line}]
|
||||
var conversation_ended: Array = [] # [{speaker_id, target_id}]
|
||||
|
||||
# #126, D-018: Medium-range sound events for fog-edge directional indicators.
|
||||
# Format: [{x, y, event_type, range_category}] — server sends current medium events per tick.
|
||||
var medium_sound_events: Array = []
|
||||
|
||||
# #125, D-018: Close-range sound events for positional 2D audio.
|
||||
# Format: [{x, y, event_type, range_category}] — consumed once per tick in main.gd.
|
||||
var close_sound_events: Array = []
|
||||
|
||||
# D-071 (#530): Consecutive ticks without player position change.
|
||||
# Incremented per snapshot in apply_snapshot(). Reset to 0 on movement.
|
||||
# ListeningFocus boost activates at 30+ ticks (main.gd manages the dip).
|
||||
var stationary_ticks: int = 0
|
||||
var _prev_player_position: Vector2 = Vector2(-1e9, -1e9) # sentinel: no previous position
|
||||
|
||||
# D-073 (#529): Server-authoritative zone_id from the player's current tile.
|
||||
# Extracted in apply_snapshot() — avoids O(N) tile scan in main.gd per Tyre review.
|
||||
# Empty string when zone_id field absent (server hasn't shipped OQ-09 yet).
|
||||
var current_zone_id: String = ""
|
||||
|
||||
func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
current_snapshot = snapshot
|
||||
|
||||
@@ -64,6 +110,14 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
push_warning("GameState: no Player entity found in %d entities" % [
|
||||
visible_entities.size()])
|
||||
|
||||
# D-071 (#530): Track consecutive stationary ticks for ListeningFocus boost.
|
||||
# Compares current player_position against previous snapshot's position.
|
||||
if player_position == _prev_player_position:
|
||||
stationary_ticks += 1
|
||||
else:
|
||||
stationary_ticks = 0
|
||||
_prev_player_position = player_position
|
||||
|
||||
# Tiles for rendering: test mode sends "tiles", live server sends tile data in "visible_tiles"
|
||||
if snapshot.has("tiles"):
|
||||
visible_tiles = snapshot.tiles
|
||||
@@ -100,6 +154,10 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
else:
|
||||
current_monologue = null
|
||||
|
||||
# #122: lattice_profile — character insert capability level for monologue colour
|
||||
if snapshot.has("lattice_profile") and snapshot.lattice_profile is String:
|
||||
lattice_profile = snapshot.lattice_profile
|
||||
|
||||
# v6: player_stance (#449, D-053)
|
||||
if snapshot.has("player_stance") and snapshot.player_stance is String:
|
||||
player_stance = snapshot.player_stance
|
||||
@@ -122,6 +180,24 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
else:
|
||||
pending_recognitions = []
|
||||
|
||||
# v9: conversation_events (#535, D-078) — overheard NPC-to-NPC lines
|
||||
if snapshot.has("conversation_events") and snapshot.conversation_events is Array:
|
||||
conversation_events = snapshot.conversation_events
|
||||
else:
|
||||
conversation_events = []
|
||||
|
||||
# v9: conversation_ended (#535, D-078) — pairs whose conversation ended
|
||||
if snapshot.has("conversation_ended") and snapshot.conversation_ended is Array:
|
||||
conversation_ended = snapshot.conversation_ended
|
||||
else:
|
||||
conversation_ended = []
|
||||
|
||||
# v8: dialogue_response (#305, D-028) — NPC follow-up after player choice
|
||||
if snapshot.has("dialogue_response") and snapshot.dialogue_response is Dictionary:
|
||||
dialogue_response = snapshot.dialogue_response
|
||||
else:
|
||||
dialogue_response = null
|
||||
|
||||
# v8: gauntlet mode (#496) — room_id and gauntlet_mode
|
||||
if snapshot.has("gauntlet_mode") and snapshot.gauntlet_mode == true:
|
||||
gauntlet_mode = true
|
||||
@@ -132,6 +208,49 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
else:
|
||||
room_id = null
|
||||
|
||||
# OQ-07 (#522): insert_active — defaults true (v0.1 always has insert).
|
||||
# Server may send false for characters without an insert in future sprints.
|
||||
if snapshot.has("insert_active") and snapshot.insert_active is bool:
|
||||
insert_active = snapshot.insert_active
|
||||
else:
|
||||
insert_active = true
|
||||
|
||||
# #507: rng_seed — server sends current RNG seed for replay determinism.
|
||||
# Field: "rng_seed" (u64 as integer). Null if server does not include it.
|
||||
if snapshot.has("rng_seed"):
|
||||
rng_seed = snapshot.rng_seed
|
||||
else:
|
||||
rng_seed = null
|
||||
|
||||
# D-018: Sound events from server — partition by range_category.
|
||||
# #126: Medium → fog-edge directional indicators.
|
||||
# #125: Close → positional 2D audio via AudioManager.
|
||||
if snapshot.has("sound_events") and snapshot.sound_events is Array:
|
||||
medium_sound_events = []
|
||||
close_sound_events = []
|
||||
for se in snapshot.sound_events:
|
||||
if not se is Dictionary:
|
||||
continue
|
||||
var rc: String = se.get("range_category", "")
|
||||
if rc == "Medium":
|
||||
medium_sound_events.append(se)
|
||||
elif rc == "Close":
|
||||
close_sound_events.append(se)
|
||||
else:
|
||||
medium_sound_events = []
|
||||
close_sound_events = []
|
||||
|
||||
# D-073 (#529): Extract zone_id from the player's current tile (server-authoritative).
|
||||
# O(1) via visible_positions dict would be ideal, but tiles are arrays without
|
||||
# positional indexing — use the same tile iteration below instead.
|
||||
current_zone_id = ""
|
||||
var _px := int(player_position.x)
|
||||
var _py := int(player_position.y)
|
||||
for _ztile in visible_tiles:
|
||||
if _ztile is Dictionary and _ztile.get("x") == _px and _ztile.get("y") == _py:
|
||||
current_zone_id = _ztile.get("zone_id", "")
|
||||
break
|
||||
|
||||
# v2: visible_tiles with visibility sectors
|
||||
# Derives visible_positions when not explicitly provided (real server mode)
|
||||
if snapshot.has("visible_tiles") and snapshot.visible_tiles is Array and snapshot.visible_tiles.size() > 0:
|
||||
|
||||
@@ -179,7 +179,7 @@ func send_input(player_input: Dictionary) -> Error:
|
||||
return ERR_CONNECTION_ERROR
|
||||
if test_mode:
|
||||
var action: int = player_input.get("action", -1)
|
||||
var wire_name: String = _action_enum_to_wire(action)
|
||||
var wire_name: String = action_enum_to_wire(action)
|
||||
if not wire_name.is_empty():
|
||||
if wire_name == "SetFacing":
|
||||
# D-054: Use action_data.facing from the input dict, not InputMapper global
|
||||
@@ -192,9 +192,9 @@ func send_input(player_input: Dictionary) -> Error:
|
||||
else:
|
||||
_test_input_queue.append(wire_name)
|
||||
return OK
|
||||
var action_name := _action_enum_to_wire(player_input.get("action", -1))
|
||||
var action_name := action_enum_to_wire(player_input.get("action", -1))
|
||||
if action_name.is_empty():
|
||||
# _action_enum_to_wire already emits push_warning for invalid actions
|
||||
# action_enum_to_wire already emits push_warning for invalid actions
|
||||
return ERR_INVALID_PARAMETER
|
||||
# Use the server's current tick so drain_for_tick processes this input immediately.
|
||||
# The client-side timestamp_msec is only useful for ordering within a frame.
|
||||
@@ -241,6 +241,17 @@ func receive_bytes(bytes: PackedByteArray) -> void:
|
||||
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"]
|
||||
# #535: Carry forward one-shot dialogue events (arrays merge, scalar falls through)
|
||||
if snapshot.get("dialogue_response") == null and _last_snapshot.get("dialogue_response") != null:
|
||||
snapshot["dialogue_response"] = _last_snapshot["dialogue_response"]
|
||||
var old_conv_events: Array = _last_snapshot.get("conversation_events", [])
|
||||
if old_conv_events.size() > 0:
|
||||
var new_conv_events: Array = snapshot.get("conversation_events", [])
|
||||
snapshot["conversation_events"] = old_conv_events + new_conv_events
|
||||
var old_conv_ended: Array = _last_snapshot.get("conversation_ended", [])
|
||||
if old_conv_ended.size() > 0:
|
||||
var new_conv_ended: Array = snapshot.get("conversation_ended", [])
|
||||
snapshot["conversation_ended"] = old_conv_ended + new_conv_ended
|
||||
_last_snapshot = snapshot
|
||||
|
||||
# Drain the outbound buffer. Returns raw input entries for batch encoding.
|
||||
@@ -251,7 +262,7 @@ func drain_outbound() -> Array[Dictionary]:
|
||||
|
||||
# Map InputMapper.Action enum values to wire-format action names (matching Rust PlayerAction).
|
||||
# OPEN_MENU is client-only — no Rust equivalent, not sent over the wire.
|
||||
static func _action_enum_to_wire(action: int) -> String:
|
||||
static func action_enum_to_wire(action: int) -> String:
|
||||
match action:
|
||||
InputMapper.Action.MOVE_NORTH: return "MoveNorth"
|
||||
InputMapper.Action.MOVE_NORTHEAST: return "MoveNortheast"
|
||||
@@ -396,6 +407,37 @@ func _test_snapshot() -> Dictionary:
|
||||
"total_delay_ticks": total_delay,
|
||||
})
|
||||
|
||||
# #535: Mock overheard NPC-NPC conversation (D-078)
|
||||
# Two NPCs (Mira and Soren) trade lines every 5 ticks starting at tick 3.
|
||||
# Conversation ends after 6 exchanges (~30 ticks).
|
||||
var conv_events: Array = []
|
||||
var conv_ended: Array = []
|
||||
var conv_start := 3
|
||||
var conv_lines := [
|
||||
{"speaker": "Mira", "target": "Soren", "line": "The cargo manifests don't add up. Three containers unaccounted for."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "Could be a logging error. Happens every... cycle."},
|
||||
{"speaker": "Mira", "target": "Soren", "line": "Not like this. Someone moved them after... check."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "You're reading too much into it. The docks are... these days."},
|
||||
{"speaker": "Mira", "target": "Soren", "line": "Then explain the weight discrepancy. Two hundred kilos... just gone."},
|
||||
{"speaker": "Soren", "target": "Mira", "line": "Fine. I'll pull the bay... tonight. But keep this between us."},
|
||||
]
|
||||
var conv_tick_interval := 5
|
||||
var conv_total_ticks := conv_lines.size() * conv_tick_interval
|
||||
if _test_tick >= conv_start and _test_tick < conv_start + conv_total_ticks:
|
||||
var conv_index := (_test_tick - conv_start) / conv_tick_interval
|
||||
var within_tick := (_test_tick - conv_start) % conv_tick_interval
|
||||
if within_tick == 0 and conv_index < conv_lines.size():
|
||||
var cl: Dictionary = conv_lines[conv_index]
|
||||
conv_events.append({
|
||||
"speaker_id": 10,
|
||||
"target_id": 11,
|
||||
"speaker_name": cl.speaker,
|
||||
"target_name": cl.target,
|
||||
"occluded_line": cl.line,
|
||||
})
|
||||
elif _test_tick == conv_start + conv_total_ticks:
|
||||
conv_ended.append({"speaker_id": 10, "target_id": 11})
|
||||
|
||||
return {
|
||||
"tick": _test_tick,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
@@ -417,6 +459,8 @@ func _test_snapshot() -> Dictionary:
|
||||
"current_dialogue": dialogue,
|
||||
"pending_recognitions": pending_recs,
|
||||
"gauntlet_mode": _test_gauntlet_mode,
|
||||
"conversation_events": conv_events,
|
||||
"conversation_ended": conv_ended,
|
||||
}
|
||||
|
||||
# Generate a small test room: 8x6 room with walls, a door, and floor
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://rqfw0ycyb4c6
|
||||
@@ -90,6 +90,20 @@ const PERIPHERAL_ALPHA: float = 0.5
|
||||
const FACING_INDICATOR_SIZE: float = 6.0
|
||||
const FACING_INDICATOR_OFFSET: float = 14.0
|
||||
|
||||
# D-076 (OQ-29 resolution): Dialogue box max-width in pixels.
|
||||
# Raised from D-076 default (640px) to 1200px for readability.
|
||||
# Tyre architecture review 2026-02-19: readability over max-width; fits
|
||||
# two columns of text comfortably, leaves world game visible alongside.
|
||||
const DIALOGUE_MAX_WIDTH: int = 1200
|
||||
|
||||
# Default camera zoom — used as fallback when get_camera_2d() returns null
|
||||
const CAMERA_DEFAULT_ZOOM: Vector2 = Vector2(2.0, 2.0)
|
||||
|
||||
# #117: Camera smoothing speed — exponential interpolation via manual lerp in main.gd.
|
||||
# Same pattern as EntityRenderer.LERP_SPEED. At 8.0: ~55% convergence after 0.1s.
|
||||
# Slightly softer than entity movement (12.0) for a touch of cinematic camera lag.
|
||||
const CAMERA_SMOOTHING_SPEED: float = 8.0
|
||||
|
||||
# #517: Implant UI font color grading — avoid pure white, project through a lens
|
||||
const IMPLANT_TEXT_COLOR: Color = Color("#E0F7FA") # Cyan-white — primary text
|
||||
const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — secondary text
|
||||
|
||||
+202
-32
@@ -14,25 +14,31 @@ extends Node2D
|
||||
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
|
||||
@onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests
|
||||
@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress
|
||||
@onready var debug_overlay = $UILayer/DebugOverlay # #511: F3 debug overlay
|
||||
@onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button
|
||||
@onready var settings_dialog = $ModalLayer/SettingsDialog # #528: audio settings (ESC/OPEN_MENU)
|
||||
|
||||
var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input
|
||||
var _last_dialogue_npc_name: String = "" # #535: NPC name for dialogue_response attribution
|
||||
var _camera_anchored: bool = false
|
||||
var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice
|
||||
var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice
|
||||
var _last_dialogue_tick: int = -1
|
||||
var _last_confrontation_tick: int = -1 # Deduplicate confrontation_monologue signals within same tick
|
||||
var _known_recognition_ids: Dictionary = {} # D-067: entity_ids that have already chimed
|
||||
var _flash_rect: ColorRect = null # #502/#501: ephemeral screen flash overlay (shared: teleport preempts amber)
|
||||
var _teleport_in_progress: bool = false # #501: defer smoothing re-enable by one frame after teleport
|
||||
var _teleport_in_progress: bool = false # #501/#117: forces camera snap (not lerp) on next _process frame
|
||||
var _pending_record_inputs: Array = [] # #507: accumulates server-bound inputs across frames; flushed into record_tick() on snapshot arrival
|
||||
var _current_zone: String = "" # D-073 (#529): zone tracking for ambient crossfades
|
||||
|
||||
const LISTENING_FOCUS_TICKS: int = 30 # D-071: stationary ticks before ListeningFocus boost activates
|
||||
|
||||
func _ready() -> void:
|
||||
print("The Settled Reach — client initialized")
|
||||
|
||||
# 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.
|
||||
# #117: Manual lerp approach — disable Godot's built-in Camera2D smoothing.
|
||||
# We lerp camera.global_position directly in _process() using CAMERA_SMOOTHING_SPEED,
|
||||
# matching entity_renderer.gd's exponential smoothing pattern. Built-in smoothing
|
||||
# would conflict because we'd be setting global_position to the target every frame.
|
||||
camera.position_smoothing_enabled = false
|
||||
|
||||
# Connect to simulation (test mode sets CONNECTED immediately)
|
||||
@@ -41,7 +47,7 @@ func _ready() -> void:
|
||||
# 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.
|
||||
# handles it via the lerp block in _process().
|
||||
var first_snapshot: Variant = SimBridge.poll_snapshot()
|
||||
if first_snapshot != null:
|
||||
GameState.apply_snapshot(first_snapshot)
|
||||
@@ -53,13 +59,15 @@ func _ready() -> void:
|
||||
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)
|
||||
dialogue_box.pause_requested.connect(_on_dialogue_pause_requested)
|
||||
dialogue_box.unpause_requested.connect(_on_dialogue_unpause_requested)
|
||||
|
||||
# #496: Print gauntlet session summary on disconnect
|
||||
if gauntlet_hud:
|
||||
SimBridge.connection_state_changed.connect(_on_connection_state_changed)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
func _process(delta: float) -> void:
|
||||
# Main game loop: poll snapshot, apply state, flush input
|
||||
var snapshot: Variant = SimBridge.poll_snapshot()
|
||||
if snapshot != null:
|
||||
@@ -81,6 +89,16 @@ func _process(_delta: float) -> void:
|
||||
if world_renderer and world_renderer.has_method("update_from_state"):
|
||||
world_renderer.update_from_state()
|
||||
|
||||
# OQ-07 (#522): propagate insert state to all z-layer-6 display nodes.
|
||||
# Cursor shape still fires (D-056 option a) — only verb labels suppressed.
|
||||
var insert_state := GameState.insert_active
|
||||
if cursor_renderer and cursor_renderer.has_method("set_insert_active"):
|
||||
cursor_renderer.set_insert_active(insert_state)
|
||||
if interaction_list and interaction_list.has_method("set_insert_active"):
|
||||
interaction_list.set_insert_active(insert_state)
|
||||
if interaction_prompt and interaction_prompt.has_method("set_insert_active"):
|
||||
interaction_prompt.set_insert_active(insert_state)
|
||||
|
||||
# D-057: Update interaction list from game state
|
||||
# Suppress during dialogue — player is in conversation, verb list is noise
|
||||
if interaction_list and interaction_list.has_method("update_from_state"):
|
||||
@@ -102,6 +120,9 @@ func _process(_delta: float) -> void:
|
||||
if fog_entities and fog_entities.has_method("update_from_state"):
|
||||
fog_entities.update_from_state()
|
||||
|
||||
# D-067: Recognition chime — fire sfx_monologue_chime on first fog recognition
|
||||
_play_recognition_chimes()
|
||||
|
||||
# #496: Update gauntlet HUD (room timer + personal bests)
|
||||
if gauntlet_hud and gauntlet_hud.has_method("update_from_state"):
|
||||
gauntlet_hud.update_from_state()
|
||||
@@ -110,31 +131,47 @@ func _process(_delta: float) -> void:
|
||||
if checklist_overlay and checklist_overlay.has_method("update_from_state"):
|
||||
checklist_overlay.update_from_state()
|
||||
|
||||
# #511: Update debug overlay (F3 toggle, dev tool)
|
||||
if debug_overlay and debug_overlay.has_method("update_from_state"):
|
||||
debug_overlay.update_from_state()
|
||||
|
||||
# D-018 #125: Play close-range sound events via positional 2D audio
|
||||
_play_close_sound_events()
|
||||
|
||||
# D-073 (#529): Zone ambient crossfade — detect player tile zone, trigger set_zone on change.
|
||||
_update_zone()
|
||||
|
||||
# D-071 (#530): ListeningFocus boost — stationary 30+ ticks boosts WorldSFX.
|
||||
# Only activates when no dialogue/confrontation dip is active (D-070).
|
||||
_update_listening_focus()
|
||||
|
||||
# Show monologue if server sent one this tick (#414)
|
||||
_consume_monologue()
|
||||
|
||||
# D-061: Show dialogue if server sent one this tick (#434)
|
||||
_consume_dialogue()
|
||||
|
||||
# Track camera to player position every frame (D-015: locked, no panning)
|
||||
if _camera_anchored:
|
||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
||||
# #535: Consume overheard conversation events and responses
|
||||
_consume_conversation_events()
|
||||
_consume_conversation_ended()
|
||||
_consume_dialogue_response()
|
||||
|
||||
# Re-enable smoothing after the first anchored frame. The frame that just
|
||||
# rendered used smoothing=OFF (correct viewport from frame one). Now we
|
||||
# turn smoothing back on and sync its internal state so subsequent frames
|
||||
# get smooth camera tracking during gameplay.
|
||||
# #501: Skip re-enable during teleport — _teleport_transition() disables
|
||||
# smoothing for a clean camera snap. Defer by one frame to avoid the
|
||||
# re-enable block in the same _process() call undoing the snap.
|
||||
if _camera_anchored and not camera.position_smoothing_enabled:
|
||||
# Track camera to player (D-015: locked, fixed-north).
|
||||
# #117: Manual exponential smoothing — same pattern as EntityRenderer.LERP_SPEED.
|
||||
# Teleport (flag set by _teleport_transition): snap immediately, resume lerp next frame.
|
||||
# Init: camera already snapped in _ready() or late-anchor path above.
|
||||
if _camera_anchored:
|
||||
var target := GameState.player_position * Constants.TILE_SIZE
|
||||
if _teleport_in_progress:
|
||||
camera.global_position = target
|
||||
_teleport_in_progress = false
|
||||
else:
|
||||
camera.position_smoothing_enabled = true
|
||||
camera.reset_smoothing()
|
||||
var weight := 1.0 - exp(-Constants.CAMERA_SMOOTHING_SPEED * delta)
|
||||
camera.global_position = camera.global_position.lerp(target, weight)
|
||||
|
||||
# Send queued input to simulation
|
||||
# #507: Server-bound inputs are accumulated into _pending_record_inputs across frames.
|
||||
# At 60fps/10tps, inputs on non-snapshot frames must not be lost from the ring buffer.
|
||||
var inputs = InputMapper.flush_queue()
|
||||
for input in inputs:
|
||||
# #495: F12 WRONG button — client-only, trigger bug report capture
|
||||
@@ -142,6 +179,14 @@ func _process(_delta: float) -> void:
|
||||
if bug_report_dialog and not bug_report_dialog.is_active():
|
||||
bug_report_dialog.start_capture()
|
||||
continue
|
||||
# #528: ESC/OPEN_MENU — client-only, toggle audio settings dialog
|
||||
if input.action == InputMapper.Action.OPEN_MENU:
|
||||
if settings_dialog:
|
||||
if settings_dialog.is_open():
|
||||
settings_dialog.close()
|
||||
else:
|
||||
settings_dialog.open()
|
||||
continue
|
||||
if input.action == InputMapper.Action.INTERACT:
|
||||
# D-057: prefer interaction list (multi-verb), fall back to prompt (v0.1)
|
||||
var target_id: int = -1
|
||||
@@ -164,6 +209,73 @@ func _process(_delta: float) -> void:
|
||||
"verb": null,
|
||||
}
|
||||
SimBridge.send_input(input)
|
||||
_pending_record_inputs.append(input)
|
||||
|
||||
# #507: Record tick data to ring buffer — once per server tick (snapshot arrival).
|
||||
# Flushes all inputs accumulated since the last snapshot (across multiple display frames),
|
||||
# then clears the accumulator for the next tick.
|
||||
if snapshot != null and bug_report_dialog and bug_report_dialog.has_method("record_tick"):
|
||||
bug_report_dialog.record_tick(
|
||||
GameState.current_tick,
|
||||
JSON.stringify(GameState.current_snapshot),
|
||||
_pending_record_inputs
|
||||
)
|
||||
_pending_record_inputs.clear()
|
||||
|
||||
|
||||
# D-018 #125: Play close-range sound events — fired once per snapshot tick.
|
||||
# Each event is passed to AudioManager.play_sound_event() for 2D positional playback
|
||||
# on the WorldSFX bus. Events with no registered asset are silently skipped (D-038).
|
||||
# Consume-once: events are cleared after processing so they don't replay if
|
||||
# _process runs again before the next server tick (D-009 multiplayer-safe pattern).
|
||||
func _play_close_sound_events() -> void:
|
||||
for evt in GameState.close_sound_events:
|
||||
if not evt is Dictionary or not evt.has("x") or not evt.has("y"):
|
||||
continue
|
||||
AudioManager.play_sound_event(
|
||||
evt.get("event_type", ""),
|
||||
Vector2(float(evt.x), float(evt.y))
|
||||
)
|
||||
GameState.close_sound_events = []
|
||||
|
||||
|
||||
# D-067: Recognition chime — fires sfx_monologue_chime when a fog entity
|
||||
# enters the cognitive delay recognition queue for the first time.
|
||||
# "The chime marks the character's attention shifting" (D-067).
|
||||
# IDs persist for the session — one chime per entity, no re-trigger on
|
||||
# fog oscillation or server re-send. Cleared on room change (teleport).
|
||||
func _play_recognition_chimes() -> void:
|
||||
for rec in GameState.pending_recognitions:
|
||||
if not rec is Dictionary or not rec.has("entity_id"):
|
||||
continue
|
||||
var eid: int = rec.entity_id
|
||||
if not _known_recognition_ids.has(eid):
|
||||
_known_recognition_ids[eid] = true
|
||||
AudioManager.play(AudioManager.CHIME_RECOGNITION)
|
||||
|
||||
|
||||
# D-073 (#529): Zone ambient crossfade — reads zone_id from GameState.current_zone_id
|
||||
# (extracted in apply_snapshot(), server-authoritative per D-020).
|
||||
# Calls AudioManager.set_zone() when zone changes (AudioManager handles crossfade).
|
||||
func _update_zone() -> void:
|
||||
var zone := GameState.current_zone_id
|
||||
if zone != _current_zone:
|
||||
_current_zone = zone
|
||||
AudioManager.set_zone(zone)
|
||||
|
||||
|
||||
# D-071 (#530): ListeningFocus boost — World SFX +2.5dB when stationary 30+ ticks.
|
||||
# Uses AudioManager.get_active_dip() as single source of truth (no separate flag).
|
||||
# Only activates when no other dip (dialogue/confrontation) is running.
|
||||
# Only deactivates its own dip — never touches dialogue/confrontation.
|
||||
# D-070: no UI indicator — the boost is "felt, not computed."
|
||||
func _update_listening_focus() -> void:
|
||||
var current_dip := AudioManager.get_active_dip()
|
||||
var threshold_met := GameState.stationary_ticks >= LISTENING_FOCUS_TICKS
|
||||
if threshold_met and current_dip == "":
|
||||
AudioManager.apply_dip("listening_focus")
|
||||
elif not threshold_met and current_dip == "listening_focus":
|
||||
AudioManager.clear_dip()
|
||||
|
||||
|
||||
# Consume-once per tick: show monologue text, then clear.
|
||||
@@ -176,7 +288,12 @@ func _consume_monologue() -> void:
|
||||
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))
|
||||
monologue_display.show_monologue(
|
||||
mono.get("text", ""),
|
||||
mono.get("duration_seconds", 5.0),
|
||||
mono.get("priority", 2),
|
||||
mono.get("is_urgent", false)
|
||||
)
|
||||
# #502: Amber flash on room reset
|
||||
var mono_id: String = mono.get("id", "")
|
||||
if mono_id.begins_with("room_reset"):
|
||||
@@ -184,6 +301,7 @@ func _consume_monologue() -> void:
|
||||
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:
|
||||
@@ -197,6 +315,7 @@ func _consume_dialogue() -> void:
|
||||
_last_dialogue_tick = GameState.current_tick
|
||||
var dlg: Dictionary = GameState.current_dialogue
|
||||
_last_dialogue_npc_id = dlg.get("npc_entity_id", -1)
|
||||
_last_dialogue_npc_name = dlg.get("npc_name", "")
|
||||
dialogue_box.show_dialogue(
|
||||
dlg.get("npc_name", ""),
|
||||
dlg.get("speech", ""),
|
||||
@@ -205,6 +324,39 @@ func _consume_dialogue() -> void:
|
||||
GameState.current_dialogue = null
|
||||
|
||||
|
||||
# #535: Consume overheard NPC-NPC conversation events (D-078).
|
||||
# Each event carries pre-occluded text — render verbatim in the dialogue log.
|
||||
func _consume_conversation_events() -> void:
|
||||
if not dialogue_box:
|
||||
return
|
||||
for event in GameState.conversation_events:
|
||||
dialogue_box.append_conversation_event(event)
|
||||
GameState.conversation_events = []
|
||||
|
||||
|
||||
# #535: Handle conversation_ended events — notify dialogue box to stop tracking pairs.
|
||||
func _consume_conversation_ended() -> void:
|
||||
if not dialogue_box:
|
||||
return
|
||||
for event in GameState.conversation_ended:
|
||||
dialogue_box.on_conversation_ended(event)
|
||||
GameState.conversation_ended = []
|
||||
|
||||
|
||||
# #535: Consume dialogue_response — NPC follow-up line after player picks an option.
|
||||
# Updates dialogue_box entity display registry with speaker identity from the wire.
|
||||
func _consume_dialogue_response() -> void:
|
||||
if GameState.dialogue_response == null or not dialogue_box:
|
||||
return
|
||||
var dr: Dictionary = GameState.dialogue_response
|
||||
var speaker_entity_id: int = dr.get("speaker_entity_id", _last_dialogue_npc_id)
|
||||
var speaker_color_index: int = dr.get("speaker_color_index", -1)
|
||||
var speaker_name: String = dr.get("speaker_name", _last_dialogue_npc_name)
|
||||
dialogue_box.update_entity_display(speaker_entity_id, speaker_name, speaker_color_index)
|
||||
dialogue_box.append_dialogue_response(speaker_name, dr.get("text", ""))
|
||||
GameState.dialogue_response = null
|
||||
|
||||
|
||||
# D-061: Handle dialogue option selection → send to server
|
||||
func _on_dialogue_option_selected(response_id: String, text: String) -> void:
|
||||
SimBridge.send_input({
|
||||
@@ -219,9 +371,29 @@ func _on_dialogue_option_selected(response_id: String, text: String) -> void:
|
||||
|
||||
|
||||
# D-063: Handle confrontation beat monologue → show on monologue display (layer 7)
|
||||
# Confrontation lines are high-priority (3) and urgent — full opacity, elevated colour.
|
||||
# Tick guard deduplicates if dialogue box emits the signal multiple times in one tick.
|
||||
func _on_confrontation_monologue(text: String, duration: float) -> void:
|
||||
if monologue_display:
|
||||
monologue_display.show_monologue(text, duration)
|
||||
if not monologue_display:
|
||||
return
|
||||
if GameState.current_tick == _last_confrontation_tick:
|
||||
return
|
||||
_last_confrontation_tick = GameState.current_tick
|
||||
monologue_display.show_monologue(text, duration, 3, true)
|
||||
|
||||
|
||||
# D-061: Auto-pause on dialogue open — routed through input recording (#507, Tyre #3)
|
||||
func _on_dialogue_pause_requested() -> void:
|
||||
var input := {"action": InputMapper.Action.PAUSE, "timestamp_msec": Time.get_ticks_msec()}
|
||||
SimBridge.send_input(input)
|
||||
_pending_record_inputs.append(input)
|
||||
|
||||
|
||||
# D-061: Auto-unpause on dialogue close — routed through input recording (#507, Tyre #3)
|
||||
func _on_dialogue_unpause_requested() -> void:
|
||||
var input := {"action": InputMapper.Action.UNPAUSE, "timestamp_msec": Time.get_ticks_msec()}
|
||||
SimBridge.send_input(input)
|
||||
_pending_record_inputs.append(input)
|
||||
|
||||
|
||||
# D-064: Handle walk-away → send WalkAway{npc_id} to server
|
||||
@@ -253,11 +425,8 @@ func _detect_teleport(old_pos: Vector2, new_pos: Vector2) -> bool:
|
||||
# Clears dialogue/monologue/interaction state (server clears its side too).
|
||||
# Scoped to Gauntlet testing only — production fast-travel uses diegetic gates.
|
||||
func _teleport_transition() -> void:
|
||||
# Snap camera: disable smoothing, force re-anchor.
|
||||
# _teleport_in_progress defers smoothing re-enable by one frame so the
|
||||
# re-enable block at the bottom of _process() doesn't undo the snap.
|
||||
camera.position_smoothing_enabled = false
|
||||
camera.global_position = GameState.player_position * Constants.TILE_SIZE
|
||||
# Set teleport flag — the camera tracking block in _process() will snap
|
||||
# to the player's new position this frame (no lerp). Flag clears after snap.
|
||||
_camera_anchored = true
|
||||
_teleport_in_progress = true
|
||||
|
||||
@@ -265,6 +434,7 @@ func _teleport_transition() -> void:
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.dialogue_active = false
|
||||
_known_recognition_ids.clear() # D-067: reset chimes for new room
|
||||
if dialogue_box and dialogue_box.is_dialogue_active():
|
||||
dialogue_box.hide_dialogue()
|
||||
|
||||
|
||||
@@ -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 = 8
|
||||
const PROTOCOL_VERSION: int = 13
|
||||
|
||||
|
||||
# -- Decode: bytes from server → GDScript types --------------------------------
|
||||
@@ -180,6 +180,32 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"speaker_entity_id": int(raw_dr.get("speaker_entity_id", -1)),
|
||||
}
|
||||
|
||||
# v9: conversation_events (#535, D-078) — overheard NPC-to-NPC dialogue lines.
|
||||
# Each event carries pre-occluded text plus speaker/target attribution.
|
||||
var conversation_events: Array = []
|
||||
var raw_conv_events: Variant = raw.get("conversation_events")
|
||||
if raw_conv_events is Array:
|
||||
for raw_ce in raw_conv_events:
|
||||
if raw_ce is Dictionary and raw_ce.has("occluded_line"):
|
||||
conversation_events.append({
|
||||
"speaker_id": int(raw_ce.get("speaker_id", 0)),
|
||||
"target_id": int(raw_ce.get("target_id", 0)),
|
||||
"speaker_name": str(raw_ce.get("speaker_name", "")),
|
||||
"target_name": str(raw_ce.get("target_name", "")),
|
||||
"occluded_line": str(raw_ce["occluded_line"]),
|
||||
})
|
||||
|
||||
# v9: conversation_ended (#535, D-078) — pairs whose conversation ended this tick.
|
||||
var conversation_ended: Array = []
|
||||
var raw_conv_ended: Variant = raw.get("conversation_ended")
|
||||
if raw_conv_ended is Array:
|
||||
for raw_end in raw_conv_ended:
|
||||
if raw_end is Dictionary:
|
||||
conversation_ended.append({
|
||||
"speaker_id": int(raw_end.get("speaker_id", 0)),
|
||||
"target_id": int(raw_end.get("target_id", 0)),
|
||||
})
|
||||
|
||||
return {
|
||||
"tick": tick,
|
||||
"entities": entities,
|
||||
@@ -195,6 +221,8 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant:
|
||||
"current_dialogue": current_dialogue,
|
||||
"dialogue_response": dialogue_response,
|
||||
"pending_recognitions": pending_recognitions,
|
||||
"conversation_events": conversation_events,
|
||||
"conversation_ended": conversation_ended,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ enum State { DEFAULT, ENTITY_HOVER, OBJECT_HOVER, WEAPON_AIM }
|
||||
var current_state: State = State.DEFAULT
|
||||
var hovered_entity_id: int = -1
|
||||
var weapon_mode_active: bool = false
|
||||
# OQ-07 (#522): when false, verb labels are suppressed (should_show_interactions → false).
|
||||
# Cursor shape transitions still fire — the character's body still orients to targets.
|
||||
var insert_active: bool = true
|
||||
|
||||
signal state_changed(new_state: State)
|
||||
signal hovered_entity_changed(entity_id: int)
|
||||
@@ -305,8 +308,15 @@ func get_z_layer() -> int:
|
||||
|
||||
|
||||
func should_show_interactions() -> bool:
|
||||
# OQ-07: insert off suppresses verb labels even though cursor shape still changes
|
||||
if not insert_active:
|
||||
return false
|
||||
return not weapon_mode_active or _shift_held
|
||||
|
||||
|
||||
func set_insert_active(active: bool) -> void:
|
||||
insert_active = active
|
||||
|
||||
|
||||
func get_interaction_range() -> int:
|
||||
return 2 # D-056: ~2 sim tiles
|
||||
|
||||
@@ -13,8 +13,11 @@ extends Node2D
|
||||
# color from RelationshipState via the knowledge graph.
|
||||
|
||||
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
|
||||
# D-044: 24x32 entity footprint within 32x32 visual tile (64x64 source scaled to 32px runtime)
|
||||
const ENTITY_WIDTH: int = 24
|
||||
const ENTITY_HEIGHT: int = 32
|
||||
const ENTITY_OFFSET_X: float = (TILE_SIZE - ENTITY_WIDTH) / 2.0 # center horizontally
|
||||
const ENTITY_OFFSET_Y: float = (TILE_SIZE - ENTITY_HEIGHT) / 2.0 # center vertically for placeholder. Migration: switch to bottom-anchor (offset = TILE_SIZE - ENTITY_HEIGHT) when real sprites land for correct y-sort ordering.
|
||||
|
||||
# Lerp speed — framerate-independent exponential smoothing.
|
||||
# At 12.0: ~70% there after 0.1s, ~95% after 0.25s.
|
||||
@@ -93,8 +96,8 @@ func update_entities(entities: Array) -> void:
|
||||
func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
var entity_node = ColorRect.new()
|
||||
entity_node.name = "Entity_" + str(entity_id)
|
||||
entity_node.size = Vector2(ENTITY_SIZE, ENTITY_SIZE)
|
||||
entity_node.pivot_offset = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0)
|
||||
entity_node.size = Vector2(ENTITY_WIDTH, ENTITY_HEIGHT)
|
||||
entity_node.pivot_offset = Vector2(ENTITY_WIDTH / 2.0, ENTITY_HEIGHT / 2.0)
|
||||
|
||||
# D-033 color by relationship (#521)
|
||||
entity_node.color = _color_for_kind(entity_data)
|
||||
@@ -110,8 +113,8 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
# 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
|
||||
floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET_X,
|
||||
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET_Y
|
||||
)
|
||||
entity_node.position = target
|
||||
_entity_targets[entity_id] = target
|
||||
@@ -129,8 +132,8 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
# 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_targets[entity_id] = Vector2(
|
||||
floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET,
|
||||
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET
|
||||
floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET_X,
|
||||
floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET_Y
|
||||
)
|
||||
|
||||
# #521: Detect relationship change → fade D-033 color (0.5s via _process)
|
||||
@@ -195,5 +198,5 @@ func _add_facing_indicator(parent_node: Control) -> void:
|
||||
])
|
||||
indicator.color = Constants.ENTITY_COLOR_PLAYER
|
||||
# Position at center of parent ColorRect — rotation around this point
|
||||
indicator.position = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0)
|
||||
indicator.position = Vector2(ENTITY_WIDTH / 2.0, ENTITY_HEIGHT / 2.0)
|
||||
parent_node.add_child(indicator)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
class_name SoundIndicatorRenderer
|
||||
extends Node2D
|
||||
|
||||
## Medium-range sound indicators (#126, D-018).
|
||||
## Renders directional arrows at the fog boundary for sounds outside LOS.
|
||||
##
|
||||
## Close-range sounds → 2D positional audio (handled by AudioManager).
|
||||
## Medium-range sounds → visual arrow at fog edge pointing toward source.
|
||||
##
|
||||
## Color per D-018/D-069:
|
||||
## Neutral #c8d0e0 — footsteps, generic sounds
|
||||
## Voice #e8c547 — speech, conversation, social sounds
|
||||
## Danger #d45d5d — alert, gunshot, explosion, threat
|
||||
##
|
||||
## Each indicator lives for INDICATOR_LIFETIME seconds and fades out over
|
||||
## the last FADE_DURATION seconds. New events are appended each tick;
|
||||
## expired indicators are removed by _process(). Deduplication prevents
|
||||
## the same source position from stacking multiple arrows.
|
||||
|
||||
const TILE_SIZE: int = Constants.TILE_SIZE
|
||||
|
||||
# Visual parameters
|
||||
const INDICATOR_LIFETIME: float = 3.5 # Total seconds visible
|
||||
const FADE_DURATION: float = 0.6 # Fade-out window at end
|
||||
const EDGE_INSET: float = 20.0 # Pixels inward from viewport edge
|
||||
const ARROW_HALF: float = 7.0 # Half-width of arrowhead base
|
||||
const ARROW_LEN: float = 12.0 # Length from tip to base
|
||||
|
||||
# D-018/D-069 colors — sourced from Constants to prevent palette drift
|
||||
const COLOR_NEUTRAL: Color = Constants.INSERT_COLOR_TEXT # Generic / footstep
|
||||
const COLOR_VOICE: Color = Constants.ENTITY_COLOR_POI # Speech / conversation
|
||||
const COLOR_DANGER: Color = Constants.ENTITY_COLOR_HOSTILE # Alert / threat / gunshot
|
||||
|
||||
# Indicators: [{x, y, event_type, elapsed}]
|
||||
var _indicators: Array = []
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _indicators.is_empty():
|
||||
return
|
||||
|
||||
var i := _indicators.size() - 1
|
||||
while i >= 0:
|
||||
_indicators[i].elapsed += delta
|
||||
if _indicators[i].elapsed >= INDICATOR_LIFETIME:
|
||||
_indicators.remove_at(i)
|
||||
i -= 1
|
||||
|
||||
queue_redraw()
|
||||
|
||||
|
||||
## Append new medium-range sound events from snapshot.
|
||||
## events: Array of {x: float, y: float, event_type: String}
|
||||
## Only Medium range_category events should be passed.
|
||||
## Deduplicates by tile position — if an indicator already exists at (x,y),
|
||||
## its timer resets instead of spawning a duplicate arrow.
|
||||
func update_sound_events(events: Array) -> void:
|
||||
for evt in events:
|
||||
if not evt.has("x") or not evt.has("y"):
|
||||
continue
|
||||
var ex: float = float(evt.x)
|
||||
var ey: float = float(evt.y)
|
||||
# Deduplicate: reset timer if an indicator already exists at this tile
|
||||
var found := false
|
||||
for ind in _indicators:
|
||||
if is_equal_approx(ind.x, ex) and is_equal_approx(ind.y, ey):
|
||||
ind.elapsed = 0.0
|
||||
ind.event_type = evt.get("event_type", "")
|
||||
found = true
|
||||
break
|
||||
if not found:
|
||||
_indicators.append({
|
||||
"x": ex,
|
||||
"y": ey,
|
||||
"event_type": evt.get("event_type", ""),
|
||||
"elapsed": 0.0,
|
||||
})
|
||||
if not _indicators.is_empty():
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if _indicators.is_empty():
|
||||
return
|
||||
|
||||
var player_world: Vector2 = GameState.player_position * TILE_SIZE
|
||||
|
||||
# Compute half-extents of the visible world area from camera zoom + viewport.
|
||||
# Arrows are placed at this boundary minus EDGE_INSET so they sit just inside
|
||||
# the fog edge and don't clip to the physical screen border.
|
||||
var vp_size := get_viewport().get_visible_rect().size
|
||||
var cam := get_viewport().get_camera_2d()
|
||||
var zoom := cam.zoom if cam else Constants.CAMERA_DEFAULT_ZOOM
|
||||
var half_extents: Vector2 = vp_size / (2.0 * zoom)
|
||||
|
||||
for ind in _indicators:
|
||||
var sound_world: Vector2 = Vector2(ind.x, ind.y) * TILE_SIZE
|
||||
var dir: Vector2 = sound_world - player_world
|
||||
if dir.is_zero_approx():
|
||||
continue
|
||||
dir = dir.normalized()
|
||||
|
||||
# Project direction onto the visible-area rectangle boundary
|
||||
var edge_pt: Vector2 = _rect_edge_point(player_world, dir, half_extents, EDGE_INSET)
|
||||
|
||||
# Alpha: full for most of lifetime, fade out over last FADE_DURATION seconds
|
||||
var t: float = ind.elapsed / INDICATOR_LIFETIME
|
||||
var fade_start: float = 1.0 - FADE_DURATION / INDICATOR_LIFETIME
|
||||
var alpha: float
|
||||
if t < fade_start:
|
||||
alpha = 1.0
|
||||
else:
|
||||
alpha = lerpf(1.0, 0.0, (t - fade_start) / (FADE_DURATION / INDICATOR_LIFETIME))
|
||||
|
||||
var color: Color = color_for_type(ind.event_type)
|
||||
color.a = alpha * 0.9
|
||||
_draw_arrow(edge_pt, dir, color)
|
||||
|
||||
|
||||
## Project from center along dir to the boundary of a rectangle with
|
||||
## half_extents, inset by inset pixels. Returns the boundary point.
|
||||
func _rect_edge_point(center: Vector2, dir: Vector2, half: Vector2, inset: float) -> Vector2:
|
||||
var h := Vector2(
|
||||
maxf(half.x - inset, 8.0),
|
||||
maxf(half.y - inset, 8.0),
|
||||
)
|
||||
# Ray-AABB slab test: find smallest positive t where the ray exits
|
||||
var t_x: float = INF if abs(dir.x) < 1e-6 else abs(h.x / dir.x)
|
||||
var t_y: float = INF if abs(dir.y) < 1e-6 else abs(h.y / dir.y)
|
||||
var t: float = minf(t_x, t_y)
|
||||
return center + dir * t
|
||||
|
||||
|
||||
## Draw a filled arrowhead at pos pointing in dir.
|
||||
## The tip is at pos; the base is ARROW_LEN pixels behind along -dir.
|
||||
func _draw_arrow(pos: Vector2, dir: Vector2, color: Color) -> void:
|
||||
var perp := Vector2(-dir.y, dir.x)
|
||||
var base_center := pos - dir * ARROW_LEN
|
||||
draw_polygon(
|
||||
PackedVector2Array([pos, base_center - perp * ARROW_HALF, base_center + perp * ARROW_HALF]),
|
||||
PackedColorArray([color, color, color])
|
||||
)
|
||||
|
||||
|
||||
## Map event type string → D-018 color category.
|
||||
func color_for_type(event_type: String) -> Color:
|
||||
var et := event_type.to_lower()
|
||||
if et.contains("voice") or et.contains("speech") or et.contains("convers") or et.contains("talk"):
|
||||
return COLOR_VOICE
|
||||
if et.contains("danger") or et.contains("gunshot") or et.contains("explosion") \
|
||||
or et.contains("alert") or et.contains("threat"):
|
||||
return COLOR_DANGER
|
||||
return COLOR_NEUTRAL
|
||||
@@ -0,0 +1 @@
|
||||
uid://cb10ir0idsr0g
|
||||
@@ -12,6 +12,7 @@ extends TileMapLayer
|
||||
# (4,0) = reset_plate — amber (#502)
|
||||
|
||||
const TILE_SIZE: int = Constants.TILE_SIZE
|
||||
const GROUND_FLOOR: int = 0 # Server floor level for ground — filter target in update_tiles()
|
||||
|
||||
enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3, RESET_PLATE = 4 }
|
||||
|
||||
@@ -64,6 +65,10 @@ func _setup_tileset() -> void:
|
||||
|
||||
# Update tiles from snapshot data
|
||||
# tiles: Array of {x: int, y: int, z: int, type: String}
|
||||
# z here is the server-side FLOOR LEVEL (0 = ground, 1 = first floor, etc.),
|
||||
# NOT the Godot scene z_index (which controls render order within a floor).
|
||||
# This node only renders floor-level 0. Higher floor levels will be handled
|
||||
# by separate TileMapLayer nodes when multi-floor rendering is implemented.
|
||||
func update_tiles(tiles: Array) -> void:
|
||||
if not _initialized:
|
||||
return
|
||||
@@ -74,6 +79,12 @@ func update_tiles(tiles: Array) -> void:
|
||||
if not tile_data.has("x") or not tile_data.has("y") or not tile_data.has("type"):
|
||||
continue
|
||||
|
||||
# Floor-level filter: only render tiles at ground floor.
|
||||
# Upper floor tiles (level 1+) are for future multi-floor nodes.
|
||||
var tile_z: int = tile_data.get("z", 0)
|
||||
if tile_z != GROUND_FLOOR:
|
||||
continue
|
||||
|
||||
var tile_type_str: String = tile_data.type
|
||||
if not TILE_TYPE_MAP.has(tile_type_str):
|
||||
push_warning("TileRenderer: unknown tile type '%s' at (%d, %d)" % [
|
||||
|
||||
@@ -16,6 +16,7 @@ extends Node2D
|
||||
@onready var tile_renderer = $FogGroup/FloorTiles
|
||||
@onready var fog_renderer = $FogOverlay
|
||||
@onready var entity_renderer = $FogGroup/YSortGroup/Entities
|
||||
@onready var sound_indicator_renderer = $SoundIndicators # #126 D-018 medium-range indicators
|
||||
|
||||
var _last_tick: int = -1
|
||||
|
||||
@@ -42,3 +43,7 @@ func update_from_state() -> void:
|
||||
# Update entity sprites
|
||||
if entity_renderer and entity_renderer.has_method("update_entities"):
|
||||
entity_renderer.update_entities(GameState.visible_entities)
|
||||
|
||||
# D-018 #126: Update medium-range sound indicators
|
||||
if sound_indicator_renderer and sound_indicator_renderer.has_method("update_sound_events"):
|
||||
sound_indicator_renderer.update_sound_events(GameState.medium_sound_events)
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
extends Control
|
||||
# #511: F3 debug overlay — real-time game state display for dev use.
|
||||
|
||||
const HEADER_COLOR := Color("#e8c547")
|
||||
const LABEL_COLOR := Color("#8890a0")
|
||||
const VALUE_COLOR := Color("#c8d0e0")
|
||||
const BG_COLOR := Color(0.08, 0.08, 0.12, 0.85)
|
||||
const FONT_SIZE := 12
|
||||
const LINE_HEIGHT := 16
|
||||
const PADDING := Vector2(10, 8)
|
||||
const COL_GAP := 16 # gap between left and right columns
|
||||
|
||||
var _cached_font: Font = null
|
||||
|
||||
func _ready() -> void:
|
||||
visible = false
|
||||
_cached_font = ThemeDB.fallback_font
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event.is_action_pressed("debug_overlay"):
|
||||
visible = not visible
|
||||
if visible:
|
||||
queue_redraw()
|
||||
|
||||
func update_from_state() -> void:
|
||||
if not visible:
|
||||
return
|
||||
queue_redraw()
|
||||
|
||||
func _draw() -> void:
|
||||
if not visible:
|
||||
return
|
||||
var font: Font = _cached_font if _cached_font else ThemeDB.fallback_font
|
||||
|
||||
# Build lines as [label, value, label, value] pairs (two columns)
|
||||
var left_lines: Array = []
|
||||
var right_lines: Array = []
|
||||
|
||||
left_lines.append(["tick", str(GameState.current_tick)])
|
||||
right_lines.append(["fps", str(Engine.get_frames_per_second())])
|
||||
|
||||
var pos := GameState.player_position
|
||||
left_lines.append(["pos", "(%d, %d)" % [int(pos.x), int(pos.y)]])
|
||||
right_lines.append(["facing", GameState.player_facing])
|
||||
|
||||
left_lines.append(["stance", GameState.player_stance])
|
||||
right_lines.append(["zone", GameState.current_zone_id if GameState.current_zone_id != "" else "-"])
|
||||
|
||||
left_lines.append(["entities", str(GameState.visible_entities.size())])
|
||||
right_lines.append(["tiles", str(GameState.visible_tiles.size())])
|
||||
|
||||
left_lines.append(["interactions", str(GameState.nearby_interactions.size())])
|
||||
right_lines.append(["recognitions", str(GameState.pending_recognitions.size())])
|
||||
|
||||
var mono_status := "active" if GameState.current_monologue != null else "idle"
|
||||
var dlg_status := "active" if GameState.dialogue_active else "idle"
|
||||
left_lines.append(["monologue", mono_status])
|
||||
right_lines.append(["dialogue", dlg_status])
|
||||
|
||||
left_lines.append(["stationary", str(GameState.stationary_ticks)])
|
||||
right_lines.append(["insert", "ON" if GameState.insert_active else "OFF"])
|
||||
|
||||
var gt := GameState.game_time
|
||||
var time_str := "%s d%s" % [gt.get("day_phase", "-"), str(gt.get("day", "-"))] if gt.size() > 0 else "-"
|
||||
var rate_str: String = gt.get("tick_rate", "-") if gt.size() > 0 else "-"
|
||||
left_lines.append(["time", time_str])
|
||||
right_lines.append(["tick_rate", rate_str])
|
||||
|
||||
var mode_str := "test" if SimBridge.test_mode else "live"
|
||||
var gauntlet_str := "ON" if GameState.gauntlet_mode else "OFF"
|
||||
left_lines.append(["mode", mode_str])
|
||||
right_lines.append(["gauntlet", gauntlet_str])
|
||||
|
||||
# Measure column widths
|
||||
var left_label_w: float = 0.0
|
||||
var left_value_w: float = 0.0
|
||||
var right_label_w: float = 0.0
|
||||
var right_value_w: float = 0.0
|
||||
|
||||
for line in left_lines:
|
||||
left_label_w = max(left_label_w, font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
|
||||
left_value_w = max(left_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
|
||||
for line in right_lines:
|
||||
right_label_w = max(right_label_w, font.get_string_size(line[0] + ": ", HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
|
||||
right_value_w = max(right_value_w, font.get_string_size(line[1], HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x)
|
||||
|
||||
var header_text := "F3 DEBUG"
|
||||
var header_w := font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1).x
|
||||
var content_w := left_label_w + left_value_w + COL_GAP + right_label_w + right_value_w
|
||||
var box_w: float = max(header_w, content_w) + PADDING.x * 2
|
||||
var line_count: int = maxi(left_lines.size(), right_lines.size())
|
||||
var box_h: float = PADDING.y * 2 + LINE_HEIGHT + LINE_HEIGHT * line_count # header + data lines
|
||||
|
||||
# Background
|
||||
draw_rect(Rect2(Vector2.ZERO, Vector2(box_w, box_h)), BG_COLOR)
|
||||
|
||||
# Header
|
||||
var y: float = PADDING.y + FONT_SIZE
|
||||
draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE + 1, HEADER_COLOR)
|
||||
y += LINE_HEIGHT
|
||||
|
||||
# Data lines (two columns)
|
||||
var right_x: float = PADDING.x + left_label_w + left_value_w + COL_GAP
|
||||
for i in range(line_count):
|
||||
if i < left_lines.size():
|
||||
var lbl: String = left_lines[i][0] + ": "
|
||||
var val: String = left_lines[i][1]
|
||||
draw_string(font, Vector2(PADDING.x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||
draw_string(font, Vector2(PADDING.x + left_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||
if i < right_lines.size():
|
||||
var lbl: String = right_lines[i][0] + ": "
|
||||
var val: String = right_lines[i][1]
|
||||
draw_string(font, Vector2(right_x, y), lbl, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, LABEL_COLOR)
|
||||
draw_string(font, Vector2(right_x + right_label_w, y), val, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, VALUE_COLOR)
|
||||
y += LINE_HEIGHT
|
||||
@@ -0,0 +1 @@
|
||||
uid://c55ow14m345vv
|
||||
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.
@@ -518,7 +518,7 @@ func test_bug_report_not_double_activatable() -> void:
|
||||
|
||||
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)
|
||||
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("")
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
## Test suite for #125: Close-range stereo audio — bus routing (D-068/D-069)
|
||||
## Spec refs:
|
||||
## D-068: 5-bus audio architecture (Music, Ambient, WorldSFX, PlayerActions, UISounds)
|
||||
## D-069: Audio dip profiles — confrontation drops WorldSFX 4–6dB
|
||||
## D-018: Three-range sound model — close-range → WorldSFX bus, 2D positional audio
|
||||
##
|
||||
## Test layers:
|
||||
## 1. D-068 Bus architecture constants (no implementation required)
|
||||
## 2. D-069 Dip profile spec values (no implementation required)
|
||||
## 3. AudioManager API — dip state machine, signal emission
|
||||
## 4. D-018 Snapshot integration stubs — graceful skip until #125 wires sound_events
|
||||
class_name TestAudioBusRouting
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
AudioManager.clear_dip()
|
||||
for bus in AudioManager.BUSES:
|
||||
AudioManager.set_volume(bus, 0.0)
|
||||
GameState.stationary_ticks = 0
|
||||
GameState._prev_player_position = Vector2(-1e9, -1e9)
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
AudioManager.clear_dip()
|
||||
GameState.stationary_ticks = 0
|
||||
GameState._prev_player_position = Vector2(-1e9, -1e9)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 1: D-068 Bus Architecture Constants
|
||||
# ==============================================================================
|
||||
|
||||
func test_d068_bus_world_sfx_name() -> void:
|
||||
## D-068: WorldSFX bus name must match exactly — used by all sound event routing.
|
||||
assert_that(AudioManager.BUS_WORLD_SFX).is_equal("WorldSFX")
|
||||
|
||||
func test_d068_bus_ambient_name() -> void:
|
||||
assert_that(AudioManager.BUS_AMBIENT).is_equal("Ambient")
|
||||
|
||||
func test_d068_bus_player_actions_name() -> void:
|
||||
assert_that(AudioManager.BUS_PLAYER_ACTIONS).is_equal("PlayerActions")
|
||||
|
||||
func test_d068_bus_ui_sounds_name() -> void:
|
||||
assert_that(AudioManager.BUS_UI_SOUNDS).is_equal("UISounds")
|
||||
|
||||
func test_d068_bus_music_name() -> void:
|
||||
assert_that(AudioManager.BUS_MUSIC).is_equal("Music")
|
||||
|
||||
func test_d068_five_buses_defined() -> void:
|
||||
## D-068: Exactly 5 buses in the architecture.
|
||||
assert_that(AudioManager.BUSES.size()).is_equal(5)
|
||||
|
||||
func test_d068_all_bus_names_present_in_buses_array() -> void:
|
||||
## D-068: BUSES array must contain all 5 named buses.
|
||||
assert_that(AudioManager.BUSES.has(AudioManager.BUS_MUSIC)).is_true()
|
||||
assert_that(AudioManager.BUSES.has(AudioManager.BUS_AMBIENT)).is_true()
|
||||
assert_that(AudioManager.BUSES.has(AudioManager.BUS_WORLD_SFX)).is_true()
|
||||
assert_that(AudioManager.BUSES.has(AudioManager.BUS_PLAYER_ACTIONS)).is_true()
|
||||
assert_that(AudioManager.BUSES.has(AudioManager.BUS_UI_SOUNDS)).is_true()
|
||||
|
||||
func test_d068_bus_names_are_unique() -> void:
|
||||
## D-068: No two buses share a name.
|
||||
var seen: Dictionary = {}
|
||||
for bus_name in AudioManager.BUSES:
|
||||
assert_that(seen.has(bus_name)).is_false()
|
||||
seen[bus_name] = true
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 2: D-069 Dip Profile Spec Values
|
||||
# Close-range sound events go on WorldSFX; confrontation dips that bus 4–6 dB.
|
||||
# ==============================================================================
|
||||
|
||||
func test_d069_three_dip_profiles_exist() -> void:
|
||||
## D-069: Three profiles — dialogue, confrontation, listening_focus.
|
||||
assert_that(AudioManager.DIP_SPECS.has("dialogue")).is_true()
|
||||
assert_that(AudioManager.DIP_SPECS.has("confrontation")).is_true()
|
||||
assert_that(AudioManager.DIP_SPECS.has("listening_focus")).is_true()
|
||||
|
||||
func test_d069_confrontation_dips_world_sfx_4_to_6_db() -> void:
|
||||
## D-069: Confrontation must drop WorldSFX by 4–6 dB. Sprint 12 uses -5.0 dB.
|
||||
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
|
||||
assert_that(buses.has("WorldSFX")).is_true()
|
||||
var dip_db: float = buses["WorldSFX"]
|
||||
assert_that(dip_db >= -6.0 and dip_db <= -4.0).is_true()
|
||||
|
||||
func test_d069_confrontation_dips_ambient() -> void:
|
||||
## D-069: Confrontation suppresses both Ambient and WorldSFX.
|
||||
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
|
||||
assert_that(buses.has("Ambient")).is_true()
|
||||
|
||||
func test_d069_confrontation_has_low_pass_filter_at_800hz() -> void:
|
||||
## D-069: Confrontation sweeps Ambient bus low-pass to ~800 Hz.
|
||||
var spec: Dictionary = AudioManager.DIP_SPECS["confrontation"]
|
||||
assert_that(spec.has("filter_hz")).is_true()
|
||||
assert_that(spec["filter_hz"]).is_equal_approx(800.0, 50.0)
|
||||
|
||||
func test_d069_dialogue_dips_ambient_not_world_sfx() -> void:
|
||||
## D-069: Dialogue dips Ambient only — world sounds are NOT suppressed.
|
||||
## Confrontation is the one that mutes world SFX.
|
||||
var buses: Dictionary = AudioManager.DIP_SPECS["dialogue"]["buses"]
|
||||
assert_that(buses.has("Ambient")).is_true()
|
||||
assert_that(buses.has("WorldSFX")).is_false()
|
||||
|
||||
func test_d069_listening_focus_boosts_world_sfx() -> void:
|
||||
## D-069/D-071: Listening focus BOOSTS WorldSFX (positive offset) for eavesdropping.
|
||||
var buses: Dictionary = AudioManager.DIP_SPECS["listening_focus"]["buses"]
|
||||
assert_that(buses.has("WorldSFX")).is_true()
|
||||
assert_that(buses["WorldSFX"] > 0.0).is_true()
|
||||
|
||||
func test_d069_all_profiles_have_positive_ease_times() -> void:
|
||||
## D-069: All profiles have ease_in and ease_out > 0 (no instant transitions).
|
||||
for profile in AudioManager.DIP_SPECS.keys():
|
||||
var spec: Dictionary = AudioManager.DIP_SPECS[profile]
|
||||
assert_that(spec.has("ease_in")).is_true()
|
||||
assert_that(spec.has("ease_out")).is_true()
|
||||
assert_that(spec["ease_in"] > 0.0).is_true()
|
||||
assert_that(spec["ease_out"] > 0.0).is_true()
|
||||
|
||||
func test_d069_confrontation_ease_out_longer_than_dialogue() -> void:
|
||||
## D-069: Confrontation exits slowly (1.0s) vs dialogue (0.5s) — more immersive.
|
||||
var conf_out: float = AudioManager.DIP_SPECS["confrontation"]["ease_out"]
|
||||
var dial_out: float = AudioManager.DIP_SPECS["dialogue"]["ease_out"]
|
||||
assert_that(conf_out >= dial_out).is_true()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 2b: D-067 Recognition Chime
|
||||
# sfx_monologue_chime fires on first fog recognition, UISounds bus (not WorldSFX).
|
||||
# ==============================================================================
|
||||
|
||||
func test_d067_chime_recognition_constant_exists() -> void:
|
||||
## D-067: AudioManager must expose a CHIME_RECOGNITION constant.
|
||||
assert_that("CHIME_RECOGNITION" in AudioManager).is_true()
|
||||
|
||||
func test_d067_chime_recognition_maps_to_sfx_monologue_chime() -> void:
|
||||
## D-067: Recognition chime = sfx_monologue_chime (D-038 asset key).
|
||||
## "Neural lattice firing" feel — soft crystalline tone.
|
||||
assert_that(AudioManager.CHIME_RECOGNITION).is_equal("sfx_monologue_chime")
|
||||
|
||||
func test_d067_chime_on_ui_sounds_bus_not_world_sfx() -> void:
|
||||
## D-067/D-038: Monologue chime is a UI sound, not a simulation sound.
|
||||
## Must use BUS_UI_SOUNDS, not BUS_WORLD_SFX.
|
||||
## Verify by checking that BUS_WORLD_SFX != BUS_UI_SOUNDS.
|
||||
assert_that(AudioManager.BUS_WORLD_SFX != AudioManager.BUS_UI_SOUNDS).is_true()
|
||||
## CHIME_RECOGNITION is played via AudioManager.play() which defaults to BUS_UI_SOUNDS.
|
||||
## No further assertion needed — play() default bus IS UISounds by design.
|
||||
|
||||
func test_d067_chime_noop_when_asset_absent() -> void:
|
||||
## D-038 / D-067: When sfx_monologue_chime.ogg is not in registry,
|
||||
## play(CHIME_RECOGNITION) must be a no-op (no crash).
|
||||
AudioManager.play(AudioManager.CHIME_RECOGNITION)
|
||||
## No assertion — absence of crash is the test.
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 3: AudioManager API — Dip State Machine
|
||||
# ==============================================================================
|
||||
|
||||
func test_audio_manager_initial_state_no_active_dip() -> void:
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("")
|
||||
|
||||
func test_audio_manager_apply_dip_sets_active() -> void:
|
||||
AudioManager.apply_dip("dialogue")
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
|
||||
|
||||
func test_audio_manager_clear_dip_resets_active() -> void:
|
||||
AudioManager.apply_dip("confrontation")
|
||||
AudioManager.clear_dip()
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("")
|
||||
|
||||
func test_audio_manager_apply_unknown_dip_leaves_state_unchanged() -> void:
|
||||
## Unknown profile: push_warning + early return, active dip stays empty.
|
||||
AudioManager.apply_dip("not_a_real_profile")
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("")
|
||||
|
||||
func test_audio_manager_apply_dip_emits_dip_changed_signal() -> void:
|
||||
## apply_dip() must emit dip_changed(profile) synchronously.
|
||||
## Array wrapper used for lambda capture — GDScript 4 captures String locals by value,
|
||||
## so a mutable reference type is required to observe signal argument inside the closure.
|
||||
var received := [""]
|
||||
var conn := func(p: String) -> void: received[0] = p
|
||||
AudioManager.dip_changed.connect(conn)
|
||||
AudioManager.apply_dip("dialogue")
|
||||
AudioManager.dip_changed.disconnect(conn)
|
||||
assert_that(received[0]).is_equal("dialogue")
|
||||
|
||||
func test_audio_manager_clear_dip_emits_dip_changed_empty() -> void:
|
||||
## clear_dip() must emit dip_changed("") to signal audio restored.
|
||||
## Array wrapper used for lambda capture — same reason as apply_dip signal test above.
|
||||
AudioManager.apply_dip("dialogue")
|
||||
var received := ["sentinel"]
|
||||
var conn := func(p: String) -> void: received[0] = p
|
||||
AudioManager.dip_changed.connect(conn)
|
||||
AudioManager.clear_dip()
|
||||
AudioManager.dip_changed.disconnect(conn)
|
||||
assert_that(received[0]).is_equal("")
|
||||
|
||||
func test_audio_manager_apply_dip_interrupts_previous() -> void:
|
||||
## Switching profiles mid-dip: active profile must update to the new one.
|
||||
AudioManager.apply_dip("dialogue")
|
||||
AudioManager.apply_dip("confrontation")
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("confrontation")
|
||||
|
||||
func test_audio_manager_dip_changed_fires_on_profile_switch() -> void:
|
||||
## Switching from dialogue to confrontation emits dip_changed("confrontation").
|
||||
## Array wrapper used for lambda capture — same reason as apply_dip signal test above.
|
||||
AudioManager.apply_dip("dialogue")
|
||||
var received := [""]
|
||||
var conn := func(p: String) -> void: received[0] = p
|
||||
AudioManager.dip_changed.connect(conn)
|
||||
AudioManager.apply_dip("confrontation")
|
||||
AudioManager.dip_changed.disconnect(conn)
|
||||
assert_that(received[0]).is_equal("confrontation")
|
||||
|
||||
func test_audio_manager_has_asset_false_for_unknown_key() -> void:
|
||||
## has_asset() must return false for a key that was never registered.
|
||||
## Ensures no-op fallback path (D-038) is correctly guarded.
|
||||
assert_that(AudioManager.has_asset("nonexistent_test_asset_xyz")).is_false()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 4: D-018 Snapshot Integration — #125 implemented
|
||||
# GameState partitions sound_events by range_category into close_sound_events
|
||||
# and medium_sound_events. Close events are played via AudioManager.play_sound_event().
|
||||
# ==============================================================================
|
||||
|
||||
func test_snapshot_close_events_stored_in_close_sound_events() -> void:
|
||||
## #125: Close-range events must land in GameState.close_sound_events.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"sound_events": [
|
||||
{"x": 5.0, "y": 5.0, "event_type": "Footstep", "range_category": "Close"},
|
||||
]
|
||||
})
|
||||
assert_that(GameState.close_sound_events.size()).is_equal(1)
|
||||
assert_that(GameState.close_sound_events[0].event_type).is_equal("Footstep")
|
||||
|
||||
func test_snapshot_medium_events_stored_in_medium_sound_events() -> void:
|
||||
## #126/#125: Medium-range events land in medium_sound_events, not close.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"sound_events": [
|
||||
{"x": 20.0, "y": 20.0, "event_type": "Footstep", "range_category": "Medium"},
|
||||
]
|
||||
})
|
||||
assert_that(GameState.medium_sound_events.size()).is_equal(1)
|
||||
assert_that(GameState.close_sound_events.size()).is_equal(0)
|
||||
|
||||
func test_snapshot_partitions_close_and_medium_events() -> void:
|
||||
## D-018: Mixed batch — Close and Medium events partitioned correctly.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"sound_events": [
|
||||
{"x": 3.0, "y": 3.0, "event_type": "Footstep", "range_category": "Close"},
|
||||
{"x": 15.0, "y": 15.0, "event_type": "Voice", "range_category": "Medium"},
|
||||
{"x": 50.0, "y": 50.0, "event_type": "Footstep", "range_category": "Long"},
|
||||
]
|
||||
})
|
||||
assert_that(GameState.close_sound_events.size()).is_equal(1)
|
||||
assert_that(GameState.medium_sound_events.size()).is_equal(1)
|
||||
|
||||
func test_snapshot_empty_sound_events_clears_both_arrays() -> void:
|
||||
## When snapshot has no sound_events, both close and medium arrays are cleared.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"sound_events": [{"x": 1.0, "y": 1.0, "event_type": "Footstep", "range_category": "Close"}]
|
||||
})
|
||||
assert_that(GameState.close_sound_events.size()).is_equal(1)
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
assert_that(GameState.close_sound_events.size()).is_equal(0)
|
||||
assert_that(GameState.medium_sound_events.size()).is_equal(0)
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 5: AudioManager.play_sound_event — asset registry and routing
|
||||
# ==============================================================================
|
||||
|
||||
func test_audio_manager_sound_event_assets_registry_exists() -> void:
|
||||
## #125: SOUND_EVENT_ASSETS must be a non-empty dictionary.
|
||||
assert_that(AudioManager.SOUND_EVENT_ASSETS is Dictionary).is_true()
|
||||
assert_that(AudioManager.SOUND_EVENT_ASSETS.size()).is_greater(0)
|
||||
|
||||
func test_audio_manager_footstep_maps_to_sfx_footstep_metal() -> void:
|
||||
## #125: "Footstep" event type → sfx_footstep_metal_walk (D-038 asset).
|
||||
assert_that(AudioManager.SOUND_EVENT_ASSETS.has("Footstep")).is_true()
|
||||
assert_that(AudioManager.SOUND_EVENT_ASSETS["Footstep"]).is_equal("sfx_footstep_metal_walk")
|
||||
|
||||
func test_audio_manager_footstep_sprint_maps_to_sfx_footstep_metal_run() -> void:
|
||||
## #125: "FootstepSprint" → sfx_footstep_metal_run (D-038 faster footstep).
|
||||
assert_that(AudioManager.SOUND_EVENT_ASSETS.has("FootstepSprint")).is_true()
|
||||
assert_that(AudioManager.SOUND_EVENT_ASSETS["FootstepSprint"]).is_equal("sfx_footstep_metal_run")
|
||||
|
||||
func test_audio_manager_play_sound_event_noop_for_unknown_type() -> void:
|
||||
## #125 / D-038: Unknown event types must be silently skipped (no-op).
|
||||
## play_sound_event should not crash and should not emit sound.
|
||||
AudioManager.play_sound_event("UnknownEventXYZ", Vector2(5.0, 5.0))
|
||||
# No assertion needed — absence of crash is the test.
|
||||
|
||||
func test_audio_manager_play_sound_event_noop_for_empty_type() -> void:
|
||||
## #125: Empty event type string must be a no-op.
|
||||
AudioManager.play_sound_event("", Vector2(5.0, 5.0))
|
||||
# No assertion needed — absence of crash is the test.
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 6: D-071 (#530) — Stationary tick tracking for ListeningFocus
|
||||
# ==============================================================================
|
||||
|
||||
func test_d071_stationary_ticks_increments_when_position_unchanged() -> void:
|
||||
## D-071: stationary_ticks must increment on each snapshot where player doesn't move.
|
||||
var player_entity := {"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [player_entity]})
|
||||
GameState.apply_snapshot({"tick": 2, "entities": [player_entity]})
|
||||
GameState.apply_snapshot({"tick": 3, "entities": [player_entity]})
|
||||
assert_that(GameState.stationary_ticks).is_equal(2) # 2 ticks of no movement (tick 2 and 3)
|
||||
|
||||
func test_d071_stationary_ticks_resets_on_movement() -> void:
|
||||
## D-071: stationary_ticks must reset to 0 when the player position changes.
|
||||
var pos_a := {"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||||
var pos_b := {"entity_id": 1, "x": 11.0, "y": 10.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||||
GameState.apply_snapshot({"tick": 1, "entities": [pos_a]})
|
||||
GameState.apply_snapshot({"tick": 2, "entities": [pos_a]})
|
||||
assert_that(GameState.stationary_ticks).is_equal(1)
|
||||
GameState.apply_snapshot({"tick": 3, "entities": [pos_b]})
|
||||
assert_that(GameState.stationary_ticks).is_equal(0)
|
||||
|
||||
func test_d071_stationary_ticks_reaches_threshold() -> void:
|
||||
## D-071: stationary_ticks must be able to reach 30+ for ListeningFocus activation.
|
||||
var player_entity := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||||
for tick in range(31):
|
||||
GameState.apply_snapshot({"tick": tick, "entities": [player_entity]})
|
||||
assert_that(GameState.stationary_ticks >= 30).is_true()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 7: D-069 (#530) — Dialogue dip profile interaction
|
||||
# ==============================================================================
|
||||
|
||||
func test_d069_dialogue_dip_overridden_by_confrontation() -> void:
|
||||
## D-069: Confrontation dip must override dialogue dip (apply_dip interrupts).
|
||||
AudioManager.apply_dip("dialogue")
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
|
||||
AudioManager.apply_dip("confrontation")
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("confrontation")
|
||||
|
||||
func test_d069_listening_focus_cleared_by_dialogue() -> void:
|
||||
## D-069: Dialogue dip must override listening_focus (higher priority focus state).
|
||||
AudioManager.apply_dip("listening_focus")
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("listening_focus")
|
||||
AudioManager.apply_dip("dialogue")
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
|
||||
|
||||
func test_d069_clear_dip_noop_when_empty() -> void:
|
||||
## D-069: clear_dip() when no dip active must be a safe no-op.
|
||||
AudioManager.clear_dip() # Should not crash
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("")
|
||||
@@ -0,0 +1 @@
|
||||
uid://duvqpvheku2j2
|
||||
@@ -0,0 +1,634 @@
|
||||
## Test suite for Sprint 13 audio tickets (D-067, D-068, D-069, D-071, D-072, D-073).
|
||||
##
|
||||
## Spec refs:
|
||||
## D-067: Recognition chime fires at ONSET of cognitive delay, not at completion.
|
||||
## D-068: 5-bus audio architecture (Music, Ambient, WorldSFX, PlayerActions, UISounds).
|
||||
## D-069: Audio dip profiles — timing values, dB offsets, interruptibility.
|
||||
## D-071: ListeningFocus boost (30+ tick gate, caller's responsibility).
|
||||
## D-072: Universal NPC conversation murmur on WorldSFX — no zone-specific variants.
|
||||
## D-073: Zone crossfade — hard boundary trigger, 1.5-2s tween, interruptible.
|
||||
##
|
||||
## Tickets covered:
|
||||
## #529 — Zone crossfade implementation (set_zone body + tween timing)
|
||||
## #530 — Dip profile call sites (dialogue/confrontation/ListeningFocus wiring)
|
||||
## #531 — Recognition chime fires at cognitive delay onset
|
||||
## #533 — NPC conversation murmur wired to WorldSFX bus
|
||||
##
|
||||
## Test layers:
|
||||
## 1. Zone crossfade spec and API (D-073 / #529)
|
||||
## 2. D-069 dip timing and dB spec values (supplementing test_audio_bus_routing)
|
||||
## 3. Volume slider proportional dip (D-068 / D-069)
|
||||
## 4. Dip call site wiring via GameState snapshot (D-069 / D-070 / #530)
|
||||
## 5. Recognition chime onset verification (D-067 / #531)
|
||||
## 6. NPC murmur routing (D-072 / #533)
|
||||
## 7. AudioManager no-op fallback sanity (D-068 / D-038)
|
||||
class_name TestAudioSprint13
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
AudioManager.clear_dip()
|
||||
AudioManager.stop_all_loops()
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, 0.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, 0.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_PLAYER_ACTIONS, 0.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_MUSIC, 0.0)
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
AudioManager.clear_dip()
|
||||
AudioManager.stop_all_loops()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 1: Zone Crossfade — D-073 / #529
|
||||
# set_zone() stub exists now; full implementation lands in #529.
|
||||
# ==============================================================================
|
||||
|
||||
func test_d073_set_zone_method_exists() -> void:
|
||||
## D-073 / #529: AudioManager must expose set_zone(zone_id: String).
|
||||
assert_that(AudioManager.has_method("set_zone")).is_true()
|
||||
|
||||
|
||||
func test_d073_set_zone_hub_does_not_crash() -> void:
|
||||
## D-073: set_zone("hub") is a safe call. No crash even before #529 implementation.
|
||||
AudioManager.set_zone("hub")
|
||||
|
||||
|
||||
func test_d073_set_zone_bar_does_not_crash() -> void:
|
||||
## D-073: set_zone("bar") is a safe call.
|
||||
AudioManager.set_zone("bar")
|
||||
|
||||
|
||||
func test_d073_set_zone_corridor_does_not_crash() -> void:
|
||||
## D-073: set_zone("corridor") is a safe call.
|
||||
AudioManager.set_zone("corridor")
|
||||
|
||||
|
||||
func test_d073_set_zone_empty_string_does_not_crash() -> void:
|
||||
## D-073: set_zone("") edge case — no zone ID. Must not crash.
|
||||
AudioManager.set_zone("")
|
||||
|
||||
|
||||
func test_d073_set_zone_unknown_zone_does_not_crash() -> void:
|
||||
## D-073: Unmapped zone ID (no matching asset) — no crash, graceful no-op.
|
||||
AudioManager.set_zone("nonexistent_zone_xyz")
|
||||
|
||||
|
||||
func test_d073_zone_asset_hub_key_matches_filename_convention() -> void:
|
||||
## D-073 / #529: v0.1 zone-to-asset mapping per sprint brief.
|
||||
## hub/workplace → amb_hub_layer (must match filename stem in res://audio/).
|
||||
## Test verifies naming convention is documentable. Activates once #529 adds the map.
|
||||
if not "ZONE_ASSETS" in AudioManager:
|
||||
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
|
||||
return
|
||||
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
|
||||
assert_that(zone_assets.has("hub")).is_true()
|
||||
assert_that(zone_assets["hub"]).is_equal("amb_hub_layer")
|
||||
|
||||
|
||||
func test_d073_zone_asset_bar_key_matches_filename_convention() -> void:
|
||||
## D-073 / #529: bar → amb_bar_layer
|
||||
if not "ZONE_ASSETS" in AudioManager:
|
||||
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
|
||||
return
|
||||
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
|
||||
assert_that(zone_assets.has("bar")).is_true()
|
||||
assert_that(zone_assets["bar"]).is_equal("amb_bar_layer")
|
||||
|
||||
|
||||
func test_d073_zone_asset_corridor_key_matches_filename_convention() -> void:
|
||||
## D-073 / #529: smuggling corridor → amb_corridor_layer
|
||||
if not "ZONE_ASSETS" in AudioManager:
|
||||
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined (#529 pending) — skip zone map test")
|
||||
return
|
||||
var zone_assets: Dictionary = AudioManager.ZONE_ASSETS
|
||||
assert_that(zone_assets.has("corridor")).is_true()
|
||||
assert_that(zone_assets["corridor"]).is_equal("amb_corridor_layer")
|
||||
|
||||
|
||||
func test_d073_game_state_extracts_zone_id_from_player_tile() -> void:
|
||||
## D-073 / Tyre review: zone_id is extracted in GameState.apply_snapshot() as
|
||||
## a first-class field (like player_facing, player_stance), avoiding O(N) tile
|
||||
## scan in main.gd per D-020 server-authoritative state.
|
||||
var player := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"entities": [player],
|
||||
"tiles": [
|
||||
{"x": 5, "y": 5, "z": 0, "type": "floor", "zone_id": "bar"},
|
||||
{"x": 6, "y": 5, "z": 0, "type": "floor", "zone_id": "hub"},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.current_zone_id).is_equal("bar")
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
|
||||
|
||||
func test_d073_game_state_zone_id_empty_when_field_absent() -> void:
|
||||
## D-073: Defensive — zone_id missing from tile data (server hasn't shipped OQ-09).
|
||||
var player := {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": {}}}
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"entities": [player],
|
||||
"tiles": [{"x": 5, "y": 5, "z": 0, "type": "floor"}],
|
||||
})
|
||||
assert_that(GameState.current_zone_id).is_equal("")
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
|
||||
|
||||
func test_d073_crossfade_duration_in_1_5_to_2_0s_range() -> void:
|
||||
## D-073: Crossfade tween duration must be 1.5-2.0s.
|
||||
## Activates once #529 defines the duration constant.
|
||||
if not "CROSSFADE_DURATION" in AudioManager:
|
||||
push_warning("TestAudioSprint13: CROSSFADE_DURATION not yet defined (#529 pending) — skip duration test")
|
||||
return
|
||||
var duration: float = AudioManager.CROSSFADE_DURATION
|
||||
assert_that(duration >= 1.5 and duration <= 2.0).is_true()
|
||||
|
||||
|
||||
func test_d073_set_zone_same_zone_repeated_is_noop() -> void:
|
||||
## D-073: Crossing back to the current zone should not restart a crossfade.
|
||||
## (No audio pops when zone boundary is ambiguous.) Activates post-#529.
|
||||
if not "ZONE_ASSETS" in AudioManager:
|
||||
push_warning("TestAudioSprint13: set_zone body not yet implemented (#529) — skip no-op test")
|
||||
return
|
||||
AudioManager.set_zone("hub")
|
||||
AudioManager.set_zone("hub")
|
||||
## Expect exactly one or zero ambient players after same-zone calls (no stacked tweens).
|
||||
## Stub: assert no crash and ambient_players size is 0 or 1, not 2.
|
||||
assert_that(AudioManager._ambient_players.size() <= 1).is_true()
|
||||
|
||||
|
||||
func test_d073_rapid_zone_crossing_interruptible() -> void:
|
||||
## D-073: Rapid back-and-forth zone crossing (interruptible crossfade).
|
||||
## When _kill_zone_tweens() fires mid-fade, old player stays at intermediate
|
||||
## volume — new tween starts from current position. No stacked tweens, no crash.
|
||||
if not "ZONE_ASSETS" in AudioManager:
|
||||
push_warning("TestAudioSprint13: ZONE_ASSETS not yet defined — skip rapid crossing test")
|
||||
return
|
||||
# Cross hub → bar → hub rapidly (simulates player walking back and forth)
|
||||
AudioManager.set_zone("hub")
|
||||
AudioManager.set_zone("bar") # Interrupts hub fade-in mid-tween
|
||||
AudioManager.set_zone("hub") # Interrupts bar fade-in mid-tween
|
||||
AudioManager.set_zone("corridor") # Interrupts hub fade-in mid-tween
|
||||
## After rapid crossing: at most 2 ambient players (outgoing fade-out + incoming fade-in).
|
||||
## No stacked tweens — _kill_zone_tweens clears previous tweens each time.
|
||||
assert_that(AudioManager._ambient_players.size() <= 2).is_true()
|
||||
## Zone tweens array should only contain active tweens from the last set_zone call.
|
||||
assert_that(AudioManager._zone_tweens.size() <= 2).is_true()
|
||||
AudioManager.stop_all_loops()
|
||||
|
||||
|
||||
func test_d068_load_prefs_persistence_roundtrip() -> void:
|
||||
## #528: Volume persistence — save/load roundtrip via ConfigFile.
|
||||
## Sets non-default volumes, saves, reloads, and verifies values match.
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -8.5)
|
||||
AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, -3.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, -12.0)
|
||||
## _save_prefs() fires inside set_volume() — prefs file is written.
|
||||
## Reload prefs by calling _load_prefs() directly.
|
||||
AudioManager._load_prefs()
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-8.5, 0.01)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_WORLD_SFX)).is_equal_approx(-3.0, 0.01)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_UI_SOUNDS)).is_equal_approx(-12.0, 0.01)
|
||||
## Reset to defaults for subsequent tests.
|
||||
for bus in AudioManager.BUSES:
|
||||
AudioManager.set_volume(bus, 0.0)
|
||||
|
||||
|
||||
func test_d068_load_prefs_handles_missing_config_gracefully() -> void:
|
||||
## #528: _load_prefs() with a missing/corrupted config file must not crash.
|
||||
## The config path is user://audio_prefs.cfg — if absent, _load_prefs returns early.
|
||||
## This test documents the graceful fallback behavior.
|
||||
AudioManager._load_prefs()
|
||||
## No assertion — absence of crash is the test.
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 2: D-069 Dip Timing and dB Spec Values
|
||||
# These tests supplement test_audio_bus_routing.gd with timing and dB precision.
|
||||
# ==============================================================================
|
||||
|
||||
func test_d069_dialogue_ease_in_is_300ms() -> void:
|
||||
## D-069: Dialogue dip ease-in = 300ms (0.3s).
|
||||
var ease_in: float = AudioManager.DIP_SPECS["dialogue"]["ease_in"]
|
||||
assert_that(ease_in).is_equal_approx(0.3, 0.05)
|
||||
|
||||
|
||||
func test_d069_dialogue_ease_out_is_500ms() -> void:
|
||||
## D-069: Dialogue dip ease-out = 500ms (0.5s).
|
||||
var ease_out: float = AudioManager.DIP_SPECS["dialogue"]["ease_out"]
|
||||
assert_that(ease_out).is_equal_approx(0.5, 0.05)
|
||||
|
||||
|
||||
func test_d069_confrontation_ease_in_is_500ms() -> void:
|
||||
## D-069: Confrontation dip ease-in = 500ms (0.5s).
|
||||
var ease_in: float = AudioManager.DIP_SPECS["confrontation"]["ease_in"]
|
||||
assert_that(ease_in).is_equal_approx(0.5, 0.05)
|
||||
|
||||
|
||||
func test_d069_confrontation_ease_out_is_1000ms() -> void:
|
||||
## D-069: Confrontation dip ease-out = 1000ms (1.0s) — longer exit for immersion.
|
||||
var ease_out: float = AudioManager.DIP_SPECS["confrontation"]["ease_out"]
|
||||
assert_that(ease_out).is_equal_approx(1.0, 0.05)
|
||||
|
||||
|
||||
func test_d069_dialogue_ambient_dip_within_6_to_8_db() -> void:
|
||||
## D-069: Dialogue dip: Ambient -6 to -8dB. Mid-range value (-7) used.
|
||||
var dip: float = AudioManager.DIP_SPECS["dialogue"]["buses"]["Ambient"]
|
||||
assert_that(dip >= -8.0 and dip <= -6.0).is_true()
|
||||
|
||||
|
||||
func test_d069_confrontation_ambient_dip_within_10_to_12_db() -> void:
|
||||
## D-069: Confrontation dip: Ambient -10 to -12dB. Mid-range value (-11) used.
|
||||
var dip: float = AudioManager.DIP_SPECS["confrontation"]["buses"]["Ambient"]
|
||||
assert_that(dip >= -12.0 and dip <= -10.0).is_true()
|
||||
|
||||
|
||||
func test_d069_confrontation_world_sfx_dip_within_4_to_6_db() -> void:
|
||||
## D-069: Confrontation dip: WorldSFX -4 to -6dB (graduated — loud events break through).
|
||||
var dip: float = AudioManager.DIP_SPECS["confrontation"]["buses"]["WorldSFX"]
|
||||
assert_that(dip >= -6.0 and dip <= -4.0).is_true()
|
||||
|
||||
|
||||
func test_d069_listening_focus_world_sfx_boost_within_2_to_3_db() -> void:
|
||||
## D-069 / D-071: ListeningFocus boosts WorldSFX +2 to +3dB (eavesdrop bonus).
|
||||
var boost: float = AudioManager.DIP_SPECS["listening_focus"]["buses"]["WorldSFX"]
|
||||
assert_that(boost >= 2.0 and boost <= 3.0).is_true()
|
||||
|
||||
|
||||
func test_d069_dialogue_spec_only_affects_ambient_bus() -> void:
|
||||
## D-069: Dialogue dip touches ONLY Ambient. WorldSFX, PlayerActions, UISounds, Music
|
||||
## must NOT appear in the spec — world events remain audible during conversation.
|
||||
var buses: Dictionary = AudioManager.DIP_SPECS["dialogue"]["buses"]
|
||||
assert_that(buses.has("Ambient")).is_true()
|
||||
assert_that(buses.has("WorldSFX")).is_false()
|
||||
assert_that(buses.has("PlayerActions")).is_false()
|
||||
assert_that(buses.has("UISounds")).is_false()
|
||||
assert_that(buses.has("Music")).is_false()
|
||||
|
||||
|
||||
func test_d069_confrontation_spec_does_not_affect_player_actions() -> void:
|
||||
## D-069: Confrontation dip leaves PlayerActions at 0 — player sounds are NOT muffled.
|
||||
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
|
||||
assert_that(buses.has("PlayerActions")).is_false()
|
||||
|
||||
|
||||
func test_d069_confrontation_spec_does_not_affect_ui_sounds() -> void:
|
||||
## D-069: Confrontation dip leaves UISounds at 0 — chimes and UI remain audible.
|
||||
var buses: Dictionary = AudioManager.DIP_SPECS["confrontation"]["buses"]
|
||||
assert_that(buses.has("UISounds")).is_false()
|
||||
|
||||
|
||||
func test_d069_listening_focus_spec_does_not_affect_ambient() -> void:
|
||||
## D-071: ListeningFocus boost is ONLY on WorldSFX. Ambient is NOT modified.
|
||||
## D-071: Eavesdropping requires MORE ambient awareness, not less — no ambient dip.
|
||||
var buses: Dictionary = AudioManager.DIP_SPECS["listening_focus"]["buses"]
|
||||
assert_that(buses.has("Ambient")).is_false()
|
||||
|
||||
|
||||
func test_d069_filter_cutoff_default_is_approx_20khz() -> void:
|
||||
## D-069: Default low-pass filter cutoff is ~20kHz — effectively bypassed.
|
||||
## Confrontation dip sweeps it down to 800Hz. Default must be >= 20000Hz.
|
||||
assert_that(AudioManager.FILTER_CUTOFF_DEFAULT >= 20000.0).is_true()
|
||||
|
||||
|
||||
func test_d069_confrontation_filter_hz_is_800hz() -> void:
|
||||
## D-069: Confrontation sweeps low-pass filter to ~800Hz for muffled feel (D-070).
|
||||
var filter_hz: float = AudioManager.DIP_SPECS["confrontation"]["filter_hz"]
|
||||
assert_that(filter_hz).is_equal_approx(800.0, 50.0)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 3: Volume Slider Proportional Dip — D-068 / D-069
|
||||
# ==============================================================================
|
||||
|
||||
func test_d068_set_volume_get_volume_roundtrip() -> void:
|
||||
## D-068: set_volume / get_volume roundtrip preserves the slider value.
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -6.0)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-6.0, 0.01)
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
|
||||
|
||||
|
||||
func test_d068_set_volume_persists_across_all_buses() -> void:
|
||||
## D-068: Each of the 5 buses has an independent volume setting.
|
||||
AudioManager.set_volume(AudioManager.BUS_MUSIC, -10.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -5.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_WORLD_SFX, -3.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_PLAYER_ACTIONS, -2.0)
|
||||
AudioManager.set_volume(AudioManager.BUS_UI_SOUNDS, -1.0)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_MUSIC)).is_equal_approx(-10.0, 0.01)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-5.0, 0.01)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_WORLD_SFX)).is_equal_approx(-3.0, 0.01)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_PLAYER_ACTIONS)).is_equal_approx(-2.0, 0.01)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_UI_SOUNDS)).is_equal_approx(-1.0, 0.01)
|
||||
|
||||
|
||||
func test_d069_get_volume_returns_slider_base_not_effective_volume() -> void:
|
||||
## D-069: get_volume() always returns the player slider setting (base).
|
||||
## The effective AudioServer volume during a dip = base + offset_db.
|
||||
## Callers storing the slider value must always read get_volume(), not AudioServer directly.
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
|
||||
AudioManager.apply_dip("dialogue")
|
||||
## Even during dip, get_volume returns the base (not base + dip offset).
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(0.0, 0.01)
|
||||
|
||||
|
||||
func test_d069_set_volume_during_active_dip_updates_base() -> void:
|
||||
## D-069: Changing slider mid-dip must update the base so the proportional
|
||||
## calculation uses the new slider value (not the pre-dip value).
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, 0.0)
|
||||
AudioManager.apply_dip("dialogue")
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -3.0)
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-3.0, 0.01)
|
||||
|
||||
|
||||
func test_d069_clear_dip_does_not_change_stored_slider_value() -> void:
|
||||
## D-069: clear_dip() restores AudioServer volumes to base, but get_volume()
|
||||
## must still reflect the player slider setting (not the dipped value).
|
||||
AudioManager.set_volume(AudioManager.BUS_AMBIENT, -5.0)
|
||||
AudioManager.apply_dip("dialogue")
|
||||
AudioManager.clear_dip()
|
||||
assert_that(AudioManager.get_volume(AudioManager.BUS_AMBIENT)).is_equal_approx(-5.0, 0.01)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 4: Dip Call Site Wiring — D-069 / D-070 / D-071 / #530
|
||||
#
|
||||
# These tests verify GameState has the snapshot fields needed for #530 wiring.
|
||||
# The assertions on AudioManager dip activation are stubbed pending implementation.
|
||||
# ==============================================================================
|
||||
|
||||
func test_d069_game_state_current_dialogue_field_exists() -> void:
|
||||
## #530 precondition: GameState.current_dialogue is the trigger for dialogue dip.
|
||||
## Field must exist so #530 wiring can check it.
|
||||
assert_that("current_dialogue" in GameState).is_true()
|
||||
|
||||
|
||||
func test_d069_snapshot_with_dialogue_sets_current_dialogue() -> void:
|
||||
## #530 precondition: apply_snapshot() with current_dialogue populates GameState correctly.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_dialogue": {
|
||||
"npc_name": "Kael", "npc_entity_id": 2,
|
||||
"speech": "Haven't seen you around.", "options": [],
|
||||
},
|
||||
})
|
||||
assert_that(GameState.current_dialogue != null).is_true()
|
||||
assert_that(GameState.current_dialogue is Dictionary).is_true()
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
|
||||
|
||||
func test_d069_snapshot_without_dialogue_clears_current_dialogue() -> void:
|
||||
## #530 precondition: Snapshot without current_dialogue → current_dialogue is null.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []},
|
||||
})
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
assert_that(GameState.current_dialogue == null).is_true()
|
||||
|
||||
|
||||
func test_d069_dialogue_dip_wired_to_game_state_dialogue_activation() -> void:
|
||||
## D-069 / #530: When current_dialogue becomes active, apply_dip("dialogue") fires.
|
||||
## TODO(#530): Uncomment the assertion once call site is wired in sim_bridge/game_state.
|
||||
AudioManager.clear_dip()
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []},
|
||||
})
|
||||
## Precondition: dialogue IS active in GameState.
|
||||
assert_that(GameState.current_dialogue != null).is_true()
|
||||
## ASSERTION (activate once #530 is implemented):
|
||||
## assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
AudioManager.clear_dip()
|
||||
|
||||
|
||||
func test_d069_dialogue_dip_cleared_when_dialogue_ends() -> void:
|
||||
## D-069 / #530: When current_dialogue returns to null, clear_dip() fires.
|
||||
## TODO(#530): Uncomment the assertion once call site is wired.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"current_dialogue": {"npc_name": "Kael", "npc_entity_id": 2, "speech": "...", "options": []},
|
||||
})
|
||||
GameState.apply_snapshot({"tick": 2}) # dialogue ends
|
||||
## ASSERTION (activate once #530 is implemented):
|
||||
## assert_that(AudioManager.get_active_dip()).is_equal("")
|
||||
assert_that(GameState.current_dialogue == null).is_true()
|
||||
|
||||
|
||||
func test_d071_listening_focus_gate_is_30_ticks() -> void:
|
||||
## D-069 / D-071: ListeningFocus boost activates after 30+ stationary ticks.
|
||||
## The tick gate is the CALLER's responsibility per AudioManager comment.
|
||||
## This test documents the threshold so it doesn't silently drift.
|
||||
## Wiring via sim_bridge.gd tracking stationary_ticks lands in #530.
|
||||
const LISTENING_FOCUS_TICK_GATE := 30
|
||||
## Stub: verify the spec value is documented.
|
||||
assert_that(LISTENING_FOCUS_TICK_GATE).is_equal(30)
|
||||
|
||||
|
||||
func test_d070_no_ui_indicator_means_no_signal_named_confrontation_ui() -> void:
|
||||
## D-070: Confrontation muffling is felt, not announced. No UI indicator.
|
||||
## Verify AudioManager does not expose a confrontation_ui_shown signal.
|
||||
var signals: Array = AudioManager.get_signal_list().map(
|
||||
func(s: Dictionary) -> String: return s.name
|
||||
)
|
||||
assert_that(signals.has("confrontation_ui_shown")).is_false()
|
||||
assert_that(signals.has("listening_focus_shown")).is_false()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 5: Recognition Chime Onset — D-067 / #531
|
||||
#
|
||||
# Chime fires at ONSET of cognitive delay (when entity FIRST appears in
|
||||
# pending_recognitions), NOT at completion (when it leaves).
|
||||
# ==============================================================================
|
||||
|
||||
func test_d067_chime_recognition_constant_defined() -> void:
|
||||
## D-067 / D-038: AudioManager must expose CHIME_RECOGNITION asset key constant.
|
||||
assert_that("CHIME_RECOGNITION" in AudioManager).is_true()
|
||||
|
||||
|
||||
func test_d067_chime_recognition_matches_d038_asset_key() -> void:
|
||||
## D-067 / D-038: sfx_monologue_chime = "neural lattice firing" feel.
|
||||
## Key must match filename stem in res://audio/.
|
||||
assert_that(AudioManager.CHIME_RECOGNITION).is_equal("sfx_monologue_chime")
|
||||
|
||||
|
||||
func test_d067_play_routes_to_ui_sounds_bus_by_default() -> void:
|
||||
## D-067: play(CHIME_RECOGNITION) routes to BUS_UI_SOUNDS by default.
|
||||
## Chime is a cognitive/UI signal, not a world sound — must NOT go on WorldSFX.
|
||||
## Verify play() default bus is UISounds (the chime caller uses the default).
|
||||
## Edge: BUS_WORLD_SFX and BUS_UI_SOUNDS must be distinct.
|
||||
assert_that(AudioManager.BUS_WORLD_SFX).is_not_equal(AudioManager.BUS_UI_SOUNDS)
|
||||
|
||||
|
||||
func test_d067_play_chime_recognition_noop_when_asset_absent() -> void:
|
||||
## D-067 / D-038: play(CHIME_RECOGNITION) is a silent no-op if asset file is absent.
|
||||
## Client must not crash when audio branch has not yet provided the .ogg file.
|
||||
AudioManager.play(AudioManager.CHIME_RECOGNITION)
|
||||
## No assertion needed — absence of crash is the test.
|
||||
|
||||
|
||||
func test_d067_onset_is_when_remaining_equals_total_delay_ticks() -> void:
|
||||
## D-067: "Onset" of cognitive delay = first frame an entity appears in
|
||||
## pending_recognitions, at remaining_ticks == total_delay_ticks.
|
||||
## This is the moment the chime must fire.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"pending_recognitions": [
|
||||
{"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0,
|
||||
"remaining_ticks": 6, "total_delay_ticks": 6},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.pending_recognitions.size()).is_equal(1)
|
||||
var rec: Dictionary = GameState.pending_recognitions[0]
|
||||
## Onset condition: remaining == total (delay just started)
|
||||
assert_that(rec.remaining_ticks).is_equal(rec.total_delay_ticks)
|
||||
GameState.apply_snapshot({"tick": 2})
|
||||
|
||||
|
||||
func test_d067_completion_is_when_entity_absent_from_pending() -> void:
|
||||
## D-067: Recognition COMPLETES (blob transitions) when entity leaves pending_recognitions.
|
||||
## The chime must NOT fire at this point — it fired at onset.
|
||||
GameState.apply_snapshot({
|
||||
"tick": 1,
|
||||
"pending_recognitions": [
|
||||
{"entity_id": 99, "x": 10.0, "y": 10.0, "z": 0,
|
||||
"remaining_ticks": 1, "total_delay_ticks": 6},
|
||||
],
|
||||
})
|
||||
assert_that(GameState.pending_recognitions.size()).is_equal(1)
|
||||
## Completion: entity removed from array
|
||||
GameState.apply_snapshot({"tick": 2, "pending_recognitions": []})
|
||||
assert_that(GameState.pending_recognitions.size()).is_equal(0)
|
||||
## Chime state: AudioManager must not have a dip triggered by recognition.
|
||||
## (Chime is a play() call, not a dip — this verifies no side effects on dip state.)
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("")
|
||||
|
||||
|
||||
func test_d067_chime_fires_at_fog_entity_spawn_not_removal() -> void:
|
||||
## D-067 / #531: The chime call site in fog_entities.gd / entity_renderer.gd
|
||||
## must be inside the "new entity" branch (not entity.has(eid)), NOT the cleanup loop.
|
||||
## This test verifies FogEntities correctly identifies the onset condition.
|
||||
## An entity with remaining_ticks == total_delay_ticks is a NEW entity entering delay.
|
||||
var onset_tick := {"entity_id": 5, "x": 5.0, "y": 5.0, "z": 0,
|
||||
"remaining_ticks": 6, "total_delay_ticks": 6}
|
||||
var mid_tick := {"entity_id": 5, "x": 5.0, "y": 5.0, "z": 0,
|
||||
"remaining_ticks": 3, "total_delay_ticks": 6}
|
||||
## Frame 1: entity APPEARS (onset — chime should fire here)
|
||||
GameState.apply_snapshot({"tick": 1, "pending_recognitions": [onset_tick]})
|
||||
assert_that(GameState.pending_recognitions[0].remaining_ticks
|
||||
== GameState.pending_recognitions[0].total_delay_ticks).is_true()
|
||||
## Frame 2: entity mid-progress (chime must NOT re-fire)
|
||||
GameState.apply_snapshot({"tick": 2, "pending_recognitions": [mid_tick]})
|
||||
assert_that(GameState.pending_recognitions[0].remaining_ticks).is_equal(3)
|
||||
## Frame 3: entity completes (chime must NOT fire)
|
||||
GameState.apply_snapshot({"tick": 3, "pending_recognitions": []})
|
||||
assert_that(GameState.pending_recognitions.size()).is_equal(0)
|
||||
|
||||
|
||||
func test_d067_chime_duration_spec_is_300_to_400ms() -> void:
|
||||
## D-067: Chime duration is 300-400ms per spec. The asset (.ogg) carries this duration.
|
||||
## This test documents the spec range so asset authoring can be validated.
|
||||
## When sfx_monologue_chime.ogg is present, its AudioStream.get_length() should
|
||||
## return a value in this range.
|
||||
const CHIME_MIN_DURATION := 0.3
|
||||
const CHIME_MAX_DURATION := 0.4
|
||||
if not AudioManager.has_asset(AudioManager.CHIME_RECOGNITION):
|
||||
push_warning("TestAudioSprint13: sfx_monologue_chime asset absent — skip duration test")
|
||||
return
|
||||
var stream: AudioStream = AudioManager._registry.get(AudioManager.CHIME_RECOGNITION)
|
||||
if stream == null:
|
||||
push_warning("TestAudioSprint13: could not retrieve chime stream from registry")
|
||||
return
|
||||
assert_that(stream.get_length() >= CHIME_MIN_DURATION
|
||||
and stream.get_length() <= CHIME_MAX_DURATION).is_true()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 6: NPC Murmur Routing — D-072 / #533
|
||||
# Single universal asset, WorldSFX bus, no zone-specific variants.
|
||||
# ==============================================================================
|
||||
|
||||
func test_d072_world_sfx_bus_is_correct_for_murmur() -> void:
|
||||
## D-072 / #533: NPC murmur routes to WorldSFX bus per D-068 architecture.
|
||||
## BUS_WORLD_SFX must be "WorldSFX" — zone ambient conspicuousness is determined
|
||||
## by that bus's noise floor relative to the murmur volume.
|
||||
assert_that(AudioManager.BUS_WORLD_SFX).is_equal("WorldSFX")
|
||||
|
||||
|
||||
func test_d072_play_at_method_accepts_bus_parameter() -> void:
|
||||
## D-072 / #533: play_at() must accept an optional bus string parameter.
|
||||
## Proximity murmur uses play_at(asset_key, world_pos, BUS_WORLD_SFX).
|
||||
assert_that(AudioManager.has_method("play_at")).is_true()
|
||||
|
||||
|
||||
func test_d072_play_at_noop_when_murmur_asset_absent() -> void:
|
||||
## D-072 / D-038: sfx_npc_murmur.ogg arrives from audio branch (#532).
|
||||
## Until then, play_at("sfx_npc_murmur", ...) must be a silent no-op.
|
||||
AudioManager.play_at("sfx_npc_murmur", Vector2(100.0, 100.0), AudioManager.BUS_WORLD_SFX)
|
||||
## No assertion — absence of crash is the test.
|
||||
|
||||
|
||||
func test_d072_play_noop_when_murmur_asset_absent() -> void:
|
||||
## D-072 / D-038: play("sfx_npc_murmur", BUS_WORLD_SFX) also no-ops gracefully.
|
||||
AudioManager.play("sfx_npc_murmur", AudioManager.BUS_WORLD_SFX)
|
||||
## No assertion — absence of crash is the test.
|
||||
|
||||
|
||||
func test_d072_no_zone_specific_murmur_variants() -> void:
|
||||
## D-072: SINGLE universal murmur asset — no zone-specific variants.
|
||||
## "One murmur asset + zone-dependent conspicuousness creates the signal/noise
|
||||
## dynamic naturally." Zone-specific variants MUST NOT exist in the registry.
|
||||
assert_that(AudioManager.has_asset("sfx_npc_murmur_bar")).is_false()
|
||||
assert_that(AudioManager.has_asset("sfx_npc_murmur_corridor")).is_false()
|
||||
assert_that(AudioManager.has_asset("sfx_npc_murmur_hub")).is_false()
|
||||
assert_that(AudioManager.has_asset("sfx_npc_murmur_workplace")).is_false()
|
||||
|
||||
|
||||
func test_d072_murmur_does_not_use_ambient_bus() -> void:
|
||||
## D-072: Bar ambient murmur is baked into amb_bar_layer (continuous background).
|
||||
## The NPC proximity murmur is a SEPARATE event-driven asset on WorldSFX, not Ambient.
|
||||
## Verify bus constant distinction.
|
||||
assert_that(AudioManager.BUS_AMBIENT).is_not_equal(AudioManager.BUS_WORLD_SFX)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Layer 7: AudioManager No-Op Fallback Sanity — D-068 / D-038
|
||||
# ==============================================================================
|
||||
|
||||
func test_d068_registry_size_is_non_negative() -> void:
|
||||
## D-068: Registry is empty when res://audio/ is absent, non-negative always.
|
||||
assert_that(AudioManager.get_registry_size() >= 0).is_true()
|
||||
|
||||
|
||||
func test_d068_play_loop_returns_null_for_missing_asset() -> void:
|
||||
## D-068 / D-038: play_loop() with unregistered asset key returns null (no crash).
|
||||
var result: Variant = AudioManager.play_loop("nonexistent_ambient_xyzabc")
|
||||
assert_that(result == null).is_true()
|
||||
|
||||
|
||||
func test_d068_stop_loop_noop_for_unknown_key() -> void:
|
||||
## D-068: stop_loop() on a key never started — no crash, no error.
|
||||
AudioManager.stop_loop("nonexistent_key_xyzabc")
|
||||
|
||||
|
||||
func test_d068_stop_all_loops_when_none_playing() -> void:
|
||||
## D-068: stop_all_loops() with no active ambient players — no crash.
|
||||
AudioManager.stop_all_loops()
|
||||
|
||||
|
||||
func test_d068_has_asset_returns_false_for_unknown_key() -> void:
|
||||
## D-068 / D-038: has_asset() guards all play methods. Verify false for unknown key.
|
||||
assert_that(AudioManager.has_asset("totally_unknown_asset_key_abc123")).is_false()
|
||||
|
||||
|
||||
func test_d068_play_noop_does_not_change_dip_state() -> void:
|
||||
## D-068: play() on a missing asset must not modify the dip state machine.
|
||||
## Verifies no-op fallback has zero side effects.
|
||||
AudioManager.apply_dip("dialogue")
|
||||
AudioManager.play("nonexistent_asset_xyzabc")
|
||||
assert_that(AudioManager.get_active_dip()).is_equal("dialogue")
|
||||
@@ -0,0 +1 @@
|
||||
uid://d30by4d62g7ts
|
||||
@@ -0,0 +1,361 @@
|
||||
## #507: WRONG button ring buffer tests.
|
||||
## Tests the 60-tick rolling history upgrade to bug_report_dialog.gd.
|
||||
## Covers ring buffer capacity, circular overwrite, JSONL format compliance,
|
||||
## replay compatibility, seed file, and regression against MVP behavior.
|
||||
##
|
||||
## MVP baseline (must still pass): snapshot.json, render.txt, description.txt.
|
||||
## New outputs: inputs.jsonl, snapshots.jsonl, seed.txt.
|
||||
##
|
||||
## Spec refs: D-030 (testability), D-020 (ObserverSnapshot boundary)
|
||||
## Sprint 11 client.md §Notes #507
|
||||
class_name TestBugReportRingBuffer
|
||||
extends GdUnitTestSuite
|
||||
|
||||
|
||||
var BugReportDialogScript = load("res://ui/bug_report_dialog.gd")
|
||||
|
||||
# Expected ring buffer capacity per spec.
|
||||
const EXPECTED_CAPACITY := 60
|
||||
|
||||
|
||||
func after_each() -> void:
|
||||
# Reset GameState fields mutated by tests to prevent cross-test leakage.
|
||||
GameState.current_tick = 0
|
||||
GameState.room_id = null
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.player_facing = "South"
|
||||
GameState.player_position = Vector2.ZERO
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.game_time = {}
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
GameState.rng_seed = null
|
||||
|
||||
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
func _make_dialog() -> Control:
|
||||
var dialog = Control.new()
|
||||
dialog.set_script(BugReportDialogScript)
|
||||
auto_free(dialog)
|
||||
add_child(dialog)
|
||||
return dialog
|
||||
|
||||
|
||||
## Create a minimal valid PlayerInput dict matching the replay.rs wire format.
|
||||
## replay.rs expects: {"tick": N, "action": "MoveNorth"} or action as dict.
|
||||
func _make_input(tick: int, action: String = "MoveNorth") -> Dictionary:
|
||||
return {"tick": tick, "action": action}
|
||||
|
||||
|
||||
## Make a minimal snapshot JSON string for snapshot buffer testing.
|
||||
func _make_snapshot_json(tick: int) -> String:
|
||||
return JSON.stringify({
|
||||
"tick": tick,
|
||||
"version": Protocol.PROTOCOL_VERSION,
|
||||
"entities": [],
|
||||
})
|
||||
|
||||
|
||||
## Check a string is valid JSON array — mirrors replay.rs line expectation.
|
||||
func _is_valid_json_array(line: String) -> bool:
|
||||
if line.is_empty():
|
||||
return false
|
||||
var result = JSON.parse_string(line)
|
||||
return result != null and result is Array
|
||||
|
||||
|
||||
# -- Ring buffer capacity ------------------------------------------------------
|
||||
|
||||
func test_buffer_capacity_is_60() -> void:
|
||||
# Spec: "Maintain a 60-entry circular buffer"
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_get_buffer_capacity"):
|
||||
push_warning("TestBugReportRingBuffer: _get_buffer_capacity not found — awaiting #507")
|
||||
return
|
||||
assert_that(dialog._get_buffer_capacity()).override_failure_message(
|
||||
"Ring buffer capacity must be 60 per spec"
|
||||
).is_equal(EXPECTED_CAPACITY)
|
||||
|
||||
|
||||
func test_snapshot_buffer_capacity_is_60() -> void:
|
||||
# Parallel snapshot buffer must match input buffer capacity.
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_get_snapshot_buffer_capacity"):
|
||||
push_warning("TestBugReportRingBuffer: _get_snapshot_buffer_capacity not found — awaiting #507")
|
||||
return
|
||||
assert_that(dialog._get_snapshot_buffer_capacity()).is_equal(EXPECTED_CAPACITY)
|
||||
|
||||
|
||||
# -- Pre-allocation -----------------------------------------------------------
|
||||
|
||||
func test_input_buffer_preallocated_at_ready() -> void:
|
||||
# Spec: "pre-allocate the 60-slot arrays at startup. Do not allocate on every tick."
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_get_input_buffer"):
|
||||
push_warning("TestBugReportRingBuffer: _get_input_buffer not found — awaiting #507")
|
||||
return
|
||||
var buf = dialog._get_input_buffer()
|
||||
assert_that(buf is Array).override_failure_message(
|
||||
"Input buffer must be an Array"
|
||||
).is_true()
|
||||
assert_that(buf.size()).override_failure_message(
|
||||
"Input buffer must be pre-allocated at capacity (%d slots)" % EXPECTED_CAPACITY
|
||||
).is_equal(EXPECTED_CAPACITY)
|
||||
|
||||
|
||||
# -- Push and fill behavior ---------------------------------------------------
|
||||
|
||||
func test_push_fills_input_buffer() -> void:
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_push_tick_inputs"):
|
||||
push_warning("TestBugReportRingBuffer: _push_tick_inputs not found — awaiting #507")
|
||||
return
|
||||
if not dialog.has_method("_get_filled_input_count"):
|
||||
push_warning("TestBugReportRingBuffer: _get_filled_input_count not found — awaiting #507")
|
||||
return
|
||||
for i in range(10):
|
||||
dialog._push_tick_inputs(i, [_make_input(i)])
|
||||
assert_that(dialog._get_filled_input_count()).override_failure_message(
|
||||
"10 pushed ticks should produce filled count of 10"
|
||||
).is_equal(10)
|
||||
|
||||
|
||||
func test_push_fills_snapshot_buffer() -> void:
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_push_tick_snapshot"):
|
||||
push_warning("TestBugReportRingBuffer: _push_tick_snapshot not found — awaiting #507")
|
||||
return
|
||||
if not dialog.has_method("_get_filled_snapshot_count"):
|
||||
push_warning("TestBugReportRingBuffer: _get_filled_snapshot_count not found — awaiting #507")
|
||||
return
|
||||
for i in range(10):
|
||||
dialog._push_tick_snapshot(_make_snapshot_json(i))
|
||||
assert_that(dialog._get_filled_snapshot_count()).is_equal(10)
|
||||
|
||||
|
||||
# -- Circular overwrite -------------------------------------------------------
|
||||
|
||||
func test_circular_overwrite_evicts_oldest() -> void:
|
||||
# After 61 pushes, filled count must be 60 (oldest evicted).
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_get_filled_input_count"):
|
||||
push_warning("TestBugReportRingBuffer: ring buffer API not found — awaiting #507")
|
||||
return
|
||||
for i in range(EXPECTED_CAPACITY + 1):
|
||||
dialog._push_tick_inputs(i, [_make_input(i)])
|
||||
assert_that(dialog._get_filled_input_count()).override_failure_message(
|
||||
"After 61 pushes, buffer must hold exactly 60 entries (oldest evicted)"
|
||||
).is_equal(EXPECTED_CAPACITY)
|
||||
|
||||
|
||||
func test_circular_overwrite_keeps_newest_inputs() -> void:
|
||||
# After 61 pushes, the flushed JSONL must contain tick 1..60 (not tick 0).
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"):
|
||||
push_warning("TestBugReportRingBuffer: ring buffer flush API not found — awaiting #507")
|
||||
return
|
||||
# Push 61 ticks. Tick 0 should be evicted; ticks 1-60 should be present.
|
||||
for i in range(EXPECTED_CAPACITY + 1):
|
||||
dialog._push_tick_inputs(i, [_make_input(i, "MoveNorth")])
|
||||
var jsonl: String = dialog._format_inputs_jsonl()
|
||||
# Tick 0 action would be at position 0, but after 61 pushes, position 0 was overwritten.
|
||||
# We check that the output has exactly 60 lines.
|
||||
var lines: PackedStringArray = jsonl.split("\n", false)
|
||||
# Filter out blank lines (PackedStringArray has no filter — convert to Array first)
|
||||
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
|
||||
assert_that(non_blank.size()).override_failure_message(
|
||||
"After 61 pushes, JSONL output must have exactly 60 non-blank lines"
|
||||
).is_equal(EXPECTED_CAPACITY)
|
||||
|
||||
|
||||
func test_exact_60_pushes_no_eviction() -> void:
|
||||
# After exactly 60 pushes, all 60 are present.
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_get_filled_input_count"):
|
||||
push_warning("TestBugReportRingBuffer: ring buffer API not found — awaiting #507")
|
||||
return
|
||||
for i in range(EXPECTED_CAPACITY):
|
||||
dialog._push_tick_inputs(i, [_make_input(i)])
|
||||
assert_that(dialog._get_filled_input_count()).override_failure_message(
|
||||
"After exactly 60 pushes, all 60 entries should be present"
|
||||
).is_equal(EXPECTED_CAPACITY)
|
||||
|
||||
|
||||
# -- JSONL format compliance (replay.rs contract) ----------------------------
|
||||
|
||||
func test_inputs_jsonl_each_line_is_json_array() -> void:
|
||||
# replay.rs: "Each line is a JSON array of PlayerInput for one tick."
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"):
|
||||
push_warning("TestBugReportRingBuffer: JSONL API not found — awaiting #507")
|
||||
return
|
||||
for i in range(3):
|
||||
dialog._push_tick_inputs(i, [_make_input(i)])
|
||||
var jsonl: String = dialog._format_inputs_jsonl()
|
||||
var lines: PackedStringArray = jsonl.split("\n", false)
|
||||
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
|
||||
for line in non_blank:
|
||||
assert_that(_is_valid_json_array(line)).override_failure_message(
|
||||
"Each JSONL line must be a valid JSON array, got: %s" % line
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_inputs_jsonl_idle_tick_is_empty_array() -> void:
|
||||
# replay.rs: "Empty array = idle tick (no input sent)."
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"):
|
||||
push_warning("TestBugReportRingBuffer: JSONL API not found — awaiting #507")
|
||||
return
|
||||
dialog._push_tick_inputs(0, []) # Idle tick: no inputs
|
||||
var jsonl: String = dialog._format_inputs_jsonl()
|
||||
var lines: PackedStringArray = jsonl.split("\n", false)
|
||||
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
|
||||
assert_that(non_blank.size()).override_failure_message(
|
||||
"One idle tick should produce exactly 1 JSONL line"
|
||||
).is_equal(1)
|
||||
assert_that(non_blank[0].strip_edges()).override_failure_message(
|
||||
"Idle tick line must be an empty JSON array '[]'"
|
||||
).is_equal("[]")
|
||||
|
||||
|
||||
func test_inputs_jsonl_has_tick_field() -> void:
|
||||
# replay.rs PlayerInput expects {"tick": N, "action": "..."}
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"):
|
||||
push_warning("TestBugReportRingBuffer: JSONL API not found — awaiting #507")
|
||||
return
|
||||
dialog._push_tick_inputs(42, [_make_input(42, "MoveEast")])
|
||||
var jsonl: String = dialog._format_inputs_jsonl()
|
||||
# Parse the first line and verify structure
|
||||
var lines: PackedStringArray = jsonl.split("\n", false)
|
||||
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
|
||||
assert_that(non_blank.size()).is_greater(0)
|
||||
var arr = JSON.parse_string(non_blank[0])
|
||||
assert_that(arr is Array).is_true()
|
||||
assert_that(arr.size() > 0).is_true()
|
||||
var first_input: Dictionary = arr[0]
|
||||
assert_that(first_input.has("tick")).override_failure_message(
|
||||
"Each PlayerInput in JSONL must have 'tick' field"
|
||||
).is_true()
|
||||
assert_that(first_input.has("action")).override_failure_message(
|
||||
"Each PlayerInput in JSONL must have 'action' field"
|
||||
).is_true()
|
||||
|
||||
|
||||
func test_inputs_jsonl_multiple_actions_per_tick() -> void:
|
||||
# replay.rs supports multiple PlayerInput per tick (one array entry per action).
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_push_tick_inputs") or not dialog.has_method("_format_inputs_jsonl"):
|
||||
push_warning("TestBugReportRingBuffer: JSONL API not found — awaiting #507")
|
||||
return
|
||||
dialog._push_tick_inputs(0, [
|
||||
_make_input(0, "MoveNorth"),
|
||||
_make_input(0, "Pause"),
|
||||
])
|
||||
var jsonl: String = dialog._format_inputs_jsonl()
|
||||
var lines: PackedStringArray = jsonl.split("\n", false)
|
||||
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
|
||||
assert_that(non_blank.size()).is_equal(1)
|
||||
var arr = JSON.parse_string(non_blank[0])
|
||||
assert_that(arr is Array).is_true()
|
||||
assert_that(arr.size()).override_failure_message(
|
||||
"Tick with 2 actions must have 2 entries in the JSONL array"
|
||||
).is_equal(2)
|
||||
|
||||
|
||||
func test_snapshots_jsonl_each_line_is_valid_json() -> void:
|
||||
# Each snapshot line should be valid JSON (parsed snapshot).
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_push_tick_snapshot") or not dialog.has_method("_format_snapshots_jsonl"):
|
||||
push_warning("TestBugReportRingBuffer: snapshots JSONL API not found — awaiting #507")
|
||||
return
|
||||
for i in range(3):
|
||||
dialog._push_tick_snapshot(_make_snapshot_json(i))
|
||||
var jsonl: String = dialog._format_snapshots_jsonl()
|
||||
var lines: PackedStringArray = jsonl.split("\n", false)
|
||||
var non_blank: Array = Array(lines).filter(func(l): return not l.strip_edges().is_empty())
|
||||
assert_that(non_blank.size()).is_equal(3)
|
||||
for line in non_blank:
|
||||
var parsed = JSON.parse_string(line)
|
||||
assert_that(parsed != null).override_failure_message(
|
||||
"Each snapshot line must be valid JSON, got: %s" % line
|
||||
).is_true()
|
||||
assert_that(parsed is Dictionary).override_failure_message(
|
||||
"Each snapshot line must parse to a Dictionary"
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Seed file ----------------------------------------------------------------
|
||||
|
||||
func test_seed_written_on_capture() -> void:
|
||||
# Spec: "Include the server's current RNG seed... Output as seed.txt"
|
||||
# We can't easily verify file output in headless mode, but we verify
|
||||
# the dialog has the method to produce the seed value.
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_get_current_seed"):
|
||||
push_warning("TestBugReportRingBuffer: _get_current_seed not found — awaiting #507")
|
||||
return
|
||||
# Should return some Variant (int or String) without crashing
|
||||
var seed_val = dialog._get_current_seed()
|
||||
assert_that(seed_val != null).override_failure_message(
|
||||
"_get_current_seed() must not return null (use 0 or 'unavailable' if seed unavailable)"
|
||||
).is_true()
|
||||
|
||||
|
||||
# -- Room metadata regression -------------------------------------------------
|
||||
|
||||
func test_description_txt_still_contains_room_id() -> void:
|
||||
# Regression: MVP description.txt must still include room metadata.
|
||||
GameState.current_tick = 5
|
||||
GameState.room_id = "warehouse_01"
|
||||
GameState.player_stance = "Walk"
|
||||
GameState.player_facing = "North"
|
||||
GameState.player_position = Vector2(10.0, 10.0)
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.game_time = {}
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
var dialog = _make_dialog()
|
||||
# Call the render text method to verify room shows up in render output
|
||||
# (description.txt itself writes via FileAccess, difficult in headless — test the render path)
|
||||
if dialog.has_method("_render_snapshot_text"):
|
||||
# The text render is for render.txt, but description.txt room comes from GameState.room_id
|
||||
# We indirectly verify by checking that the game state has the right fields.
|
||||
assert_that(GameState.room_id).is_equal("warehouse_01")
|
||||
GameState.room_id = null
|
||||
|
||||
|
||||
# -- MVP regression -----------------------------------------------------------
|
||||
|
||||
func test_render_snapshot_text_still_works() -> void:
|
||||
# Regression: MVP _render_snapshot_text() must still work after upgrade.
|
||||
GameState.current_tick = 10
|
||||
GameState.player_position = Vector2(5.0, 5.0)
|
||||
GameState.player_facing = "East"
|
||||
GameState.player_stance = "Careful"
|
||||
GameState.visible_entities = []
|
||||
GameState.visible_tiles = []
|
||||
GameState.game_time = {}
|
||||
GameState.current_monologue = null
|
||||
GameState.current_dialogue = null
|
||||
var dialog = _make_dialog()
|
||||
if not dialog.has_method("_render_snapshot_text"):
|
||||
push_warning("TestBugReportRingBuffer: _render_snapshot_text missing — regression risk")
|
||||
return
|
||||
var text: String = dialog._render_snapshot_text()
|
||||
assert_that(text.length() > 0).is_true()
|
||||
assert_that(text.contains("t10")).is_true()
|
||||
|
||||
|
||||
func test_dialog_is_active_api_unchanged() -> void:
|
||||
# Regression: public API from MVP must be unchanged.
|
||||
var dialog = _make_dialog()
|
||||
assert_that(dialog.has_method("start_capture")).override_failure_message(
|
||||
"start_capture() must still be present (MVP regression)"
|
||||
).is_true()
|
||||
assert_that(dialog.has_method("is_active")).override_failure_message(
|
||||
"is_active() must still be present (MVP regression)"
|
||||
).is_true()
|
||||
@@ -0,0 +1 @@
|
||||
uid://b1tata3wjyb46
|
||||
@@ -97,6 +97,20 @@ func test_camera_smoothing_off_after_ready() -> void:
|
||||
assert_that(camera.position_smoothing_enabled).is_false()
|
||||
|
||||
|
||||
func test_camera_smoothing_stays_off_with_manual_lerp() -> void:
|
||||
# #117: Manual lerp approach — Godot's built-in smoothing must stay OFF always.
|
||||
# CAMERA_SMOOTHING_SPEED is used as the lerp weight, not Godot's position_smoothing_speed.
|
||||
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_false()
|
||||
|
||||
|
||||
# --- Camera behavior across frames ---
|
||||
|
||||
func test_camera_tracks_player_after_process() -> void:
|
||||
@@ -113,33 +127,22 @@ func test_camera_tracks_player_after_process() -> void:
|
||||
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:
|
||||
func test_camera_lerps_toward_player_movement() -> void:
|
||||
# #117: With manual lerp, camera moves TOWARD player position (not snapping).
|
||||
# After one 16ms frame the camera should be partway between old and new position.
|
||||
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
|
||||
var initial_pos := camera.global_position # anchored at (320, 320)
|
||||
|
||||
# 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)
|
||||
var new_target := GameState.player_position * Constants.TILE_SIZE # (320, 288)
|
||||
# Camera should have moved north (lower y) but NOT reached the target yet
|
||||
assert_that(camera.global_position.y).is_less(initial_pos.y)
|
||||
assert_that(camera.global_position.y).is_greater(new_target.y)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://dhgcekc2ywr26
|
||||
@@ -152,8 +152,8 @@ func test_entity_snap_on_first_appear() -> void:
|
||||
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
|
||||
floorf(8.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X,
|
||||
floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
|
||||
)
|
||||
assert_that(node.position).override_failure_message(
|
||||
"Entity should snap to position on first appear (no lerp)"
|
||||
@@ -179,8 +179,8 @@ func test_entity_lerp_moves_toward_target() -> void:
|
||||
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
|
||||
floorf(6.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X,
|
||||
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
|
||||
)
|
||||
# Position should have moved toward target (x increased)
|
||||
assert_that(after_pos.x > start_pos.x).override_failure_message(
|
||||
@@ -206,8 +206,8 @@ func test_entity_lerp_converges_within_300ms() -> void:
|
||||
"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
|
||||
floorf(7.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X,
|
||||
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
|
||||
)
|
||||
# Simulate 0.3s at 60fps (18 frames × 0.016s ≈ 0.288s)
|
||||
for i in 20:
|
||||
@@ -298,8 +298,8 @@ func test_lerp_weight_increases_with_delta() -> void:
|
||||
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
|
||||
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X,
|
||||
floorf(5.0) * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y
|
||||
)
|
||||
# Large delta step
|
||||
var large_start: float = small_node.position.x
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://qv808kduvlbk
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user